From a205b616c4e7a7b256617f91f517ec4f481da78c Mon Sep 17 00:00:00 2001 From: gethi Date: Tue, 19 May 2026 20:21:01 +0100 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20P4c=20slice=203=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20lookahead=20sliding-window=20human=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-slice3-lookahead-sliding-window-design.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-phase-4c-slice3-lookahead-sliding-window-design.md diff --git a/docs/superpowers/specs/2026-05-19-phase-4c-slice3-lookahead-sliding-window-design.md b/docs/superpowers/specs/2026-05-19-phase-4c-slice3-lookahead-sliding-window-design.md new file mode 100644 index 0000000..638168f --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-phase-4c-slice3-lookahead-sliding-window-design.md @@ -0,0 +1,140 @@ +# Phase 4c slice 3 — Hard-mode lookahead: sliding-window human projection — Design + +> Status: design approved, ready for implementation plan. + +## 1. Overview + +Hard-mode AI uses one round of lookahead to choose its launch target: `bestTargetByLookahead` simulates a round for each candidate target and picks the highest-scoring projected state. Inside that simulation every non-viewer leader needs projected orders. AI opponents are re-planned via `dispatch`; a **human** opponent is projected from order history. + +Today that projection is **Approach A** — replay the human's *last* round verbatim: + +```ts +const lastRound = state.orderHistory[state.orderHistory.length - 1]; +ordersByLeader[id] = lastRound?.[id] ?? []; +``` + +**Problem:** if the human passed last round (submitted `[]`), Approach A projects them as passive — even if they launched in every prior round. The lookahead then under-estimates retaliation risk and can pick a reckless target. + +This slice implements **Approach B (sliding-window history)** in its minimal form: project the human by their **most recent non-empty round within a window of the last 5 rounds**. A recent pass no longer reads as passivity; a non-empty round older than the window is treated as stale. + +This is the smallest of the deferred "Approach B/C lookahead upgrades" (slice 2 design §7.1). Approach C (personality-fit modelling) is **not** in scope. + +## 2. Decisions + +| Question | Decision | +|----------|----------| +| Which upgrade | Approach B only — sliding-window history. Approach C deferred. | +| Window-collapse rule | **Most recent non-empty round** within the window. Not modal, not most-active. | +| Window size | **5 rounds.** A named constant. The phase-2.5 design named 3; widened to 5 so a human who builds quietly for several rounds before striking still projects their last real move. | +| "Non-empty" | The history entry for the leader exists and has `length > 0`. `undefined` and `[]` are skipped. | +| Scope of change | One file — `src/engine/ai/lookahead.ts`. AI-opponent projection (`dispatch`) untouched. | +| Determinism | Unaffected — the projection is a pure history read, no RNG. | + +## 3. The change + +### 3.1 New helper — `recentHumanOrders` + +Added to `src/engine/ai/lookahead.ts`, exported (so it is unit-testable in isolation): + +```ts +/** How many recent rounds the human projection looks back over. */ +const LOOKAHEAD_HISTORY_WINDOW = 5; + +/** + * Project a human opponent for Hard-mode lookahead: the orders from the most + * recent round, within the last LOOKAHEAD_HISTORY_WINDOW rounds, that has a + * non-empty order list for `leaderId`. + * + * A recent pass (`[]`) no longer reads as passivity — the projection walks + * back to the human's last real move. A non-empty round older than the window + * is treated as stale and ignored (the human is projected as passive `[]`). + */ +export function recentHumanOrders( + orderHistory: Partial>[], + leaderId: LeaderId, +): Order[] { + const stop = Math.max(0, orderHistory.length - LOOKAHEAD_HISTORY_WINDOW); + for (let r = orderHistory.length - 1; r >= stop; r--) { + const orders = orderHistory[r]?.[leaderId]; + if (orders && orders.length > 0) return orders; + } + return []; +} +``` + +### 3.2 Call-site change + +In `bestTargetByLookahead`, the human branch changes from the inline last-round +read to a call to the helper: + +```ts +if (isHuman(id)) { + ordersByLeader[id] = recentHumanOrders(state.orderHistory, id); + continue; +} +``` + +No other code changes. `bestTargetByLookahead` is the only site that projects a +human from history (verified: `orderHistory` is read for projection only here; +`ai/index.ts` invokes `bestTargetByLookahead` once, in hard mode only). + +## 4. Behaviour + +| History (most recent last) | Projected orders | +|---|---| +| Human acted last round | That round — identical to Approach A. | +| Human passed last round, acted 1–2 rounds before | The most recent acted round. | +| Human passed all of the last 5 rounds | `[]` — passive (same as Approach A). | +| No history (round 1) | `[]` — passive (same as Approach A). | +| Human's only non-empty round is older than 5 rounds back | `[]` — treated as stale. | + +The change is a strict superset of Approach A's correct cases: whenever the +human acted last round, behaviour is unchanged. The only new behaviour is +recovering a recent move when the last round was a pass. + +### 4.1 Staleness safety net (unchanged) + +`simulateOneRound` already re-validates projected orders and gracefully drops +any the leader can no longer afford — e.g. a launch order from 2 rounds ago when +the human's stockpile is now empty collapses to passive within the simulation. +Projecting a slightly older round therefore introduces no new staleness hazard. + +## 5. Testing + +### 5.1 Unit tests — `recentHumanOrders` (`tests/engine/ai/lookahead.test.ts`) + +- Empty history → `[]`. +- Last round non-empty → that round's orders. +- Last round `[]`, prior round non-empty → the prior round's orders. +- All rounds within the window empty/`[]` → `[]`. +- A non-empty round positioned one round *outside* the window → `[]` (not picked). +- History entry `undefined` for the leader → skipped (treated as empty). + +### 5.2 Behavioural test — `bestTargetByLookahead` + +One test: a human who passed last round but launched at the Hard-mode viewer two +rounds ago is projected as launching — verified by the viewer's target choice +reflecting the projected retaliation (contrast: under Approach A the same setup +projects the human as passive). + +### 5.3 Regression + +Full suite + `tsc --noEmit` stay green. Determinism tests are unaffected (no RNG +change). Existing `lookahead.test.ts` tests hold — Approach A behaviour is +preserved whenever the human acted last round. + +## 6. Out of scope + +- Approach C — personality-fit modelling (classify the human as the closest AI + personality and project via that planner). Deferred. +- Any change to AI-opponent projection (`dispatch`), `scoreState`, or + `simulateOneRound`. +- Window size as a runtime/config setting — it is a compile-time constant. +- Multi-round (depth > 1) lookahead. + +## 7. Risks + +| Risk | Mitigation | +|------|------------| +| An older projection (up to 5 rounds back) is itself a pivot the human has since abandoned. | Accepted: still strictly better than projecting a pass as passivity; the window caps staleness at 5 rounds; `simulateOneRound` drops now-unaffordable orders. | +| `recentHumanOrders` returns a reference into `state.orderHistory`. | `bestTargetByLookahead` / `simulateOneRound` treat order lists as read-only (existing contract — Approach A already passed the same reference). No mutation; no copy needed. | From f11ed4a7577a45df6e0d18c6bfee68c811dbc33f Mon Sep 17 00:00:00 2001 From: gethi Date: Tue, 19 May 2026 20:37:45 +0100 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20P4c=20slice=203=20implementation=20?= =?UTF-8?q?plan=20=E2=80=94=20lookahead=20sliding-window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ...hase-4c-slice3-lookahead-sliding-window.md | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-phase-4c-slice3-lookahead-sliding-window.md diff --git a/docs/superpowers/plans/2026-05-19-phase-4c-slice3-lookahead-sliding-window.md b/docs/superpowers/plans/2026-05-19-phase-4c-slice3-lookahead-sliding-window.md new file mode 100644 index 0000000..b543e98 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-phase-4c-slice3-lookahead-sliding-window.md @@ -0,0 +1,356 @@ +# Phase 4c slice 3 — Lookahead sliding-window human projection — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade Hard-mode lookahead's human-opponent projection from last-round replay (Approach A) to a sliding-window "most recent non-empty round" projection (Approach B). + +**Architecture:** One new pure helper, `recentHumanOrders`, in `src/engine/ai/lookahead.ts`. The human branch of `bestTargetByLookahead` calls it instead of the inline last-round read. Window = 5 rounds. Engine-only, no RNG, determinism preserved. + +**Tech Stack:** TypeScript 5.4 strict, Vitest 1.6. Deterministic seeded RNG threaded through `state.rngState` (not touched by this slice). + +**Spec:** `docs/superpowers/specs/2026-05-19-phase-4c-slice3-lookahead-sliding-window-design.md` + +--- + +## Background the implementer must know + +- **Where lookahead lives:** `src/engine/ai/lookahead.ts`. `bestTargetByLookahead(state, viewer, baseline, candidates, launch, opponentPlanner)` simulates one round per candidate target and returns the highest-scoring target. It is called once, from `src/engine/ai/index.ts`, in `hard` mode only, to re-target a launch. +- **The current human projection (Approach A)** — inside `bestTargetByLookahead`, in the loop over `state.cast`: + ```ts + if (isHuman(id)) { + // Project the human as repeating last round's orders. Falls back to [] + // for the first round (no history yet) or if they passed last round. + // simulateOneRound re-validates and gracefully drops invalid orders + // (e.g., a launch order from last round when their stockpile is now empty). + const lastRound = state.orderHistory[state.orderHistory.length - 1]; + ordersByLeader[id] = lastRound?.[id] ?? []; + continue; + } + ``` + This is the only block this slice changes. AI opponents (the `else` branch, `opponentPlanner`) are untouched. +- **`orderHistory` shape:** `GameState.orderHistory` is `Partial>[]` — one entry per resolved round, each mapping leader → that round's submitted orders. Index `0` is the oldest round; the last index is the most recent. A leader that passed a round has `[]`; a leader absent from a round's entry is `undefined`. +- **The problem:** if the human passed last round, Approach A projects them as passive even after rounds of launching. Approach B walks back through a 5-round window to the human's most recent non-empty round. +- **Window = 5** — a deliberate widening of the phase-2.5 design's named value of 3 (so a human who builds quietly for several rounds before striking still projects their last real move). +- **Staleness safety net (unchanged):** `simulateOneRound` re-validates projected orders and drops any the leader can no longer afford — a launch from an earlier round the human can't arm now collapses to passive within the simulation. +- **`isHuman`, `Order`, `LeaderId`** are already imported in `lookahead.ts` (`isHuman` from `../state`; `Order`, `LeaderId` from `../types`). No new imports are needed in the source file. + +--- + +## File Structure + +**Modified — engine:** +- `src/engine/ai/lookahead.ts` — add the `LOOKAHEAD_HISTORY_WINDOW` constant and the `recentHumanOrders` helper; swap the human branch of `bestTargetByLookahead` to call it. + +**Modified — tests:** +- `tests/engine/ai/lookahead.test.ts` — add a `describe('recentHumanOrders')` block (7 unit tests) and a `describe('bestTargetByLookahead — sliding-window human projection (P4c.3)')` block (1 property test). + +No other files change. + +--- + +## Task 1: `recentHumanOrders` helper + +The pure sliding-window helper. Walks back from the most recent round, within a 5-round window, and returns the first round with non-empty orders for the leader. + +**Confidence: 97%** — a pure function with no dependencies beyond the `Order`/`LeaderId` types; full code and tests given. Residual risk: none material. + +**Files:** +- Modify: `src/engine/ai/lookahead.ts` +- Modify: `tests/engine/ai/lookahead.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `tests/engine/ai/lookahead.test.ts`: + +First, update the two existing import lines so `recentHumanOrders` and `LeaderId` are available. The file currently has: + +```ts +import { simulateOneRound, scoreState, bestTargetByLookahead } from '../../../src/engine/ai/lookahead'; +``` +```ts +import type { Order } from '../../../src/engine/types'; +``` + +Change them to: + +```ts +import { simulateOneRound, scoreState, bestTargetByLookahead, recentHumanOrders } from '../../../src/engine/ai/lookahead'; +``` +```ts +import type { LeaderId, Order } from '../../../src/engine/types'; +``` + +Then APPEND this new `describe` block to the END of the file: + +```ts +describe('recentHumanOrders', () => { + const build: Order = { kind: 'build-factory' }; + const launch: Order = { + kind: 'launch', target: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people', + }; + + it('returns [] for empty history', () => { + expect(recentHumanOrders([], 'player1')).toEqual([]); + }); + + it('returns the last round when it is non-empty', () => { + const history: Partial>[] = [ + { player1: [build] }, + { player1: [launch] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); + + it('walks back past an empty last round to the most recent non-empty round', () => { + const history: Partial>[] = [ + { player1: [launch] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); + + it('returns [] when every round in the window is empty', () => { + const history: Partial>[] = [ + { player1: [] }, + { player1: [] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([]); + }); + + it('picks a non-empty round at the far edge of the 5-round window', () => { + // Length 5: index 0 is exactly 5 rounds back (the last in the window). + const history: Partial>[] = [ + { player1: [launch] }, // index 0 — far edge of the window + { player1: [] }, + { player1: [] }, + { player1: [] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); + + it('ignores a non-empty round one position outside the 5-round window', () => { + // Length 6: index 0 is 6 rounds back — the scan covers indices 5..1 only. + const history: Partial>[] = [ + { player1: [launch] }, // index 0 — outside the window + { player1: [] }, + { player1: [] }, + { player1: [] }, + { player1: [] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([]); + }); + + it('skips a round with no entry for the leader (undefined)', () => { + const history: Partial>[] = [ + { player1: [launch] }, + { carnage: [build] }, // no player1 key → undefined → skipped + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/lookahead.test.ts` +Expected: FAIL — the build/typecheck error is `"recentHumanOrders" is not exported by ".../lookahead.ts"` (the symbol does not exist yet). + +- [ ] **Step 3: Implement `recentHumanOrders`** + +In `src/engine/ai/lookahead.ts`, add the constant and the helper **directly above the `export function bestTargetByLookahead` declaration** (after `scoreState`). Insert exactly: + +```ts +/** How many recent rounds the human projection looks back over. */ +const LOOKAHEAD_HISTORY_WINDOW = 5; + +/** + * Project a human opponent for Hard-mode lookahead: the orders from the most + * recent round, within the last LOOKAHEAD_HISTORY_WINDOW rounds, that has a + * non-empty order list for `leaderId`. + * + * A recent pass (`[]`) no longer reads as passivity — the projection walks + * back to the human's last real move. A non-empty round older than the window + * is treated as stale and ignored (the human is projected as passive `[]`). + */ +export function recentHumanOrders( + orderHistory: Partial>[], + leaderId: LeaderId, +): Order[] { + const stop = Math.max(0, orderHistory.length - LOOKAHEAD_HISTORY_WINDOW); + for (let r = orderHistory.length - 1; r >= stop; r--) { + const orders = orderHistory[r]?.[leaderId]; + if (orders && orders.length > 0) return orders; + } + return []; +} +``` + +`Order` and `LeaderId` are already imported at the top of `lookahead.ts` (`import type { DeliveryType, GameState, LeaderId, Order, TargetType, Yield } from '../types';`) — do not add imports. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/lookahead.test.ts` +Expected: PASS — the 7 new `recentHumanOrders` tests plus every pre-existing test in the file (`simulateOneRound`, `scoreState`, `bestTargetByLookahead`). The call site still uses Approach A at this point; the existing tests are unaffected. + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc --noEmit` +Expected: clean, no output. + +- [ ] **Step 6: Commit** + +```bash +git add src/engine/ai/lookahead.ts tests/engine/ai/lookahead.test.ts +git commit -m "engine: add recentHumanOrders — sliding-window human projection helper" +``` + +--- + +## Task 2: Wire `recentHumanOrders` into `bestTargetByLookahead` + +Swap the human branch of `bestTargetByLookahead` from the inline last-round read to the helper, and add a property test that the window walks back past a recent pass. + +**Confidence: 93%** — the call-site change is a verbatim three-line swap. The property test is a deterministic equality (guaranteed to hold under Approach B by construction); the 7 unit tests from Task 1 are the rigorous coverage of the windowing logic. Residual risk: the property test could be vacuous for some seeds (the human's projected orders may not change the target choice) — it remains a correct regression guard regardless, and the unit tests carry the core coverage. + +**Files:** +- Modify: `src/engine/ai/lookahead.ts` +- Modify: `tests/engine/ai/lookahead.test.ts` + +- [ ] **Step 1: Swap the call site in `bestTargetByLookahead`** + +In `src/engine/ai/lookahead.ts`, inside `bestTargetByLookahead`, replace the human branch. Find this block: + +```ts + if (isHuman(id)) { + // Project the human as repeating last round's orders. Falls back to [] + // for the first round (no history yet) or if they passed last round. + // simulateOneRound re-validates and gracefully drops invalid orders + // (e.g., a launch order from last round when their stockpile is now empty). + const lastRound = state.orderHistory[state.orderHistory.length - 1]; + ordersByLeader[id] = lastRound?.[id] ?? []; + continue; + } +``` + +Replace it with: + +```ts + if (isHuman(id)) { + // Project the human by their most recent non-empty round within a + // sliding window (Approach B — see recentHumanOrders). A recent pass no + // longer reads as passivity. simulateOneRound re-validates and drops + // orders the human can no longer afford (e.g. a launch from an earlier + // round when their stockpile is now empty). + ordersByLeader[id] = recentHumanOrders(state.orderHistory, id); + continue; + } +``` + +This is the only source change in this task. + +- [ ] **Step 2: Add the property test** + +APPEND this `describe` block to the END of `tests/engine/ai/lookahead.test.ts` (after the `recentHumanOrders` block from Task 1). It uses `initialState` and `dispatch`, both already imported at the top of the file. + +```ts +describe('bestTargetByLookahead — sliding-window human projection (P4c.3)', () => { + it('projects a human who passed last round by their most recent acted round', () => { + // A Hard-mode viewer (carnage) chooses between two candidate targets. + // player1 (human) launched at carnage, and the projection must reach that + // round whether it is the last round or one round before a pass. + const base = initialState({ + cast: ['carnage', 'player1', 'chump'], + difficulty: 'hard', + seed: 'lh-window', + }); + base.leaders.carnage.stockpile.missiles = 1; + base.leaders.carnage.stockpile.warheadsSmall = 1; + // player1 needs the stock so simulateOneRound keeps the projected launch. + base.leaders.player1.stockpile.missiles = 1; + base.leaders.player1.stockpile.warheadsSmall = 1; + + const humanLaunch: Order = { + kind: 'launch', target: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people', + }; + + // History A: player1 launched last round. + const launchedLast = structuredClone(base); + launchedLast.orderHistory = [{ player1: [humanLaunch] }]; + + // History B: player1 launched, then passed last round. + const passedLast = structuredClone(base); + passedLast.orderHistory = [{ player1: [humanLaunch] }, { player1: [] }]; + + const spec = { delivery: 'missile' as const, warhead: 'small' as const, targetType: 'people' as const }; + const candidates: LeaderId[] = ['player1', 'chump']; + + const tLaunchedLast = bestTargetByLookahead(launchedLast, 'carnage', [], candidates, spec, dispatch); + const tPassedLast = bestTargetByLookahead(passedLast, 'carnage', [], candidates, spec, dispatch); + + // Approach B walks back past the empty last round to the same launch round, + // so both histories project player1 identically → identical target choice. + expect(tPassedLast).toBe(tLaunchedLast); + // Sanity: the result is one of the candidates. + expect(candidates).toContain(tPassedLast); + }); +}); +``` + +- [ ] **Step 3: Run the lookahead tests** + +Run: `npx vitest run tests/engine/ai/lookahead.test.ts` +Expected: PASS — the new property test, the 7 `recentHumanOrders` tests, and every pre-existing test. The pre-existing `bestTargetByLookahead` tests that involve a human (e.g. the mixed-cast-with-history test) still pass: when the human acted last round, Approach B projects exactly what Approach A did. + +- [ ] **Step 4: Run the full suite + typecheck** + +Run: `npm test -- --run` +Expected: PASS — all green. (Slow, ~40s+; let the ai-duel and determinism tests finish.) + +Run: `npx tsc --noEmit` +Expected: clean, no output. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/ai/lookahead.ts tests/engine/ai/lookahead.test.ts +git commit -m "engine: lookahead projects humans via sliding-window history" +``` + +--- + +## Final steps (after all tasks) + +1. Dispatch a final branch-wide code review covering both commits. +2. Run `npm test -- --run` and `npx tsc --noEmit` once more — both must be green/clean. +3. Use `superpowers:finishing-a-development-branch`: push + PR + babysit BugBot + merge + main pull (the standing flow for this project). The branch `feat/p4c-slice3-lookahead-sliding-window` already exists and carries the design-spec commit. + +--- + +## Confidence summary + +| Task | Confidence | Note | +|------|-----------|------| +| T1 — `recentHumanOrders` helper | 97% | pure fn, full code + 7 tests given; types verified present in `lookahead.ts` | +| T2 — wire into `bestTargetByLookahead` | 93% | verbatim 3-line call-site swap; property test is a deterministic equality, unit tests carry core coverage | + +No task is below 90%. The one soft spot — whether the T2 property test is vacuous for the chosen seed — does not lower correctness: the test is a valid Approach-B equality guard regardless, and Task 1's 7 unit tests rigorously cover the windowing logic independently of the lookahead integration. + +--- + +## Self-review + +**Spec coverage:** +- §3.1 `recentHumanOrders` helper + `LOOKAHEAD_HISTORY_WINDOW = 5` → Task 1. +- §3.2 call-site change in `bestTargetByLookahead` → Task 2 Step 1. +- §4 behaviour (last round acted / passed-then-walk-back / all-empty / stale-outside-window) → covered by Task 1's 7 unit tests (last-round-non-empty, walk-back, all-empty, far-edge-picked, outside-window-ignored, undefined-skipped, empty-history). +- §5.1 unit tests → Task 1 Step 1 (the spec lists 6; the plan adds a 7th — "far edge of the window" — so the window size itself is pinned from both sides). +- §5.2 behavioural test → Task 2 Step 2 (realised as a deterministic Approach-B equality property, per the spec's own minor/accepted note that this is a test-construction task). +- §5.3 regression → Task 2 Step 4 (full suite + tsc); existing `lookahead.test.ts` tests are explicitly expected to still pass. +- §6 out of scope (Approach C, `dispatch`/`scoreState`/`simulateOneRound`, runtime window config, multi-round lookahead) → no task touches them. + +**Placeholder scan:** No TBD/TODO. Every code step has the exact code. Every command has its expected output. + +**Type consistency:** `recentHumanOrders(orderHistory: Partial>[], leaderId: LeaderId): Order[]` — the signature in Task 1 Step 3 matches the call in Task 2 Step 1 (`recentHumanOrders(state.orderHistory, id)`, where `state.orderHistory` is typed `Partial>[]` and `id: LeaderId`) and the test calls in Task 1 Step 1. `LOOKAHEAD_HISTORY_WINDOW` is defined once (Task 1) and read only inside the helper. From b3d7690b05dd4b7e30c1491c1c29a294b3016edb Mon Sep 17 00:00:00 2001 From: gethi Date: Tue, 19 May 2026 20:41:07 +0100 Subject: [PATCH 3/4] =?UTF-8?q?engine:=20add=20recentHumanOrders=20?= =?UTF-8?q?=E2=80=94=20sliding-window=20human=20projection=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/lookahead.ts | 24 ++++++++++ tests/engine/ai/lookahead.test.ts | 73 ++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/engine/ai/lookahead.ts b/src/engine/ai/lookahead.ts index 1c28106..5d9aee8 100644 --- a/src/engine/ai/lookahead.ts +++ b/src/engine/ai/lookahead.ts @@ -62,6 +62,30 @@ export function scoreState(state: GameState, viewer: LeaderId): number { return me.population - maxOther; } +/** How many recent rounds the human projection looks back over. */ +const LOOKAHEAD_HISTORY_WINDOW = 5; + +/** + * Project a human opponent for Hard-mode lookahead: the orders from the most + * recent round, within the last LOOKAHEAD_HISTORY_WINDOW rounds, that has a + * non-empty order list for `leaderId`. + * + * A recent pass (`[]`) no longer reads as passivity — the projection walks + * back to the human's last real move. A non-empty round older than the window + * is treated as stale and ignored (the human is projected as passive `[]`). + */ +export function recentHumanOrders( + orderHistory: Partial>[], + leaderId: LeaderId, +): Order[] { + const stop = Math.max(0, orderHistory.length - LOOKAHEAD_HISTORY_WINDOW); + for (let r = orderHistory.length - 1; r >= stop; r--) { + const orders = orderHistory[r]?.[leaderId]; + if (orders && orders.length > 0) return orders; + } + return []; +} + /** * Pick the candidate launch target whose projected post-round state scores * highest from `viewer`'s perspective. diff --git a/tests/engine/ai/lookahead.test.ts b/tests/engine/ai/lookahead.test.ts index a427786..08829d0 100644 --- a/tests/engine/ai/lookahead.test.ts +++ b/tests/engine/ai/lookahead.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { simulateOneRound, scoreState, bestTargetByLookahead } from '../../../src/engine/ai/lookahead'; +import { simulateOneRound, scoreState, bestTargetByLookahead, recentHumanOrders } from '../../../src/engine/ai/lookahead'; import { dispatch } from '../../../src/engine/ai/dispatch'; import { planAi } from '../../../src/engine/ai'; import { initialState } from '../../../src/engine/state'; -import type { Order } from '../../../src/engine/types'; +import type { LeaderId, Order } from '../../../src/engine/types'; describe('simulateOneRound', () => { it('runs one round forward without mutating the input state', () => { @@ -118,3 +118,72 @@ describe('bestTargetByLookahead', () => { expect(() => planAi(s, 'chump')).not.toThrow(); }); }); + +describe('recentHumanOrders', () => { + const build: Order = { kind: 'build-factory' }; + const launch: Order = { + kind: 'launch', target: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people', + }; + + it('returns [] for empty history', () => { + expect(recentHumanOrders([], 'player1')).toEqual([]); + }); + + it('returns the last round when it is non-empty', () => { + const history: Partial>[] = [ + { player1: [build] }, + { player1: [launch] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); + + it('walks back past an empty last round to the most recent non-empty round', () => { + const history: Partial>[] = [ + { player1: [launch] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); + + it('returns [] when every round in the window is empty', () => { + const history: Partial>[] = [ + { player1: [] }, + { player1: [] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([]); + }); + + it('picks a non-empty round at the far edge of the 5-round window', () => { + // Length 5: index 0 is exactly 5 rounds back (the last in the window). + const history: Partial>[] = [ + { player1: [launch] }, // index 0 — far edge of the window + { player1: [] }, + { player1: [] }, + { player1: [] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); + + it('ignores a non-empty round one position outside the 5-round window', () => { + // Length 6: index 0 is 6 rounds back — the scan covers indices 5..1 only. + const history: Partial>[] = [ + { player1: [launch] }, // index 0 — outside the window + { player1: [] }, + { player1: [] }, + { player1: [] }, + { player1: [] }, + { player1: [] }, + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([]); + }); + + it('skips a round with no entry for the leader (undefined)', () => { + const history: Partial>[] = [ + { player1: [launch] }, + { carnage: [build] }, // no player1 key → undefined → skipped + ]; + expect(recentHumanOrders(history, 'player1')).toEqual([launch]); + }); +}); From 4dad583198c2a5681e866ebd116ed47f1342f91b Mon Sep 17 00:00:00 2001 From: gethi Date: Tue, 19 May 2026 20:46:16 +0100 Subject: [PATCH 4/4] engine: lookahead projects humans via sliding-window history --- src/engine/ai/lookahead.ts | 12 ++--- src/engine/resolution.ts | 7 +-- tests/engine/ai/lookahead.test.ts | 73 +++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/engine/ai/lookahead.ts b/src/engine/ai/lookahead.ts index 5d9aee8..3a6dcec 100644 --- a/src/engine/ai/lookahead.ts +++ b/src/engine/ai/lookahead.ts @@ -126,12 +126,12 @@ export function bestTargetByLookahead( const opp = state.leaders[id]; if (!opp || !opp.alive) continue; if (isHuman(id)) { - // Project the human as repeating last round's orders. Falls back to [] - // for the first round (no history yet) or if they passed last round. - // simulateOneRound re-validates and gracefully drops invalid orders - // (e.g., a launch order from last round when their stockpile is now empty). - const lastRound = state.orderHistory[state.orderHistory.length - 1]; - ordersByLeader[id] = lastRound?.[id] ?? []; + // Project the human by their most recent non-empty round within a + // sliding window (Approach B — see recentHumanOrders). A recent pass no + // longer reads as passivity. simulateOneRound re-validates and drops + // orders the human can no longer afford (e.g. a launch from an earlier + // round when their stockpile is now empty). + ordersByLeader[id] = recentHumanOrders(state.orderHistory, id); continue; } ordersByLeader[id] = opponentPlanner(state, id); diff --git a/src/engine/resolution.ts b/src/engine/resolution.ts index 27ecc7b..e2a0d28 100644 --- a/src/engine/resolution.ts +++ b/src/engine/resolution.ts @@ -302,9 +302,10 @@ export function resolveRound(state: GameState): ResolveResult { } // Persist this round's orders for next round's planAi (Hard-mode lookahead - // reads orderHistory[length-1][humanId] for human opponents; AI opponents - // are still re-planned via dispatch). Read from the original `state` parameter - // to match the existing pattern in this function (see line 119-121). + // reads the most recent non-empty round within the last few rounds for human + // opponents — see recentHumanOrders; AI opponents are still re-planned via + // dispatch). Read from the original `state` parameter to match the existing + // pattern in this function (see line 119-121). const thisRound: Partial> = {}; for (const id of s.cast) { const sealed = state.pendingOrders[id]; diff --git a/tests/engine/ai/lookahead.test.ts b/tests/engine/ai/lookahead.test.ts index 08829d0..faac1cd 100644 --- a/tests/engine/ai/lookahead.test.ts +++ b/tests/engine/ai/lookahead.test.ts @@ -187,3 +187,76 @@ describe('recentHumanOrders', () => { expect(recentHumanOrders(history, 'player1')).toEqual([launch]); }); }); + +describe('bestTargetByLookahead — sliding-window human projection (P4c.3)', () => { + it('projects a human who passed last round by their most recent acted round', () => { + // Scenario: chump leads by gap G=8 (pop 30) over player1 (pop 22). + // small-dmg=2, large-dmg=15; constraint: small-dmg(2) < G(8) < large-dmg(15). + // + // With no human projection (broken), carnage's small hit on chump leaves + // chump at 28 vs player1 at 22 → max_other=28 when attacking chump, + // max_other=30 when attacking player1 → carnage picks chump. + // + // With human projected to launch large at chump, chump falls to 15 before + // carnage acts. Attacking chump: max_other=22 (player1). Attacking player1: + // max_other=max(15, 20)=20. Lower max_other beats chump → carnage flips to + // player1. This is the flip that proves the human projection is load-bearing. + function makeBase() { + const s = initialState({ cast: ['carnage', 'player1', 'chump'], difficulty: 'hard', seed: 'lh-window' }); + // chump leads by G=8; large-dmg(15) > G > small-dmg(2) + s.leaders.chump.population = 30; + s.leaders.player1.population = 22; + // No deployed defences → no interception + s.leaders.chump.deployedShields = 0; + s.leaders.chump.deployedAA = 0; + s.leaders.player1.deployedShields = 0; + s.leaders.player1.deployedAA = 0; + s.leaders.carnage.deployedShields = 0; + s.leaders.carnage.deployedAA = 0; + // Disarm chump so it cannot launch and confound the sim + s.leaders.chump.stockpile.missiles = 0; + s.leaders.chump.stockpile.bombers = 0; + s.leaders.chump.stockpile.warheadsSmall = 0; + s.leaders.chump.stockpile.warheadsMedium = 0; + s.leaders.chump.stockpile.warheadsLarge = 0; + // player1 must hold the stockpile for the projected large launch to survive re-validation + s.leaders.player1.stockpile.missiles = 1; + s.leaders.player1.stockpile.warheadsLarge = 1; + // carnage fires the spec (small) launch + s.leaders.carnage.stockpile.missiles = 1; + s.leaders.carnage.stockpile.warheadsSmall = 1; + return s; + } + + const humanLaunch: Order = { + kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'large', targetType: 'people', + }; + const spec = { delivery: 'missile' as const, warhead: 'small' as const, targetType: 'people' as const }; + const candidates: LeaderId[] = ['player1', 'chump']; + + // History A: human launched last round. + const launchedLast = makeBase(); + launchedLast.orderHistory = [{ player1: [humanLaunch] }]; + + // History B: human launched, then passed last round (Approach B must walk back). + const passedLast = makeBase(); + passedLast.orderHistory = [{ player1: [humanLaunch] }, { player1: [] }]; + + // History C: human never acted — what a BROKEN recentHumanOrders would + // effectively produce for passedLast. Used only to prove non-vacuity. + const noHistory = makeBase(); + noHistory.orderHistory = []; + + const tLaunchedLast = bestTargetByLookahead(launchedLast, 'carnage', [], candidates, spec, dispatch); + const tPassedLast = bestTargetByLookahead(passedLast, 'carnage', [], candidates, spec, dispatch); + const tNoHistory = bestTargetByLookahead(noHistory, 'carnage', [], candidates, spec, dispatch); + + // Non-vacuity guard: the human's projected launch MUST change the target — + // otherwise this test cannot detect a broken projection. + expect(tLaunchedLast).not.toBe(tNoHistory); + // Approach B walks back past the empty last round to the same launch round, + // so passedLast projects identically to launchedLast. + expect(tPassedLast).toBe(tLaunchedLast); + expect(candidates).toContain(tPassedLast); + }); +});