23 KiB
| 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 |
|
|
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)
CombatOutcomeenum inmc-combat/src/resolver.rsadds three variants:Captured,RansomOffered,Destroyed(alongside existingSurvived,Killed); re-exported frommc-combat/src/lib.rsCombatParamscarriesdefender_capturable: boolandposture_resolution: PostureResolution(Capture | Destroy | Ransom); the bridge setsdefender_capturablefrom JSONcapturable: trueon the unit typeresolve()branches onposture_resolutiononly when defender would die ANDdefender_capturable— emitsCaptured/RansomOffered/Destroyedinstead ofKilled. Non-capturable defenders ignore posture and continue to emitKilledunchanged- XP gain is suppressed on
CapturedandRansomOffered; awarded as normal onDestroyedandKilled CombatResultaddsransom_price: i32, computed asdefender.build_cost × ransom_multiplier(per-unitransom_multiplierJSON field, falling back todefault_ransom_multiplierfromcombat_balance.json)
Posture state + resolution order
mc-statecarriescivilian_posture: HashMap<PlayerId, CapturePosture>per-civ relationship default andMapUnit.posture_override: Option<CapturePosture>;CapturePosture = Capture | Destroy | Ransom | Prompt- Resolution order is exactly:
unit.posture_override→ relation default → global default (Capturefor AI,Promptfor human whilenew_player_capture_promptis true). Tested inmc-combat/tests/capture.rs
Ransom queue + state machine (mc-turn)
- On
CombatOutcome::RansomOffered,mc-turn::processormoves the unit into a captive holding state (newMapUnit.captive_of: Option<PlayerId>field), pins it on the captor's tile, and enqueues aRansomOffer { captor, owner, unit_id, price, expires_turn }in a newmc-turn/src/ransom.rsqueue - Accepting an offer: deducts
pricegold from owner, returns unit to owner, clearscaptive_of, emitsUnitRansomAccepted - Refusing: converts the offer to capture immediately (reuses the
Capturedbranch), emitsUnitRansomExpiredthenUnitCaptured - Expiry after
ransom_offer_duration_turnsturns (default 3, JSON-driven): converts to capture as above CombatOutcome::Captured: changesMapUnit.owner_idto attacker, resets HP to current, clears movement, emitsUnitCapturedCombatOutcome::Destroyed: removes unit (same asKilled) but emits distinctCivilianDestroyedevent for chronicle / AI memory
AI scoring + personalities
mc-ai/src/tactical/combat_predict.rsdetectsdefender_capturableand 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 chosenPostureResolutiontopolicy.rsPersonalityPriorsadds 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 picksDestroyoverCapturefor an enemy worker - Same test file: a merchant personality picks
Ransomwhen ransom price > destroy denial value - Same test file: an expansionist personality with own-worker-pool below threshold picks
CaptureoverDestroy
GDExtension bridge
api-gdextsurfaces the three newCombatOutcomevariants to GDScript as integer codes / dict tagsGdRansomQueueexposespending_offers_for(player_id) → Array[Dict],accept(offer_id),refuse(offer_id)GdPlayer.set_civilian_posture(other_player_id, posture)andGdMapUnit.set_posture_override(posture)exist and round-trip through state
Presentation (GDScript — Rail 3)
combat_resolver.gdbranches on the three new outcomes and emitsEventBus.unit_captured/ransom_offered/civilian_destroyedcombat_preview.gdshows a "Resolution" row when defender is capturable, with active posture and a per-engagement override chip (single-useposture_overridewrite)combat_result.gdrenders all three new outcomes; forRansomOfferedshows price, captor, and expiry turnransom_offers.tscn+.gd(NEW): modal listed on turn-start, three actions per offer (Accept / Refuse / Defer-to-next-turn-until-expiry)diplomacy_panel.gdper-civ row gains a posture dropdown (Capture | Destroy | Ransom | Prompt)unit_panel.gdexposes 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(defaulttrue); 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.jsoneach carry"capturable": trueand"ransom_multiplier": 2.0(explicit opt-in; engineers / caravans / freepeople omitted by design — see Out of scope)combat_balance.json(orsetup.json) carriesdefault_ransom_multiplier: 2.0,ransom_offer_duration_turns: 3,denial_value_factor: 0.6tools/validate_data.py(or equivalent schema validator) recognises the newcapturable,ransom_multiplier,capture_weight,ransom_accept_threshold,destroy_civilian_aggressionfields without warnings
Events + chronicle
mc-eventsdefinesUnitCaptured,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.rscovers: (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 afterransom_offer_duration_turns; (f) non-capturable defender resolves toKilledregardless 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.rsexercises the queued PvP path including the Ransom posture — 4/4 green as of 2026-05-14 afterprocess_ai_ransom_decisionslanded (commit9b21d7105) and the captive-skip guard in the proximity-discovery loop. The previous wiring-gap note incapture_chronicle_pipeline.rsis resolved byprocessor.rs:2186-2243and: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 buildsucceeds; Vitest passes - Proof scene
src/game/engine/scenes/tests/proof_civilian_capture.tscnrenders 4 panels (Capture, Destroy, Ransom-Accepted, Ransom-Expired); screenshot captured viatools/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-bridgeMC_AI_DATA_DIRenv-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 bymc-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.rsa-f,mc-turn/tests/capture_pvp_end_to_end.rs, headless GUT scenario) and demonstrated visually by the approved 4-panel proof sceneproof_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 underp2-55e-richer-ransom-eventsand 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_mastercapture. 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-upp2-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.
Related
p2-53i— support-unit action vocabulary; this objective hooks into the samearchetype: "civilian"filter rather than introducing a new tagp1-29— anti-early-domination work; capture mechanics interact with the capture/development tempo studied across cycles 2–5p1-07— chronicle notification coverage; the five new events plug into the same pipelinep0-26— Rail-1 tactical AI port; AI scoring extensions must land inmc-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-capturedo 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, withstatus: missingand 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).
- Rust:
- Dependencies: none.
- Acceptance gate:
cargo test -p mc-combat test_combat_outcome_new_variantsgreen. - 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 setsdefender_capturablefromcapturable: trueJSON). - Dependencies: bullet above.
- Acceptance gate:
cargo test -p mc-combat test_resolve_branches_on_posturegreen.
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_capturegreen.
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.
- Rust:
- Dependencies: combat outcome bullets.
- Acceptance gate:
cargo test -p mc-combat test_ransom_price_uses_per_unit_multipliergreen. - 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(typedCapturePosture { Capture, Destroy, Ransom, Prompt }enum). - Dependencies: combat bullets.
- Acceptance gate:
cargo test -p mc-combat tests/capture.rs::test_posture_resolution_ordergreen. - SOLID/DRY/SSoT rails: typed
CapturePostureenum inmc-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_expiregreen; expiry converts toCaptured. - 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_recordedgreen.
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).
- Rust:
- Dependencies: posture + ransom bullets.
- Acceptance gate:
cargo test -p mc-ai tests/capture_scoring.rscovering 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— newGdRansomQueue,GdPlayer::set_civilian_posture,GdMapUnit::set_posture_override.
- Rust:
- Dependencies: Rust bullets above.
- Acceptance gate:
bash src/simulator/build-gdext.shclean. - 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.
- GDScript:
- 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— recognisecapturable,ransom_multiplier,capture_weight,ransom_accept_threshold,destroy_civilian_aggression.
- Data:
- Dependencies: combat bullets.
- Acceptance gate: validator zero warnings; broken fixture errors.
- SOLID/DRY/SSoT rails: ONLY in
public/resources/; nodata/<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.gdgreen.
Bullet: 30-turn raider-vs-merchant AI playthrough produces all three outcome types in chronicle
- Files to touch:
tools/run-headless-batch.shsmoke fixture + chronicle inspector script. - Dependencies: AI scoring bullet.
- Acceptance gate: chronicle contains
Captured,Destroyed, AND completeRansomOffered → Accepted-or-Expiredtransition.
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 testgreen.
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'sfollow_ups:list. - Dependencies: none — admin task.
- Acceptance gate:
python3 tools/objectives-report.pyrecognises 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 inresolve_single_pvp_attack) and:2936-2990(proximity-discovery loop inprocess_pvp_combat). Both sites read(capturable, ransom_multiplier, build_cost)fromstate.units_catalog, resolve attacker posture viacapture::resolve_posture, and pass all four fields intoCombatParams. Non-capturable defenders keepdefender_capturable=falseand fall through toKilled/Survivedunchanged. - 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_decisionsinprocessor.rs). Detailed acceptance evidence for that hook lives in sub-objectivep2-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.rsa..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.