Give the enemy something to do with its turn #41

Merged
icub3d merged 1 commit from 11-stub-enemy-behaviour into main 2026-09-13 22:43:55 +00:00
Owner

#8 left hostiles giving their turns up instantly, purely so the loop would turn. src/ai/
replaces that with a placeholder that closes, shoots, and otherwise holds — deliberately
not interesting, only enough that a mission is a fight rather than a solo walk.

The boundary is the part that is not placeholder

The AI reads the same Battle snapshot the HUD forecasts from, and submits the same
Action values a player's press becomes. By the time resolution sees one there is nothing
left to say who sent it — which is what makes the enemy replayable, and what will make it
runnable on a server.

Behaviour names the seam M4 implements. Everything around it — how a turn is handed out,
how an action is submitted, how it resolves — stays exactly as it is.

Every decision goes through forecast before it is returned, and falls back to waiting if
refused. That is not tidiness: a refused action spends nothing, and a unit that spends
nothing never ends its turn.
A test walks every combination of action points and distance
and asserts the rules allow whatever came back.

Three bugs the enemy exposed, all older than this change

Once hostiles actually shot, the loop stopped hiding its faults.

The player was being denied turns. begin_turn and finish_animating share a set, and
insert_resource is deferred — so finish_animating saw no turn, took its "that one is
over" branch, and revoked the turn begin_turn had just granted. A probe caught a turn
handed to a player unit and taken away the next frame. Their conditions are now exclusive,
so exactly one acts on any frame. This is why the mission never waited for the player.

A stale message ate the player's turn. notice_submission asked whether the message
buffer was non-empty. A message lives about two frames, so an action submitted last turn
still sat there and marched the next unit through Resolving without it having done
anything. It has its own reader now, and sees each message once.

The dead were still getting turns. Nothing despawns a unit that falls, so corpses stayed
in the schedule and were handed turns they could not spend. reconcile now keeps only units
still standing; #12 decides what a mission does about the bodies.

Each has a regression test.

Acceptance criteria

  • Deterministic under a fixed seed — stronger than asked: it never draws at all, so
    a decision is a function of the battle. Asserted by repeating the same battle.
  • Same Action API as player input — resolution genuinely cannot tell them apart;
    there is one message type and one route.
  • States that it is an M1 placeholder and names its replacement — module docs, first
    paragraph.
  • Never hangs the turn — guaranteed by validating with forecast and falling back
    to Wait, which is always available and always terminal. Tested exhaustively over the
    points/distance grid.

Verification

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test —
188 passing, up from 180.

On a Pixel Tablet a mission is now a fight: the HUD showed Nadia Feuerbach at 7/10 with her
turn waiting on the player. The trooper's range is 5 on a 12x8 map, so it shoots from near
its spawn rather than closing — correct for a behaviour that shoots first.

Closes #11

🤖 Generated with Claude Code

https://claude.ai/code/session_01TA4hJHkRSU3XBxZYtMKXdh

#8 left hostiles giving their turns up instantly, purely so the loop would turn. `src/ai/` replaces that with a placeholder that closes, shoots, and otherwise holds — deliberately not interesting, only enough that a mission is a fight rather than a solo walk. ## The boundary is the part that is not placeholder The AI reads the same `Battle` snapshot the HUD forecasts from, and submits the same `Action` values a player's press becomes. By the time resolution sees one there is nothing left to say who sent it — which is what makes the enemy replayable, and what will make it runnable on a server. `Behaviour` names the seam M4 implements. Everything around it — how a turn is handed out, how an action is submitted, how it resolves — stays exactly as it is. Every decision goes through `forecast` before it is returned, and falls back to waiting if refused. That is not tidiness: **a refused action spends nothing, and a unit that spends nothing never ends its turn.** A test walks every combination of action points and distance and asserts the rules allow whatever came back. ## Three bugs the enemy exposed, all older than this change Once hostiles actually shot, the loop stopped hiding its faults. **The player was being denied turns.** `begin_turn` and `finish_animating` share a set, and `insert_resource` is deferred — so `finish_animating` saw no turn, took its "that one is over" branch, and revoked the turn `begin_turn` had just granted. A probe caught a turn handed to a player unit and taken away the next frame. Their conditions are now exclusive, so exactly one acts on any frame. This is why the mission never waited for the player. **A stale message ate the player's turn.** `notice_submission` asked whether the message buffer was non-empty. A message lives about two frames, so an action submitted last turn still sat there and marched the next unit through `Resolving` without it having done anything. It has its own reader now, and sees each message once. **The dead were still getting turns.** Nothing despawns a unit that falls, so corpses stayed in the schedule and were handed turns they could not spend. `reconcile` now keeps only units still standing; #12 decides what a mission does about the bodies. Each has a regression test. ## Acceptance criteria - [x] **Deterministic under a fixed seed** — stronger than asked: it never draws at all, so a decision is a function of the battle. Asserted by repeating the same battle. - [x] **Same `Action` API as player input** — resolution genuinely cannot tell them apart; there is one message type and one route. - [x] **States that it is an M1 placeholder and names its replacement** — module docs, first paragraph. - [x] **Never hangs the turn** — guaranteed by validating with `forecast` and falling back to `Wait`, which is always available and always terminal. Tested exhaustively over the points/distance grid. ## Verification `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` — **188 passing**, up from 180. On a Pixel Tablet a mission is now a fight: the HUD showed Nadia Feuerbach at 7/10 with her turn waiting on the player. The trooper's range is 5 on a 12x8 map, so it shoots from near its spawn rather than closing — correct for a behaviour that shoots first. Closes #11 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TA4hJHkRSU3XBxZYtMKXdh
#8 left hostiles giving their turns up instantly, purely so the loop would
turn. `src/ai/` replaces that with a placeholder that closes, shoots, and
otherwise holds — deliberately not interesting, only enough that a mission is a
fight rather than a solo walk. M4 replaces it.

What is not placeholder is the boundary. The AI reads the same `Battle` snapshot
the HUD forecasts from, and submits the same `Action` values a player's press
becomes, so by the time resolution sees one there is nothing left to say who
sent it. `Behaviour` names the seam M4 implements; everything around it — how a
turn is handed out, how an action is submitted, how it resolves — stays as it is.

Every decision goes through `forecast` before it is returned and falls back to
waiting if refused. That is not tidiness: a refused action spends nothing, and a
unit that spends nothing never ends its turn. A test walks every combination of
points and distance and asserts the rules allow whatever came back.

Nothing draws from `CombatRng` — a decision is a function of the battle, so a
mission replays whether or not the opponent was ever random.

Three bugs surfaced once the enemy started actually shooting, all older than
this change:

`begin_turn` and `finish_animating` share a set, and `insert_resource` is
deferred — so `finish_animating` saw no turn, took its "that one is over"
branch, and revoked the turn `begin_turn` had just granted. The player was being
denied turns almost entirely; a probe showed a turn handed to a player unit and
taken away the next frame. Their conditions are now exclusive, so exactly one
acts on any frame.

`notice_submission` asked whether the message buffer was non-empty. A message
lives about two frames, so an action submitted last turn still sat there and
marched the next unit through `Resolving` without it having done anything —
eating the player's turn. It has its own reader now, and sees each message once.

Nothing dropped the dead from the schedule, because nothing despawns a unit that
falls. With hostiles that kill, corpses were being handed turns they could not
spend. `reconcile` now keeps only units still standing; #12 decides what a
mission does about the bodies.

Verified on a Pixel Tablet: a mission is now a fight, and the HUD showed Nadia
Feuerbach at 7/10 with her turn waiting on the player.

Closes #11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA4hJHkRSU3XBxZYtMKXdh
icub3d merged commit 9757814258 into main 2026-09-13 22:43:55 +00:00
icub3d deleted branch 11-stub-enemy-behaviour 2026-09-13 22:43:55 +00:00
Sign in to join this conversation.
No description provided.