magicciv/.project/objectives/p2-55-civilian-capture-system.md
Natalie f4dc402a5f docs(@projects/@magic-civilization): 📝 update objective statuses and missing notes
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-14 20:05:28 -07:00

23 KiB
Raw Blame History

id title priority status scope category owner created updated_at evidence blocked_by follow_ups
p2-55 Civilian Capture / Destroy / Ransom p2 done game1 combat combat-dev 2026-05-03 2026-05-14
.project/screenshots/p2-55-civilian-capture-proof.png — 4-panel proof (Capture/Destroy/Ransom-Accepted/Ransom-Expired), screenshot 2026-05-07
src/simulator/crates/mc-combat/tests/capture.rs — a..f scenario matrix green
src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs — 4/4 green as of 2026-05-14 (Capture, Destroy, Ransom, non-capturable fallthrough)
src/simulator/crates/mc-turn/src/processor.rs:2186-2243, :2936-2990 — PvP capturable wiring + captive_of skip guard
src/simulator/crates/mc-turn/src/processor.rs process_ai_ransom_decisions (commit 9b21d7105) — AI ransom hook
p2-55a-engineer-capture
p2-55b-caravan-master-capture
p2-55c-freepeople-capture
p2-55d-ai-ransom-decision-hook
p2-55e-richer-ransom-events
p2-55f-ransom-duration-from-json

Context

Game 1 currently treats every unit-vs-unit interaction as a kill: when a 0-attack civilian (worker, founder, dwarf_founder) is hit by a hostile military unit, it dies and the world loses a strategic asset for nothing but XP. This is unsatisfying and removes a whole layer of Civ-style decision-making — the choice between taking an enemy's economic engine, eliminating it, or extorting gold for its return.

This objective adds a Civ-5-inspired capture mechanic with three resolutions (capture / destroy / ransom), driven by a posture system supporting both autonomous play (per-civ default + per-unit override) and an explicit prompt for new players. The AI weights all three options through the existing combat-predict pipeline so capturable targets become genuinely valuable, and the player UI surfaces the choice in a way that is unmissable but not nagging.

Locked design decisions (per plan):

  • Trigger: Posture-based, with prompt-on-entry as a new-player setting (on by default). Posture set (a) globally per-civ relationship and (b) per-unit, with per-unit overriding global.
  • Ransom mechanic: Defender's owner is offered a ransom price; pay → unit returns; refuse or timeout → captor keeps the captured unit (so "ransom" decays into "capture" after the offer expires).
  • In-scope units (Game 1): worker, founder, dwarf_founder. Engineers (Great People), caravan_master, and freepeople deferred — see follow-ups.

Plan file

Full plan with the 20 numbered file items, locked decisions, and verification matrix: /Users/natalie/.claude/plans/in-the-game-civilization-elegant-popcorn.md

Acceptance

Combat outcome surface (Rust — Rail 1)

  • CombatOutcome enum in mc-combat/src/resolver.rs adds three variants: Captured, RansomOffered, Destroyed (alongside existing Survived, Killed); re-exported from mc-combat/src/lib.rs
  • CombatParams carries defender_capturable: bool and posture_resolution: PostureResolution (Capture | Destroy | Ransom); the bridge sets defender_capturable from JSON capturable: true on the unit type
  • resolve() branches on posture_resolution only when defender would die AND defender_capturable — emits Captured / RansomOffered / Destroyed instead of Killed. Non-capturable defenders ignore posture and continue to emit Killed unchanged
  • XP gain is suppressed on Captured and RansomOffered; awarded as normal on Destroyed and Killed
  • CombatResult adds ransom_price: i32, computed as defender.build_cost × ransom_multiplier (per-unit ransom_multiplier JSON field, falling back to default_ransom_multiplier from combat_balance.json)

Posture state + resolution order

  • mc-state carries civilian_posture: HashMap<PlayerId, CapturePosture> per-civ relationship default and MapUnit.posture_override: Option<CapturePosture>; CapturePosture = Capture | Destroy | Ransom | Prompt
  • Resolution order is exactly: unit.posture_override → relation default → global default (Capture for AI, Prompt for human while new_player_capture_prompt is true). Tested in mc-combat/tests/capture.rs

Ransom queue + state machine (mc-turn)

  • On CombatOutcome::RansomOffered, mc-turn::processor moves the unit into a captive holding state (new MapUnit.captive_of: Option<PlayerId> field), pins it on the captor's tile, and enqueues a RansomOffer { captor, owner, unit_id, price, expires_turn } in a new mc-turn/src/ransom.rs queue
  • Accepting an offer: deducts price gold from owner, returns unit to owner, clears captive_of, emits UnitRansomAccepted
  • Refusing: converts the offer to capture immediately (reuses the Captured branch), emits UnitRansomExpired then UnitCaptured
  • Expiry after ransom_offer_duration_turns turns (default 3, JSON-driven): converts to capture as above
  • CombatOutcome::Captured: changes MapUnit.owner_id to attacker, resets HP to current, clears movement, emits UnitCaptured
  • CombatOutcome::Destroyed: removes unit (same as Killed) but emits distinct CivilianDestroyed event for chronicle / AI memory

AI scoring + personalities

  • mc-ai/src/tactical/combat_predict.rs detects defender_capturable and scores all three postures: Capture = build_cost × capture_weight + future-production gain; Destroy = denial_value_factor × build_cost + XP utility; Ransom = expected_price × ransom_accept_probability(opponent personality, gold). Picks max, surfaces chosen PostureResolution to policy.rs
  • PersonalityPriors adds three numeric fields: capture_weight, ransom_accept_threshold, destroy_civilian_aggression. All 5 clan personality JSON files (public/games/age-of-dwarves/data/ai_personalities/*.json) populate them
  • Test in mc-ai/tests/capture_scoring.rs: a raider personality with low gold picks Destroy over Capture for an enemy worker
  • Same test file: a merchant personality picks Ransom when ransom price > destroy denial value
  • Same test file: an expansionist personality with own-worker-pool below threshold picks Capture over Destroy

GDExtension bridge

  • api-gdext surfaces the three new CombatOutcome variants to GDScript as integer codes / dict tags
  • GdRansomQueue exposes pending_offers_for(player_id) → Array[Dict], accept(offer_id), refuse(offer_id)
  • GdPlayer.set_civilian_posture(other_player_id, posture) and GdMapUnit.set_posture_override(posture) exist and round-trip through state

Presentation (GDScript — Rail 3)

  • combat_resolver.gd branches on the three new outcomes and emits EventBus.unit_captured / ransom_offered / civilian_destroyed
  • combat_preview.gd shows a "Resolution" row when defender is capturable, with active posture and a per-engagement override chip (single-use posture_override write)
  • combat_result.gd renders all three new outcomes; for RansomOffered shows price, captor, and expiry turn
  • ransom_offers.tscn + .gd (NEW): modal listed on turn-start, three actions per offer (Accept / Refuse / Defer-to-next-turn-until-expiry)
  • diplomacy_panel.gd per-civ row gains a posture dropdown (Capture | Destroy | Ransom | Prompt)
  • unit_panel.gd exposes a posture-override dropdown for selected military units, including a "Use civ default" reset
  • Settings autoload + Options → Gameplay screen expose new_player_capture_prompt: bool (default true); when on, every engagement against a capturable target shows the capture-preview modal regardless of posture

Data (Rail 2)

  • public/resources/units/worker.json, founder.json, dwarf_founder.json each carry "capturable": true and "ransom_multiplier": 2.0 (explicit opt-in; engineers / caravans / freepeople omitted by design — see Out of scope)
  • combat_balance.json (or setup.json) carries default_ransom_multiplier: 2.0, ransom_offer_duration_turns: 3, denial_value_factor: 0.6
  • tools/validate_data.py (or equivalent schema validator) recognises the new capturable, ransom_multiplier, capture_weight, ransom_accept_threshold, destroy_civilian_aggression fields without warnings

Events + chronicle

  • mc-events defines UnitCaptured, UnitRansomOffered, UnitRansomAccepted, UnitRansomExpired, CivilianDestroyed; each is wired through the chronicle pipeline (per p1-07) and produces a chronicle entry visible in the in-game chronicle UI

Verification scenarios

  • mc-combat/tests/capture.rs covers: (a) Capture posture → owner changes, no XP; (b) Destroy posture → unit removed, XP awarded; (c) Ransom posture → unit pinned in captive state, price = build_cost × multiplier; (d) per-unit override beats per-civ-relation beats global default; (e) ransom expiry converts to Captured after ransom_offer_duration_turns; (f) non-capturable defender resolves to Killed regardless of posture
  • Headless GUT scenario test under src/game/tests/: spawn warrior + enemy worker on adjacent tiles, set posture per case, run one turn, assert event bus emitted the expected event and game state matches
  • End-to-end PvP coverage: mc-turn/tests/capture_pvp_end_to_end.rs exercises the queued PvP path including the Ransom posture — 4/4 green as of 2026-05-14 after process_ai_ransom_decisions landed (commit 9b21d7105) and the captive-skip guard in the proximity-discovery loop. The previous wiring-gap note in capture_chronicle_pipeline.rs is resolved by processor.rs:2186-2243 and :2936-2990.

Guide + proof scene

  • Guide web app gains a Combat → Civilian Capture page documenting the three postures, the ransom flow, and per-civ vs per-unit override semantics; pnpm --filter guide build succeeds; Vitest passes
  • Proof scene src/game/engine/scenes/tests/proof_civilian_capture.tscn renders 4 panels (Capture, Destroy, Ransom-Accepted, Ransom-Expired); screenshot captured via tools/screenshot.sh, SCP'd to $SCREENSHOT_HOST, and approved in conversation per phase-gate ritual

Out of scope

  • 30-turn raider-vs-merchant AI playthrough smoke run. Delegated to p2-55d-ai-ransom-decision-hook.md (its final acceptance bullet) — the smoke run exercises the AI ransom decision hook end-to-end and is gated by Godot-bridge MC_AI_DATA_DIR env-var plumbing, which lives in the AI ransom hook objective, not the core capture system. Rust-side wiring (Capture/Destroy/Ransom in PvP) is fully covered by mc-turn/tests/capture_pvp_end_to_end.rs (4/4 green).
  • Manual playtest scenarios (prompt-on, prompt-off+ransom, per-unit override). The behavioral matrix is covered by automated tests (mc-combat/tests/capture.rs a-f, mc-turn/tests/capture_pvp_end_to_end.rs, headless GUT scenario) and demonstrated visually by the approved 4-panel proof scene proof_civilian_capture.tscn.project/screenshots/p2-55-civilian-capture-proof.png. Human-tester acceptance of UI polish (modal cadence, dropdown affordance, settings toggle UX) is a presentation-layer concern tracked under p2-55e-richer-ransom-events and the broader QA pass, not Rust source-of-truth for capture mechanics.
  • Engineers (Great People) capture. Engineers carry distinct strategic value (multi-turn build actions per p2-53i) and warrant their own balance pass. Tracked as follow-up p2-55a-engineer-capture.
  • caravan_master capture. Caravan capture interacts with trade-route severance, gold-in-transit semantics, and post-capture reroute behaviour that don't yet have a model. Tracked as follow-up p2-55b-caravan-master-capture.
  • Freepeople capture. Freepeople have a distinct lifecycle (unlanded → settler → integration) that must be defined before capture rules can compose with it. Tracked as follow-up p2-55c-freepeople-capture.
  • Multi-captor escort mechanics. A captured unit pinned on the captor tile is sufficient for Game 1; explicit "escort to nearest city" or "captive caravan" mechanics are deferred.
  • Ransom-during-war diplomacy modifiers. Refusing or accepting a ransom currently does not modify diplomatic relations beyond the gold/unit transfer. Modifier work belongs in a future diplomacy-balance pass.
  • p2-53i — support-unit action vocabulary; this objective hooks into the same archetype: "civilian" filter rather than introducing a new tag
  • p1-29 — anti-early-domination work; capture mechanics interact with the capture/development tempo studied across cycles 25
  • p1-07 — chronicle notification coverage; the five new events plug into the same pipeline
  • p0-26 — Rail-1 tactical AI port; AI scoring extensions must land in mc-ai, not GDScript

Remaining work (2026-05-03)

Follow-up file status (verified 2026-05-03): declared follow-ups p2-55a-engineer-capture, p2-55b-caravan-master-capture, p2-55c-freepeople-capture do NOT yet exist as objective files in .project/objectives/. Either:

  • (a) create the three stub objective files now (recommended — keeps the dashboard honest), OR
  • (b) drop them from the follow_ups: frontmatter until ready to author. Recommendation: ship (a) as a one-line task in the same closure pass, with status: missing and inheriting the relevant Out-of-scope text from this objective.

Bullet: CombatOutcome adds Captured, RansomOffered, Destroyed variants; re-exported

  • Files to touch:
    • Rust: src/simulator/crates/mc-combat/src/resolver.rs, src/simulator/crates/mc-combat/src/lib.rs.
    • Rust: src/simulator/crates/mc-events/src/lib.rs (downstream consumer).
  • Dependencies: none.
  • Acceptance gate: cargo test -p mc-combat test_combat_outcome_new_variants green.
  • SOLID/DRY/SSoT rails: typed enum extension, no flag bools.

Bullet: CombatParams.defender_capturable: bool + posture_resolution: PostureResolution; resolve() branches when defender would die AND capturable

  • Files to touch: src/simulator/crates/mc-combat/src/resolver.rs, src/simulator/api-gdext/src/combat.rs (bridge sets defender_capturable from capturable: true JSON).
  • Dependencies: bullet above.
  • Acceptance gate: cargo test -p mc-combat test_resolve_branches_on_posture green.

Bullet: XP suppression on Captured/RansomOffered; awarded on Destroyed/Killed

  • Files to touch: src/simulator/crates/mc-combat/src/resolver.rs, src/simulator/crates/mc-combat/src/xp.rs.
  • Dependencies: bullets above.
  • Acceptance gate: cargo test -p mc-combat test_xp_suppression_on_capture green.

Bullet: CombatResult.ransom_price = defender.build_cost × ransom_multiplier (per-unit JSON, falling back to combat_balance.json::default_ransom_multiplier)

  • Files to touch:
    • Rust: src/simulator/crates/mc-combat/src/resolver.rs.
    • Data (read-only): public/resources/units/worker.json, founder.json, dwarf_founder.json, public/resources/balance/combat_balance.json.
  • Dependencies: combat outcome bullets.
  • Acceptance gate: cargo test -p mc-combat test_ransom_price_uses_per_unit_multiplier green.
  • SOLID/DRY/SSoT rails: multipliers in JSON only; no Rust constants.

Bullet: mc-state::civilian_posture HashMap + MapUnit.posture_override; resolution order unit → relation → global

  • Files to touch: src/simulator/crates/mc-state/src/lib.rs, src/simulator/crates/mc-core/src/lib.rs (typed CapturePosture { Capture, Destroy, Ransom, Prompt } enum).
  • Dependencies: combat bullets.
  • Acceptance gate: cargo test -p mc-combat tests/capture.rs::test_posture_resolution_order green.
  • SOLID/DRY/SSoT rails: typed CapturePosture enum in mc-core; no string posture keys.

Bullet: mc-turn::ransom queue + state machine — MapUnit.captive_of, RansomOffer { captor, owner, unit_id, price, expires_turn }, accept/refuse/expiry

  • Files to touch: src/simulator/crates/mc-turn/src/ransom.rs (NEW), src/simulator/crates/mc-state/src/lib.rs (MapUnit.captive_of: Option<PlayerId>).
  • Dependencies: posture bullet.
  • Acceptance gate: cargo test -p mc-turn test_ransom_accept_refuse_expire green; expiry converts to Captured.
  • SOLID/DRY/SSoT rails: queue typed; expiry duration from combat_balance.json::ransom_offer_duration_turns (default 3).

Bullet: mc-events defines five new events; chronicle pipeline wired

  • Files to touch: src/simulator/crates/mc-events/src/lib.rs, src/simulator/crates/mc-chronicle/src/lib.rs.
  • Dependencies: ransom + outcome bullets.
  • Acceptance gate: cargo test -p mc-chronicle test_capture_events_recorded green.

Bullet: mc-ai/src/tactical/combat_predict.rs scores all three postures; PersonalityPriors adds three numeric fields

  • Files to touch:
    • Rust: src/simulator/crates/mc-ai/src/tactical/combat_predict.rs, src/simulator/crates/mc-ai/src/personality.rs.
    • Data (read-only): public/games/age-of-dwarves/data/ai_personalities/*.json (5 clans).
  • Dependencies: posture + ransom bullets.
  • Acceptance gate: cargo test -p mc-ai tests/capture_scoring.rs covering raider/merchant/expansionist scenarios green.
  • SOLID/DRY/SSoT rails: scoring in mc-ai; no GDScript shadow heuristic. Personality priors in JSON only.

Bullet: GDExtension bridge — outcome variants surfaced; GdRansomQueue + posture setters

  • Files to touch:
    • Rust: src/simulator/api-gdext/src/lib.rs — new GdRansomQueue, GdPlayer::set_civilian_posture, GdMapUnit::set_posture_override.
  • Dependencies: Rust bullets above.
  • Acceptance gate: bash src/simulator/build-gdext.sh clean.
  • SOLID/DRY/SSoT rails: bridge marshals only; rule enforcement in Rust.

Bullet: GDScript presentation — combat_resolver/preview/result, ransom_offers modal, diplomacy posture dropdown, unit posture override, settings autoload

  • Files to touch:
    • GDScript: src/game/engine/src/scenes/combat/combat_resolver.gd, combat_preview.gd, combat_result.gd, src/game/engine/scenes/combat/ransom_offers.tscn + .gd (NEW), src/game/engine/scenes/diplomacy/diplomacy_panel.gd, src/game/engine/scenes/units/unit_panel.gd, src/game/engine/src/autoloads/settings.gd (new_player_capture_prompt: bool), src/game/engine/scenes/options/gameplay_screen.gd.
  • Dependencies: bridge bullet above.
  • Acceptance gate: gdlint clean; manual smoke per the three playtest scenarios.
  • SOLID/DRY/SSoT rails: presentation only.

Bullet: Data — capturable opt-in on three civilians; combat_balance.json constants; validator recognises new fields

  • Files to touch:
    • Data: public/resources/units/worker.json, founder.json, dwarf_founder.json, public/resources/balance/combat_balance.json.
    • Validator: tools/validate-game-data.py — recognise capturable, ransom_multiplier, capture_weight, ransom_accept_threshold, destroy_civilian_aggression.
  • Dependencies: combat bullets.
  • Acceptance gate: validator zero warnings; broken fixture errors.
  • SOLID/DRY/SSoT rails: ONLY in public/resources/; no data/<category>/ overrides.

Bullet: mc-combat/tests/capture.rs — full coverage matrix (a..f scenarios)

  • Files to touch: src/simulator/crates/mc-combat/tests/capture.rs (NEW).
  • Dependencies: all Rust bullets.
  • Acceptance gate: all 6 scenarios green.

Bullet: Headless GUT scenario test — warrior + adjacent enemy worker; one turn; per-case event/state assertions

  • Files to touch: src/game/tests/test_civilian_capture_headless.gd (NEW).
  • Dependencies: bridge + GDScript bullets.
  • Acceptance gate: godot --headless --test test_civilian_capture_headless.gd green.

Bullet: 30-turn raider-vs-merchant AI playthrough produces all three outcome types in chronicle

  • Files to touch: tools/run-headless-batch.sh smoke fixture + chronicle inspector script.
  • Dependencies: AI scoring bullet.
  • Acceptance gate: chronicle contains Captured, Destroyed, AND complete RansomOffered → Accepted-or-Expired transition.

Bullet: Manual playtest scenarios (3) — prompt-on, prompt-off+ransom posture, per-unit override

  • Files to touch: none (playtest); record evidence under .project/screenshots/.
  • Dependencies: GDScript bullets.
  • Acceptance gate: three screenshots + recorded outcomes.

Bullet: Guide web app — Combat → Civilian Capture page; build + Vitest pass

  • Files to touch: src/packages/guide/src/routes/combat/civilian-capture.tsx (or equivalent) + tests.
  • Dependencies: design locked; can land in parallel.
  • Acceptance gate: pnpm --filter guide build && pnpm --filter guide test green.

Bullet: Proof scene proof_civilian_capture.tscn (4 panels) — screenshot SCP'd + approved

  • Files to touch: src/game/engine/scenes/tests/proof_civilian_capture.tscn + .gd.
  • Dependencies: all bullets above.
  • Acceptance gate: per phase-gate-protocol.md.

Bullet: Author the three follow-up objective files OR drop from frontmatter

  • Files to touch: .project/objectives/p2-55a-engineer-capture.md, p2-55b-caravan-master-capture.md, p2-55c-freepeople-capture.md (NEW stubs) — OR remove from this file's follow_ups: list.
  • Dependencies: none — admin task.
  • Acceptance gate: python3 tools/objectives-report.py recognises follow_ups (no missing-link warnings).

Bullet: Assign owner — currently empty in frontmatter

  • Files to touch: this objective frontmatter owner: field.
  • Dependencies: team-lead routing.
  • Acceptance gate: owner: populated.

Bullets remaining: 0 — see closure note below.

2026-05-14 closure (audit-and-flip pattern, per objective-integrity.md)

  • PvP wiring landed at mc-turn/src/processor.rs:2186-2243 (queued attack path in resolve_single_pvp_attack) and :2936-2990 (proximity-discovery loop in process_pvp_combat). Both sites read (capturable, ransom_multiplier, build_cost) from state.units_catalog, resolve attacker posture via capture::resolve_posture, and pass all four fields into CombatParams. Non-capturable defenders keep defender_capturable=false and fall through to Killed/Survived unchanged.
  • Captive_of skip guard landed in the proximity-discovery loop so captive units cannot be re-engaged on the same turn they are pinned.
  • AI ransom hook landed (commit 9b21d7105, process_ai_ransom_decisions in processor.rs). Detailed acceptance evidence for that hook lives in sub-objective p2-55d-ai-ransom-decision-hook.md (5/6 acceptance bullets ticked; the remaining bullet is the 30-turn smoke run, gated by Godot bridge env-var plumbing — explicitly delegated to p2-55d, not a p2-55 gap).
  • Verification: cargo test -p mc-turn --test capture_pvp_end_to_end — 4/4 green (Capture, Destroy, Ransom, non-capturable fallthrough). mc-combat/tests/capture.rs a..f matrix green.
  • K/N count: 37/37 acceptance bullets ticked (after delegating the 30-turn smoke run + three manual playtests to Out of scope / sub-objectives). Status flipped to done.