From 1342a60a65e4beb5408a80630c3727635b42c774 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 17 Aug 2026 09:52:19 +0200 Subject: [PATCH] Split reverse_survivors sim.ts 361 -> 69 + three modules Split along actor lines, but not the obvious way. A plain sim/enemies cut needs makeEnemy and grantXp going out and enemiesStep coming back -- a cycle. Nothing here would have caught it: no eslint-plugin-import, and tsc and vitest both pass on cyclic ES modules. It shows up as a TDZ error in the bundle. So the graph is a tree instead: simUnit.ts (42) leaf -- geometry, projectile constants, makeEnemy, enemySpeed; imports only types and vec simSurvivor.ts (132) createSurvivor, xp/upgrades, survivorStep simThreats.ts (168) enemiesStep, projectilesStep, and enemy death sim.ts (69) createInitialState and step, the only module that reaches all three Repointing director.ts's makeEnemy at the leaf also removes the sim <-> director cycle that was already there, surviving on const-arrow late binding. npm run check and npm run build both pass -- 160 tests, coverage identical at 531/159/101/514, 32 modules bundled at 210.09 kB against 210.11 kB before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- ...everse-survivors-sim-split-2026-08-17.json | 20 ++ ...everse-survivors-sim-split-2026-08-17.json | 58 ++++ reverse_survivors/src/App.test.tsx | 2 +- reverse_survivors/src/core/director.ts | 2 +- reverse_survivors/src/core/sim.test.ts | 7 +- reverse_survivors/src/core/sim.ts | 322 +----------------- reverse_survivors/src/core/simEnemies.test.ts | 5 +- reverse_survivors/src/core/simFrenzy.test.ts | 5 +- reverse_survivors/src/core/simSurvivor.ts | 132 +++++++ reverse_survivors/src/core/simThreats.ts | 168 +++++++++ reverse_survivors/src/core/simUnit.ts | 42 +++ reverse_survivors/src/core/simUnits.test.ts | 5 +- reverse_survivors/src/render/draw.test.ts | 3 +- reverse_survivors/src/render/draw.ts | 2 +- 14 files changed, 449 insertions(+), 324 deletions(-) create mode 100644 docs/superpowers/contracts/reverse-survivors-sim-split-2026-08-17.json create mode 100644 docs/superpowers/evidence/reverse-survivors-sim-split-2026-08-17.json create mode 100644 reverse_survivors/src/core/simSurvivor.ts create mode 100644 reverse_survivors/src/core/simThreats.ts create mode 100644 reverse_survivors/src/core/simUnit.ts diff --git a/docs/superpowers/contracts/reverse-survivors-sim-split-2026-08-17.json b/docs/superpowers/contracts/reverse-survivors-sim-split-2026-08-17.json new file mode 100644 index 00000000..e6673976 --- /dev/null +++ b/docs/superpowers/contracts/reverse-survivors-sim-split-2026-08-17.json @@ -0,0 +1,20 @@ +{ + "title": "Split reverse_survivors src/core/sim.ts under the 250-line cap", + "objective": "Bring reverse_survivors/src/core/sim.ts (361 lines) under the repo-wide 250-line file cap by splitting the simulation tick along actor lines into an acyclic three-module graph -- a leaf holding shared geometry and enemy construction, a survivor module, and a threats module for enemies and projectiles -- leaving sim.ts owning state construction and the step orchestration, with behaviour proven by the project's own 160-test suite at 100% coverage and by the same npm run check plus npm run build that CI runs.", + "acceptance_criteria": [ + "sim.ts and all three new modules are each at most 250 lines, and check_file_length.sh --all no longer lists src/core/sim.ts.", + "The new import graph is acyclic and introduces no cycle: simUnit.ts imports nothing from core/ except types and vec; simSurvivor.ts imports simUnit but never sim.ts or simThreats.ts; simThreats.ts imports simUnit and simSurvivor but never sim.ts. Verified by grep, since this repo has no eslint-plugin-import and neither tsc nor vitest would catch a cycle -- it would surface as a TDZ error only in the built bundle.", + "npm run check (typecheck && eslint . --max-warnings 0 && vitest run --coverage) passes: 13 test files, 160 tests, and 100% statements/branches/functions/lines with the same 531/159/101/514 totals, since no statement is added or removed.", + "npm run build (tsc --noEmit && vite build) succeeds, which is the only gate that would expose an import cycle. Module count rises from 29 to 32 for the three new files; bundle size stays within a few bytes of the 210.11 kB baseline.", + "Every importer of a moved symbol imports it from the module that now defines it, with no re-export barrel left in sim.ts.", + "Zero suppressions added: no eslint-disable, no @ts-ignore, no @ts-expect-error, and no coverage threshold or exclude entry changed." + ], + "out_of_scope": [ + "Any behaviour change to the simulation: the flee-and-drift survivor movement, the multishot angular spread, the deterministic non-RNG split angles, the frenzy damage and speed channels, the contact cooldown and the win/lose conditions all keep their current semantics exactly.", + "Changing the seeded RNG draw order, which would shift every replay and every seeded assertion.", + "Renaming any exported symbol; only the module a symbol lives in changes.", + "Splitting src/core/types.ts (295 lines) -- it is a separate commit.", + "Wiring the file-length pre-commit hook -- it must land last." + ], + "verifier": "npm run check (expect 13 files / 160 tests passed and 100% on all four metrics, totals 531/159/101/514); npm run build (expect success, 32 modules transformed); wc -l on sim.ts and the three new modules (expect all under 250); grep of each new module's import lines to confirm no edge points back into sim.ts; bash ~/utils/scripts/check_file_length.sh --all (expect src/core/sim.ts absent); grep -rn 'eslint-disable\\|ts-ignore\\|ts-expect-error' src/ (expect no hits); pre-commit run --files on every touched file. All captured at HEAD as the baseline and re-run after the split." +} diff --git a/docs/superpowers/evidence/reverse-survivors-sim-split-2026-08-17.json b/docs/superpowers/evidence/reverse-survivors-sim-split-2026-08-17.json new file mode 100644 index 00000000..179ca65c --- /dev/null +++ b/docs/superpowers/evidence/reverse-survivors-sim-split-2026-08-17.json @@ -0,0 +1,58 @@ +{ + "intent": "Bring reverse_survivors/src/core/sim.ts (361 lines) under the repo-wide 250-line cap with no change to simulation behaviour, so seeded replays and every assertion about the game loop are unaffected.", + "scope": [ + "reverse_survivors/src/core/sim.ts — reduced to createInitialState and the step orchestration", + "reverse_survivors/src/core/simUnit.ts — new leaf: arena/survivor geometry, projectile constants, SPLIT_RADIUS, CENTER, enemySpeed, makeEnemy", + "reverse_survivors/src/core/simSurvivor.ts — new: xpForLevel, createSurvivor, applyUpgrade, grantXp, nearestEnemy, survivorStep", + "reverse_survivors/src/core/simThreats.ts — new: frenzyDamageMul, detonate, split, killByShot, enemiesStep, findHit, projectilesStep", + "Import sites rewritten: core/director.ts, render/draw.ts, App.test.tsx, render/draw.test.ts, and the four sim*.test.ts files", + "Non-goal: any behaviour change, any change to the seeded RNG draw order, any symbol rename" + ], + "changes": [ + "Split along actor lines into a deliberately acyclic graph rather than the obvious two-way cut. A naive 'enemies module' would have needed makeEnemy and grantXp while sim.ts needed enemiesStep back, creating a sim <-> simThreats cycle. Instead simUnit.ts is a leaf importing nothing from core/ but types and vec; simSurvivor.ts builds on it; simThreats.ts builds on both; sim.ts is the only module reaching all three.", + "Repointed director.ts's makeEnemy import from sim.ts to simUnit.ts. This removes a pre-existing sim <-> director cycle that had survived only because every symbol is a const arrow called at runtime — a latent hazard this split eliminates as a side effect rather than inherits.", + "Rewrote eight import sites to name the owning module. Six are test files; all six edits are import-path-only, with no assertion, seed or expected value changed. Test-file edits are expected in this series at the user's explicit instruction to rewrite import sites rather than leave re-export barrels.", + "Function bodies moved verbatim, including the comments recording why split() draws no RNG and why applyStatus is called unconditionally; no statement was added, removed or reordered, which is why coverage totals are unchanged." + ], + "verification": [ + { + "command": "npm run check (typecheck && eslint . --max-warnings 0 && vitest run --coverage)", + "result": "pass", + "evidence": "Test Files 13 passed (13); Tests 160 passed (160); Statements 100% (531/531), Branches 100% (159/159), Functions 100% (101/101), Lines 100% (514/514) — byte-identical totals to the baseline captured at HEAD. This is the exact command reverse-survivors CI runs." + }, + { + "command": "npm run build (tsc --noEmit && vite build)", + "result": "pass", + "evidence": "Built in 124ms, 32 modules transformed (29 at baseline, +3 for the new files), bundle 210.09 kB vs 210.11 kB baseline. This is the second CI step and the only gate that would expose an import cycle, which would surface as a TDZ error in the bundle rather than as a test or typecheck failure." + }, + { + "command": "grep of each new module's import lines", + "result": "pass", + "evidence": "simUnit.ts imports only './types' and './vec'; simSurvivor.ts has no import of sim.ts or simThreats.ts; simThreats.ts has no import of sim.ts; the only remaining non-test import of sim.ts is App.tsx at the top of the graph. The graph is a tree." + }, + { + "command": "wc -l src/core/sim.ts src/core/simUnit.ts src/core/simSurvivor.ts src/core/simThreats.ts", + "result": "pass", + "evidence": "sim.ts 69, simUnit.ts 42, simSurvivor.ts 132, simThreats.ts 168 — all four well under the 250-line cap, down from a single 361-line file." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "src/core/sim.ts no longer appears in the over-cap listing; src/core/types.ts (295) is the remaining reverse_survivors entry." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "All configured hooks pass on the touched files." + } + ], + "risks": [ + "The new module names (simUnit, simSurvivor, simThreats) deliberately avoid the stems simEnemies/simUnits/simFrenzy, which are existing *test* files with no matching source. Creating simEnemies.ts beside simEnemies.test.ts would have implied a source/test pairing that does not exist, since those test files all exercise sim.ts as a whole.", + "This repo has no eslint-plugin-import, so nothing automatically prevents a future edit from reintroducing a cycle; the acyclicity is verified by grep here and enforced only by vite build failing on the resulting TDZ error.", + "simUnit.ts holds constants used by the renderer (FIRE_RANGE, SURVIVOR_RADIUS) as well as by the simulation, so render/draw.ts now depends on a core module named for units. The alternative — duplicating the constants — would let the drawn and simulated radii drift apart." + ], + "rollback": [ + "git revert the commit — five edited files, three added files, and no data, config or schema migration.", + "After rollback, re-run npm run check and npm run build; expect 160 tests passing at 100% coverage (531/159/101/514) and a successful build at 29 modules." + ] +} diff --git a/reverse_survivors/src/App.test.tsx b/reverse_survivors/src/App.test.tsx index f3709c40..86ac971c 100644 --- a/reverse_survivors/src/App.test.tsx +++ b/reverse_survivors/src/App.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { App } from './App' -import { makeEnemy } from './core/sim' +import { makeEnemy } from './core/simUnit' import type { RafHarness } from './test/harness' import { installRaf, makeCtxStub } from './test/harness' diff --git a/reverse_survivors/src/core/director.ts b/reverse_survivors/src/core/director.ts index d96290d2..63e4c391 100644 --- a/reverse_survivors/src/core/director.ts +++ b/reverse_survivors/src/core/director.ts @@ -1,4 +1,4 @@ -import { makeEnemy } from './sim' +import { makeEnemy } from './simUnit' import { nextFloat, pick } from './rng' import type { BossKind, DirectorAction, EdgeId, GameState, PowerKind, UnitKind } from './types' import { diff --git a/reverse_survivors/src/core/sim.test.ts b/reverse_survivors/src/core/sim.test.ts index b2ab0841..c9a70344 100644 --- a/reverse_survivors/src/core/sim.test.ts +++ b/reverse_survivors/src/core/sim.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - applyUpgrade, createInitialState, DANGER_RADIUS, grantXp, makeEnemy, step, - xpForLevel, -} from './sim' +import { createInitialState, step } from './sim' +import { DANGER_RADIUS, makeEnemy } from './simUnit' +import { applyUpgrade, grantXp, xpForLevel } from './simSurvivor' import type { GameState, } from './types' import { ARENA, GAME_DURATION, } from './types' diff --git a/reverse_survivors/src/core/sim.ts b/reverse_survivors/src/core/sim.ts index fc576b44..28b6173f 100644 --- a/reverse_survivors/src/core/sim.ts +++ b/reverse_survivors/src/core/sim.ts @@ -1,43 +1,19 @@ +/** + * The simulation tick: build a game state, then advance it. + * + * The per-actor work lives in three modules with a one-way import graph — + * `simUnit` (shared geometry and enemy construction) is the leaf, `simSurvivor` + * builds on it, and `simThreats` builds on both. This file is the only place + * that reaches into all three, and the only place the director's actions are + * applied. + */ + import { applyAction, tickDirector } from './director' -import { createRng, pick } from './rng' -import { applyStatus, factor, tickStatuses } from './status' -import type { - DifficultyConfig, DifficultyId, DirectorAction, Enemy, EnemyKind, EnemySpec, GameState, - Survivor, UpgradeId, -} from './types' -import { - 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' - -export const SURVIVOR_RADIUS = 12 -export const DANGER_RADIUS = 140 -export const FIRE_RANGE = 260 -export const PROJ_SPEED = 420 -const PROJ_TTL = 1.5 -const ENEMY_PROJ_TTL = 2 -const CENTER: Vec = { x: ARENA.w / 2, y: ARENA.h / 2 } - -export const xpForLevel = (level: number): number => 20 + 15 * (level - 1) - -const createSurvivor = (cfg: DifficultyConfig): Survivor => ({ - pos: { x: CENTER.x, y: CENTER.y }, - hp: 100 * cfg.survivorHp, - maxHp: 100 * cfg.survivorHp, - speed: 150, - regen: 0, - damage: 8, - multishot: 1, - fireCooldown: 0.55, - fireTimer: 0, - level: 1, - xp: 0, - xpNext: xpForLevel(1), - kills: 0, - statuses: { slow: 0, suppress: 0, bleed: 0 }, -}) +import { createRng } from './rng' +import type { DifficultyId, DirectorAction, GameState } from './types' +import { DIFFICULTIES, ENERGY_START } from './types' +import { createSurvivor, survivorStep } from './simSurvivor' +import { enemiesStep, projectilesStep } from './simThreats' export const createInitialState = ( seed: number, @@ -70,274 +46,6 @@ export const createInitialState = ( } } -/** 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 - state.nextId += 1 - const enemy: Enemy = { - id, - kind, - pos: { x: pos.x, y: pos.y }, - hp: ENEMY_SPECS[kind].hp * state.difficulty.enemyHp, - hitTimer: 0, - fireTimer: 0, - spawnTimer: ENEMY_SPECS[kind].spawnEvery, - } - state.enemies.push(enemy) - return enemy -} - -const APPLY_UPGRADE: Record void> = { - damage: (sv) => { sv.damage += 3 }, - fireRate: (sv) => { sv.fireCooldown = Math.max(0.15, sv.fireCooldown * 0.88) }, - speed: (sv) => { sv.speed += 14 }, - vitality: (sv) => { sv.maxHp += 20; sv.hp += 20 }, - regen: (sv) => { sv.regen += 0.6 }, - multishot: (sv) => { sv.multishot += 1 }, -} - -export const applyUpgrade = (sv: Survivor, id: UpgradeId): void => { - APPLY_UPGRADE[id](sv) -} - -export const grantXp = (state: GameState, amount: number): void => { - const sv = state.survivor - sv.xp += amount - while (sv.xp >= sv.xpNext) { - sv.xp -= sv.xpNext - sv.level += 1 - sv.xpNext = xpForLevel(sv.level) - const upgrade = pick(state.rng, UPGRADE_POOL) - applyUpgrade(sv, upgrade) - state.upgrades.push(upgrade) - } -} - -const nearestEnemy = (state: GameState): Enemy | null => { - let best: Enemy | null = null - let bestD = Infinity - for (const e of state.enemies) { - const d = dist(e.pos, state.survivor.pos) - if (d < bestD) { - bestD = d - best = e - } - } - return best -} - -const survivorStep = (state: GameState, dt: number): void => { - const sv = state.survivor - // Tick before reading: a status applied this tick is felt from the next one, - // and a 0-second (inert) application is never observable. - tickStatuses(sv, dt) - const moveMul = factor(sv.statuses.slow, STATUS_POWER.slow) - const fireMul = factor(sv.statuses.suppress, STATUS_POWER.suppress) - const regenMul = factor(sv.statuses.bleed, STATUS_POWER.bleed) - const speed = sv.speed * moveMul - let fleeX = 0 - let fleeY = 0 - for (const e of state.enemies) { - const d = dist(e.pos, sv.pos) - if (d < DANGER_RADIUS && d > 0) { - const weight = (DANGER_RADIUS - d) / DANGER_RADIUS - fleeX += ((sv.pos.x - e.pos.x) / d) * weight - fleeY += ((sv.pos.y - e.pos.y) / d) * weight - } - } - const fleeMag = Math.hypot(fleeX, fleeY) - let vel: Vec = { x: 0, y: 0 } - if (fleeMag > 0) { - vel = { x: (fleeX / fleeMag) * speed, y: (fleeY / fleeMag) * speed } - } else if (dist(sv.pos, CENTER) > 4) { - vel = toward(sv.pos, CENTER, speed * 0.4) - } - sv.pos.x = clamp(sv.pos.x + vel.x * dt, SURVIVOR_RADIUS, ARENA.w - SURVIVOR_RADIUS) - sv.pos.y = clamp(sv.pos.y + vel.y * dt, SURVIVOR_RADIUS, ARENA.h - SURVIVOR_RADIUS) - - sv.hp = Math.min(sv.maxHp, sv.hp + sv.regen * regenMul * dt) - - sv.fireTimer = Math.max(0, sv.fireTimer - dt) - const target = nearestEnemy(state) - if (target !== null && sv.fireTimer === 0 && dist(target.pos, sv.pos) <= FIRE_RANGE) { - const baseAngle = Math.atan2(target.pos.y - sv.pos.y, target.pos.x - sv.pos.x) - for (let i = 0; i < sv.multishot; i += 1) { - const angle = baseAngle + (i - (sv.multishot - 1) / 2) * 0.12 - const id = state.nextId - state.nextId += 1 - state.projectiles.push({ - id, - from: 'survivor', - pos: { x: sv.pos.x, y: sv.pos.y }, - vel: { x: Math.cos(angle) * PROJ_SPEED, y: Math.sin(angle) * PROJ_SPEED }, - radius: 4, - damage: sv.damage, - ttl: PROJ_TTL, - debuff: 'slow', - debuffDuration: 0, - }) - } - // Suppression scales the reload, not the countdown: the remaining timer stays - // linear in dt, which keeps replays and assertions exact. - sv.fireTimer = sv.fireCooldown * fireMul - } -} - -/** Frenzy's damage multiplier, derived from state so every damage channel agrees. */ -export const frenzyDamageMul = (state: GameState): number => - factor(state.director.frenzyTimer, FRENZY_DAMAGE) - -const detonate = (state: GameState, enemy: Enemy): void => { - const spec = ENEMY_SPECS[enemy.kind] - enemy.hp = 0 - if (dist(enemy.pos, state.survivor.pos) <= spec.blastRadius) { - state.survivor.hp -= spec.blastDamage * frenzyDamageMul(state) - } -} - -export const SPLIT_RADIUS = 22 - -/** - * Emits `splitCount` children at fixed angular offsets. Deterministic on purpose: - * drawing from the RNG here would shift every seeded replay downstream. - * Loop-only — `splitCount: 0` makes this a no-op without a guard branch. - */ -const split = (state: GameState, spec: EnemySpec, pos: Vec): void => { - for (let i = 0; i < spec.splitCount; i += 1) { - const angle = (Math.PI * 2 * i) / spec.splitCount - makeEnemy(state, spec.spawnKind, { - x: clamp(pos.x + Math.cos(angle) * SPLIT_RADIUS, 10, ARENA.w - 10), - y: clamp(pos.y + Math.sin(angle) * SPLIT_RADIUS, 10, ARENA.h - 10), - }) - } -} - -const killByShot = (state: GameState, enemy: Enemy): void => { - const spec = ENEMY_SPECS[enemy.kind] - state.survivor.kills += 1 - grantXp(state, spec.xp) - if (spec.detonates) { - detonate(state, enemy) - } - split(state, spec, enemy.pos) -} - -type MoveFn = (e: Enemy, sv: Survivor, d: number, speed: number) => Vec - -const MOVE_FNS: Record<'chase' | 'skirmish', MoveFn> = { - chase: (e, sv, _d, speed) => toward(e.pos, sv.pos, speed), - skirmish: (e, sv, d, speed) => { - const spec = ENEMY_SPECS[e.kind] - if (d > spec.keepDistance) { - return toward(e.pos, sv.pos, speed) - } - if (d < spec.keepDistance * 0.65) { - return toward(sv.pos, e.pos, speed) - } - return { x: 0, y: 0 } - }, -} - -const enemiesStep = (state: GameState, dt: number): void => { - const sv = state.survivor - const spawned: { kind: EnemyKind; pos: Vec }[] = [] - // Frenzy is read straight off director state — no import, so no new import cycle. - const speedMul = factor(state.director.frenzyTimer, FRENZY_SPEED) - const damageMul = frenzyDamageMul(state) - for (const e of state.enemies) { - 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, 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) - - if (dist(e.pos, sv.pos) <= spec.radius + SURVIVOR_RADIUS) { - if (spec.detonates) { - detonate(state, e) - } else if (e.hitTimer === 0) { - sv.hp -= spec.contactDamage * damageMul - // Unconditional: a 0-duration spec makes this a provable no-op, which is - // cheaper than a guard branch under the 100 % gate. - applyStatus(sv, spec.debuff, spec.debuffDuration) - e.hitTimer = CONTACT_COOLDOWN - } - } - - if (spec.projDamage > 0) { - e.fireTimer = Math.max(0, e.fireTimer - dt) - if (e.fireTimer === 0 && d <= spec.fireRange) { - const id = state.nextId - state.nextId += 1 - state.projectiles.push({ - id, - from: 'enemy', - pos: { x: e.pos.x, y: e.pos.y }, - vel: toward(e.pos, sv.pos, spec.projSpeed), - radius: 5, - damage: spec.projDamage * damageMul, - ttl: ENEMY_PROJ_TTL, - debuff: spec.debuff, - debuffDuration: spec.debuffDuration, - }) - e.fireTimer = spec.fireCooldown - } - } - - if (spec.spawnEvery > 0) { - e.spawnTimer = Math.max(0, e.spawnTimer - dt) - if (e.spawnTimer === 0) { - spawned.push({ kind: spec.spawnKind, pos: { x: e.pos.x, y: e.pos.y } }) - e.spawnTimer = spec.spawnEvery - } - } - } - for (const s of spawned) { - makeEnemy(state, s.kind, s.pos) - } -} - -const findHit = (enemies: Enemy[], pos: Vec, radius: number): Enemy | null => { - for (const e of enemies) { - if (e.hp > 0 && dist(e.pos, pos) <= ENEMY_SPECS[e.kind].radius + radius) { - return e - } - } - return null -} - -const projectilesStep = (state: GameState, dt: number): void => { - const sv = state.survivor - const surviving: typeof state.projectiles = [] - for (const p of state.projectiles) { - p.pos.x += p.vel.x * dt - p.pos.y += p.vel.y * dt - p.ttl -= dt - if (p.ttl <= 0) { - continue - } - if (p.from === 'survivor') { - const hit = findHit(state.enemies, p.pos, p.radius) - if (hit !== null) { - hit.hp -= p.damage - if (hit.hp <= 0) { - killByShot(state, hit) - } - continue - } - } else if (dist(p.pos, sv.pos) <= p.radius + SURVIVOR_RADIUS) { - sv.hp -= p.damage - applyStatus(sv, p.debuff, p.debuffDuration) - continue - } - surviving.push(p) - } - state.projectiles = surviving -} - export const step = (state: GameState, actions: readonly DirectorAction[], dt: number): void => { if (state.status !== 'running') { return diff --git a/reverse_survivors/src/core/simEnemies.test.ts b/reverse_survivors/src/core/simEnemies.test.ts index 44ebb50d..b0c1a98f 100644 --- a/reverse_survivors/src/core/simEnemies.test.ts +++ b/reverse_survivors/src/core/simEnemies.test.ts @@ -1,9 +1,8 @@ /** Enemy behaviour, bombers and projectiles. */ import { describe, expect, it } from 'vitest' -import { - createInitialState, makeEnemy, step, - } from './sim' +import { createInitialState, step } from './sim' +import { makeEnemy } from './simUnit' import type { GameState, Projectile } from './types' import { ARENA, } from './types' diff --git a/reverse_survivors/src/core/simFrenzy.test.ts b/reverse_survivors/src/core/simFrenzy.test.ts index 84b56793..0ce9e22f 100644 --- a/reverse_survivors/src/core/simFrenzy.test.ts +++ b/reverse_survivors/src/core/simFrenzy.test.ts @@ -5,9 +5,8 @@ /** Enemy behaviour, bombers and projectiles. */ import { describe, expect, it } from 'vitest' -import { - createInitialState, makeEnemy, step, - } from './sim' +import { createInitialState, step } from './sim' +import { makeEnemy } from './simUnit' import type { GameState, } from './types' import { ARENA, DIFFICULTIES, FRENZY_DAMAGE, FRENZY_SPEED, } from './types' diff --git a/reverse_survivors/src/core/simSurvivor.ts b/reverse_survivors/src/core/simSurvivor.ts new file mode 100644 index 00000000..b8094b46 --- /dev/null +++ b/reverse_survivors/src/core/simSurvivor.ts @@ -0,0 +1,132 @@ +/** + * The survivor: how one is created, how it levels, and what it does each tick. + * + * The survivor is the *opponent* in this game — it is driven entirely by this + * file rather than by input. Its behaviour is a flee-from-crowds vector with a + * drift back to the arena centre, plus auto-fire at the nearest target in range. + */ + +import { pick } from './rng' +import { factor, tickStatuses } from './status' +import type { DifficultyConfig, Enemy, GameState, Survivor, UpgradeId } from './types' +import { ARENA, STATUS_POWER, UPGRADE_POOL } from './types' +import { + CENTER, DANGER_RADIUS, FIRE_RANGE, PROJ_SPEED, PROJ_TTL, SURVIVOR_RADIUS, +} from './simUnit' +import type { Vec } from './vec' +import { clamp, dist, toward } from './vec' + +export const xpForLevel = (level: number): number => 20 + 15 * (level - 1) + +export const createSurvivor = (cfg: DifficultyConfig): Survivor => ({ + pos: { x: CENTER.x, y: CENTER.y }, + hp: 100 * cfg.survivorHp, + maxHp: 100 * cfg.survivorHp, + speed: 150, + regen: 0, + damage: 8, + multishot: 1, + fireCooldown: 0.55, + fireTimer: 0, + level: 1, + xp: 0, + xpNext: xpForLevel(1), + kills: 0, + statuses: { slow: 0, suppress: 0, bleed: 0 }, +}) + +const APPLY_UPGRADE: Record void> = { + damage: (sv) => { sv.damage += 3 }, + fireRate: (sv) => { sv.fireCooldown = Math.max(0.15, sv.fireCooldown * 0.88) }, + speed: (sv) => { sv.speed += 14 }, + vitality: (sv) => { sv.maxHp += 20; sv.hp += 20 }, + regen: (sv) => { sv.regen += 0.6 }, + multishot: (sv) => { sv.multishot += 1 }, +} + +export const applyUpgrade = (sv: Survivor, id: UpgradeId): void => { + APPLY_UPGRADE[id](sv) +} + +export const grantXp = (state: GameState, amount: number): void => { + const sv = state.survivor + sv.xp += amount + while (sv.xp >= sv.xpNext) { + sv.xp -= sv.xpNext + sv.level += 1 + sv.xpNext = xpForLevel(sv.level) + const upgrade = pick(state.rng, UPGRADE_POOL) + applyUpgrade(sv, upgrade) + state.upgrades.push(upgrade) + } +} + +const nearestEnemy = (state: GameState): Enemy | null => { + let best: Enemy | null = null + let bestD = Infinity + for (const e of state.enemies) { + const d = dist(e.pos, state.survivor.pos) + if (d < bestD) { + bestD = d + best = e + } + } + return best +} + +export const survivorStep = (state: GameState, dt: number): void => { + const sv = state.survivor + // Tick before reading: a status applied this tick is felt from the next one, + // and a 0-second (inert) application is never observable. + tickStatuses(sv, dt) + const moveMul = factor(sv.statuses.slow, STATUS_POWER.slow) + const fireMul = factor(sv.statuses.suppress, STATUS_POWER.suppress) + const regenMul = factor(sv.statuses.bleed, STATUS_POWER.bleed) + const speed = sv.speed * moveMul + let fleeX = 0 + let fleeY = 0 + for (const e of state.enemies) { + const d = dist(e.pos, sv.pos) + if (d < DANGER_RADIUS && d > 0) { + const weight = (DANGER_RADIUS - d) / DANGER_RADIUS + fleeX += ((sv.pos.x - e.pos.x) / d) * weight + fleeY += ((sv.pos.y - e.pos.y) / d) * weight + } + } + const fleeMag = Math.hypot(fleeX, fleeY) + let vel: Vec = { x: 0, y: 0 } + if (fleeMag > 0) { + vel = { x: (fleeX / fleeMag) * speed, y: (fleeY / fleeMag) * speed } + } else if (dist(sv.pos, CENTER) > 4) { + vel = toward(sv.pos, CENTER, speed * 0.4) + } + sv.pos.x = clamp(sv.pos.x + vel.x * dt, SURVIVOR_RADIUS, ARENA.w - SURVIVOR_RADIUS) + sv.pos.y = clamp(sv.pos.y + vel.y * dt, SURVIVOR_RADIUS, ARENA.h - SURVIVOR_RADIUS) + + sv.hp = Math.min(sv.maxHp, sv.hp + sv.regen * regenMul * dt) + + sv.fireTimer = Math.max(0, sv.fireTimer - dt) + const target = nearestEnemy(state) + if (target !== null && sv.fireTimer === 0 && dist(target.pos, sv.pos) <= FIRE_RANGE) { + const baseAngle = Math.atan2(target.pos.y - sv.pos.y, target.pos.x - sv.pos.x) + for (let i = 0; i < sv.multishot; i += 1) { + const angle = baseAngle + (i - (sv.multishot - 1) / 2) * 0.12 + const id = state.nextId + state.nextId += 1 + state.projectiles.push({ + id, + from: 'survivor', + pos: { x: sv.pos.x, y: sv.pos.y }, + vel: { x: Math.cos(angle) * PROJ_SPEED, y: Math.sin(angle) * PROJ_SPEED }, + radius: 4, + damage: sv.damage, + ttl: PROJ_TTL, + debuff: 'slow', + debuffDuration: 0, + }) + } + // Suppression scales the reload, not the countdown: the remaining timer stays + // linear in dt, which keeps replays and assertions exact. + sv.fireTimer = sv.fireCooldown * fireMul + } +} diff --git a/reverse_survivors/src/core/simThreats.ts b/reverse_survivors/src/core/simThreats.ts new file mode 100644 index 00000000..2ba38e66 --- /dev/null +++ b/reverse_survivors/src/core/simThreats.ts @@ -0,0 +1,168 @@ +/** + * Everything the director's units do each tick: enemy movement and contact, + * their fire and spawning, projectile flight, and what happens when an enemy + * dies. + * + * Imports run one way — this file reaches into `simUnit` and `simSurvivor`, and + * neither reaches back — so the simulation graph stays acyclic. + */ + +import { applyStatus, factor } from './status' +import type { Enemy, EnemyKind, EnemySpec, GameState, Survivor } from './types' +import { ARENA, CONTACT_COOLDOWN, ENEMY_SPECS, FRENZY_DAMAGE, FRENZY_SPEED } from './types' +import { + ENEMY_PROJ_TTL, SPLIT_RADIUS, SURVIVOR_RADIUS, enemySpeed, makeEnemy, +} from './simUnit' +import { grantXp } from './simSurvivor' +import type { Vec } from './vec' +import { clamp, dist, toward } from './vec' + +/** Frenzy's damage multiplier, derived from state so every damage channel agrees. */ +export const frenzyDamageMul = (state: GameState): number => + factor(state.director.frenzyTimer, FRENZY_DAMAGE) + +const detonate = (state: GameState, enemy: Enemy): void => { + const spec = ENEMY_SPECS[enemy.kind] + enemy.hp = 0 + if (dist(enemy.pos, state.survivor.pos) <= spec.blastRadius) { + state.survivor.hp -= spec.blastDamage * frenzyDamageMul(state) + } +} + +/** + * Emits `splitCount` children at fixed angular offsets. Deterministic on purpose: + * drawing from the RNG here would shift every seeded replay downstream. + * Loop-only — `splitCount: 0` makes this a no-op without a guard branch. + */ +const split = (state: GameState, spec: EnemySpec, pos: Vec): void => { + for (let i = 0; i < spec.splitCount; i += 1) { + const angle = (Math.PI * 2 * i) / spec.splitCount + makeEnemy(state, spec.spawnKind, { + x: clamp(pos.x + Math.cos(angle) * SPLIT_RADIUS, 10, ARENA.w - 10), + y: clamp(pos.y + Math.sin(angle) * SPLIT_RADIUS, 10, ARENA.h - 10), + }) + } +} + +const killByShot = (state: GameState, enemy: Enemy): void => { + const spec = ENEMY_SPECS[enemy.kind] + state.survivor.kills += 1 + grantXp(state, spec.xp) + if (spec.detonates) { + detonate(state, enemy) + } + split(state, spec, enemy.pos) +} + +type MoveFn = (e: Enemy, sv: Survivor, d: number, speed: number) => Vec + +const MOVE_FNS: Record<'chase' | 'skirmish', MoveFn> = { + chase: (e, sv, _d, speed) => toward(e.pos, sv.pos, speed), + skirmish: (e, sv, d, speed) => { + const spec = ENEMY_SPECS[e.kind] + if (d > spec.keepDistance) { + return toward(e.pos, sv.pos, speed) + } + if (d < spec.keepDistance * 0.65) { + return toward(sv.pos, e.pos, speed) + } + return { x: 0, y: 0 } + }, +} + +export const enemiesStep = (state: GameState, dt: number): void => { + const sv = state.survivor + const spawned: { kind: EnemyKind; pos: Vec }[] = [] + // Frenzy is read straight off director state — no import, so no new import cycle. + const speedMul = factor(state.director.frenzyTimer, FRENZY_SPEED) + const damageMul = frenzyDamageMul(state) + for (const e of state.enemies) { + 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, 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) + + if (dist(e.pos, sv.pos) <= spec.radius + SURVIVOR_RADIUS) { + if (spec.detonates) { + detonate(state, e) + } else if (e.hitTimer === 0) { + sv.hp -= spec.contactDamage * damageMul + // Unconditional: a 0-duration spec makes this a provable no-op, which is + // cheaper than a guard branch under the 100 % gate. + applyStatus(sv, spec.debuff, spec.debuffDuration) + e.hitTimer = CONTACT_COOLDOWN + } + } + + if (spec.projDamage > 0) { + e.fireTimer = Math.max(0, e.fireTimer - dt) + if (e.fireTimer === 0 && d <= spec.fireRange) { + const id = state.nextId + state.nextId += 1 + state.projectiles.push({ + id, + from: 'enemy', + pos: { x: e.pos.x, y: e.pos.y }, + vel: toward(e.pos, sv.pos, spec.projSpeed), + radius: 5, + damage: spec.projDamage * damageMul, + ttl: ENEMY_PROJ_TTL, + debuff: spec.debuff, + debuffDuration: spec.debuffDuration, + }) + e.fireTimer = spec.fireCooldown + } + } + + if (spec.spawnEvery > 0) { + e.spawnTimer = Math.max(0, e.spawnTimer - dt) + if (e.spawnTimer === 0) { + spawned.push({ kind: spec.spawnKind, pos: { x: e.pos.x, y: e.pos.y } }) + e.spawnTimer = spec.spawnEvery + } + } + } + for (const s of spawned) { + makeEnemy(state, s.kind, s.pos) + } +} + +const findHit = (enemies: Enemy[], pos: Vec, radius: number): Enemy | null => { + for (const e of enemies) { + if (e.hp > 0 && dist(e.pos, pos) <= ENEMY_SPECS[e.kind].radius + radius) { + return e + } + } + return null +} + +export const projectilesStep = (state: GameState, dt: number): void => { + const sv = state.survivor + const surviving: typeof state.projectiles = [] + for (const p of state.projectiles) { + p.pos.x += p.vel.x * dt + p.pos.y += p.vel.y * dt + p.ttl -= dt + if (p.ttl <= 0) { + continue + } + if (p.from === 'survivor') { + const hit = findHit(state.enemies, p.pos, p.radius) + if (hit !== null) { + hit.hp -= p.damage + if (hit.hp <= 0) { + killByShot(state, hit) + } + continue + } + } else if (dist(p.pos, sv.pos) <= p.radius + SURVIVOR_RADIUS) { + sv.hp -= p.damage + applyStatus(sv, p.debuff, p.debuffDuration) + continue + } + surviving.push(p) + } + state.projectiles = surviving +} diff --git a/reverse_survivors/src/core/simUnit.ts b/reverse_survivors/src/core/simUnit.ts new file mode 100644 index 00000000..8f4fdad0 --- /dev/null +++ b/reverse_survivors/src/core/simUnit.ts @@ -0,0 +1,42 @@ +/** + * Shared unit vocabulary: arena geometry, the survivor's own dimensions, and + * enemy construction. + * + * This module deliberately imports nothing else from `core/` bar types and + * vectors. Everything the per-actor step modules need in common lands here, so + * the simulation's import graph stays a tree rather than the cycle it would be + * if these lived beside their callers. + */ + +import type { Enemy, EnemyKind, GameState } from './types' +import { ARENA, ENEMY_SPECS } from './types' +import type { Vec } from './vec' + +export const SURVIVOR_RADIUS = 12 +export const DANGER_RADIUS = 140 +export const FIRE_RANGE = 260 +export const PROJ_SPEED = 420 +export const PROJ_TTL = 1.5 +export const ENEMY_PROJ_TTL = 2 +export const SPLIT_RADIUS = 22 +export const CENTER: Vec = { x: ARENA.w / 2, y: ARENA.h / 2 } + +/** 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 + state.nextId += 1 + const enemy: Enemy = { + id, + kind, + pos: { x: pos.x, y: pos.y }, + hp: ENEMY_SPECS[kind].hp * state.difficulty.enemyHp, + hitTimer: 0, + fireTimer: 0, + spawnTimer: ENEMY_SPECS[kind].spawnEvery, + } + state.enemies.push(enemy) + return enemy +} diff --git a/reverse_survivors/src/core/simUnits.test.ts b/reverse_survivors/src/core/simUnits.test.ts index 0356680f..00d494b4 100644 --- a/reverse_survivors/src/core/simUnits.test.ts +++ b/reverse_survivors/src/core/simUnits.test.ts @@ -3,9 +3,8 @@ /** Enemy behaviour, bombers and projectiles. */ import { describe, expect, it } from 'vitest' -import { - createInitialState, makeEnemy, SPLIT_RADIUS, step, - } from './sim' +import { createInitialState, step } from './sim' +import { makeEnemy, SPLIT_RADIUS } from './simUnit' import type { GameState, Projectile } from './types' import { ARENA, STATUS_POWER, diff --git a/reverse_survivors/src/render/draw.test.ts b/reverse_survivors/src/render/draw.test.ts index 8f018e0f..8bd6d544 100644 --- a/reverse_survivors/src/render/draw.test.ts +++ b/reverse_survivors/src/render/draw.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createInitialState, makeEnemy } from '../core/sim' +import { createInitialState } from '../core/sim' +import { makeEnemy } from '../core/simUnit' import type { GameState } from '../core/types' import { makeCtxStub } from '../test/harness' import { draw } from './draw' diff --git a/reverse_survivors/src/render/draw.ts b/reverse_survivors/src/render/draw.ts index 5b7a2b4b..8029592a 100644 --- a/reverse_survivors/src/render/draw.ts +++ b/reverse_survivors/src/render/draw.ts @@ -1,4 +1,4 @@ -import { FIRE_RANGE, SURVIVOR_RADIUS } from '../core/sim' +import { FIRE_RANGE, SURVIVOR_RADIUS } from '../core/simUnit' import type { EnemyKind, GameState } from '../core/types' import { ARENA, ENEMY_SPECS, STATUS_ORDER } from '../core/types'