mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 14:27:49 +01:00
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
parent
44b8b79207
commit
1342a60a65
@ -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."
|
||||
}
|
||||
@ -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 <changed 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."
|
||||
]
|
||||
}
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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<UpgradeId, (sv: Survivor) => 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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
132
reverse_survivors/src/core/simSurvivor.ts
Normal file
132
reverse_survivors/src/core/simSurvivor.ts
Normal file
@ -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<UpgradeId, (sv: Survivor) => 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
|
||||
}
|
||||
}
|
||||
168
reverse_survivors/src/core/simThreats.ts
Normal file
168
reverse_survivors/src/core/simThreats.ts
Normal file
@ -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
|
||||
}
|
||||
42
reverse_survivors/src/core/simUnit.ts
Normal file
42
reverse_survivors/src/core/simUnit.ts
Normal file
@ -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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user