Split reverse_survivors types.ts 295 -> 157 + roster + statuses

The 82-line ENEMY_SPECS table and its kind unions are data, not vocabulary --
that is the block you edit to balance the game. Moved to enemies.ts (143).

statusKinds.ts (25) is not cosmetic: enemy specs name the status they inflict
and the survivor carries seconds-remaining per status, so leaving StatusKind
in types.ts would have made types.ts and enemies.ts mutually dependent. It
imports nothing, so the graph is statusKinds <- enemies <- types.

14 files repointed by script rather than by hand, splitting each types import
across the three owning modules by symbol.

The tests cannot catch a mistyped stat -- they assert behaviour, not the
balance table, so a wrong enemy hp passes 160 tests at 100% coverage. Checked
separately: 191 numeric pairs before, 191 after, compared as sorted multisets,
identical. check and build both pass; bundle 210.11 kB, same as baseline.

No reverse_survivors file is over the 250-line cap now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 09:57:26 +02:00
parent 1342a60a65
commit c2760ef5e3
19 changed files with 301 additions and 189 deletions

View File

@ -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."
}

View File

@ -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 <changed 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."
]
}

View File

@ -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)

View File

@ -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'

View File

@ -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)

View File

@ -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<EnemyKind, EnemySpec> = {
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<BossKind, number> = {
colossus: 60, hivemind: 120, leech: 180,
}
export const BOSS_COOLDOWN: Record<BossKind, number> = {
colossus: 25, hivemind: 30, leech: 28,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -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 }

View File

@ -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'

View File

@ -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 },

View File

@ -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.

View File

@ -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<StatusKind, number> = {
slow: 0.55, // move speed
suppress: 1.75, // fire cooldown — higher is slower
bleed: 0, // regen
}
export const STATUS_LABELS: Record<StatusKind, string> = {
slow: 'mired',
suppress: 'stifled',
bleed: 'unknitting',
}

View File

@ -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<StatusKind, number> = {
slow: 0.55, // move speed
suppress: 1.75, // fire cooldown — higher is slower
bleed: 0, // regen
}
export const STATUS_LABELS: Record<StatusKind, string> = {
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<EnemyKind, EnemySpec> = {
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<BossKind, number> = {
colossus: 60, hivemind: 120, leech: 180,
}
export const BOSS_COOLDOWN: Record<BossKind, number> = {
colossus: 25, hivemind: 30, leech: 28,
}
export type DifficultyId = 'haunting' | 'normal' | 'crusade'
export interface DifficultyConfig {

View File

@ -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 {