From 43b63ac266059b48d0b2a91236d6dbd3c95807a4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 23:08:51 +0200 Subject: [PATCH] reverse_survivors: survivor status effects (slow, suppress, bleed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc --- ...e-survivors-status-effects-2026-08-09.json | 16 +++ ...e-survivors-status-effects-2026-08-09.json | 38 +++++++ reverse_survivors/src/core/sim.test.ts | 99 ++++++++++++++++++- reverse_survivors/src/core/sim.ts | 30 +++++- reverse_survivors/src/core/snapshot.ts | 18 +++- reverse_survivors/src/core/status.test.ts | 82 +++++++++++++++ reverse_survivors/src/core/status.ts | 34 +++++++ reverse_survivors/src/core/types.ts | 35 +++++++ reverse_survivors/src/render/draw.test.ts | 26 ++++- reverse_survivors/src/render/draw.ts | 11 ++- reverse_survivors/src/styles.css | 23 +++++ reverse_survivors/src/ui/Hud.tsx | 7 ++ reverse_survivors/src/ui/panels.test.tsx | 18 ++++ 13 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/contracts/reverse-survivors-status-effects-2026-08-09.json create mode 100644 docs/superpowers/evidence/reverse-survivors-status-effects-2026-08-09.json create mode 100644 reverse_survivors/src/core/status.test.ts create mode 100644 reverse_survivors/src/core/status.ts diff --git a/docs/superpowers/contracts/reverse-survivors-status-effects-2026-08-09.json b/docs/superpowers/contracts/reverse-survivors-status-effects-2026-08-09.json new file mode 100644 index 00000000..104c08eb --- /dev/null +++ b/docs/superpowers/contracts/reverse-survivors-status-effects-2026-08-09.json @@ -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" +} diff --git a/docs/superpowers/evidence/reverse-survivors-status-effects-2026-08-09.json b/docs/superpowers/evidence/reverse-survivors-status-effects-2026-08-09.json new file mode 100644 index 00000000..1674c00c --- /dev/null +++ b/docs/superpowers/evidence/reverse-survivors-status-effects-2026-08-09.json @@ -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." + ] +} diff --git a/reverse_survivors/src/core/sim.test.ts b/reverse_survivors/src/core/sim.test.ts index 68f539fb..d7fc41e7 100644 --- a/reverse_survivors/src/core/sim.test.ts +++ b/reverse_survivors/src/core/sim.test.ts @@ -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) + }) +}) diff --git a/reverse_survivors/src/core/sim.ts b/reverse_survivors/src/core/sim.ts index 4fd8365f..90f2ea7f 100644 --- a/reverse_survivors/src/core/sim.ts +++ b/reverse_survivors/src/core/sim.ts @@ -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) diff --git a/reverse_survivors/src/core/snapshot.ts b/reverse_survivors/src/core/snapshot.ts index 66c71e11..54f8ca5a 100644 --- a/reverse_survivors/src/core/snapshot.ts +++ b/reverse_survivors/src/core/snapshot.ts @@ -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, diff --git a/reverse_survivors/src/core/status.test.ts b/reverse_survivors/src/core/status.test.ts new file mode 100644 index 00000000..4c9a71a5 --- /dev/null +++ b/reverse_survivors/src/core/status.test.ts @@ -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) + }) +}) diff --git a/reverse_survivors/src/core/status.ts b/reverse_survivors/src/core/status.ts new file mode 100644 index 00000000..8ef2b3fc --- /dev/null +++ b/reverse_survivors/src/core/status.ts @@ -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) +} diff --git a/reverse_survivors/src/core/types.ts b/reverse_survivors/src/core/types.ts index 884eb595..d589c426 100644 --- a/reverse_survivors/src/core/types.ts +++ b/reverse_survivors/src/core/types.ts @@ -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 = { + slow: 0.55, // move speed + suppress: 1.75, // fire cooldown — higher is slower + bleed: 0, // regen +} + +export const STATUS_LABELS: Record = { + slow: 'mired', + suppress: 'stifled', + bleed: 'unknitting', +} + export type UnitKind = | 'rusher' | 'stalker' | 'tank' | 'bomber' | 'splitter' | 'artillery' | 'warden' export type BossKind = 'colossus' | 'hivemind' | 'leech' @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 } 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 { diff --git a/reverse_survivors/src/render/draw.test.ts b/reverse_survivors/src/render/draw.test.ts index 13721345..8f018e0f 100644 --- a/reverse_survivors/src/render/draw.test.ts +++ b/reverse_survivors/src/render/draw.test.ts @@ -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) + }) +}) diff --git a/reverse_survivors/src/render/draw.ts b/reverse_survivors/src/render/draw.ts index f061e344..5b7a2b4b 100644 --- a/reverse_survivors/src/render/draw.ts +++ b/reverse_survivors/src/render/draw.ts @@ -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() + } } diff --git a/reverse_survivors/src/styles.css b/reverse_survivors/src/styles.css index 6550a997..69f3ebf2 100644 --- a/reverse_survivors/src/styles.css +++ b/reverse_survivors/src/styles.css @@ -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; diff --git a/reverse_survivors/src/ui/Hud.tsx b/reverse_survivors/src/ui/Hud.tsx index 365b3cf2..c2cf9df1 100644 --- a/reverse_survivors/src/ui/Hud.tsx +++ b/reverse_survivors/src/ui/Hud.tsx @@ -41,6 +41,13 @@ export const Hud = ({ snap, onRestart }: Props): ReactElement => {

{Math.ceil(snap.survivorHp)} hp · level {snap.survivorLevel} · {snap.kills} of yours slain

+
    + {snap.statuses.map((c) => ( +
  • + {c.label} {c.seconds}s +
  • + ))} +
    {snap.upgrades.map((u) => (
  • {UPGRADE_LABELS[u.id]}
  • diff --git a/reverse_survivors/src/ui/panels.test.tsx b/reverse_survivors/src/ui/panels.test.tsx index f75d9d25..e7b953d7 100644 --- a/reverse_survivors/src/ui/panels.test.tsx +++ b/reverse_survivors/src/ui/panels.test.tsx @@ -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( 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( 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