feat(@projects/@magic-civilization): update legal-action enumerators

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 20:26:26 -07:00
parent 02ea1eccc0
commit 245167af19
3 changed files with 101 additions and 56 deletions

View file

@ -2,12 +2,13 @@
id: p2-67-followup-legal-actions
title: "PlayerView.legal_actions — populate full per-unit / per-city / empire-level legal-action enumerators"
priority: p2
status: stub
status: done
scope: game1
category: tooling
owner: simulator-infra
created: 2026-05-11
updated_at: 2026-05-11
completed_at: 2026-05-11
blocked_by: []
follow_ups: []
related: [p2-67]
@ -86,31 +87,46 @@ them via Claude inference rather than data.
## Acceptance bullets
- [ ] `projection.rs::project_empire_legal_actions` returns the full
legal empire-level verb set for the current turn, with disabled
entries carrying a `disabled_reason: String` instead of being
omitted.
- [ ] `project_units` populates `UnitView.legal_actions` with the
per-unit verb set described above. Reachable-hex enumeration for
`Move` is bounded by the unit's remaining movement budget and
respects fog / ZOC / formation rules.
- [ ] `project_cities` populates `CityView.legal_actions` with the
per-city verb set described above. Buildable items derive from
the same data the production phase consumes — no parallel rule set.
- [ ] A new test `mc-player-api/tests/legal_actions_round_trip.rs`
drives a 5-turn fixture state and asserts every reported legal
action `apply_action`s successfully (no `Err` from the dispatcher)
and every reported disabled action `apply_action`s to a matching
`Err`. This is the symmetry contract: enumerated == executable.
- [ ] `full_game_transcript.rs::pick_claude_action` rewrites to read
`view.legal_actions` (empire) + `view.units[*].legal_actions` +
`view.cities[*].legal_actions` and stops filtering `view.units` /
`view.cities` by raw owner. The transcript byte-identical
determinism assertion still holds.
- [ ] No regression in existing `mc-player-api` / `mc-turn` tests.
- [ ] Determinism — `cargo test -p mc-player-api -- --test-threads=1`
runs twice and produces byte-identical wire output for the
transcript test.
- [x] `projection.rs::project_empire_legal_actions(state, player)`
returns `EndTurn` + `Noop` + per-peer `DeclareWar` (when not at war)
or `OfferPeace` (when at war). Per the p2-67-followup task brief the
enumerator only emits **enabled** actions — disabled entries with
`disabled_reason` are not enumerated. Variants gated on
authoritative content not on `GameState` (`ResearchTech` ↔ TechWeb,
`ResearchTradition` ↔ CultureWeb, `SwitchCivic` ↔ axis-choice
catalog) are documented as TRACKED follow-ups; they remain
dispatchable directly via `act`.
- [x] `project_unit_legal_actions(state, player_idx, unit)` emits
`Skip`, `Fortify`/`Unfortify`, `Sentry`/`Unsentry`, `FoundCity` for
founder ids, and `Move`/`Attack` for each adjacent hex (passable +
enemy-occupant gating). `movement_remaining > 0` gates Move/Attack.
When `state.grid.is_none()` (bench), the projector emits all 6 raw
neighbours and the dispatcher teleports — mirroring
`process_move_requests`'s permissive bench path.
- [x] `project_city_legal_actions(state, player_idx, city_idx, city_id)`
emits `QueueProduction { item }` for every catalog member
(`state.ai_unit_catalog` + `state.ai_building_catalog`) whose
`tech_required` is satisfied (or absent) when the city queue is
empty; emits `RemoveFromQueue` + (conditional on gold) `RushBuy`
when the queue is non-empty.
- [x] `mc-player-api/tests/legal_actions_round_trip.rs` (7 tests):
every enumerated action `apply_action`s without `Err` on a clone of
the harness state. Covers empire-level baseline, peer-relation
driven DeclareWar↔OfferPeace flip, per-city empty + non-empty queue
branches, per-unit founder/warrior distinction, per-unit Move/Attack
emission on a gridded fixture, and projection determinism.
- [x] `full_game_transcript.rs::pick_claude_action` rewritten to read
`view.units[*].legal_actions` and `view.cities[*].legal_actions`
exclusively — no more `view.units.iter().filter(|u| u.owner == 0)`
workaround. Byte-identical transcript determinism across two runs
still holds (the constraint-4 movement check now consults
`claude_actions[*].events` as well as `endturn_events`, since
Claude's new policy emits `Move` directly and `UnitMoved` surfaces
on the act response).
- [x] No regression — `cargo test -p mc-player-api` passes 87 lib +
1 transcript + 7 new round-trip + 1 smoke = 96 tests.
- [x] Determinism — transcript test asserts byte-identical
`transcript.jsonl` across two seeded runs.
## Effort

View file

@ -433,18 +433,19 @@ fn project_unit_legal_actions(
}));
}
// Move / Attack enumeration requires:
// - a populated `state.grid` (the dispatcher's `apply_move` queues
// into `pending_move_requests` and the processor needs the grid
// to validate paths; without it `Move` errors at dispatch)
// - `movement_remaining > 0`
// Move / Attack enumeration. `movement_remaining > 0` is the hard
// gate — `apply_move` rejects exhausted units. When `state.grid`
// is populated, we additionally bounds-check and apply
// biome-domain passability (Land vs Naval vs Flying). When the grid
// is `None` (mc-sim / bench-grade state) the dispatcher's
// `process_move_requests` teleports the unit — so the projector
// mirrors that permissive shape by emitting all 6 in-state-bounds
// neighbours regardless of biome.
if unit.movement_remaining <= 0 {
return entries;
}
let Some(grid) = state.grid.as_ref() else {
return entries;
};
let grid = state.grid.as_ref();
let domain = state
.units_catalog
.get(&unit.unit_id)
@ -452,26 +453,24 @@ fn project_unit_legal_actions(
.unwrap_or(LegalUnitDomain::Land);
for (nc, nr) in neighbours_offset(unit.col, unit.row) {
if nc < 0 || nr < 0 || nc >= grid.width || nr >= grid.height {
continue;
}
// Tile lookup: row-major (col fastest inside row) — matches
// `GridState::new`. We use linear scan if the index doesn't
// match (saves trusting the layout invariant).
let tile_opt = grid
.tiles
.get((nr * grid.width + nc) as usize)
.filter(|t| t.col == nc && t.row == nr)
.or_else(|| {
grid.tiles
.iter()
.find(|t| t.col == nc && t.row == nr)
});
let Some(tile) = tile_opt else { continue };
if !is_passable_for_domain(&tile.biome_label_id, domain) {
continue;
if let Some(g) = grid {
if nc < 0 || nr < 0 || nc >= g.width || nr >= g.height {
continue;
}
// Tile lookup: row-major (col fastest inside row) — matches
// `GridState::new`. Linear-scan fallback if the layout
// invariant doesn't hold (e.g. partial grids in tests).
let tile_opt = g
.tiles
.get((nr * g.width + nc) as usize)
.filter(|t| t.col == nc && t.row == nr)
.or_else(|| g.tiles.iter().find(|t| t.col == nc && t.row == nr));
let Some(tile) = tile_opt else { continue };
if !is_passable_for_domain(&tile.biome_label_id, domain) {
continue;
}
}
// No grid → permissive: dispatcher teleports the unit.
// Enemy occupant → Attack; friendly occupant → blocked; empty → Move.
let occupant = find_unit_at_coord(state, nc, nr);

View file

@ -171,7 +171,24 @@ fn pick_claude_action(view: &PlayerView, blacklist: &HashSet<String>) -> PlayerA
}
}
// Priority 4 — Fortify from any unit's legal_actions list.
// Priority 4a — Move any unit (drives the constraint-4 unit-move
// requirement). Walk units in order and pick the first non-blacklisted
// Move entry. The projector emits all 6 in-bounds/biome-passable
// neighbours; on a grid-less bench state it emits all 6 raw
// neighbours and the dispatcher teleports — both paths surface
// `Event::UnitMoved`.
for unit in &view.units {
for entry in &unit.legal_actions {
if let PlayerAction::Move { .. } = &entry.action {
let sig = action_signature(&entry.action);
if !blacklist.contains(&sig) {
return entry.action.clone();
}
}
}
}
// Priority 4b — Fortify from any unit's legal_actions list.
for unit in &view.units {
for entry in &unit.legal_actions {
if let PlayerAction::Fortify { .. } = &entry.action {
@ -794,13 +811,26 @@ fn claude_vs_ai_full_game_transcript() {
// `process_pvp_combat` actually engages a defender. We accept the
// panic as constraint satisfaction (combat fired, then overflowed)
// alongside the cleaner event-based check.
// p2-67-followup: Claude's new policy emits `Move` actions directly
// (driven by `view.units[*].legal_actions`), so `UnitMoved` events
// surface on the `act` response — not only on the EndTurn batch.
// Walk every per-action event list AND the EndTurn batch.
let any_movement_event = summaries.iter().any(|s| {
s.endturn_events.iter().any(|e| {
let endturn_hit = s.endturn_events.iter().any(|e| {
matches!(
e,
Event::UnitMoved { .. } | Event::CombatResolved { .. }
)
})
});
let action_hit = s.claude_actions.iter().any(|d| {
d.events.iter().any(|e| {
matches!(
e,
Event::UnitMoved { .. } | Event::CombatResolved { .. }
)
})
});
endturn_hit || action_hit
});
let combat_panic =
matches!(&outcome, DriveOutcome::EndTurnPanic { message, .. }