Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Slice 1 — Characters & difficulty Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Netanyahoo becomes a big-but-rare warmonger, Khameneverhere's grudge goes large, and `easy` gets light non-lethal noise instead of chaotic randomness — so games play differently, large nukes become a character trait, and easy stops being harsher than normal.

**Architecture:** Three engine edits (two build plans + the easy randomization knob), each pure/deterministic, then a balance-verification pass. No win-condition/scoring/UI change.

**Tech Stack:** TypeScript engine (pure fns, seeded RNG), vitest, `vite-node`.

**Spec:** `docs/superpowers/specs/2026-07-26-fun-while-dying-slice1-characters-difficulty-design.md`

## Global Constraints

- Every commit: `npm run typecheck` clean AND `npm run test:run` green.
- Tests: unconditional assertions only.
- Rebalance via CONSTANTS (build targets, easy pct), never by loosening a test threshold.
- Producers-before-consumers order preserved (`[...build.orders, ...salvo.orders]`).
- No win-condition/`checkOutcome`/`scoreState`/UI change (keeps hard-mode lookahead stable).

## Guardrails (better-memory)

- Planner/build-plan changes shift hard-mode lookahead + dispatcher tests OUTSIDE the edited files — run the FULL engine suite, update coincidentally-pinned expectations (mem: changing scoring/planner inputs silently shifts lookahead tests).
- `buildToward` walks the plan in order and stops when AP runs out — front-load the tier you want reachable (large), or it never builds (the exact reason large nukes never appeared).
- Every commit independently typechecks; if `applyRandomization` gains a param, update its call site same commit.

## File Structure

```
src/engine/ai/netanyahoo.ts (modify: NETANYAHOO_BUILD_PLAN → big-but-rare)
src/engine/ai/khameneverhere.ts (modify: KHAMENEVERHERE_BUILD_PLAN → add large tier)
src/engine/ai/index.ts (modify: DIFFICULTY_RANDOM_PCT.easy; applyRandomization diff-aware candidate pool + threaded diff)
tests/engine/ai/netanyahoo.test.ts (update to new build plan)
tests/engine/ai/khameneverhere.test.ts (update to new build plan)
tests/engine/ai/index.test.ts OR a new easy-randomization test (candidate-pool + pct)
tests/engine/ai/lookahead.test.ts, dispatcher.test.ts (update only if coincidentally pinned)
```

---

### Task 1: Netanyahoo — big-but-rare warmonger

**Files:** Modify `src/engine/ai/netanyahoo.ts`; Test `tests/engine/ai/netanyahoo.test.ts`.

- [ ] **Step 1: Update tests first** — netanyahoo.test.ts: assert the planner, given enough AP/rounds, emits `build-warhead` with `yield:'large'` and NO `yield:'small'`; still launches first; still respects the Chump-exception. Keep existing target-ranking assertions. Unconditional asserts. (Read the current test to reuse its state-builders.)
- [ ] **Step 2: Run — expect FAIL** (`npx vitest run tests/engine/ai/netanyahoo.test.ts`).
- [ ] **Step 3: Implement** — replace `NETANYAHOO_BUILD_PLAN`:

```ts
const NETANYAHOO_BUILD_PLAN: BuildPlanEntry[] = [
{ build: { item: 'missile' }, target: 4 },
{ build: { item: 'warhead', yield: 'large' }, target: 3 },
{ build: { item: 'warhead', yield: 'medium' }, target: 2 },
];
```
Update the file's doc comment to describe the big-but-rare doctrine (large-first, no small). Planner body/launch logic unchanged.
- [ ] **Step 4: Run — expect PASS.** Then `npx vitest run tests/engine/ai` (sibling planners/lookahead may shift — note failures for Task 4).

---

### Task 2: Khameneverhere — grudge goes large

**Files:** Modify `src/engine/ai/khameneverhere.ts`; Test `tests/engine/ai/khameneverhere.test.ts`.

- [ ] **Step 1: Update tests first** — assert the planner can emit `build-warhead` `yield:'large'` when AP allows; grudge target ranking unchanged; still launch-first. Unconditional.
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** — replace `KHAMENEVERHERE_BUILD_PLAN`:

```ts
const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [
{ build: { item: 'missile' }, target: 5 },
{ build: { item: 'warhead', yield: 'large' }, target: 2 },
{ build: { item: 'warhead', yield: 'medium' }, target: 2 },
{ build: { item: 'warhead', yield: 'small' }, target: 2 },
];
```
Update the doc comment (grudge now goes large, falls back to medium/small so never disarmed).
- [ ] **Step 4: Run — expect PASS**, then `npx vitest run tests/engine/ai`.

---

### Task 3: Easy — light non-lethal noise

**Files:** Modify `src/engine/ai/index.ts`; Test: new/extended easy-randomization test.

- [ ] **Step 1: Write tests first** — assert: (a) `DIFFICULTY_RANDOM_PCT.easy === 0.12` (normal 0.1, hard 0); (b) the easy candidate pool excludes `build-warhead medium` and `build-warhead large`, while normal's includes them. If the candidate pool is internal, expose it via a small pure helper `randomizationCandidates(diff): Order[]` and test that directly (easy list has no medium/large warhead; normal does). Unconditional asserts.
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement:**
- `DIFFICULTY_RANDOM_PCT.easy` `0.3 → 0.12`.
- Extract the candidate list into `function randomizationCandidates(diff: Difficulty): Order[]`: full 8-item list for non-easy; on `easy`, omit `{build-warhead medium}` and `{build-warhead large}` (keep factory, missile, bomber, small warhead, shield, aa).
- Thread `diff` into `applyRandomization` (add a `diff: Difficulty` param) and build `candidates` from `randomizationCandidates(diff)`. Update the call site (`applyRandomization(state, leaderId, orders, DIFFICULTY_RANDOM_PCT[diff], diff)`).
- [ ] **Step 4: Run — expect PASS**, then `npm run typecheck` (catches the call-site/param wiring).

---

### Task 4: Balance verification & suite green

**Files:** whichever tests the changes shifted (lookahead/dispatcher/balance); no new source unless a rebalance is needed.

- [ ] **Step 1: Full suite** — `npm run test:run`. Update any tests that pinned old planner behaviour coincidentally (lookahead target picks, dispatcher routing) to the new correct behaviour — do NOT weaken assertions, correct them to what the new doctrine legitimately does.
- [ ] **Step 2: Termination gate** — confirm `tests/engine/ai-duel.test.ts` (`unfinished === 0` across 80 seeds) and `integration.test.ts` termination pass. If any game now stalls (Netanyahoo arming too slowly to end a 1v1), the Chump-exception/only-opponent fallback should prevent it; if not, adjust Netanyahoo's build targets (constants) so he can still finish — re-run.
- [ ] **Step 3: Distribution sanity** — read the duel breakdown the test prints. Expectation: no leader degenerately dominates (soft ≤ ~40/80); more pyrrhic/apocalypse is fine (on-theme). If Netanyahoo or Khameneverhere runs away or flatlines, tune build targets / easy pct (constants) and re-run. Record the final breakdown in the report.
- [ ] **Step 4: Typecheck + build** — `npm run typecheck && npm run build` clean.
- [ ] **Step 5: Commit** (single commit for the coupled slice; branch `feat/character-personalities`):

```bash
git add src/engine/ai tests/engine
git commit -m "feat(engine): big-nuke Netanyahoo/Khameneverhere doctrines + easy non-lethal noise"
```

---

## Self-Review Notes

- Spec coverage: §2 → Task 1; §3 → Task 2; §4 → Task 3; §5 balance/tests → Task 4.
- The one cross-file ripple is planner-behaviour → lookahead/dispatcher tests (Task 4 Step 1) and the `applyRandomization` signature (Task 3 Step 3) — both called out.
- No win-condition/scoring/UI change, so hard-mode `scoreState` and the RoundSummary/Winners UI are untouched; large-nuke visibility is now a deliberate build-plan trait, not randomness.
- If TDD reveals `buildToward` still can't reach large at realistic AP for Netanyahoo (economy too thin for a target-3 large tier), reduce the large target or add a factory entry first — flag as DONE_WITH_CONCERNS with the duel breakdown rather than silently under-arming him.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# nuke — "fun while dying" slice 1: characters & difficulty

**Date:** 2026-07-26
**Status:** approved in brainstorming; pending spec review
**Design frame:** Make games play differently and the cast read louder — the variety the playability assessment (`docs/playability/playability-usability-assessment.md`, criteria C3/C6/C7) says the game lacks. Two named leaders get distinct big-nuke/all-in doctrines, and `easy` is reworked so its difficulty is *character*, not chaotic randomness (fixing the data finding that easy is harsher than normal). Slice 1 of 3; slice 2 (newspaper) shipped in PR #16; slice 3 (score-how-you-died) follows.

## 0. Decisions (locked in brainstorming)

- **Netanyahoo → big-but-rare warmonger.** A deliberate big-bomb doctrine: builds toward *large* warheads over volume; fires fewer, huge strikes rather than spamming small.
- **Khameneverhere → grudge big-nuke builder.** His vengeance goes large — build plan reaches large warheads, fired at his top grudge target.
- **Easy → light non-lethal noise.** Keep a little randomness for per-game variety, but never let it manufacture big/concentrated strikes; lower the rate. Fixes the easy-harsher-than-normal paradox.
- **Scope: these two leaders + easy only.** Other planners (Chump/Carnage/Mileigh/Burn'em) unchanged this slice.

## 1. Background (why large nukes never appear today)

From the AI-behaviour study: large warheads only ever appear via the easy/normal *randomness knob*, never as strategy (hard fired 0 large across 100 games). Root cause (verified in code): `launchSalvo` fires largest-yield-first, so the constraint is *building*. Only Netanyahoo has a large-warhead build entry and it sits **last** in his plan (`{large, target:2}` after missile/small/medium), so AP is exhausted before `buildToward` reaches it. Khameneverhere has no large entry at all. This slice makes large warheads a **deliberate character trait** for these two by making the large tier reachable.

## 2. Netanyahoo — big-but-rare (`src/engine/ai/netanyahoo.ts`)

Keep the warmonger frame (launch-first uncapped salvo → `launchSalvo` fires largest-first; Chump-exception until provoked, with the only-opponent deadlock fallback; propaganda at Chump). **Change only the build plan** so AP flows to large warheads first:

```ts
const NETANYAHOO_BUILD_PLAN: BuildPlanEntry[] = [
{ build: { item: 'missile' }, target: 4 }, // delivery, first
{ build: { item: 'warhead', yield: 'large' }, target: 3 }, // primary doctrine
{ build: { item: 'warhead', yield: 'medium' }, target: 2 }, // fallback yield
];
```

- Drop the small-warhead tier entirely — Netanyahoo does not do small nukes.
- Large is now reachable (front of the plan after delivery), so over a few rounds he accumulates large warheads and `launchSalvo` fires them. Because large costs 3 AP to build, he arms slowly → **rare but devastating** strikes. This is the doctrine, not a bug.
- `netanyahoo-launch-bonus` (existing `bonusRule`) already rewards his launching — unchanged.

## 3. Khameneverhere — grudge goes large (`src/engine/ai/khameneverhere.ts`)

Keep the grudge frame (top-grudge-first ranking, launch-first uncapped, no diplomacy). Add a large tier, front-loaded enough to be reachable:

```ts
const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [
{ build: { item: 'missile' }, target: 5 },
{ build: { item: 'warhead', yield: 'large' }, target: 2 }, // vengeance goes big
{ build: { item: 'warhead', yield: 'medium' }, target: 2 },
{ build: { item: 'warhead', yield: 'small' }, target: 2 },
];
```

- Large first among warheads → when he can afford it, his grudge strikes land large; when he can't, he falls back to medium/small so he is never disarmed. He stays launch-happy (unlike Netanyahoo's rarer cadence) but now hits harder against whoever wronged him.

## 4. Easy difficulty — light non-lethal noise (`src/engine/ai/index.ts`)

Two changes to `applyRandomization` / its config:
1. **Lower the rate:** `DIFFICULTY_RANDOM_PCT.easy` `0.3 → 0.12`. (Normal stays `0.1`, hard `0`.)
2. **Non-lethal candidate pool on easy:** the random-swap `candidates` list currently includes `build-warhead medium` and `build-warhead large` — the exact source of easy's chaotic big strikes (12.4% of easy launches were large, ~3× normal). Make the candidate pool **difficulty-aware**: on easy, exclude `build-warhead medium` and `build-warhead large` (keep factory, missile, bomber, small warhead, shield, aa). Normal keeps the full pool (its large-warhead rate of 3.8% is acceptable and we don't want to shift normal balance).

Effect: easy still varies game-to-game (12% noise) so no two easy games are identical, but the noise can no longer manufacture big/concentrated strikes → easy stops being deadlier than normal. This is the "difficulty = character, not chaos" fix.

Determinism note: randomization seeds from `state.rngState` (existing "shadow" read), so easy stays reproducible per seed while differing across seeds — unchanged mechanism, just gentler content.

## 5. Balance & tests

- **Hard gate (must stay green):** `tests/engine/ai-duel.test.ts` asserts `unfinished === 0` across 80 seeds (every all-AI game terminates); `tests/engine/integration.test.ts` termination. Big-nuke doctrines accelerate deaths, which helps termination, but Netanyahoo's slower arming must not stall — the existing Chump-exception/only-opponent fallback prevents that; verify with the duel run.
- **Distribution:** the duel test prints a per-leader win breakdown but has **no hard threshold**. Target: no single leader degenerately dominates (soft ≤ ~40/80) and more pyrrhic/apocalypse outcomes are *fine* (on-theme). If a leader runs away or games stall, rebalance the **constants** (build targets, easy pct) — never loosen a test threshold.
- **Per-planner tests:** `tests/engine/ai/netanyahoo.test.ts` and `.../khameneverhere.test.ts` assert build/launch behaviour — update to the new build plans (e.g. Netanyahoo emits `build-warhead large` when AP allows and no longer emits small; Khameneverhere can emit large). Assertions unconditional.
- **Lookahead / dispatcher ripple:** hard-mode lookahead (`lookahead.ts`) uses `dispatch` for opponents and only rewrites launch *targets*; changing build plans can shift `lookahead.test.ts` / `dispatcher.test.ts` expectations. Run the full engine suite and update any coincidentally-pinned expectations (mem: planner/scoring changes shift lookahead tests outside the planned files).
- **Easy randomization test:** add a unit test that the easy candidate pool excludes medium/large warheads (and normal does not), and that `DIFFICULTY_RANDOM_PCT.easy` is the lowered value.

## 6. Out of scope
- Other leaders' personalities (Chump/Carnage/Mileigh/Burn'em) — untouched.
- Newspaper content (slice 2, shipped). Scoring/outcome (slice 3).
- Win-condition / `checkOutcome` / `scoreState` changes — NONE (keeps hard-mode lookahead stable).
- UI — none; this is engine-only.

## 7. Constraints
- Every commit typechecks (`tsc --noEmit`) and passes `npm run test:run`; no guarded assertions.
- Rebalance via constants, not test thresholds.
- Keep producers-before-consumers order in emitted batches (existing `[...build.orders, ...salvo.orders]`).
44 changes: 31 additions & 13 deletions src/engine/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,38 @@ import { isHuman } from '../state';
import { dispatch } from './dispatch';
import { bestTargetByLookahead } from './lookahead';

const DIFFICULTY_RANDOM_PCT: Record<Difficulty, number> = {
easy: 0.3,
export const DIFFICULTY_RANDOM_PCT: Record<Difficulty, number> = {
easy: 0.12,
normal: 0.1,
hard: 0,
};

/**
* Candidate pool for random order-swaps in `applyRandomization`. Non-easy
* difficulties get the full 8-item pool. Easy excludes `build-warhead`
* medium/large — those are the source of easy's chaotic big/concentrated
* strikes (12.4% of easy launches were large, ~3x normal) — so easy's noise
* stays non-lethal: it varies the game without manufacturing big strikes.
*/
export function randomizationCandidates(diff: Difficulty): Order[] {
const full: Order[] = [
{ kind: 'build-factory' },
{ kind: 'build-missile' },
{ kind: 'build-bomber' },
{ kind: 'build-warhead', yield: 'small' },
{ kind: 'build-warhead', yield: 'medium' },
{ kind: 'build-warhead', yield: 'large' },
{ kind: 'build-defence', type: 'shield' },
{ kind: 'build-defence', type: 'aa' },
];
if (diff === 'easy') {
return full.filter(
(o) => !(o.kind === 'build-warhead' && (o.yield === 'medium' || o.yield === 'large')),
);
}
return full;
}

export function planAi(state: GameState, leaderId: LeaderId, difficulty?: Difficulty): Order[] {
const me = state.leaders[leaderId];
// Alive-check first by design: dead leaders (AI or human) return [] — same shape,
Expand Down Expand Up @@ -62,7 +88,7 @@ export function planAi(state: GameState, leaderId: LeaderId, difficulty?: Diffic

// Easy / Normal randomization: replace each order with probability difficulty-pct.
if (DIFFICULTY_RANDOM_PCT[diff] > 0) {
orders = applyRandomization(state, leaderId, orders, DIFFICULTY_RANDOM_PCT[diff]);
orders = applyRandomization(state, leaderId, orders, DIFFICULTY_RANDOM_PCT[diff], diff);
}

return orders;
Expand All @@ -73,20 +99,12 @@ function applyRandomization(
leaderId: LeaderId,
orders: Order[],
pct: number,
diff: Difficulty,
): Order[] {
let rngState = state.rngState;
const me = state.leaders[leaderId];
let remainingBudget = me.ap;
const candidates: Order[] = [
{ kind: 'build-factory' },
{ kind: 'build-missile' },
{ kind: 'build-bomber' },
{ kind: 'build-warhead', yield: 'small' },
{ kind: 'build-warhead', yield: 'medium' },
{ kind: 'build-warhead', yield: 'large' },
{ kind: 'build-defence', type: 'shield' },
{ kind: 'build-defence', type: 'aa' },
];
const candidates = randomizationCandidates(diff);

const out: Order[] = [];
for (const o of orders) {
Expand Down
13 changes: 8 additions & 5 deletions src/engine/ai/khameneverhere.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import { topGrudgeTarget } from './scoring';
import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression';

/**
* Khameneverhere — Grudge personality (P4c.2 rework).
* Khameneverhere — Grudge personality, vengeance-goes-large doctrine (slice 1).
*
* Very aggressive, launch-focused. Ranks targets by grudge (top grudge first,
* then remaining living leaders). Launch-first uncapped salvo, then build the
* remainder toward a raised stockpile with medium warheads. No diplomacy.
* remainder toward a raised stockpile. Large is front-loaded among warheads
* (right after delivery) so his grudge strikes land large when AP allows;
* medium and small are fallback yields so he is never disarmed. No diplomacy.
*/
const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [
{ build: { item: 'missile' }, target: 6 },
{ build: { item: 'warhead', yield: 'small' }, target: 4 },
{ build: { item: 'warhead', yield: 'medium' }, target: 3 },
{ build: { item: 'missile' }, target: 5 },
{ build: { item: 'warhead', yield: 'large' }, target: 2 },
{ build: { item: 'warhead', yield: 'medium' }, target: 2 },
{ build: { item: 'warhead', yield: 'small' }, target: 2 },
];

export function planKhameneverhere(state: GameState, leaderId: LeaderId): Order[] {
Expand Down
Loading
Loading