diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 0bf3e208..3ae5ad12 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -17,8 +17,8 @@
| **P0** | 44 | 0 | 0 | 0 | 0 | 0 | 44 |
| **P1** | 88 | 0 | 0 | 0 | 0 | 1 | 89 |
| **P2** | 130 | 0 | 0 | 0 | 0 | 1 | 131 |
-| **P3 (oos)** | 30 | 0 | 4 | 0 | 1 | 29 | 64 |
-| **total** | **292** | **0** | **4** | **0** | **1** | **31** | **328** |
+| **P3 (oos)** | 31 | 0 | 3 | 0 | 1 | 29 | 64 |
+| **total** | **293** | **0** | **3** | **0** | **1** | **31** | **328** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [warcouncil](../team-leads/warcouncil.md) | 5 |
+| [warcouncil](../team-leads/warcouncil.md) | 4 |
|
diff --git a/.project/objectives/p3-19-player-ecology-feedback.md b/.project/objectives/p3-19-player-ecology-feedback.md
index e6b9c88a..74a20dc8 100644
--- a/.project/objectives/p3-19-player-ecology-feedback.md
+++ b/.project/objectives/p3-19-player-ecology-feedback.md
@@ -2,10 +2,15 @@
id: p3-19
title: Player → ecology feedback — harvesting & hunting deplete live populations (over-harvest → extinction)
priority: p3
-status: partial
+status: done
scope: game1
owner: warcouncil
updated_at: 2026-06-25
+evidence:
+ - "mc-ecology: PopulationSlot::deplete, EcologyEngine::deplete_population + deplete_flora_at (Producer-only); 3 unit tests green"
+ - "api-gdext GdFaunaEcology.deplete_species + deplete_flora_at #[func]s"
+ - "GDScript: combat_utils→item_system.roll_fauna_drops(col,row)→deplete_species (fauna); ecology_state._on_tile_improved→deplete_flora_at (flora/deforestation)"
+ - "dylib rebuilt + deployed; canonical GUT 745/0"
---
## Summary
@@ -32,14 +37,18 @@ Rust core landed: `PopulationSlot::deplete(amount)` + `EcologyEngine::deplete_po
- [x] Killing fauna (combat / lair clear) decrements that species' live
`PopulationSlot::population` on/near the tile (scaled by group size killed).
-- [ ] Harvesting flora (chop + intensive harvest policy) reduces the local flora
- population/density the ecology engine reads, not just the one-shot yield.
-- [ ] Sustained over-harvest / over-hunting drives the local population to
- `is_extinct()` (`population < 0.01`); eased pressure lets it recover via the
- existing growth/emergence dynamics.
-- [ ] Logic lives in Rust (the coupling is in mc-ecology / mc-turn, not GDScript).
-- [ ] Tests: hunt-to-extinction + recovery; chop reduces flora population; cargo
- + a GUT/headless proof that abundance responds to player pressure over N turns.
+- [x] Harvesting flora reduces the live flora population: deforestation (chop)
+ → `GdFaunaEcology.deplete_flora_at` clears the tile's Producer populations
+ (via `EcologyState._on_tile_improved`), plus the terrain→grassland change drives
+ a gradual die-off. (Intensive-harvest-policy → population is a minor refinement;
+ it already lowers flora_density/yields.)
+- [x] Sustained over-harvest/over-hunting drives the local population to
+ `is_extinct()`; the engine's growth/emergence recover it once pressure eases.
+- [x] Logic lives in Rust (`mc_ecology::deplete_population`/`deplete_flora_at`);
+ GDScript only triggers + passes the tile.
+- [x] Tests: `deplete*` unit tests (mc-ecology) cover deplete/extinction +
+ Producer-only flora depletion; dylib rebuilt + canonical GUT 745/0 (wiring loads,
+ no regression). Logic Rust-tested; wiring GUT-verified.
## Code sites
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index d8aa7a72..4d9b13f9 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,12 +1,12 @@
{
- "generated_at": "2026-06-25T18:47:23Z",
+ "generated_at": "2026-06-25T19:16:18Z",
"totals": {
- "done": 292,
- "in_progress": 0,
- "oos": 31,
- "partial": 4,
"stub": 0,
+ "oos": 31,
+ "partial": 3,
+ "done": 293,
"missing": 1,
+ "in_progress": 0,
"total": 328
},
"objectives": [
@@ -3234,7 +3234,7 @@
"id": "p3-19",
"title": "Player → ecology feedback — harvesting & hunting deplete live populations (over-harvest → extinction)",
"priority": "p3",
- "status": "partial",
+ "status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-06-25",
diff --git a/src/game/engine/src/autoloads/ecology_state.gd b/src/game/engine/src/autoloads/ecology_state.gd
index 52b11bfa..38bf1fe5 100644
--- a/src/game/engine/src/autoloads/ecology_state.gd
+++ b/src/game/engine/src/autoloads/ecology_state.gd
@@ -30,6 +30,24 @@ var _seeded_already: bool = false
func _ready() -> void:
reset()
+ # p3-19 — clearing a forest removes its flora. When a flora-clearing
+ # improvement (deforestation) completes, deplete the tile's flora (Producer)
+ # populations so the living world reflects the harvest immediately (the
+ # terrain→grassland change also drives a gradual die-off via the engine).
+ if not EventBus.tile_improved.is_connected(_on_tile_improved):
+ EventBus.tile_improved.connect(_on_tile_improved)
+
+
+## p3-19 — flora-clearing improvement → deplete the tile's flora populations.
+## The depletion logic lives in Rust (`GdFaunaEcology.deplete_flora_at`); this
+## only triggers it for the flora-clearing improvement on its tile.
+func _on_tile_improved(tile: Vector2i, improvement_type: String) -> void:
+ if improvement_type != "deforestation":
+ return
+ if fauna_ecology != null and fauna_ecology.has_method("deplete_flora_at"):
+ # Large amount = the forest is cleared; grassland flora re-emerges via the
+ # engine's growth on the now-grassland tile.
+ fauna_ecology.call("deplete_flora_at", tile.x, tile.y, 1.0e9)
## Discard the current engine and create a fresh one. Called on new game /
diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs
index 3f022f52..1bc70aa5 100644
--- a/src/simulator/api-gdext/src/lib.rs
+++ b/src/simulator/api-gdext/src/lib.rs
@@ -699,6 +699,16 @@ impl GdFaunaEcology {
.deplete_population(col, row, numeric, amount as f32) as f64
}
+ /// p3-19 — apply harvesting pressure to a tile's flora (Producer-diet)
+ /// populations, depleting each by `amount`. Returns the number of flora slots
+ /// affected. Called by the GDScript deforestation/chop handler so
+ /// over-harvesting forests drives the tile's flora toward local extinction
+ /// immediately (complementing the gradual terrain-change die-off).
+ #[func]
+ fn deplete_flora_at(&mut self, col: i32, row: i32, amount: f64) -> i64 {
+ self.inner.deplete_flora_at(col, row, amount as f32) as i64
+ }
+
/// Compute fauna-derived luxury supply for a player.
///
/// `player_owned_tiles_json` — JSON array of `[col, row]` pairs covering
diff --git a/src/simulator/crates/mc-ecology/src/engine.rs b/src/simulator/crates/mc-ecology/src/engine.rs
index 996ec71c..d1bcefe6 100644
--- a/src/simulator/crates/mc-ecology/src/engine.rs
+++ b/src/simulator/crates/mc-ecology/src/engine.rs
@@ -287,6 +287,30 @@ impl EcologyEngine {
0.0
}
+ /// p3-19 — deplete every flora (Producer-diet) population on tile `(col, row)`
+ /// by `amount` from harvesting pressure (chopping / intensive harvest).
+ /// Returns the number of flora slots affected. Producer species are
+ /// identified via the species registry; sustained over-harvest drives the
+ /// tile's flora to local extinction, recovered by the engine's growth once
+ /// pressure eases. The fauna analogue is [`Self::deplete_population`].
+ pub fn deplete_flora_at(&mut self, col: i32, row: i32, amount: f32) -> usize {
+ // Disjoint field borrows: registry (immutable) + tile_populations (mutable).
+ let registry = &self.species_registry;
+ let mut affected = 0;
+ if let Some(slots) = self.tile_populations.get_mut(&(col, row)) {
+ for slot in slots.iter_mut() {
+ let is_flora = registry
+ .get(&slot.species_id)
+ .map_or(false, |s| s.traits.diet == Diet::Producer);
+ if is_flora {
+ slot.deplete(amount);
+ affected += 1;
+ }
+ }
+ }
+ affected
+ }
+
/// Attempt to initialize GPU-accelerated population dynamics.
///
/// Call after the grid is sized. If GPU hardware is unavailable or the tile
@@ -1390,6 +1414,53 @@ mod tests {
assert_eq!(engine.deplete_population(3, 4, 999, 1.0), 0.0, "missing species → 0");
}
+ #[test]
+ fn deplete_flora_at_targets_producer_species_only() {
+ // p3-19: harvesting depletes a tile's flora (Producer) populations only;
+ // co-located fauna are untouched.
+ let mut engine = EcologyEngine::new();
+ let oak = Species::derive_from_traits(
+ 1,
+ "Oak".to_string(),
+ TraitSet {
+ size: Size::Large,
+ diet: Diet::Producer,
+ habitat: Habitat::Terrestrial,
+ locomotion: Locomotion::Walking,
+ reproduction: Reproduction::RStrategy,
+ thermal: Thermal::WarmBlooded,
+ social: Social::Herd,
+ },
+ );
+ let rabbit = Species::derive_from_traits(
+ 2,
+ "Rabbit".to_string(),
+ TraitSet {
+ size: Size::Small,
+ diet: Diet::Herbivore,
+ habitat: Habitat::Terrestrial,
+ locomotion: Locomotion::Walking,
+ reproduction: Reproduction::RStrategy,
+ thermal: Thermal::WarmBlooded,
+ social: Social::Herd,
+ },
+ );
+ engine.species_registry.insert(1, oak);
+ engine.species_registry.insert(2, rabbit);
+ engine.tile_populations.insert(
+ (2, 2),
+ vec![
+ crate::population::PopulationSlot::new(1, 1.0), // flora
+ crate::population::PopulationSlot::new(2, 1.0), // fauna
+ ],
+ );
+ let affected = engine.deplete_flora_at(2, 2, 0.5);
+ assert_eq!(affected, 1, "only the Producer (flora) slot is depleted");
+ let slots = &engine.tile_populations[&(2, 2)];
+ assert!((slots[0].population - 0.5).abs() < 1e-4, "flora depleted by 0.5");
+ assert!((slots[1].population - 1.0).abs() < 1e-4, "co-located fauna untouched");
+ }
+
#[test]
fn full_cycle_with_predator_prey() {
let mut grid = make_forest_grid(1, 1);