File size: 10,001 Bytes
d0e9298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6109dc
 
 
 
 
 
 
d0e9298
b6109dc
 
 
d0e9298
e3658fa
 
 
 
 
 
 
 
 
 
cce54f5
 
 
 
 
 
 
 
 
 
 
e3658fa
 
 
 
 
 
 
 
 
 
 
 
d0e9298
 
 
 
 
 
9f4b083
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0e9298
 
 
 
 
 
 
 
 
 
2b4cf02
 
 
 
 
 
 
 
 
 
 
 
778e159
2b4cf02
 
778e159
2b4cf02
778e159
 
 
2b4cf02
 
 
 
 
 
 
 
 
 
 
d0e9298
 
 
 
e3658fa
 
778e159
 
 
 
d0e9298
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# Contributing

Stellar Frontier: RTS is a vanilla-JavaScript, ES-module game with **no build step** and **no
runtime dependencies**. The files in the repo are exactly what the browser loads. That simplicity
is a feature β€” please keep it. A few rules are load-bearing; the test suite enforces them, so a
change that breaks one fails `npm test` rather than shipping.

## Getting set up

```
node --version      # must be >= 20
npm start           # serve the game at http://localhost:8080  (zero-dep static server)
npm test            # run the full suite (node --test)
```

There is nothing to install β€” no `npm install`, no bundler, no transpiler.

## The hard rules

These are invariants, not preferences. Each has a guarding test that will go red if you break it.

### 1. The engine is pure, deterministic, and DOM-free

Everything under `engine/` is the simulation: pure logic, no rendering, no browser. It must obey:

- **One source of randomness.** All randomness comes from the seeded PRNG in `engine/rng.js`
  (`mulberry32`, plus the `hashStr` tie-break helper). The engine may **never** call
  `Math.random`, `Date.now`, `new Date`, or `performance.now` β€” not even in a comment.
  (`test/engine-purity.test.js`.) A line that genuinely isn't the sim can opt out with a
  `deterministic-exempt` comment, but that should be vanishingly rare.
- **No DOM / browser globals.** No `document`, `window`, `localStorage`, `fetch`,
  `requestAnimationFrame`, etc. under `engine/`. The one sanctioned seam is the render loop in
  `engine/loop.js`, whose `requestAnimationFrame` lines carry a `browser-exempt` marker.
  (`test/engine-purity.test.js`.)
- **Same seed β‡’ same game.** Two runs from the same seed must produce byte-identical state, on
  every world. If you touch the engine, keep replays identical β€” watch iteration order and
  float-accumulation order especially. (`test/determinism.test.js`,
  `test/determinism-roster.test.js`.)

If you need a stable-but-varying value (a per-unit angle, a tie-break), hash an id through
`hashStr` β€” don't reach for a clock or `Math.random`.

### 2. No build step, ever

The browser loads the repo as-is. So:

- Ship plain ES modules the browser understands β€” no JSX, no TypeScript syntax, no bundler-only
  imports.
- Every `getElementById` target must exist in `index.html` (or be created in JS), every relative
  import must resolve, and every file must parse. (`test/static-integrity.test.js`.)
- UI modules should stay import-safe under Node (guard top-level `window`/`document` access), so
  their logic can be unit-tested. `dom.js` already resolves `document` defensively; follow that
  pattern.

### 3. Saves are versioned

Save data is untrusted input and is version-gated:

- `engine/persist.js` owns `SAVE_VERSION` (skirmish) and `GALAXY_SAVE_VERSION` (Odyssey). **Bump
  the relevant one whenever you change a save's shape in a way older saves can't survive.** The
  version check is exact-match (`if (save.v !== SAVE_VERSION) throw`) β€” there is no migration
  step, so bumping the version makes every save written under the old version unloadable; the load
  fails fast with a clear "unsupported save version" error instead of feeding stale-shaped data
  into the sim. If a change is purely additive (a new optional field with a sensible default), you
  usually don't need to bump the version β€” `sanitizeSave`/`cleanEntity` already default missing
  fields for saves at the *current* version.
- Loading always sanitizes and coerces (`sanitizeSave`, `cleanEntity`) β€” never trust a field's
  type or range straight off the wire. That coercion covers corrupt or missing fields within a
  supported version; it's not a substitute for bumping the version when the shape itself changes
  in an incompatible way.

## Types (JSDoc + `// @ts-check`)

The core sim shapes β€” `State`, `Unit`, `Building`, `Player`, `Galaxy`, and friends β€” are defined
as JSDoc `@typedef`s in `engine/types.js`. That file has **no runtime code** and is never
imported; it exists purely so the type checker (and any editor with the bundled TypeScript
language service β€” e.g. VS Code out of the box) can verify field access against a real model
instead of an untyped bag. **No build step, no runtime dependency** β€” the shipped code stays plain
ES modules.

Type checking is **opt-in per file**: a file is checked only if it starts with a `// @ts-check`
pragma. Twenty engine files opt in today β€” the core data and hot-path modules (`state.js`, `movement.js`,
`gather.js`, `grid.js`, `fog.js`, `separation.js`, `formation.js`, `haul.js`, `recycle.js`,
`wreckage.js`) plus `supply.js`, `colliders.js`, `scout.js`, `victory.js`, `production.js`,
`persist.js`, `aiCommon.js`, `aiStrategy.js`, `aiDifficulty.js` and `aiArchetypes.js`. Expand
coverage file-by-file by adding the pragma **and annotating** the functions'
`state`/`unit`/`building` params with the shared typedefs.

The annotation half is not optional: `strict` and `noImplicitAny` are off, so an un-annotated
parameter is `any` and the pragma alone checks nothing. `test/types-contract.test.js` enforces both
halves β€” every `// @ts-check` file must annotate its exported functions, and every field a core
factory constructs must be declared on its `@typedef`.
This is what catches the silent-`undefined`-field class of bug β€” a mistyped or renamed field is a
check-time error, not a wrong result the same-seed determinism test can't see.

```
npm run typecheck        # runs `tsc -p jsconfig.json` β€” needs a TypeScript compiler available
                         # (global `tsc`, or `npx -y typescript` / a local install). Editors with
                         # the TS language service check the annotated files live, with no install.
```

When you add or rename a field on a core shape, update its `@typedef` in `engine/types.js` in the
same change.

## Style

Match the surrounding code: the same comment density (this codebase explains *why*, not *what*),
the same naming, the same idioms. Prefer a small pure helper in the right module over a clever
one-liner. Add or update a test for any behavioural change.

## Test-Driven Development

For a new feature or a behavioural change, write the test(s) first, from the requirement, before
writing the implementation:

1. Turn the requirement into one or more `node:test` cases in the file where that behavior
   belongs (see the existing suite for the idiom β€” direct state construction, no mocks). Do this
   from what the feature is supposed to do, not by reading ahead into a planned implementation.
2. Run them and confirm they fail for the right reason (a missing export, an assertion against
   current behavior) β€” red before green.
3. Implement the smallest change that makes them pass, following the rest of this guide (purity,
   determinism, save versioning).
4. Run the whole suite (`npm test`) and `npm run typecheck` β€” a new feature can surface a
   now-outdated assumption in an older test; update that test's assertion to the new, intended
   contract rather than deleting coverage.

## Commits

- Keep commits focused and their messages descriptive β€” say what changed and why, and note that
  the suite stays green.
- Every commit is signed off with:

  ```
  Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  ```

## Protecting `main` (one-time repo setup β€” not yet done)

Everything above is enforced by tests, and the tests run in CI on every push and pull request. But
nothing stops a red build being merged anyway, and that is not hypothetical: `npm run typecheck`
failed on every commit from 2026-08-05 to 2026-08-08, and PRs #90 and #91 both merged to `main`
straight through it. A gate nobody is required to pass is a gate that eventually gets walked past.

This is a repository setting, so it cannot live in a file here. It takes about two minutes:

**Settings β†’ Branches β†’ Add branch ruleset** (or *Add rule* on the classic UI)

- Target branch: `main`
- β˜‘ **Require status checks to pass before merging**, and add all three by name:
  - `tests (node 20)`
  - `tests (node 22)`
  - `browser smoke test`

  All three must be listed. The matrix produces one check per Node version, and requiring only one
  lets a version-specific regression through β€” which is the whole reason the matrix exists. The
  smoke job is the only check that can see a page which parses cleanly and then throws on load.
- β˜‘ **Require branches to be up to date before merging** β€” so a check that passed against a stale
  base cannot count for a merge onto a newer one.
- β˜‘ **Block force pushes**

Leave "Require a pull request before merging" to taste; it is orthogonal to the failure above,
which was about a red check rather than an unreviewed one.

The check names come from `.github/workflows/test.yml`'s job name
(`name: tests (node ${{ matrix.node-version }})`). If that line is ever edited, the required
checks silently stop matching and the gate goes quiet β€” so change the two together.

## Release checklist

When cutting a release:

1. `npm test` is green (determinism + purity + static-integrity included), and `npm run typecheck`
   reports no errors on the `// @ts-check`ed files.
2. `npm run smoke` is green β€” boots the real page in real Chromium, starts a match and clicks
   things, failing on any uncaught error (`tools/smoke.js`; CI runs it as the *browser smoke test*
   job). It is shallow on purpose. Still worth ten minutes by hand for anything the script does not
   cover β€” an Odyssey run, and a save/reload of both modes.
3. Bump `APP_VERSION` in `version.js` **and** `version` in `package.json` to the new semver, and
   keep `version.json` in sync (the auto-update check compares them). (`test/release-manifest.test.js`,
   `test/version.test.js`.)
4. If any save shape changed, confirm `SAVE_VERSION` / `GALAXY_SAVE_VERSION` were bumped and old
   saves still load.
5. Add a dated section to `CHANGELOG.md`.
6. Tag the release: `git tag vX.Y.Z && git push --tags`.