mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 17:14:08 +01:00
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
242 lines
8.1 KiB
TypeScript
242 lines
8.1 KiB
TypeScript
/** Splitters, new units and status effects. */
|
|
|
|
/** Enemy behaviour, bombers and projectiles. */
|
|
|
|
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 } from './types'
|
|
import { STATUS_POWER } from './statusKinds'
|
|
|
|
const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 }
|
|
|
|
const fresh = (seed = 1, duration?: number): GameState =>
|
|
duration === undefined ? createInitialState(seed) : createInitialState(seed, duration)
|
|
|
|
const shot = (state: GameState, x: number, y: number, damage: number): Projectile => {
|
|
const p: Projectile = {
|
|
id: state.nextId,
|
|
from: 'survivor',
|
|
pos: { x, y },
|
|
vel: { x: 0, y: 0 },
|
|
radius: 4,
|
|
damage,
|
|
ttl: 1,
|
|
debuff: 'slow',
|
|
debuffDuration: 0,
|
|
}
|
|
state.nextId += 1
|
|
state.projectiles.push(p)
|
|
return p
|
|
}
|
|
|
|
describe('splitter', () => {
|
|
it('dies into three rushers at fixed offsets', () => {
|
|
const s = fresh()
|
|
const sp = makeEnemy(s, 'splitter', { x: 300, y: 300 })
|
|
shot(s, 300, 300, 999)
|
|
step(s, [], 0.001)
|
|
const children = s.enemies.filter((e) => e.id !== sp.id)
|
|
expect(children).toHaveLength(3)
|
|
expect(children.every((c) => c.kind === 'rusher')).toBe(true)
|
|
// Children drift toward the survivor within the same tick, so assert the
|
|
// spawn ring loosely — the exact geometry is pinned by the test below.
|
|
for (const c of children) {
|
|
expect(Math.hypot(c.pos.x - 300, c.pos.y - 300)).toBeLessThan(SPLIT_RADIUS + 5)
|
|
}
|
|
})
|
|
|
|
it('spawns children on a ring at 120-degree offsets', () => {
|
|
const s = fresh()
|
|
makeEnemy(s, 'splitter', { x: 300, y: 300 })
|
|
shot(s, 300, 300, 999)
|
|
// dt of 0 isolates the spawn geometry from the movement that follows it.
|
|
step(s, [], 0)
|
|
const angles = s.enemies
|
|
.map((e) => Math.atan2(e.pos.y - 300, e.pos.x - 300))
|
|
.sort((a, b) => a - b)
|
|
expect(angles).toHaveLength(3)
|
|
// Pairwise gaps without indexing (noUncheckedIndexedAccess would widen those).
|
|
const gaps: number[] = []
|
|
let prev: number | null = null
|
|
for (const a of angles) {
|
|
if (prev !== null) {
|
|
gaps.push(a - prev)
|
|
}
|
|
prev = a
|
|
}
|
|
expect(gaps).toHaveLength(2)
|
|
for (const g of gaps) {
|
|
expect(g).toBeCloseTo((Math.PI * 2) / 3, 5)
|
|
}
|
|
})
|
|
|
|
it('children are deterministic and draw no RNG', () => {
|
|
const run = (): { x: number; y: number }[] => {
|
|
const s = fresh(42)
|
|
makeEnemy(s, 'splitter', { x: 300, y: 300 })
|
|
shot(s, 300, 300, 999)
|
|
step(s, [], 0.001)
|
|
return s.enemies.map((e) => ({ x: e.pos.x, y: e.pos.y }))
|
|
}
|
|
expect(run()).toEqual(run())
|
|
})
|
|
|
|
it('clamps children into the arena when it dies in a corner', () => {
|
|
const s = fresh()
|
|
makeEnemy(s, 'splitter', { x: 2, y: 2 })
|
|
shot(s, 2, 2, 999)
|
|
step(s, [], 0.001)
|
|
for (const e of s.enemies) {
|
|
expect(e.pos.x).toBeGreaterThanOrEqual(10)
|
|
expect(e.pos.y).toBeGreaterThanOrEqual(10)
|
|
}
|
|
})
|
|
|
|
it('a later projectile in the same tick can hit a fresh child', () => {
|
|
const s = fresh()
|
|
makeEnemy(s, 'splitter', { x: 300, y: 300 })
|
|
shot(s, 300, 300, 999)
|
|
shot(s, 300 + SPLIT_RADIUS, 300, 999)
|
|
step(s, [], 0.001)
|
|
expect(s.survivor.kills).toBe(2)
|
|
})
|
|
|
|
it('non-splitting kinds spawn no children', () => {
|
|
const s = fresh()
|
|
makeEnemy(s, 'rusher', { x: 300, y: 300 })
|
|
shot(s, 300, 300, 999)
|
|
step(s, [], 0.001)
|
|
expect(s.enemies).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('new units', () => {
|
|
it('artillery outranges the survivor and fires while it cannot', () => {
|
|
const s = fresh()
|
|
const a = makeEnemy(s, 'artillery', { x: CENTER.x + 400, y: CENTER.y })
|
|
a.fireTimer = 0
|
|
step(s, [], 0.001)
|
|
expect(s.projectiles.some((p) => p.from === 'enemy')).toBe(true)
|
|
expect(s.projectiles.some((p) => p.from === 'survivor')).toBe(false)
|
|
})
|
|
|
|
it('artillery holds station at its keep distance', () => {
|
|
const s = fresh()
|
|
const a = makeEnemy(s, 'artillery', { x: CENTER.x + 420, y: CENTER.y })
|
|
const before = a.pos.x
|
|
step(s, [], 0.016)
|
|
expect(a.pos.x).toBeCloseTo(before, 5)
|
|
})
|
|
|
|
it('warden closes faster than a rusher', () => {
|
|
const s = fresh()
|
|
const w = makeEnemy(s, 'warden', { x: CENTER.x + 300, y: CENTER.y })
|
|
const r = makeEnemy(s, 'rusher', { x: CENTER.x + 300, y: CENTER.y })
|
|
step(s, [], 0.016)
|
|
expect(CENTER.x + 300 - w.pos.x).toBeGreaterThan(CENTER.x + 300 - r.pos.x)
|
|
})
|
|
|
|
it('the hivemind still births rushers via spawnKind', () => {
|
|
const s = fresh()
|
|
const h = makeEnemy(s, 'hivemind', { x: 40, y: 40 })
|
|
h.spawnTimer = 0
|
|
step(s, [], 0.001)
|
|
expect(s.enemies.some((e) => e.kind === 'rusher')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('status effects', () => {
|
|
it('a warden contact mires the survivor and halves its movement', () => {
|
|
const slowed = fresh()
|
|
slowed.survivor.pos = { x: 100, y: CENTER.y }
|
|
makeEnemy(slowed, 'warden', { x: 100, y: CENTER.y })
|
|
step(slowed, [], 0.01)
|
|
// Statuses tick in survivorStep, which runs before enemiesStep applies
|
|
// contact — so a status landing this tick starts at its full duration.
|
|
expect(slowed.survivor.statuses.slow).toBe(2.0)
|
|
|
|
// Control: same displacement, an enemy that inflicts nothing.
|
|
const control = fresh()
|
|
control.survivor.pos = { x: 100, y: CENTER.y }
|
|
makeEnemy(control, 'rusher', { x: 100, y: CENTER.y })
|
|
step(control, [], 0.01)
|
|
expect(control.survivor.statuses.slow).toBe(0)
|
|
|
|
const before = { slowed: slowed.survivor.pos.x, control: control.survivor.pos.x }
|
|
slowed.enemies = []
|
|
control.enemies = []
|
|
step(slowed, [], 0.05)
|
|
step(control, [], 0.05)
|
|
const movedSlowed = slowed.survivor.pos.x - before.slowed
|
|
const movedControl = control.survivor.pos.x - before.control
|
|
expect(movedSlowed).toBeCloseTo(movedControl * STATUS_POWER.slow, 5)
|
|
})
|
|
|
|
it('slow expires and full speed returns', () => {
|
|
const s = fresh()
|
|
s.survivor.pos = { x: 100, y: CENTER.y }
|
|
s.survivor.statuses.slow = 0.02
|
|
step(s, [], 0.05)
|
|
expect(s.survivor.statuses.slow).toBe(0)
|
|
})
|
|
|
|
it('an artillery shell stifles the survivor and lengthens its reload', () => {
|
|
const s = fresh()
|
|
s.projectiles.push({
|
|
id: 900, from: 'enemy', pos: { x: CENTER.x, y: CENTER.y },
|
|
vel: { x: 0, y: 0 }, radius: 5, damage: 13, ttl: 1,
|
|
debuff: 'suppress', debuffDuration: 2.5,
|
|
})
|
|
makeEnemy(s, 'tank', { x: CENTER.x + 200, y: CENTER.y })
|
|
step(s, [], 0.001)
|
|
expect(s.survivor.statuses.suppress).toBeGreaterThan(0)
|
|
// The shot fired this tick reloaded before the status landed; the next one pays.
|
|
s.survivor.fireTimer = 0
|
|
step(s, [], 0.001)
|
|
expect(s.survivor.fireTimer).toBeCloseTo(0.55 * STATUS_POWER.suppress, 5)
|
|
})
|
|
|
|
it('a leech contact blocks regeneration until it expires', () => {
|
|
const s = fresh()
|
|
s.survivor.regen = 5
|
|
s.survivor.hp = 50
|
|
makeEnemy(s, 'leech', { x: CENTER.x, y: CENTER.y })
|
|
const hp0 = s.survivor.hp
|
|
step(s, [], 0.1)
|
|
// Contact damage lands and regen is suppressed, so hp cannot have risen.
|
|
expect(s.survivor.hp).toBeLessThan(hp0)
|
|
expect(s.survivor.statuses.bleed).toBeGreaterThan(0)
|
|
|
|
s.enemies = []
|
|
s.survivor.statuses.bleed = 0
|
|
const hp1 = s.survivor.hp
|
|
step(s, [], 0.1)
|
|
expect(s.survivor.hp).toBeGreaterThan(hp1)
|
|
})
|
|
|
|
it('repeated warden contacts refresh rather than stack', () => {
|
|
const s = fresh()
|
|
makeEnemy(s, 'warden', { x: CENTER.x, y: CENTER.y })
|
|
makeEnemy(s, 'warden', { x: CENTER.x, y: CENTER.y })
|
|
step(s, [], 0.01)
|
|
expect(s.survivor.statuses.slow).toBeLessThanOrEqual(2.0)
|
|
})
|
|
|
|
it('a plain rusher inflicts no status at all', () => {
|
|
const s = fresh()
|
|
makeEnemy(s, 'rusher', { x: CENTER.x, y: CENTER.y })
|
|
step(s, [], 0.01)
|
|
expect(s.survivor.statuses).toEqual({ slow: 0, suppress: 0, bleed: 0 })
|
|
})
|
|
|
|
it('survivor fire never debuffs the survivor', () => {
|
|
const s = fresh()
|
|
makeEnemy(s, 'tank', { x: CENTER.x + 200, y: CENTER.y })
|
|
step(s, [], 0.001)
|
|
const own = s.projectiles.find((p) => p.from === 'survivor')
|
|
expect(own?.debuffDuration).toBe(0)
|
|
})
|
|
})
|