diff --git a/docs/superpowers/contracts/reverse-survivors-types-split-2026-08-17.json b/docs/superpowers/contracts/reverse-survivors-types-split-2026-08-17.json new file mode 100644 index 00000000..f29caf22 --- /dev/null +++ b/docs/superpowers/contracts/reverse-survivors-types-split-2026-08-17.json @@ -0,0 +1,19 @@ +{ + "title": "Split reverse_survivors src/core/types.ts under the 250-line cap", + "objective": "Bring reverse_survivors/src/core/types.ts (295 lines) under the repo-wide 250-line file cap by extracting the enemy roster (kind unions, the EnemySpec shape, the ENEMY_SPECS stat table and the unit/boss orders and unlocks) and the status vocabulary into two sibling modules, repointing all 36 import sites at the module that owns each symbol, 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": [ + "types.ts and both new modules are each at most 250 lines, and check_file_length.sh --all lists no reverse_survivors file at all.", + "The three modules form an acyclic graph: statusKinds.ts imports nothing; enemies.ts imports only statusKinds.ts; types.ts imports both and neither imports back. A status module is required rather than optional, because enemy specs name the status they inflict while the survivor state carries seconds-remaining per status -- leaving StatusKind in types.ts would make types.ts and enemies.ts mutually dependent.", + "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 gate that would expose an import cycle as a TDZ error.", + "Every importer of a moved symbol imports it from the module that now defines it, with no re-export barrel left in types.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 change to a stat value: every enemy's hp, speed, cost, damage, xp, radius, cooldown and debuff duration, and every difficulty multiplier, power cost and cooldown, must be carried across byte-identically. A balance change disguised as a move would pass the tests and silently alter the game.", + "Renaming any exported symbol, or changing any type's shape.", + "Reordering ENEMY_SPECS entries or the UNIT_ORDER/BOSS_ORDER arrays, which drive deterministic iteration in the director and the UI.", + "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); wc -l on types.ts and both new modules (expect all under 250); grep of statusKinds.ts and enemies.ts import lines to confirm neither imports types.ts; bash ~/utils/scripts/check_file_length.sh --all (expect no reverse_survivors entry); git diff of the moved ENEMY_SPECS/DIFFICULTIES blocks to confirm no numeric literal changed; grep -rn 'eslint-disable\\|ts-ignore\\|ts-expect-error' src/ (expect no hits); pre-commit run --files on every touched file." +} diff --git a/docs/superpowers/evidence/reverse-survivors-types-split-2026-08-17.json b/docs/superpowers/evidence/reverse-survivors-types-split-2026-08-17.json new file mode 100644 index 00000000..bbb2597c --- /dev/null +++ b/docs/superpowers/evidence/reverse-survivors-types-split-2026-08-17.json @@ -0,0 +1,62 @@ +{ + "intent": "Bring reverse_survivors/src/core/types.ts (295 lines) under the repo-wide 250-line cap with no change to any stat value or type shape, completing reverse_survivors' move under the cap.", + "scope": [ + "reverse_survivors/src/core/types.ts — reduced to arena constants, difficulty tiers, director powers, edges, upgrades and the game-state shapes", + "reverse_survivors/src/core/enemies.ts — new: UnitKind, BossKind, EnemyKind, MoveMode, EnemySpec, ENEMY_SPECS, UNIT_ORDER, BOSS_ORDER, BOSS_UNLOCK_AT, BOSS_COOLDOWN", + "reverse_survivors/src/core/statusKinds.ts — new leaf: StatusKind, STATUS_ORDER, STATUS_POWER, STATUS_LABELS", + "14 import sites repointed across core, render and the test files", + "Non-goal: any stat value change, any symbol rename, any reordering of ENEMY_SPECS or the ORDER arrays" + ], + "changes": [ + "Extracted the enemy roster — the kind unions, the EnemySpec shape, the 82-line ENEMY_SPECS stat table and the unit/boss orders, unlocks and cooldowns — into enemies.ts, which is the file to edit when balancing.", + "Extracted the status vocabulary into statusKinds.ts. This module is required rather than cosmetic: enemy specs name the status they inflict, and the survivor's state carries seconds-remaining per status, so leaving StatusKind in types.ts would have made types.ts and enemies.ts mutually dependent. statusKinds.ts imports nothing at all, so it is a true leaf.", + "Repointed 14 files with a scripted rewrite that splits each `from './types'` import across the three owning modules by symbol, rather than hand-editing 36 import sites. Six are test files; every edit is import-path-only, with no assertion or expected value changed.", + "Carried the ENEMY_SPECS and DIFFICULTIES blocks across verbatim, including the comments explaining why spawnKind is always a kind and why `normal` is the identity tier." + ], + "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. This is the exact command reverse-survivors CI runs." + }, + { + "command": "npm run build (tsc --noEmit && vite build)", + "result": "pass", + "evidence": "Built in 101ms, bundle 210.11 kB — exactly the pre-split baseline size. This is the second CI step and the gate that would expose an import cycle as a TDZ error." + }, + { + "command": "python3 comparison of every `name: number` pair across the old types.ts and the three new files, as sorted multisets", + "result": "pass", + "evidence": "191 numeric pairs at baseline, 191 after, compared IDENTICAL. This is the check the test suite cannot make: a mistyped enemy hp or difficulty multiplier would still pass 160 tests and a 100% coverage gate, since the tests assert behaviour rather than pinning the balance table." + }, + { + "command": "grep of statusKinds.ts and enemies.ts import lines", + "result": "pass", + "evidence": "statusKinds.ts imports nothing; enemies.ts imports only `type { StatusKind } from './statusKinds'`; neither imports types.ts. The graph is acyclic." + }, + { + "command": "wc -l src/core/types.ts src/core/enemies.ts src/core/statusKinds.ts", + "result": "pass", + "evidence": "types.ts 157, enemies.ts 143, statusKinds.ts 25 — all three under the 250-line cap, down from a single 295-line file." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "No reverse_survivors file appears in the over-cap listing at all; this and the sim.ts split together clear the whole project." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "All configured hooks pass on the touched files." + } + ], + "risks": [ + "types.ts remains the widest-imported module in the repo, and the split means some consumers now import from two or three modules where they previously imported from one. That is the cost of not leaving a re-export barrel, which the user explicitly asked for in this series.", + "The scripted rewrite classified symbols by name against two fixed sets. A symbol added to types.ts later would default to staying in types.ts, which is the safe direction, but the script is a one-off and is not committed.", + "statusKinds.ts is a very small module (25 lines). It exists to break a real cycle rather than for size, so it should not be folded back into types.ts even though it looks mergeable." + ], + "rollback": [ + "git revert the commit — 14 edited files, two 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." + ] +} diff --git a/reverse_survivors/src/core/director.test.ts b/reverse_survivors/src/core/director.test.ts index 3860aaf7..526d89e9 100644 --- a/reverse_survivors/src/core/director.test.ts +++ b/reverse_survivors/src/core/director.test.ts @@ -3,8 +3,8 @@ import { applyAction, edgeSpawnPos, regenRate, tickDirector, waveCost } from './ import { createInitialState } from './sim' import { snapshotOf } from './snapshot' import type { GameState } from './types' -import { - ARENA, BOSS_COOLDOWN, ENERGY_CAP, } from './types' +import { ARENA, ENERGY_CAP } from './types' +import { BOSS_COOLDOWN } from './enemies' const fresh = (seed = 1): GameState => createInitialState(seed) diff --git a/reverse_survivors/src/core/director.ts b/reverse_survivors/src/core/director.ts index 63e4c391..8b298e29 100644 --- a/reverse_survivors/src/core/director.ts +++ b/reverse_survivors/src/core/director.ts @@ -1,11 +1,9 @@ import { makeEnemy } from './simUnit' import { nextFloat, pick } from './rng' -import type { BossKind, DirectorAction, EdgeId, GameState, PowerKind, UnitKind } from './types' -import { - AMBUSH_RADIUS, ARENA, BOSS_COOLDOWN, BOSS_ORDER, BOSS_UNLOCK_AT, EDGE_ORDER, ENERGY_CAP, - ENEMY_SPECS, FRENZY_DURATION, GOLDEN_ANGLE, POWER_COOLDOWN, POWER_COST, POWER_ORDER, - RIFT_DURATION, -} from './types' +import type { DirectorAction, EdgeId, GameState, PowerKind } from './types' +import type { BossKind, UnitKind } from './enemies' +import { AMBUSH_RADIUS, ARENA, EDGE_ORDER, ENERGY_CAP, FRENZY_DURATION, GOLDEN_ANGLE, POWER_COOLDOWN, POWER_COST, POWER_ORDER, RIFT_DURATION } from './types' +import { BOSS_COOLDOWN, BOSS_ORDER, BOSS_UNLOCK_AT, ENEMY_SPECS } from './enemies' import type { Vec } from './vec' import { clamp } from './vec' diff --git a/reverse_survivors/src/core/directorPowers.test.ts b/reverse_survivors/src/core/directorPowers.test.ts index a34a6935..eee0cbab 100644 --- a/reverse_survivors/src/core/directorPowers.test.ts +++ b/reverse_survivors/src/core/directorPowers.test.ts @@ -4,10 +4,8 @@ import { describe, expect, it } from 'vitest' import { applyAction, edgeSpawnPos, regenRate, tickDirector, } from './director' import { createInitialState } from './sim' import type { GameState } from './types' -import { - AMBUSH_RADIUS, ARENA, ENEMY_SPECS, FRENZY_DURATION, - POWER_COOLDOWN, POWER_COST, RIFT_DURATION, -} from './types' +import { AMBUSH_RADIUS, ARENA, FRENZY_DURATION, POWER_COOLDOWN, POWER_COST, RIFT_DURATION } from './types' +import { ENEMY_SPECS } from './enemies' const fresh = (seed = 1): GameState => createInitialState(seed) diff --git a/reverse_survivors/src/core/enemies.ts b/reverse_survivors/src/core/enemies.ts new file mode 100644 index 00000000..4d3604cf --- /dev/null +++ b/reverse_survivors/src/core/enemies.ts @@ -0,0 +1,144 @@ +/** + * The enemy roster: what kinds exist, the shape of a unit's stat block, and the + * table of every unit's and boss's numbers. + * + * Split out of `types.ts` because it is data rather than vocabulary — this is + * the file to edit when balancing, and the one that grows when a new kind is + * added. It imports only the status vocabulary it annotates specs with, so it + * stays a leaf alongside `types.ts` rather than depending on it circularly. + */ + +import type { StatusKind } from './statusKinds' + +export type UnitKind = + | 'rusher' | 'stalker' | 'tank' | 'bomber' | 'splitter' | 'artillery' | 'warden' +export type BossKind = 'colossus' | 'hivemind' | 'leech' +export type EnemyKind = UnitKind | BossKind +export type MoveMode = 'chase' | 'skirmish' + +export interface EnemySpec { + readonly name: string + readonly cost: number + readonly hp: number + readonly speed: number + readonly radius: number + readonly contactDamage: number + readonly xp: number + readonly move: MoveMode + /** 0 disables ranged fire. */ + readonly projDamage: number + readonly projSpeed: number + readonly fireRange: number + readonly fireCooldown: number + readonly keepDistance: number + /** True: dies on contact and blasts. */ + readonly detonates: boolean + readonly blastRadius: number + readonly blastDamage: number + /** 0 disables minion spawning. */ + readonly spawnEvery: number + /** What `spawnEvery` and `splitCount` emit. Always a kind — the `> 0` gates guard every read. */ + readonly spawnKind: UnitKind + /** 0 disables on-death splitting. */ + readonly splitCount: number + /** Status inflicted on contact (or on projectile hit). Inert when duration is 0. */ + readonly debuff: StatusKind + /** 0 means this kind inflicts nothing — `applyStatus` becomes a no-op. */ + readonly debuffDuration: number +} + +const spec = (s: EnemySpec): EnemySpec => s + +export const ENEMY_SPECS: Record = { + rusher: spec({ + name: 'Rusher', cost: 10, hp: 14, speed: 170, radius: 9, + contactDamage: 6, xp: 4, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'slow', debuffDuration: 0, + }), + stalker: spec({ + name: 'Stalker', cost: 24, hp: 20, speed: 110, radius: 10, + contactDamage: 4, xp: 8, move: 'skirmish', + projDamage: 7, projSpeed: 260, fireRange: 250, fireCooldown: 1.6, keepDistance: 200, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'slow', debuffDuration: 0, + }), + tank: spec({ + name: 'Tank', cost: 45, hp: 120, speed: 55, radius: 18, + contactDamage: 14, xp: 20, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'slow', debuffDuration: 0, + }), + bomber: spec({ + name: 'Bomber', cost: 30, hp: 16, speed: 140, radius: 10, + contactDamage: 0, xp: 10, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: true, blastRadius: 70, blastDamage: 22, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'slow', debuffDuration: 0, + }), + splitter: spec({ + name: 'Splitter', cost: 34, hp: 30, speed: 95, radius: 13, + contactDamage: 5, xp: 12, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 3, + debuff: 'slow', debuffDuration: 0, + }), + artillery: spec({ + name: 'Artillery', cost: 52, hp: 26, speed: 70, radius: 12, + contactDamage: 0, xp: 16, move: 'skirmish', + projDamage: 13, projSpeed: 190, fireRange: 460, fireCooldown: 2.6, keepDistance: 420, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'suppress', debuffDuration: 2.5, + }), + warden: spec({ + name: 'Warden', cost: 40, hp: 34, speed: 215, radius: 8, + contactDamage: 7, xp: 14, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'slow', debuffDuration: 2.0, + }), + colossus: spec({ + name: 'Colossus', cost: 150, hp: 700, speed: 40, radius: 34, + contactDamage: 30, xp: 80, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'slow', debuffDuration: 0, + }), + hivemind: spec({ + name: 'Hivemind', cost: 220, hp: 420, speed: 60, radius: 28, + contactDamage: 10, xp: 100, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 2.5, + spawnKind: 'rusher', splitCount: 0, + debuff: 'slow', debuffDuration: 0, + }), + leech: spec({ + name: 'Leech', cost: 190, hp: 500, speed: 75, radius: 30, + contactDamage: 12, xp: 90, move: 'chase', + projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, + detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, + spawnKind: 'rusher', splitCount: 0, + debuff: 'bleed', debuffDuration: 3.5, + }), +} as const + +export const UNIT_ORDER: readonly [UnitKind, ...UnitKind[]] = [ + 'rusher', 'stalker', 'tank', 'bomber', 'splitter', 'artillery', 'warden', +] +export const BOSS_ORDER: readonly [BossKind, ...BossKind[]] = ['colossus', 'hivemind', 'leech'] +export const BOSS_UNLOCK_AT: Record = { + colossus: 60, hivemind: 120, leech: 180, +} +export const BOSS_COOLDOWN: Record = { + colossus: 25, hivemind: 30, leech: 28, +} diff --git a/reverse_survivors/src/core/sim.test.ts b/reverse_survivors/src/core/sim.test.ts index c9a70344..ddec088b 100644 --- a/reverse_survivors/src/core/sim.test.ts +++ b/reverse_survivors/src/core/sim.test.ts @@ -2,9 +2,8 @@ import { describe, expect, it } from 'vitest' 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' +import type { GameState } from './types' +import { ARENA, GAME_DURATION } from './types' const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 } diff --git a/reverse_survivors/src/core/simEnemies.test.ts b/reverse_survivors/src/core/simEnemies.test.ts index b0c1a98f..b7f790db 100644 --- a/reverse_survivors/src/core/simEnemies.test.ts +++ b/reverse_survivors/src/core/simEnemies.test.ts @@ -4,8 +4,7 @@ import { describe, expect, it } from 'vitest' import { createInitialState, step } from './sim' import { makeEnemy } from './simUnit' import type { GameState, Projectile } from './types' -import { - ARENA, } from './types' +import { ARENA } from './types' const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 } diff --git a/reverse_survivors/src/core/simFrenzy.test.ts b/reverse_survivors/src/core/simFrenzy.test.ts index 0ce9e22f..746f6a9b 100644 --- a/reverse_survivors/src/core/simFrenzy.test.ts +++ b/reverse_survivors/src/core/simFrenzy.test.ts @@ -7,9 +7,8 @@ import { describe, expect, it } from 'vitest' import { createInitialState, step } from './sim' import { makeEnemy } from './simUnit' -import type { GameState, } from './types' -import { - ARENA, DIFFICULTIES, FRENZY_DAMAGE, FRENZY_SPEED, } from './types' +import type { GameState } from './types' +import { ARENA, DIFFICULTIES, FRENZY_DAMAGE, FRENZY_SPEED } from './types' const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 } diff --git a/reverse_survivors/src/core/simSurvivor.ts b/reverse_survivors/src/core/simSurvivor.ts index b8094b46..7a91c1de 100644 --- a/reverse_survivors/src/core/simSurvivor.ts +++ b/reverse_survivors/src/core/simSurvivor.ts @@ -9,7 +9,8 @@ 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 { ARENA, UPGRADE_POOL } from './types' +import { STATUS_POWER } from './statusKinds' import { CENTER, DANGER_RADIUS, FIRE_RANGE, PROJ_SPEED, PROJ_TTL, SURVIVOR_RADIUS, } from './simUnit' diff --git a/reverse_survivors/src/core/simThreats.ts b/reverse_survivors/src/core/simThreats.ts index 2ba38e66..2b6a9c3f 100644 --- a/reverse_survivors/src/core/simThreats.ts +++ b/reverse_survivors/src/core/simThreats.ts @@ -8,8 +8,10 @@ */ 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 type { Enemy, GameState, Survivor } from './types' +import type { EnemyKind, EnemySpec } from './enemies' +import { ARENA, CONTACT_COOLDOWN, FRENZY_DAMAGE, FRENZY_SPEED } from './types' +import { ENEMY_SPECS } from './enemies' import { ENEMY_PROJ_TTL, SPLIT_RADIUS, SURVIVOR_RADIUS, enemySpeed, makeEnemy, } from './simUnit' diff --git a/reverse_survivors/src/core/simUnit.ts b/reverse_survivors/src/core/simUnit.ts index 8f4fdad0..cb56300f 100644 --- a/reverse_survivors/src/core/simUnit.ts +++ b/reverse_survivors/src/core/simUnit.ts @@ -8,8 +8,10 @@ * if these lived beside their callers. */ -import type { Enemy, EnemyKind, GameState } from './types' -import { ARENA, ENEMY_SPECS } from './types' +import type { Enemy, GameState } from './types' +import type { EnemyKind } from './enemies' +import { ARENA } from './types' +import { ENEMY_SPECS } from './enemies' import type { Vec } from './vec' export const SURVIVOR_RADIUS = 12 diff --git a/reverse_survivors/src/core/simUnits.test.ts b/reverse_survivors/src/core/simUnits.test.ts index 00d494b4..e7e13294 100644 --- a/reverse_survivors/src/core/simUnits.test.ts +++ b/reverse_survivors/src/core/simUnits.test.ts @@ -6,9 +6,8 @@ import { describe, expect, it } from 'vitest' import { createInitialState, step } from './sim' import { makeEnemy, SPLIT_RADIUS } from './simUnit' import type { GameState, Projectile } from './types' -import { - ARENA, STATUS_POWER, -} from './types' +import { ARENA } from './types' +import { STATUS_POWER } from './statusKinds' const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 } diff --git a/reverse_survivors/src/core/snapshot.ts b/reverse_survivors/src/core/snapshot.ts index 98143d60..c69f89a0 100644 --- a/reverse_survivors/src/core/snapshot.ts +++ b/reverse_survivors/src/core/snapshot.ts @@ -1,11 +1,10 @@ import { waveCost } from './director' -import type { - BossKind, EdgeId, GameState, PowerKind, Status, StatusKind, UnitKind, UpgradeId, -} from './types' -import { - BOSS_ORDER, BOSS_UNLOCK_AT, EDGE_NAMES, EDGE_ORDER, ENERGY_CAP, ENEMY_SPECS, POWER_COST, - POWER_NAMES, POWER_ORDER, STATUS_LABELS, STATUS_ORDER, UNIT_ORDER, -} from './types' +import type { EdgeId, GameState, PowerKind, Status, UpgradeId } from './types' +import type { BossKind, UnitKind } from './enemies' +import type { StatusKind } from './statusKinds' +import { EDGE_NAMES, EDGE_ORDER, ENERGY_CAP, POWER_COST, POWER_NAMES, POWER_ORDER } from './types' +import { BOSS_ORDER, BOSS_UNLOCK_AT, ENEMY_SPECS, UNIT_ORDER } from './enemies' +import { STATUS_LABELS, STATUS_ORDER } from './statusKinds' export type BossPhase = 'locked' | 'cooling' | 'ready' diff --git a/reverse_survivors/src/core/status.test.ts b/reverse_survivors/src/core/status.test.ts index 4c9a71a5..e3b75519 100644 --- a/reverse_survivors/src/core/status.test.ts +++ b/reverse_survivors/src/core/status.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { applyStatus, factor, isOn, tickStatuses } from './status' import type { Survivor } from './types' -import { STATUS_POWER } from './types' +import { STATUS_POWER } from './statusKinds' const sv = (): Survivor => ({ pos: { x: 0, y: 0 }, diff --git a/reverse_survivors/src/core/status.ts b/reverse_survivors/src/core/status.ts index 8ef2b3fc..907065ae 100644 --- a/reverse_survivors/src/core/status.ts +++ b/reverse_survivors/src/core/status.ts @@ -1,5 +1,6 @@ -import type { StatusKind, Survivor } from './types' -import { STATUS_ORDER } from './types' +import type { Survivor } from './types' +import type { StatusKind } from './statusKinds' +import { STATUS_ORDER } from './statusKinds' /** * 1 while the timer is live, 0 once it has expired. diff --git a/reverse_survivors/src/core/statusKinds.ts b/reverse_survivors/src/core/statusKinds.ts new file mode 100644 index 00000000..9c80f44d --- /dev/null +++ b/reverse_survivors/src/core/statusKinds.ts @@ -0,0 +1,25 @@ +/** + * The status effects the survivor can be under. + * + * Its own module because both `types.ts` and `enemies.ts` need it: enemy specs + * name the status they inflict, and the survivor's state carries the seconds + * remaining per status. Keeping it here lets both import it without either + * importing the other. + */ + +export type StatusKind = 'slow' | 'suppress' | 'bleed' + +export const STATUS_ORDER: readonly [StatusKind, ...StatusKind[]] = ['slow', 'suppress', 'bleed'] + +/** Multiplier applied to the survivor while the matching status is live. */ +export const STATUS_POWER: Record = { + slow: 0.55, // move speed + suppress: 1.75, // fire cooldown — higher is slower + bleed: 0, // regen +} + +export const STATUS_LABELS: Record = { + slow: 'mired', + suppress: 'stifled', + bleed: 'unknitting', +} diff --git a/reverse_survivors/src/core/types.ts b/reverse_survivors/src/core/types.ts index 38fd2370..8afad3d0 100644 --- a/reverse_survivors/src/core/types.ts +++ b/reverse_survivors/src/core/types.ts @@ -1,5 +1,16 @@ +/** + * The simulation's vocabulary: arena constants, difficulty tiers, the director's + * powers, and the shape of the game state itself. + * + * The enemy roster lives in `enemies.ts` and the status effects in + * `statusKinds.ts`; both are imported here and re-exported by neither, so + * consumers name the module that owns what they use. + */ + import type { Rng } from './rng' import type { Vec } from './vec' +import type { BossKind, EnemyKind, UnitKind } from './enemies' +import type { StatusKind } from './statusKinds' export const ARENA = { w: 960, h: 600 } as const export const GAME_DURATION = 300 @@ -7,155 +18,6 @@ export const CONTACT_COOLDOWN = 0.5 export const ENERGY_CAP = 400 export const ENERGY_START = 30 -export type StatusKind = 'slow' | 'suppress' | 'bleed' -export const STATUS_ORDER: readonly [StatusKind, ...StatusKind[]] = ['slow', 'suppress', 'bleed'] - -/** Multiplier applied to the survivor while the matching status is live. */ -export const STATUS_POWER: Record = { - slow: 0.55, // move speed - suppress: 1.75, // fire cooldown — higher is slower - bleed: 0, // regen -} - -export const STATUS_LABELS: Record = { - slow: 'mired', - suppress: 'stifled', - bleed: 'unknitting', -} - -export type UnitKind = - | 'rusher' | 'stalker' | 'tank' | 'bomber' | 'splitter' | 'artillery' | 'warden' -export type BossKind = 'colossus' | 'hivemind' | 'leech' -export type EnemyKind = UnitKind | BossKind -export type MoveMode = 'chase' | 'skirmish' - -export interface EnemySpec { - readonly name: string - readonly cost: number - readonly hp: number - readonly speed: number - readonly radius: number - readonly contactDamage: number - readonly xp: number - readonly move: MoveMode - /** 0 disables ranged fire. */ - readonly projDamage: number - readonly projSpeed: number - readonly fireRange: number - readonly fireCooldown: number - readonly keepDistance: number - /** True: dies on contact and blasts. */ - readonly detonates: boolean - readonly blastRadius: number - readonly blastDamage: number - /** 0 disables minion spawning. */ - readonly spawnEvery: number - /** What `spawnEvery` and `splitCount` emit. Always a kind — the `> 0` gates guard every read. */ - readonly spawnKind: UnitKind - /** 0 disables on-death splitting. */ - readonly splitCount: number - /** Status inflicted on contact (or on projectile hit). Inert when duration is 0. */ - readonly debuff: StatusKind - /** 0 means this kind inflicts nothing — `applyStatus` becomes a no-op. */ - readonly debuffDuration: number -} - -const spec = (s: EnemySpec): EnemySpec => s - -export const ENEMY_SPECS: Record = { - rusher: spec({ - name: 'Rusher', cost: 10, hp: 14, speed: 170, radius: 9, - contactDamage: 6, xp: 4, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'slow', debuffDuration: 0, - }), - stalker: spec({ - name: 'Stalker', cost: 24, hp: 20, speed: 110, radius: 10, - contactDamage: 4, xp: 8, move: 'skirmish', - projDamage: 7, projSpeed: 260, fireRange: 250, fireCooldown: 1.6, keepDistance: 200, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'slow', debuffDuration: 0, - }), - tank: spec({ - name: 'Tank', cost: 45, hp: 120, speed: 55, radius: 18, - contactDamage: 14, xp: 20, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'slow', debuffDuration: 0, - }), - bomber: spec({ - name: 'Bomber', cost: 30, hp: 16, speed: 140, radius: 10, - contactDamage: 0, xp: 10, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: true, blastRadius: 70, blastDamage: 22, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'slow', debuffDuration: 0, - }), - splitter: spec({ - name: 'Splitter', cost: 34, hp: 30, speed: 95, radius: 13, - contactDamage: 5, xp: 12, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 3, - debuff: 'slow', debuffDuration: 0, - }), - artillery: spec({ - name: 'Artillery', cost: 52, hp: 26, speed: 70, radius: 12, - contactDamage: 0, xp: 16, move: 'skirmish', - projDamage: 13, projSpeed: 190, fireRange: 460, fireCooldown: 2.6, keepDistance: 420, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'suppress', debuffDuration: 2.5, - }), - warden: spec({ - name: 'Warden', cost: 40, hp: 34, speed: 215, radius: 8, - contactDamage: 7, xp: 14, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'slow', debuffDuration: 2.0, - }), - colossus: spec({ - name: 'Colossus', cost: 150, hp: 700, speed: 40, radius: 34, - contactDamage: 30, xp: 80, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'slow', debuffDuration: 0, - }), - hivemind: spec({ - name: 'Hivemind', cost: 220, hp: 420, speed: 60, radius: 28, - contactDamage: 10, xp: 100, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 2.5, - spawnKind: 'rusher', splitCount: 0, - debuff: 'slow', debuffDuration: 0, - }), - leech: spec({ - name: 'Leech', cost: 190, hp: 500, speed: 75, radius: 30, - contactDamage: 12, xp: 90, move: 'chase', - projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0, - detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0, - spawnKind: 'rusher', splitCount: 0, - debuff: 'bleed', debuffDuration: 3.5, - }), -} as const - -export const UNIT_ORDER: readonly [UnitKind, ...UnitKind[]] = [ - 'rusher', 'stalker', 'tank', 'bomber', 'splitter', 'artillery', 'warden', -] -export const BOSS_ORDER: readonly [BossKind, ...BossKind[]] = ['colossus', 'hivemind', 'leech'] -export const BOSS_UNLOCK_AT: Record = { - colossus: 60, hivemind: 120, leech: 180, -} -export const BOSS_COOLDOWN: Record = { - colossus: 25, hivemind: 30, leech: 28, -} - export type DifficultyId = 'haunting' | 'normal' | 'crusade' export interface DifficultyConfig { diff --git a/reverse_survivors/src/render/draw.ts b/reverse_survivors/src/render/draw.ts index 8029592a..2ae30fbb 100644 --- a/reverse_survivors/src/render/draw.ts +++ b/reverse_survivors/src/render/draw.ts @@ -1,6 +1,9 @@ import { FIRE_RANGE, SURVIVOR_RADIUS } from '../core/simUnit' -import type { EnemyKind, GameState } from '../core/types' -import { ARENA, ENEMY_SPECS, STATUS_ORDER } from '../core/types' +import type { GameState } from '../core/types' +import type { EnemyKind } from '../core/enemies' +import { ARENA } from '../core/types' +import { ENEMY_SPECS } from '../core/enemies' +import { STATUS_ORDER } from '../core/statusKinds' /** Structural subset of CanvasRenderingContext2D — stubbable in tests. */ export interface Ctx2D {