feat(reverse_survivors): migrate Reverse Survivors game into the monorepo

Moves the standalone React 19 + Vite 8 + TypeScript 6 game from
~/Downloads/reverse/reverse-survivors into reverse_survivors/, so it is
version-controlled, indexed, and covered by CI instead of sitting untracked
outside any repository.

- Relocates the project's nested .github/workflows/ci.yml to the repository
  root as .github/workflows/reverse-survivors-ci.yml. GitHub only reads
  workflows from the root, so the nested copy would have been inert — same
  treatment billsplit received. The workflow is path-scoped to
  reverse_survivors/** and runs npm ci / check / build on Node 22.
- Sets cache-dependency-path on actions/setup-node, since
  defaults.run.working-directory applies only to run: steps and not to
  actions; without it the npm cache looks for a lockfile at the root.
- Keeps npm and the existing package-lock.json. TypeScript stays pinned at
  6.0.3 deliberately: typescript-eslint 8.65 caps its peer range at <6.1.0
  and TS 7 drops the JS compiler API that typed linting needs.
- Adds a reverse_survivors/ row to the Repository Layout table in README.md
  and CLAUDE.md.

Verified in place: npm ci, npm run check (95 tests, 100% statements /
branches / functions / lines), npm run build, and a dev-server smoke check
serving /src/main.tsx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 10:45:12 +02:00
parent 8e24ff093f
commit a47191673a
38 changed files with 7055 additions and 0 deletions

View File

@ -0,0 +1,35 @@
name: reverse-survivors CI
# Relocated here from reverse_survivors/.github/workflows/ci.yml when the game
# moved into this monorepo — GitHub only reads workflows from the repository
# root, so the nested copy would have been inert.
on:
push:
branches: [main]
paths:
- "reverse_survivors/**"
- ".github/workflows/reverse-survivors-ci.yml"
pull_request:
branches: [main]
paths:
- "reverse_survivors/**"
- ".github/workflows/reverse-survivors-ci.yml"
defaults:
run:
working-directory: reverse_survivors
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: reverse_survivors/package-lock.json
- run: npm ci
- run: npm run check
- run: npm run build

View File

@ -33,6 +33,7 @@ Archived / unmaintained projects live in the sibling repository
| `python_pkg/` | Python packages — each maintained subpackage lives here |
| `linux_configuration/` | Arch Linux setup, i3 config, system maintenance scripts |
| `phone_focus_mode/` | GPS-based Android focus enforcer (Bash, ADB, Magisk) |
| `reverse_survivors/` | Reverse Survivors — React/Vite game, you play the horde (TypeScript, 100 % coverage gate) |
| `meta/` | Repo-wide tooling: `pyproject.toml`, `requirements.txt`, `run.sh`, `lint_python.sh`, `.fvmrc` |
| `scripts/` | Workspace-level helper scripts and pre-commit hooks (moved to `meta/scripts/`) |
| `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts |

View File

@ -23,6 +23,7 @@ Utility scripts for development workflows — build file validation, secret dete
| `python_pkg/` | Python packages (each maintained subpackage lives here) |
| `linux_configuration/` | Arch Linux setup, i3 config, system maintenance scripts |
| `phone_focus_mode/` | GPS-based Android focus enforcer |
| `reverse_survivors/` | Reverse Survivors — React/Vite game where you play the horde against an AI survivor (TypeScript, 100 % coverage gate) |
| `scripts/` | Workspace-level helper scripts and pre-commit hooks |
| `docs/` | Reference docs and historical reports |
| `third_party/` | Vendored upstream skills/agents |

View File

@ -0,0 +1,20 @@
{
"title": "Migrate Reverse Survivors into the testsAndMisc monorepo",
"objective": "Relocate the standalone React 19 + Vite 8 + TypeScript 6 game from ~/Downloads/reverse/reverse-survivors to reverse_survivors/ inside this monorepo, preserving its toolchain and quality gates verbatim. Success means the project builds, lints, and passes its 100 % coverage gate from the new path; a root-level path-scoped GitHub Actions workflow runs those same gates on every touching push or PR; and the directory is listed in both index tables. The migration is complete only when npm run check passes in place — a moved project that cannot build is the failure mode this contract exists to prevent.",
"acceptance_criteria": [
"reverse_survivors/ contains all 33 project files; git status --porcelain -uall reports 33 and nothing was swallowed by the root .gitignore",
"The project's nested .github/ is deleted and its workflow lives at .github/workflows/reverse-survivors-ci.yml with paths: filters and defaults.run.working-directory both naming reverse_survivors",
"npm ci, npm run check (typecheck + eslint --max-warnings 0 + vitest at 100 % thresholds), and npm run build all pass from reverse_survivors/",
"npm run dev serves the app and Vite transforms /src/main.tsx from the new path",
"README.md and CLAUDE.md each gain a reverse_survivors/ Repository Layout row",
"The commit stages explicit paths only, leaving the concurrent uncommitted billsplit migration and the other modified files in the working tree untouched"
],
"out_of_scope": [
"Converting the project from npm to pnpm, or regenerating package-lock.json",
"Changing the project's source code, pinned dependency versions, ESLint config, or coverage thresholds",
"Adding a per-project run.sh — meta/run.sh is a polling-script profiler, not a project dispatcher, and Node 24 is already present locally",
"Committing, fixing, or otherwise touching the in-progress billsplit migration sitting untracked in this working tree",
"Backfilling the missing billsplit/ and bucket_catch/ rows in the index tables"
],
"verifier": "cd reverse_survivors && npm ci && npm run check && npm run build (the project's own gate, mirrored by .github/workflows/reverse-survivors-ci.yml), plus pre-commit run --files and pre-commit run --hook-stage pre-push --files over the staged paths"
}

View File

@ -0,0 +1,56 @@
{
"intent": "Bring the standalone Reverse Survivors game (React 19 + Vite 8 + TypeScript 6) into this monorepo at reverse_survivors/, so it is version-controlled, indexed in README.md/CLAUDE.md, and built by a path-scoped GitHub Actions workflow instead of sitting untracked in ~/Downloads.",
"scope": [
"reverse_survivors/ — 33 files copied from ~/Downloads/reverse/reverse-survivors",
".github/workflows/reverse-survivors-ci.yml — relocated from the project's nested .github/workflows/ci.yml",
"README.md and CLAUDE.md — one Repository Layout row each",
"Non-goal: no change to the project's source, pinned dependency versions, lint config, or coverage thresholds",
"Non-goal: no conversion from npm to pnpm; the project ships package-lock.json and its CI uses npm ci",
"Non-goal: the concurrent uncommitted billsplit migration in this working tree is left untouched"
],
"changes": [
"Copied the project with cp -a to reverse_survivors/ (snake_case, matching kcd2_dice_solver / bucket_catch / phone_focus_mode), then deleted its nested .github/ directory — GitHub only reads workflows from the repository root, so the nested copy would have been inert. Same treatment billsplit received.",
"Added .github/workflows/reverse-survivors-ci.yml modeled on billsplit-ci.yml: push/pull_request on main filtered to reverse_survivors/** and the workflow file itself, defaults.run.working-directory: reverse_survivors, then npm ci / npm run check / npm run build on Node 22.",
"Set cache-dependency-path: reverse_survivors/package-lock.json on actions/setup-node, because defaults.run.working-directory applies only to run: steps and not to actions, so the npm cache would otherwise look for a lockfile at the repository root and fail.",
"Kept the project's own .gitignore (node_modules, dist, coverage) so it stays runnable standalone, matching kcd2_dice_solver. The root .prettierignore already covers **/package-lock.json, **/dist/ and **/coverage/, so no change was needed there.",
"Added a reverse_survivors/ row to the Repository Layout table in both README.md and CLAUDE.md."
],
"verification": [
{
"command": "cd reverse_survivors && npm ci",
"result": "pass",
"evidence": "added 277 packages, and audited 278 packages in 21s; found 0 vulnerabilities. One npm warn EBADENGINE for jsdom@30.0.1 (wants node ^22.22.2 || ^24.15.0, local node is v24.12.0) — a warning only; the CI job pins node-version: 22, which satisfies the ^22.22.2 range."
},
{
"command": "cd reverse_survivors && npm run check",
"result": "pass",
"evidence": "tsc --noEmit clean; eslint --max-warnings 0 clean; Test Files 8 passed (8), Tests 95 passed (95) in 15.19s. Coverage summary: Statements 100% (410/410), Branches 100% (117/117), Functions 100% (75/75), Lines 100% (396/396) — the vitest thresholds are hard-set to 100 and the run fails below it."
},
{
"command": "cd reverse_survivors && npm run build",
"result": "pass",
"evidence": "vite v8.2.0 built in 381ms; dist/index.html 0.42 kB, dist/assets/index-FNJxch8t.css 3.42 kB, dist/assets/index-hDCecHon.js 203.53 kB (gzip 64.55 kB)."
},
{
"command": "cd reverse_survivors && npm run dev (then curl http://localhost:5173/)",
"result": "pass",
"evidence": "VITE v8.2.0 ready in 560 ms on http://localhost:5173/. curl of / returned the app shell with <title>Reverse Survivors — you are the horde</title> and <script type=\"module\" src=\"/src/main.tsx\">; curl of /src/main.tsx returned HTTP 200, confirming Vite transforms the TypeScript entry point from the new path. Server stopped afterwards; port 5173 confirmed closed."
},
{
"command": "git status --porcelain -uall reverse_survivors/ | wc -l",
"result": "pass",
"evidence": "33 — matches the 34 source files minus the deleted nested .github/workflows/ci.yml. A --ignored -uall listing showed no files swallowed by the root .gitignore beyond the expected node_modules/dist/coverage."
}
],
"risks": [
"The project pins bleeding-edge versions (eslint 10.8.0, TypeScript 6.0.3, Vite 8.2.0, vitest 4.1.10). TypeScript is deliberately held at 6.0.3 because typescript-eslint 8.65 caps its peer range at <6.1.0 and TS 7 drops the JS compiler API that typed linting needs — a future dependency bump must respect that constraint or typed linting breaks.",
"jsdom@30.0.1 declares an engine range that the local Node v24.12.0 misses by a patch band. Tests pass regardless and CI runs Node 22, but a local Node upgrade to 24.15+ or a jsdom bump would clear the warning properly.",
"The 100 % coverage gate means any future edit to reverse_survivors/ without matching tests turns the CI job red. This is the project's intended design, not an accident.",
"The workflow's paths: filters and working-directory: hardcode the reverse_survivors directory name; renaming the directory silently disables CI for it."
],
"rollback": [
"git rm -r --cached reverse_survivors .github/workflows/reverse-survivors-ci.yml and revert the README.md / CLAUDE.md rows, or git revert the migration commit.",
"After rollback, confirm no other workflow references reverse_survivors/ and that git status is clean apart from the pre-existing billsplit changes.",
"The source at ~/Downloads/reverse/ is deleted as part of this migration, so the monorepo copy is authoritative; recover from git history rather than from Downloads."
]
}

3
reverse_survivors/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
coverage

View File

@ -0,0 +1,62 @@
# Reverse Survivors
Vampire Survivors, inverted: **you are the horde**. An AI-controlled survivor
kites, auto-fires, and levels up on its own. You spend a regenerating pool of
souls to spawn grunts, call escalating ring waves, and — once the seals break —
summon bosses. Kill the intruder before the 5:00 clock runs out, or watch dawn
break on your defeat.
## Play
```
npm install
npm run dev
```
- **Rusher / Stalker / Tank / Bomber** — single spawns at a random arena edge.
- **Wave N** — a ring formation around the survivor; each call grows and costs more.
- **Colossus** (unlocks 1:00) and **Hivemind** (unlocks 2:00) — expensive, on cooldown.
The Hivemind births free rushers while it lives.
- The survivor gains XP per kill and rolls an upgrade per level
(damage, fire rate, speed, vitality, regen, multishot). Time favors it. Pressure early.
Where enemies appear is deliberately not yours to choose — the director picks
_what_ and _when_; the seeded RNG picks _where_.
## Quality gates
`npm run check` = typecheck + lint + tests with coverage. All three must pass; CI runs the same.
| Gate | Enforced by |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 100 % lines / branches / functions / statements | Vitest `coverage.thresholds` hard-set to 100 — the run fails below it |
| Strictest typed linting | ESLint 10 flat config: `typescript-eslint` strictTypeChecked + stylisticTypeChecked, `@eslint-react` strict-type-checked, react-hooks (compiler-backed rules), react-refresh, vitest plugin, `--max-warnings 0` |
| Strictest compiler | TS `strict` + `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `erasableSyntaxOnly`, `noUnusedLocals/Parameters`, and friends |
Pinned stack (live-latest at build time): React 19.2.8 · Vite 8.2.0 · Vitest 4.1.10 ·
ESLint 10.8.0 · typescript-eslint 8.65.0 · TypeScript **6.0.3**.
> Why not TypeScript 7? TS 7 is the native (Go) compiler and does not expose the
> JS compiler API that typed linting needs — `typescript-eslint@8.65` caps its peer
> range at `<6.1.0`. TS 6.0.x is the newest compiler that still type-lints.
## Architecture (what makes 100 % honest)
- `src/core/` — the whole game as pure-ish functions over a plain state object.
Deterministic: seeded mulberry32 RNG lives _in_ the state; same seed + same
action sequence ⇒ identical run. Behavior dispatch uses records instead of
switches, and clamping uses `Math.min/max` instead of `if`, so there are no
unreachable defensive branches to fake-cover.
- `src/render/draw.ts` — draws onto a minimal `Ctx2D` structural interface;
tests assert against a typed stub. (This layer's tests are mock-assertion
ritual, as warned — the assurance lives in the core suite.)
- `src/ui/` — thin React shell. The rAF loop clamps dt to 100 ms and holds its
callback in a ref. `App` accepts a `boot` prop as an explicit test seam to
reach both endings without simulating five minutes.
- `src/main.tsx` is covered too, including the missing-`#root` throw, via
`vi.resetModules()` + dynamic import.
## Scripts
`dev` · `build` (tsc + vite) · `preview` · `typecheck` · `lint` · `test` ·
`coverage` · `check` (all gates)

View File

@ -0,0 +1,47 @@
import js from '@eslint/js'
import react from '@eslint-react/eslint-plugin'
import vitest from '@vitest/eslint-plugin'
import { defineConfig } from 'eslint/config'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default defineConfig(
{ ignores: ['dist', 'coverage', 'node_modules'] },
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
react.configs['strict-type-checked'],
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-shadow': 'error',
'no-console': 'error',
'eqeqeq': ['error', 'always'],
'prefer-const': ['error', { destructuring: 'all' }],
},
},
{
files: ['src/**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
extends: [vitest.configs.recommended],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
},
{
files: ['eslint.config.js'],
extends: [tseslint.configs.disableTypeChecked],
},
)

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reverse Survivors — you are the horde</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4452
reverse_survivors/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
{
"name": "reverse-survivors",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
"test": "vitest run",
"coverage": "vitest run --coverage",
"check": "npm run typecheck && npm run lint && npm run coverage"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "5.18.1",
"@eslint/js": "10.0.1",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"@vitest/coverage-v8": "4.1.10",
"@vitest/eslint-plugin": "1.6.24",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"globals": "17.8.0",
"jsdom": "30.0.1",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.2.0",
"vitest": "4.1.10"
},
"private": true,
"type": "module"
}

View File

@ -0,0 +1,70 @@
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 type { RafHarness } from './test/harness'
import { installRaf, makeCtxStub } from './test/harness'
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
const pump = (raf: RafHarness, ts: number): void => {
act(() => {
raf.pump(ts)
})
}
describe('App', () => {
it('runs the loop and accrues souls even without a canvas context', () => {
const raf = installRaf()
render(<App />)
expect(screen.getByText('30 / 400')).toBeInTheDocument()
pump(raf, 0)
for (let i = 1; i <= 20; i += 1) {
pump(raf, i * 1000)
}
expect(screen.getByText('42 / 400')).toBeInTheDocument()
expect(screen.getByText(/0:02 \/ 5:00/)).toBeInTheDocument()
})
it('spends souls on a spawn and draws the horde', () => {
const raf = installRaf()
const stub = makeCtxStub()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockImplementation(() => stub as unknown as CanvasRenderingContext2D)
render(<App seed={5} />)
fireEvent.click(screen.getByRole('button', { name: /rusher/i }))
pump(raf, 0)
expect(screen.getByText('20 / 400')).toBeInTheDocument()
const arcsWithHorde = stub.arc.mock.calls.length
expect(arcsWithHorde).toBeGreaterThan(4)
})
it('shows the director victory overlay and restarts fresh', () => {
const raf = installRaf()
render(
<App
seed={9}
boot={(state) => {
state.survivor.hp = 5
makeEnemy(state, 'rusher', { x: state.survivor.pos.x, y: state.survivor.pos.y })
}}
/>,
)
pump(raf, 0)
expect(screen.getByText('The intruder falls')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /open the gates/i }))
expect(screen.queryByText('The intruder falls')).not.toBeInTheDocument()
expect(screen.getByText('30 / 400')).toBeInTheDocument()
})
it('shows the dawn overlay when the survivor outlasts the horde', () => {
const raf = installRaf()
render(<App boot={(state) => { state.duration = 0.05 }} />)
pump(raf, 0)
pump(raf, 100)
expect(screen.getByText('Dawn breaks')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,72 @@
import type { ReactElement } from 'react'
import { useRef, useState } from 'react'
import { fmtTime } from './ui/format'
import { createInitialState, step } from './core/sim'
import type { HudSnapshot } from './core/snapshot'
import { snapshotOf } from './core/snapshot'
import type { DirectorAction, GameState } from './core/types'
import type { Ctx2D } from './render/draw'
import { draw } from './render/draw'
import { CanvasStage } from './ui/CanvasStage'
import { Hud } from './ui/Hud'
import { SpawnBar } from './ui/SpawnBar'
import { useGameLoop } from './ui/useGameLoop'
interface Props {
seed?: number
/** Test seam: mutate the freshly created state before the loop starts. */
boot?: (state: GameState) => void
}
const createGame = (seed: number, boot: Props['boot']): GameState => {
const state = createInitialState(seed)
if (boot !== undefined) {
boot(state)
}
return state
}
export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
const gameRef = useRef<GameState>(createGame(seed, boot))
const [snap, setSnap] = useState<HudSnapshot>(() => snapshotOf(createGame(seed, boot)))
const ctxRef = useRef<Ctx2D | null>(null)
const queueRef = useRef<DirectorAction[]>([])
useGameLoop((dt) => {
const state = gameRef.current
step(state, queueRef.current, dt)
queueRef.current = []
const ctx = ctxRef.current
if (ctx !== null) {
draw(ctx, state)
}
setSnap(snapshotOf(state))
})
const handleAction = (action: DirectorAction): void => {
queueRef.current.push(action)
}
const handleRestart = (): void => {
gameRef.current = createGame(Date.now() >>> 0, undefined)
queueRef.current = []
setSnap(snapshotOf(gameRef.current))
}
const handleCtx = (ctx: Ctx2D | null): void => {
ctxRef.current = ctx
}
return (
<div className="frame">
<header className="masthead">
<h1>Reverse Survivors</h1>
<p className="tagline">You are the horde. The hero is the problem.</p>
<p className="clock">{fmtTime(snap.t)} / {fmtTime(snap.duration)}</p>
</header>
<main className="board">
<CanvasStage onCtx={handleCtx} />
<Hud snap={snap} onRestart={handleRestart} />
</main>
<SpawnBar snap={snap} onAction={handleAction} />
</div>
)
}

View File

@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest'
import { applyAction, edgeSpawnPos, regenRate, tickDirector, waveCost } from './director'
import { createInitialState } from './sim'
import { snapshotOf } from './snapshot'
import type { GameState } from './types'
import { ARENA, BOSS_COOLDOWN, ENERGY_CAP } from './types'
const fresh = (seed = 1): GameState => createInitialState(seed)
describe('economy', () => {
it('regen ramps every 30 seconds', () => {
expect(regenRate(0)).toBe(6)
expect(regenRate(29.9)).toBe(6)
expect(regenRate(30)).toBe(8)
expect(regenRate(90)).toBe(12)
})
it('wave cost escalates', () => {
expect(waveCost(0)).toBe(60)
expect(waveCost(3)).toBe(135)
})
it('energy accrues and caps', () => {
const s = fresh()
s.director.energy = ENERGY_CAP - 0.1
tickDirector(s, 1)
expect(s.director.energy).toBe(ENERGY_CAP)
})
it('boss cooldowns tick down and floor at zero', () => {
const s = fresh()
s.director.bossCooldowns.colossus = 0.5
tickDirector(s, 1)
expect(s.director.bossCooldowns.colossus).toBe(0)
})
})
describe('edge spawns', () => {
it('always lands on an arena edge, and all four edges occur', () => {
const s = fresh(7)
const edges = new Set<string>()
for (let i = 0; i < 40; i += 1) {
const p = edgeSpawnPos(s)
const onEdge =
p.y === 0 ? 'top'
: p.y === ARENA.h ? 'bottom'
: p.x === 0 ? 'left'
: p.x === ARENA.w ? 'right'
: 'off'
edges.add(onEdge)
}
expect(edges.has('off')).toBe(false)
expect(edges).toEqual(new Set(['top', 'bottom', 'left', 'right']))
})
})
describe('spawning units', () => {
it('deducts energy and adds the enemy', () => {
const s = fresh()
expect(applyAction(s, { type: 'spawn', kind: 'rusher' })).toBe(true)
expect(s.director.energy).toBe(20)
expect(s.enemies).toHaveLength(1)
})
it('rejects what the director cannot afford', () => {
const s = fresh()
expect(applyAction(s, { type: 'spawn', kind: 'tank' })).toBe(false)
expect(s.enemies).toHaveLength(0)
expect(s.director.energy).toBe(30)
})
})
describe('waves', () => {
it('first wave rings six rushers around the survivor', () => {
const s = fresh()
s.director.energy = 100
expect(applyAction(s, { type: 'wave' })).toBe(true)
expect(s.enemies).toHaveLength(6)
expect(s.enemies.every((e) => e.kind === 'rusher')).toBe(true)
expect(s.director.waveIndex).toBe(1)
})
it('later waves grow and mix in stalkers', () => {
const s = fresh()
s.director.energy = 400
applyAction(s, { type: 'wave' })
applyAction(s, { type: 'wave' })
expect(s.enemies).toHaveLength(6 + 9)
expect(s.enemies.filter((e) => e.kind === 'stalker')).toHaveLength(1)
})
it('rejects an unaffordable wave', () => {
const s = fresh()
expect(applyAction(s, { type: 'wave' })).toBe(false)
expect(s.enemies).toHaveLength(0)
})
})
describe('bosses', () => {
it('locked before the unlock time', () => {
const s = fresh()
s.director.energy = 400
expect(applyAction(s, { type: 'boss', kind: 'colossus' })).toBe(false)
})
it('rejected while cooling down', () => {
const s = fresh()
s.t = 60
s.director.energy = 400
expect(applyAction(s, { type: 'boss', kind: 'colossus' })).toBe(true)
s.director.energy = 400
expect(applyAction(s, { type: 'boss', kind: 'colossus' })).toBe(false)
})
it('rejected when unaffordable', () => {
const s = fresh()
s.t = 60
s.director.energy = 10
expect(applyAction(s, { type: 'boss', kind: 'colossus' })).toBe(false)
})
it('summoning pays, spawns, and starts the cooldown', () => {
const s = fresh()
s.t = 120
s.director.energy = 400
expect(applyAction(s, { type: 'boss', kind: 'hivemind' })).toBe(true)
expect(s.director.energy).toBe(400 - 220)
expect(s.enemies[0]?.kind).toBe('hivemind')
expect(s.director.bossCooldowns.hivemind).toBe(BOSS_COOLDOWN.hivemind)
})
})
describe('snapshot', () => {
it('derives affordability both ways', () => {
const s = fresh()
const snap = snapshotOf(s)
expect(snap.units.find((u) => u.kind === 'rusher')?.affordable).toBe(true)
expect(snap.units.find((u) => u.kind === 'tank')?.affordable).toBe(false)
expect(snap.waveAffordable).toBe(false)
s.director.energy = 300
expect(snapshotOf(s).waveAffordable).toBe(true)
})
it('reports boss phases: locked, cooling, ready', () => {
const s = fresh()
expect(snapshotOf(s).bosses[0]?.phase).toBe('locked')
s.t = 61
s.director.bossCooldowns.colossus = 5
expect(snapshotOf(s).bosses[0]?.phase).toBe('cooling')
s.director.bossCooldowns.colossus = 0
expect(snapshotOf(s).bosses[0]?.phase).toBe('ready')
expect(snapshotOf(s).bosses[0]?.unlockIn).toBe(0)
})
it('keeps only the last three upgrades, numbered', () => {
const s = fresh()
s.upgrades = ['damage', 'speed', 'regen', 'multishot']
const snap = snapshotOf(s)
expect(snap.upgrades).toEqual([
{ n: 2, id: 'speed' },
{ n: 3, id: 'regen' },
{ n: 4, id: 'multishot' },
])
})
it('floors the reported survivor hp at zero', () => {
const s = fresh()
s.survivor.hp = -12
expect(snapshotOf(s).survivorHp).toBe(0)
})
})

View File

@ -0,0 +1,96 @@
import { makeEnemy } from './sim'
import { nextFloat, pick } from './rng'
import type { BossKind, DirectorAction, GameState, UnitKind } from './types'
import {
ARENA, BOSS_COOLDOWN, BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS,
} from './types'
import type { Vec } from './vec'
import { clamp } from './vec'
/** Energy per second: ramps every 30 seconds so pressure always grows. */
export const regenRate = (t: number): number => 6 + 2 * Math.floor(t / 30)
export const waveCost = (waveIndex: number): number => 60 + 25 * waveIndex
export const tickDirector = (state: GameState, dt: number): void => {
const d = state.director
d.energy = Math.min(ENERGY_CAP, d.energy + regenRate(state.t) * dt)
for (const boss of BOSS_ORDER) {
d.bossCooldowns[boss] = Math.max(0, d.bossCooldowns[boss] - dt)
}
}
type EdgeFn = (roll: number) => Vec
const EDGES: readonly [EdgeFn, ...EdgeFn[]] = [
(roll): Vec => ({ x: roll * ARENA.w, y: 0 }),
(roll): Vec => ({ x: roll * ARENA.w, y: ARENA.h }),
(roll): Vec => ({ x: 0, y: roll * ARENA.h }),
(roll): Vec => ({ x: ARENA.w, y: roll * ARENA.h }),
]
export const edgeSpawnPos = (state: GameState): Vec => {
const edge = pick(state.rng, EDGES)
return edge(nextFloat(state.rng))
}
const spawnUnit = (state: GameState, kind: UnitKind): boolean => {
const spec = ENEMY_SPECS[kind]
if (state.director.energy < spec.cost) {
return false
}
state.director.energy -= spec.cost
makeEnemy(state, kind, edgeSpawnPos(state))
return true
}
const callWave = (state: GameState): boolean => {
const cost = waveCost(state.director.waveIndex)
if (state.director.energy < cost) {
return false
}
state.director.energy -= cost
const w = state.director.waveIndex
const rushers = 6 + 2 * w
const stalkers = w
const total = rushers + stalkers
const sv = state.survivor.pos
for (let i = 0; i < total; i += 1) {
const angle = (Math.PI * 2 * i) / total + nextFloat(state.rng) * 0.3
const kind = i < rushers ? 'rusher' : 'stalker'
makeEnemy(state, kind, {
x: clamp(sv.x + Math.cos(angle) * 280, 10, ARENA.w - 10),
y: clamp(sv.y + Math.sin(angle) * 280, 10, ARENA.h - 10),
})
}
state.director.waveIndex += 1
return true
}
const summonBoss = (state: GameState, kind: BossKind): boolean => {
if (state.t < BOSS_UNLOCK_AT[kind]) {
return false
}
if (state.director.bossCooldowns[kind] > 0) {
return false
}
const spec = ENEMY_SPECS[kind]
if (state.director.energy < spec.cost) {
return false
}
state.director.energy -= spec.cost
makeEnemy(state, kind, edgeSpawnPos(state))
state.director.bossCooldowns[kind] = BOSS_COOLDOWN[kind]
return true
}
/** Applies a director order. Returns false when rejected (cost, lock, or cooldown). */
export const applyAction = (state: GameState, action: DirectorAction): boolean => {
switch (action.type) {
case 'spawn':
return spawnUnit(state, action.kind)
case 'wave':
return callWave(state)
case 'boss':
return summonBoss(state, action.kind)
}
}

View File

@ -0,0 +1,33 @@
/** Mutable state for a mulberry32 generator. */
export interface Rng {
s: number
}
export const createRng = (seed: number): Rng => ({ s: seed >>> 0 })
/** Deterministic float in [0, 1). Advances the generator. */
export const nextFloat = (rng: Rng): number => {
rng.s = (rng.s + 0x6d2b79f5) >>> 0
let t = rng.s
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
/** Deterministic integer in [0, maxExclusive). */
export const nextInt = (rng: Rng, maxExclusive: number): number =>
Math.floor(nextFloat(rng) * maxExclusive)
/** Deterministic pick from a non-empty tuple. */
export const pick = <T>(rng: Rng, items: readonly [T, ...T[]]): T => {
const idx = nextInt(rng, items.length)
let out: T = items[0]
let i = 0
for (const item of items) {
if (i === idx) {
out = item
}
i += 1
}
return out
}

View File

@ -0,0 +1,370 @@
import { describe, expect, it } from 'vitest'
import {
applyUpgrade, createInitialState, DANGER_RADIUS, grantXp, makeEnemy, step, xpForLevel,
} from './sim'
import type { GameState, Projectile } from './types'
import { ARENA, GAME_DURATION } from './types'
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,
}
state.nextId += 1
state.projectiles.push(p)
return p
}
describe('createInitialState', () => {
it('uses the default duration when omitted', () => {
expect(fresh().duration).toBe(GAME_DURATION)
})
it('accepts a duration override', () => {
expect(fresh(1, 5).duration).toBe(5)
})
it('starts the survivor at the arena center', () => {
expect(fresh().survivor.pos).toEqual(CENTER)
})
})
describe('xp and upgrades', () => {
it('thresholds grow linearly', () => {
expect(xpForLevel(1)).toBe(20)
expect(xpForLevel(3)).toBe(50)
})
it('grantXp below threshold only accumulates', () => {
const s = fresh()
grantXp(s, 10)
expect(s.survivor.level).toBe(1)
expect(s.survivor.xp).toBe(10)
expect(s.upgrades).toHaveLength(0)
})
it('grantXp can jump multiple levels and logs one upgrade per level', () => {
const s = fresh()
grantXp(s, 60)
expect(s.survivor.level).toBe(3)
expect(s.survivor.xp).toBe(5)
expect(s.upgrades).toHaveLength(2)
})
it('each upgrade mutates the survivor as designed', () => {
const s = fresh().survivor
applyUpgrade(s, 'damage')
expect(s.damage).toBe(11)
applyUpgrade(s, 'speed')
expect(s.speed).toBe(164)
applyUpgrade(s, 'vitality')
expect(s.maxHp).toBe(120)
expect(s.hp).toBe(120)
applyUpgrade(s, 'regen')
expect(s.regen).toBeCloseTo(0.6)
applyUpgrade(s, 'multishot')
expect(s.multishot).toBe(2)
applyUpgrade(s, 'fireRate')
expect(s.fireCooldown).toBeCloseTo(0.55 * 0.88)
})
it('fire rate floors at 0.15', () => {
const s = fresh().survivor
for (let i = 0; i < 30; i += 1) {
applyUpgrade(s, 'fireRate')
}
expect(s.fireCooldown).toBe(0.15)
})
})
describe('survivor movement', () => {
it('idles when centered with no threats', () => {
const s = fresh()
step(s, [], 0.1)
expect(s.survivor.pos).toEqual(CENTER)
})
it('seeks the center when displaced', () => {
const s = fresh()
s.survivor.pos = { x: 100, y: CENTER.y }
step(s, [], 0.1)
expect(s.survivor.pos.x).toBeGreaterThan(100)
})
it('flees from a close enemy', () => {
const s = fresh()
makeEnemy(s, 'rusher', { x: CENTER.x + 20, y: CENTER.y })
step(s, [], 0.05)
expect(s.survivor.pos.x).toBeLessThan(CENTER.x)
})
it('ignores enemies beyond the danger radius when moving', () => {
const s = fresh()
makeEnemy(s, 'rusher', { x: CENTER.x + DANGER_RADIUS + 60, y: CENTER.y })
step(s, [], 0.01)
expect(s.survivor.pos.x).toBe(CENTER.x)
})
it('survives an enemy exactly on top of it without NaN positions', () => {
const s = fresh()
makeEnemy(s, 'rusher', { x: CENTER.x, y: CENTER.y })
step(s, [], 0.01)
expect(Number.isFinite(s.survivor.pos.x)).toBe(true)
expect(Number.isFinite(s.survivor.pos.y)).toBe(true)
})
})
describe('survivor firing', () => {
it('does not fire with no enemies', () => {
const s = fresh()
step(s, [], 0.05)
expect(s.projectiles).toHaveLength(0)
})
it('does not fire at enemies beyond range', () => {
const s = fresh()
makeEnemy(s, 'tank', { x: CENTER.x + 400, y: CENTER.y })
step(s, [], 0.01)
expect(s.projectiles).toHaveLength(0)
})
it('fires at an enemy in range and honors the cooldown', () => {
const s = fresh()
makeEnemy(s, 'tank', { x: CENTER.x + 200, y: CENTER.y })
step(s, [], 0.01)
expect(s.projectiles).toHaveLength(1)
step(s, [], 0.01)
expect(s.projectiles).toHaveLength(1)
})
it('aims at the nearest of several enemies', () => {
const s = fresh()
makeEnemy(s, 'tank', { x: CENTER.x - 280, y: CENTER.y })
const near = makeEnemy(s, 'tank', { x: CENTER.x + 200, y: CENTER.y })
step(s, [], 0.001)
const p = s.projectiles[0]
expect(p?.vel.x).toBeGreaterThan(0)
expect(near.hp).toBe(120)
})
it('multishot fires a spread', () => {
const s = fresh()
s.survivor.multishot = 3
makeEnemy(s, 'tank', { x: CENTER.x + 200, y: CENTER.y })
step(s, [], 0.001)
expect(s.projectiles).toHaveLength(3)
})
})
describe('enemy behaviors', () => {
it('chasers close the distance', () => {
const s = fresh()
const e = makeEnemy(s, 'rusher', { x: CENTER.x + 300, y: CENTER.y })
step(s, [], 0.1)
expect(e.pos.x).toBeLessThan(CENTER.x + 300)
})
it('melee contact damages the survivor once per cooldown window', () => {
const s = fresh()
makeEnemy(s, 'rusher', { x: CENTER.x, y: CENTER.y })
step(s, [], 0.01)
expect(s.survivor.hp).toBe(100 - 6)
step(s, [], 0.01)
expect(s.survivor.hp).toBe(100 - 6)
})
it('tank hits harder', () => {
const s = fresh()
makeEnemy(s, 'tank', { x: CENTER.x + 5, y: CENTER.y })
step(s, [], 0.01)
expect(s.survivor.hp).toBe(100 - 14)
})
it('stalker approaches when too far', () => {
const s = fresh()
const e = makeEnemy(s, 'stalker', { x: CENTER.x + 300, y: CENTER.y })
step(s, [], 0.05)
expect(e.pos.x).toBeLessThan(CENTER.x + 300)
})
it('stalker retreats when too close', () => {
const s = fresh()
const e = makeEnemy(s, 'stalker', { x: CENTER.x + 100, y: CENTER.y })
step(s, [], 0.05)
expect(e.pos.x).toBeGreaterThan(CENTER.x + 100)
})
it('stalker holds position inside its comfort band', () => {
const s = fresh()
const e = makeEnemy(s, 'stalker', { x: CENTER.x + 160, y: CENTER.y })
const before = e.pos.x
step(s, [], 0.05)
expect(e.pos.x).toBe(before)
})
it('stalker fires when in range and ready, then cools down', () => {
const s = fresh()
makeEnemy(s, 'stalker', { x: CENTER.x + 220, y: CENTER.y })
step(s, [], 0.01)
expect(s.projectiles.filter((p) => p.from === 'enemy')).toHaveLength(1)
step(s, [], 0.01)
expect(s.projectiles.filter((p) => p.from === 'enemy')).toHaveLength(1)
})
it('stalker does not fire from beyond its range', () => {
const s = fresh()
makeEnemy(s, 'stalker', { x: CENTER.x + 400, y: CENTER.y })
step(s, [], 0.01)
expect(s.projectiles.filter((p) => p.from === 'enemy')).toHaveLength(0)
})
it('hivemind emits a rusher on its spawn cadence', () => {
const s = fresh()
const hive = makeEnemy(s, 'hivemind', { x: 900, y: 560 })
step(s, [], 2.5)
expect(s.enemies.filter((e) => e.kind === 'rusher')).toHaveLength(1)
expect(hive.spawnTimer).toBe(2.5)
step(s, [], 0.1)
expect(s.enemies.filter((e) => e.kind === 'rusher')).toHaveLength(1)
})
})
describe('bomber', () => {
it('detonates on contact: damages the survivor, dies, grants nothing', () => {
const s = fresh()
makeEnemy(s, 'bomber', { x: CENTER.x, y: CENTER.y })
step(s, [], 0.01)
expect(s.survivor.hp).toBe(100 - 22)
expect(s.enemies).toHaveLength(0)
expect(s.survivor.kills).toBe(0)
expect(s.survivor.xp).toBe(0)
})
it('sniped from afar: grants xp, blast misses the survivor', () => {
const s = fresh()
makeEnemy(s, 'bomber', { x: CENTER.x + 300, y: CENTER.y })
shot(s, CENTER.x + 300, CENTER.y, 99)
step(s, [], 0.001)
expect(s.survivor.kills).toBe(1)
expect(s.survivor.xp).toBe(10)
expect(s.survivor.hp).toBe(100)
expect(s.enemies).toHaveLength(0)
})
it('shot inside blast range: grants xp and still burns the survivor', () => {
const s = fresh()
makeEnemy(s, 'bomber', { x: CENTER.x + 30, y: CENTER.y })
shot(s, CENTER.x + 30, CENTER.y, 99)
step(s, [], 0.001)
expect(s.survivor.kills).toBe(1)
expect(s.survivor.hp).toBeCloseTo(100 - 22, 5)
})
})
describe('projectiles', () => {
it('expire by ttl', () => {
const s = fresh()
shot(s, 50, 50, 1)
step(s, [], 2)
expect(s.projectiles).toHaveLength(0)
})
it('a miss keeps flying', () => {
const s = fresh()
shot(s, 50, 50, 1)
step(s, [], 0.01)
expect(s.projectiles).toHaveLength(1)
})
it('damages without killing when the target outlasts the hit', () => {
const s = fresh()
const tank = makeEnemy(s, 'tank', { x: CENTER.x + 400, y: CENTER.y })
shot(s, CENTER.x + 400, CENTER.y, 8)
step(s, [], 0.001)
expect(tank.hp).toBe(112)
expect(s.survivor.kills).toBe(0)
expect(s.enemies).toHaveLength(1)
})
it('a second projectile in the same tick passes through an already-dead target', () => {
const s = fresh()
makeEnemy(s, 'rusher', { x: CENTER.x + 400, y: CENTER.y })
shot(s, CENTER.x + 400, CENTER.y, 20)
shot(s, CENTER.x + 400, CENTER.y, 20)
step(s, [], 0.001)
expect(s.survivor.kills).toBe(1)
expect(s.projectiles).toHaveLength(1)
})
it('enemy fire hurts the survivor', () => {
const s = fresh()
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,
})
step(s, [], 0.001)
expect(s.survivor.hp).toBe(93)
expect(s.projectiles).toHaveLength(0)
})
it('enemy fire that misses keeps flying', () => {
const s = fresh()
s.projectiles.push({
id: 501, from: 'enemy', pos: { x: 30, y: 30 },
vel: { x: 0, y: 0 }, radius: 5, damage: 7, ttl: 1,
})
step(s, [], 0.001)
expect(s.survivor.hp).toBe(100)
expect(s.projectiles).toHaveLength(1)
})
})
describe('game end', () => {
it('director wins when the survivor drops to zero', () => {
const s = fresh()
s.survivor.hp = 5
makeEnemy(s, 'rusher', { x: CENTER.x, y: CENTER.y })
step(s, [], 0.01)
expect(s.status).toBe('directorWon')
expect(s.outcomeTime).toBeCloseTo(0.01)
})
it('survivor wins when the clock runs out', () => {
const s = fresh(1, 0.05)
step(s, [], 0.1)
expect(s.status).toBe('survivorWon')
})
it('a kill on the final tick still counts as a director win', () => {
const s = fresh(1, 0.05)
s.survivor.hp = 5
makeEnemy(s, 'rusher', { x: CENTER.x, y: CENTER.y })
step(s, [], 0.1)
expect(s.status).toBe('directorWon')
})
it('a finished game is inert', () => {
const s = fresh(1, 0.05)
step(s, [], 0.1)
const t = s.t
step(s, [], 0.1)
expect(s.t).toBe(t)
})
it('director actions flow through step', () => {
const s = fresh()
step(s, [{ type: 'spawn', kind: 'rusher' }], 0.001)
expect(s.enemies).toHaveLength(1)
expect(s.director.energy).toBeLessThan(30)
})
})

View File

@ -0,0 +1,296 @@
import { applyAction, tickDirector } from './director'
import { createRng, pick } from './rng'
import type {
DirectorAction, Enemy, EnemyKind, GameState, Survivor, UpgradeId,
} from './types'
import {
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, GAME_DURATION, 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 = (): Survivor => ({
pos: { x: CENTER.x, y: CENTER.y },
hp: 100,
maxHp: 100,
speed: 150,
regen: 0,
damage: 8,
multishot: 1,
fireCooldown: 0.55,
fireTimer: 0,
level: 1,
xp: 0,
xpNext: xpForLevel(1),
kills: 0,
})
export const createInitialState = (seed: number, duration = GAME_DURATION): GameState => ({
status: 'running',
t: 0,
duration,
rng: createRng(seed),
survivor: createSurvivor(),
enemies: [],
projectiles: [],
director: {
energy: ENERGY_START,
waveIndex: 0,
bossCooldowns: { colossus: 0, hivemind: 0 },
},
upgrades: [],
nextId: 1,
outcomeTime: 0,
})
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,
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
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) * sv.speed, y: (fleeY / fleeMag) * sv.speed }
} else if (dist(sv.pos, CENTER) > 4) {
vel = toward(sv.pos, CENTER, sv.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.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,
})
}
sv.fireTimer = sv.fireCooldown
}
}
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
}
}
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)
}
}
const MOVE_FNS: Record<'chase' | 'skirmish', (e: Enemy, sv: Survivor, d: number) => Vec> = {
chase: (e, sv) => toward(e.pos, sv.pos, ENEMY_SPECS[e.kind].speed),
skirmish: (e, sv, d) => {
const spec = ENEMY_SPECS[e.kind]
if (d > spec.keepDistance) {
return toward(e.pos, sv.pos, spec.speed)
}
if (d < spec.keepDistance * 0.65) {
return toward(sv.pos, e.pos, spec.speed)
}
return { x: 0, y: 0 }
},
}
const enemiesStep = (state: GameState, dt: number): void => {
const sv = state.survivor
const spawned: { kind: EnemyKind; pos: Vec }[] = []
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)
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
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,
ttl: ENEMY_PROJ_TTL,
})
e.fireTimer = spec.fireCooldown
}
}
if (spec.spawnEvery > 0) {
e.spawnTimer = Math.max(0, e.spawnTimer - dt)
if (e.spawnTimer === 0) {
spawned.push({ kind: 'rusher', 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
continue
}
surviving.push(p)
}
state.projectiles = surviving
}
export const step = (state: GameState, actions: readonly DirectorAction[], dt: number): void => {
if (state.status !== 'running') {
return
}
state.t += dt
tickDirector(state, dt)
for (const action of actions) {
applyAction(state, action)
}
survivorStep(state, dt)
enemiesStep(state, dt)
projectilesStep(state, dt)
state.enemies = state.enemies.filter((e) => e.hp > 0)
if (state.survivor.hp <= 0) {
state.status = 'directorWon'
state.outcomeTime = state.t
} else if (state.t >= state.duration) {
state.status = 'survivorWon'
state.outcomeTime = state.t
}
}

View File

@ -0,0 +1,90 @@
import { waveCost } from './director'
import type { BossKind, GameState, Status, UnitKind, UpgradeId } from './types'
import {
BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS, UNIT_ORDER,
} from './types'
export type BossPhase = 'locked' | 'cooling' | 'ready'
export interface UnitButton {
kind: UnitKind
name: string
cost: number
affordable: boolean
}
export interface BossButton {
kind: BossKind
name: string
cost: number
phase: BossPhase
unlockIn: number
cooldown: number
affordable: boolean
}
export interface UpgradeEntry {
n: number
id: UpgradeId
}
export interface HudSnapshot {
status: Status
t: number
duration: number
energy: number
energyCap: number
waveIndex: number
waveCost: number
waveAffordable: boolean
survivorHp: number
survivorMaxHp: number
survivorLevel: number
kills: number
upgrades: UpgradeEntry[]
units: UnitButton[]
bosses: BossButton[]
outcomeTime: number
}
const bossPhase = (state: GameState, kind: BossKind): BossPhase => {
if (state.t < BOSS_UNLOCK_AT[kind]) {
return 'locked'
}
if (state.director.bossCooldowns[kind] > 0) {
return 'cooling'
}
return 'ready'
}
export const snapshotOf = (state: GameState): HudSnapshot => ({
status: state.status,
t: state.t,
duration: state.duration,
energy: state.director.energy,
energyCap: ENERGY_CAP,
waveIndex: state.director.waveIndex,
waveCost: waveCost(state.director.waveIndex),
waveAffordable: state.director.energy >= waveCost(state.director.waveIndex),
survivorHp: Math.max(0, state.survivor.hp),
survivorMaxHp: state.survivor.maxHp,
survivorLevel: state.survivor.level,
kills: state.survivor.kills,
upgrades: state.upgrades.map((id, i) => ({ n: i + 1, id })).slice(-3),
units: UNIT_ORDER.map((kind) => ({
kind,
name: ENEMY_SPECS[kind].name,
cost: ENEMY_SPECS[kind].cost,
affordable: state.director.energy >= ENEMY_SPECS[kind].cost,
})),
bosses: BOSS_ORDER.map((kind) => ({
kind,
name: ENEMY_SPECS[kind].name,
cost: ENEMY_SPECS[kind].cost,
phase: bossPhase(state, kind),
unlockIn: Math.max(0, Math.ceil(BOSS_UNLOCK_AT[kind] - state.t)),
cooldown: Math.ceil(state.director.bossCooldowns[kind]),
affordable: state.director.energy >= ENEMY_SPECS[kind].cost,
})),
outcomeTime: state.outcomeTime,
})

View File

@ -0,0 +1,150 @@
import type { Rng } from './rng'
import type { Vec } from './vec'
export const ARENA = { w: 960, h: 600 } as const
export const GAME_DURATION = 300
export const CONTACT_COOLDOWN = 0.5
export const ENERGY_CAP = 400
export const ENERGY_START = 30
export type UnitKind = 'rusher' | 'stalker' | 'tank' | 'bomber'
export type BossKind = 'colossus' | 'hivemind'
export type EnemyKind = UnitKind | BossKind
export type MoveMode = 'chase' | 'skirmish'
export interface EnemySpec {
readonly name: string
readonly cost: number
readonly hp: number
readonly speed: number
readonly radius: number
readonly contactDamage: number
readonly xp: number
readonly move: MoveMode
/** 0 disables ranged fire. */
readonly projDamage: number
readonly projSpeed: number
readonly fireRange: number
readonly fireCooldown: number
readonly keepDistance: number
/** True: dies on contact and blasts. */
readonly detonates: boolean
readonly blastRadius: number
readonly blastDamage: number
/** 0 disables minion spawning. */
readonly spawnEvery: number
}
const spec = (s: EnemySpec): EnemySpec => s
export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
rusher: spec({
name: 'Rusher', cost: 10, hp: 14, speed: 170, radius: 9,
contactDamage: 6, xp: 4, move: 'chase',
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
}),
stalker: spec({
name: 'Stalker', cost: 24, hp: 20, speed: 110, radius: 10,
contactDamage: 4, xp: 8, move: 'skirmish',
projDamage: 7, projSpeed: 260, fireRange: 250, fireCooldown: 1.6, keepDistance: 200,
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
}),
tank: spec({
name: 'Tank', cost: 45, hp: 120, speed: 55, radius: 18,
contactDamage: 14, xp: 20, move: 'chase',
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
}),
bomber: spec({
name: 'Bomber', cost: 30, hp: 16, speed: 140, radius: 10,
contactDamage: 0, xp: 10, move: 'chase',
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
detonates: true, blastRadius: 70, blastDamage: 22, spawnEvery: 0,
}),
colossus: spec({
name: 'Colossus', cost: 150, hp: 700, speed: 40, radius: 34,
contactDamage: 30, xp: 80, move: 'chase',
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
}),
hivemind: spec({
name: 'Hivemind', cost: 220, hp: 420, speed: 60, radius: 28,
contactDamage: 10, xp: 100, move: 'chase',
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 2.5,
}),
} as const
export const UNIT_ORDER: readonly [UnitKind, ...UnitKind[]] = ['rusher', 'stalker', 'tank', 'bomber']
export const BOSS_ORDER: readonly [BossKind, ...BossKind[]] = ['colossus', 'hivemind']
export const BOSS_UNLOCK_AT: Record<BossKind, number> = { colossus: 60, hivemind: 120 }
export const BOSS_COOLDOWN: Record<BossKind, number> = { colossus: 25, hivemind: 30 }
export type UpgradeId = 'damage' | 'fireRate' | 'speed' | 'vitality' | 'regen' | 'multishot'
export const UPGRADE_POOL: readonly [UpgradeId, ...UpgradeId[]] = [
'damage', 'fireRate', 'speed', 'vitality', 'regen', 'multishot',
]
export interface Survivor {
pos: Vec
hp: number
maxHp: number
speed: number
regen: number
damage: number
multishot: number
fireCooldown: number
fireTimer: number
level: number
xp: number
xpNext: number
kills: number
}
export interface Enemy {
id: number
kind: EnemyKind
pos: Vec
hp: number
hitTimer: number
fireTimer: number
spawnTimer: number
}
export interface Projectile {
id: number
from: 'survivor' | 'enemy'
pos: Vec
vel: Vec
radius: number
damage: number
ttl: number
}
export interface DirectorState {
energy: number
waveIndex: number
bossCooldowns: Record<BossKind, number>
}
export type DirectorAction =
| { type: 'spawn'; kind: UnitKind }
| { type: 'wave' }
| { type: 'boss'; kind: BossKind }
export type Status = 'running' | 'directorWon' | 'survivorWon'
export interface GameState {
status: Status
t: number
duration: number
rng: Rng
survivor: Survivor
enemies: Enemy[]
projectiles: Projectile[]
director: DirectorState
upgrades: UpgradeId[]
nextId: number
outcomeTime: number
}

View File

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { createRng, nextFloat, nextInt, pick } from './rng'
import { clamp, dist, toward } from './vec'
describe('vec', () => {
it('measures distance', () => {
expect(dist({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5)
})
it('builds a velocity toward a target with the requested speed', () => {
const v = toward({ x: 0, y: 0 }, { x: 10, y: 0 }, 50)
expect(v).toEqual({ x: 50, y: 0 })
})
it('returns zero velocity for coincident points', () => {
expect(toward({ x: 5, y: 5 }, { x: 5, y: 5 }, 99)).toEqual({ x: 0, y: 0 })
})
it('clamps below, inside, and above the range', () => {
expect(clamp(-1, 0, 10)).toBe(0)
expect(clamp(4, 0, 10)).toBe(4)
expect(clamp(11, 0, 10)).toBe(10)
})
})
describe('rng', () => {
it('is deterministic for a given seed', () => {
const a = createRng(123)
const b = createRng(123)
expect([nextFloat(a), nextFloat(a), nextFloat(a)])
.toEqual([nextFloat(b), nextFloat(b), nextFloat(b)])
})
it('produces different sequences for different seeds', () => {
expect(nextFloat(createRng(1))).not.toBe(nextFloat(createRng(2)))
})
it('stays within [0, 1)', () => {
const rng = createRng(77)
for (let i = 0; i < 200; i += 1) {
const v = nextFloat(rng)
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThan(1)
}
})
it('wraps negative seeds into unsigned space', () => {
expect(createRng(-1).s).toBe(0xffffffff)
})
it('nextInt stays within [0, max)', () => {
const rng = createRng(9)
for (let i = 0; i < 100; i += 1) {
const v = nextInt(rng, 4)
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThan(4)
}
})
it('pick eventually returns every element of a tuple', () => {
const rng = createRng(5)
const seen = new Set<string>()
for (let i = 0; i < 64; i += 1) {
seen.add(pick(rng, ['a', 'b', 'c'] as const))
}
expect(seen).toEqual(new Set(['a', 'b', 'c']))
})
it('pick is deterministic', () => {
expect(pick(createRng(42), [10, 20, 30] as const))
.toBe(pick(createRng(42), [10, 20, 30] as const))
})
})

View File

@ -0,0 +1,18 @@
export interface Vec {
x: number
y: number
}
export const dist = (a: Vec, b: Vec): number => Math.hypot(a.x - b.x, a.y - b.y)
/** Velocity vector of magnitude `speed` pointing from `from` to `to`. Zero when coincident. */
export const toward = (from: Vec, to: Vec, speed: number): Vec => {
const d = dist(from, to)
if (d === 0) {
return { x: 0, y: 0 }
}
return { x: ((to.x - from.x) / d) * speed, y: ((to.y - from.y) / d) * speed }
}
export const clamp = (v: number, min: number, max: number): number =>
Math.min(Math.max(v, min), max)

View File

@ -0,0 +1,27 @@
import { act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
beforeEach(() => {
vi.resetModules()
document.body.innerHTML = ''
vi.stubGlobal('requestAnimationFrame', (): number => 0)
vi.stubGlobal('cancelAnimationFrame', (): void => undefined)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('main', () => {
it('refuses to boot without a #root element', async () => {
await expect(import('./main')).rejects.toThrow('missing #root')
})
it('mounts the app into #root', async () => {
document.body.innerHTML = '<div id="root"></div>'
await act(async () => {
await import('./main')
})
expect(document.querySelector('h1')?.textContent).toBe('Reverse Survivors')
})
})

View File

@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
const rootEl = document.getElementById('root')
if (rootEl === null) {
throw new Error('missing #root element')
}
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { createInitialState, makeEnemy } from '../core/sim'
import type { GameState } from '../core/types'
import { makeCtxStub } from '../test/harness'
import { draw } from './draw'
const fresh = (): GameState => createInitialState(1)
// Empty state baseline: ritual circle + survivor halo + body + hp ring.
const BASE = { beginPath: 4, arc: 4, fill: 2, stroke: 2, fillRect: 1 }
describe('draw', () => {
it('paints the empty arena baseline', () => {
const ctx = makeCtxStub()
draw(ctx, fresh())
expect(ctx.fillRect).toHaveBeenCalledTimes(BASE.fillRect)
expect(ctx.beginPath).toHaveBeenCalledTimes(BASE.beginPath)
expect(ctx.arc).toHaveBeenCalledTimes(BASE.arc)
expect(ctx.fill).toHaveBeenCalledTimes(BASE.fill)
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke)
})
it('paints projectiles from both sides', () => {
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 },
)
draw(ctx, s)
expect(ctx.fill).toHaveBeenCalledTimes(BASE.fill + 2)
expect(ctx.arc).toHaveBeenCalledTimes(BASE.arc + 2)
})
it('a fresh grunt gets a body but no ring and no hp arc', () => {
const ctx = makeCtxStub()
const s = fresh()
makeEnemy(s, 'rusher', { x: 100, y: 100 })
draw(ctx, s)
expect(ctx.fill).toHaveBeenCalledTimes(BASE.fill + 1)
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke)
})
it('a damaged enemy shows an hp arc', () => {
const ctx = makeCtxStub()
const s = fresh()
const tank = makeEnemy(s, 'tank', { x: 100, y: 100 })
tank.hp = 60
draw(ctx, s)
expect(ctx.fill).toHaveBeenCalledTimes(BASE.fill + 1)
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke + 1)
})
it('bosses wear a blood ring', () => {
const ctx = makeCtxStub()
const s = fresh()
makeEnemy(s, 'colossus', { x: 100, y: 100 })
draw(ctx, s)
expect(ctx.fill).toHaveBeenCalledTimes(BASE.fill + 1)
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke + 1)
expect(ctx.arc).toHaveBeenCalledTimes(BASE.arc + 2)
})
})

View File

@ -0,0 +1,107 @@
import { FIRE_RANGE, SURVIVOR_RADIUS } from '../core/sim'
import type { EnemyKind, GameState } from '../core/types'
import { ARENA, ENEMY_SPECS } from '../core/types'
/** Structural subset of CanvasRenderingContext2D — stubbable in tests. */
export interface Ctx2D {
fillStyle: string | CanvasGradient | CanvasPattern
strokeStyle: string | CanvasGradient | CanvasPattern
lineWidth: number
globalAlpha: number
fillRect: (x: number, y: number, w: number, h: number) => void
beginPath: () => void
arc: (x: number, y: number, r: number, a0: number, a1: number) => void
fill: () => void
stroke: () => void
}
const COLORS = {
void: '#140b1c',
ring: '#2b1a3a',
bone: '#e8dcc8',
holy: '#7ce0c3',
holyDim: '#2e5c50',
blood: '#c94f7c',
arcane: '#b06ce8',
} as const
const ENEMY_COLORS: Record<EnemyKind, string> = {
rusher: '#c94f7c',
stalker: '#b06ce8',
tank: '#8a4b3c',
bomber: '#e0a13d',
colossus: '#7d2c50',
hivemind: '#5a3d8a',
}
const IS_BOSS: Record<EnemyKind, boolean> = {
rusher: false,
stalker: false,
tank: false,
bomber: false,
colossus: true,
hivemind: true,
}
const circle = (ctx: Ctx2D, x: number, y: number, r: number): void => {
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
}
export const draw = (ctx: Ctx2D, state: GameState): void => {
ctx.globalAlpha = 1
ctx.fillStyle = COLORS.void
ctx.fillRect(0, 0, ARENA.w, ARENA.h)
// Ritual circle: the survivor's consecrated ground.
ctx.globalAlpha = 0.5
ctx.strokeStyle = COLORS.ring
ctx.lineWidth = 2
circle(ctx, ARENA.w / 2, ARENA.h / 2, 150)
ctx.stroke()
ctx.globalAlpha = 1
for (const p of state.projectiles) {
ctx.fillStyle = p.from === 'survivor' ? COLORS.holy : COLORS.blood
circle(ctx, p.pos.x, p.pos.y, p.radius)
ctx.fill()
}
for (const e of state.enemies) {
const spec = ENEMY_SPECS[e.kind]
ctx.fillStyle = ENEMY_COLORS[e.kind]
circle(ctx, e.pos.x, e.pos.y, spec.radius)
ctx.fill()
if (IS_BOSS[e.kind]) {
ctx.strokeStyle = COLORS.blood
ctx.lineWidth = 3
circle(ctx, e.pos.x, e.pos.y, spec.radius + 6)
ctx.stroke()
}
if (e.hp < spec.hp) {
ctx.strokeStyle = COLORS.bone
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(e.pos.x, e.pos.y, spec.radius + 3, -Math.PI / 2, -Math.PI / 2 + (e.hp / spec.hp) * Math.PI * 2)
ctx.stroke()
}
}
const sv = state.survivor
ctx.globalAlpha = 0.12
ctx.fillStyle = COLORS.holy
circle(ctx, sv.pos.x, sv.pos.y, FIRE_RANGE)
ctx.fill()
ctx.globalAlpha = 1
ctx.fillStyle = COLORS.holy
circle(ctx, sv.pos.x, sv.pos.y, SURVIVOR_RADIUS)
ctx.fill()
ctx.strokeStyle = COLORS.holyDim
ctx.lineWidth = 3
ctx.beginPath()
ctx.arc(
sv.pos.x, sv.pos.y, SURVIVOR_RADIUS + 5,
-Math.PI / 2, -Math.PI / 2 + (Math.max(0, sv.hp) / sv.maxHp) * Math.PI * 2,
)
ctx.stroke()
}

View File

@ -0,0 +1,222 @@
:root {
--void: #140b1c;
--panel: #1e1229;
--panel-edge: #34204a;
--bone: #e8dcc8;
--bone-dim: #9c8f7a;
--blood: #c94f7c;
--arcane: #b06ce8;
--holy: #7ce0c3;
--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
--font-data: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(1200px 500px at 50% -10%, #241335 0%, var(--void) 60%);
color: var(--bone);
font-family: var(--font-data);
min-height: 100vh;
}
.frame {
max-width: 1280px;
margin: 0 auto;
padding: 16px 20px 28px;
display: flex;
flex-direction: column;
gap: 14px;
}
.masthead {
display: flex;
align-items: baseline;
gap: 16px;
border-bottom: 1px solid var(--panel-edge);
padding-bottom: 10px;
}
.masthead h1 {
font-family: var(--font-display);
font-size: 1.7rem;
letter-spacing: 0.06em;
margin: 0;
color: var(--bone);
}
.tagline {
margin: 0;
color: var(--bone-dim);
font-size: 0.8rem;
flex: 1;
}
.clock {
margin: 0;
font-size: 1.1rem;
color: var(--arcane);
}
.board {
display: flex;
gap: 14px;
align-items: flex-start;
}
.stage {
border: 1px solid var(--panel-edge);
border-radius: 6px;
background: var(--void);
width: 100%;
max-width: 960px;
height: auto;
display: block;
}
.hud {
width: 270px;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 14px;
position: relative;
}
.hud-block {
background: var(--panel);
border: 1px solid var(--panel-edge);
border-radius: 6px;
padding: 12px 14px;
}
.hud-label {
font-family: var(--font-display);
font-size: 0.95rem;
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0 0 8px;
color: var(--bone-dim);
}
.hud-num { margin: 6px 0 0; font-size: 0.85rem; }
.phials { display: flex; gap: 5px; }
.phial {
width: 18px;
height: 26px;
border: 1px solid var(--panel-edge);
border-radius: 3px 3px 8px 8px;
background: transparent;
}
.phial-full {
background: linear-gradient(180deg, var(--arcane), #6d3aa8);
border-color: var(--arcane);
box-shadow: 0 0 8px rgba(176, 108, 232, 0.45);
}
.hpbar {
height: 10px;
border: 1px solid var(--panel-edge);
border-radius: 5px;
overflow: hidden;
background: #120b18;
}
.hpbar-fill {
height: 100%;
background: linear-gradient(90deg, #2e5c50, var(--holy));
transition: width 120ms linear;
}
.feed {
list-style: none;
margin: 8px 0 0;
padding: 0;
font-size: 0.75rem;
color: var(--holy);
}
.feed li::before { content: "▲ "; color: var(--bone-dim); }
.spawnbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.card {
font-family: var(--font-data);
background: var(--panel);
color: var(--bone);
border: 1px solid var(--panel-edge);
border-radius: 6px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 3px;
cursor: pointer;
min-width: 118px;
text-align: left;
}
.card:hover:not(:disabled) {
border-color: var(--blood);
box-shadow: 0 0 10px rgba(201, 79, 124, 0.35);
}
.card:focus-visible {
outline: 2px solid var(--arcane);
outline-offset: 2px;
}
.card:disabled { opacity: 0.42; cursor: not-allowed; }
.card-name { font-family: var(--font-display); letter-spacing: 0.05em; }
.card-cost { font-size: 0.72rem; color: var(--blood); }
.card-boss { border-color: #5c2440; }
.card-wave { border-color: #3d2f63; }
.overlay {
background: var(--panel);
border-radius: 6px;
padding: 16px;
text-align: center;
}
.overlay h2 { font-family: var(--font-display); margin: 0 0 6px; }
.overlay p { margin: 0 0 12px; font-size: 0.8rem; color: var(--bone-dim); }
.overlay-win { border: 1px solid var(--blood); }
.overlay-loss { border: 1px solid var(--holy); }
.overlay button {
font-family: var(--font-display);
background: var(--blood);
color: var(--void);
border: none;
border-radius: 5px;
padding: 9px 16px;
cursor: pointer;
letter-spacing: 0.04em;
}
.overlay-loss button { background: var(--holy); }
@media (prefers-reduced-motion: reduce) {
.hpbar-fill { transition: none; }
}
@media (max-width: 900px) {
.board { flex-direction: column; }
.hud { width: 100%; }
}

View File

@ -0,0 +1,56 @@
import type { Mock } from 'vitest'
import { vi } from 'vitest'
import type { Ctx2D } from '../render/draw'
export interface RafHarness {
/** Fire all queued frame callbacks with the given timestamp (ms). */
pump: (ts: number) => void
pending: () => number
restore: () => void
}
export const installRaf = (): RafHarness => {
let queue: FrameRequestCallback[] = []
let nextHandle = 0
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback): number => {
queue.push(cb)
nextHandle += 1
return nextHandle
})
vi.stubGlobal('cancelAnimationFrame', (): void => {
queue = []
})
return {
pump: (ts: number): void => {
const batch = queue
queue = []
for (const cb of batch) {
cb(ts)
}
},
pending: (): number => queue.length,
restore: (): void => {
vi.unstubAllGlobals()
},
}
}
export interface CtxStub extends Ctx2D {
fillRect: Mock<(x: number, y: number, w: number, h: number) => void>
beginPath: Mock<() => void>
arc: Mock<(x: number, y: number, r: number, a0: number, a1: number) => void>
fill: Mock<() => void>
stroke: Mock<() => void>
}
export const makeCtxStub = (): CtxStub => ({
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
globalAlpha: 1,
fillRect: vi.fn<(x: number, y: number, w: number, h: number) => void>(),
beginPath: vi.fn<() => void>(),
arc: vi.fn<(x: number, y: number, r: number, a0: number, a1: number) => void>(),
fill: vi.fn<() => void>(),
stroke: vi.fn<() => void>(),
})

View File

@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})

View File

@ -0,0 +1,18 @@
import type { ReactElement } from 'react'
import type { Ctx2D } from '../render/draw'
import { ARENA } from '../core/types'
interface Props {
onCtx: (ctx: Ctx2D | null) => void
}
export const CanvasStage = ({ onCtx }: Props): ReactElement => (
<canvas
className="stage"
width={ARENA.w}
height={ARENA.h}
ref={(el) => {
onCtx(el === null ? null : el.getContext('2d'))
}}
/>
)

View File

@ -0,0 +1,67 @@
import type { ReactElement } from 'react'
import type { HudSnapshot } from '../core/snapshot'
import { fmtTime } from './format'
const PHIALS: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const UPGRADE_LABELS: Record<HudSnapshot['upgrades'][number]['id'], string> = {
damage: 'sharper bolts',
fireRate: 'faster casting',
speed: 'fleeter feet',
vitality: 'thicker blood',
regen: 'knitting flesh',
multishot: 'split volley',
}
interface Props {
snap: HudSnapshot
onRestart: () => void
}
export const Hud = ({ snap, onRestart }: Props): ReactElement => {
const filled = Math.floor((snap.energy / snap.energyCap) * PHIALS.length)
const hpPct = (snap.survivorHp / snap.survivorMaxHp) * 100
return (
<aside className="hud">
<div className="hud-block">
<h2 className="hud-label">Soul reserve</h2>
<div className="phials" role="meter" aria-valuenow={Math.floor(snap.energy)} aria-valuemin={0} aria-valuemax={snap.energyCap}>
{PHIALS.map((n) => (
<span key={`phial-${String(n)}`} className={n <= filled ? 'phial phial-full' : 'phial'} />
))}
</div>
<p className="hud-num">{Math.floor(snap.energy)} / {snap.energyCap}</p>
</div>
<div className="hud-block">
<h2 className="hud-label">The intruder</h2>
<div className="hpbar">
<div className="hpbar-fill" style={{ width: `${String(hpPct)}%` }} />
</div>
<p className="hud-num">
{Math.ceil(snap.survivorHp)} hp · level {snap.survivorLevel} · {snap.kills} of yours slain
</p>
<ul className="feed">
{snap.upgrades.map((u) => (
<li key={`up-${String(u.n)}`}>{UPGRADE_LABELS[u.id]}</li>
))}
</ul>
</div>
{snap.status === 'directorWon' && (
<div className="overlay overlay-win">
<h2>The intruder falls</h2>
<p>Extinguished at {fmtTime(snap.outcomeTime)}.</p>
<button type="button" onClick={onRestart}>Open the gates again</button>
</div>
)}
{snap.status === 'survivorWon' && (
<div className="overlay overlay-loss">
<h2>Dawn breaks</h2>
<p>The intruder outlasted your horde for {fmtTime(snap.duration)}.</p>
<button type="button" onClick={onRestart}>Raise a new horde</button>
</div>
)}
</aside>
)
}

View File

@ -0,0 +1,60 @@
import type { ReactElement } from 'react'
import type { BossButton } from '../core/snapshot'
import type { HudSnapshot } from '../core/snapshot'
import type { DirectorAction } from '../core/types'
interface Props {
snap: HudSnapshot
onAction: (action: DirectorAction) => void
}
const bossCaption = (b: BossButton): string => {
if (b.phase === 'locked') {
return `seals break in ${String(b.unlockIn)}s`
}
if (b.phase === 'cooling') {
return `recovering ${String(b.cooldown)}s`
}
return `${String(b.cost)} souls`
}
export const SpawnBar = ({ snap, onAction }: Props): ReactElement => {
const running = snap.status === 'running'
return (
<nav className="spawnbar" aria-label="Summoning">
{snap.units.map((u) => (
<button
key={u.kind}
type="button"
className="card"
disabled={!running || !u.affordable}
onClick={() => { onAction({ type: 'spawn', kind: u.kind }) }}
>
<span className="card-name">{u.name}</span>
<span className="card-cost">{u.cost} souls</span>
</button>
))}
<button
type="button"
className="card card-wave"
disabled={!running || !snap.waveAffordable}
onClick={() => { onAction({ type: 'wave' }) }}
>
<span className="card-name">Wave {snap.waveIndex + 1}</span>
<span className="card-cost">{snap.waveCost} souls</span>
</button>
{snap.bosses.map((b) => (
<button
key={b.kind}
type="button"
className="card card-boss"
disabled={!running || b.phase !== 'ready' || !b.affordable}
onClick={() => { onAction({ type: 'boss', kind: b.kind }) }}
>
<span className="card-name">{b.name}</span>
<span className="card-cost">{bossCaption(b)}</span>
</button>
))}
</nav>
)
}

View File

@ -0,0 +1,6 @@
export const fmtTime = (seconds: number): string => {
const whole = Math.floor(seconds)
const m = Math.floor(whole / 60)
const s = whole % 60
return `${String(m)}:${String(s).padStart(2, '0')}`
}

View File

@ -0,0 +1,100 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { createInitialState } from '../core/sim'
import { snapshotOf } from '../core/snapshot'
import type { HudSnapshot } from '../core/snapshot'
import type { GameState } from '../core/types'
import { Hud } from './Hud'
import { SpawnBar } from './SpawnBar'
const snapWith = (mutate: (s: GameState) => void = () => undefined): HudSnapshot => {
const s = createInitialState(1)
mutate(s)
return snapshotOf(s)
}
describe('Hud', () => {
it('fills soul phials proportionally', () => {
const { container } = render(<Hud snap={snapWith((s) => { s.director.energy = 200 })} onRestart={() => undefined} />)
expect(container.querySelectorAll('.phial')).toHaveLength(10)
expect(container.querySelectorAll('.phial-full')).toHaveLength(5)
expect(screen.getByText('200 / 400')).toBeInTheDocument()
})
it('reports the intruder and its recent upgrades', () => {
const snap = snapWith((s) => {
s.survivor.hp = 50
s.survivor.kills = 4
s.upgrades = ['damage', 'multishot']
})
const { container } = render(<Hud snap={snap} onRestart={() => undefined} />)
expect(container.querySelector('.hpbar-fill')).toHaveStyle({ width: '50%' })
expect(screen.getByText(/4 of yours slain/)).toBeInTheDocument()
expect(screen.getByText('sharper bolts')).toBeInTheDocument()
expect(screen.getByText('split volley')).toBeInTheDocument()
})
it('shows no overlay while the game runs', () => {
render(<Hud snap={snapWith()} onRestart={() => undefined} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('celebrates a director victory and restarts on demand', () => {
const onRestart = vi.fn()
const snap = snapWith((s) => { s.status = 'directorWon'; s.outcomeTime = 83 })
render(<Hud snap={snap} onRestart={onRestart} />)
expect(screen.getByText('The intruder falls')).toBeInTheDocument()
expect(screen.getByText(/1:23/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /open the gates/i }))
expect(onRestart).toHaveBeenCalledOnce()
})
it('mourns a survivor victory and restarts on demand', () => {
const onRestart = vi.fn()
render(<Hud snap={snapWith((s) => { s.status = 'survivorWon' })} onRestart={onRestart} />)
expect(screen.getByText('Dawn breaks')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /raise a new horde/i }))
expect(onRestart).toHaveBeenCalledOnce()
})
})
describe('SpawnBar', () => {
it('dispatches unit spawns the director can afford', () => {
const onAction = vi.fn()
render(<SpawnBar snap={snapWith()} onAction={onAction} />)
fireEvent.click(screen.getByRole('button', { name: /rusher/i }))
expect(onAction).toHaveBeenCalledWith({ type: 'spawn', kind: 'rusher' })
expect(screen.getByRole('button', { name: /tank/i })).toBeDisabled()
})
it('dispatches waves when funded and blocks them when broke', () => {
const onAction = vi.fn()
const { rerender } = render(<SpawnBar snap={snapWith()} onAction={onAction} />)
expect(screen.getByRole('button', { name: /wave 1/i })).toBeDisabled()
rerender(<SpawnBar snap={snapWith((s) => { s.director.energy = 100 })} onAction={onAction} />)
fireEvent.click(screen.getByRole('button', { name: /wave 1/i }))
expect(onAction).toHaveBeenCalledWith({ type: 'wave' })
})
it('freezes all summoning once the game ends', () => {
render(<SpawnBar snap={snapWith((s) => { s.status = 'directorWon' })} onAction={() => undefined} />)
expect(screen.getByRole('button', { name: /rusher/i })).toBeDisabled()
})
it('walks a boss through locked, cooling, poor, and ready', () => {
const onAction = vi.fn()
const { rerender } = render(<SpawnBar snap={snapWith()} onAction={onAction} />)
expect(screen.getByText(/seals break in 60s/)).toBeInTheDocument()
rerender(<SpawnBar snap={snapWith((s) => { s.t = 61; s.director.bossCooldowns.colossus = 5 })} onAction={onAction} />)
expect(screen.getByText(/recovering 5s/)).toBeInTheDocument()
rerender(<SpawnBar snap={snapWith((s) => { s.t = 61; s.director.energy = 20 })} onAction={onAction} />)
expect(screen.getByRole('button', { name: /colossus/i })).toBeDisabled()
expect(screen.getByText('150 souls')).toBeInTheDocument()
rerender(<SpawnBar snap={snapWith((s) => { s.t = 61; s.director.energy = 200 })} onAction={onAction} />)
fireEvent.click(screen.getByRole('button', { name: /colossus/i }))
expect(onAction).toHaveBeenCalledWith({ type: 'boss', kind: 'colossus' })
})
})

View File

@ -0,0 +1,71 @@
import { render, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Ctx2D } from '../render/draw'
import { installRaf, makeCtxStub } from '../test/harness'
import { CanvasStage } from './CanvasStage'
import { fmtTime } from './format'
import { useGameLoop } from './useGameLoop'
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('fmtTime', () => {
it('formats minutes and zero-padded seconds', () => {
expect(fmtTime(0)).toBe('0:00')
expect(fmtTime(65)).toBe('1:05')
expect(fmtTime(300.9)).toBe('5:00')
})
})
describe('useGameLoop', () => {
it('feeds clamped dt values and swaps callbacks without restarting', () => {
const raf = installRaf()
const dts: number[] = []
const cb1 = (dt: number): void => { dts.push(dt) }
const { rerender } = renderHook(({ cb }: { cb: (dt: number) => void }) => { useGameLoop(cb) }, {
initialProps: { cb: cb1 },
})
expect(raf.pending()).toBe(1)
raf.pump(0)
raf.pump(16)
raf.pump(1016)
expect(dts[0]).toBe(0)
expect(dts[1]).toBeCloseTo(0.016)
expect(dts[2]).toBe(0.1)
const dts2: number[] = []
rerender({ cb: (dt: number): void => { dts2.push(dt) } })
raf.pump(1032)
expect(dts2).toHaveLength(1)
expect(dts).toHaveLength(3)
})
it('cancels its frame on unmount', () => {
const raf = installRaf()
const { unmount } = renderHook(() => { useGameLoop(() => undefined) })
expect(raf.pending()).toBe(1)
unmount()
expect(raf.pending()).toBe(0)
})
})
describe('CanvasStage', () => {
it('hands over the 2d context when available, and null on unmount', () => {
const stub = makeCtxStub()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockImplementation(() => stub as unknown as CanvasRenderingContext2D)
const seen: (Ctx2D | null)[] = []
const { unmount } = render(<CanvasStage onCtx={(c) => { seen.push(c) }} />)
expect(seen).toEqual([stub])
unmount()
expect(seen).toEqual([stub, null])
})
it('hands over null when the environment has no 2d context', () => {
const seen: (Ctx2D | null)[] = []
render(<CanvasStage onCtx={(c) => { seen.push(c) }} />)
expect(seen).toEqual([null])
})
})

View File

@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react'
/** Runs `onFrame(dtSeconds)` on every animation frame. dt is clamped to 100 ms. */
export const useGameLoop = (onFrame: (dt: number) => void): void => {
const cbRef = useRef(onFrame)
useEffect(() => {
cbRef.current = onFrame
})
useEffect(() => {
let handle = 0
let last = -1
const tick = (ts: number): void => {
if (last < 0) {
last = ts
}
const dt = Math.min((ts - last) / 1000, 0.1)
last = ts
cbRef.current(dt)
handle = requestAnimationFrame(tick)
}
handle = requestAnimationFrame(tick)
return (): void => {
cancelAnimationFrame(handle)
}
}, [])
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,18 @@
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
thresholds: { lines: 100, functions: 100, branches: 100, statements: 100 },
reporter: ['text', 'json-summary'],
},
},
})