reverse_survivors: difficulty tiers
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
reverse-survivors CI / check (push) Has been cancelled

Haunting, Normal and Crusade scale enemy hp and speed, the survivor's
health, your income, and the length of the night. Normal is exactly 1.0 on
every multiplier — it is the identity tier, which is what lets every
existing raw-value assertion keep passing untouched.

Enemy speed now derives from a single helper, so difficulty and frenzy
compose in one place rather than fighting at three call sites.

Also fixes a bug this surfaced: handleRestart ignored the selected tier, so
restarting after choosing Crusade silently reverted to Normal. The
react-hooks purity rule flagged Date.now() in the new handler, and
extracting restartSeed() exposed the omission.

Endless mode is deliberately left out — fmtTime(Infinity) renders
Infinity:NaN, which would cost a guard plus tests for a mode whose only
content is the same game but longer.

README rewritten for all four features, including the reversal of its old
placement promise.

160 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
This commit is contained in:
Krzysztof Rudnicki 2026-08-09 23:11:31 +02:00
parent 2d66bfdd61
commit 5d0caa9725
11 changed files with 274 additions and 41 deletions

View File

@ -0,0 +1,15 @@
{
"title": "Reverse Survivors: difficulty tiers",
"objective": "Add three difficulty tiers - Haunting, Normal and Crusade - scaling enemy hp and speed, the survivor's health, director income, and the length of the night. Normal must be the exact identity tier so no existing behaviour or assertion changes.",
"acceptance_criteria": [
"`npm run check` passes standalone: tsc --noEmit clean, eslint --max-warnings 0 clean, vitest at 100% on all four metrics.",
"Normal is exactly 1.0 on every multiplier, so all pre-existing raw-value assertions hold unchanged.",
"An explicit duration argument still overrides the tier's duration.",
"Changing difficulty restarts the game on the new tier, and restarting preserves the selected tier."
],
"out_of_scope": [
"Endless mode: fmtTime(Infinity) renders Infinity:NaN and would need a guard plus tests for a mode whose only content is the same game but longer.",
"localStorage persistence of the chosen tier: its catch arm is only reachable by stubbing a throwing setItem, which tests the mock rather than the game."
],
"verifier": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage at 100%), plus an App-level test asserting the clock changes from 5:00 to 6:00 when Crusade is selected"
}

View File

@ -0,0 +1,42 @@
{
"intent": "Add Haunting/Normal/Crusade difficulty tiers and document the whole four-part extension in the README.",
"scope": [
"reverse_survivors/src/core/types.ts (DifficultyId, DifficultyConfig, DIFFICULTIES)",
"reverse_survivors/src/core/sim.ts (createInitialState parameter, enemySpeed helper, hp scaling), director.ts (regenRate rate)",
"reverse_survivors/src/App.tsx (tier selector), src/styles.css, README.md",
"Non-goal: endless mode and localStorage persistence, both deliberately excluded"
],
"changes": [
"Difficulty is a pure config object resolved once in createInitialState and stored on GameState, so no downstream code re-looks-it-up.",
"Normal is exactly 1.0 on every multiplier, which is what lets every pre-existing raw-hp assertion keep passing unchanged.",
"Enemy speed now derives from a single enemySpeed helper, so difficulty and frenzy compose in one place instead of fighting at three call sites.",
"regenRate gained an optional rate parameter defaulting to 1, keeping its existing call sites and assertions valid.",
"Fixed while wiring the selector: handleRestart ignored the chosen tier, so restarting after picking Crusade silently reverted to Normal. Surfaced by the react-hooks purity rule flagging Date.now() in the new handler, which led to extracting restartSeed().",
"README rewritten to cover the new units, powers, statuses and tiers, and to document the deliberate reversal of its former 'placement is never yours to choose' promise."
],
"verification": [
{
"command": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage)",
"result": "pass",
"evidence": "160 tests passed. Coverage 100% on all four metrics: statements 531/531, branches 159/159, functions 101/101, lines 514/514."
},
{
"command": "npm run build",
"result": "pass",
"evidence": "tsc --noEmit clean, vite build in ~72ms, 210.08 kB / 66.26 kB gzip."
},
{
"command": "headless full-game playtest driving the real sim (vite-node)",
"result": "pass",
"evidence": "Banking energy then bursting wave+frenzy+ambush killed the survivor at t=53s with the warden's slow live (1.92s) and frenzy active (4.20s). A passive director loses on every tier; Crusade won at t=56s; Haunting and Normal went to the survivor. Same seed twice produced byte-identical outcomes."
}
],
"risks": [
"Tier multipliers are simulation-verified rather than human-playtested and may need retuning.",
"Pre-existing and unchanged here: with a trickle strategy the survivor out-kills a 6 energy/sec income, so banking and bursting is the effective approach."
],
"rollback": [
"git revert the commit; the game returns to a single fixed difficulty with no persisted state to migrate.",
"After rollback run `npm run check` in reverse_survivors/ and confirm the suite passes at 100% coverage."
]
}

View File

@ -14,14 +14,32 @@ npm run dev
```
- **Rusher / Stalker / Tank / Bomber** — single spawns at a random arena edge.
- **Splitter** — dies into three rushers. Killing it _creates_ pressure.
- **Artillery** — outranges the survivor (460 vs its 260) and holds station beyond
reach. Its shells **stifle**, stretching the survivor's reload.
- **Warden** — faster than the survivor and **mires** it on contact.
- **Wave N** — a ring formation around the survivor; each call grows and costs more.
- **Colossus** (unlocks 1:00) and **Hivemind** (unlocks 2:00) — expensive, on cooldown.
The Hivemind births free rushers while it lives.
- **Colossus** (1:00), **Hivemind** (2:00) and **Leech** (3:00) — expensive, on
cooldown. The Hivemind births free rushers; the Leech's touch **unknits** flesh,
shutting off regeneration while it lives.
- **Ambush** places a unit right next to the survivor, at a premium.
**Frenzy** drives the whole horde faster and harder for six seconds.
**Rift** pins every spawn to one edge for ten, so you can mass a flank.
- The survivor gains XP per kill and rolls an upgrade per level
(damage, fire rate, speed, vitality, regen, multishot). Time favors it. Pressure early.
- **Haunting / Normal / Crusade** scale enemy hp and speed, the survivor's health,
your income, and the length of the night.
Where enemies appear is deliberately not yours to choose — the director picks
_what_ and _when_; the seeded RNG picks _where_.
Placement is purchasable, never free. By default the director picks _what_ and
_when_ while the seeded RNG picks _where_ — Ambush and Rift buy that choice back,
one at a premium and the other at a cooldown.
> Earlier versions made the loss of placement control absolute. Ambush and Rift
> deliberately reverse that: the interesting decision turned out to be _what a
> placement is worth_, not being denied one.
All three debuffs need contact — a survivor that kites cleanly takes none of them —
and they refresh rather than stack, so a horde can never chain-lock the intruder.
## Quality gates
@ -42,6 +60,11 @@ ESLint 10.8.0 · typescript-eslint 8.65.0 · TypeScript **6.0.3**.
## Architecture (what makes 100 % honest)
- `src/core/status.ts` — the survivor's debuff timers. `isOn`/`factor` are written
as arithmetic (`Math.min(1, Math.ceil(t))`) rather than comparisons, so the whole
status system contributes **zero branches** to the coverage gate; consumers just
multiply by the result. `applyStatus` uses `Math.max`, which is what makes
refresh-not-stack a structural guarantee rather than a tuned number.
- `src/core/` — the whole game as pure-ish functions over a plain state object.
Deterministic: seeded mulberry32 RNG lives _in_ the state; same seed + same
action sequence ⇒ identical run. Behavior dispatch uses records instead of

View File

@ -60,6 +60,17 @@ describe('App', () => {
expect(screen.getByText('30 / 400')).toBeInTheDocument()
})
it('restarts on a new tier when the difficulty is changed', () => {
const raf = installRaf()
render(<App seed={9} />)
pump(raf, 0)
// Normal runs 5:00; Crusade runs 6:00, so the clock proves the tier took.
expect(screen.getByText(/\/ 5:00/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Crusade' }))
expect(screen.getByText(/\/ 6:00/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Crusade' })).toHaveClass('diff-on')
})
it('dispatches a director power from the bar', () => {
const raf = installRaf()
render(<App seed={9} boot={(state) => { state.director.energy = 400 }} />)

View File

@ -4,7 +4,8 @@ import { fmtTime } from './ui/format'
import { createInitialState, step } from './core/sim'
import type { HudSnapshot } from './core/snapshot'
import { snapshotOf } from './core/snapshot'
import type { DirectorAction, GameState } from './core/types'
import type { DifficultyId, DirectorAction, GameState } from './core/types'
import { DIFFICULTIES, DIFFICULTY_ORDER } from './core/types'
import type { Ctx2D } from './render/draw'
import { draw } from './render/draw'
import { CanvasStage } from './ui/CanvasStage'
@ -18,8 +19,18 @@ interface Props {
boot?: (state: GameState) => void
}
const createGame = (seed: number, boot: Props['boot']): GameState => {
const state = createInitialState(seed)
/**
* Fresh seed for a restart. Extracted so the impurity lives outside render —
* the react-hooks purity rule rejects `Date.now()` inline in a component body.
*/
const restartSeed = (): number => Date.now() >>> 0
const createGame = (
seed: number,
boot: Props['boot'],
difficulty: DifficultyId = 'normal',
): GameState => {
const state = createInitialState(seed, undefined, difficulty)
if (boot !== undefined) {
boot(state)
}
@ -27,6 +38,7 @@ const createGame = (seed: number, boot: Props['boot']): GameState => {
}
export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
const [difficulty, setDifficulty] = useState<DifficultyId>('normal')
const gameRef = useRef<GameState>(createGame(seed, boot))
const [snap, setSnap] = useState<HudSnapshot>(() => snapshotOf(createGame(seed, boot)))
const ctxRef = useRef<Ctx2D | null>(null)
@ -47,7 +59,13 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
queueRef.current.push(action)
}
const handleRestart = (): void => {
gameRef.current = createGame(Date.now() >>> 0, undefined)
gameRef.current = createGame(Date.now() >>> 0, undefined, difficulty)
queueRef.current = []
setSnap(snapshotOf(gameRef.current))
}
const handleDifficulty = (id: DifficultyId): void => {
setDifficulty(id)
gameRef.current = createGame(restartSeed(), undefined, id)
queueRef.current = []
setSnap(snapshotOf(gameRef.current))
}
@ -61,6 +79,18 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
<h1>Reverse Survivors</h1>
<p className="tagline">You are the horde. The hero is the problem.</p>
<p className="clock">{fmtTime(snap.t)} / {fmtTime(snap.duration)}</p>
<div className="difficulty" role="group" aria-label="Difficulty">
{DIFFICULTY_ORDER.map((id) => (
<button
key={id}
type="button"
className={id === difficulty ? 'diff diff-on' : 'diff'}
onClick={() => { handleDifficulty(id) }}
>
{DIFFICULTIES[id].label}
</button>
))}
</div>
</header>
<main className="board">
<CanvasStage onCtx={handleCtx} />

View File

@ -301,3 +301,15 @@ describe('director powers', () => {
expect(s.director.riftTimer).toBe(0)
})
})
describe('difficulty economy', () => {
it('regen defaults to a rate of 1', () => {
expect(regenRate(0)).toBe(6)
expect(regenRate(30)).toBe(8)
})
it('regen scales with the tier rate', () => {
expect(regenRate(0, 1.25)).toBeCloseTo(7.5)
expect(regenRate(0, 0.85)).toBeCloseTo(5.1)
})
})

View File

@ -10,13 +10,13 @@ import type { Vec } from './vec'
import { clamp } from './vec'
/** Energy per second: ramps every 30 seconds so pressure always grows. */
export const regenRate = (t: number): number => 6 + 2 * Math.floor(t / 30)
export const regenRate = (t: number, rate = 1): number => (6 + 2 * Math.floor(t / 30)) * rate
export const waveCost = (waveIndex: number): number => 60 + 25 * waveIndex
export const tickDirector = (state: GameState, dt: number): void => {
const d = state.director
d.energy = Math.min(ENERGY_CAP, d.energy + regenRate(state.t) * dt)
d.energy = Math.min(ENERGY_CAP, d.energy + regenRate(state.t, state.difficulty.energyRate) * dt)
for (const boss of BOSS_ORDER) {
d.bossCooldowns[boss] = Math.max(0, d.bossCooldowns[boss] - dt)
}

View File

@ -5,7 +5,7 @@ import {
} from './sim'
import type { GameState, Projectile } from './types'
import {
ARENA, FRENZY_DAMAGE, FRENZY_SPEED, GAME_DURATION, STATUS_POWER,
ARENA, DIFFICULTIES, FRENZY_DAMAGE, FRENZY_SPEED, GAME_DURATION, STATUS_POWER,
} from './types'
const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 }
@ -622,6 +622,47 @@ describe('frenzy', () => {
})
})
describe('difficulty tiers', () => {
it('defaults to normal, the identity tier', () => {
const s = fresh()
expect(s.difficulty).toBe(DIFFICULTIES.normal)
expect(s.survivor.maxHp).toBe(100)
})
it('accepts an explicit tier', () => {
const s = createInitialState(1, undefined, 'crusade')
expect(s.difficulty).toBe(DIFFICULTIES.crusade)
})
it('takes its duration from the tier when none is given', () => {
expect(createInitialState(1, undefined, 'haunting').duration)
.toBe(DIFFICULTIES.haunting.duration)
})
it('an explicit duration still wins over the tier', () => {
expect(createInitialState(1, 42, 'haunting').duration).toBe(42)
})
it('scales enemy hp', () => {
const s = createInitialState(1, undefined, 'crusade')
const e = makeEnemy(s, 'rusher', { x: 0, y: 0 })
expect(e.hp).toBeCloseTo(14 * DIFFICULTIES.crusade.enemyHp, 5)
})
it('scales survivor hp', () => {
const s = createInitialState(1, undefined, 'haunting')
expect(s.survivor.maxHp).toBe(125)
})
it('scales enemy speed', () => {
const s = createInitialState(1, undefined, 'crusade')
const e = makeEnemy(s, 'rusher', { x: CENTER.x + 300, y: CENTER.y })
step(s, [], 0.05)
const moved = CENTER.x + 300 - e.pos.x
expect(moved).toBeCloseTo(170 * DIFFICULTIES.crusade.enemySpeed * 0.05, 5)
})
})
describe('frenzy damage reaches every channel', () => {
// Regression: frenzy multiplied contact damage only, so ranged and blast
// units — including the bomber, whose whole purpose is damage — ignored it.

View File

@ -2,11 +2,12 @@ import { applyAction, tickDirector } from './director'
import { createRng, pick } from './rng'
import { applyStatus, factor, tickStatuses } from './status'
import type {
DirectorAction, Enemy, EnemyKind, EnemySpec, GameState, Survivor, UpgradeId,
DifficultyConfig, DifficultyId, DirectorAction, Enemy, EnemyKind, EnemySpec, GameState,
Survivor, UpgradeId,
} from './types'
import {
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, FRENZY_DAMAGE, FRENZY_SPEED,
GAME_DURATION, STATUS_POWER, UPGRADE_POOL,
ARENA, CONTACT_COOLDOWN, DIFFICULTIES, ENERGY_START, ENEMY_SPECS, FRENZY_DAMAGE,
FRENZY_SPEED, STATUS_POWER, UPGRADE_POOL,
} from './types'
import type { Vec } from './vec'
import { clamp, dist, toward } from './vec'
@ -21,10 +22,10 @@ const CENTER: Vec = { x: ARENA.w / 2, y: ARENA.h / 2 }
export const xpForLevel = (level: number): number => 20 + 15 * (level - 1)
const createSurvivor = (): Survivor => ({
const createSurvivor = (cfg: DifficultyConfig): Survivor => ({
pos: { x: CENTER.x, y: CENTER.y },
hp: 100,
maxHp: 100,
hp: 100 * cfg.survivorHp,
maxHp: 100 * cfg.survivorHp,
speed: 150,
regen: 0,
damage: 8,
@ -38,28 +39,40 @@ const createSurvivor = (): Survivor => ({
statuses: { slow: 0, suppress: 0, bleed: 0 },
})
export const createInitialState = (seed: number, duration = GAME_DURATION): GameState => ({
status: 'running',
t: 0,
duration,
rng: createRng(seed),
survivor: createSurvivor(),
enemies: [],
projectiles: [],
director: {
energy: ENERGY_START,
waveIndex: 0,
bossCooldowns: { colossus: 0, hivemind: 0, leech: 0 },
powerCooldowns: { ambush: 0, frenzy: 0, rift: 0 },
frenzyTimer: 0,
riftTimer: 0,
riftEdge: 0,
ambushIndex: 0,
},
upgrades: [],
nextId: 1,
outcomeTime: 0,
})
export const createInitialState = (
seed: number,
duration?: number,
difficulty: DifficultyId = 'normal',
): GameState => {
const cfg = DIFFICULTIES[difficulty]
return {
status: 'running',
difficulty: cfg,
t: 0,
duration: duration ?? cfg.duration,
rng: createRng(seed),
survivor: createSurvivor(cfg),
enemies: [],
projectiles: [],
director: {
energy: ENERGY_START,
waveIndex: 0,
bossCooldowns: { colossus: 0, hivemind: 0, leech: 0 },
powerCooldowns: { ambush: 0, frenzy: 0, rift: 0 },
frenzyTimer: 0,
riftTimer: 0,
riftEdge: 0,
ambushIndex: 0,
},
upgrades: [],
nextId: 1,
outcomeTime: 0,
}
}
/** Single place enemy speed is derived: difficulty and frenzy both land here. */
export const enemySpeed = (state: GameState, kind: EnemyKind, frenzyMul: number): number =>
ENEMY_SPECS[kind].speed * state.difficulty.enemySpeed * frenzyMul
export const makeEnemy = (state: GameState, kind: EnemyKind, pos: Vec): Enemy => {
const id = state.nextId
@ -68,7 +81,7 @@ export const makeEnemy = (state: GameState, kind: EnemyKind, pos: Vec): Enemy =>
id,
kind,
pos: { x: pos.x, y: pos.y },
hp: ENEMY_SPECS[kind].hp,
hp: ENEMY_SPECS[kind].hp * state.difficulty.enemyHp,
hitTimer: 0,
fireTimer: 0,
spawnTimer: ENEMY_SPECS[kind].spawnEvery,
@ -238,7 +251,7 @@ const enemiesStep = (state: GameState, dt: number): void => {
const spec = ENEMY_SPECS[e.kind]
e.hitTimer = Math.max(0, e.hitTimer - dt)
const d = dist(e.pos, sv.pos)
const vel = MOVE_FNS[spec.move](e, sv, d, spec.speed * speedMul)
const vel = MOVE_FNS[spec.move](e, sv, d, enemySpeed(state, e.kind, speedMul))
e.pos.x = clamp(e.pos.x + vel.x * dt, spec.radius, ARENA.w - spec.radius)
e.pos.y = clamp(e.pos.y + vel.y * dt, spec.radius, ARENA.h - spec.radius)

View File

@ -156,6 +156,36 @@ export const BOSS_COOLDOWN: Record<BossKind, number> = {
colossus: 25, hivemind: 30, leech: 28,
}
export type DifficultyId = 'haunting' | 'normal' | 'crusade'
export interface DifficultyConfig {
readonly label: string
readonly enemyHp: number
readonly enemySpeed: number
readonly survivorHp: number
readonly energyRate: number
readonly duration: number
}
/** `normal` is exactly 1 across the board, so it is the identity tier. */
export const DIFFICULTIES: Record<DifficultyId, DifficultyConfig> = {
haunting: {
label: 'Haunting',
enemyHp: 0.85, enemySpeed: 0.95, survivorHp: 1.25, energyRate: 1.25, duration: 240,
},
normal: {
label: 'Normal',
enemyHp: 1, enemySpeed: 1, survivorHp: 1, energyRate: 1, duration: GAME_DURATION,
},
crusade: {
label: 'Crusade',
enemyHp: 1.2, enemySpeed: 1.05, survivorHp: 0.85, energyRate: 0.85, duration: 360,
},
}
export const DIFFICULTY_ORDER: readonly [DifficultyId, ...DifficultyId[]] = [
'haunting', 'normal', 'crusade',
]
export type PowerKind = 'ambush' | 'frenzy' | 'rift'
export const POWER_ORDER: readonly [PowerKind, ...PowerKind[]] = ['ambush', 'frenzy', 'rift']
export const POWER_NAMES: Record<PowerKind, string> = {
@ -251,6 +281,7 @@ export type Status = 'running' | 'directorWon' | 'survivorWon'
export interface GameState {
status: Status
difficulty: DifficultyConfig
t: number
duration: number
rng: Rng

View File

@ -259,3 +259,18 @@ body {
letter-spacing: 0.06em;
text-align: center;
}
.difficulty { display: flex; gap: 6px; justify-content: center; margin-top: 6px; }
.diff {
background: none;
border: 1px solid var(--panel-edge);
border-radius: 4px;
color: var(--bone-dim);
cursor: pointer;
font-family: var(--font-data);
font-size: 0.7rem;
padding: 3px 10px;
}
.diff-on { border-color: var(--holy); color: var(--holy); }