reverse_survivors: survivor status effects (slow, suppress, bleed)

The warden mires, the artillery stifles, the leech unknits. All three need
contact, so a survivor that kites cleanly takes none of them — debuffs
punish positional failure rather than taxing existence.

isOn/factor are written as arithmetic rather than comparisons:
Math.min(1, Math.ceil(t)) is 1 while a timer is live and 0 once it expires,
so consumers multiply instead of branching. The whole status module
therefore contributes zero branches, which is what makes the 100% gate
affordable for a system this cross-cutting. Statuses are applied
unconditionally too; a 0-duration spec is a provable no-op.

applyStatus uses Math.max, so re-applying refreshes rather than stacks.
That caps a horde's total lockdown at one duration by construction — a
structural guarantee, not a tuned number.

123 tests, 100% coverage. Branches 117 -> 119: the draw halo gate and the
snapshot chip filter. The status logic itself adds none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
This commit is contained in:
Krzysztof Rudnicki 2026-08-09 23:08:51 +02:00
parent f0712d3854
commit 43b63ac266
13 changed files with 426 additions and 11 deletions

View File

@ -0,0 +1,16 @@
{
"title": "Reverse Survivors: survivor status effects",
"objective": "Give the survivor three debuff channels - slow (movement), suppress (fire rate) and bleed (regen block) - so ranged and fast units have a lever beyond raw damage. The system must be cross-cutting yet contribute zero branches, so the 100% coverage gate stays affordable.",
"acceptance_criteria": [
"`npm run check` passes standalone: tsc --noEmit clean, eslint --max-warnings 0 clean, vitest at 100% statements/branches/functions/lines.",
"The status module itself adds zero branches, verified against the v8 branch count (117 -> 119 accounted for solely by the draw halo gate and the snapshot chip filter).",
"Re-applying a status refreshes rather than stacks, so total lockdown is capped at one duration regardless of horde size.",
"An enemy that inflicts nothing leaves all three timers at exactly 0.",
"Survivor-fired projectiles can never debuff the survivor."
],
"out_of_scope": [
"Director powers and difficulty tiers, which ship as separate commits.",
"Aura or ranged-application debuffs: all three require contact by design, so a cleanly kiting survivor takes none."
],
"verifier": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage at 100%), plus targeted sim.test.ts assertions comparing slowed movement against an unslowed control run"
}

View File

@ -0,0 +1,38 @@
{
"intent": "Give the survivor three status debuffs - slow, suppress (fire rate) and bleed (regen block) - so the new ranged and fast units have a lever beyond raw damage.",
"scope": [
"reverse_survivors/src/core/status.ts (new leaf module) and status.test.ts",
"reverse_survivors/src/core/types.ts (StatusKind, STATUS_POWER/LABELS, per-spec debuff fields, Survivor.statuses, Projectile payload)",
"reverse_survivors/src/core/sim.ts (tick and consume), snapshot.ts (chips), src/ui/Hud.tsx, src/render/draw.ts (halo), src/styles.css",
"Non-goal: director powers and difficulty tiers ship as their own commits"
],
"changes": [
"isOn/factor are arithmetic (Math.min(1, Math.ceil(t))) rather than comparisons, so the whole status system contributes ZERO branches; consumers multiply by the result instead of branching on it.",
"applyStatus uses Math.max, making refresh-not-stack a structural guarantee: a horde can never chain-lock the survivor past one status duration, regardless of how many enemies land hits.",
"Statuses are applied unconditionally at contact and projectile-hit sites; a 0-duration spec makes the call a provable no-op, which is cheaper than a guard branch under the 100% gate.",
"Projectiles carry their own debuff payload so a hit needs no lookup of a possibly-dead firer.",
"Statuses tick at the top of survivorStep, before they are read, so a status applied on tick N is felt from N+1 and an inert application is never observable.",
"Suppression scales the reload rather than the countdown, keeping the remaining timer linear in dt so replays and assertions stay exact.",
"The draw.ts halo is gated so an unafflicted survivor renders exactly as before, keeping the existing BASE draw-call counts valid."
],
"verification": [
{
"command": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage)",
"result": "pass",
"evidence": "123 tests passed. Coverage 100% on all four metrics: statements 446/446, branches 119/119, functions 84/84, lines 429/429. Branch count rose 117 -> 119, accounted for by the halo gate and the snapshot chip filter; the status module itself adds none."
},
{
"command": "targeted assertions in sim.test.ts",
"result": "pass",
"evidence": "Warden contact slows movement by exactly STATUS_POWER.slow against an unslowed control run; artillery shells lengthen the next reload to 0.55 * 1.75; a leech contact blocks regen and it resumes on expiry; two simultaneous warden contacts do not exceed the 2.0s cap; a plain rusher leaves all three timers at 0."
}
],
"risks": [
"Bleed is a no-op unless the survivor has bought the regen upgrade, since it starts at regen 0. This is intended - the Leech counters a specific build - and matches the README description.",
"All three debuffs require contact, so a survivor that kites cleanly takes none of them. This is deliberate: debuffs punish positional failure rather than taxing existence."
],
"rollback": [
"git revert the commit; src/core/status.ts and status.test.ts are new files and disappear with it.",
"After rollback run `npm run check` in reverse_survivors/ and confirm the suite passes at 100% coverage."
]
}

View File

@ -4,7 +4,7 @@ import {
xpForLevel,
} from './sim'
import type { GameState, Projectile } from './types'
import { ARENA, GAME_DURATION } from './types'
import { ARENA, GAME_DURATION, STATUS_POWER } from './types'
const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 }
@ -20,6 +20,8 @@ const shot = (state: GameState, x: number, y: number, damage: number): Projectil
radius: 4,
damage,
ttl: 1,
debuff: 'slow',
debuffDuration: 0,
}
state.nextId += 1
state.projectiles.push(p)
@ -312,6 +314,7 @@ describe('projectiles', () => {
s.projectiles.push({
id: 500, from: 'enemy', pos: { x: CENTER.x, y: CENTER.y },
vel: { x: 0, y: 0 }, radius: 5, damage: 7, ttl: 1,
debuff: 'slow', debuffDuration: 0,
})
step(s, [], 0.001)
expect(s.survivor.hp).toBe(93)
@ -323,6 +326,7 @@ describe('projectiles', () => {
s.projectiles.push({
id: 501, from: 'enemy', pos: { x: 30, y: 30 },
vel: { x: 0, y: 0 }, radius: 5, damage: 7, ttl: 1,
debuff: 'slow', debuffDuration: 0,
})
step(s, [], 0.001)
expect(s.survivor.hp).toBe(100)
@ -485,3 +489,96 @@ describe('new units', () => {
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)
})
})

View File

@ -1,10 +1,12 @@
import { applyAction, tickDirector } from './director'
import { createRng, pick } from './rng'
import { applyStatus, factor, tickStatuses } from './status'
import type {
DirectorAction, Enemy, EnemyKind, EnemySpec, GameState, Survivor, UpgradeId,
} from './types'
import {
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, GAME_DURATION, UPGRADE_POOL,
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, GAME_DURATION, STATUS_POWER,
UPGRADE_POOL,
} from './types'
import type { Vec } from './vec'
import { clamp, dist, toward } from './vec'
@ -33,6 +35,7 @@ const createSurvivor = (): Survivor => ({
xp: 0,
xpNext: xpForLevel(1),
kills: 0,
statuses: { slow: 0, suppress: 0, bleed: 0 },
})
export const createInitialState = (seed: number, duration = GAME_DURATION): GameState => ({
@ -110,6 +113,13 @@ const nearestEnemy = (state: GameState): Enemy | null => {
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) {
@ -123,14 +133,14 @@ const survivorStep = (state: GameState, dt: number): void => {
const fleeMag = Math.hypot(fleeX, fleeY)
let vel: Vec = { x: 0, y: 0 }
if (fleeMag > 0) {
vel = { x: (fleeX / fleeMag) * sv.speed, y: (fleeY / fleeMag) * sv.speed }
vel = { x: (fleeX / fleeMag) * speed, y: (fleeY / fleeMag) * speed }
} else if (dist(sv.pos, CENTER) > 4) {
vel = toward(sv.pos, CENTER, sv.speed * 0.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 * dt)
sv.hp = Math.min(sv.maxHp, sv.hp + sv.regen * regenMul * dt)
sv.fireTimer = Math.max(0, sv.fireTimer - dt)
const target = nearestEnemy(state)
@ -148,9 +158,13 @@ const survivorStep = (state: GameState, dt: number): void => {
radius: 4,
damage: sv.damage,
ttl: PROJ_TTL,
debuff: 'slow',
debuffDuration: 0,
})
}
sv.fireTimer = sv.fireCooldown
// 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
}
}
@ -219,6 +233,9 @@ const enemiesStep = (state: GameState, dt: number): void => {
detonate(state, e)
} else if (e.hitTimer === 0) {
sv.hp -= spec.contactDamage
// 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
}
}
@ -236,6 +253,8 @@ const enemiesStep = (state: GameState, dt: number): void => {
radius: 5,
damage: spec.projDamage,
ttl: ENEMY_PROJ_TTL,
debuff: spec.debuff,
debuffDuration: spec.debuffDuration,
})
e.fireTimer = spec.fireCooldown
}
@ -284,6 +303,7 @@ const projectilesStep = (state: GameState, dt: number): void => {
}
} 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)

View File

@ -1,7 +1,7 @@
import { waveCost } from './director'
import type { BossKind, GameState, Status, UnitKind, UpgradeId } from './types'
import type { BossKind, GameState, Status, StatusKind, UnitKind, UpgradeId } from './types'
import {
BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS, UNIT_ORDER,
BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS, STATUS_LABELS, STATUS_ORDER, UNIT_ORDER,
} from './types'
export type BossPhase = 'locked' | 'cooling' | 'ready'
@ -28,6 +28,12 @@ export interface UpgradeEntry {
id: UpgradeId
}
export interface StatusChip {
kind: StatusKind
label: string
seconds: number
}
export interface HudSnapshot {
status: Status
t: number
@ -44,6 +50,7 @@ export interface HudSnapshot {
upgrades: UpgradeEntry[]
units: UnitButton[]
bosses: BossButton[]
statuses: StatusChip[]
outcomeTime: number
}
@ -71,6 +78,13 @@ export const snapshotOf = (state: GameState): HudSnapshot => ({
survivorLevel: state.survivor.level,
kills: state.survivor.kills,
upgrades: state.upgrades.map((id, i) => ({ n: i + 1, id })).slice(-3),
statuses: STATUS_ORDER
.map((kind) => ({
kind,
label: STATUS_LABELS[kind],
seconds: Math.ceil(state.survivor.statuses[kind]),
}))
.filter((chip) => chip.seconds > 0),
units: UNIT_ORDER.map((kind) => ({
kind,
name: ENEMY_SPECS[kind].name,

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { applyStatus, factor, isOn, tickStatuses } from './status'
import type { Survivor } from './types'
import { STATUS_POWER } from './types'
const sv = (): Survivor => ({
pos: { x: 0, y: 0 },
hp: 100,
maxHp: 100,
speed: 150,
regen: 0,
damage: 8,
multishot: 1,
fireCooldown: 0.55,
fireTimer: 0,
level: 1,
xp: 0,
xpNext: 20,
kills: 0,
statuses: { slow: 0, suppress: 0, bleed: 0 },
})
describe('isOn', () => {
it('is 0 only at exactly zero', () => {
expect(isOn(0)).toBe(0)
})
it('is 1 for any live remainder, however small', () => {
expect(isOn(0.001)).toBe(1)
expect(isOn(5)).toBe(1)
})
})
describe('factor', () => {
it('is 1 when the timer is idle', () => {
expect(factor(0, STATUS_POWER.slow)).toBe(1)
})
it('is the active multiplier while the timer runs', () => {
expect(factor(2, STATUS_POWER.slow)).toBeCloseTo(0.55)
})
it('supports a total shutdown multiplier', () => {
expect(factor(1, STATUS_POWER.bleed)).toBe(0)
})
})
describe('tickStatuses', () => {
it('drains every timer and floors at zero', () => {
const s = sv()
s.statuses.slow = 1
s.statuses.suppress = 0.4
tickStatuses(s, 0.5)
expect(s.statuses.slow).toBeCloseTo(0.5)
expect(s.statuses.suppress).toBe(0)
expect(s.statuses.bleed).toBe(0)
})
it('never goes negative on a large dt', () => {
const s = sv()
s.statuses.bleed = 1
tickStatuses(s, 99)
expect(s.statuses.bleed).toBe(0)
})
})
describe('applyStatus', () => {
it('refreshes to the longer remainder and never stacks', () => {
const s = sv()
applyStatus(s, 'slow', 2)
applyStatus(s, 'slow', 1)
expect(s.statuses.slow).toBe(2)
applyStatus(s, 'slow', 3)
expect(s.statuses.slow).toBe(3)
})
it('a zero duration is a no-op, which is how inert specs call it', () => {
const s = sv()
applyStatus(s, 'bleed', 0)
expect(s.statuses.bleed).toBe(0)
})
})

View File

@ -0,0 +1,34 @@
import type { StatusKind, Survivor } from './types'
import { STATUS_ORDER } from './types'
/**
* 1 while the timer is live, 0 once it has expired.
*
* Deliberately arithmetic rather than `timer > 0`: every consumer of this value
* multiplies by it, so an `if` here would add a branch pair per status per
* consumer — the coverage gate is 100 % and those branches buy nothing.
* Timers only reach 0 via `Math.max(0, t - dt)`, so the domain is [0, inf).
*/
export const isOn = (timer: number): number => Math.min(1, Math.ceil(timer))
/** `active` while the timer runs, 1 when idle. Branch-free blend. */
export const factor = (timer: number, active: number): number =>
1 - isOn(timer) * (1 - active)
export const tickStatuses = (sv: Survivor, dt: number): void => {
for (const kind of STATUS_ORDER) {
sv.statuses[kind] = Math.max(0, sv.statuses[kind] - dt)
}
}
/**
* Refresh-not-stack: re-applying extends the timer, never compounds it.
* This is what caps a horde's total lockdown at one status duration — the
* guarantee is structural, not a tuned number.
*
* A 0-second duration is a no-op, which is how non-debuffing enemies call this
* unconditionally without needing a guard branch.
*/
export const applyStatus = (sv: Survivor, kind: StatusKind, seconds: number): void => {
sv.statuses[kind] = Math.max(sv.statuses[kind], seconds)
}

View File

@ -7,6 +7,22 @@ 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'
@ -38,6 +54,10 @@ export interface EnemySpec {
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
@ -49,6 +69,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -56,6 +77,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -63,6 +85,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -70,6 +93,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -77,6 +101,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -84,6 +109,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -91,6 +117,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -98,6 +125,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -105,6 +133,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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,
@ -112,6 +141,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
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
@ -145,6 +175,8 @@ export interface Survivor {
xp: number
xpNext: number
kills: number
/** Seconds remaining per status. A finite-union Record, so indexing stays `number`. */
statuses: Record<StatusKind, number>
}
export interface Enemy {
@ -165,6 +197,9 @@ export interface Projectile {
radius: number
damage: number
ttl: number
/** Carried on the shot itself so a hit needs no lookup of a possibly-dead firer. */
debuff: StatusKind
debuffDuration: number
}
export interface DirectorState {

View File

@ -24,8 +24,14 @@ describe('draw', () => {
const ctx = makeCtxStub()
const s = fresh()
s.projectiles.push(
{ id: 1, from: 'survivor', pos: { x: 10, y: 10 }, vel: { x: 0, y: 0 }, radius: 4, damage: 1, ttl: 1 },
{ id: 2, from: 'enemy', pos: { x: 20, y: 20 }, vel: { x: 0, y: 0 }, radius: 5, damage: 1, ttl: 1 },
{
id: 1, from: 'survivor', pos: { x: 10, y: 10 }, vel: { x: 0, y: 0 },
radius: 4, damage: 1, ttl: 1, debuff: 'slow', debuffDuration: 0,
},
{
id: 2, from: 'enemy', pos: { x: 20, y: 20 }, vel: { x: 0, y: 0 },
radius: 5, damage: 1, ttl: 1, debuff: 'slow', debuffDuration: 0,
},
)
draw(ctx, s)
expect(ctx.fill).toHaveBeenCalledTimes(BASE.fill + 2)
@ -61,3 +67,19 @@ describe('draw', () => {
expect(ctx.arc).toHaveBeenCalledTimes(BASE.arc + 2)
})
})
describe('status halo', () => {
it('is absent for an unafflicted survivor', () => {
const ctx = makeCtxStub()
draw(ctx, fresh())
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke)
})
it('draws one extra ring when a status is live', () => {
const ctx = makeCtxStub()
const s = fresh()
s.survivor.statuses.slow = 1.5
draw(ctx, s)
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke + 1)
})
})

View File

@ -1,6 +1,6 @@
import { FIRE_RANGE, SURVIVOR_RADIUS } from '../core/sim'
import type { EnemyKind, GameState } from '../core/types'
import { ARENA, ENEMY_SPECS } from '../core/types'
import { ARENA, ENEMY_SPECS, STATUS_ORDER } from '../core/types'
/** Structural subset of CanvasRenderingContext2D — stubbable in tests. */
export interface Ctx2D {
@ -112,4 +112,13 @@ export const draw = (ctx: Ctx2D, state: GameState): void => {
-Math.PI / 2, -Math.PI / 2 + (Math.max(0, sv.hp) / sv.maxHp) * Math.PI * 2,
)
ctx.stroke()
// Afflicted halo. Gated so an unafflicted survivor draws exactly as before.
const afflicted = STATUS_ORDER.some((kind) => sv.statuses[kind] > 0)
if (afflicted) {
ctx.strokeStyle = COLORS.arcane
ctx.lineWidth = 2
circle(ctx, sv.pos.x, sv.pos.y, SURVIVOR_RADIUS + 10)
ctx.stroke()
}
}

View File

@ -143,6 +143,29 @@ body {
.feed li::before { content: "▲ "; color: var(--bone-dim); }
.status-chips {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 0;
padding: 0;
}
.chip {
font-family: var(--font-data);
font-size: 0.68rem;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid currentcolor;
opacity: 0.85;
}
.chip-slow { color: var(--arcane); }
.chip-suppress { color: var(--blood); }
.chip-bleed { color: var(--bone-dim); }
.spawnbar {
display: flex;
flex-wrap: wrap;

View File

@ -41,6 +41,13 @@ export const Hud = ({ snap, onRestart }: Props): ReactElement => {
<p className="hud-num">
{Math.ceil(snap.survivorHp)} hp · level {snap.survivorLevel} · {snap.kills} of yours slain
</p>
<ul className="status-chips">
{snap.statuses.map((c) => (
<li key={`st-${c.kind}`} className={`chip chip-${c.kind}`}>
{c.label} {c.seconds}s
</li>
))}
</ul>
<ul className="feed">
{snap.upgrades.map((u) => (
<li key={`up-${String(u.n)}`}>{UPGRADE_LABELS[u.id]}</li>

View File

@ -21,6 +21,24 @@ describe('Hud', () => {
expect(screen.getByText('200 / 400')).toBeInTheDocument()
})
it('lists live status chips and omits idle ones', () => {
const snap = snapWith((s) => {
s.survivor.statuses.slow = 1.2
s.survivor.statuses.bleed = 3
})
const { container } = render(<Hud snap={snap} onRestart={() => undefined} />)
const chips = container.querySelectorAll('.status-chips li')
expect(chips).toHaveLength(2)
expect(screen.getByText(/mired/)).toBeInTheDocument()
expect(screen.getByText(/unknitting/)).toBeInTheDocument()
expect(screen.queryByText(/stifled/)).not.toBeInTheDocument()
})
it('renders no status chips for an unafflicted survivor', () => {
const { container } = render(<Hud snap={snapWith()} onRestart={() => undefined} />)
expect(container.querySelectorAll('.status-chips li')).toHaveLength(0)
})
it('reports the intruder and its recent upgrades', () => {
const snap = snapWith((s) => {
s.survivor.hp = 50