mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
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:
parent
8e24ff093f
commit
a47191673a
35
.github/workflows/reverse-survivors-ci.yml
vendored
Normal file
35
.github/workflows/reverse-survivors-ci.yml
vendored
Normal 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
|
||||
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
20
docs/superpowers/contracts/reverse-survivors-2026-08-03.json
Normal file
20
docs/superpowers/contracts/reverse-survivors-2026-08-03.json
Normal 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"
|
||||
}
|
||||
56
docs/superpowers/evidence/reverse-survivors-2026-08-03.json
Normal file
56
docs/superpowers/evidence/reverse-survivors-2026-08-03.json
Normal 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
3
reverse_survivors/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
62
reverse_survivors/README.md
Normal file
62
reverse_survivors/README.md
Normal 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)
|
||||
47
reverse_survivors/eslint.config.js
Normal file
47
reverse_survivors/eslint.config.js
Normal 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],
|
||||
},
|
||||
)
|
||||
12
reverse_survivors/index.html
Normal file
12
reverse_survivors/index.html
Normal 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
4452
reverse_survivors/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
reverse_survivors/package.json
Normal file
41
reverse_survivors/package.json
Normal 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"
|
||||
}
|
||||
70
reverse_survivors/src/App.test.tsx
Normal file
70
reverse_survivors/src/App.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
72
reverse_survivors/src/App.tsx
Normal file
72
reverse_survivors/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
reverse_survivors/src/core/director.test.ts
Normal file
171
reverse_survivors/src/core/director.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
96
reverse_survivors/src/core/director.ts
Normal file
96
reverse_survivors/src/core/director.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
33
reverse_survivors/src/core/rng.ts
Normal file
33
reverse_survivors/src/core/rng.ts
Normal 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
|
||||
}
|
||||
370
reverse_survivors/src/core/sim.test.ts
Normal file
370
reverse_survivors/src/core/sim.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
296
reverse_survivors/src/core/sim.ts
Normal file
296
reverse_survivors/src/core/sim.ts
Normal 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
|
||||
}
|
||||
}
|
||||
90
reverse_survivors/src/core/snapshot.ts
Normal file
90
reverse_survivors/src/core/snapshot.ts
Normal 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,
|
||||
})
|
||||
150
reverse_survivors/src/core/types.ts
Normal file
150
reverse_survivors/src/core/types.ts
Normal 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
|
||||
}
|
||||
73
reverse_survivors/src/core/vec-rng.test.ts
Normal file
73
reverse_survivors/src/core/vec-rng.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
18
reverse_survivors/src/core/vec.ts
Normal file
18
reverse_survivors/src/core/vec.ts
Normal 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)
|
||||
27
reverse_survivors/src/main.test.tsx
Normal file
27
reverse_survivors/src/main.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
14
reverse_survivors/src/main.tsx
Normal file
14
reverse_survivors/src/main.tsx
Normal 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>,
|
||||
)
|
||||
63
reverse_survivors/src/render/draw.test.ts
Normal file
63
reverse_survivors/src/render/draw.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
107
reverse_survivors/src/render/draw.ts
Normal file
107
reverse_survivors/src/render/draw.ts
Normal 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()
|
||||
}
|
||||
222
reverse_survivors/src/styles.css
Normal file
222
reverse_survivors/src/styles.css
Normal 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%; }
|
||||
}
|
||||
56
reverse_survivors/src/test/harness.ts
Normal file
56
reverse_survivors/src/test/harness.ts
Normal 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>(),
|
||||
})
|
||||
7
reverse_survivors/src/test/setup.ts
Normal file
7
reverse_survivors/src/test/setup.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
18
reverse_survivors/src/ui/CanvasStage.tsx
Normal file
18
reverse_survivors/src/ui/CanvasStage.tsx
Normal 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'))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
67
reverse_survivors/src/ui/Hud.tsx
Normal file
67
reverse_survivors/src/ui/Hud.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
reverse_survivors/src/ui/SpawnBar.tsx
Normal file
60
reverse_survivors/src/ui/SpawnBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
reverse_survivors/src/ui/format.ts
Normal file
6
reverse_survivors/src/ui/format.ts
Normal 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')}`
|
||||
}
|
||||
100
reverse_survivors/src/ui/panels.test.tsx
Normal file
100
reverse_survivors/src/ui/panels.test.tsx
Normal 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' })
|
||||
})
|
||||
})
|
||||
71
reverse_survivors/src/ui/ui-bits.test.tsx
Normal file
71
reverse_survivors/src/ui/ui-bits.test.tsx
Normal 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])
|
||||
})
|
||||
})
|
||||
26
reverse_survivors/src/ui/useGameLoop.ts
Normal file
26
reverse_survivors/src/ui/useGameLoop.ts
Normal 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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
24
reverse_survivors/tsconfig.json
Normal file
24
reverse_survivors/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
reverse_survivors/vite.config.ts
Normal file
18
reverse_survivors/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user