diff --git a/.changeset/blue-rocks-install.md b/.changeset/blue-rocks-install.md new file mode 100644 index 0000000..2f30cf6 --- /dev/null +++ b/.changeset/blue-rocks-install.md @@ -0,0 +1,5 @@ +--- +"create-eigen-game": patch +--- + +The `firebase` CLI is now suggested as `curl -sL https://firebase.tools | bash` rather than `npm install -g firebase-tools`, both in the missing-tooling report and in the scaffolded project's README. That installer is Google's own, it picks a standalone binary or npm to suit the machine, and it is the one the Firebase documentation leads with. The npm install still works; it just needs a global prefix the reader may not have write access to. diff --git a/.changeset/plain-words-no-dashes.md b/.changeset/plain-words-no-dashes.md new file mode 100644 index 0000000..557a7a4 --- /dev/null +++ b/.changeset/plain-words-no-dashes.md @@ -0,0 +1,16 @@ +--- +"@eigeninteractive/server": patch +"@eigeninteractive/kernel": patch +"@eigeninteractive/rules": patch +"@eigeninteractive/testkit": patch +"create-eigen-game": patch +--- + +Em dashes are gone from every line this repository writes. Most of that is comments and documentation, but some of it is text that ships: + +- **Error and response messages.** `State updated, try again` (kernel `stateUpdated`), `Too many requests in a short window. Slow down and try again.`, `Unsupported image type '…'. Use image/jpeg, …`, `Account deletion failed. Please try again`. Dispatch on `code`, never on `error`, so nothing that follows that rule is affected. +- **OpenAPI descriptions**, and therefore the generated `eigen_api` Dart client and the published HTTP reference. Wording only; no operation, schema or status code moved. +- **Engine-rendered public pages.** A page title now reads `Terms of Service: My Game`, joined with a colon rather than a dash, and the `/j` share description separates the versus line with a comma. +- **`create-eigen-game`'s own output**, including the greeting, the missing-tooling report and the scaffolded project's README. + +`worker-configuration.d.ts` is untouched: it is Cloudflare's generated runtime types, reproduced by `wrangler types`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b740728..c230701 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,7 +2,7 @@ version: 2 # Keeps the pinned GitHub Actions current. Without this they silently drift: # major tags like `actions/checkout@v7` float across patches but never across -# majors, so a workflow keeps working while falling years behind — which is +# majors, so a workflow keeps working while falling years behind, which is # exactly how this repo ended up on `pnpm/action-setup@v4` after the workspace # moved to pnpm 11 (v6 is where pnpm 11 support landed). # diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0536cdc..240586c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,14 +11,14 @@ name: Checks # # `workflow_call` re-executes these jobs; it does not look up whether they # already passed on this SHA (GitHub has no such mechanism, and it would be the -# wrong thing anyway — main is a different commit from the PR head as soon as +# wrong thing anyway: main is a different commit from the PR head as soon as # anything else merges). What is shared is the definition, not the run. on: pull_request: branches: [main] workflow_call: -# Correctness only — this workflow never deploys and needs no Cloudflare +# Correctness only. This workflow never deploys and needs no Cloudflare # credentials. Deploying is a deliberate `pnpm --filter deploy` from a # machine with `wrangler login`, because it applies D1 migrations against a real # database. Nothing here talks to the network beyond the package registry. @@ -27,11 +27,11 @@ on: # `secrets: inherit`. # # The Workers tests run under @cloudflare/vitest-pool-workers, which boots the -# real `workerd` runtime with local D1/R2/DO simulation — no account, no +# real `workerd` runtime with local D1/R2/DO simulation: no account, no # bindings to provision, and the same runtime semantics as production. That is # why `pnpm -r test` is the whole story and there is no separate integration job. -# Superseding a pull request's run is free — only the newest push matters. A +# Superseding a pull request's run is free, since only the newest push matters. A # run reached through `workflow_call` is NOT cancellable: it gates a release, so # cancelling it because a second commit landed on main would abort a publish # mid-flight. Keying on the SHA rather than the ref keeps those runs from @@ -55,7 +55,7 @@ jobs: - uses: actions/checkout@v7 with: # The changesets guard at the end of this job diffs against - # `origin/main`, which needs a merge base — a shallow clone has none. + # `origin/main`, which needs a merge base, and a shallow clone has none. fetch-depth: 0 - uses: pnpm/action-setup@v6 @@ -104,7 +104,7 @@ jobs: # Same shape of guard, higher stakes: migrations are generated from the # drizzle schemas and applied at deploy, so a schema edit that skipped # `db:generate` ships code expecting columns the database does not have. - # Nothing else catches it — the tests run against migrations, so schema + # Nothing else catches it: the tests run against migrations, so schema # and migrations drift together silently until a real deploy. # # `< /dev/null` matters: drizzle-kit prompts when a change is ambiguous @@ -120,7 +120,7 @@ jobs: # A brand-new migration is an untracked file, which `git diff` alone # would not see. test -z "$(git status --porcelain -- packages/server/migrations packages/server/src/do/migrations)" \ - || { echo "::error::db:generate produced a new migration file — commit it."; git status --short -- packages/server/migrations packages/server/src/do/migrations; exit 1; } + || { echo "::error::db:generate produced a new migration file. Commit it."; git status --short -- packages/server/migrations packages/server/src/do/migrations; exit 1; } # worker-configuration.d.ts is generated from wrangler.jsonc bindings and # committed. Typecheck only catches a binding that is *used* and missing; @@ -128,7 +128,7 @@ jobs: # # IF THIS FAILS AND `wrangler types` SHOWS NO DIFF LOCALLY: you have an # `examples/rps/.dev.vars`. It is gitignored, absent here, and `wrangler - # types` READS IT — so generating locally bakes your local secret names + # types` READS IT, so generating locally bakes your local secret names # into the committed file and it stops being reproducible from committed # inputs. Move `.dev.vars` aside, regenerate, commit that, and move it # back. This guard exists precisely to catch that contamination. @@ -142,7 +142,7 @@ jobs: # actually SHIP, which nothing else here can see. # # A change to a published package with no `.changeset/*.md` merges green, - # produces no version pull request, and is never released — silently, with + # produces no version pull request, and is never released, silently, with # no artifact anywhere to notice. That is the failure mode worth # automating against; forgetting to MERGE the version PR is not, because # it leaves an open pull request titled "Release: version packages" at the @@ -154,11 +154,11 @@ jobs: # # Skipped on the version pull request itself, which by construction edits # every published package while deleting the changesets that describe - # them — exactly the shape this guard rejects. Without the exclusion it is + # them, which is exactly the shape this guard rejects. Without the exclusion it is # a required check that branch can never satisfy. # - # For a change that genuinely should not release — a comment, a test, an - # internal refactor — `pnpm changeset --empty` records that decision as a + # For a change that genuinely should not release (a comment, a test, an + # internal refactor) `pnpm changeset --empty` records that decision as a # committed file rather than leaving the guard to be argued with. - name: Changes to published packages are described by a changeset if: github.event_name == 'pull_request' && github.head_ref != 'changeset-release/main' @@ -168,7 +168,7 @@ jobs: # The Dart client is generated from openapi.json and committed, so that a # breaking wire change arrives as a reviewable Dart diff in the same pull - # request that changed the schema — rather than surfacing days later in + # request that changed the schema, rather than surfacing days later in # another repository. This job is what makes "committed" mean "current". # # It is separate from `check` because it is the only thing here that needs @@ -209,10 +209,10 @@ jobs: git diff --exit-code -- clients/dart \ || { echo "::error::The Dart client is stale. Run 'pnpm dart-client' and commit clients/dart."; exit 1; } test -z "$(git status --porcelain -- clients/dart)" \ - || { echo "::error::Generation produced new files under clients/dart — commit them."; git status --short -- clients/dart; exit 1; } + || { echo "::error::Generation produced new files under clients/dart. Commit them."; git status --short -- clients/dart; exit 1; } # Proves the artifact we would publish actually compiles, and keeps the - # pub.dev score honest — `dart analyze` is what pana grades. + # pub.dev score honest, since `dart analyze` is what pana grades. - name: Analyze run: dart analyze working-directory: clients/dart @@ -224,8 +224,8 @@ jobs: # Scaffolds a real project and compiles both halves of it. # # Everything else here checks a package. This checks the thing a new user - # actually runs, which nothing did before: `templates/app-overlay` — the Dart - # files that import `package:eigen_flutter/eigen_flutter.dart` — was never + # actually runs, which nothing did before: `templates/app-overlay`, the Dart + # files that import `package:eigen_flutter/eigen_flutter.dart`, was never # compiled anywhere, so the `eigen_flutter` range the scaffolder pins was an # assertion no build had tested. eigen-flutter's own `example/` is a different # tree and does not stand in for it. @@ -273,12 +273,12 @@ jobs: # This does leave real coverage out, which an earlier version of this # comment denied. The Gradle config the scaffolder appends, the icons it # generates, and the manifest entries `eigen_flutter`'s plugin merges are - # none of them Dart sources — and that gap is what let a corrupted wrapper + # none of them Dart sources, and that gap is what let a corrupted wrapper # jar and corrupted launcher PNGs ship in every scaffolded project while # this job stayed green. # # Adding a release build was measured rather than estimated: 9m32s against - # this job's 2m06s, or 6m07s with `~/.gradle` cached — and the caching + # this job's 2m06s, or 6m07s with `~/.gradle` cached, and the caching # needs a restore/save split to be worth anything, because every runner is # a fresh VM. `gradle/actions/setup-gradle` does not work here at all; it # keys on a Gradle project in the workspace, and the build happens in a diff --git a/.github/workflows/publish-eigen-api.yml b/.github/workflows/publish-eigen-api.yml index b297cd1..6b4db4f 100644 --- a/.github/workflows/publish-eigen-api.yml +++ b/.github/workflows/publish-eigen-api.yml @@ -14,8 +14,8 @@ name: Publish eigen_api # Do not restructure this workflow expecting the constraint to lift. # # Hence the handoff: release.yml publishes to npm, then pushes an -# `eigen_api-v` tag with a PAT — a tag pushed by the built-in -# GITHUB_TOKEN would deliberately not trigger anything — and that tag fires this +# `eigen_api-v` tag with a PAT (a tag pushed by the built-in +# GITHUB_TOKEN would deliberately not trigger anything) and that tag fires this # workflow, whose OIDC token now carries a tag ref that pub.dev accepts. # # The alternative is storing a long-lived pub.dev credential in GitHub secrets @@ -25,7 +25,7 @@ name: Publish eigen_api # pub.dev setup, once (eigen_api → Admin → Automated publishing): # - Repository: eigeninteractive/eigen-server # - Tag pattern: eigen_api-v{{version}} -# No credential is stored anywhere — the OIDC identity is the authentication. +# No credential is stored anywhere: the OIDC identity is the authentication. on: push: tags: ["eigen_api-v*"] @@ -49,7 +49,7 @@ jobs: # The tag names the version; the committed pubspec must already carry it # (version-packages stamped it in the version PR). A mismatch means the - # tag was pushed against the wrong commit — fail rather than publish it. + # tag was pushed against the wrong commit, so fail rather than publish it. - name: Tag matches pubspec version working-directory: clients/dart run: | @@ -58,7 +58,7 @@ jobs: [ "$tag" = "$pkg" ] || { echo "::error::tag eigen_api-v$tag does not match pubspec version $pkg"; exit 1; } - # The generated code is committed, so nothing is generated here — publish + # The generated code is committed, so nothing is generated here. Publish # exactly what the tag points at. - name: Dry run run: dart pub publish --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 525731a..4767e77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ name: Release # 3. Merging that PR runs this workflow again, finds no pending changesets, # and publishes to npm. # -# So releasing is "merge the version PR" — the decision stays explicit (npm +# So releasing is "merge the version PR", and the decision stays explicit (npm # versions cannot be unpublished after 72 hours) without anyone hand-editing a # version number or remembering the publish order. Changesets accumulate: five # merged PRs keep updating one version PR, and one merge releases all five. @@ -53,7 +53,7 @@ name: Release # # `eigen_api`, the generated Dart client in clients/dart, rides along at the same # version. The wire contract is the server's, so the client that speaks it -# carries the server's version — a consumer's `eigen_api: ^0.1.0` then states +# carries the server's version, so a consumer's `eigen_api: ^0.1.0` states # exactly the compatibility it means. `pnpm version-packages` regenerates it, so # the version PR already carries the stamped pubspec. on: @@ -73,7 +73,7 @@ jobs: # is a different commit as soon as anything else merges, and publishing is the # one irreversible step here, so it is checked on the exact commit that ships. # - # It matters that this is the WHOLE gate rather than a re-typed subset — + # It matters that this is the WHOLE gate rather than a re-typed subset: # checks.yml also proves openapi.json, the drizzle migrations, the worker # types and the generated Dart client are all in sync with their sources. # Those guards are worth more at publish time than anywhere else. @@ -86,7 +86,7 @@ jobs: # Decides what this push means: `version` when changesets are pending, # `publish` when they are not and something is unpublished, `none` otherwise. # Hoisting it into its own job is what lets the two paths below carry - # different permissions — and makes the decision visible in the Actions UI + # different permissions, and makes the decision visible in the Actions UI # rather than buried inside one step. select-mode: name: Select mode @@ -169,7 +169,7 @@ jobs: # An App token, not GITHUB_TOKEN, so the version pull request gets CHECKS. # GitHub suppresses workflow triggers for events raised by the built-in - # token, so a version PR it opens shows no checks at all — and with + # token, so a version PR it opens shows no checks at all, and with # required status checks on `main`, it could never satisfy them and would # be permanently unmergeable without an admin bypass. - uses: actions/create-github-app-token@v3 @@ -220,7 +220,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # The tarballs must contain built output — `dist` is what consumers get. + # The tarballs must contain built output; `dist` is what consumers get. - name: Build run: pnpm -r build @@ -236,7 +236,7 @@ jobs: # # NOTE: `actions/setup-node` must NOT set `registry-url` here. That writes an # .npmrc containing `_authToken=${NODE_AUTH_TOKEN}`, and npm prefers that over - # an OIDC exchange — surfacing as a nonsense `E404 ... is not in this + # an OIDC exchange, surfacing as a nonsense `E404 ... is not in this # registry` rather than an auth error. There is no npm token by design. publish: name: Publish @@ -246,7 +246,7 @@ jobs: timeout-minutes: 20 permissions: contents: write # git tags + GitHub releases - id-token: write # OIDC — this is the npm credential + id-token: write # OIDC, the npm credential steps: - uses: actions/checkout@v7 with: @@ -267,7 +267,7 @@ jobs: run: pnpm -r build # Scoped to BOTH repositories: this job pushes a tag here and dispatches - # to eigen-web. GITHUB_TOKEN cannot do the latter at all — it is scoped to + # to eigen-web. GITHUB_TOKEN cannot do the latter at all; it is scoped to # the repository that minted it, same organization or not. - uses: actions/create-github-app-token@v3 id: app-token @@ -280,7 +280,7 @@ jobs: eigen-web permission-contents: write - # Provenance is emitted automatically under trusted publishing — there is + # Provenance is emitted automatically under trusted publishing; there is # no `--provenance` flag to pass. It requires this repository to stay # PUBLIC; npm refuses to sign provenance from a private source repo. - name: Publish to npm @@ -295,13 +295,13 @@ jobs: # Not "when anything published", which is what this used to test and is # no longer the same question. `create-eigen-game` versions independently, # so a scaffolder-only release reaches this point having touched neither - # `openapi.json` nor `clients/dart` — and both steps below then do the + # `openapi.json` nor `clients/dart`, and both steps below then do the # wrong thing. The dispatch regenerates references that did not change, # and the tag collides with the one the previous engine release pushed. # # The collision is the worse half: it fails the run at the very END, after # npm has irreversibly published, so the failure can never be cleared by - # rerunning. That is exactly how the first scaffolder-only release went — + # rerunning. That is exactly how the first scaffolder-only release went: # `create-eigen-game@0.3.0` published correctly and the run went red # anyway, on `fatal: tag 'eigen_api-v0.2.0' already exists`. # @@ -316,13 +316,13 @@ jobs: if jq -e '.[] | select(.name == "@eigeninteractive/server")' <<<"$PACKAGES" >/dev/null; then echo "released=true" >> "$GITHUB_OUTPUT" else - echo "::notice::No engine release in this run ($(jq -r '[.[].name] | join(", ")' <<<"$PACKAGES")) — skipping the eigen-web dispatch and the eigen_api tag." + echo "::notice::No engine release in this run ($(jq -r '[.[].name] | join(", ")' <<<"$PACKAGES")). Skipping the eigen-web dispatch and the eigen_api tag." echo "released=false" >> "$GITHUB_OUTPUT" fi # eigen-web vendors this repo's `openapi.json` and package APIs to build # its HTTP and TypeScript references. It cannot notice a change on its - # own, so tell it — its receiving workflow regenerates, commits to main + # own, so tell it. Its receiving workflow regenerates, commits to main # and deploys. # # Gated on a real publish rather than on a push to main, and that is the @@ -351,10 +351,10 @@ jobs: # closed as won't-fix, so this split is permanent. # # The tag must be pushed with the App token: a tag pushed by the built-in - # GITHUB_TOKEN deliberately does not trigger further workflow runs — the + # GITHUB_TOKEN deliberately does not trigger further workflow runs, by the # same suppression rule that costs the version PR its checks above. # - # If this step fails after npm published, push the tag by hand — re-running + # If this step fails after npm published, push the tag by hand. Re-running # the workflow finds no pending changesets and will not publish again. - name: Tag eigen_api for pub.dev if: steps.engine.outputs.released == 'true' @@ -366,10 +366,10 @@ jobs: # Belt to the gate above's braces. The gate is the correct fix and # this is never expected to fire, but tagging is the last thing a - # release does and it runs after an irreversible npm publish — so it + # release does and it runs after an irreversible npm publish, so it # should be safe to rerun rather than merely correct to run once. if git ls-remote --exit-code --tags "$remote" "refs/tags/eigen_api-v$v" >/dev/null 2>&1; then - echo "::notice::eigen_api-v$v already exists — nothing to tag." + echo "::notice::eigen_api-v$v already exists. Nothing to tag." exit 0 fi diff --git a/AGENTS.md b/AGENTS.md index 703d599..96ae437 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,8 @@ and is the source of truth. Retrieve it rather than relying on memory: | Any page as Markdown | append `.md` to its URL | | HTTP contract (OpenAPI 3.1) | `https://eigeninteractive.com/openapi.json` | -The docs are task-first, and each page covers **both halves** of a task — the -TypeScript rules and the Dart client — because they are one change. +The docs are task-first, and each page covers **both halves** of a task, the +TypeScript rules and the Dart client, because they are one change. Start points: `/docs/getting-started/your-first-game.md` (a whole game, both languages), `/docs/build-a-game/the-contract.md` (what you write and what the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b42f55f..a1a6713 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ through their published `exports`, which point at `dist`. That is also true of a game repository pointed at this checkout, so while you are changing the engine, leave a watcher running rather than rebuilding by -hand — a forgotten `pnpm -r build` means the game is testing a stale engine, +hand. A forgotten `pnpm -r build` means the game is testing a stale engine, which fails as a puzzle rather than as an error: ```bash @@ -77,8 +77,8 @@ hand-edit generated artifacts to make the check pass. ## Running the scaffolder from source -Publishing to npm is not part of the loop. Build it and run the CLI directly — -same arguments as `pnpm create eigen-game`: +Publishing to npm is not part of the loop. Build it and run the CLI directly, +with the same arguments as `pnpm create eigen-game`: ```bash pnpm --filter create-eigen-game build @@ -88,8 +88,8 @@ node packages/create-eigen-game/dist/cli.js ../my-game The generated project resolves the engine from npm, which is what you want while iterating on the CLI and its templates: it is exactly what a user gets. -To test the templates against the engine in your working tree instead — an -unreleased engine change, seen from a game author's side — run the CI gate, +To test the templates against the engine in your working tree instead (an +unreleased engine change, seen from a game author's side) run the CI gate, which scaffolds into a temp directory with the four engine packages overridden to `link:` this workspace: diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 9be276c..62af239 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -27,14 +27,14 @@ as `@eigeninteractive/server`, whose wire contract it implements. ### Why the scaffolder versions on its own `create-eigen-game` was a fifth member of that group, because it emits an engine -range and derived it from **its own version** — correct only while the two were +range and derived it from **its own version**, correct only while the two were the same number. That bought a real guarantee at a price that eventually came due. The requirement is one-directional: the scaffolder must follow the engine, but the engine must not follow the scaffolder. A `fixed` group is symmetric by definition, so a template typo proposed moving four published packages onto a -new version line — and pre-1.0, a new line is a breaking one that no published +new version line, and pre-1.0 a new line is a breaking one that no published `eigen_flutter` speaks yet. The range now comes from the scaffolder's own `@eigeninteractive/server` @@ -46,13 +46,13 @@ scaffolder simply reads it. The forward half needs no machinery, and briefly had some that was actively harmful. A script added a `create-eigen-game` changeset whenever the engine -crossed a line — which released a scaffolder pairing the **new** engine with the +crossed a line, which released a scaffolder pairing the **new** engine with the **old** shell, since no compatible shell can exist yet at that moment. It published the exact mismatch the pin prevents. Doing nothing is strictly better: the scaffolder on npm keeps pairing 0.2 with 0.2, a line behind but working. The moment worth releasing on is not "the engine moved" but "a compatible shell -now exists" — and that moment already carries a code change here, raising +now exists", and that moment already carries a code change here, raising `flutterClientVersion`, which needs a changeset like any other. So the scaffolder follows the engine by construction, one step later than the naive trigger and one step more correct. The **Scaffolded project builds** job turns @@ -63,12 +63,12 @@ scaffolders already on npm pick up 0.2.7 from `^0.2.0` by themselves. The **Flutter** client range is a stated pin in `src/index.ts`, and deliberately neither derived nor resolved. `eigen_flutter` lives in another repository, so -nothing here can compute it — see [the scaffolder's Flutter +nothing here can compute it. See [the scaffolder's Flutter pin](#the-scaffolders-flutter-pin) and the [compatibility matrix](https://eigeninteractive.com/docs/reference/compatibility). `create-eigen-game` is also **unscoped**, because that is what makes -`npm create eigen-game` resolve — so it is not owned by the npm organization via +`npm create eigen-game` resolve, so it is not owned by the npm organization via a scope, and the org's team is granted access explicitly instead: ```bash @@ -86,7 +86,7 @@ The release workflows use: - `RELEASE_APP_CLIENT_ID` (**variable**, not a secret) and `RELEASE_APP_PRIVATE_KEY` (secret): credentials for the **Eigen Release** GitHub App, installed on the organization with access to `eigen-server` and - `eigen-web`. Use the App's **Client ID** (`Iv23li…`), not the numeric App ID — + `eigen-web`. Use the App's **Client ID** (`Iv23li…`), not the numeric App ID; `actions/create-github-app-token` deprecated its `app-id` input. - An **`npm`** repository environment, named by each package's trusted publisher on npmjs.com. @@ -94,7 +94,7 @@ The release workflows use: There is deliberately **no npm token**. npm authentication is trusted publishing (OIDC): GitHub's short-lived identity is the credential, and provenance is emitted automatically without a `--provenance` flag. That is why the publish job -must never set `registry-url` on `actions/setup-node` — it writes an `.npmrc` +must never set `registry-url` on `actions/setup-node`, because it writes an `.npmrc` whose token npm prefers over an OIDC exchange. `release.yml` mints a short-lived token from that App and uses it for all three @@ -105,7 +105,7 @@ expiry to track. Two properties make the App necessary rather than merely tidier. GitHub suppresses workflow triggers for events raised by the built-in `GITHUB_TOKEN`, so a version PR it opened would carry no checks and a tag it pushed would start -no workflow — an App's token is not that token, so both work. And +no workflow. An App's token is not that token, so both work. And `GITHUB_TOKEN` is scoped to the repository that minted it, so it can never dispatch to `eigen-web`. @@ -140,7 +140,7 @@ Dart client is in sync with the spec Do not require the `Checks / …` prefixed variants. Those are the same jobs reached through `workflow_call` from `release.yml`, which never runs on a pull -request — requiring them would block every merge permanently. +request, so requiring them would block every merge permanently. Two deliberate settings: @@ -150,7 +150,7 @@ Two deliberate settings: `release.yml` re-runs the whole gate against the merge commit, so a stale base is caught there rather than by forcing a rebase on every merge. -No bypass actor is configured. The release App never pushes to `main` — it +No bypass actor is configured. The release App never pushes to `main`; it pushes the `changeset-release/main` branch and opens a pull request like anyone else. @@ -163,13 +163,13 @@ than steps, so a single job would carry publishing privileges on runs that only open a pull request. The sub-actions are pinned to a prerelease commit, which is a deliberate, -bounded choice — see [docs/blockers.md](docs/blockers.md) for the reasoning and +bounded choice. See [docs/blockers.md](docs/blockers.md) for the reasoning and the move to a stable `v2`. ### Registry configuration Each registry authorises this repository directly; no publishing credential is -stored anywhere. The settings below are already in place — they are recorded so +stored anywhere. The settings below are already in place, and are recorded so a change can be recognised as a change. All five npm packages, under each package's **Settings → Trusted publisher**: @@ -196,9 +196,9 @@ authentication error. Publishing a brand-new package is the one flow CI cannot do for you, because both registries authorise a package that already exists: -- **npm** — a trusted publisher is configured on an existing package, so the +- **npm**: a trusted publisher is configured on an existing package, so the first version must be pushed by hand. -- **pub.dev** — automated publishing cannot be configured until the package +- **pub.dev**: automated publishing cannot be configured until the package exists, and a *new* package cannot be published straight into a verified publisher at all. @@ -208,7 +208,7 @@ So the first version of any new package is manual, and everything after is not. `.changeset/config.json` means it shares the engine's version and bumps with it; leaving it out means Changesets versions it on its own. Prefer the fixed group unless the package genuinely has an independent release cadence. -2. **npm:** run `npm login` from *outside* this repository — npm evaluates +2. **npm:** run `npm login` from *outside* this repository, because npm evaluates `devEngines` before any command and refuses here because that field names pnpm. Then `pnpm pack` the package and publish the tarball with `npm publish --access public --otp=`. @@ -227,7 +227,7 @@ So the first version of any new package is manual, and everything after is not. 4. Register it under [Registry configuration](#registry-configuration) so CI can publish every subsequent version. 5. An **unscoped** npm package is owned by whoever published it, not by the - organisation — there is no way to publish one *as* an org, and unpublishing + organisation. There is no way to publish one *as* an org, and unpublishing to retry only burns the version number. Grant the org team access instead: ```bash @@ -241,7 +241,7 @@ Changesets owns versioning and changelog generation. Contributors commit a `checks.yml` enforces this with `changeset status`: a pull request that touches a published package without a changeset fails. That guard exists because the -omission is otherwise invisible — the pull request merges green, no version +omission is otherwise invisible: the pull request merges green, no version pull request appears, and the change is simply never released. For a change that genuinely should not ship a version, `pnpm changeset --empty` records the decision as a committed file. It has to be *committed*: `changeset status` @@ -260,7 +260,7 @@ The routine flow is: dispatches the documentation refresh. A scaffolder-only release skips both: `eigen_api` did not move and there is no new API to document. -In CI the `workspace:*` rewriting is the `pack` job's responsibility — it +In CI the `workspace:*` rewriting is the `pack` job's responsibility: it resolves those constraints into tarballs that `publish` then uploads, which is what keeps the workspace handling away from the authenticated upload. When publishing **manually**, use `pnpm publish -r` and never `npm publish`: pnpm @@ -320,21 +320,21 @@ It is a caret range, so it only needs raising when `eigen_flutter` crosses a line: `flutter pub add eigen_flutter@^0.2.0` already takes the newest 0.2.x at scaffold time. -This briefly resolved from pub.dev instead — "the newest `eigen_flutter` whose -own `eigen_api` constraint targets the engine line being scaffolded" — to avoid +This briefly resolved from pub.dev instead, as "the newest `eigen_flutter` whose +own `eigen_api` constraint targets the engine line being scaffolded", to avoid a literal nobody would remember to update. **That predicate is wrong**, and it is worth knowing why before anyone reaches for it again. The `eigen_api` constraint describes the *wire* a shell speaks; the templates call its *Dart API*; the two move independently. A future `eigen_flutter` may legitimately hold `eigen_api: ^0.2.0` while renaming everything `lib/game/v1/rules.dart` touches, -and it would have been selected — emitting a project that does not compile, on +and it would have been selected, emitting a project that does not compile, on someone's first contact with the engine. A pin cannot fail that way. It can only be stale, and staleness is now a red check rather than a broken scaffold: the **Scaffolded project builds** job in `checks.yml` generates a real project on every change and checks it twice. -`flutter analyze` and `flutter test` cover the **Dart API** — that the templates +`flutter analyze` and `flutter test` cover the **Dart API**: that the templates call symbols the shell actually has. That job is also the only thing that compiles `templates/app-overlay` at all; before it, the Dart templates were never built anywhere, in this repository or eigen-flutter. @@ -350,7 +350,7 @@ scaffold. A mismatch is not automatically a defect, though, and the job asks pub.dev which one it is. **The shell cannot match a brand-new engine line**: `eigen_api` ships with the engine, so no `eigen_flutter` of any version number can constrain -`^0.3.0` until `eigen_api 0.3.0` is published — which is the engine release +`^0.3.0` until `eigen_api 0.3.0` is published, which is the engine release itself. An unconditional assertion fails on the version pull request that crosses the line, and as a required check would block the merge that publishes the `eigen_api` the shell is waiting on. That is a permanent deadlock, not a delay. @@ -369,7 +369,7 @@ raised and the scaffolder released. This queries the registry the deleted resolver queried, for the opposite purpose. *Choosing* a shell by its `eigen_api` constraint is wrong, because the constraint cannot see the Dart API the templates call. Asking whether a shell -for this line exists at all makes no claim about the Dart API — `flutter +for this line exists at all makes no claim about the Dart API; `flutter analyze` above already settled that. It resolves the four engine packages from this workspace rather than npm. That @@ -379,7 +379,7 @@ registry would fail on the exact commit that publishes it, deadlocking every line-crossing release. `eigen_flutter` is deliberately not overridden that way. Substituting it would -defeat the purpose — a real resolution against pub.dev is the only thing that +defeat the purpose: a real resolution against pub.dev is the only thing that makes the pin mean anything. ## Downstream documentation @@ -413,20 +413,20 @@ eigen-web pull request. ### A release that crosses a version line -The documentation site is versioned on this engine's release line — pre-1.0 the -minor, so `0.2.x` is one line and `0.3.0` starts the next — and eigen-web +The documentation site is versioned on this engine's release line (pre-1.0 the +minor, so `0.2.x` is one line and `0.3.0` starts the next) and eigen-web asserts its version label against `info.version` in the spec it receives. So a release that crosses a line makes the sync pull request go **red** instead of auto-merging. That is deliberate: someone has to decide whether the old line gets frozen at `/docs//*` before the site starts describing the new one. -Nothing here breaks and nothing needs rerunning — the reference simply waits in +Nothing here breaks and nothing needs rerunning; the reference simply waits in an open pull request until that decision is made in eigen-web, whose `CONTRIBUTING.md` carries the procedure. Two artifacts still need a hand once a compatible `eigen_flutter` ships: the compatibility matrix on eigen-web's `docs/reference/compatibility.md`, and -`flutterClientVersion` in `packages/create-eigen-game/src/index.ts` — see [the +`flutterClientVersion` in `packages/create-eigen-game/src/index.ts`. See [the scaffolder's Flutter pin](#the-scaffolders-flutter-pin). ## Deployment diff --git a/clients/dart/.openapi-generator-ignore b/clients/dart/.openapi-generator-ignore index a5c253b..772a7ec 100644 --- a/clients/dart/.openapi-generator-ignore +++ b/clients/dart/.openapi-generator-ignore @@ -1,5 +1,5 @@ -# Files the generator must not overwrite. Everything not listed here — lib/, -# doc/, README.md, build.yaml — is regenerated wholesale on every run. +# Files the generator must not overwrite. Everything not listed here (lib/, +# doc/, README.md, build.yaml) is regenerated wholesale on every run. # The dart-dio template stamps `sdk: >=3.5.0` while its own json_serializable # output uses the Dart 3.8 null-aware-elements feature, so the real floor is @@ -7,7 +7,7 @@ # version-tracks-the-engine rule. pubspec.yaml -# Carries the `unused_import: ignore` that the template's own output requires — +# Carries the `unused_import: ignore` that the template's own output requires, # it imports ErrorResponse into every api file whether or not that file uses it. # Without this line the generator overwrites the setting on every run and the # package publishes with seven warnings against its pub.dev score. diff --git a/clients/dart/CHANGELOG.md b/clients/dart/CHANGELOG.md index 0cca3bc..9ad6e9f 100644 --- a/clients/dart/CHANGELOG.md +++ b/clients/dart/CHANGELOG.md @@ -2,12 +2,12 @@ This package is generated from the Eigen engine's OpenAPI specification and its version tracks [`@eigeninteractive/server`](https://www.npmjs.com/package/@eigeninteractive/server) -exactly — 0.2.4 here is 0.2.4 there. It has no changes of its own. +exactly: 0.2.4 here is 0.2.4 there. It has no changes of its own. See the engine's changelog: -While the engine is pre-1.0, a breaking wire change bumps the **minor** — a +While the engine is pre-1.0, a breaking wire change bumps the **minor**. A constraint of `^0.1.0` resolves to `>=0.1.0 <0.2.0`, so 0.1.x is additive and 0.2.0 is the break. From 1.0.0 on it is the major, as usual. diff --git a/clients/dart/README.md b/clients/dart/README.md index b320d46..97eb790 100644 --- a/clients/dart/README.md +++ b/clients/dart/README.md @@ -6,7 +6,7 @@ > the wire types a game needs and keeps the `*Api` classes out of your > namespace. > -> It is published only because pub.dev rejects path dependencies — the same +> It is published only because pub.dev rejects path dependencies, the same > reason Flutter's federated plugins publish `*_platform_interface` packages > nobody imports. Documentation lives at > . diff --git a/clients/dart/analysis_options.yaml b/clients/dart/analysis_options.yaml index 56ded3e..2162dd2 100644 --- a/clients/dart/analysis_options.yaml +++ b/clients/dart/analysis_options.yaml @@ -1,6 +1,6 @@ # This package is 100% generated. Analysis here is a COMPILE CHECK, not a style # gate: it exists to catch the generator emitting Dart that does not typecheck -# — which has happened (an undefined-class reference slipped through once). It +# which has happened (an undefined-class reference slipped through once). It # does not hold generated code to the engine's lint standards. # # The two relaxations are for output the dart-dio + json_serializable templates diff --git a/clients/dart/doc/HealthApi.md b/clients/dart/doc/HealthApi.md index afe2a22..6cbf8ed 100644 --- a/clients/dart/doc/HealthApi.md +++ b/clients/dart/doc/HealthApi.md @@ -17,7 +17,7 @@ Method | HTTP request | Description Liveness probe -Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable — it does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected. +Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable. It does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected. ### Example ```dart diff --git a/clients/dart/lib/src/api/health_api.dart b/clients/dart/lib/src/api/health_api.dart index 0587adb..263ef7e 100644 --- a/clients/dart/lib/src/api/health_api.dart +++ b/clients/dart/lib/src/api/health_api.dart @@ -17,7 +17,7 @@ class HealthApi { const HealthApi(this._dio); /// Liveness probe - /// Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable — it does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected. + /// Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable. It does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected. /// /// Parameters: /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation diff --git a/clients/dart/pubspec.yaml b/clients/dart/pubspec.yaml index 5227e49..0f0f1dd 100644 --- a/clients/dart/pubspec.yaml +++ b/clients/dart/pubspec.yaml @@ -7,13 +7,13 @@ description: >- # # Why owned (beyond the version-stamping below): the dart-dio template stamps # `environment.sdk: '>=3.5.0 <4.0.0'`, but the `.g.dart` that json_serializable -# emits uses the Dart 3.8 `null-aware-elements` feature — verified, those files -# fail to analyze below 3.8 — so the real floor is ^3.8.0. json_serializable +# emits uses the Dart 3.8 `null-aware-elements` feature (verified: those files +# fail to analyze below 3.8) so the real floor is ^3.8.0. json_serializable # emits that syntax regardless of the declared lower bound, so lowering it only # breaks generation. Relax to match once the template stamps a 3.8 bound itself. # # `version:` is stamped by the generation script from @eigeninteractive/server's -# package.json — the wire contract is the server's, so the client carries the +# package.json. The wire contract is the server's, so the client carries the # server's version. Do not edit it by hand. version: 0.2.4 homepage: https://eigeninteractive.com diff --git a/docs/blockers.md b/docs/blockers.md index 175b143..baad6aa 100644 --- a/docs/blockers.md +++ b/docs/blockers.md @@ -4,7 +4,7 @@ Upstream limitations that force temporary compatibility choices, across every EigenInteractive repository. It is for engine maintainers, not game implementors. One file rather than one per repository, because these are read at the same -moments — planning a release, or deciding whether an upstream package has moved +moments: planning a release, or deciding whether an upstream package has moved far enough to act on. A blocker in `eigen_flutter` and a blocker in the release pipeline both answer "what are we still working around?", and splitting them by repository only meant neither list got re-checked. @@ -27,7 +27,7 @@ even when nothing has changed. **Status:** Adopted deliberately. `release.yml` pins the `select-mode`, `version`, `pack` and `publish` sub-actions to commit `c47fa68bd43bb8ae0bae7e558622593deebf5955` (`v2.0.0-next.3`). Move to a stable -`v2` tag when one is published. Last checked: 2026-08-05 — still none. The line +`v2` tag when one is published. Last checked: 2026-08-05, still none. The line advanced to `v2.0.0-next.4` on 2026-08-03; the newest non-prerelease is `v1.9.0`. @@ -41,7 +41,7 @@ is now no npm credential anywhere in the pipeline. That alternative is also closing. npm [restricted bypass-2FA tokens][npm-bypass-restrict] on 2026-07-31 and has announced that they lose direct publish access in January 2027. Trusted publishing is becoming the only -supported way to publish from CI, so this is not a preference to revisit — +supported way to publish from CI, so this is not a preference to revisit; only the pinned prerelease below is. The prerelease risk is bounded rather than absent: @@ -49,7 +49,7 @@ The prerelease risk is bounded rather than absent: - **Pinned by commit, so it cannot shift underneath us.** The usual hazard of a `next` line does not apply. - **Changesets runs this exact layout in production** to publish its own - packages — 52 of its last 60 runs succeeded when checked. + packages: 52 of its last 60 runs succeeded when checked. - **The failures observed upstream were in pre-mode handling** (`ENOENT: .changeset/pre/changes.md` in `select-mode`), which only executes after `changeset pre enter`. This repo does not use prerelease mode. @@ -60,15 +60,15 @@ The prerelease risk is bounded rather than absent: The known cost is that inputs are still moving: v1's `version:` input is already renamed to `script:` on this line, and `next.4` removed `setup-git-user` and replaced `commit-mode` with a boolean `push-with-git-cli`. Neither reaches -`release.yml`, which passes only `github-token` — the one input `next.4` now +`release.yml`, which passes only `github-token`, the one input `next.4` now requires to be explicit, since it stops accepting the `GITHUB_TOKEN` environment variable and `actions/checkout` credentials as substitutes. Expect input -adjustments when migrating to stable, not a re-architecture — the four-job +adjustments when migrating to stable, not a re-architecture: the four-job topology is the part being bought, and that is settled. This pin has a second half. `@changesets/cli` is pinned to `3.0.0-next.10` because `select-mode` calls `changeset publish-plan`, which does not exist on -the stable 2.x line. Both move together or neither does — and `next.4` now +the stable 2.x line. Both move together or neither does, and `next.4` now *enforces* that, failing outright against a v2 CLI and directing those projects to `changesets/action@v1`. The CLI has not stabilised either: `latest` is `2.31.1` and `next` is `3.0.0-next.11`. @@ -82,7 +82,7 @@ to `changesets/action@v1`. The CLI has not stabilised either: `latest` is 3. Replace the four pinned commits with the stable ref. 4. Move `@changesets/cli` to the matching stable release, and confirm `changeset publish-plan` exists there before dropping the prerelease pin. -5. Verify with a real release, not a dry run — the publish path is the one that +5. Verify with a real release, not a dry run. The publish path is the one that cannot be exercised any other way. #### Related constraints that are NOT blockers @@ -111,14 +111,14 @@ Upstream references: - [npm: trusted publishers][npm-trusted] - [npm: classic tokens revoked][npm-classic-revoked] - [Changesets' own publish workflow][cs-publish-yml] -- [changesets/action#515 — separate publish workflow for OIDC][ca-515] +- [changesets/action#515, separate publish workflow for OIDC][ca-515] ## `eigen-flutter` ### Flutter Android built-in Kotlin migration **Status:** Blocked on a compatible `in_app_review` release and a project -upgrade to Flutter 3.47 or later. Last checked: 2026-08-05 — neither has moved. +upgrade to Flutter 3.47 or later. Last checked: 2026-08-05, neither has moved. `in_app_review` is still 2.0.12 (published 2026-05-15), and 3.47 has not reached stable: the current stable is 3.44.8 and 3.47 exists only on beta (`3.47.0-0.3.pre`). @@ -151,7 +151,7 @@ SDK. That would select an unsupported configuration while new major version. 3. Upgrade the development and CI Flutter SDKs to 3.47 or later. The pinned version is `eigen-flutter/.fvmrc`, which CI reads through - `flutter-version-file` — changing it moves both at once. + `flutter-version-file`; changing it moves both at once. 4. Follow Flutter's app migration guide: enable built-in Kotlin, remove the temporary opt-out properties, and remove obsolete Kotlin plugin/version declarations from generated Android apps. diff --git a/examples/rps/src/index.ts b/examples/rps/src/index.ts index 035c700..4eeec33 100644 --- a/examples/rps/src/index.ts +++ b/examples/rps/src/index.ts @@ -1,5 +1,5 @@ /** - * The RPS example worker — the whole implementor surface: the rules + * The RPS example worker: the whole implementor surface, being the rules * module, a `GameDO` subclass binding it, and one `createEngine` call. * Deploys with `pnpm deploy` (engine D1 migrations apply, then the code). */ @@ -27,12 +27,12 @@ export const engineConfig = { apple: { appId: "TEAMID.com.eigeninteractive.rps", storeUrl: "https://apps.apple.com" }, }, // opt-in avatars: worker-served (no publicBaseUrl → the relative - // /avatars/{uid} route). Local R2 simulation under `wrangler dev` — a real + // /avatars/{uid} route). Local R2 simulation under `wrangler dev`; a real // bucket enters only at a deploy with uploads enabled. avatars: { bucket: (env: Env) => env.AVATARS }, // The public web surface. This block is the entire setup for the landing // page, the three legal documents, and the crawler files (`/sitemap.xml`, - // `/robots.txt`, `/site.webmanifest`) — no templates to copy, no routes to + // `/robots.txt`, `/site.webmanifest`): no templates to copy, no routes to // register. Absolute URLs (canonical/OG/sitemap) come from the request // origin, so nothing about the domain is configured here. // @@ -40,7 +40,7 @@ export const engineConfig = { // templates. Review them before publishing a real app; they are a starting // point, and the operator carries the liability for what they say. // - // The engine never generates images — but the Flutter app already produces + // The engine never generates images, but the Flutter app already produces // every one it needs. `flutter_launcher_icons` emits `web/favicon.png` and // `web/icons/Icon-{192,512}.png` (+ maskable) from the same 1024x1024 source // the app icon uses, and `web/og-image.png` is the app's own share card. Copy @@ -48,7 +48,7 @@ export const engineConfig = { // and OG tags then share them. // the engine's default paths are exactly those names. site: { - tagline: "Rock, paper, scissors — the smallest complete game on the EigenInteractive engine.", + tagline: "Rock, paper, scissors: the smallest complete game on the EigenInteractive engine.", primaryColor: "#3f51b5", operator: { name: "EigenInteractive", diff --git a/examples/rps/src/module/index.ts b/examples/rps/src/module/index.ts index c49fef0..8853017 100644 --- a/examples/rps/src/module/index.ts +++ b/examples/rps/src/module/index.ts @@ -1,6 +1,6 @@ /** * The RPS {@link GameModule}: the {@link GameRules} units keyed by - * `schemaVersion` — exactly the versions this build ships. + * `schemaVersion`: exactly the versions this build ships. */ import type { GameModule } from "@eigeninteractive/rules"; diff --git a/examples/rps/src/module/v1.ts b/examples/rps/src/module/v1.ts index 104edaf..1c04259 100644 --- a/examples/rps/src/module/v1.ts +++ b/examples/rps/src/module/v1.ts @@ -1,5 +1,5 @@ /** - * Schema version 1 of Rock–Paper–Scissors — one self-contained + * Schema version 1 of Rock–Paper–Scissors: one self-contained * {@link GameRules} unit: the Zod payload contracts plus all six hooks, typed * to this version's shapes. * @@ -9,11 +9,11 @@ * `computeObservation`, which also masks the *opponent's* pending status. * That masking is what makes the same-view rule work with zero game code: an * opponent's hidden commit doesn't change your projected view, so your - * in-flight submission still lands — while the round *resolution* (reveal) + * in-flight submission still lands, while the round *resolution* (reveal) * does change it, correctly invalidating anything computed against the * previous round. * - * ## Payload typing — schema-first + * ## Payload typing, schema-first * * Declare a Zod schema per payload (`state`, `action`, `config`) and derive * the types with `z.infer`. The engine parses every payload with this unit's @@ -21,9 +21,9 @@ * values, and it re-validates the state a hook returns before committing. * Conventions: derive payload types as `type` aliases via `z.infer` (an * `interface` fails the engine's `JsonObject` constraint); keep schemas - * transform-free — what parses is what persists. + * transform-free, since what parses is what persists. * - * When rules or shapes change incompatibly, don't edit this file's semantics — + * When rules or shapes change incompatibly, don't edit this file's semantics: * copy it to `v2.ts` (importing whatever didn't change from here), make the * change there, and register it in `index.ts`. Games created under v1 keep * running against this unit until they drain. @@ -49,7 +49,7 @@ const stateSchema = z.object({ wins: z.tuple([z.int().min(0), z.int().min(0)]), /** The current round's hidden commits, per seat. */ commits: z.tuple([moveSchema.nullable(), moveSchema.nullable()]), - /** The last resolved round — the reveal the clients animate. */ + /** The last resolved round: the reveal the clients animate. */ lastRound: roundSchema.nullable(), }); @@ -188,7 +188,7 @@ class RpsRulesV1 implements GameRules { }; } const seat = playerIndex as 0 | 1; - // Live projection — two deliberate omissions that ARE the game: + // Live projection, with two deliberate omissions that ARE the game: // - the opponent's commit is hidden (only your own comes back); // - the opponent's pending status is masked (you see only your own), // so their hidden commit never changes your view and the same-view @@ -228,7 +228,7 @@ class RpsRulesV1 implements GameRules { /** The v1 rules unit, registered under key `1` in `index.ts`. The class is * authored against its concrete payload types (`implements GameRules` above — fully type-checked); annotating the export as + * Observation, Action, Config>` above, fully type-checked); annotating the export as * {@link AnyGameRules} erases those types for the registry with no cast, since * the engine re-validates every payload against `schemas` before a hook runs. */ export const rulesV1: AnyGameRules = new RpsRulesV1(); diff --git a/examples/rps/test/apply-migrations.ts b/examples/rps/test/apply-migrations.ts index d899116..5e33bdf 100644 --- a/examples/rps/test/apply-migrations.ts +++ b/examples/rps/test/apply-migrations.ts @@ -1,6 +1,6 @@ import { applyD1Migrations } from "cloudflare:test"; import { env } from "cloudflare:workers"; -// Bring the simulated rps_dev to the current engine schema — the same .sql +// Bring the simulated rps_dev to the current engine schema, using the same .sql // migrations `wrangler d1 migrations apply rps_dev` runs for real. await applyD1Migrations(env.rps_dev, env.TEST_MIGRATIONS); diff --git a/examples/rps/test/env.d.ts b/examples/rps/test/env.d.ts index e363bac..4481f9d 100644 --- a/examples/rps/test/env.d.ts +++ b/examples/rps/test/env.d.ts @@ -11,7 +11,7 @@ declare global { interface Env { TEST_MIGRATIONS: D1Migration[]; } - /** Types `exports` from `cloudflare:workers` — the loopback bindings for + /** Types `exports` from `cloudflare:workers`: the loopback bindings for * the test worker's top-level exports, so `exports.default.fetch()` (the * supported replacement for the deprecated `SELF` fetcher) resolves. * `wrangler types` generates this for a deployable worker; the test diff --git a/examples/rps/test/index.spec.ts b/examples/rps/test/index.spec.ts index 4b789fd..8960bec 100644 --- a/examples/rps/test/index.spec.ts +++ b/examples/rps/test/index.spec.ts @@ -46,7 +46,7 @@ it("plays a full game: waiting room, same-view simultaneous commits, finish, rep const started = await api(ALICE, "POST", `/games/${gameId}/start`, {}); expect(await started.json()).toMatchObject({ version: 0 }); - // Both seats commit against v0. The second arrives stale — and lands, + // Both seats commit against v0. The second arrives stale, and lands, // because RPS masks the opponent's hidden commit (same-view rule). const rock = await api(ALICE, "POST", `/games/${gameId}/action`, { seat: 0, data: { move: "rock" }, expectedVersion: 0 }); expect(await rock.json()).toMatchObject({ version: 1 }); @@ -57,7 +57,7 @@ it("plays a full game: waiting room, same-view simultaneous commits, finish, rep expect(resolved.frame.outcomes).toHaveLength(2); // The finish apply lands in D1 (single attempt, post-commit). `readGameRow` - // is the engine's own accessor — the D1 table definitions are internal. + // is the engine's own accessor; the D1 table definitions are internal. await expect.poll(async () => (await readGameRow(env.rps_dev, gameId))?.status).toBe("finished"); // A non-participant replays the finished public game as viewer: the diff --git a/examples/rps/test/unit/same-view.spec.ts b/examples/rps/test/unit/same-view.spec.ts index 7b9fe83..30034d2 100644 --- a/examples/rps/test/unit/same-view.spec.ts +++ b/examples/rps/test/unit/same-view.spec.ts @@ -3,7 +3,7 @@ * through the real kernel `commit()` with the real RPS rules: * * - ACCEPT: an opponent's hidden commit doesn't change your projected view, - * so your stale-version submission still lands — RPS works with zero game + * so your stale-version submission still lands. RPS works with zero game * code, and versions stay strictly serial (A commits N+1, stale-but-same B * commits N+2). * - REJECT: the round *resolution* (reveal) changes your view, so anything @@ -71,7 +71,7 @@ function expectPlan(result: CommitPlan | { rejected: true }): CommitPlan { } describe("same-view rule over real RPS rules", () => { - it("accepts the second simultaneous commit at a stale version — strictly serial", () => { + it("accepts the second simultaneous commit at a stale version, strictly serial", () => { // A commits rock against v4 → commits as v5. const planA = expectPlan(commit(action(0, "rock", 4))); expect(planA.nextState.version).toBe(5); @@ -81,7 +81,7 @@ describe("same-view rule over real RPS rules", () => { // is masked. B submits paper still expecting v4. const expected = frameFor(v4, 1); const current = frameFor(v5, 1); - expect(current).toEqual(expected); // the crux — B literally can't tell + expect(current).toEqual(expected); // the crux: B literally can't tell const planB = expectPlan( commit( @@ -91,7 +91,7 @@ describe("same-view rule over real RPS rules", () => { }), ), ); - // Accepted, and committed as the NEXT serial version — no forks, no gaps. + // Accepted, and committed as the NEXT serial version: no forks, no gaps. expect(planB.nextState.version).toBe(6); // The round resolved: paper beats rock. expect(planB.nextState.state).toMatchObject({ @@ -101,7 +101,7 @@ describe("same-view rule over real RPS rules", () => { }); }); - it("rejects a submission computed before the reveal — the view genuinely changed", () => { + it("rejects a submission computed before the reveal, since the view genuinely changed", () => { // Play the round out to v6 (resolved, revealed). const planA = expectPlan(commit(action(0, "rock", 4))); const v5 = planA.nextState; @@ -115,7 +115,7 @@ describe("same-view rule over real RPS rules", () => { ); const v6 = planB.nextState; - // Seat 0 now submits a round-2 move it computed back at v4 — but its view + // Seat 0 now submits a round-2 move it computed back at v4, but its view // moved (lastRound revealed, wins changed): genuine conflict. const result = commit( action(0, "scissors", 4, { diff --git a/examples/rps/test/unit/twin.spec.ts b/examples/rps/test/unit/twin.spec.ts index 6edc559..5e174dd 100644 --- a/examples/rps/test/unit/twin.spec.ts +++ b/examples/rps/test/unit/twin.spec.ts @@ -1,7 +1,7 @@ /** * The twin-drift suite: every JSON fixture under src/module/fixtures/ runs * against the TS rules unit here, and against the Dart `GameRules` twin in - * the client repo's CI — one recorded behavior, two implementations. + * the client repo's CI: one recorded behavior, two implementations. */ import { twinFixtureTests } from "@eigeninteractive/testkit"; diff --git a/examples/rps/test/wrangler.jsonc b/examples/rps/test/wrangler.jsonc index 750c872..7309d33 100644 --- a/examples/rps/test/wrangler.jsonc +++ b/examples/rps/test/wrangler.jsonc @@ -1,5 +1,5 @@ /** - * Test-only worker config for the vitest-pool-workers suite — the deployable + * Test-only worker config for the vitest-pool-workers suite. The deployable * wrangler.jsonc with `main` pointed at the test worker (production config + * test auth). Never deployed. */ diff --git a/examples/rps/vitest.config.mts b/examples/rps/vitest.config.mts index 6f29713..6d04b26 100644 --- a/examples/rps/vitest.config.mts +++ b/examples/rps/vitest.config.mts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; // Workers-pool tests only (run inside workerd against the real bindings). // Pure rules tests live under test/unit/ and run in Node via -// vitest.unit.config.mts — see the `test` script. +// vitest.unit.config.mts; see the `test` script. export default defineConfig(async () => { const migrations = await readD1Migrations(new URL("./node_modules/@eigeninteractive/server/migrations", import.meta.url).pathname); return { diff --git a/examples/rps/wrangler.jsonc b/examples/rps/wrangler.jsonc index 629ee07..44383f2 100644 --- a/examples/rps/wrangler.jsonc +++ b/examples/rps/wrangler.jsonc @@ -48,7 +48,7 @@ { "binding": "rps_dev", "database_name": "rps-dev", - // Placeholder. Local development and the test suite never read this — + // Placeholder. Local development and the test suite never read this; // `wrangler dev` and vitest-pool-workers simulate D1 locally against the // binding name. Replace it with your own before deploying: // wrangler d1 create rps-dev @@ -56,7 +56,7 @@ // Engine-owned schema, like the DO's: migrations ship inside // @eigeninteractive/server and `deploy` applies them before the code goes out. // App-custom data belongs in a SEPARATE second d1_databases entry with - // its own migrations_dir — never in this database. + // its own migrations_dir, never in this database. "migrations_dir": "node_modules/@eigeninteractive/server/migrations" } ], @@ -67,13 +67,13 @@ } ], // Per-user write rate limits. The engine wires each entry by its conventional - // `EIGEN_RATE_LIMIT_*` name, so no code is needed in index.ts — this block is + // `EIGEN_RATE_LIMIT_*` name, so no code is needed in index.ts. This block is // the entire setup, and the limits below are the recommended starting values. // The `limit`/`period` here are the source of truth (the engine reads // neither); `period` is 10 or 60 only. `namespace_id` is a positive integer - // that must be unique WITHIN your Cloudflare account — ids are account-scoped, + // that must be unique WITHIN your Cloudflare account, since ids are account-scoped, // so a value reused by another Worker would share counters. Per-colo, - // eventually consistent — an abuse dampener, not a hard quota. + // eventually consistent: an abuse dampener, not a hard quota. "ratelimits": [ { "name": "EIGEN_RATE_LIMIT_AVATAR_UPLOAD", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }, { "name": "EIGEN_RATE_LIMIT_GAME_CREATE", "namespace_id": "1002", "simple": { "limit": 10, "period": 60 } }, diff --git a/packages/create-eigen-game/CHANGELOG.md b/packages/create-eigen-game/CHANGELOG.md index d284438..905c0ad 100644 --- a/packages/create-eigen-game/CHANGELOG.md +++ b/packages/create-eigen-game/CHANGELOG.md @@ -6,17 +6,17 @@ - [#41](https://github.com/eigeninteractive/eigen-server/pull/41) [`3f65f4c`](https://github.com/eigeninteractive/eigen-server/commit/3f65f4cfbf5f3092381f6864e79cbb09e8eba268) Thanks [@seenu-k](https://github.com/seenu-k)! - Ask, rather than assume: every decision a scaffold makes is now a question, and every question has exactly one flag that answers it. - The run opens by saying what it is about to build and **why Firebase is part of it** — that the Worker verifies Firebase ID tokens to decide who holds a seat, that turn notifications go through the same project, that it is free and one project serves every game. A second service arriving unannounced in a scaffold looks like an imposition; the reason costs four lines and it is not. + The run opens by saying what it is about to build and **why Firebase is part of it**: that the Worker verifies Firebase ID tokens to decide who holds a seat, that turn notifications go through the same project, that it is free and one project serves every game. A second service arriving unannounced in a scaffold looks like an imposition; the reason costs four lines and it is not. - It then checks the two CLIs and the Google sign-in and reports **every** problem at once, in the order they have to be fixed in, rather than the first one — a machine with neither CLI used to learn about `flutterfire` only after installing `firebase-tools` and running the whole thing again. If anything is missing, the question is whether to scaffold anyway and connect Firebase later, and the default is **no**: the tools are two commands away, and the alternative is an app that throws `Firebase is not configured` the moment it launches. Answering no writes nothing and prints the commands, including the `PATH` line that `dart pub global activate` needs and does not set. + It then checks the two CLIs and the Google sign-in and reports **every** problem at once, in the order they have to be fixed in, rather than the first one. A machine with neither CLI used to learn about `flutterfire` only after installing `firebase-tools` and running the whole thing again. If anything is missing, the question is whether to scaffold anyway and connect Firebase later, and the default is **no**: the tools are two commands away, and the alternative is an app that throws `Firebase is not configured` the moment it launches. Answering no writes nothing and prints the commands, including the `PATH` line that `dart pub global activate` needs and does not set. Git and the GitHub Actions workflows are questions too, defaulted yes and no respectively, each with its reason next to it. The package manager is asked only when nothing else can say, which replaces a silent fallback to pnpm that was written into every script in the generated project. **Breaking, and deliberately so: a run with no terminal now fails on an unanswered question instead of choosing for you.** The default that made this necessary is `--org`: a non-interactive run without it used to ship `com.example.my_game`, and Google Play makes that permanent at the first upload. A default is fine as the pre-filled answer to a question someone is looking at; it is not fine as a decision made in a pipe. The error prints the complete command to re-run with every default already filled in, so the fix is one paste and the value worth changing is visible in it. - `--ci` is gone. It read as "I am running in CI" — which is what it means in `npm ci`, in Jest, and in this CLI's own `isCI` check — while actually meaning "emit GitHub Actions workflows". It is now `--workflows` / `--no-workflows`, and the subcommand is `add workflows`. `add ci` still works, undocumented, because it is written into the README of every project already scaffolded. + `--ci` is gone. It read as "I am running in CI", which is what it means in `npm ci`, in Jest, and in this CLI's own `isCI` check, while actually meaning "emit GitHub Actions workflows". It is now `--workflows` / `--no-workflows`, and the subcommand is `add workflows`. `add ci` still works, undocumented, because it is written into the README of every project already scaffolded. - An occupied destination is refused **before** the first question rather than by `scaffoldGame` after the last one. Being told the directory was taken after carefully giving an organization is the same insult as being told about `flutterfire` after two minutes of Flutter and pub. The message names the first three things standing in the way, which usually settles whether the path was mistyped or already scaffolded once — where "not empty" only invites an `ls`. There is deliberately no offer to delete it: every other question here is reversible and that one is not. A directory that exists and is *empty* is no longer refused at all, since people make one out of habit. A cloned repository is not that case — `.git` stands in the way like any other entry, and the message names it — so `git clone` then scaffold still means scaffolding beside it and moving the result in. + An occupied destination is refused **before** the first question rather than by `scaffoldGame` after the last one. Being told the directory was taken after carefully giving an organization is the same insult as being told about `flutterfire` after two minutes of Flutter and pub. The message names the first three things standing in the way, which usually settles whether the path was mistyped or already scaffolded once, where "not empty" only invites an `ls`. There is deliberately no offer to delete it: every other question here is reversible and that one is not. A directory that exists and is *empty* is no longer refused at all, since people make one out of habit. A cloned repository is not that case: `.git` stands in the way like any other entry, and the message names it, so `git clone` then scaffold still means scaffolding beside it and moving the result in. Also adds `--version`, and stops asking about git at all when the destination is already inside a repository, since the scaffold declines to nest one there whatever it is told. @@ -26,11 +26,11 @@ - [#38](https://github.com/eigeninteractive/eigen-server/pull/38) [`2ebb404`](https://github.com/eigeninteractive/eigen-server/commit/2ebb40408e785b3f904e34e50f0500cd3f9008d5) Thanks [@seenu-k](https://github.com/seenu-k)! - Generate the Worker's types against the wrangler a scaffold actually installed. - Every scaffolded project opened its first `wrangler dev` with `❓ Your types might be out of date`. Nothing was: the `Env` interface was complete and correct, bindings and all. `worker-configuration.d.ts` is committed, and its second line stamps the workerd version that produced the runtime types — `wrangler` floats on a caret, Cloudflare ships workerd about weekly, and the copy in `templates/worker` was generated whenever this package was last built. Across three weeks of workerd releases the whole diff is that one comment line, byte-identical either side of it, and `wrangler dev` compares the line. + Every scaffolded project opened its first `wrangler dev` with `❓ Your types might be out of date`. Nothing was: the `Env` interface was complete and correct, bindings and all. `worker-configuration.d.ts` is committed, and its second line stamps the workerd version that produced the runtime types. `wrangler` floats on a caret, Cloudflare ships workerd about weekly, and the copy in `templates/worker` was generated whenever this package was last built. Across three weeks of workerd releases the whole diff is that one comment line, byte-identical either side of it, and `wrangler dev` compares the line. So the scaffold now runs `cf-typegen` in `server/` immediately after installing its packages, against the wrangler that install just resolved rather than the one this package was built with, and the file lands correct in the scaffold commit. - Left alone deliberately: `dev.generate_types`, which would have Wrangler rewrite the file mid-`dev` instead of mentioning it. It trades one warning per workerd release for one silent write to a tracked file per workerd release, on the command an implementor runs all day — and on a team, for that line flipping back and forth as each machine's wrangler resolves differently. A warning costs nothing to ignore; a dirty tree does not. `cf-typegen` is there for the implementor who wants it gone, and `typecheck` has always regenerated before `tsc`. + Left alone deliberately: `dev.generate_types`, which would have Wrangler rewrite the file mid-`dev` instead of mentioning it. It trades one warning per workerd release for one silent write to a tracked file per workerd release, on the command an implementor runs all day, and on a team, for that line flipping back and forth as each machine's wrangler resolves differently. A warning costs nothing to ignore; a dirty tree does not. `cf-typegen` is there for the implementor who wants it gone, and `typecheck` has always regenerated before `tsc`. ## 0.8.0 @@ -38,17 +38,17 @@ - [#36](https://github.com/eigeninteractive/eigen-server/pull/36) [`8cc9d6b`](https://github.com/eigeninteractive/eigen-server/commit/8cc9d6b3fdc996890c02af9baab8280de9ea10bc) Thanks [@seenu-k](https://github.com/seenu-k)! - Configure Firebase as part of scaffolding, and show the applicationId while asking for it. - A scaffold now finishes with a runnable app. `firebase_options.dart` was a throwing placeholder until `firebase:configure` had been run, so the first `flutter run` ended at `Firebase is not configured` — a step that lived only in the README, which is read after the failure rather than before it. It runs before the scaffold commit, so `firebase.json`, `google-services.json`, the generated `firebase_options.dart` and `web/firebase-config.js`, and FlutterFire's two Gradle edits are committed with everything else instead of arriving as the project's first diff. + A scaffold now finishes with a runnable app. `firebase_options.dart` was a throwing placeholder until `firebase:configure` had been run, so the first `flutter run` ended at `Firebase is not configured`, a step that lived only in the README, which is read after the failure rather than before it. It runs before the scaffold commit, so `firebase.json`, `google-services.json`, the generated `firebase_options.dart` and `web/firebase-config.js`, and FlutterFire's two Gradle edits are committed with everything else instead of arriving as the project's first diff. - Never at the cost of the scaffold. The two CLIs and the Google sign-in are checked *before* the two minutes of Flutter and pub, and anything missing — including no terminal to answer the project prompt on — turns the step off, names what is missing and the command that installs it, and leaves exactly the scaffold that `--no-firebase` produces. Failures during the step itself are treated the same way: the project is complete, the commit still happens, and the summary ends by naming `firebase:configure`. + Never at the cost of the scaffold. The two CLIs and the Google sign-in are checked *before* the two minutes of Flutter and pub, and anything missing, including no terminal to answer the project prompt on, turns the step off, names what is missing and the command that installs it, and leaves exactly the scaffold that `--no-firebase` produces. Failures during the step itself are treated the same way: the project is complete, the commit still happens, and the summary ends by naming `firebase:configure`. `--no-firebase` skips it. `--firebase-project ` names the project instead of being asked, which is also the form that works with no terminal attached. - The organization prompt shows the `applicationId` each answer produces, rather than describing how one is derived, and says when that string is also what gets registered in Firebase. `--org com.acme.chess` for a game called `chess` reads like the whole identifier and is not — it yields `com.acme.chess.chess`, Google Play makes that permanent at the first upload, and FlutterFire matches an existing Android app on exactly that string. + The organization prompt shows the `applicationId` each answer produces, rather than describing how one is derived, and says when that string is also what gets registered in Firebase. `--org com.acme.chess` for a game called `chess` reads like the whole identifier and is not: it yields `com.acme.chess.chess`, Google Play makes that permanent at the first upload, and FlutterFire matches an existing Android app on exactly that string. - The CLI now speaks in one voice, through [`@clack/prompts`](https://github.com/bombshell-dev/clack). Each phase of a scaffold is a named step whose subprocess output is shown while it runs, cleared when it succeeds, and kept when it fails — so progress is visible, a failure is still debuggable from what is on screen, and a successful run no longer ends in several hundred lines of pub, Flutter and package-manager chatter nobody read. The organization question renders the game name dimmed after the cursor while it is typed, which is the part that made `com.acme.chess` become `com.acme.chess.chess`, and a pasted answer that repeats the game name is offered the shorter one. Ctrl-C at the prompt exits without writing anything. + The CLI now speaks in one voice, through [`@clack/prompts`](https://github.com/bombshell-dev/clack). Each phase of a scaffold is a named step whose subprocess output is shown while it runs, cleared when it succeeds, and kept when it fails, so progress is visible, a failure is still debuggable from what is on screen, and a successful run no longer ends in several hundred lines of pub, Flutter and package-manager chatter nobody read. The organization question renders the game name dimmed after the cursor while it is typed, which is the part that made `com.acme.chess` become `com.acme.chess.chess`, and a pasted answer that repeats the game name is offered the shorter one. Ctrl-C at the prompt exits without writing anything. - Two registers, chosen by clack's own `isTTY`/`isCI`: a pipe or a CI log gets every tool's output in full, since that is what a log is read for. A pty that reports itself as a terminal with no width — `script`, some container terminals — is given the 80 columns clack assumes for a stream with no width at all, because `0` satisfies its check and the box rules it draws from that width were killing a scaffold partway through with `RangeError: Invalid string length`. + Two registers, chosen by clack's own `isTTY`/`isCI`: a pipe or a CI log gets every tool's output in full, since that is what a log is read for. A pty that reports itself as a terminal with no width (`script`, some container terminals) is given the 80 columns clack assumes for a stream with no width at all, because `0` satisfies its check and the box rules it draws from that width were killing a scaffold partway through with `RangeError: Invalid string length`. ## 0.7.3 @@ -88,7 +88,7 @@ No behaviour changes here. The range a scaffolded project receives comes from this package's own `@eigeninteractive/server` devDependency, which pnpm rewrites - to an exact version when it packs — deliberately, because that is the version CI + to an exact version when it packs, deliberately, because that is the version CI compiled the templates against. The consequence is that the range only moves when this package is republished: 0.7.0 was packed against 0.2.1 and goes on emitting `^0.2.1` however many engine releases follow it, and pnpm will keep @@ -116,7 +116,7 @@ The Flutter half is excluded: `dart format` owns it. A test scaffolds a project and runs Biome inside it, so the generated files are - held to the configuration they ship with. Its first run found two violations — + held to the configuration they ship with. Its first run found two violations: in the `biome.json` this change adds. `pnpm-lock.yaml` at the root is no longer ignored. It described nothing when the @@ -128,7 +128,7 @@ A generated project's `dev` script started a Worker against an empty local D1, so the scheduled handler failed on its first run with - `D1_ERROR: no such table: users` and again with `no such table: games` — two + `D1_ERROR: no such table: users` and again with `no such table: games`: two screens of stack trace before the first request, on a project that had done nothing wrong. `dev` now runs the new `db:migrate:local` script first, which is idempotent: it applies `0000_init.sql` once and does nothing thereafter. @@ -139,7 +139,7 @@ writes that key itself, directly above the comment explaining why it was commented out, and pins one exact version per entry so the exemption expires with the version instead of opening the whole scope. Prose in that file could - not survive anyway — pnpm rewrites it in place, and a YAML formatter is free to + not survive anyway, since pnpm rewrites it in place and a YAML formatter is free to move whatever is left. The README says what both keys are for, including the one place the release quarantine is silent: `pnpm create eigen-game`, where there is no manifest to record an exemption in. @@ -152,15 +152,15 @@ script leaves behind, and record in the generated `pnpm-workspace.yaml` what pnpm 11's `minimumReleaseAge` does to an engine release. - A run prints several screens belonging to other tools — pnpm's `dlx` install, - `flutter create`, pub, two icon generators, the server install — so `Created + A run prints several screens belonging to other tools (pnpm's `dlx` install, + `flutter create`, pub, two icon generators, the server install) so `Created my-game in …` was indistinguishable from the noise above it. The summary names where the project went, whether the scaffold was committed, and the two files a game is written in: `server/src/module/v1.ts` and `app/lib/game/v1/rules.dart`. `ScaffoldResult` carries a `git` outcome so it reports rather than guesses. - The root `package.json` declares no dependencies — it only forwards scripts into - `server/` and `app/` — so the first `pnpm contract` left an untracked + The root `package.json` declares no dependencies, only forwarding scripts into + `server/` and `app/`, so the first `pnpm contract` left an untracked `node_modules/` and an empty lockfile beside the scaffold's own commit. Both are ignored now, anchored so `server/pnpm-lock.yaml` and `app/pubspec.lock` stay committed. npm strips `.gitignore` from tarballs, so each of these files has a @@ -175,8 +175,8 @@ The `minimumReleaseAgeExclude` block is commented out, because nothing needs it: `minimumReleaseAgeStrict` is false unless `minimumReleaseAge` is set explicitly, so a range only a fresh release satisfies still installs it. It matters for - anyone whose organization sets that setting — strict resolution then fails an - install on the day an engine release ships instead of falling back — and the + anyone whose organization sets that setting, since strict resolution then fails an + install on the day an engine release ships instead of falling back, and the comment says so, next to the cost of excluding packages that execute during a build. @@ -195,7 +195,7 @@ rather than given a nested repository. `--org` is now asked for interactively when omitted and there is a terminal, - because it becomes the Android `applicationId` — which Google Play makes + because it becomes the Android `applicationId`, which Google Play makes permanent at first upload. Both the flag and the answer are validated as reverse domain notation, so `com.example-games` fails at scaffold time rather than at the first Gradle build. An empty value still falls back to @@ -205,8 +205,8 @@ - [#19](https://github.com/eigeninteractive/eigen-server/pull/19) [`993883f`](https://github.com/eigeninteractive/eigen-server/commit/993883f8bb71ebfb36708e2badd7ae98859b7094) Thanks [@seenu-k](https://github.com/seenu-k)! - Use **EigenInteractive** as the product name throughout, matching the domain, the npm scope and the GitHub organization. Package descriptions, READMEs and - the OpenAPI document title change; every identifier — `@eigeninteractive/*`, - `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header — is + the OpenAPI document title change; every identifier (`@eigeninteractive/*`, + `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header) is untouched. ## 0.4.0 @@ -225,14 +225,14 @@ Fixes template rendering corrupting binary files. `renderTree` read every file as UTF-8 and wrote it back, so any byte that is not valid UTF-8 became U+FFFD, - and it walked the destination rather than the template — which for the app + and it walked the destination rather than the template, which for the app overlay meant rewriting everything `flutter create` had just produced. Every scaffolded project has been getting a damaged Gradle wrapper JAR and damaged launcher icons. Nothing caught it because the end-to-end check runs `flutter analyze`/`test`/`build web`, none of which read those bytes. Two edits to Flutter's own output are gone. The release signing config no - longer prepends `import java.util.Properties` — parsing `key.properties` with + longer prepends `import java.util.Properties`; parsing `key.properties` with the Kotlin stdlib needs no import, so the edit is append-only and cannot collide with what `flutterfire configure` writes. The notification icon and its Firebase meta-data now come from `eigen_flutter`'s Android plugin via manifest diff --git a/packages/create-eigen-game/README.md b/packages/create-eigen-game/README.md index d1c8a79..41db51d 100644 --- a/packages/create-eigen-game/README.md +++ b/packages/create-eigen-game/README.md @@ -24,13 +24,13 @@ so passing the flag skips the question: | Emit the GitHub Actions workflows | `--workflows`, `--no-workflows` | | Which package manager the generated scripts use | `--package-manager npm\|pnpm` | -The package manager is only asked when nothing else can say — `npm create` and +The package manager is only asked when nothing else can say. `npm create` and `pnpm create` both announce themselves. The Firebase question only appears when the `firebase` and `flutterfire` CLIs are not both installed and signed in, and its default is to stop rather than scaffold, since a scaffolded app throws `Firebase is not configured` at launch until a project is connected. -With no terminal to ask on — CI, a pipe, an agent session — an unanswered +With no terminal to ask on (CI, a pipe, an agent session) an unanswered question is an error rather than a default chosen where nobody can see it, and the message prints the whole command to re-run with each default filled in. `--org` is why: `com.example.my_game` is permanent once Google Play has taken diff --git a/packages/create-eigen-game/scripts/scaffold-e2e.mjs b/packages/create-eigen-game/scripts/scaffold-e2e.mjs index 9134349..d966155 100644 --- a/packages/create-eigen-game/scripts/scaffold-e2e.mjs +++ b/packages/create-eigen-game/scripts/scaffold-e2e.mjs @@ -4,8 +4,8 @@ // // This is the only thing that checks the Dart templates at all. `pnpm -r // typecheck` compiles `templates/worker` against the workspace engine, but -// nothing anywhere compiled `templates/app-overlay` — the files that import -// `package:eigen_flutter/eigen_flutter.dart` — so the pinned `eigen_flutter` +// nothing anywhere compiled `templates/app-overlay`, the files that import +// `package:eigen_flutter/eigen_flutter.dart`, so the pinned `eigen_flutter` // range was an assertion no build ever tested. eigen-flutter's own `example/` // is a different tree and cannot stand in for it. // @@ -21,7 +21,7 @@ // publishes it. The gate would deadlock every line-crossing release. // // So the four engine packages are overridden to this workspace. That also makes -// the check the more useful one — it compiles the templates against the engine +// the check the more useful one: it compiles the templates against the engine // about to ship rather than the engine that shipped last. // // `eigen_flutter` is deliberately NOT overridden. It lives in another @@ -47,7 +47,7 @@ const shell = (command, args, cwd) => { /** * pnpm reads `overrides` from `pnpm-workspace.yaml` in a single-package project - * too, and overrides apply transitively — which is what makes this work at all: + * too, and overrides apply transitively, which is what makes this work at all: * `@eigeninteractive/server` depends on `kernel` and peer-depends on `rules`, * and those would otherwise be fetched from npm at the very version that does * not exist yet. @@ -122,7 +122,7 @@ const shellsSpeaking = async (engineLine) => { // The shell CANNOT match a brand-new engine line, and that is structural rather // than an oversight. `eigen_api` is published with the engine, and no // `eigen_flutter` of any version number can constrain `^0.3.0` until -// `eigen_api 0.3.0` exists on pub.dev — which happens when the engine's own +// `eigen_api 0.3.0` exists on pub.dev, which happens when the engine's own // release merges. // // An unconditional assertion therefore fails on the version pull request that @@ -143,7 +143,7 @@ const shellsSpeaking = async (engineLine) => { // This queries the registry the deleted resolver queried, for the opposite // purpose. Choosing a shell by its `eigen_api` constraint is wrong, because the // constraint cannot see the Dart API the templates call. Checking whether a -// shell for this line exists at all asks nothing about the Dart API — and the +// shell for this line exists at all asks nothing about the Dart API, and the // build above already answered that question. const emittedEngine = JSON.parse(readFileSync(resolve(serverRoot, "package.json"), "utf8")).dependencies["@eigeninteractive/server"]; const lock = readFileSync(resolve(appRoot, "pubspec.lock"), "utf8"); @@ -157,8 +157,7 @@ const speakers = await shellsSpeaking(engineLine); if (speakers.length === 0) { console.log( - `::notice::No published eigen_flutter constrains eigen_api ^${engineLine}.0 yet, so there is no wire pairing to check. ` + - `This is the expected state between an engine line crossing and the shell release that follows it — the shell cannot be built for ${engineLine}.x until eigen_api ${engineLine}.0 is on pub.dev.`, + `::notice::No published eigen_flutter constrains eigen_api ^${engineLine}.0 yet, so there is no wire pairing to check. ` + `This is the expected state between an engine line crossing and the shell release that follows it: the shell cannot be built for ${engineLine}.x until eigen_api ${engineLine}.0 is on pub.dev.`, ); } else if (line(resolvedApi) !== engineLine) { throw new Error( @@ -168,7 +167,7 @@ if (speakers.length === 0) { ); } -// The server half. `contract` already ran during bootstrap — this is the part a +// The server half. `contract` already ran during bootstrap; this is the part a // game author would run next, and it is what proves the emitted engine range // and the template sources agree. shell("pnpm", ["run", "typecheck"], serverRoot); diff --git a/packages/create-eigen-game/src/cli.ts b/packages/create-eigen-game/src/cli.ts index 199f77b..8dd33e3 100644 --- a/packages/create-eigen-game/src/cli.ts +++ b/packages/create-eigen-game/src/cli.ts @@ -22,8 +22,8 @@ kebab-case game slug; the CLI derives the display name, Dart package name, and Dart/TypeScript type prefix from it. Every option below answers a question the CLI would otherwise ask. Pass it to -skip the question; leave it out to be asked. With no terminal to ask on — CI, a -pipe, an agent session — an unanswered question is an error rather than a +skip the question; leave it out to be asked. With no terminal to ask on (CI, a +pipe, an agent session) an unanswered question is an error rather than a default quietly chosen for you, and the message prints the command to re-run. Options: @@ -56,7 +56,7 @@ normaliseTerminalWidth(process.stdout); /** * Whether to draw, using clack's own predicates rather than a hand-rolled - * `isTTY` — the same pair `taskLog` consults internally. A pipe has nowhere to + * `isTTY`, the same pair `taskLog` consults internally. A pipe has nowhere to * put a redraw, and a CI log is read after the fact, in full, by someone who * wants every line a tool printed. */ @@ -102,18 +102,18 @@ function resolveManager(requested: string | undefined): PackageManager | undefin * * Firebase is not an integration this engine offers; it is where a player * comes from. Somebody meeting that for the first time deserves the reason in - * the same breath as the requirement, and deserves to know the size of it — + * the same breath as the requirement, and deserves to know the size of it: * one free project, shared by every game they build. */ function greet(): void { ui.open(); ui.info("A game is one repository with two halves: a Cloudflare Worker that owns the rules,\nand a Flutter app for Android and the web. This writes both, wired together and\nalready playable."); ui.info( - `Firebase signs your players in. The Worker verifies Firebase ID tokens to decide who\nholds a seat, and sends turn notifications through the same project — so a scaffolded\napp throws ${color.dim("Firebase is not configured")} at launch until one is connected.\n\nIt is free to start, one project serves every game you build, and this connects yours\nas it goes. ${color.dim(PREREQUISITES)}`, + `Firebase signs your players in. The Worker verifies Firebase ID tokens to decide who\nholds a seat, and sends turn notifications through the same project, so a scaffolded\napp throws ${color.dim("Firebase is not configured")} at launch until one is connected.\n\nIt is free to start, one project serves every game you build, and this connects yours\nas it goes. ${color.dim(PREREQUISITES)}`, ); } -/** What is missing, and the one command that fixes each — the same list the warning and the parting message are both built from. */ +/** What is missing, and the one command that fixes each: the same list the warning and the parting message are both built from. */ function firebaseReport(problems: FirebaseProblem[]): string { const lines = problems.flatMap((problem) => [` ${problem.reason}`, ` ${color.cyan(problem.fix)}`]); // `dart pub global activate` does not put the binary on PATH, which is the @@ -126,7 +126,7 @@ function firebaseReport(problems: FirebaseProblem[]): string { /** * The parting message when someone answers "no" to scaffolding without - * Firebase — every command they need, in the order they run in. + * Firebase: every command they need, in the order they run in. * * Deliberately not a failure of the scaffold: nothing has been written, they * asked for that, and the exit code is non-zero only so a script that wrapped @@ -134,7 +134,7 @@ function firebaseReport(problems: FirebaseProblem[]): string { */ function stopForFirebase(problems: FirebaseProblem[]): void { // The commands only. What each one is for is in the warning directly above, - // and a `note` draws a box sized to its longest line — so repeating the + // and a `note` draws a box sized to its longest line, so repeating the // reasons here is what makes it wrap into something unreadable at 80 // columns, which is exactly the width this is most likely to be read at. note(`${problems.map((problem) => ` ${color.cyan(problem.fix)}`).join("\n")}\n\nThen run this again.\n${color.dim(PREREQUISITES)}`, "Set up Firebase"); @@ -145,7 +145,7 @@ function stopForFirebase(problems: FirebaseProblem[]): void { /** * The error for a run with no terminal and an unanswered question. * - * The strictness is the point — a default applied where nobody can see it is + * The strictness is the point: a default applied where nobody can see it is * how `com.example.my_game` reaches Google Play, which freezes it at the first * upload. What keeps that from being merely annoying is this message: the * whole command, with every default already filled in, so the fix is one @@ -153,7 +153,7 @@ function stopForFirebase(problems: FirebaseProblem[]): void { */ function unanswered(directory: string, missing: { flags: string; suggestion: string }[]): string { const command = ["npx create-eigen-game", directory, ...missing.map((question) => question.suggestion)].join(" "); - return [`No terminal to ask on, so every answer has to come from a flag. These were not given:`, "", ...missing.map((question) => ` ${question.flags}`), "", "That command, with each default filled in — read them before you run it:", "", ` ${command}`].join("\n"); + return [`No terminal to ask on, so every answer has to come from a flag. These were not given:`, "", ...missing.map((question) => ` ${question.flags}`), "", "That command, with each default filled in. Read them before you run it:", "", ` ${command}`].join("\n"); } /** `--foo` and `--no-foo`, which `parseArgs` has no notion of: `undefined` means nobody said, which is what makes it a question. */ @@ -168,7 +168,7 @@ function flagPair(yes: boolean | undefined, no: boolean | undefined, name: strin function resolveOrg(value: string): string | undefined { if (value.trim() === "") return undefined; if (!ORG.test(value)) { - throw new Error(`invalid organization: ${value}\n\nUse reverse domain notation — two or more dot-separated segments of letters, digits and underscores, each starting with a letter, as in ${DEFAULT_ORG} or dev.yourname.games.`); + throw new Error(`invalid organization: ${value}\n\nUse reverse domain notation: two or more dot-separated segments of letters, digits and underscores, each starting with a letter, as in ${DEFAULT_ORG} or dev.yourname.games.`); } return value; } @@ -176,8 +176,8 @@ function resolveOrg(value: string): string | undefined { /** * Shows each step running, and keeps its output only when it fails. * - * Every tool a scaffold drives has plenty to say — pub resolving 179 - * dependencies, two icon generators, a package manager — and none of it is + * Every tool a scaffold drives has plenty to say (pub resolving 179 + * dependencies, two icon generators, a package manager) and none of it is * read while it scrolls past. `taskLog` is exactly that bargain: the stream is * visible while the step runs, cleared when it succeeds, and left on screen * when it does not, so a failure is still debuggable from what is in the @@ -189,7 +189,7 @@ function resolveOrg(value: string): string | undefined { */ function clackReporter(): Reporter { // `taskLog` redraws by clearing lines, which is an escape-sequence storm in - // anything that is not a terminal — a CI log, a pipe, `scaffold-e2e.mjs`. + // anything that is not a terminal: a CI log, a pipe, `scaffold-e2e.mjs`. // There, the honest thing is the plain reporter: every tool's output, in // full, which is what a log gets read for anyway. if (!decorated) return plainReporter; @@ -212,7 +212,7 @@ function clackReporter(): Reporter { } }, handOver(label, body) { - log.step(`${label} ${color.dim("— FlutterFire takes over here")}`); + log.step(`${label} ${color.dim("(FlutterFire takes over here)")}`); interactive = true; try { return body(); @@ -245,7 +245,7 @@ async function main(args: string[]): Promise { "firebase-project": { type: "string" }, // Each negation is declared under the name it is typed with, because // `parseArgs` has no boolean negation of its own. `flagPair` puts the - // two halves back together, and the third state — neither given — is the + // two halves back together, and the third state, neither given, is the // one that matters: it is what turns the flag into a question. "no-firebase": { type: "boolean" }, git: { type: "boolean" }, @@ -295,7 +295,7 @@ async function main(args: string[]): Promise { // Everything that makes the command impossible, before the greeting rather // than after the last question. `applicationId` validates the slug; // `destinationProblem` is the one `scaffoldGame` would raise at the far end, - // asked here instead — being told the directory was occupied *after* giving + // asked here instead. Being told the directory was occupied *after* giving // an organization is the same insult as being told about `flutterfire` after // two minutes of Flutter and pub. const game = basename(resolve(directory)); @@ -306,8 +306,8 @@ async function main(args: string[]): Promise { greet(); // Every question this run will ask, settled before any of it starts. A flag - // is an answer already given; anything left over is asked, or — with nothing - // to ask on — collected here and reported together, so one re-run can carry + // is an answer already given; anything left over is asked, or, with nothing + // to ask on, collected here and reported together, so one re-run can carry // all of them. const interactive = process.stdin.isTTY === true; const missing: { flags: string; suggestion: string }[] = []; @@ -318,8 +318,8 @@ async function main(args: string[]): Promise { /** * Records a question that could not be asked, and hands back the default it - * suggested. The value is never used — `missing` throws before anything is - * written — but returning it keeps each answer definitely typed instead of + * suggested. The value is never used, since `missing` throws before anything + * is written, but returning it keeps each answer definitely typed instead of * carrying a `| undefined` all the way to the scaffold call. */ const needsFlag = (flags: string, suggestion: string, fallback: T): T => { @@ -393,7 +393,7 @@ async function main(args: string[]): Promise { } } - // Reached only when nothing else could say — `npm create` and `pnpm create` + // Reached only when nothing else could say. `npm create` and `pnpm create` // both announce themselves in `npm_config_user_agent`. What is left is a // global install, where the old silent fallback to pnpm was a guess written // into every script in the generated project. diff --git a/packages/create-eigen-game/src/engine-range.ts b/packages/create-eigen-game/src/engine-range.ts index 22748ee..e7ee2b2 100644 --- a/packages/create-eigen-game/src/engine-range.ts +++ b/packages/create-eigen-game/src/engine-range.ts @@ -1,8 +1,8 @@ /** * Where the engine range emitted into a scaffolded project comes from. * - * Its own module because the two branches below execute in different worlds — - * one only inside this repository, the other only inside a published tarball — + * Its own module because the two branches below execute in different worlds, + * one only inside this repository and the other only inside a published tarball, * and a function whose published-only path never runs in CI is a function that * is not tested. `src/index.ts` uses it; the package `exports` map does not * expose it, so it is internal despite being importable by the tests. @@ -22,17 +22,17 @@ export const ENGINE_PACKAGE = "@eigeninteractive/server"; * ever saw. * * It replaced a derivation from this package's OWN version, which was correct - * only while `create-eigen-game` sat in the `fixed` changesets group — and that + * only while `create-eigen-game` sat in the `fixed` changesets group, and that * membership is what forced an engine-wide release for every scaffolder-only * change. Reading the dependency keeps the guarantee and drops the coupling. * * Two branches, because the same manifest reads differently in the two places * this runs: * - * - **Published tarball** — pnpm rewrites `workspace:*` to the exact version + * - **Published tarball**: pnpm rewrites `workspace:*` to the exact version * while packing, so the manifest already states it (`"0.2.0"`), and no * sibling package exists on disk to consult. - * - **This workspace** — the manifest still says `workspace:*`, which names no + * - **This workspace**: the manifest still says `workspace:*`, which names no * version, so the sibling package is read directly. * * @param declared the raw `@eigeninteractive/server` devDependency value diff --git a/packages/create-eigen-game/src/index.ts b/packages/create-eigen-game/src/index.ts index d00cb41..615f996 100644 --- a/packages/create-eigen-game/src/index.ts +++ b/packages/create-eigen-game/src/index.ts @@ -36,7 +36,7 @@ export interface Reporter { /** * Gives a terminal that reports no width the width clack would have assumed. * - * A pty can report itself as a TTY and set `columns` to 0 — `script` does, and + * A pty can report itself as a TTY and set `columns` to 0. `script` does, and * so do some container terminals. clack falls back to 80 for a stream with no * `columns` at all, but `0` is a number and satisfies that check, so the width * it draws its rules from goes negative and a scaffold dies partway through @@ -62,7 +62,7 @@ export const plainReporter: Reporter = { * * `interactive` inherits, which is the only way FlutterFire can put a question * on screen and read the answer. Otherwise both streams are captured and - * handed to the reporter — on success so it can discard them, and on failure + * handed to the reporter: on success so it can discard them, and on failure * so the reason survives. */ export function capturingRunner(reporter: Reporter): Runner { @@ -94,7 +94,7 @@ export interface ScaffoldOptions { */ bootstrap?: boolean; /** - * Emit the GitHub Actions workflows. Off by default — see the call site for + * Emit the GitHub Actions workflows. Off by default; see the call site for * why, and `addContinuousIntegration` for adding them to an existing * project later. */ @@ -153,7 +153,7 @@ const templatesRoot = resolve(packageRoot, "templates"); * The engine range emitted into a scaffolded project's package.json. * * Read from this package's `@eigeninteractive/server` devDependency rather than - * from its own version — see `engine-range.ts` for why that is the version the + * from its own version. See `engine-range.ts` for why that is the version the * templates are known to compile against, and what it replaced. */ const engineVersion = engineRange((JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) as { devDependencies?: Record }).devDependencies?.[ENGINE_PACKAGE], () => { @@ -169,7 +169,7 @@ const engineVersion = engineRange((JSON.parse(readFileSync(resolve(packageRoot, * A stated version, not a derived or resolved one. The Dart templates import * `package:eigen_flutter/eigen_flutter.dart` and * `package:eigen_flutter/testing/twin_fixtures.dart` and are written against a - * specific Dart API — and `eigen_flutter` lives in another repository, versioned + * specific Dart API, and `eigen_flutter` lives in another repository, versioned * independently, so nothing in this repository can compute which release that * is. Only compiling a scaffolded app establishes it, which is what the * `scaffold` job in checks.yml does on every change. @@ -178,14 +178,14 @@ const engineVersion = engineRange((JSON.parse(readFileSync(resolve(packageRoot, * own `eigen_api` constraint targets the engine line being scaffolded". That * predicate is wrong. The `eigen_api` constraint describes the WIRE the shell * speaks, not the Dart API these templates call, and the two move - * independently — a future `eigen_flutter` may legitimately keep `eigen_api: + * independently: a future `eigen_flutter` may legitimately keep `eigen_api: * ^0.2.0` while renaming everything the templates touch. It would have been * selected, and the generated app would not compile. * * A caret RANGE rather than an exact version, so it still improves without a * republish: `flutter pub add eigen_flutter@^0.3.0` already picks the newest * 0.3.x at scaffold time, which is the part the pub.dev lookup was duplicating. - * What it deliberately cannot do is cross to 0.4.x — the one move that needs a + * What it deliberately cannot do is cross to 0.4.x, the one move that needs a * human to confirm the templates still compile. * * Staleness is therefore a failing check rather than a broken scaffold: this is @@ -207,7 +207,7 @@ const gameSlug = (value: string): string => { const dartName = (value: string): string => value.replaceAll("-", "_"); /** - * The Android `applicationId` — and iOS bundle id — a scaffold at `directory` + * The Android `applicationId`, and iOS bundle id, a scaffold at `directory` * will produce, so the CLI can show it rather than describe it. * * `flutter create --org X --project-name Y` derives `X.Y`. The organization is @@ -241,7 +241,7 @@ export function detectPackageManager(userAgent = process.env.npm_config_user_age /** * Substitutes the scaffold's tokens, of which there are two deliberate kinds. * - * `{{BRACED}}` for values that have no valid example — a package manager, + * `{{BRACED}}` for values that have no valid example: a package manager, * an engine version, an Android package id. They appear only in manifests, * READMEs, workflows and the `Appfile`: files nothing compiles. * @@ -255,7 +255,7 @@ export function detectPackageManager(userAgent = process.env.npm_config_user_age * * The cost, and the one rule for template authors: a template file cannot * contain the literal string `example-game`, `Example Game`, `ExampleGame` or - * `example_game` and keep it — every occurrence is rewritten, in prose and in + * `example_game` and keep it; every occurrence is rewritten, in prose and in * code alike. Write around it, or brace a new token instead. * * Note this means the set of files carrying a token changes whenever someone @@ -279,14 +279,14 @@ function render(contents: string, name: string, manager: PackageManager, package /** * True when the bytes are valid UTF-8. * - * This is not a guess at whether a file is "binary" — it is the precondition + * This is not a guess at whether a file is "binary"; it is the precondition * of the operation. Token substitution is `String.replaceAll`, so a file that * cannot be decoded cannot take part, and one that can is safe to rewrite. * * Deliberately not an extension list. A DENY list (`.png`, `.jar`, …) fails * silently in the dangerous direction: the day someone adds a `.webp`, it is * corrupted with no error. An ALLOW list fails the safe way but fits this tree - * badly — `Gemfile`, `Fastfile`, `.nvmrc`, `.fvmrc` and `.ruby-version` all + * badly: `Gemfile`, `Fastfile`, `.nvmrc`, `.fvmrc` and `.ruby-version` all * need rendering and none carry a usable extension, so it would have to be a * list of extensions *and* a list of bare filenames, both drifting. * @@ -312,7 +312,7 @@ function renderTree(source: string, destination: string, name: string, manager: cpSync(source, destination, { recursive: true }); // Walks the TEMPLATE, not the destination. The app overlay renders on top of // a `flutter create` result, so walking the destination meant rewriting - // every file Flutter had just generated — including its binaries — for files + // every file Flutter had just generated, including its binaries, for files // this scaffolder does not own and has no business touching. const visit = (directory: string): void => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -381,14 +381,14 @@ function enableAndroidCoreLibraryDesugaring(appRoot: string): void { appendFileSync(gradlePath, `${gradle.endsWith("\n") ? "\n" : "\n\n"}${androidDesugaring}\n`); } -// A second top-level `android { }` block is valid Gradle Kotlin DSL — Gradle +// A second top-level `android { }` block is valid Gradle Kotlin DSL, since Gradle // merges repeated extension-configuration blocks in one file, which is what // lets this stay a pure append, same as the desugaring block above. A second // top-level `plugins { }` block is NOT valid (Gradle allows exactly one per -// script), which is why the Crashlytics Gradle plugin registration — -// `flutterfire configure`'s own territory — is deliberately left alone here. +// script), which is why the Crashlytics Gradle plugin registration, +// `flutterfire configure`'s own territory, is deliberately left alone here. // -// `Properties` needs a real `import`, prepended separately below — a +// `Properties` needs a real `import`, prepended separately below. A // fully-qualified `java.util.Properties()` reference here was tried first, // to avoid needing to touch the top of the file at all, but fails to // resolve under AGP 9's Kotlin DSL script compilation @@ -431,12 +431,12 @@ android { function enableAndroidReleaseSigning(appRoot: string): void { const gradlePath = resolve(appRoot, "android/app/build.gradle.kts"); const gradle = readFileSync(gradlePath, "utf8"); - // Not `gradle.includes("signingConfigs")` — Flutter's own template already + // Not `gradle.includes("signingConfigs")`, because Flutter's own template already // contains that substring (`signingConfig = signingConfigs.getByName("debug")`), // so that check would always short-circuit and never append anything. if (gradle.includes("releaseKeyProperties")) return; // A pure append. This previously also PREPENDED `import java.util.Properties`, - // because Kotlin requires imports before every other top-level declaration — + // because Kotlin requires imports before every other top-level declaration, // which meant reaching into the very start of a file this scaffolder does // not own, the most position-dependent edit here and the one already broken // once by an AGP upgrade. Parsing `key.properties` with the Kotlin stdlib @@ -448,14 +448,14 @@ function enableAndroidReleaseSigning(appRoot: string): void { // `flutter_launcher_icons`/`flutter_native_splash` read these as plain // top-level pubspec keys, so appending is safe regardless of what // `flutter create` already wrote under `flutter:`. Deliberately omits -// `ios`/`macos` — `scaffoldGame` only creates `--platforms android,web`, and +// `ios`/`macos`, since `scaffoldGame` only creates `--platforms android,web`, and // `flutter_launcher_icons` errors if told to target a platform whose // directory doesn't exist. // The colours are the EigenInteractive palette (ink #1B1E24, paper #F4F1EA) because the // shipped placeholder art is the EigenInteractive mark: the adaptive foreground is the // reversed, light-on-dark variant, so an ink background is what makes it // legible. Rebranding a game means replacing the four PNGs *and* these -// colours together — see https://eigeninteractive.com/docs/ship-it/branding. +// colours together. See https://eigeninteractive.com/docs/ship-it/branding. const launcherIconsAndSplashConfig = ` flutter_launcher_icons: android: true @@ -518,7 +518,7 @@ export function addContinuousIntegration(options: AddContinuousIntegrationOption // The generated `contract` script embeds the manager the project was // scaffolded with (`cd server && pnpm run contract && …`), which is a more - // reliable signal than a lockfile — it is present before anything is + // reliable signal than a lockfile: it is present before anything is // installed, and it is what the project actually uses. const recorded = manifest.scripts?.contract?.includes("pnpm ") ? "pnpm" : manifest.scripts?.contract?.includes("npm ") ? "npm" : undefined; const manager = options.packageManager ?? recorded ?? detectPackageManager() ?? "pnpm"; @@ -569,8 +569,8 @@ export type FirebaseReadiness = { ready: true } | { ready: false; problems: Fire * `flutterfire` is missing costs nothing here and costs two minutes of Flutter * and pub at the far end, which is where the step actually runs. * - * Every problem, not the first one. The checks are independent — two CLIs from - * two ecosystems, plus a sign-in — so short-circuiting turns one setup into a + * Every problem, not the first one. The checks are independent (two CLIs from + * two ecosystems, plus a sign-in) so short-circuiting turns one setup into a * sequence of runs that each reveal the next missing piece. Reported in the * order they have to be fixed in: the CLI, then the sign-in it stores, then * the bridge that consumes both. @@ -588,7 +588,11 @@ export function firebaseReadiness(probe: Probe = probeCommand): FirebaseReadines } else { // Nothing to say about the sign-in: the CLI that would answer is the one // that is missing, and `firebase login` is what installing leads to anyway. - problems.push({ reason: "the `firebase` CLI is not installed", fix: "npm install -g firebase-tools" }); + // Google's own installer, which picks a standalone binary or npm to suit + // the machine. `npm install -g firebase-tools` also works and is what this + // used to print, but it needs a global npm prefix the reader may not have + // write access to, and it is not the install the Firebase docs lead with. + problems.push({ reason: "the `firebase` CLI is not installed", fix: "curl -sL https://firebase.tools | bash" }); } if (!probe("flutterfire", ["--version"]).ok) { @@ -603,7 +607,7 @@ export function firebaseReadiness(probe: Probe = probeCommand): FirebaseReadines * * `login:list --json` prints the stored refresh and access tokens along with * the account list. Read the shape, count the entries, and never surface the - * output — not in a warning, not in a captured stream, not on the failure + * output: not in a warning, not in a captured stream, not on the failure * path. That is why this returns a boolean rather than the response. */ function signedOut(probe: Probe): boolean { @@ -622,21 +626,21 @@ function signedOut(probe: Probe): boolean { * Whether the scaffold would land inside a repository that already exists. * * Asked of the nearest ancestor that exists, because the destination does not - * yet. The point is not to change what {@link scaffoldGame} does — it already - * declines to nest a repository — but to stop the CLI asking a question whose + * yet. The point is not to change what {@link scaffoldGame} does, which already + * declines to nest a repository, but to stop the CLI asking a question whose * answer cannot matter. */ /** * Why this destination cannot be scaffolded into, said as a whole message, or * `undefined` when there is nothing in the way. * - * A directory that exists and is *empty* is not in the way — people make one - * out of habit — so it is removed and rewritten rather than refused over + * A directory that exists and is *empty* is not in the way, since people make + * one out of habit, so it is removed and rewritten rather than refused over * nothing. Anything with contents in it is refused, and a cloned repository is * not an exception: `.git` is in the way like any other entry, and the message * names it. Publishing into one would mean parking `.git`, renaming staging * over the top and putting it back, with a failure in the middle able to - * orphan a remote — which is a lot of care to spend on a flow that has a + * orphan a remote, which is a lot of care to spend on a flow that has a * one-line alternative. * * Refused, and deliberately not offered as a "remove it and continue?" @@ -647,7 +651,7 @@ function signedOut(probe: Probe): boolean { * question this CLI asks is reversible. This one would not be. * * Exported so the CLI can ask it before the first question rather than after - * the last one — {@link scaffoldGame} asks it again, for callers that are not + * the last one. {@link scaffoldGame} asks it again, for callers that are not * the CLI. */ export function destinationProblem(directory: string): string | undefined { @@ -664,7 +668,7 @@ export function destinationProblem(directory: string): string | undefined { // directory that has already been scaffolded once. const shown = entries.slice(0, 3).sort().join(", "); const rest = entries.length > 3 ? `, and ${entries.length - 3} more` : ""; - return `${root} already exists, and is not empty — it holds ${shown}${rest}.\n\nScaffold somewhere else, or clear it out yourself. This will not delete a directory you already had.`; + return `${root} already exists, and is not empty: it holds ${shown}${rest}.\n\nScaffold somewhere else, or clear it out yourself. This will not delete a directory you already had.`; } export function insideWorkTree(directory: string, probe: Probe = probeCommand): boolean { @@ -680,19 +684,19 @@ export function insideWorkTree(directory: string, probe: Probe = probeCommand): /** * Runs the Flutter client's `configure_firebase` against the generated app. * - * The command owns everything about how Firebase is configured — which + * The command owns everything about how Firebase is configured: which * platforms, which CLIs must exist, whether anyone is signed in, and what to * do when no project has been chosen yet. This only decides *when*: before the * scaffold commit, so a configured project is committed configured. * - * Not fatal. Every failure here — a missing `flutterfire`, no Google login, a - * cancelled picker — leaves a complete and usable project that is exactly what + * Not fatal. Every failure here (a missing `flutterfire`, no Google login, a + * cancelled picker) leaves a complete and usable project that is exactly what * a scaffold without `--firebase` produces, so it warns with the command to * re-run and lets the commit happen. */ function configureFirebase(appRoot: string, project: boolean | string, run: Runner, reporter: Reporter): "configured" | "failed" { // FlutterFire asks before overwriting `firebase_options.dart`, and the - // scaffold has just written one — a throwing placeholder whose entire + // scaffold has just written one, a throwing placeholder whose entire // purpose is to be replaced by this step. Answering that is a question with // one right answer, so the placeholder is moved out of the way and the // question never gets asked. @@ -704,7 +708,7 @@ function configureFirebase(appRoot: string, project: boolean | string, run: Runn try { // `dart run` compiles the package executable on first use and says so. // Doing it against `--help`, which returns immediately, moves that noise - // into a step whose output can be captured — the real run cannot be, since + // into a step whose output can be captured; the real run cannot be, since // FlutterFire prompts through it. reporter.step("Preparing the Firebase configurator", () => run("dart", ["run", "eigen_flutter:configure_firebase", "--help"], appRoot)); reporter.handOver("Configuring Firebase", () => run("dart", ["run", "eigen_flutter:configure_firebase", ...(typeof project === "string" ? ["--project", project] : [])], appRoot)); @@ -719,7 +723,7 @@ function configureFirebase(appRoot: string, project: boolean | string, run: Runn if (existsSync(placeholder)) rmSync(parked, { force: true }); else renameSync(parked, placeholder); } - reporter.warn("Could not configure Firebase. The project is complete — run `firebase:configure` yourself, then commit what it writes."); + reporter.warn("Could not configure Firebase. The project is complete. Run `firebase:configure` yourself, then commit what it writes."); return "failed"; } } @@ -739,8 +743,8 @@ function configureFirebase(appRoot: string, project: boolean | string, run: Runn * took two minutes of Flutter and pub to produce. */ function initialiseRepository(root: string, name: string, run: Runner, reporter: Reporter): GitOutcome { - // Scaffolding inside an existing checkout — a monorepo, or a repository - // created ahead of time — is a legitimate thing to do, and a nested + // Scaffolding inside an existing checkout (a monorepo, or a repository + // created ahead of time) is a legitimate thing to do, and a nested // repository there is silent breakage: the outer `git add` records a gitlink // and the app's files never leave the machine. Asked directly rather than // through `run`, because it is a question, not a step. @@ -755,7 +759,7 @@ function initialiseRepository(root: string, name: string, run: Runner, reporter: run("git", ["init", "--quiet"], root); run("git", ["add", "--all"], root); } catch { - reporter.warn("Could not initialise a git repository. The project is complete — run `git init` yourself when ready."); + reporter.warn("Could not initialise a git repository. The project is complete. Run `git init` yourself when ready."); return "failed"; } @@ -824,7 +828,7 @@ export function scaffoldGame(options: ScaffoldOptions): ScaffoldResult { }); // Actually apply the icons rather than only configuring them. Both tools // write generated files (mipmaps, `web/icons/`, splash drawables and - // styles) that are committed, not built — leaving them unrun would ship + // styles) that are committed, not built; leaving them unrun would ship // Flutter's own blue logo until a game author happened to notice. reporter.step("Generating the icons and splash", () => { run("dart", ["run", "flutter_launcher_icons"], appRoot); @@ -838,7 +842,7 @@ export function scaffoldGame(options: ScaffoldOptions): ScaffoldResult { // workerd version that produced the runtime types. `wrangler` floats // on a caret and Cloudflare ships workerd about weekly, so the copy in // `templates/worker` is stale for every scaffold made more than a few - // days after it was last regenerated — and `wrangler dev` opens by + // days after it was last regenerated, and `wrangler dev` opens by // saying the types might be out of date. Regenerating against the // wrangler that was just installed, rather than the one this package // was built with, is what makes that true. It has to follow the server @@ -868,10 +872,10 @@ export function scaffoldGame(options: ScaffoldOptions): ScaffoldResult { // be configured nor a repository that failed to initialise is a reason to // delete two minutes of Flutter and pub. // - // Firebase first, so what it writes — `firebase.json`, + // Firebase first, so what it writes (`firebase.json`, // `android/app/google-services.json`, the real `firebase_options.dart` and // `web/firebase-config.js` in place of the throwing placeholders, and - // FlutterFire's two Gradle edits — lands in the scaffold commit rather than + // FlutterFire's two Gradle edits) lands in the scaffold commit rather than // arriving as the project's first diff. const firebase = options.firebase ? configureFirebase(resolve(root, "app"), options.firebase, run, reporter) : "skipped"; const git = (options.git ?? bootstrap) ? initialiseRepository(root, name, run, reporter) : "skipped"; diff --git a/packages/create-eigen-game/src/prompt.ts b/packages/create-eigen-game/src/prompt.ts index d3bc470..7889335 100644 --- a/packages/create-eigen-game/src/prompt.ts +++ b/packages/create-eigen-game/src/prompt.ts @@ -1,6 +1,6 @@ /** * Every question this CLI asks, in one module so the wording lives together - * and can be asserted — `cli.ts` is the bin entry and runs `main` on import, + * and can be asserted. `cli.ts` is the bin entry and runs `main` on import, * which makes anything defined there untestable. * * The organization prompt is the reason this drops to `@clack/core` rather @@ -34,7 +34,7 @@ export interface Io { /** * Two or more dot-separated Java identifiers. `flutter create --org` accepts * anything and defers the complaint to Gradle, which reports it as a manifest - * error in a generated file — so `com.example-games` costs a full scaffold and + * error in a generated file, so `com.example-games` costs a full scaffold and * a first build before anyone learns that a hyphen is not legal in a package * segment. */ @@ -103,7 +103,7 @@ export async function askForOrg(game: string, registering: boolean, io: Io = {}) const shorter = repeatsGameName(org, game) ? withoutGameName(org) : undefined; if (shorter !== undefined) { const shorten = await confirm({ - message: `That gives ${color.yellow(`${org}.${game}`)} — the game name twice. Use ${color.green(shorter)} instead?`, + message: `That gives ${color.yellow(`${org}.${game}`)}, the game name twice. Use ${color.green(shorter)} instead?`, initialValue: true, input, output, @@ -124,7 +124,7 @@ export async function askForOrg(game: string, registering: boolean, io: Io = {}) * * `hint` is written rather than passed to `confirm`, which has nowhere to put * it. It is the same dimmed line the organization prompt uses, and carries the - * thing that makes the default the default — a question whose answer is + * thing that makes the default the default: a question whose answer is * pre-chosen owes the reader why. */ async function ask(message: string, initialValue: boolean, hint: string, io: Io): Promise { diff --git a/packages/create-eigen-game/templates/app-overlay/android/app/proguard-rules.pro b/packages/create-eigen-game/templates/app-overlay/android/app/proguard-rules.pro index 4941453..1e1da52 100644 --- a/packages/create-eigen-game/templates/app-overlay/android/app/proguard-rules.pro +++ b/packages/create-eigen-game/templates/app-overlay/android/app/proguard-rules.pro @@ -4,6 +4,6 @@ # google_sign_in (Credential Manager), in_app_update, and in_app_review use # Google Play libraries that include their own consumer rules via Maven. # -# Only libraries confirmed to lack consumer rules are listed below — currently +# Only libraries confirmed to lack consumer rules are listed below: currently # none. Add a keep rule here only after confirming a dependency ships no # consumer rules of its own and is stripped by R8. diff --git a/packages/create-eigen-game/templates/app-overlay/lib/main.dart.template b/packages/create-eigen-game/templates/app-overlay/lib/main.dart.template index b72d0a9..554b720 100644 --- a/packages/create-eigen-game/templates/app-overlay/lib/main.dart.template +++ b/packages/create-eigen-game/templates/app-overlay/lib/main.dart.template @@ -17,7 +17,7 @@ Future main() async { // `seedColor` is left at its default, the EigenInteractive teal that the // placeholder icon and the worker's own pages are drawn in. To make the // app yours, import 'package:flutter/material.dart' and add - // `seedColor: Colors.deepPurple` (or any Color) — Material 3 rebuilds the + // `seedColor: Colors.deepPurple` (or any Color), and Material 3 rebuilds the // whole light and dark scheme from it. Do it alongside the launcher-icon // and splash colours in pubspec.yaml, which cannot read Dart: // https://eigeninteractive.com/docs/ship-it/branding diff --git a/packages/create-eigen-game/templates/ci/.github/workflows/checks.yml.template b/packages/create-eigen-game/templates/ci/.github/workflows/checks.yml.template index 3e51328..121faba 100644 --- a/packages/create-eigen-game/templates/ci/.github/workflows/checks.yml.template +++ b/packages/create-eigen-game/templates/ci/.github/workflows/checks.yml.template @@ -37,7 +37,7 @@ jobs: steps: - uses: actions/checkout@v7 - # `corepack enable`, not `pnpm/action-setup` — this project can be + # `corepack enable`, not `pnpm/action-setup`, because this project can be # scaffolded with either npm or pnpm (see the {{PACKAGE_MANAGER}} # commands below), and corepack puts both on PATH regardless of which # one a game author actually chose. @@ -72,9 +72,9 @@ jobs: node-version-file: .nvmrc cache: {{PACKAGE_MANAGER}} - # `contract:check` (below) round-trips through both halves — it + # `contract:check` (below) round-trips through both halves: it # invokes the server's own contract generator, then verifies the app's - # generated payloads still match — so the server's Node dependencies + # generated payloads still match, so the server's Node dependencies # have to exist here too, not just in the `server` job's own runner. - name: Install server dependencies working-directory: server @@ -110,7 +110,7 @@ jobs: # A VM widget test can pass while a dependency still imports dart:io or # calls an unsupported plugin. Compiling the real web entrypoint is - # what actually proves it ships — mirrors eigen-flutter's own `example` + # what actually proves it ships, mirroring eigen-flutter's own `example` # job, which does the same for the RPS reference app. # # No Android SDK step in this job, for the same reason diff --git a/packages/create-eigen-game/templates/ci/.github/workflows/release.yml.template b/packages/create-eigen-game/templates/ci/.github/workflows/release.yml.template index 511523a..9faae9d 100644 --- a/packages/create-eigen-game/templates/ci/.github/workflows/release.yml.template +++ b/packages/create-eigen-game/templates/ci/.github/workflows/release.yml.template @@ -2,13 +2,13 @@ name: Release # Ships a signed Android build on every push to main: # -# 1. checks — the exact PR gate, re-run against the merge commit. -# 2. build — a signed, obfuscated AAB. -# 3. deploy — fastlane uploads it to the Play Store internal track. +# 1. checks : the exact PR gate, re-run against the merge commit. +# 2. build : a signed, obfuscated AAB. +# 3. deploy : fastlane uploads it to the Play Store internal track. # # Worker deployment is NOT here, on purpose: it applies D1 migrations against # a real database, so it stays the deliberate, manual `{{PACKAGE_MANAGER}} run -# deploy` from a machine with `wrangler login` — the same reasoning the +# deploy` from a machine with `wrangler login`, the same reasoning the # engine's own CI uses for never deploying a Worker automatically. on: push: @@ -60,7 +60,7 @@ jobs: # The keystore and Play service account are the only things here that # are genuinely secret. `app-config.json`, `firebase_options.dart` and - # `google-services.json` are public app identifiers, not credentials — + # `google-services.json` are public app identifiers, not credentials, so # commit them for real once `firebase:configure` has been run, rather # than reconstructing them from secrets on every build. See # https://eigeninteractive.com/docs/ship-it/configure and diff --git a/packages/create-eigen-game/templates/project/README.md b/packages/create-eigen-game/templates/project/README.md index 74a5a55..5c99b17 100644 --- a/packages/create-eigen-game/templates/project/README.md +++ b/packages/create-eigen-game/templates/project/README.md @@ -47,7 +47,7 @@ whitespace everywhere, including the YAML that Biome does not format, and `.vscode/` recommends the Biome extension and turns formatting on save on for the languages it owns. Nothing generated depends on a comment surviving in a file a formatter or a tool may rewrite, so nothing is exempted from formatting. -Change any of it — it is your repository — but the generated files are written +Change any of it, since it is your repository, but the generated files are written to satisfy it as shipped. ## Flutter app @@ -87,7 +87,7 @@ Configure Android, Flutter Web, and the messaging service worker together: ``` Scaffolding does this for you, so a project created with the tooling in place -is already configured and this is the command for changing it afterwards — a +is already configured and this is the command for changing it afterwards: a different project, or picking up a configuration change. If it was skipped, the app throws `Firebase is not configured` the moment it launches; rules, fixtures and `wrangler dev` all run regardless. @@ -96,12 +96,16 @@ It drives two CLIs, which are separate global installs and neither of which comes with Flutter or Node: ```sh -npm install -g firebase-tools # the `firebase` CLI +curl -sL https://firebase.tools | bash # the `firebase` CLI dart pub global activate flutterfire_cli # the `flutterfire` CLI export PATH="$PATH":"$HOME/.pub-cache/bin" # `activate` does not do this firebase login # both CLIs share this ``` +The first line is Google's own installer; [the Firebase CLI +reference](https://firebase.google.com/docs/cli) has the other ways, including +Windows. + A run with no project chosen yet asks which to use, and can create one. Name it up front to skip the prompts, which is also how CI runs this: @@ -113,8 +117,8 @@ Later runs reuse the project recorded in `app/firebase.json`, so there is nothing to pass again. Add `--help` for the rest. It writes `app/firebase.json`, `app/android/app/google-services.json`, -`app/lib/firebase_options.dart` and `app/web/firebase-config.js` — the last two -replacing scaffolded placeholders that throw — and lets FlutterFire add the +`app/lib/firebase_options.dart` and `app/web/firebase-config.js` (the last two +replacing scaffolded placeholders that throw) and lets FlutterFire add the Google Services plugin to the two Android Gradle files. **Commit all of it.** Every one is a public app identifier rather than a credential, nothing here is git-ignored, and an Android or web build fails without them. The Firebase @@ -130,7 +134,7 @@ new". The command uses FlutterFire's selected Web app to generate both `lib/firebase_options.dart` and `web/firebase-config.js`; do not copy Firebase -identifiers by hand. Put the public VAPID key in `app-config.json` separately — +identifiers by hand. Put the public VAPID key in `app-config.json` separately; Firebase's app SDK configuration does not include that Web Push certificate. `eigen_flutter` bundles the pinned Cropper.js assets and loads them only when avatar editing starts, so the app needs no Cropper.js setup or runtime CDN. The @@ -163,8 +167,8 @@ with `wrangler secret put`; never commit the downloaded JSON or `.dev.vars`. ## Store release CI is **not** generated by default, because the release pipeline needs an -upload keystore and a Play service account that a new project does not have -— it would fail on every push until both exist. Add it when you are ready to +upload keystore and a Play service account that a new project does not have, +so it would fail on every push until both exist. Add it when you are ready to ship, from the project root: ```sh @@ -178,7 +182,7 @@ thing up front.) `release.yml` builds a signed, obfuscated Android App Bundle on every push to `main` and uploads it to the Play Store internal track via `app/fastlane`. `checks.yml` is the PR gate it -calls first — it needs no secrets, since `app-config.json`, +calls first, and it needs no secrets, since `app-config.json`, `firebase_options.dart` and `google-services.json` are public app identifiers, not credentials. Commit them for real once `{{PACKAGE_MANAGER}} run firebase:configure` has been run, rather than @@ -197,15 +201,15 @@ required: Per-app one-time setup: create an upload keystore, add the four signing secrets above, create the Google Play service account and add its JSON, and -do the **first** Play Console upload by hand to create the listing — -everything after that flows through `fastlane android internal` / +do the **first** Play Console upload by hand to create the listing. +Everything after that flows through `fastlane android internal` / `fastlane android production`. The Fastlane lanes upload the binary only (`skip_upload_metadata`/`images`/`screenshots`), so the store listing stays hand-maintained in the Console. ### Replace the placeholder branding -A fresh scaffold is already branded — with the **EigenInteractive mark**, not +A fresh scaffold is already branded, with the **EigenInteractive mark**, not this game's. The launcher icon, splash and web icons were generated at scaffold time so the app looks finished from the first run, but shipping them to a store would put @@ -220,11 +224,11 @@ the engine's mark on your listing. Replace them before a first release: The notification icon is the exception: `eigen_flutter` ships a default `ic_notification` drawable, so nothing is missing and nothing breaks if you leave it. To use your own, add -`android/app/src/main/res/drawable/ic_notification.xml` — a monochrome +`android/app/src/main/res/drawable/ic_notification.xml`, a monochrome ``, not an SVG. Android resource merging gives your app precedence over the package, so there is nothing to delete or override explicitly. -Also update the palette in `pubspec.yaml` — `adaptive_icon_background` and the +Also update the palette in `pubspec.yaml`. `adaptive_icon_background` and the `flutter_native_splash` colours are the EigenInteractive ink/paper pair, which will not suit different artwork. Then regenerate: @@ -245,5 +249,5 @@ cider bump patch # 1.0.0 -> 1.0.1 ``` The build number is set from `github.run_number` in CI and is not managed by -hand. Worker deployment is unaffected by any of this — it stays the manual +hand. Worker deployment is unaffected by any of this; it stays the manual `{{PACKAGE_MANAGER}} run deploy` described above. diff --git a/packages/create-eigen-game/templates/worker/README.md b/packages/create-eigen-game/templates/worker/README.md index 6df0c1e..c0f18be 100644 --- a/packages/create-eigen-game/templates/worker/README.md +++ b/packages/create-eigen-game/templates/worker/README.md @@ -63,8 +63,8 @@ fetches its platform binary, and `workerd` is the runtime `vitest` and the file entirely, so the scaffold is correct under both. `minimumReleaseAgeExclude` appears on its own. pnpm 11 will not install a -version published less than a day ago — a quarantine against a compromised -publish — and the engine packages are subject to it like any other dependency. +version published less than a day ago, a quarantine against a compromised +publish, and the engine packages are subject to it like any other dependency. When a range can only be satisfied by a release younger than that, as `^0.3.0` is on the day 0.3.0 ships, pnpm installs it anyway and records the exact version it allowed. Each entry names one version, so the exemption expires with diff --git a/packages/create-eigen-game/templates/worker/src/index.ts b/packages/create-eigen-game/templates/worker/src/index.ts index cf54a08..9eac509 100644 --- a/packages/create-eigen-game/templates/worker/src/index.ts +++ b/packages/create-eigen-game/templates/worker/src/index.ts @@ -19,7 +19,7 @@ export default createEngine({ // tagline, screenshots and structured data, and /terms, /privacy and // /delete-account start serving the engine's default documents with your // details in them. The app stores require all three, so this is not optional - // for long — but read what they say before you publish, because you are the + // for long, but read what they say before you publish, because you are the // one on the hook for it. https://eigeninteractive.com/docs/ship-it/branding // // site: { diff --git a/packages/create-eigen-game/test/engine-range.spec.ts b/packages/create-eigen-game/test/engine-range.spec.ts index f3f45d3..39d935a 100644 --- a/packages/create-eigen-game/test/engine-range.spec.ts +++ b/packages/create-eigen-game/test/engine-range.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { engineRange } from "../src/engine-range.js"; -// The published branch — a manifest whose `workspace:*` has already been -// rewritten by pnpm — never executes anywhere else. `scaffold.spec.ts` only +// The published branch, a manifest whose `workspace:*` has already been +// rewritten by pnpm, never executes anywhere else. `scaffold.spec.ts` only // ever runs the workspace branch, and by the time the other one is wrong the // package is on npm. describe("engineRange", () => { diff --git a/packages/create-eigen-game/test/prompt.spec.ts b/packages/create-eigen-game/test/prompt.spec.ts index baad176..439b23f 100644 --- a/packages/create-eigen-game/test/prompt.spec.ts +++ b/packages/create-eigen-game/test/prompt.spec.ts @@ -32,7 +32,7 @@ describe("the organization answer", () => { expect(ORG.test(value)).toBe(true); } // A hyphen is not legal in a Java package segment, and `flutter create` - // accepts it anyway — the complaint arrives from Gradle, in a generated + // accepts it anyway; the complaint arrives from Gradle, in a generated // manifest, after a full scaffold and a first build. for (const value of ["com.example-games", "com", "1com.example", "com..example", ""]) { expect(ORG.test(value)).toBe(false); @@ -58,7 +58,7 @@ describe("the organization answer", () => { expect(drawn).toContain("com.acme"); expect(drawn).toContain(".chess"); expect(drawn).toContain("Google Play makes permanent"); - // Only said when it is true — this run is not configuring Firebase. + // Only said when it is true, and this run is not configuring Firebase. expect(drawn).not.toContain("Firebase project you pick next"); }); @@ -86,7 +86,7 @@ describe("the organization answer", () => { }); it("declines to shorten an answer into something that is not an organization", () => { - // `com.chess` would leave a single segment, which is not a package name — + // `com.chess` would leave a single segment, which is not a package name, // so there is nothing to offer, and the answer stands as given. expect(repeatsGameName("com.chess", "chess")).toBe(true); expect(withoutGameName("com.chess")).toBeUndefined(); @@ -102,7 +102,7 @@ describe("the yes/no questions", () => { const { answer, drawn } = await drive(askToScaffoldWithoutFirebase, accept); // The tools are two commands away, and the alternative is an app that - // throws at launch — so the default is to stop, and the reason is on + // throws at launch, so the default is to stop, and the reason is on // screen next to it rather than in the message that follows. expect(answer).toBe(false); expect(drawn).toContain("firebase:configure"); diff --git a/packages/create-eigen-game/test/scaffold.spec.ts b/packages/create-eigen-game/test/scaffold.spec.ts index 38e2b6c..ffef22f 100644 --- a/packages/create-eigen-game/test/scaffold.spec.ts +++ b/packages/create-eigen-game/test/scaffold.spec.ts @@ -14,7 +14,7 @@ const temporaryParent = (): string => mkdtempSync(resolve(tmpdir(), "create-eige // its templates were compiled against. A literal would need editing on every // release, and would pass while the template silently pinned the previous one. // -// Note this is the ENGINE's version, no longer the scaffolder's — the two are +// Note this is the ENGINE's version, no longer the scaffolder's. The two are // free to differ now that `create-eigen-game` has left the `fixed` group, and // asserting against the scaffolder's own version would quietly stop testing // anything the first time they do. @@ -121,7 +121,7 @@ describe("scaffoldGame", () => { expect(devVars).not.toContain("WEB_APP_ORIGIN="); expect(devVars).toContain("FIREBASE_PRIVATE_KEY="); - // Native release plumbing — rendered unconditionally by the app-overlay + // Native release plumbing, rendered unconditionally by the app-overlay // tree, so present even with `bootstrap: false` (unlike the Gradle/pubspec // appends below, which need a real `flutter create` output to edit). expect(readFileSync(resolve(root, "app/fastlane/Appfile"), "utf8")).toContain('package_name("com.example.my_game")'); @@ -136,7 +136,7 @@ describe("scaffoldGame", () => { // Binary templates must arrive byte-identical. They did not: renderTree // read every file as UTF-8 and wrote it back, so any byte that is not // valid UTF-8 became U+FFFD. A 1024px PNG grew by tens of kilobytes of - // replacement characters and stopped decoding — `flutter_launcher_icons` + // replacement characters and stopped decoding, so `flutter_launcher_icons` // failed with NoDecoderForImageFormatException. Compare the bytes, not // just the length, and not merely "the file exists". for (const asset of ["icon.png", "icon_foreground.png", "splash.png", "splash_dark.png"]) { @@ -195,13 +195,13 @@ describe("scaffoldGame", () => { // dependencies (`dev:` prefix) while the engine/Firebase packages above // are not. expect(run).toHaveBeenCalledWith("flutter", ["pub", "add", "dev:flutter_launcher_icons", "dev:flutter_native_splash"], expect.stringMatching(/\/app$/)); - // Configuring the icon tools is not enough — both write committed files, + // Configuring the icon tools is not enough: both write committed files, // so they have to actually run or the app ships Flutter's own blue logo. expect(run).toHaveBeenCalledWith("dart", ["run", "flutter_launcher_icons"], expect.stringMatching(/\/app$/)); expect(run).toHaveBeenCalledWith("dart", ["run", "flutter_native_splash:create"], expect.stringMatching(/\/app$/)); expect(run).toHaveBeenCalledWith("pnpm", ["install"], expect.stringMatching(/\/server$/)); // Regenerated against the wrangler the install above resolved, not the one - // the committed template was generated with — otherwise `wrangler dev` + // the committed template was generated with, or else `wrangler dev` // opens by reporting types that are only stale in their workerd stamp. expect(run).toHaveBeenCalledWith("pnpm", ["run", "cf-typegen"], expect.stringMatching(/\/server$/)); // The root install is Biome's, and is what makes `pnpm lint` work from the @@ -224,13 +224,13 @@ describe("scaffoldGame", () => { const appPubspec = readFileSync(resolve(root, "app/pubspec.yaml"), "utf8"); expect(appPubspec).toContain("flutter_launcher_icons:"); expect(appPubspec).toContain("flutter_native_splash:"); - // `scaffoldGame` only creates `--platforms android,web` — `ios`/`macos` + // `scaffoldGame` only creates `--platforms android,web`, so `ios`/`macos` // keys would make `flutter_launcher_icons` error on a platform directory // that doesn't exist. expect(appPubspec).not.toContain("ios:"); expect(appPubspec).not.toContain("macos:"); // The shipped adaptive foreground is the reversed, light-on-dark mark, so - // the background behind it has to be ink — the previous "#FFFFFF" default + // the background behind it has to be ink; the previous "#FFFFFF" default // would render it invisible. expect(appPubspec).toContain('adaptive_icon_background: "#1B1E24"'); expect(appPubspec).toContain("image_dark: assets/icon/splash_dark.png"); @@ -420,7 +420,7 @@ describe("firebaseReadiness", () => { // Asked here rather than left to `configure_firebase`, which runs at the // far end of two minutes of Flutter and pub. expect(firebaseReadiness(machine("flutterfire"))).toEqual({ ready: false, problems: [{ reason: "the `flutterfire` CLI is not installed", fix: "dart pub global activate flutterfire_cli" }] }); - expect(firebaseReadiness(machine("firebase"))).toEqual({ ready: false, problems: [{ reason: "the `firebase` CLI is not installed", fix: "npm install -g firebase-tools" }] }); + expect(firebaseReadiness(machine("firebase"))).toEqual({ ready: false, problems: [{ reason: "the `firebase` CLI is not installed", fix: "curl -sL https://firebase.tools | bash" }] }); }); it("catches a machine that has the tools but is signed out", () => { @@ -436,7 +436,7 @@ describe("firebaseReadiness", () => { }); it("says nothing about the sign-in when the CLI that would answer is missing", () => { - // `login:list` cannot be run, so there is no evidence either way — and + // `login:list` cannot be run, so there is no evidence either way, and // installing firebase-tools leads to `firebase login` regardless. expect(reasons(machine("firebase", '{"status":"success","result":[]}'))).toEqual(["the `firebase` CLI is not installed"]); }); @@ -472,7 +472,7 @@ describe("destinationProblem", () => { const problem = destinationProblem(root); // Three of them, because that usually settles whether this was a mistyped - // path or a directory that has already been scaffolded once — where "not + // path or a directory that has already been scaffolded once, where "not // empty" only invites an `ls`. expect(problem).toContain("README.md, app, notes.txt"); expect(problem).toContain("and 1 more"); @@ -500,7 +500,7 @@ describe("insideWorkTree", () => { }; expect(insideWorkTree(resolve(parent, "not-created-yet/nor-this"), probe)).toBe(true); - // `git -C` rather than a cwd, because `Probe` has nowhere to put one — and + // `git -C` rather than a cwd, because `Probe` has nowhere to put one, and // the directory it names has to be one that already exists. expect(asked).toEqual([["git", "-C", parent, "rev-parse", "--is-inside-work-tree"]]); }); @@ -566,14 +566,14 @@ describe("repository initialisation", () => { }); // Here rather than with the other template assertions because it needs a - // commit to be meaningful — an uncommitted tree reports every file — and the + // commit to be meaningful (an uncommitted tree reports every file) and the // committer identity this block stubs is what a CI image does not have. it("ignores what a root script leaves behind", () => { const root = resolve(temporaryParent(), "my-game"); scaffoldGame({ directory: root, bootstrap: false, git: true }); - // Installing at the root — which the scaffolder does, for Biome — leaves a + // Installing at the root, which the scaffolder does for Biome, leaves a // node_modules/ that must not be committed, beside a lockfile that must. mkdirSync(resolve(root, "node_modules"), { recursive: true }); writeFileSync(resolve(root, "node_modules/.modules.yaml"), ""); @@ -680,7 +680,7 @@ describe("repository initialisation", () => { run: (command, args, cwd) => { if (command !== "dart" || args.includes("--help")) return; // FlutterFire writes this, then the service worker configuration is - // derived from it — so the second half can fail with the first half + // derived from it, so the second half can fail with the first half // done, and a real file is worth more than the placeholder. writeFileSync(resolve(cwd, "lib/firebase_options.dart"), "// generated\n"); throw new Error("firebase: HTTP 503"); @@ -759,7 +759,7 @@ describe("repository initialisation", () => { describe("template rendering", () => { it("pins which files are copied verbatim rather than rendered", () => { - // The rule — "render it if it decodes as UTF-8" — is otherwise invisible, + // The rule, "render it if it decodes as UTF-8", is otherwise invisible, // which is how the old traversal mangled every binary for so long without // anyone noticing. This states the outcome for the whole tree, so adding // an asset that lands on the copy-verbatim side shows up as a diff to @@ -781,9 +781,9 @@ describe("template rendering", () => { it("keeps each packaged .gitignore identical to the one it stands in for", () => { // npm strips files named `.gitignore` from tarballs, so every tree that // ships one keeps a second copy under `scaffold/` that the published - // scaffolder writes instead. Two copies of the same file drift silently — - // this one already had, losing `app/pubspec_overrides.yaml` from the - // Git-rendered side — and only the `scaffold/` copy reaches a real project. + // scaffolder writes instead. Two copies of the same file drift silently. + // This one already had, losing `app/pubspec_overrides.yaml` from the + // Git-rendered side, and only the `scaffold/` copy reaches a real project. const templates = resolve(import.meta.dirname, "../templates"); for (const [tree, packaged] of [ ["project", "scaffold/project.gitignore"], @@ -794,7 +794,7 @@ describe("template rendering", () => { }); it("ships the generated Biome config under a name Biome will not load here", () => { - // Biome refuses to run at all when it finds a nested root configuration — + // Biome refuses to run at all when it finds a nested root configuration: // both the CLI and the editor's language server, which fails with an empty // error message and simply stops working on the whole repository. A // template named `biome.json` is exactly that, and the scaffolder already @@ -819,7 +819,7 @@ describe("template rendering", () => { // did not write, and their first diff is noise. // // Run from inside the generated project so Biome resolves the config it - // was given, not this workspace's — they differ, and the generated one is + // was given, not this workspace's. They differ, and the generated one is // the only one that matters here. The binary comes from this workspace // because `bootstrap: false` installs nothing. const biome = resolve(import.meta.dirname, "../../../node_modules/.bin/biome"); @@ -865,7 +865,7 @@ describe("continuous integration", () => { const result = addContinuousIntegration({ directory: root }); expect(result.files).toEqual(workflows); - // Read back from the project rather than passed in — a project scaffolded + // Read back from the project rather than passed in: a project scaffolded // with pnpm must not silently gain npm workflows. expect(readFileSync(resolve(root, workflows[0]), "utf8")).toContain("pnpm install"); }); diff --git a/packages/create-eigen-game/test/summary.spec.ts b/packages/create-eigen-game/test/summary.spec.ts index a2c74d5..766ec03 100644 --- a/packages/create-eigen-game/test/summary.spec.ts +++ b/packages/create-eigen-game/test/summary.spec.ts @@ -40,7 +40,7 @@ describe("summarise", () => { it("keeps naming the Firebase step until it has been done", () => { // The app throws `Firebase is not configured` at launch until this has run - // once, so it belongs with the other things to run next — and stops + // once, so it belongs with the other things to run next, and stops // belonging there the moment the scaffold did it. expect(summarise(result("committed"), "pnpm", false).next).toContain("pnpm firebase:configure"); expect(summarise(result("committed", "failed"), "pnpm", false).next).toContain("pnpm firebase:configure"); diff --git a/packages/kernel/CHANGELOG.md b/packages/kernel/CHANGELOG.md index 4a59762..044669f 100644 --- a/packages/kernel/CHANGELOG.md +++ b/packages/kernel/CHANGELOG.md @@ -27,8 +27,8 @@ - [#19](https://github.com/eigeninteractive/eigen-server/pull/19) [`993883f`](https://github.com/eigeninteractive/eigen-server/commit/993883f8bb71ebfb36708e2badd7ae98859b7094) Thanks [@seenu-k](https://github.com/seenu-k)! - Use **EigenInteractive** as the product name throughout, matching the domain, the npm scope and the GitHub organization. Package descriptions, READMEs and - the OpenAPI document title change; every identifier — `@eigeninteractive/*`, - `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header — is + the OpenAPI document title change; every identifier (`@eigeninteractive/*`, + `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header) is untouched. - Updated dependencies [[`993883f`](https://github.com/eigeninteractive/eigen-server/commit/993883f8bb71ebfb36708e2badd7ae98859b7094)]: - @eigeninteractive/rules@0.2.1 @@ -46,7 +46,7 @@ ## 0.1.0 -Initial release. The Eigen engine — a server-authoritative engine for turn-based +Initial release. The Eigen engine, a server-authoritative engine for turn-based multiplayer games on Cloudflare Workers. Documentation: diff --git a/packages/kernel/src/commit.ts b/packages/kernel/src/commit.ts index 5c580ce..cee7355 100644 --- a/packages/kernel/src/commit.ts +++ b/packages/kernel/src/commit.ts @@ -1,5 +1,5 @@ /** - * `commit()` — the pure, serialized heart of the engine. One state transition + * `commit()` is the pure, serialized heart of the engine. One state transition * in, one {@link CommitPlan} * (or {@link Rejected}) out. Zero I/O, zero platform imports, injected clock: * the host (a Durable Object) loads the snapshot, calls this, and applies the @@ -7,7 +7,7 @@ * * The pipeline is: status/pending/deadline guards, bank deduction, timeout * abstain-and-zero, then the deadline precedence chain. Two rules are worth - * knowing before reading further — the grace window is the single + * knowing before reading further: the grace window is the single * {@link DEADLINE_GRACE_MS} constant applied in one place, and a stale * `expectedVersion` is arbitrated by the same-view rule rather than flatly * rejected. Versions stay strictly serial: the same-view rule governs @@ -28,7 +28,7 @@ import { computeNextDeadline, DEADLINE_GRACE_MS, deadlineExpired, deductBank } f export type GameStatus = "waiting" | "ready" | "active" | "finished" | "aborted"; -/** The game's standing configuration — the DO `meta` snapshot. */ +/** The game's standing configuration: the DO `meta` snapshot. */ export interface GameRow { status: GameStatus; schemaVersion: number; @@ -51,7 +51,7 @@ export interface Seat { type: "human" | "bot"; } -/** The latest committed transition — state plus the engine-owned clocks. All +/** The latest committed transition: state plus the engine-owned clocks. All * instants are epoch milliseconds. */ export interface StateRow { version: number; @@ -68,7 +68,7 @@ export interface StateRow { // ── Intents ─────────────────────────────────────────────────────────────────── -/** What the host asks the kernel to do — the kernel-facing half of a +/** What the host asks the kernel to do: the kernel-facing half of a * `Command` (authorization already happened at the edge; dedupe at the DO). */ export type Intent = | { @@ -84,7 +84,7 @@ export type Intent = * current version in the common case; a *lower* value is arbitrated by * the same-view rule. */ expectedVersion: number; - /** The raw move payload — parsed against the unit's action schema. */ + /** The raw move payload, parsed against the unit's action schema. */ data: unknown; actor: "user" | "bot"; } @@ -100,7 +100,7 @@ export interface CommitInput { state: StateRow | null; roster: Seat[]; intent: Intent; - /** The commit instant (epoch ms) — sampled once by the host, never read + /** The commit instant (epoch ms), sampled once by the host and never read * here. */ now: number; /** The version unit for the game's `schemaVersion`, already resolved by @@ -110,7 +110,7 @@ export interface CommitInput { * Same-view material for a stale game action: the acting seat's stored * frames at `expectedVersion` and at the current version. Only consulted * when `intent.expectedVersion < state.version`; if absent (or either - * frame is missing — e.g. compacted away), the stale action is rejected + * frame is missing, e.g. compacted away), the stale action is rejected * conservatively. */ staleViews?: { @@ -122,17 +122,17 @@ export interface CommitInput { // ── Plan (what the host applies) ────────────────────────────────────────────── /** The action-log entry for a transition. Null only for the start transition - * (v0), which no action produced. `playerIndex` is the performer's seat — + * (v0), which no action produced. `playerIndex` is the performer's seat, and * null for identity-less system actions (timeout, auto-forfeit). * * The `ratings` variant is engine-owned, never produced by `commit()`: the * host appends it as the post-finish ratings transition (step 3) once - * the D1 apply returns the deltas. Game hooks never see it — its data is the + * the D1 apply returns the deltas. Game hooks never see it; its data is the * engine's, not the game's opaque payload. */ export type TransitionAction = { type: "user" | "bot"; kind: "game"; data: JsonObject; playerIndex: number } | { type: ActionType; kind: "lifecycle"; data: LifecycleAction; playerIndex: number | null } | { type: "system"; kind: "ratings"; data: { deltas: RatingDelta[] }; playerIndex: null }; /** A push/wake the host should attempt post-commit (single attempt + error - * log — no retry machinery in v1). The kernel names seats; the host resolves + * log, with no retry machinery in v1). The kernel names seats; the host resolves * delivery (FCM targets, bot webhook vs local bot). */ export type Effect = { kind: "wakeBot"; seat: number; botId: string } | { kind: "notifyTurn"; seat: number; userId: string } | { kind: "notifyFinished"; userIds: string[] }; @@ -140,7 +140,7 @@ export interface CommitPlan { /** The next transition row, already versioned (`v+1`, or 0 for start). */ nextState: StateRow; action: TransitionAction | null; - /** Per-seat projected frames (identified seats only) — persisted with the + /** Per-seat projected frames (identified seats only), persisted with the * transition, fanned out over sockets. No raw state escapes the kernel. */ frames: ObservationFrame[]; /** Per-seat results when this transition ends the game, else null. @@ -150,8 +150,8 @@ export interface CommitPlan { * computes them inside the rating CAS via `computeRatings` (ratings.ts) and * the host delivers them as a follow-up versioned ratings transition. */ outcomes: OutcomeEntry[] | null; - /** The instant the DO must arm its alarm at — the true deadline plus the - * grace window — or null to clear it. */ + /** The instant the DO must arm its alarm at, the true deadline plus the + * grace window, or null to clear it. */ alarm: number | null; effects: Effect[]; } @@ -218,7 +218,7 @@ function commitAction(input: CommitInput, intent: Extract state.version) { - // The client claims a version the game hasn't reached — a protocol bug, + // The client claims a version the game hasn't reached, which is a protocol bug, // but the client-facing remedy is the same resync as any version miss. return reject("stateUpdated", `Expected version ${intent.expectedVersion} is ahead of the game ` + `(current ${state.version})`); } @@ -228,7 +228,7 @@ function commitAction(input: CommitInput, intent: Extract, index: number) { const posterior = posteriors.get(index); if (posterior === undefined) { @@ -152,11 +152,11 @@ function singleSeatUpdate(seat: PlayerInput, fieldPosteriors: Map(schema: StandardSchemaV1, value: unknown): StandardSchemaV1.Result { const result = schema["~standard"].validate(value); if (result instanceof Promise) { - throw new GameBugError("game schema validated asynchronously — engine schemas must be sync"); + throw new GameBugError("game schema validated asynchronously; engine schemas must be sync"); } return result; } /** Parse a client-submitted payload (an action's `data`, a create request's * `config`) through its schema. Failure is the caller's fault. Returns the - * parsed value, so what flows onward — into hooks and the action log — is the + * parsed value, so what flows onward, into hooks and the action log, is the * sanitized shape (unknown keys stripped, defaults applied), never the raw * submission. */ export function parseClientPayload(schema: StandardSchemaV1, value: unknown, what: string): ParseResult { @@ -47,7 +47,7 @@ export function parseClientPayload(schema: StandardSchemaV1, valu /** Parse a stored payload (a state row, the game's config) through its * schema. Failure means corrupted data or a schema that no longer matches - * what this version historically wrote — an engine-side bug, thrown. */ + * what this version historically wrote: an engine-side bug, thrown. */ export function parseStoredPayload(schema: StandardSchemaV1, value: unknown, what: string, schemaVersion: number): T { const result = validateSync(schema, value); if (result.issues) { diff --git a/packages/kernel/src/timing.ts b/packages/kernel/src/timing.ts index ac34320..d52f2ce 100644 --- a/packages/kernel/src/timing.ts +++ b/packages/kernel/src/timing.ts @@ -1,19 +1,19 @@ /** - * Pure timing math — deadlines, the grace window, and the budget bank. All + * Pure timing math: deadlines, the grace window, and the budget bank. All * instants are epoch milliseconds and always injected: the kernel never reads * a clock. * * The grace window compensates network physics (server time is measured at * request arrival, not at the tap). It is ONE constant with exactly two call * sites: the kernel accepts an action while `now <= deadline + grace`, and the - * DO arms its alarm at `deadline + grace`. Whichever arrives first — the latent - * action or the alarm — commits; the loser sees already-advanced state and + * DO arms its alarm at `deadline + grace`. Whichever arrives first, the latent + * action or the alarm, commits; the loser sees already-advanced state and * no-ops. * - * Budget-mode fairness: the grace forgives *acceptance*, not *time charged* — + * Budget-mode fairness: the grace forgives *acceptance*, not *time charged*; * the elapsed bank deduction (floored at 0) still runs. So does flag-fall: a * player whose bank hits 0 can overrun by up to the grace and still have that - * final move counted (bounded, self-limiting — accepted behaviour). + * final move counted (bounded and self-limiting: accepted behaviour). */ import { GameBugError } from "./errors.js"; @@ -50,7 +50,7 @@ export interface NextDeadline { turnStartedAt: number | null; } -/** Computes the deadline and `turnStartedAt` for the next action — the +/** Computes the deadline and `turnStartedAt` for the next action: the * precedence chain used by start and every commit mode. Pass * `gameOver = true` when the transition ends the game. * @@ -60,7 +60,7 @@ export interface NextDeadline { * 4. per-action mode → now + configured `turnSeconds` * 5. untimed → both null * - * Budget mode allows at most one pending seat — enforced at the source by + * Budget mode allows at most one pending seat, enforced at the source by * `assertBudgetPending` before any envelope reaches this; the MIN remains the * graceful-degradation safeguard should a multi-pending state arrive anyway. */ @@ -92,7 +92,7 @@ export function computeNextDeadline(input: { return { deadline: now + minBank, turnStartedAt }; } if (budgetSeconds !== null) { - // Budget mode with an empty pending set and no outcome — nothing to time. + // Budget mode with an empty pending set and no outcome: nothing to time. return { deadline: null, turnStartedAt }; } if (turnSeconds !== null) { diff --git a/packages/kernel/test/commit.spec.ts b/packages/kernel/test/commit.spec.ts index dda2cdd..1edc519 100644 --- a/packages/kernel/test/commit.spec.ts +++ b/packages/kernel/test/commit.spec.ts @@ -19,7 +19,7 @@ function input(overrides: Partial = {}): CommitInput { function expectPlan(result: ReturnType) { if (isRejected(result)) { - throw new Error(`expected a plan, got rejection: ${result.code} — ${result.message}`); + throw new Error(`expected a plan, got rejection: ${result.code}: ${result.message}`); } return result; } @@ -71,7 +71,7 @@ describe("commit: start", () => { expect(plan.alarm).toBe(NOW + 60_000 + DEADLINE_GRACE_MS); }); - it("throws on an empty seed — a host bug", () => { + it("throws on an empty seed, which is a host bug", () => { expect(() => commit({ ...startInput("ready"), intent: { kind: "start", seed: "" } })).toThrow(GameBugError); }); }); @@ -179,7 +179,7 @@ describe("commit: same-view rule", () => { const view = { data: { count: 4 }, pendingPlayers: [0] }; - it("accepts a stale action when the seat's view is unchanged — as the next serial version", () => { + it("accepts a stale action when the seat's view is unchanged, as the next serial version", () => { const plan = expectPlan(commit(stale({ expected: view, current: { ...view } }))); expect(plan.nextState.version).toBe(5); }); @@ -372,7 +372,7 @@ describe("commit: forfeit / autoForfeit", () => { }); describe("commit: rated finish", () => { - // Rating deltas are not the kernel's to compute at commit time — they need + // Rating deltas are not the kernel's to compute at commit time; they need // global priors (D1-domain data). The plan carries outcomes only; the D1 // applier computes deltas inside the rating CAS and the host delivers them // as a follow-up versioned ratings transition. diff --git a/packages/kernel/test/helpers.ts b/packages/kernel/test/helpers.ts index ccfa671..fe5897c 100644 --- a/packages/kernel/test/helpers.ts +++ b/packages/kernel/test/helpers.ts @@ -1,5 +1,5 @@ /** - * Test fixtures: a hand-rolled Standard Schema (no schema library — the + * Test fixtures: a hand-rolled Standard Schema (no schema library, because the * kernel only speaks the spec) and a tiny deterministic turn game exercising * every commit path: sequential turns, a per-action override, outcomes, * illegal moves. @@ -24,7 +24,7 @@ export function schemaOf(check: (value: unknown) => boolean): GamePayloadSche }; } -/** A Standard Schema whose validate returns a Promise — for the sync-only +/** A Standard Schema whose validate returns a Promise, for the sync-only * enforcement test. */ export function asyncSchema(): GamePayloadSchema { return { diff --git a/packages/kernel/test/timing.spec.ts b/packages/kernel/test/timing.spec.ts index 44e9e06..6e15956 100644 --- a/packages/kernel/test/timing.spec.ts +++ b/packages/kernel/test/timing.spec.ts @@ -46,7 +46,7 @@ describe("deductBank", () => { expect(times[0]).toBe(60_000); }); - it("throws on a null turnStartedAt — a game bug", () => { + it("throws on a null turnStartedAt, which is a game bug", () => { expect(() => deductBank([60_000], 0, NOW, null, null)).toThrow(GameBugError); }); diff --git a/packages/rules/CHANGELOG.md b/packages/rules/CHANGELOG.md index 2194b24..d45ecc5 100644 --- a/packages/rules/CHANGELOG.md +++ b/packages/rules/CHANGELOG.md @@ -12,8 +12,8 @@ - [#19](https://github.com/eigeninteractive/eigen-server/pull/19) [`993883f`](https://github.com/eigeninteractive/eigen-server/commit/993883f8bb71ebfb36708e2badd7ae98859b7094) Thanks [@seenu-k](https://github.com/seenu-k)! - Use **EigenInteractive** as the product name throughout, matching the domain, the npm scope and the GitHub organization. Package descriptions, READMEs and - the OpenAPI document title change; every identifier — `@eigeninteractive/*`, - `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header — is + the OpenAPI document title change; every identifier (`@eigeninteractive/*`, + `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header) is untouched. ## 0.2.0 @@ -24,7 +24,7 @@ ## 0.1.0 -Initial release. The Eigen engine — a server-authoritative engine for turn-based +Initial release. The Eigen engine, a server-authoritative engine for turn-based multiplayer games on Cloudflare Workers. Documentation: diff --git a/packages/rules/README.md b/packages/rules/README.md index 627874e..eb1dfaa 100644 --- a/packages/rules/README.md +++ b/packages/rules/README.md @@ -1,7 +1,7 @@ # @eigeninteractive/rules The game-rules contract for the -[EigenInteractive engine](https://eigeninteractive.com) — a server-authoritative +[EigenInteractive engine](https://eigeninteractive.com), a server-authoritative engine for turn-based multiplayer games. This package is **pure types plus two helpers**, with zero engine dependencies. diff --git a/packages/rules/src/contract.ts b/packages/rules/src/contract.ts index 9b6d855..6ea064c 100644 --- a/packages/rules/src/contract.ts +++ b/packages/rules/src/contract.ts @@ -1,5 +1,5 @@ /** - * The implementor contract — everything a game author must understand, and + * The implementor contract: everything a game author must understand, and * nothing else. Enums are plain unions, schema slots are typed against * Standard Schema (bring Zod, Valibot, ArkType…), and randomness comes from * the engine-owned {@link Rng} interface. @@ -7,7 +7,7 @@ * The Dart client has a same-named `GameRules` twin per version (payload * codec, `isValidAction`/`previewAction`, rendering, and the * `ratingPool`/`botSeatable` twins); shared JSON fixtures keep the two in - * sync — see `@eigeninteractive/testkit`. + * sync. See `@eigeninteractive/testkit`. */ import type { JsonObject } from "./json.js"; @@ -19,7 +19,7 @@ import type { GamePayloadSchema } from "./standard-json-schema.js"; * hook. `forfeit` is a voluntary resign; `autoForfeit` the engine-driven * variant (account-deletion purge); `timeout` is the clock. The two forfeits * share a shape (both target `data.playerIndex`) and most games resolve them - * identically — but the hook receives the real trigger, so a game may choose + * identically, but the hook receives the real trigger, so a game may choose * different consequences (e.g. a draw rather than a loss when the seat was * purged). */ export type LifecycleType = "timeout" | "forfeit" | "autoForfeit"; @@ -48,7 +48,7 @@ export type ActionKind = "game" | "lifecycle"; * game's stored base seed and the state version the envelope commits as. Draw * freely (`next()` → float in `[0, 1)`, stateful within the invocation); * replaying the transition re-derives the identical sequence, so the game - * stays a pure function of (base seed, action log) — provided the hook draws + * stays a pure function of (base seed, action log), provided the hook draws * in deterministic code order. */ export interface Rng { next(): number; @@ -75,13 +75,13 @@ export type OutcomeEntry = { }; /** - * The result of advancing the game by one transition — the return of + * The result of advancing the game by one transition: the return of * `initialState`, `applyAction`, and `applyLifecycle`. */ export interface Envelope { /** New pure game payload (board, deck, fog…). Never carries whose-turn or - * winner info — those are engine-owned fields. Must match the game's - * `schemaVersion` schema — the engine validates it before committing. */ + * winner info; those are engine-owned fields. Must match the game's + * `schemaVersion` schema, which the engine validates before committing. */ state: TState; /** 0-based seats that may act next. Empty ⇒ game over. */ pendingPlayers: number[]; @@ -96,10 +96,10 @@ export interface Envelope { export interface ObservationSlice { /** What this seat is permitted to see. */ data: TObservation; - /** Pending set as this seat sees it — may be narrowed from the true set for + /** Pending set as this seat sees it. May be narrowed from the true set for * hidden-info games (e.g. a Nope window, or a simultaneous-commit round * where revealing that the opponent moved would leak information). It must - * stay truthful about the seat *itself* — the engine enforces that. */ + * stay truthful about the seat *itself*, which the engine enforces. */ pendingPlayers: number[]; } @@ -107,14 +107,14 @@ export interface ObservationSlice /** Args common to every hook: the game config, parsed by the engine against * the version schema of the {@link GameRules} entry being invoked. No - * `schemaVersion` field — a rules unit is version-specific by construction, + * `schemaVersion` field: a rules unit is version-specific by construction, * so hooks never branch on version. */ interface HookContext { config: TConfig; } export interface InitialStateArgs extends HookContext { - /** Deterministic RNG for this transition — see {@link Rng}. */ + /** Deterministic RNG for this transition. See {@link Rng}. */ rng: Rng; playerCount: number; } @@ -124,7 +124,7 @@ export interface ApplyActionArgs extends HookContext { state: TState; /** Seats awaiting an action. For `timeout` these are exactly the seats that - * ran out of time — resolve the whole set in one envelope (you may declare a + * ran out of time, so resolve the whole set in one envelope (you may declare a * draw). For `forfeit`/`autoForfeit`, the target seat is in * `data.playerIndex`. */ pending: number[]; - /** The trigger — always equal to `data.type`. */ + /** The trigger, always equal to `data.type`. */ type: LifecycleType; data: LifecycleAction; - /** Deterministic per-transition RNG — see {@link Rng}. */ + /** Deterministic per-transition RNG. See {@link Rng}. */ rng: Rng; } /** - * The action that produced the state being projected — a `game` action + * The action that produced the state being projected: a `game` action * (`applyAction`), a `lifecycle` action (`applyLifecycle`), or `null` for * the initial frame (`initialState`), which no action produced. * - * This is how a game tells each seat *what happened* — pure frame diffing + * This is how a game tells each seat *what happened*, since pure frame diffing * can't recover causality (identical footprints, hidden-info moves, composite * resolutions). Embed whatever animation/narration cues a seat is permitted * to see into that seat's slice `data` (e.g. a `lastMove` field); visibility @@ -176,11 +176,11 @@ export interface ComputeObservationArgs; - /** TRUE only when projecting a finished game for replay — hidden-info games + /** TRUE only when projecting a finished game for replay, where hidden-info games * may reveal opponent state. */ isReplay: boolean; } @@ -201,7 +201,7 @@ export interface RatingPoolArgs { /** A candidate bot seating, passed to {@link GameRules.botSeatable}. * `gameConfig` is parsed against the game's version schema; `botConfig` is the - * bot's declared capabilities — game-owned but unversioned by the game + * bot's declared capabilities: game-owned but unversioned by the game * schemas, so it stays opaque. */ export interface BotSeatableArgs { gameConfig: TConfig; @@ -211,12 +211,12 @@ export interface BotSeatableArgs { /** A seated engine bot's turn to move, passed to the matching entry in * {@link GameRules.botActions}. The brain runs inside the game's Durable * Object post-commit and sees exactly what a human at this seat would - * (`observation` — the same fog-of-war projection, so a bot cannot read hidden + * (`observation`, the same fog-of-war projection, so a bot cannot read hidden * state its seat may not); `botConfig` is that bot registry row's declared * knob (difficulty, personality). The engine self-applies the returned move as * this seat's action, validated against `schemas.action` exactly like a human * move. `rng` is deterministic per (game, version, seat) for reproducible - * tests — but the chosen move is what gets logged, so the brain need not be + * tests, but the chosen move is what gets logged, so the brain need not be * pure (replay uses the recorded action, never re-runs the brain). */ export interface BotActionArgs extends HookContext { observation: ObservationSlice; @@ -225,7 +225,7 @@ export interface BotActionArgs = (args: BotActionArgs) => TAction; @@ -233,10 +233,10 @@ export type BotAction { /** The pure game payload stored per transition. */ @@ -254,10 +254,10 @@ export interface GameSchemas` etc. — use `type` + * schemas in {@link schemas} (`z.infer` etc., using `type` * aliases, not `interface`s). The engine parses every payload with this * unit's schemas before invoking its hooks, so hook bodies never see - * unvalidated JSON — and never another version's shape. When rules or shapes + * unvalidated JSON, and never another version's shape. When rules or shapes * change incompatibly, ship a new `GameRules` under the next version key * (reusing unchanged pieces by import) instead of branching inside hooks. */ @@ -270,20 +270,20 @@ export interface GameRules): Envelope; /** Apply a player's move. The engine has already confirmed it is this - * seat's turn at the expected version, so do not re-check turn order — only + * seat's turn at the expected version, so do not re-check turn order. Only * validate move legality and throw {@link IllegalMoveError} if it fails; * the engine renders it as the caller's error. Any other throw is a game * bug and surfaces as a server error. */ applyAction(args: ApplyActionArgs): Envelope; /** Resolve a lifecycle action (`forfeit`/`timeout`) into an envelope. - * Lifecycle actions operate on the game from outside its rules — they may + * Lifecycle actions operate on the game from outside its rules. They may * be player-triggered (a resign) or engine-triggered (timeout, purge); * either way the consequence is the game's to decide. Unlike `applyAction` - * it cannot be "illegal" — it always resolves. */ + * it cannot be "illegal"; it always resolves. */ applyLifecycle(args: ApplyLifecycleArgs): Envelope; - /** Project the state into one seat's view — including what that seat may + /** Project the state into one seat's view, including what that seat may * see of the transition that produced it (`args.cause`), so the client can * animate. Perfect-info games can use the `passthroughObservation` helper * (which ignores the cause). What this hook reveals also implicitly sets @@ -291,7 +291,7 @@ export interface GameRules): ObservationSlice; - /** Decide whether — and in which pool — a game with these settings is + /** Decide whether, and in which pool, a game with these settings is * rated. Return the pool name (e.g. `'rapid'`) or `null` for unrated. The * engine computes `canBeRated = pool != null && !guest` and validates the * client's concrete `rated` assertion against it (rejecting a mismatch). @@ -305,10 +305,10 @@ export interface GameRules): boolean; - /** Optional — the in-DO bot brains, **keyed by bot username**. When a + /** Optional: the in-DO bot brains, **keyed by bot username**. When a * seated `engine`-type bot's turn starts, the engine resolves its registry * row's `username`, looks the move function up here, runs it post-commit, - * and self-applies the returned move — so a bot game needs no external + * and self-applies the returned move, so a bot game needs no external * service. Several bots that share behaviour point their usernames at the * same function and differ by their per-row `botConfig`; distinct behaviour * is a distinct entry. A seated engine bot whose username is absent here @@ -320,37 +320,37 @@ export interface GameRules` (or annotating a * literal `: GameRules<…>`); assigning that into a `versions` map just works, * with no `as`-cast, because `any` disables the variance check at this seam. */ -// biome-ignore lint/suspicious/noExplicitAny: type-erased plugin registry — see the doc comment above; `any` is the existential escape, guarded at runtime by per-entry schema validation. +// biome-ignore lint/suspicious/noExplicitAny: type-erased plugin registry; see the doc comment above. `any` is the existential escape, guarded at runtime by per-entry schema validation. export type AnyGameRules = GameRules; /** - * The complete game-specific surface — the same-named twin of the Dart + * The complete game-specific surface: the same-named twin of the Dart * `GameModule` (whose extras are client-only creation/about UI). Implement * this once per app and pass it to `createEngine`; the engine owns all - * version dispatch — every request resolves the game's `schemaVersion` + * version dispatch, so every request resolves the game's `schemaVersion` * entry from {@link versions} and invokes that unit's hooks. Game code never * branches on version. */ export interface GameModule { - /** The {@link GameRules} units keyed by `schemaVersion` — exactly the + /** The {@link GameRules} units keyed by `schemaVersion`: exactly the * versions this build ships. Sparse on purpose: game creation rejects a * version not present here, loading a stored game requires its version's * entry, and a drained old version is retired by deleting its entry. The - * value type is {@link AnyGameRules} — each entry is authored against its + * value type is {@link AnyGameRules}; each entry is authored against its * concrete payload types and erased here; safe because the engine parses * each payload with the same entry's schemas before invoking its hooks. */ versions: Record; diff --git a/packages/rules/src/helpers.ts b/packages/rules/src/helpers.ts index 6eebcdd..5ee5570 100644 --- a/packages/rules/src/helpers.ts +++ b/packages/rules/src/helpers.ts @@ -1,12 +1,12 @@ /** - * The small runtime companions to the contract — the only two values (not + * The small runtime companions to the contract: the only two values (not * types) this package exports. */ import type { ComputeObservationArgs, ObservationSlice } from "./contract.js"; import type { JsonObject } from "./json.js"; -/** Thrown by a game's `applyAction` to reject a move that breaks the rules — +/** Thrown by a game's `applyAction` to reject a move that breaks the rules. * the *expected* failure of the hook (a mis-tap, a client bug), rendered to * the caller as their error. Anything else a hook throws is treated as a game * bug and surfaces as a server error. Domain-level on purpose: the game @@ -15,7 +15,7 @@ export class IllegalMoveError extends Error {} /** * Default `computeObservation` for perfect-information games: every seat sees - * the full state and the true pending set. Ignores `args.cause` — a + * the full state and the true pending set. Ignores `args.cause`, since a * perfect-info client can usually infer the transition from consecutive * frames; embed explicit cues in the slice instead when it can't. Note that * under the same-view rule a passthrough game is automatically strict about diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index b47ae60..4b6b000 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -1,5 +1,5 @@ /** - * `@eigeninteractive/rules` — the contract a game implements. A `GameModule` bundles one + * `@eigeninteractive/rules`: the contract a game implements. A `GameModule` bundles one * `GameRules` unit per schema version; the engine calls its hooks and never * inspects game state directly. This package is types plus a couple of * helpers: it has no runtime dependencies and pulls in no engine code. diff --git a/packages/rules/src/json.ts b/packages/rules/src/json.ts index 98f9759..8c066f7 100644 --- a/packages/rules/src/json.ts +++ b/packages/rules/src/json.ts @@ -1,6 +1,6 @@ /** * The engine's JSON vocabulary. Every game payload (`state`, `config`, an - * action's `data`, observation slices) is plain JSON — it crosses process, + * action's `data`, observation slices) is plain JSON: it crosses process, * storage, and wire boundaries verbatim, so nothing richer is representable. */ @@ -10,15 +10,15 @@ export type Json = string | number | boolean | null | JsonArray | JsonObject; /** A JSON array. An `interface` (not an inline `Json[]`) so the recursion is - * through a named type TypeScript resolves lazily — inline recursion blows + * through a named type TypeScript resolves lazily; inline recursion blows * the instantiation depth (TS2589) when workerd's RPC types map over it. */ export interface JsonArray extends Array {} -/** A JSON object — the shape of `state`, `config`, `data`, and observation +/** A JSON object: the shape of `state`, `config`, `data`, and observation * slices, and the constraint every game payload type must satisfy. An * `interface` for the same lazy-resolution reason as `JsonArray`. * Declare *game payload* types as `type` aliases (e.g. via your schema - * library's inference), not `interface`s — a payload `interface` lacks the + * library's inference), not `interface`s, since a payload `interface` lacks the * implicit index signature this constraint relies on. */ export interface JsonObject { [key: string]: Json | undefined; diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index f320735..75017bd 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -5,11 +5,11 @@ ### Patch Changes - [#34](https://github.com/eigeninteractive/eigen-server/pull/34) [`905b841`](https://github.com/eigeninteractive/eigen-server/commit/905b841158238c21bd7086d9a73e23a7a0fb4ba3) Thanks [@seenu-k](https://github.com/seenu-k)! - Reword the footer credit to `Built with EigenInteractive`, linking only the - name — accent-coloured, no underline. A custom `madeByCredit` that names the + name, accent-coloured and without an underline. A custom `madeByCredit` that names the engine keeps the link on that word; one that does not renders as plain text. - Every link the engine renders — the legal pages, the store buttons, the credit - — now opens in a new tab, and the `/j` share page honours `madeByCredit` + Every link the engine renders (the legal pages, the store buttons, the credit) + now opens in a new tab, and the `/j` share page honours `madeByCredit` instead of always showing the default. - Updated dependencies []: - @eigeninteractive/kernel@0.2.4 @@ -22,13 +22,13 @@ - [#32](https://github.com/eigeninteractive/eigen-server/pull/32) [`258c2d7`](https://github.com/eigeninteractive/eigen-server/commit/258c2d7c53f31dfcfa8fa22832f378c0eaf3c4be) Thanks [@seenu-k](https://github.com/seenu-k)! - Give the download page something to look at, and stop it offering links it cannot honour. The page now leads with the app's own launcher icon, centres itself rather than - clinging to the top of an empty viewport, and ends in a footer on every page — + clinging to the top of an empty viewport, and ends in a footer on every page: the operator's copyright and legal links when `site` is configured, and a credit line either way. `site.madeByCredit` overrides that line or removes it with `null`, mirroring the Flutter shell's `Branding.madeByCredit`. Before a web build exists there is no app icon to show, so the EigenInteractive - mark stands in — inline SVG drawn in the page's own tokens, so it takes a + mark stands in: inline SVG drawn in the page's own tokens, so it takes a configured `primaryColor` and follows the visitor's colour scheme. A game with neither a web build nor store URLs has nothing to offer at all, and now says `Coming soon.` instead of ending after the tagline. @@ -38,7 +38,7 @@ bound from the first `wrangler dev` whether or not `public/` has anything in it, so the button was sending visitors to `/`, which redirected straight back. And `--on-primary` is now paired with `--primary` per colour scheme, computed from - luminance for a game that configures its own — white on the dark scheme's light + luminance for a game that configures its own: white on the dark scheme's light teal was the one pairing in the palette that failed contrast outright. - Updated dependencies []: - @eigeninteractive/kernel@0.2.3 @@ -50,7 +50,7 @@ - [#27](https://github.com/eigeninteractive/eigen-server/pull/27) [`c7e1172`](https://github.com/eigeninteractive/eigen-server/commit/c7e1172497088a38e5d09167b12e60b3f402f9c5) Thanks [@seenu-k](https://github.com/seenu-k)! - Give the engine-rendered public pages the EigenInteractive look: Space Grotesk on headings and the call to action, and the Material 3 palette generated from - `Colors.teal` — the same seed the Flutter shell now defaults to, so a game that + `Colors.teal`, the same seed the Flutter shell now defaults to, so a game that has configured nothing reads as one product across its app and its pages. The palette replaces three unrelated defaults: `#4f46e5` in the stylesheet, @@ -59,7 +59,7 @@ `ColorScheme.fromSeed` output too, which is why the greys are faintly green. `--primary` also gets a dark-scheme value. It previously kept its light value on - a dark ground. A game that sets `site.primaryColor` still wins in both schemes — + a dark ground. A game that sets `site.primaryColor` still wins in both schemes, that arrives as an inline style on the root element, which beats the stylesheet. Both faces are served by the worker at versioned, immutable paths and declared @@ -77,7 +77,7 @@ CI, so the two cannot drift. `tsup` could inline a `.woff2`, but `vitest-pool-workers` resolves worker-side modules outside vite's plugin graph, so the route would have answered 500 in the test suite while working in - production — the same reason `site.css` renders empty under test today. + production, the same reason `site.css` renders empty under test today. - Updated dependencies []: - @eigeninteractive/kernel@0.2.2 - @eigeninteractive/rules@0.2.2 @@ -88,8 +88,8 @@ - [#19](https://github.com/eigeninteractive/eigen-server/pull/19) [`993883f`](https://github.com/eigeninteractive/eigen-server/commit/993883f8bb71ebfb36708e2badd7ae98859b7094) Thanks [@seenu-k](https://github.com/seenu-k)! - Use **EigenInteractive** as the product name throughout, matching the domain, the npm scope and the GitHub organization. Package descriptions, READMEs and - the OpenAPI document title change; every identifier — `@eigeninteractive/*`, - `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header — is + the OpenAPI document title change; every identifier (`@eigeninteractive/*`, + `eigen_flutter`, `create-eigen-game`, the `Eigen-Signature` header) is untouched. - Updated dependencies [[`993883f`](https://github.com/eigeninteractive/eigen-server/commit/993883f8bb71ebfb36708e2badd7ae98859b7094)]: - @eigeninteractive/kernel@0.2.1 @@ -109,7 +109,7 @@ ## 0.1.0 -Initial release. The Eigen engine — a server-authoritative engine for turn-based +Initial release. The Eigen engine, a server-authoritative engine for turn-based multiplayer games on Cloudflare Workers. Documentation: diff --git a/packages/server/README.md b/packages/server/README.md index ddcbc76..4250a1e 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,7 +1,7 @@ # @eigeninteractive/server The deployable half of the -[EigenInteractive engine](https://eigeninteractive.com) — a server-authoritative +[EigenInteractive engine](https://eigeninteractive.com), a server-authoritative engine for turn-based multiplayer games on Cloudflare Workers. One deployment is a single Worker that owns its own domain, database and @@ -33,7 +33,7 @@ export default createEngine({ client. Set `clientOrigins` only when a deployment needs multiple or non-standard browser origins. -Ships the engine's D1 migrations under `migrations/` — point your +Ships the engine's D1 migrations under `migrations/`; point your `migrations_dir` at `node_modules/@eigeninteractive/server/migrations` and apply them with `wrangler d1 migrations apply`. You never author them. diff --git a/packages/server/drizzle-d1.config.ts b/packages/server/drizzle-d1.config.ts index 0a13b6d..e7f63c2 100644 --- a/packages/server/drizzle-d1.config.ts +++ b/packages/server/drizzle-d1.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from "drizzle-kit"; -/** The engine's D1 schema — engine-private, like the DO's. Plain .sql migrations, +/** The engine's D1 schema, engine-private like the DO's. Plain .sql migrations, * shipped in the package's `migrations/` dir; the app's `deploy` script runs - * `wrangler d1 migrations apply` before `wrangler deploy` — never at runtime, + * `wrangler d1 migrations apply` before `wrangler deploy`, never at runtime, * and never seen by implementors (app-custom data lives in a separate D1). */ export default defineConfig({ dialect: "sqlite", diff --git a/packages/server/drizzle-do.config.ts b/packages/server/drizzle-do.config.ts index 4b18883..d14e518 100644 --- a/packages/server/drizzle-do.config.ts +++ b/packages/server/drizzle-do.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit"; /** The per-game Durable Object's SQLite schema. The `durable-sqlite` driver emits a bundled - * `migrations.js` that compiles into the worker — each game DO migrates + * `migrations.js` that compiles into the worker; each game DO migrates * itself inside `blockConcurrencyWhile` on activation (no deploy step). */ export default defineConfig({ dialect: "sqlite", diff --git a/packages/server/openapi.json b/packages/server/openapi.json index eb83325..494ffa4 100644 --- a/packages/server/openapi.json +++ b/packages/server/openapi.json @@ -12,7 +12,7 @@ }, { "name": "Me", - "description": "The signed-in player — profile, username, ratings, rating history and registered devices." + "description": "The signed-in player: profile, username, ratings, rating history and registered devices." }, { "name": "Players", @@ -1306,7 +1306,7 @@ ], "security": [], "summary": "Liveness probe", - "description": "Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable — it does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected.", + "description": "Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable. It does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected.", "responses": { "200": { "description": "The worker is deployed and serving", @@ -1521,7 +1521,7 @@ ], "responses": { "200": { - "description": "The game summary — never state", + "description": "The game summary, never state", "content": { "application/json": { "schema": { @@ -1783,7 +1783,7 @@ } }, "502": { - "description": "Deletion failed — the account is intact; retry", + "description": "Deletion failed; the account is intact, retry", "content": { "application/json": { "schema": { @@ -2168,7 +2168,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2258,7 +2258,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2309,7 +2309,7 @@ }, "responses": { "200": { - "description": "Seated — the game's id and the post-join roster", + "description": "Seated: the game's id and the post-join roster", "content": { "application/json": { "schema": { @@ -2359,7 +2359,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2399,7 +2399,7 @@ }, "responses": { "200": { - "description": "Seated — the game's id and the post-join roster", + "description": "Seated: the game's id and the post-join roster", "content": { "application/json": { "schema": { @@ -2449,7 +2449,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2500,7 +2500,7 @@ }, "responses": { "200": { - "description": "Left — the post-leave roster", + "description": "Left: the post-leave roster", "content": { "application/json": { "schema": { @@ -2550,7 +2550,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2651,7 +2651,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2702,7 +2702,7 @@ }, "responses": { "200": { - "description": "Bot seated — the new roster", + "description": "Bot seated: the new roster", "content": { "application/json": { "schema": { @@ -2752,7 +2752,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2803,7 +2803,7 @@ }, "responses": { "200": { - "description": "Started — version 0 committed", + "description": "Started: version 0 committed", "content": { "application/json": { "schema": { @@ -2853,7 +2853,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -2904,7 +2904,7 @@ }, "responses": { "200": { - "description": "Committed — the acting seat's frame", + "description": "Committed: the acting seat's frame", "content": { "application/json": { "schema": { @@ -2954,7 +2954,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -3055,7 +3055,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -3171,7 +3171,7 @@ } }, "409": { - "description": "State conflict — resync and retry", + "description": "State conflict; resync and retry", "content": { "application/json": { "schema": { @@ -3321,7 +3321,7 @@ }, "responses": { "204": { - "description": "Registered — the caller's pushes will reach this install" + "description": "Registered: the caller's pushes will reach this install" }, "400": { "description": "Invalid request", diff --git a/packages/server/scripts/emit-openapi.mjs b/packages/server/scripts/emit-openapi.mjs index 8940aa8..f76f10f 100644 --- a/packages/server/scripts/emit-openapi.mjs +++ b/packages/server/scripts/emit-openapi.mjs @@ -1,4 +1,4 @@ -// Emits the engine's OpenAPI 3.1 document (run `pnpm openapi`; builds first — +// Emits the engine's OpenAPI 3.1 document (run `pnpm openapi`; builds first, // the import below is the built, node-safe entry). // // `info.version` is read from package.json rather than written into the diff --git a/packages/server/scripts/encode-fonts.mjs b/packages/server/scripts/encode-fonts.mjs index 5942eb4..29f9921 100644 --- a/packages/server/scripts/encode-fonts.mjs +++ b/packages/server/scripts/encode-fonts.mjs @@ -4,7 +4,7 @@ // diff can be reasoned about, and what the docs site serves byte-for-byte. The // `.ts` exists because `vitest-pool-workers` resolves worker-side modules // outside vite's plugin graph, so a bundler-loaded binary import is empty under -// test — the font route would answer 500 in the suite while working in +// test: the font route would answer 500 in the suite while working in // production. A string literal is the one representation every pipeline agrees // on. See `src/site/fonts/README.md`. // diff --git a/packages/server/src/asset-modules.d.ts b/packages/server/src/asset-modules.d.ts index c55aaa4..0ec37fd 100644 --- a/packages/server/src/asset-modules.d.ts +++ b/packages/server/src/asset-modules.d.ts @@ -4,7 +4,7 @@ * The public pages' stylesheet is authored as a real `.css` file so it * highlights, formats, and diffs like the CSS it is. tsup inlines it via its * text loader (see `tsup.config.ts`), exactly as it already does for drizzle's - * `.sql` bundle — so the published package carries it as a plain JS string and + * `.sql` bundle, so the published package carries it as a plain JS string and * implementors need no wrangler `Text` rule. * * This declaration is compile-time only: it is not emitted into the published diff --git a/packages/server/src/auth/admin.ts b/packages/server/src/auth/admin.ts index ba417b5..ae393c6 100644 --- a/packages/server/src/auth/admin.ts +++ b/packages/server/src/auth/admin.ts @@ -1,11 +1,11 @@ /** - * Firebase Auth admin — the one privileged auth op the engine performs: + * Firebase Auth admin: the one privileged auth op the engine performs, * deleting a user's Firebase account during account deletion / guest purge *. Uses the Identity Toolkit admin REST endpoint with a * service-account bearer (scope `identitytoolkit`), reusing the shared * `google/oauth` token step. * - * Single attempt: the caller decides what a failure means — the + * Single attempt: the caller decides what a failure means, and the * delete-account route aborts before the D1 purge (so the account is never * left resurrectable), the cron guest-purge skips the guest and retries next * run. Verified against the Identity Platform reference (`accounts:delete`, @@ -18,7 +18,7 @@ const IDENTITY_TOOLKIT_SCOPE = "https://www.googleapis.com/auth/identitytoolkit" const DELETE_URL = "https://identitytoolkit.googleapis.com/v1/accounts:delete"; /** Delete the Firebase account for `uid` as an administrator. Resolves on - * success — and treats "already gone" as success, so a re-run after a partial + * success, and treats "already gone" as success, so a re-run after a partial * deletion is idempotent. Throws on any other failure (the caller logs and * decides). */ export async function deleteFirebaseAccount(sa: ServiceAccount, uid: string): Promise { @@ -37,7 +37,7 @@ export async function deleteFirebaseAccount(sa: ServiceAccount, uid: string): Pr } const detail = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; const message = detail.error?.message ?? ""; - // A concurrent/earlier delete already removed the account — the goal state. + // A concurrent/earlier delete already removed the account: the goal state. if (message === "USER_NOT_FOUND") return; throw new Error(`Firebase account delete failed for ${uid}: HTTP ${res.status} ${message}`); } diff --git a/packages/server/src/auth/firebase.ts b/packages/server/src/auth/firebase.ts index ad578da..c5b5d45 100644 --- a/packages/server/src/auth/firebase.ts +++ b/packages/server/src/auth/firebase.ts @@ -1,5 +1,5 @@ /** - * Firebase ID-token verification — jose against Google's + * Firebase ID-token verification: jose against Google's * securetoken JWKS, plus the Firebase claim checks. ~40 lines of our code, by * design: the user explicitly rejected `firebase-auth-cloudflare-workers`. * Only `FIREBASE_PROJECT_ID` is needed to verify; the service-account trio is @@ -8,7 +8,7 @@ import { createRemoteJWKSet, type JWTVerifyGetKey, jwtVerify } from "jose"; -/** Verification failure — always the caller's fault; the app maps it to 401. */ +/** Verification failure, always the caller's fault; the app maps it to 401. */ export class AuthError extends Error {} /** What a verified ID token asserts. `isAnonymous` (the @@ -34,7 +34,7 @@ export interface TokenVerifier { /** Google's JWKS for securetoken (the Firebase ID-token signer). */ const FIREBASE_JWKS_URL = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"; -/** Cached per isolate — jose caches the fetched keys (and refetches on +/** Cached per isolate. jose caches the fetched keys (and refetches on * rotation) inside this resolver, so every verifier shares one cache. */ let remoteJwks: JWTVerifyGetKey | undefined; diff --git a/packages/server/src/bot/bot-auth.ts b/packages/server/src/bot/bot-auth.ts index 9c040f9..df1449d 100644 --- a/packages/server/src/bot/bot-auth.ts +++ b/packages/server/src/bot/bot-auth.ts @@ -1,5 +1,5 @@ /** - * External-bot auth — HMAC both directions (engine→bot + * External-bot auth: HMAC both directions (engine→bot * wake, bot→engine action) using a per-bot key **derived** from one engine * secret, so registering a bot needs no new secret and no redeploy: * @@ -7,15 +7,15 @@ * signature = "v1," + base64(HMAC-SHA256(derivedKey, ":")) * * The `domain` tag (`wake` = engine→bot, `action` = bot→engine) is signed, so - * a signature captured in one direction can never verify in the other — no - * reflection. The `v1,` prefix names the scheme (Standard-Webhooks style) and + * a signature captured in one direction can never verify in the other, so there + * is no reflection. The `v1,` prefix names the scheme (Standard-Webhooks style) and * rides outside the signed bytes; a future scheme gets `v2,` and can verify * side-by-side during a migration. At registration the operator computes the * bot's key with the exported `deriveBotKey()` (or the equivalent `openssl` * one-liner in its docstring) and hands that to the bot's owner, who never * sees the master secret. * - * The crypto is all platform-native — WebCrypto for the HMAC, and the + * The crypto is all platform-native: WebCrypto for the HMAC, and the * runtime's own `Uint8Array` base64 codec (`toBase64`/`fromBase64`) for the * transport encoding. The master secret is taken as an argument rather than * read from a global, so this module stays pure and testable. @@ -41,7 +41,7 @@ async function deriveBotKeyBytes(masterSecret: string, botId: string): Promise { return (await deriveBotKeyBytes(masterSecret, botId)).toBase64(); } -/** Sign `message` for one direction with the bot's derived key — used for +/** Sign `message` for one direction with the bot's derived key, used for * wakes (`"wake"`); a bot's own client code produces the `"action"` twin. */ export async function signForBot(masterSecret: string, botId: string, domain: SignatureDomain, message: string): Promise { const key = await importHmacKey(await deriveBotKeyBytes(masterSecret, botId), ["sign"]); diff --git a/packages/server/src/bot/routes.ts b/packages/server/src/bot/routes.ts index ce0207c..9db9acc 100644 --- a/packages/server/src/bot/routes.ts +++ b/packages/server/src/bot/routes.ts @@ -4,17 +4,17 @@ * * It shares the `/api` prefix with the client engine group but **not its auth** * (see `buildApp`): the two are separate sub-apps, so the engine's Firebase - * middleware — scoped to `/api/engine/*` — never runs here. A bot carries no + * middleware, scoped to `/api/engine/*`, never runs here. A bot carries no * user token; it authenticates the request itself by signing the exact request * body (bound to the `action` domain) and sending the signature in the - * `Eigen-Signature` header — the same header the engine uses to sign wakes in + * `Eigen-Signature` header: the same header the engine uses to sign wakes in * the other direction (the direction is bound in the signed bytes, not the * header name). The handler verifies that HMAC before trusting any claim in the * body, then runs the move through the normal command path (the DO verifies the * named seat exactly as for a human). * * It is a real OpenAPI operation (so the one emitted spec documents it), but - * declares the `botHmac` security scheme instead of `firebase` — the header + * declares the `botHmac` security scheme instead of `firebase`, so the header * signature is a representable `apiKey`, which the in-body envelope was not. * That `apiKey` scheme is the header's documentation, so it is read directly * rather than re-declared as a request parameter. @@ -66,7 +66,7 @@ export function registerBotRoutes(app: EngineApp, ctx: RouteContext): void { if (signature === undefined || signature.length === 0) throw new HttpError(401, "Missing signature"); // Verify over the EXACT received bytes (Hono caches the body, so this is // the same text validation parsed), bound to the `action` domain, before - // trusting the claim (constant-time). A bad signature is a flat 401 — + // trusting the claim (constant-time). A bad signature is a flat 401: // no oracle about which field was wrong. const raw = await c.req.text(); if (!(await verifyBotSignature(secret, claim.botId, "action", raw, signature))) { diff --git a/packages/server/src/d1/apply.ts b/packages/server/src/d1/apply.ts index f06c27c..0c57b0b 100644 --- a/packages/server/src/d1/apply.ts +++ b/packages/server/src/d1/apply.ts @@ -1,12 +1,12 @@ /** * The finish apply (step 3) and the fire-and-forget - * summary upsert. Called from the DO post-commit — never under the + * summary upsert. Called from the DO post-commit, never under the * input gate. * * Rating deltas are computed HERE, not in the DO: they depend on global * cross-game priors, and any prior snapshotted into the DO is stale by - * construction (games can run for days). The whole apply — summary row, - * rating CAS, history log, and the `finish_id` marker — is ONE `batch()` + * construction (games can run for days). The whole apply (summary row, + * rating CAS, history log, and the `finish_id` marker) is ONE `batch()` * transaction, so the dedupe marker and the effects it guards can never * disagree. * @@ -29,7 +29,7 @@ import { games, participants, playerRatings, ratingHistory, users } from "./sche export interface FinishApplyInput { gameId: string; - /** The DO-minted idempotency key — the apply is a no-op replay when + /** The DO-minted idempotency key. The apply is a no-op replay when * the games row already carries it. */ finishId: string; outcomes: OutcomeEntry[]; @@ -43,11 +43,11 @@ const CAS_ATTEMPTS = 5; /** Apply one finished game to D1. Returns the rated deltas (null for an * unrated game) for the DO to deliver as the ratings transition. Throws on - * failure — the caller logs and keeps the outbox row (single attempt at + * failure, so the caller logs and keeps the outbox row (single attempt at * the call site; the internal loop only absorbs CAS conflicts). */ export async function applyFinish(d1: D1Database, input: FinishApplyInput): Promise { const db = orm(d1); - /** Non-null exactly when this finish is rated — the sole rated/pool gate. */ + /** Non-null exactly when this finish is rated: the sole rated/pool gate. */ const pool = input.rated ? input.ratingPool : null; for (let attempt = 1; attempt <= CAS_ATTEMPTS; attempt++) { @@ -112,7 +112,7 @@ export async function applyFinish(d1: D1Database, input: FinishApplyInput): Prom // Purge guard: a seat whose account was deleted since this game // began still carries its userId in the DO roster (the purge nulls only // the D1 mirror, never wakes the DO), so it survives into `players` and - // shapes the OpenSkill field — but it must NOT get a rating row, or the + // shapes the OpenSkill field, but it must NOT get a rating row, or the // purge would resurrect a player_ratings entry for a non-existent user. // Skip the write (and the returned delta) for any user identity no longer // in `users`; bots are never purged. Mirrors the old @@ -131,7 +131,7 @@ export async function applyFinish(d1: D1Database, input: FinishApplyInput): Prom // is expected to produce: a concurrent finish collided on // idx_rating_history_*_cas. Anything else (a schema mistake, a D1 // outage) is deterministic or needs a different remedy, and retrying it - // four more times just delays the report — so it propagates now. + // four more times just delays the report, so it propagates now. if (!isUniqueViolation(error) || attempt === CAS_ATTEMPTS) throw error; } } @@ -150,7 +150,7 @@ function identityKey(userId: string | null, botId: string | null): string { return userId !== null ? `u:${userId}` : `b:${botId}`; } -/** Which of these user ids still exist — the purge guard's read (see the +/** Which of these user ids still exist: the purge guard's read (see the * call site). Empty in ⇒ empty out, no round trip. */ async function readExistingUsers(d1: D1Database, userIds: string[]): Promise> { if (userIds.length === 0) return new Set(); @@ -177,8 +177,8 @@ async function readPriors(d1: D1Database, pool: string, roster: Seat[]): Promise return priors; } -/** Per identity: the history INSERT — stamped with the revision this delta - * was computed against, which is the CAS (see the module docstring) — then +/** Per identity: the history INSERT, stamped with the revision this delta + * was computed against, which is the CAS (see the module docstring), then * the paired rating write. A never-rated identity has `revision_before = 0` * and gets an INSERT; everyone else gets an UPDATE to `revision_before + 1`. * @@ -238,7 +238,7 @@ function ratingStatements(db: ReturnType, input: FinishApplyInput, p revision: revisionBefore + 1, updatedAt: input.now, }) - // Redundant given the history index above — a silent no-op here + // Redundant given the history index above; a silent no-op here // is unreachable once that INSERT committed. Kept as a cheap // assertion, not as the guard. .where(and(identityWhere, eq(playerRatings.revision, revisionBefore))), @@ -256,7 +256,7 @@ async function recoverDeltas(d1: D1Database, finishId: string): Promise { @@ -272,7 +272,7 @@ export async function updateSummary(d1: D1Database, args: { gameId: string; stat .where(eq(games.id, args.gameId)); } -/** The roster mirror after a committed waiting-room command — the DO's +/** The roster mirror after a committed waiting-room command. The DO's * roster is the integrity copy; this rewrites the D1 display copy wholesale * (delete + reinsert), which is idempotent and immune to per-row drift. * Fire-and-forget post-commit (the DO leaves it unawaited; no `waitUntil`), @@ -337,7 +337,7 @@ export async function createGame(d1: D1Database, input: CreateGameInput): Promis } /** Lazy-init read: the D1 game + participants rows the DO copies into - * its `meta`/`roster` on first contact — one batched round trip. */ + * its `meta`/`roster` on first contact, in one batched round trip. */ export async function readGameRow(d1: D1Database, gameId: string) { const db = orm(d1); const [gameRows, seatRows] = await db.batch([ diff --git a/packages/server/src/d1/blocks.ts b/packages/server/src/d1/blocks.ts index 1f48049..be06fe6 100644 --- a/packages/server/src/d1/blocks.ts +++ b/packages/server/src/d1/blocks.ts @@ -1,5 +1,5 @@ /** - * Block enforcement — the interaction and visibility effects of a `blocked` + * Block enforcement: the interaction and visibility effects of a `blocked` * relationship, shared by the read filters (lobby, friends' games) and the * seating boundary (join / join-by-code). * @@ -10,7 +10,7 @@ * - **Forward-only.** Nothing here touches a game already in progress; the * effects gate future visibility and future seating, never eviction. * - **Never redacts history.** Finished games and the `players?ids=` identity - * lookup are untouched — you already played a blocked user, and rewriting + * lookup are untouched: you already played a blocked user, and rewriting * that record would break rendering a game you both appear in. * * The `relationships` row is stored once per unordered pair in canonical order @@ -28,7 +28,7 @@ export function pair(a: string, b: string): { u1: string; u2: string } { } /** A pair-matching predicate that works regardless of which argument is - * smaller — for the search/read paths where the candidate id is a column. */ + * smaller, for the search/read paths where the candidate id is a column. */ export function samePair(colA: AnyColumn, colB: AnyColumn, x: string | AnyColumn, y: string | AnyColumn) { return or(and(eq(colA, x), eq(colB, y)), and(eq(colA, y), eq(colB, x))); } diff --git a/packages/server/src/d1/errors.ts b/packages/server/src/d1/errors.ts index 1ca6d1f..16b0609 100644 --- a/packages/server/src/d1/errors.ts +++ b/packages/server/src/d1/errors.ts @@ -1,8 +1,8 @@ /** * D1 / SQLite failure classification. * - * Neither D1 nor drizzle exposes a structured error code — its "error - * constants" are themselves just message prefixes — so every predicate here + * Neither D1 nor drizzle exposes a structured error code (its "error + * constants" are themselves just message prefixes) so every predicate here * matches on message text. The important part is that they all match down the * `cause` chain rather than the top-level message alone, because how deep the * real text sits depends on how the statement was issued: @@ -23,7 +23,7 @@ * it guards. Walking the chain is correct for both, so it is the only form * used here. * - * Physical column names (`short_code`) — not the camelCase Drizzle property — + * Physical column names (`short_code`), not the camelCase Drizzle property, * appear in these messages, so the patterns live beside the schema that * defines them. */ @@ -33,7 +33,7 @@ const MAX_CAUSE_DEPTH = 5; /** True when any message down the `cause` chain matches any of `patterns`. - * Patterns must not be `g`-flagged — `test()` is stateful with `g`. */ + * Patterns must not be `g`-flagged, since `test()` is stateful with `g`. */ export function matchesCause(error: unknown, ...patterns: RegExp[]): boolean { for (let e: unknown = error, depth = 0; e instanceof Error && depth < MAX_CAUSE_DEPTH; e = e.cause, depth++) { if (patterns.some((re) => re.test(e.message))) return true; @@ -46,14 +46,14 @@ export function isUniqueViolation(error: unknown): boolean { return matchesCause(error, /UNIQUE constraint failed/i); } -/** A UNIQUE rejection specifically on `games.short_code` — the signal the +/** A UNIQUE rejection specifically on `games.short_code`: the signal the * create loop retries on. Narrowed to the column so a genuine clash on any * other UNIQUE index (which retrying could never fix) still surfaces. * * The qualified `games.short_code` must appear in the constraint list that * follows `failed:`, not merely somewhere in the message. Some D1 messages * embed the offending SQL (`D1_EXEC_ERROR: ... sql error: ...`), and that SQL - * names `short_code` as a column — so a loose `.*short_code` would read a + * names `short_code` as a column, so a loose `.*short_code` would read a * UNIQUE violation on a *different* games column as a code collision and burn * the whole retry budget before surfacing it. `[^:]*` cannot cross the colon * that terminates the constraint list, which is what keeps the two apart. */ diff --git a/packages/server/src/d1/orm.ts b/packages/server/src/d1/orm.ts index d2e30a1..a46bb6a 100644 --- a/packages/server/src/d1/orm.ts +++ b/packages/server/src/d1/orm.ts @@ -6,7 +6,7 @@ import { drizzle } from "drizzle-orm/d1"; * configured in exactly one place. Schema properties are camelCase and the * columns stay snake_case; because the mapping is derived from the property * name, a call site can never forget the option and silently query a camelCase - * column that does not exist. Every column name is derived — the schema carries + * column that does not exist. Every column name is derived; the schema carries * no explicit column strings at all. */ export const orm = (d1: D1Database) => drizzle(d1, { casing: "snake_case" }); diff --git a/packages/server/src/d1/reads.ts b/packages/server/src/d1/reads.ts index 1548e7d..4bdd2ea 100644 --- a/packages/server/src/d1/reads.ts +++ b/packages/server/src/d1/reads.ts @@ -1,7 +1,7 @@ /** * Worker → D1 reads: lobby, history lists, profiles, * players, bot catalog, and the per-route policy lookups. The rule they all - * serve: **never wake a Durable Object to serve a read** — only commands, the + * serve: **never wake a Durable Object to serve a read**. Only commands, the * socket, and range fetches touch the DO. */ @@ -15,8 +15,8 @@ export type GameRow = typeof games.$inferSelect; export type BotRow = typeof bots.$inferSelect; /** The bot registry row as a discriminated union on `type`. The two - * `bots` CHECK constraints make these shapes exact at the storage layer — - * `external` always has a `webhook_url`, the others never do — so narrowing a + * `bots` CHECK constraints make these shapes exact at the storage layer + * (`external` always has a `webhook_url`, the others never do) so narrowing a * loaded row is total. */ export type Bot = (Omit & { type: "engine"; webhookUrl: null }) | (Omit & { type: "external"; webhookUrl: string }) | (Omit & { type: "local"; webhookUrl: null }); @@ -29,13 +29,13 @@ export function narrowBot(row: BotRow): Bot { return row as Bot; } -/** A games row joined with its roster — the create's inverse, and the +/** A games row joined with its roster: the create's inverse, and the * shape every summary response projects from. * * `ratings` mirrors `outcomes`: every identity's change, not just the caller's. * That keeps the summary per-game rather than viewer-relative, so the same * projection is correct on the lobby, another player's history, and the - * caller's own — and a client picks out its own seat the same way it already + * caller's own, and a client picks out its own seat the same way it already * does for outcomes. Only populated for finished rated games; absent * everywhere else. */ export interface GameWithRoster extends GameRow { @@ -43,7 +43,7 @@ export interface GameWithRoster extends GameRow { ratings?: RatingDelta[]; } -/** Rebuild a {@link RatingDelta} from its stored `ratingHistory` row — the +/** Rebuild a {@link RatingDelta} from its stored `ratingHistory` row: the * inverse of the write in `applyFinish`. Shared by the batch history read * (`withRatings`) and the crash-recovery rebuild (`recoverDeltas`), so the * flat-row → nested-delta shape lives in exactly one place. */ @@ -63,7 +63,7 @@ export function ratingDeltaFromRow(row: typeof ratingHistory.$inferSelect): Rati /** Batch-load the rating changes for a page of games. * - * One query for the whole page, like the roster join above — per-game reads + * One query for the whole page, like the roster join above; per-game reads * would turn a history page into N+1 round trips. Unrated and unfinished games * simply have no rows, so they cost nothing beyond the filter. */ async function withRatings(d1: D1Database, rows: GameWithRoster[]): Promise { @@ -123,8 +123,8 @@ export async function readGame(d1: D1Database, gameId: string): Promise { const db = orm(d1); - // One round trip, like readGame — but the roster is keyed by gameId, which + // One round trip, like readGame, but the roster is keyed by gameId, which // a code lookup does not yield until it resolves. A subquery bridges the gap: // participants are filtered by "the id of the row with this shortCode", so // both statements still go in a single batch instead of a sequential id - // lookup. (readGame needs no subquery — it already holds the id.) + // lookup. (readGame needs no subquery; it already holds the id.) const idForCode = db.select({ id: games.id }).from(games).where(eq(games.shortCode, shortCode)); const [gameRows, seatRows] = await db.batch([ db.select().from(games).where(eq(games.shortCode, shortCode)), @@ -157,22 +157,22 @@ export async function readGameByCode(d1: D1Database, shortCode: string): Promise /** Keyset pagination: fetch strictly older than the caller's last row. * * A cursor rather than an offset because these lists change underneath the - * reader — a new lobby game shifts every OFFSET by one and makes a scroll show + * reader: a new lobby game shifts every OFFSET by one and makes a scroll show * the same row twice. The cursor is the previous page's last sort value, so a * page is stable no matter what was inserted since. It also stays index-served * at any depth, where OFFSET degrades linearly. * * Ties are possible (two games created in the same millisecond) and would drop * a row; in practice the epoch-ms resolution plus `limit` makes that vanishing, - * and the alternative — a compound (timestamp, id) cursor — is not worth the + * and the alternative, a compound (timestamp, id) cursor, is not worth the * wire complexity for a game list. */ function olderThan(column: SQLWrapper, cursor: number | null) { return cursor === null ? undefined : lt(column, cursor); } -/** The lobby page: public joinable games, newest first — exactly the shape +/** The lobby page: public joinable games, newest first, exactly the shape * `idx_games_lobby` (the ported partial index) serves. When `caller` is given, - * games seating anyone they have blocked (either direction) are hidden — the + * games seating anyone they have blocked (either direction) are hidden. The * creator counts as a participant, so this covers both games a blocked user * created and games they joined. */ export async function readLobby(d1: D1Database, limit: number, cursor: number | null = null, caller?: string): Promise { @@ -188,7 +188,7 @@ export async function readLobby(d1: D1Database, limit: number, cursor: number | /** "My games" through the participants index (THE access path for * games-of-user). `active` = anything still alive; `finished` = the history - * list, newest finish first (aborted rows carry no finishedAt — they sort by + * list, newest finish first (aborted rows carry no finishedAt, so they sort by * updatedAt). */ export async function readMyGames(d1: D1Database, userId: string, bucket: "active" | "finished", limit: number, cursor: number | null = null): Promise { const db = orm(d1); @@ -209,7 +209,7 @@ export async function readMyGames(d1: D1Database, userId: string, bucket: "activ ); } -/** Another player's finished PUBLIC games — the replay list on a profile. +/** Another player's finished PUBLIC games: the replay list on a profile. * * Public-only is the access rule that makes this safe to expose for an * arbitrary id: a private or friends-only game is nobody else's business, and @@ -232,7 +232,7 @@ export async function readPlayerPublicGames(d1: D1Database, playerId: string, li ); } -/** The batch identity endpoint (`players?ids=`) — why games rows carry no +/** The batch identity endpoint (`players?ids=`), and why games rows carry no * denormalized identity; the client's persisted player cache keeps it warm. */ export async function readPlayers(d1: D1Database, ids: string[]) { if (ids.length === 0) return []; @@ -246,7 +246,7 @@ export async function readBots(d1: D1Database, ids?: string[]): Promise new Promise((resolve) => setTimeout(r /** * Run `op`, retrying a *retryable* failure with jittered exponential backoff up - * to `attempts`. A non-retryable failure — or the last attempt — throws. + * to `attempts`. A non-retryable failure, or the last attempt, throws. * * Safe to leave unawaited inside a Durable Object: the DO stays alive while the * returned promise (and its backoff timers) is pending, so the whole sequence * runs to completion without `waitUntil`, exactly like the single-attempt * writes it wraps. * - * `op` MUST be idempotent — a retry can fire after a write that actually + * `op` MUST be idempotent: a retry can fire after a write that actually * landed but whose acknowledgement was lost. */ export async function withRetry(op: () => Promise, options: RetryOptions = {}): Promise { diff --git a/packages/server/src/d1/schema.ts b/packages/server/src/d1/schema.ts index 7cdb8a1..b9e0815 100644 --- a/packages/server/src/d1/schema.ts +++ b/packages/server/src/d1/schema.ts @@ -1,5 +1,5 @@ /** - * The D1 schema — the global store: identity, social, + * The D1 schema, the global store: identity, social, * bots, ratings, and game summary rows. A read-model + registry, never an * arbiter: live game truth is each game's DO; D1 is what lists, lobbies, * leaderboards, and profiles read. Never wake a DO to serve a read. @@ -13,7 +13,7 @@ * are epoch milliseconds. * * Migrations are generated by drizzle-kit and applied with - * `wrangler d1 migrations apply` — never at runtime. + * `wrangler d1 migrations apply`, never at runtime. */ import type { GameStatus, Seat } from "@eigeninteractive/kernel"; @@ -21,14 +21,14 @@ import type { GameAccess, JsonObject, OutcomeEntry } from "@eigeninteractive/rul import { sql } from "drizzle-orm"; import { check, index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; -/** One row per identity, public and private fields together — authorization is +/** One row per identity, public and private fields together; authorization is * enforced in the routes, not by table separation. Provisioned on first sight * of a verified Firebase token; `avatar_url` defaults to the provider photo * (null ⇒ client renders initials). */ export const users = sqliteTable( "users", { - /** The Firebase uid — stable across guest → permanent upgrades. */ + /** The Firebase uid, stable across guest → permanent upgrades. */ id: text().primaryKey(), username: text().notNull().unique(), /** Null for guests until they convert; UNIQUE tolerates multiple NULLs. */ @@ -54,7 +54,7 @@ export const games = sqliteTable( schemaVersion: integer().notNull(), config: text({ mode: "json" }).$type().notNull(), /** Exactly one timing mode: turn XOR budget XOR untimed (worker policy - * validates; no CHECK — D1 is display, the DO is integrity). */ + * validates; no CHECK, since D1 is display and the DO is integrity). */ turnSeconds: integer(), budgetSeconds: integer(), incrementSeconds: integer(), @@ -63,14 +63,14 @@ export const games = sqliteTable( minPlayers: integer().notNull(), maxPlayers: integer().notNull(), shortCode: text().notNull().unique(), - /** Dashboard hints ("your turn", countdowns) — display-only staleness OK. */ + /** Dashboard hints ("your turn", countdowns); display-only staleness OK. */ pendingPlayers: text({ mode: "json" }).$type(), turnDeadline: integer(), - /** Folded former game_outcomes table — written once by the finish apply. */ + /** Folded former game_outcomes table, written once by the finish apply. */ outcomes: text({ mode: "json" }).$type(), /** The D1 apply's idempotency key; set when the apply lands. */ finishId: text(), - /** Stamped by the finish apply (and the future abort path) — history + /** Stamped by the finish apply (and the future abort path); history * lists sort by it. */ finishedAt: integer(), /** Cold-tier seam: NULL = history lives in the game's DO. V1 never writes @@ -87,7 +87,7 @@ export const games = sqliteTable( ], ); -/** The roster join table — one row per seat, and the indexed access path for +/** The roster join table: one row per seat, and the indexed access path for * "games of user X". Written at create/join/leave alongside the games row; the * DO's own roster remains the integrity copy. The game-scoped unique indexes * are what actually guard the join race. */ @@ -97,7 +97,7 @@ export const participants = sqliteTable( id: text().primaryKey(), gameId: text().notNull(), /** Exactly one of user_id/bot_id for a live identity; both may be NULL - * after an account purge (worker-enforced — D1 carries no CHECKs). */ + * after an account purge (worker-enforced, since D1 carries no CHECKs). */ userId: text(), botId: text(), playerIndex: integer().notNull(), @@ -107,7 +107,7 @@ export const participants = sqliteTable( (t) => [uniqueIndex("idx_participants_unique").on(t.gameId, t.userId).where(sql`user_id IS NOT NULL`), uniqueIndex("idx_participants_player_index").on(t.gameId, t.playerIndex), index("idx_participants_user_id").on(t.userId), index("idx_participants_game_id").on(t.gameId)], ); -/** Friends and blocks — one row per pair, in canonical order +/** Friends and blocks: one row per pair, in canonical order * (`user_id_1 < user_id_2`, worker-enforced) + UNIQUE, so a relationship can * never exist twice in opposite orientations. */ export const relationships = sqliteTable( @@ -127,12 +127,12 @@ export const relationships = sqliteTable( (t) => [uniqueIndex("idx_relationships_pair").on(t.userId1, t.userId2), index("idx_relationships_user2").on(t.userId2)], ); -/** How a bot's moves are produced — the dispatch discriminator: +/** How a bot's moves are produced, the dispatch discriminator: * - `engine`: the brain ships in the game's `GameModule` as * `GameRules.botActions[username]`, run in-process by the DO; * - `external`: the bot is hosted elsewhere and woken over HMAC (`webhook_url` * is then required); - * - `local`: client-driven (future offline-solo transcript import) — a + * - `local`: client-driven (future offline-solo transcript import), a * registry row for identity only, never dispatched server-side. */ export type BotType = "engine" | "external" | "local"; @@ -147,7 +147,7 @@ export const bots = sqliteTable( username: text().notNull().unique(), displayName: text().notNull(), avatarUrl: text(), - /** Highest game schema this bot supports — the seating gate. */ + /** Highest game schema this bot supports: the seating gate. */ schemaVersion: integer().notNull(), type: text().$type().notNull(), webhookUrl: text(), @@ -156,7 +156,7 @@ export const bots = sqliteTable( createdAt: integer().notNull(), }, (t) => [ - // A webhook is present iff the bot is external — the type/columns stay a + // A webhook is present iff the bot is external, so the type/columns stay a // valid discriminated union at the storage layer, not just in TS. check("bots_webhook_matches_type", sql`(${t.type} = 'external') = (${t.webhookUrl} IS NOT NULL)`), check("bots_type_valid", sql`${t.type} IN ('engine', 'external', 'local')`), @@ -166,7 +166,7 @@ export const bots = sqliteTable( /** Per-identity per-pool OpenSkill rating. Exactly one of user_id/bot_id is * set. `revision` is the CAS counter: the finish apply reads * (mu, sigma, revision), computes in TS, and writes a `rating_history` row - * stamped with the revision it read — whose UNIQUE index is what rejects a + * stamped with the revision it read, whose UNIQUE index is what rejects a * concurrent finish (see `rating_history.revision_before`). The fix for the * legacy concurrent-finish lost-update bug. */ export const playerRatings = sqliteTable( @@ -178,7 +178,7 @@ export const playerRatings = sqliteTable( pool: text().notNull(), mu: real().notNull(), sigma: real().notNull(), - /** max(0, round((mu − 3σ) · 40)) — denormalized for leaderboards. */ + /** max(0, round((mu − 3σ) · 40)), denormalized for leaderboards. */ displayRating: integer().notNull(), revision: integer().notNull(), createdAt: integer().notNull(), @@ -187,14 +187,14 @@ export const playerRatings = sqliteTable( (t) => [uniqueIndex("idx_player_ratings_user_pool").on(t.userId, t.pool).where(sql`user_id IS NOT NULL`), uniqueIndex("idx_player_ratings_bot_pool").on(t.botId, t.pool).where(sql`bot_id IS NOT NULL`)], ); -/** Immutable per-game rating log for the profile history screen — and the +/** Immutable per-game rating log for the profile history screen, and the * concurrency control for rating writes. * * Two unique indexes, guarding two different races: * - * - `(game_id, identity)` — idempotence. A re-poked apply for the SAME game + * - `(game_id, identity)`: idempotence. A re-poked apply for the SAME game * cannot double-write. Paired with `finish_id`. - * - `(identity, pool, revision_before)` — the CAS. Two finishes of DIFFERENT + * - `(identity, pool, revision_before)`: the CAS. Two finishes of DIFFERENT * games sharing a player both read revision 7 and both try to log * `revision_before = 7`; the second violates this index, its batch rolls * back, and the apply recomputes against fresh priors. This is what makes @@ -215,7 +215,7 @@ export const ratingHistory = sqliteTable( gameId: text().notNull(), pool: text().notNull(), finishId: text().notNull(), - /** The `player_ratings.revision` this delta was computed against — 0 for + /** The `player_ratings.revision` this delta was computed against, 0 for * an identity that had no rating row yet. The post-write revision is * always this + 1, so the column doubles as the per-identity ordering of * the log. */ diff --git a/packages/server/src/d1/social.ts b/packages/server/src/d1/social.ts index 7449fb6..b96b5de 100644 --- a/packages/server/src/d1/social.ts +++ b/packages/server/src/d1/social.ts @@ -1,5 +1,5 @@ /** - * The social graph — friend relationships, user search, and the "friends' open + * The social graph: friend relationships, user search, and the "friends' open * games" read. All D1-only: social is cross-game and never touches a Durable * Object. The `relationships` table stores one row per unordered pair in * canonical order (`user_id_1 < user_id_2`) with a `status` @@ -44,7 +44,7 @@ export async function sendFriendRequest(d1: D1Database, caller: string, target: if (existing.status === "accepted") return { outcome: "already_friends" }; // Pending: whose is it? if (existing.initiatedBy === caller) return { outcome: "already_pending" }; - // The target sent us one first — accept it. + // The target sent us one first, so accept it. await db .update(relationships) .set({ status: "accepted", updatedAt: Date.now() }) @@ -71,7 +71,7 @@ export async function acceptFriendRequest(d1: D1Database, caller: string, target } /** Remove a friendship, withdraw an outgoing request, or decline an incoming - * one — all the same delete. Never touches a `blocked` row (that is `unblock`'s + * one: all the same delete. Never touches a `blocked` row (that is `unblock`'s * job). Idempotent. */ export async function removeRelationship(d1: D1Database, caller: string, target: string): Promise { const db = orm(d1); @@ -91,14 +91,14 @@ export async function blockUser(d1: D1Database, caller: string, target: string): .onConflictDoUpdate({ target: [relationships.userId1, relationships.userId2], set: { status: "blocked", initiatedBy: caller, updatedAt: now } }); } -/** Unblock — only the user who created the block may lift it. Idempotent. */ +/** Unblock: only the user who created the block may lift it. Idempotent. */ export async function unblockUser(d1: D1Database, caller: string, target: string): Promise { const db = orm(d1); const { u1, u2 } = pair(caller, target); await db.delete(relationships).where(and(eq(relationships.userId1, u1), eq(relationships.userId2, u2), eq(relationships.status, "blocked"), eq(relationships.initiatedBy, caller))); } -/** The other user's public identity — the shared core of a friend and a +/** The other user's public identity: the shared core of a friend and a * pending-request entry. */ interface IdentityFields { userId: string; @@ -193,9 +193,9 @@ export async function searchUsers(d1: D1Database, caller: string, query: string, return rows; } -/** Joinable games created by the caller's accepted friends — the "friends' +/** Joinable games created by the caller's accepted friends: the "friends' * open games" lobby. Waiting/ready games only, newest first. */ -/** The user ids of a user's accepted friends — the fan-out target for a +/** The user ids of a user's accepted friends: the fan-out target for a * friends-access game invite, and the creator filter behind `friendsOpenGames`. */ export async function acceptedFriendIds(d1: D1Database, userId: string): Promise { const rows = await orm(d1) @@ -213,7 +213,7 @@ export async function friendsOpenGames(d1: D1Database, caller: string, limit: nu const rows = await db .select() .from(games) - // A friend's game can still seat someone the caller has blocked — filter + // A friend's game can still seat someone the caller has blocked, so filter // those out here too, not just in the plain lobby. .where(and(inArray(games.createdBy, friendIds), inArray(games.status, ["waiting", "ready"]), noBlockedParticipant(d1, caller), cursor === null ? undefined : lt(games.createdAt, cursor))) .orderBy(desc(games.createdAt)) diff --git a/packages/server/src/do/game-do.ts b/packages/server/src/do/game-do.ts index 96abe78..aa419ff 100644 --- a/packages/server/src/do/game-do.ts +++ b/packages/server/src/do/game-do.ts @@ -1,7 +1,7 @@ /** - * The game's Durable Object — its serialized session AND its permanent + * The game's Durable Object: its serialized session AND its permanent * database. One DO per gameId, addressed by - * `idFromName(gameId)`. Implementors subclass {@link BaseGameDO} — the + * `idFromName(gameId)`. Implementors subclass {@link BaseGameDO}, the * platform-idiomatic shape (cf. `agents`' `Agent`, partyserver's `Server`): * * ```ts @@ -18,12 +18,12 @@ * post-commit) strictly after the SQLite commit. All storage access goes * through drizzle's durable-sqlite driver, which is fully SYNCHRONOUS * (`.get()`/`.all()`/`.run()`, and `db.transaction` wraps - * `storage.transactionSync` with a non-async callback — an `await` inside it + * `storage.transactionSync` with a non-async callback, so an `await` inside it * is a syntax error, which is the guarantee made structural). The one * sanctioned non-storage await near the gate is the lazy init, inside * `blockConcurrencyWhile` on first contact. * - * The deadline alarm is the ONLY `setAlarm` client — a stray call would + * The deadline alarm is the ONLY `setAlarm` client; a stray call would * silently unarm the turn deadline. */ @@ -63,7 +63,7 @@ import migrations from "./migrations/migrations.js"; import * as t from "./schema.js"; /** Caps how long the fire-and-forget wake holds its outbound connection for a - * slow or hanging bot webhook. Not a correctness deadline — we never wait + * slow or hanging bot webhook. Not a correctness deadline: we never wait * for the bot's move (it arrives on `/api/bot/action`) and a lost wake rides * the turn deadline; this is purely a resource bound. */ const WAKE_TIMEOUT_MS = 10_000; @@ -74,7 +74,7 @@ type TransitionRow = typeof t.transitions.$inferSelect; /** What each hibernating socket remembers: the authenticated * principal only. Seats are resolved against the CURRENT roster at every * send, so a socket opened pre-join starts receiving its seat's frames the - * moment its user is seated — no re-tagging machinery. */ + * moment its user is seated, with no re-tagging machinery. */ interface SocketAttachment { userId: string | null; } @@ -102,9 +102,9 @@ interface SocketAttachment { * ``` */ export abstract class BaseGameDO extends DurableObject implements GameStub { - /** The implementor's game — the `versions` map the engine dispatches on. */ + /** The implementor's game: the `versions` map the engine dispatches on. */ protected abstract readonly gameModule: GameModule; - /** The EngineConfig seam: the engine never assumes binding names — the + /** The EngineConfig seam: the engine never assumes binding names, so the * subclass picks the D1 database off its own Env. */ protected abstract d1(env: TEnv): D1Database; /** Required Firebase Admin effects. Tests override this with the explicit @@ -150,7 +150,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga // ── Waiting room: integrity under the gate ───────────────────────── /** The DO column for join/leave/add-bot: status + seat integrity - * checks, roster rewrite, ready/waiting threshold — one synchronous storage + * checks, roster rewrite, ready/waiting threshold: one synchronous storage * transaction, then the snapshot push and the D1 mirror post-commit. * Refusals here are *expected* lobby races (accepted staleness: the lobby * may show a game that just filled), so they come back as values. */ @@ -183,7 +183,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga if (seat === undefined) { return { ok: false, code: "notParticipant", message: "Not seated in this game" }; } - // Compact the indexes — safe pre-start only, which lobby + // Compact the indexes, safe pre-start only, which lobby // statuses guarantee: no frames or transitions reference seats yet. nextRoster = roster.filter((s) => s.playerIndex !== seat.playerIndex).map((s, i) => ({ ...s, playerIndex: i })); break; @@ -219,7 +219,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga const gameId = meta.gameId; // Background D1 mirror, off the response path. No ctx.waitUntil: a Durable // Object stays alive while a promise is pending, so an unawaited (but - // .catch-guarded) promise runs to completion on its own — waitUntil is a + // .catch-guarded) promise runs to completion on its own; waitUntil is a // stateless-Worker idiom that's redundant here. this.#mirrorD1(`roster mirror for game ${gameId}`, () => mirrorRoster(this.d1(this.env), { gameId, status, seats: nextRoster, now })); // A join that just filled the lobby: nudge the away creator to start. Skip @@ -232,10 +232,10 @@ export abstract class BaseGameDO extends DurableObject implements Ga } /** Cancel: creator-only, lobby statuses only; status → `aborted` and - * the DO's storage is dropped — nothing worth retaining, the D1 row alone + * the DO's storage is dropped, since nothing is worth retaining and the D1 row alone * serves history lists. The D1 mirror is AWAITED here (unlike every other * lobby effect): the aborted games row is the only survivor, so its write - * failing must fail the command — a retry re-enters through the `aborted` + * failing must fail the command; a retry re-enters through the `aborted` * branch and completes idempotently. */ async #cancel(cmd: Extract): Promise { const meta = this.#meta(); @@ -255,7 +255,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga } /** Unconditional teardown (cron reap): mark the game aborted in D1 and - * drop the DO's storage — no creator gate, no init requirement. A + * drop the DO's storage: no creator gate, no init requirement. A * never-touched lobby's DO has no `meta` row, so the caller passes the * gameId. Idempotent: a re-run re-aborts a game whose storage is already * gone. Used by the cron; `cancel` shares the teardown for its live path. */ @@ -270,9 +270,9 @@ export abstract class BaseGameDO extends DurableObject implements Ga } /** The shared abort teardown: mirror the aborted status to D1 (the only - * survivor — awaited, so a failure surfaces), notify and close any sockets, + * survivor, awaited so a failure surfaces), notify and close any sockets, * then drop the alarm and all storage. The schema goes with the storage, so - * restore it — a later poke lazy-re-inits from the aborted D1 row. */ + * restore it; a later poke lazy-re-inits from the aborted D1 row. */ async #tearDownAborted(gameId: string, closeReason: string): Promise { await mirrorRoster(this.d1(this.env), { gameId, status: "aborted", seats: [], now: Date.now() }); this.#broadcast({ type: "roster", status: "aborted", players: [] }); @@ -280,7 +280,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga try { ws.close(1000, closeReason); } catch { - // Already closing — nothing to do. + // Already closing, nothing to do. } } await this.ctx.storage.deleteAlarm(); @@ -291,7 +291,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga async #commitCommand(cmd: Extract): Promise { const meta = this.#meta(); const roster = this.#roster(); - // Creator-only start — a clean rejection, not a throw: any seated + // Creator-only start: a clean rejection, not a throw. Any seated // client can reach this without a protocol violation. if (cmd.kind === "start" && meta.createdBy !== null && cmd.actor.userId !== meta.createdBy) { return { ok: false, code: "notCreator", message: "Only the creator can start the game" }; @@ -324,7 +324,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga return await this.#apply(cmd, meta, roster, result, now, actingSeat); } - /** Verify the acting seat against the authoritative roster — the D1 + /** Verify the acting seat against the authoritative roster; the D1 * participants copy is a display mirror and never arbitrates. Both humans * and bots name their seat; it must belong to the actor (user id from the * token, bot id from the HMAC claim). A seat the actor does not hold is @@ -373,7 +373,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga }; } - /** Apply the plan — ONE SQLite transaction, gate held. Everything + /** Apply the plan: ONE SQLite transaction, gate held. Everything * after the transaction is post-commit: interleaving is harmless. */ async #apply(cmd: Extract, meta: MetaRow, roster: Seat[], plan: CommitPlan, now: number, actingSeat: number | null): Promise { const next = plan.nextState; @@ -398,8 +398,8 @@ export abstract class BaseGameDO extends DurableObject implements Ga turnStartedAt: next.turnStartedAt, }) .run(); - // Every transition writes its frames and its dedupe row, uniformly — - // no finish special case. Compaction is NOT here: live tables drain + // Every transition writes its frames and its dedupe row, uniformly, + // with no finish special case. Compaction is NOT here: live tables drain // when the outbox clears (`#commitRatingsTransition`). if (plan.frames.length > 0) { tx.insert(t.frames).values(this.#frameRows(next.version, plan.frames)).run(); @@ -426,7 +426,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga // Post-commit work runs off the response path. None of it is wrapped in // ctx.waitUntil: a Durable Object stays alive while any promise or I/O is // pending, so these unawaited promises run to completion on their own - // (waitUntil is redundant in a DO — see the roster mirror above). Each keeps + // (waitUntil is redundant in a DO; see the roster mirror above). Each keeps // its own .catch so a failure logs rather than becoming an unhandled // rejection. #finishEffects also self-catches; the outer .catch is a belt. if (finish !== null) { @@ -454,8 +454,8 @@ export abstract class BaseGameDO extends DurableObject implements Ga * * These rows are display-only and re-derivable, but they have no * reconciliation sweep, so a lost write stays stale until the next - * transition. A bounded jittered retry recovers the common case — a - * transient reset or network blip — while a deterministic failure still + * transition. A bounded jittered retry recovers the common case, a + * transient reset or network blip, while a deterministic failure still * surfaces once. Unawaited and self-catching: the DO stays alive for the * pending promise, so the backoff runs to completion without `waitUntil`. */ #mirrorD1(label: string, write: () => Promise): void { @@ -464,20 +464,20 @@ export abstract class BaseGameDO extends DurableObject implements Ga }).catch((error) => console.error(`${label} failed after retries`, error)); } - // ── Post-commit effects — bot turns, and turn/finish pushes ────────── + // ── Post-commit effects: bot turns, and turn/finish pushes ────────── /** Deliver the kernel's named effects for a committed transition: a seated * bot's turn (in-DO brain self-apply, or an external HMAC wake) and human - * turn/finish pushes. Single attempt + log throughout — a bot that + * turn/finish pushes. Single attempt + log throughout; a bot that * never moves rides the turn deadline (bots ⇒ timed). Runs post-commit as an - * unawaited, self-catching promise (no `waitUntil` — see `#apply`). */ + * unawaited, self-catching promise (no `waitUntil`; see `#apply`). */ async #dispatchEffects(meta: MetaRow, roster: Seat[], plan: CommitPlan, next: StateRow): Promise { for (const effect of plan.effects) { if (effect.kind === "wakeBot") { await this.#botTurn(meta, plan, next, effect.seat, effect.botId); } // `notifyTurn` / `notifyFinished` (FCM pushes) are delivered by the - // push step — wired in `#pushNotifications` below. + // push step, wired in `#pushNotifications` below. } await this.#pushNotifications(meta, roster, plan); } @@ -486,9 +486,9 @@ export abstract class BaseGameDO extends DurableObject implements Ga * bot runs its in-DO brain (`botActions[username]`) and the DO self-applies * the move; an `external` bot gets a signed HMAC wake carrying its * observation; a `local` bot is client-driven and never dispatched here - * (it should not be seatable online — a stray one just logs). The bot sees + * (it should not be seatable online, and a stray one just logs). The bot sees * only its seat's projection (`plan.frames`), so it can never read hidden - * state — the same fog a human at the seat gets. */ + * state: the same fog a human at the seat gets. */ async #botTurn(meta: MetaRow, plan: CommitPlan, next: StateRow, seat: number, botId: string): Promise { try { const bot = await readBot(this.d1(this.env), botId); @@ -510,7 +510,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga await this.#wakeExternalBot(meta, bot, seat, observation, next); break; case "local": - console.error(`local bot ${bot.id} was seated in an online game (game ${meta.gameId} seat ${seat}) — local bots are client-driven and not dispatchable`); + console.error(`local bot ${bot.id} was seated in an online game (game ${meta.gameId} seat ${seat}): local bots are client-driven and not dispatchable`); break; } } catch (error) { @@ -521,7 +521,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga /** In-DO brain: look the engine bot's move function up by its username in * the game's `botActions`, run it, and self-apply the result as this seat's - * action — a normal serialized command, so it commits as the next version, + * action: a normal serialized command, so it commits as the next version, * dedupes on its deterministic commandId, and (if it leaves another bot * pending) chains through the same effect dispatch. */ async #runBotBrain(meta: MetaRow, bot: Extract, seat: number, observation: ObservationSlice, next: StateRow): Promise { @@ -552,7 +552,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga }); if (!result.ok) { // A rejection here (e.g. a race lost the version) is expected and - // harmless — the winning transition already advanced the game. + // harmless; the winning transition already advanced the game. console.error(`in-DO bot move not applied (${result.code}) for game ${meta.gameId} seat ${seat}: ${result.message}`); } } @@ -560,7 +560,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga /** External bot: POST a signed wake carrying the bot's freshly-committed * observation, so the bot needs no callback to fetch state. Fire-and-forget: * a single attempt, no retry, and we neither wait for nor act on the - * result — the move arrives later on `/api/bot/action`, and a lost or bounced + * result: the move arrives later on `/api/bot/action`, and a lost or bounced * wake rides the turn deadline. A failure is therefore logged, never thrown; * the only bound is `WAKE_TIMEOUT_MS`, capping the held connection. */ async #wakeExternalBot(meta: MetaRow, bot: Extract, seat: number, observation: ObservationSlice, next: StateRow): Promise { @@ -587,9 +587,9 @@ export abstract class BaseGameDO extends DurableObject implements Ga signal: AbortSignal.timeout(WAKE_TIMEOUT_MS), }); await res.body?.cancel(); - if (!res.ok) console.warn(`external bot wake for game ${meta.gameId} seat ${seat} got HTTP ${res.status} — ignored (the turn deadline backstops it)`); + if (!res.ok) console.warn(`external bot wake for game ${meta.gameId} seat ${seat} got HTTP ${res.status}, ignored (the turn deadline backstops it)`); } catch (error) { - console.warn(`external bot wake for game ${meta.gameId} seat ${seat} failed — ignored (the turn deadline backstops it)`, error); + console.warn(`external bot wake for game ${meta.gameId} seat ${seat} failed, ignored (the turn deadline backstops it)`, error); } } @@ -608,7 +608,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga } /** Best-effort "your game is ready to start" push to the creator when a join - * fills the lobby. Fire-and-forget like the D1 mirror — the DO stays alive + * fills the lobby. Fire-and-forget like the D1 mirror; the DO stays alive * for the pending promise. */ #pushReady(creatorId: string, gameId: string): void { void this.firebaseAdmin(this.env).notifyUser(this.d1(this.env), creatorId, readyPush(gameId)); @@ -616,7 +616,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga /** The engine bot-signing master secret, read from env by the documented * `BOT_SIGNING_SECRET` convention (mirrors `FIREBASE_PROJECT_ID`). Null when - * unset — external bots are then unsupported and their wakes are skipped. */ + * unset; external bots are then unsupported and their wakes are skipped. */ #botSigningSecret(): string | null { const secret = (this.env as Record).BOT_SIGNING_SECRET; return typeof secret === "string" && secret.length > 0 ? secret : null; @@ -660,12 +660,12 @@ export abstract class BaseGameDO extends DurableObject implements Ga return; } const version = finalState.version + 1; - // Engine-owned action variant — no game hook produces or ever sees it. + // Engine-owned action variant; no game hook produces or ever sees it. const action: TransitionAction = { type: "system", kind: "ratings", data: { deltas }, playerIndex: null }; const frames = this.#project(meta, roster, finalState.state, [], null, false); this.#db.transaction((tx) => { tx.insert(t.transitions).values({ version, state: finalState.state, action, pending: [], deadline: null, playerTimes: null, turnStartedAt: null }).run(); - // Uniform like every transition — and drained one statement later by + // Uniform like every transition, and drained one statement later by // the compaction, kept ceremony-free on purpose: zero special cases // beats saving a doomed write. if (frames.length > 0) { @@ -690,7 +690,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga } /** The gated admin re-poke (step 4): re-runs the D1 apply for a - * finish whose effects never landed. Idempotent end to end — finish_id + * finish whose effects never landed. Idempotent end to end: finish_id * dedupes the apply, and the outbox row exists iff the ratings transition * hasn't been committed. Returns false when there is nothing to do. */ async repokeFinish(): Promise { @@ -703,7 +703,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga return this.#db.select({ finishId: t.outbox.finishId }).from(t.outbox).get() === undefined; } - // ── Deadline alarm — the ONLY alarm client ───────────────────────── + // ── Deadline alarm: the ONLY alarm client ───────────────────────── async alarm(): Promise { const meta = this.#loadMeta(); @@ -724,7 +724,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga // ── Sockets (hibernating/) ────────────────────────────────────── /** The worker routes the upgrade here after authenticating; the principal - * header is worker-set (never client-supplied — the worker strips inbound + * header is worker-set (never client-supplied; the worker strips inbound * headers when forwarding). One socket serves the game's whole lifetime: * unversioned roster snapshots pre-game, versioned frames from v0. * A not-yet-seated user's socket simply receives no frames until the @@ -742,7 +742,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga pair[1].serializeAttachment(attachment); this.ctx.acceptWebSocket(pair[1]); // Either way the open says where the game is, so a client never has to - // guess. Pre-game that is the roster snapshot (idempotent — a reconnect + // guess. Pre-game that is the roster snapshot (idempotent, so a reconnect // just gets it again); from v0 the roster is frozen and what moves is the // version, so that is what a sync carries. Both are cheap and neither // replays history: the client decides what, if anything, to fetch. @@ -781,7 +781,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga } /** Deliver one seat's frame to every socket whose authenticated principal - * owns that seat — resolved against the roster at send time. */ + * owns that seat, resolved against the roster at send time. */ #send(roster: Seat[], seat: number, message: FrameMessage): void { const owner = roster.find((s) => s.playerIndex === seat); if (owner === undefined || owner.userId === null) return; @@ -799,7 +799,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga } } - /** Push an unversioned roster snapshot to EVERY socket — seated or + /** Push an unversioned roster snapshot to EVERY socket, seated or * not, it is public lobby information and idempotent by construction. */ #broadcast(snapshot: RosterSnapshot): void { const payload = JSON.stringify(snapshot); @@ -807,7 +807,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga try { ws.send(payload); } catch { - // Dead socket — reconnect gets the current snapshot on open. + // Dead socket; reconnect gets the current snapshot on open. } } } @@ -841,7 +841,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga .orderBy(t.transitions.version) .all(); // Live path: one range read of the seat's stored frames (empty - // post-compaction and for replay — those re-project below). + // post-compaction and for replay, which re-project below). const stored = new Map(); if (args.seat !== null && !isReplay) { const frameRows = this.#db @@ -915,10 +915,10 @@ export abstract class BaseGameDO extends DurableObject implements Ga // ── Lazy init & snapshot loads ───────────────────────────────────── /** First contact: copy the D1 game row into `meta` + `roster`. The one - * sanctioned non-storage await near the gate — `blockConcurrencyWhile` + * sanctioned non-storage await near the gate: `blockConcurrencyWhile` * holds ALL events, so nothing interleaves, and it runs once per game. * Returns false when no such game exists (callers answer 404 / - * `unknownGame`; a missing row must not throw here — an exception inside + * `unknownGame`; a missing row must not throw here, since an exception inside * `blockConcurrencyWhile` resets the whole object). */ async #ensureInit(gameId: string): Promise { if (this.#loadMeta() !== undefined) return true; @@ -1002,7 +1002,7 @@ export abstract class BaseGameDO extends DurableObject implements Ga return frames.map((f) => ({ version, playerIndex: f.playerIndex, data: f.data, pendingPlayers: f.pendingPlayers })); } - /** One seat's stored live frame at one version — the frames-table PK. */ + /** One seat's stored live frame at one version: the frames-table PK. */ #storedView(version: number, seat: number): SeatView | null { const row = this.#db .select() diff --git a/packages/server/src/do/migrations/migrations.d.ts b/packages/server/src/do/migrations/migrations.d.ts index 7972d7b..8883097 100644 --- a/packages/server/src/do/migrations/migrations.d.ts +++ b/packages/server/src/do/migrations/migrations.d.ts @@ -1,5 +1,5 @@ /** Shape of the drizzle-kit `durable-sqlite` migrations bundle (generated - * `migrations.js` — regenerated by `pnpm db:generate:do`, never edited). The + * `migrations.js`, regenerated by `pnpm db:generate:do` and never edited). The * `.sql` import inside it is inlined as text by the tsup build. */ declare const migrations: { journal: { entries: { idx: number; when: number; tag: string; breakpoints: boolean }[] }; diff --git a/packages/server/src/do/schema.ts b/packages/server/src/do/schema.ts index 24e5450..e7935bc 100644 --- a/packages/server/src/do/schema.ts +++ b/packages/server/src/do/schema.ts @@ -1,5 +1,5 @@ /** - * The game DO's SQLite schema — one database per game, + * The game DO's SQLite schema: one database per game, * live *and* finished: after the finish compaction this IS the game's * permanent history, field-for-field the future cold-tier blob. * @@ -48,7 +48,7 @@ export const roster = sqliteTable("roster", { type: text().$type<"human" | "bot">().notNull(), }); -/** One row per version — the single-row transition shape, and it is +/** One row per version: the single-row transition shape, and it is * **append-only immutable**: no transition row is ever updated after commit, * which is what makes this table the game's permanent history verbatim. * The engine-owned envelope is the row's typed columns; `state` is the @@ -67,7 +67,7 @@ export const transitions = sqliteTable("transitions", { turnStartedAt: integer(), }); -/** Per-seat projected frames, identified seats only — LIVE-ONLY: serves +/** Per-seat projected frames, identified seats only. LIVE-ONLY: serves * socket gap recovery and the same-view compare, then the finish * compaction empties the whole table (replay re-projects instead). * A separate table so compaction never touches `transitions`. */ @@ -92,7 +92,7 @@ export const commands = sqliteTable("commands", { }); /** What the D1 apply needs, written atomically with the finish and - * cleared only AFTER the apply succeeds — a surviving row is the recovery + * cleared only AFTER the apply succeeds; a surviving row is the recovery * signal for the gated admin re-poke. */ export const outbox = sqliteTable("outbox", { finishId: text().primaryKey(), diff --git a/packages/server/src/engine.ts b/packages/server/src/engine.ts index b586b50..a8b58a0 100644 --- a/packages/server/src/engine.ts +++ b/packages/server/src/engine.ts @@ -1,5 +1,5 @@ /** - * `createEngine` — the deployable API. An implementor + * `createEngine` is the deployable API. An implementor * ships exactly: * * ```ts @@ -23,7 +23,7 @@ * (`/api/bot/*`, self-authenticated by an HMAC signature). They are * separate sub-apps mounted on one `/api` root, so the engine's auth * middleware is scoped to the engine sub-app and never touches the bot group. - * Handlers return only their declared 200 shape — every failure is an + * Handlers return only their declared 200 shape; every failure is an * HttpError throw rendered by the app-level error handler as `{ error, code? }`. */ @@ -69,7 +69,7 @@ export interface DeepLinkConfig { storeUrl?: string; }; apple?: { - /** `TEAMID.BUNDLEID` — the Universal Links `appID`. */ + /** `TEAMID.BUNDLEID`, the Universal Links `appID`. */ appId: string; /** App Store URL for the "not installed" fallback. */ storeUrl?: string; @@ -84,21 +84,21 @@ export interface AvatarsConfig { bucket(env: TEnv): R2Bucket; /** Max upload size in bytes; defaults to 2 MiB. */ maxBytes?: number; - /** Public base URL for direct-from-bucket reads — a bucket custom domain + /** Public base URL for direct-from-bucket reads: a bucket custom domain * (`https://avatars.game.com`) or an r2.dev URL. Absent/empty → the worker * serves avatars at `/avatars/{uid}` (the zoneless default). Set it and * stored `avatarUrl`s point straight at the bucket, so reads never invoke * the worker. An `env` accessor so dev (unset) and prod (a var) differ with - * no code change — the "the flip stays a config change" seam. */ + * no code change, the "the flip stays a config change" seam. */ publicBaseUrl?(env: TEnv): string | undefined; } -/** The EngineConfig seam: the engine never assumes binding names — the +/** The EngineConfig seam: the engine never assumes binding names, so the * implementor picks bindings off their own Env. Annotate the accessors' `env` * parameter and both type arguments infer. */ export interface EngineConfig> { gameModule: GameModule; - /** The whitelabel app's display name — the single source of truth for the + /** The whitelabel app's display name, the single source of truth for the * engine's own identity (share metadata and public-page titles today; * FCM titles and share copy later). Deliberately top-level, not nested under * `deepLink`, so there is one place to set it regardless of which optional @@ -126,10 +126,10 @@ export interface EngineConfig> { deepLink?: DeepLinkConfig; /** Opt-in avatar uploads. Omit → not mounted. */ avatars?: AvatarsConfig; - /** The public web surface — download page, legal documents, crawler files. + /** The public web surface: download page, legal documents, crawler files. * Omit → not mounted (the worker is API-only). */ site?: SiteConfig; - /** Cron-backstop tuning — guest-purge/reap windows and batch caps. + /** Cron-backstop tuning: guest-purge/reap windows and batch caps. * Omit for the defaults (`LIFECYCLE_DEFAULTS`); set any subset to * override just those. */ lifecycle?: LifecycleOptions; @@ -143,7 +143,7 @@ export interface EngineConfig> { }; } -/** {@link AvatarsConfig} with the implementor's Env generic erased — what the +/** {@link AvatarsConfig} with the implementor's Env generic erased: what the * routes see. `maxBytes` is resolved to its default here. */ export interface ResolvedAvatars { bucket(env: unknown): R2Bucket; @@ -158,7 +158,7 @@ export { DEFAULT_CREDIT } from "./site/config.js"; export interface RouteContext { gameModule: GameModule; - /** The whitelabel app name (see {@link EngineConfig.appName}) — read by the + /** The whitelabel app name (see {@link EngineConfig.appName}), read by the * `/j` landing page and any future engine-owned copy. */ appName: string; d1(env: unknown): D1Database; @@ -170,7 +170,7 @@ export interface RouteContext { * binding. Null keeps native-only deployments working without web output. */ webAssets(env: unknown): Fetcher | null; /** The engine bot-signing master secret, read from env by the - * `BOT_SIGNING_SECRET` convention. Null when unset — the `/api/bot/action` + * `BOT_SIGNING_SECRET` convention. Null when unset; the `/api/bot/action` * route then refuses every request (external bots are unsupported). */ botSigningSecret(env: unknown): string | null; /** Required Firebase Admin effects used by FCM and account deletion. */ @@ -182,10 +182,10 @@ export interface RouteContext { * deployment serves web only. Browser `/join` and `/game` routes can still * use the Flutter asset binding. */ deepLink: DeepLinkConfig | null; - /** Avatar config, or null when uploads are not enabled — the + /** Avatar config, or null when uploads are not enabled, in which case the * upload/serve routes are then not mounted. */ avatars: ResolvedAvatars | null; - /** Site config, or null when the public web surface is not configured — the + /** Site config, or null when the public web surface is not configured, in which case the * landing/legal/crawler routes are then not mounted. */ site: ResolvedSite | null; } @@ -199,7 +199,7 @@ export interface Authed { export type AppEnv = { Bindings: object; Variables: { auth: Authed } }; /** A fresh @hono/zod-openapi app with the engine's shared validation-error - * shape. No basePath — callers mount it under a prefix (`/api/engine`, + * shape. No basePath: callers mount it under a prefix (`/api/engine`, * `/api/bot`) so a validation failure reads the same everywhere. */ function newOpenApiApp() { return new OpenAPIHono({ @@ -213,7 +213,7 @@ function newOpenApiApp() { }); } -/** The engine's hono app type — what the route modules register against. Both +/** The engine's hono app type, what the route modules register against. Both * the Firebase-authed engine group and the HMAC bot group are this shape (a * bare @hono/zod-openapi app); the auth difference is per-group middleware, not * a different app type. */ @@ -244,7 +244,7 @@ function authMiddleware(ctx: RouteContext): MiddlewareHandler { // remains a configuration-free liveness probe. ctx.firebaseAdmin(c.env); const header = c.req.header("authorization"); - // The query fallback exists for WebSocket upgrades — browsers cannot set + // The query fallback exists for WebSocket upgrades, since browsers cannot set // headers on those. Everything else sends the Authorization header. const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : c.req.query("token"); if (token === undefined || token.length === 0) { @@ -277,7 +277,7 @@ function browserCors(ctx: RouteContext): MiddlewareHandler { * * The engine and bot groups are separate `OpenAPIHono` instances, so the * engine's `use("*", auth)` is scoped to it and never runs for `/api/bot/*` or - * the web routes — those authenticate themselves (HMAC) or are public. + * the web routes, which authenticate themselves (HMAC) or are public. * Mounting merges each group's OpenAPI operations into the outer document, so a * single spec describes both API groups, each with its own security scheme * (`firebase` vs `botHmac`). The web routes are plain (non-OpenAPI) handlers @@ -318,7 +318,7 @@ export function buildApp(ctx: RouteContext) { name: "Eigen-Signature", description: "An external bot's HMAC signature over the exact request body, bound to the `action` domain. Scheme `v1,`; the per-bot key is `HMAC(BOT_SIGNING_SECRET, botId)`. The engine signs wakes with the same header in the other direction.", }); - // Liveness. Unconditional, unauthed, and deliberately does NO I/O — no D1, + // Liveness. Unconditional, unauthed, and deliberately does NO I/O: no D1, // no DO, no config disclosure. That is what makes it safe to leave open: it // costs exactly one worker invocation, the same as the 404 every unknown // path already returns, so it adds no amplification surface and needs no @@ -331,7 +331,7 @@ export function buildApp(ctx: RouteContext) { // thing for an app to show. `security: []` opts it out of the document-wide // bearer requirement. // - // `no-store` matters — a cached 200 at the edge would keep reporting healthy + // `no-store` matters, because a cached 200 at the edge would keep reporting healthy // after the worker stopped being able to serve. app.openapi( createRoute({ @@ -342,12 +342,12 @@ export function buildApp(ctx: RouteContext) { security: [], summary: "Liveness probe", description: - "Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable — it does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected.", + "Public, unauthenticated liveness check. Performs no I/O and reads no configuration, so a 200 means only that the worker is deployed and routable. It does **not** imply that D1, the game Durable Objects, or auth are correctly configured. Served `no-store`. Safe to call without a token; a bad token is ignored rather than rejected.", responses: { 200: { // `status` is a plain string, NOT a literal/enum. A single-member // enum would generate a closed Dart enum, and closed enums are a - // breaking change to extend — so a later "degraded" would need a + // breaking change to extend, so a later "degraded" would need a // schema-version bump and a coordinated client release. The status // of a liveness probe is exactly the kind of field that grows, and // clients should treat the 200 itself as the signal anyway. @@ -472,7 +472,7 @@ export function createEngine>( return { fetch: (request, env, executionCtx) => app.fetch(request, env, executionCtx), // The cron backstop: stale-guest purge + abandoned-game reap. No - // timeout sweep — the DO deadline alarm owns every turn deadline. Runs + // timeout sweep, since the DO deadline alarm owns every turn deadline. Runs // in-band; the platform keeps the invocation alive while the promise // pends, so no waitUntil is needed. scheduled: (_controller, env) => runScheduled(ops(env), cfg.lifecycle), @@ -481,12 +481,12 @@ export function createEngine>( // ── OpenAPI emission (generated here, vendored into the Dart repo) ────── -/** Build the API document from an inert app — route handlers never run, so +/** Build the API document from an inert app: route handlers never run, so * the context can refuse everything. `appName` is an unused placeholder here: * with `deepLink: null` the landing route (its only reader) is never mounted. * * `version` is an argument rather than a constant in here because it has - * exactly one correct value — `@eigeninteractive/server`'s own — and changesets + * exactly one correct value, `@eigeninteractive/server`'s own, and changesets * owns that value. Baked in as a literal it silently disagrees with the package * on the first release: nothing reads it back, and the CI drift check only * compares this file against itself, so the lie survives every check. The Dart @@ -522,7 +522,7 @@ export function openApiDocument(version: string): OpenAPIObject { // documentation (otherwise they appear in first-route-seen order). tags: [ { name: "Games", description: "Create, join, start, play and finish games. The heart of the API: every move is a proposal the server validates against authoritative state." }, - { name: "Me", description: "The signed-in player — profile, username, ratings, rating history and registered devices." }, + { name: "Me", description: "The signed-in player: profile, username, ratings, rating history and registered devices." }, { name: "Players", description: "Other players: their public profile, their finished games and their ratings." }, { name: "Social", description: "Friends, friend requests, blocking and user search." }, { name: "Bots", description: "The bots this deployment offers as opponents." }, diff --git a/packages/server/src/google/oauth.ts b/packages/server/src/google/oauth.ts index 88bb5f5..f22efee 100644 --- a/packages/server/src/google/oauth.ts +++ b/packages/server/src/google/oauth.ts @@ -1,5 +1,5 @@ /** - * Google service-account OAuth — the shared token step + * Google service-account OAuth: the shared token step * for every Google API the engine calls: FCM (`notify/fcm.ts`) and the * Firebase Auth admin delete (`auth/admin.ts`). We sign the * service-account JWT (RS256) with `jose` and exchange it at Google's token @@ -24,7 +24,7 @@ const TOKEN_SKEW_MS = 60_000; export interface ServiceAccount { projectId: string; clientEmail: string; - /** PKCS#8 PEM. Env storage often escapes newlines — {@link readServiceAccount} + /** PKCS#8 PEM. Env storage often escapes newlines, so {@link readServiceAccount} * un-escapes them. */ privateKey: string; } @@ -47,11 +47,11 @@ interface CachedToken { token: string; expiresAt: number; } -/** Keyed by `${clientEmail}\n${scope}` — FCM and admin use different scopes off +/** Keyed by `${clientEmail}\n${scope}`, since FCM and admin use different scopes off * the same account, so they must not share a cache slot. */ const cache = new Map(); -/** A bearer for `scope` — cached per (account, scope) in the isolate, minted +/** A bearer for `scope`, cached per (account, scope) in the isolate, minted * via a signed service-account JWT exchanged at Google's token endpoint. */ export async function accessToken(sa: ServiceAccount, scope: string): Promise { const now = Date.now(); diff --git a/packages/server/src/history/store.ts b/packages/server/src/history/store.ts index e0789ec..b35e6e1 100644 --- a/packages/server/src/history/store.ts +++ b/packages/server/src/history/store.ts @@ -3,8 +3,8 @@ * replay fetches its projected transitions through this ~20-line interface, so * the source of history can change without touching the replay route. * - * V1 ships exactly ONE implementation — {@link doHistoryStore}, the game's own - * DO range-fetch — and no dispatch logic. The future cold-tier sweep adds an + * V1 ships exactly ONE implementation, {@link doHistoryStore}, the game's own + * DO range-fetch, and no dispatch logic. The future cold-tier sweep adds an * R2-backed implementation and a DO-if-present-else-R2 composition behind this * same interface; the route never changes. * @@ -17,7 +17,7 @@ import type { FrameMessage, GameStub } from "../protocol.js"; export interface HistoryStore { /** Projected frames for a finished game's version range, for the caller's - * seat (null = public-viewer projection). Raw state never leaves the DO — + * seat (null = public-viewer projection). Raw state never leaves the DO; * the projection happens at the source. */ replay(gameId: string, args: { seat: number | null; from: number; to: number }): Promise; } diff --git a/packages/server/src/http.ts b/packages/server/src/http.ts index 498e1e9..3f26d9b 100644 --- a/packages/server/src/http.ts +++ b/packages/server/src/http.ts @@ -9,7 +9,7 @@ import type { RejectCode } from "@eigeninteractive/kernel"; import type { CommandResult, LobbyRejectCode } from "./protocol.js"; /** - * Every stable machine code an error body may carry — the kernel's and lobby's + * Every stable machine code an error body may carry: the kernel's and lobby's * rejection vocabularies plus the few the routes raise directly. This is the * closed set the wire enum (`errorCodeShape`) publishes, so a client can * `switch` on it exhaustively; adding a member here is a wire change and needs @@ -23,7 +23,7 @@ export type ErrorCode = | Exclude | LobbyRejectCode /** Raised by a route before the command reaches the game. Each one exists - * because the client renders a distinct response to it — a field-level form + * because the client renders a distinct response to it: a field-level form * error, a "create an account" prompt, a retry with a different file. A * failure the client can only report generically stays uncoded. */ | "schemaUnsupported" @@ -38,7 +38,7 @@ export type ErrorCode = export class HttpError extends Error { readonly status: 400 | 401 | 403 | 404 | 409 | 413 | 415 | 422 | 429 | 500 | 502; readonly code: ErrorCode | undefined; - /** Seconds the caller should wait before retrying — rendered as the + /** Seconds the caller should wait before retrying, rendered as the * `Retry-After` header. Set only on a 429 (see `ErrorCode.rateLimited`); * `undefined` everywhere else. */ readonly retryAfterSeconds: number | undefined; @@ -74,7 +74,7 @@ export function rejectStatus(code: RejectCode | LobbyRejectCode): 400 | 403 | 40 export function unwrap(result: CommandResult): Extract { if (!result.ok) { // A timeout that lost its race abstains, and only the DO's alarm raises - // system intents — so an abstain answering a client command is an engine bug. + // system intents, so an abstain answering a client command is an engine bug. if (result.code === "abstain") throw new HttpError(500, "engine bug: a client command was abstained"); throw new HttpError(rejectStatus(result.code), result.message, result.code); } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a959ef8..6dbba34 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,5 +1,5 @@ /** - * `@eigeninteractive/server` — everything that deploys: the + * `@eigeninteractive/server`: everything that deploys, being the * `createEngine` API factory, the GameDO base class, the D1 applier, and * the protocol types. * @@ -15,7 +15,7 @@ export { displayRating, type RatingDelta } from "@eigeninteractive/kernel"; export { type AuthClaims, AuthError, createFirebaseVerifier, type TokenVerifier } from "./auth/firebase.js"; export { ensureUser, type UserRow } from "./auth/provision.js"; // The operator utility for onboarding an external bot: derive the key you hand -// its owner. The signing/verifying halves stay internal — the engine does that. +// its owner. The signing/verifying halves stay internal; the engine does that. export { deriveBotKey } from "./bot/bot-auth.js"; export { applyFinish, type CreateGameInput, createGame, type FinishApplyInput, mirrorRoster, readGameRow, updateSummary } from "./d1/apply.js"; export { isTransientD1Error, type RetryOptions, withRetry } from "./d1/retry.js"; diff --git a/packages/server/src/lifecycle/cron.ts b/packages/server/src/lifecycle/cron.ts index 96c1453..8b7e5d7 100644 --- a/packages/server/src/lifecycle/cron.ts +++ b/packages/server/src/lifecycle/cron.ts @@ -1,13 +1,13 @@ /** - * The cron backstop — the worker's `scheduled` handler. + * The cron backstop: the worker's `scheduled` handler. * - * Deliberately NOT a timeout sweep. A game's DO deadline alarm is its timer — - * durable, per-game, platform-retried — so turn timeouts never need scanning + * Deliberately NOT a timeout sweep. A game's DO deadline alarm is its timer, + * durable, per-game and platform-retried, so turn timeouts never need scanning * for. This handler does only what has no per-entity timer of its own: * - * 1. **Stale-guest purge** — old, inactive anonymous accounts, torn down + * 1. **Stale-guest purge**: old, inactive anonymous accounts, torn down * through the same {@link purgeUser} path the delete-account route uses. - * 2. **Abandoned-game reap** — never-started lobbies, and untimed active games + * 2. **Abandoned-game reap**: never-started lobbies, and untimed active games * (which have no alarm at all) idle too long, aborted so they stop * occupying the lobby and release their DO storage. * @@ -24,7 +24,7 @@ import { type EngineOps, purgeUser } from "./purge.js"; const DAY_MS = 24 * 60 * 60 * 1000; -/** The knobs on the cron backstop — every one optional, so an implementor +/** The knobs on the cron backstop, every one optional, so an implementor * overrides only what they mean to and inherits {@link LIFECYCLE_DEFAULTS} for * the rest. Surfaced as `lifecycle` on `createEngine`. Times are milliseconds. */ export interface LifecycleOptions { @@ -37,9 +37,9 @@ export interface LifecycleOptions { lobbyTtlMs?: number; /** An untimed active game idle for this long is abandoned. Long, because * untimed games are legitimately slow (correspondence-style) and have no - * deadline alarm to lean on — this is the only backstop that ever ends one. */ + * deadline alarm to lean on, so this is the only backstop that ever ends one. */ untimedActiveTtlMs?: number; - /** Max guests purged per run — a daily sweep, so a bounded batch drains a + /** Max guests purged per run. A daily sweep, so a bounded batch drains a * backlog over a few days rather than doing unbounded work in one run. */ guestBatch?: number; /** Max games reaped per run (same bounded-batch reasoning). */ diff --git a/packages/server/src/lifecycle/purge.ts b/packages/server/src/lifecycle/purge.ts index ffca2c7..90b7b91 100644 --- a/packages/server/src/lifecycle/purge.ts +++ b/packages/server/src/lifecycle/purge.ts @@ -1,15 +1,15 @@ /** - * Account deletion & guest purge — the one path shared + * Account deletion & guest purge: the one path shared * by the `DELETE /api/engine/me` route and the cron guest sweep. * * Order is **games → Firebase → D1**, and it matters: Firebase is a separate * system, and our auth middleware re-provisions a `users` row on any valid - * token — so deleting the D1 row while the Firebase account still lives would + * token, so deleting the D1 row while the Firebase account still lives would * let the very next request RESURRECT the user. We therefore: * * 1. resolve the user's live games and forfeit / cancel / leave each (a * rated forfeit applies ratings while the user row still exists); - * 2. delete the Firebase account (single attempt) — on failure we throw + * 2. delete the Firebase account (single attempt); on failure we throw * BEFORE touching D1, so nothing is half-deleted and a retry is clean; * 3. run the D1 purge as one `batch()`. * @@ -25,14 +25,14 @@ import { deviceInstallations, games, participants, playerRatings, ratingHistory, import type { FirebaseAdminEffects } from "../firebase/admin-effects.js"; import type { Command, GameStub } from "../protocol.js"; -/** The engine surface the lifecycle paths need — supplied by `createEngine` +/** The engine surface the lifecycle paths need, supplied by `createEngine` * (route handlers and the `scheduled` handler alike). */ export interface EngineOps { d1: D1Database; stub(gameId: string): GameStub; /** Required Firebase Admin effects, real in production and explicit in tests. */ firebaseAdmin: FirebaseAdminEffects; - /** The avatars R2 bucket, or null when uploads aren't enabled — the user's + /** The avatars R2 bucket, or null when uploads aren't enabled. The user's * avatar object (key = uid) is deleted on purge when present. */ avatarBucket: R2Bucket | null; } @@ -45,7 +45,7 @@ interface LiveSeat { seat: number; } -/** The user's live games (waiting/ready/active) with their seat — through the +/** The user's live games (waiting/ready/active) with their seat, through the * participants index. No limit: a deletion must clear every one. */ async function readLiveSeats(d1: D1Database, userId: string): Promise { const rows = await orm(d1) @@ -58,7 +58,7 @@ async function readLiveSeats(d1: D1Database, userId: string): Promise { @@ -89,7 +89,7 @@ async function purgeD1(d1: D1Database, userId: string): Promise { } /** Delete `userId` end to end. Throws only if the Firebase delete - * fails — the D1 purge is then intentionally skipped so the account stays + * fails, in which case the D1 purge is skipped so the account stays * fully retriable (never resurrectable). The caller decides what a throw * means: the route surfaces an error; the cron logs and moves on. */ export async function purgeUser(ops: EngineOps, userId: string): Promise { @@ -101,7 +101,7 @@ export async function purgeUser(ops: EngineOps, userId: string): Promise { await purgeD1(ops.d1, userId); // Best-effort: the avatar object outlives the D1 row otherwise. A - // failure just leaves an orphaned blob — harmless, no identity points at it. + // failure just leaves an orphaned blob: harmless, no identity points at it. if (ops.avatarBucket !== null) { await ops.avatarBucket.delete(userId).catch((error) => console.error(`purge: deleting avatar for ${userId} failed`, error)); } diff --git a/packages/server/src/notify/fcm.ts b/packages/server/src/notify/fcm.ts index 9865369..9b1c981 100644 --- a/packages/server/src/notify/fcm.ts +++ b/packages/server/src/notify/fcm.ts @@ -1,5 +1,5 @@ /** - * FCM (HTTP v1) sender — posts messages to the FCM + * FCM (HTTP v1) sender: posts messages to the FCM * endpoint with a bearer from the shared `google/oauth` token step. Pure FCM: * no database access (the FID lookup + stale-device pruning live in `push.ts`). * @@ -13,10 +13,10 @@ import { accessToken, readServiceAccount, type ServiceAccount } from "../google/ const MESSAGING_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"; // Re-exported so `push.ts` and the DO keep importing the service-account -// surface from `notify` — the token plumbing moved to `google/oauth`. +// surface from `notify`; the token plumbing moved to `google/oauth`. export { readServiceAccount, type ServiceAccount }; -/** The notification payload — the boundary type for the FCM HTTP v1 API. */ +/** The notification payload: the boundary type for the FCM HTTP v1 API. */ export interface NotificationMessage { title: string; body: string; @@ -30,13 +30,13 @@ export interface PushTarget { platform: "ios" | "android" | "web"; } -/** `error.status` values that mean the installation is permanently dead — safe +/** `error.status` values that mean the installation is permanently dead, and safe * to prune. Firebase documents a send to an unregistered FID as a 404 * (`NOT_FOUND`); `UNREGISTERED` covers the transitional error spelling. * Transient statuses and `INVALID_ARGUMENT` are deliberately retained. */ const PRUNABLE_STATUS = new Set(["NOT_FOUND", "UNREGISTERED"]); -/** One send's result — `prunable` tells the caller to drop a dead device. */ +/** One send's result. `prunable` tells the caller to drop a dead device. */ export interface SendResult { fid: string; prunable: boolean; diff --git a/packages/server/src/notify/push.ts b/packages/server/src/notify/push.ts index 11c6b4b..4573ecd 100644 --- a/packages/server/src/notify/push.ts +++ b/packages/server/src/notify/push.ts @@ -1,13 +1,13 @@ /** - * Turn/finish push orchestration — the DB-touching half + * Turn/finish push orchestration: the DB-touching half * of the FCM flow: resolve a user's device FIDs, send, and prune permanently * dead installations. The pure send lives in {@link ./fcm.js}. * * Best-effort and single-attempt: every entry point catches its own * errors and logs, so a caller can fire them unawaited without an outer guard - * (in the DO that's a bare post-commit call — no `waitUntil`). A human's - * push has no retry — the game state is the truth and the app catches up on - * open — so a failed send just disappears, and a repeated turn (timeout + * (in the DO that's a bare post-commit call, with no `waitUntil`). A human's + * push has no retry, since the game state is the truth and the app catches up + * on open, so a failed send just disappears, and a repeated turn (timeout * re-nudge) is the only "retry" there is. */ @@ -56,7 +56,7 @@ export function finishPush(gameId: string): NotificationMessage { /** The "your game is ready to start" push, addressed to the creator when a * join fills the lobby while they are away. */ export function readyPush(gameId: string): NotificationMessage { - return { title: "Ready to start", body: "Your game has enough players — tap to start.", data: { category: "gameReady", deepLink: `/game/${gameId}` } }; + return { title: "Ready to start", body: "Your game has enough players. Tap to start.", data: { category: "gameReady", deepLink: `/game/${gameId}` } }; } /** The "a friend started a game you can join" push, fanned out to the diff --git a/packages/server/src/openapi.ts b/packages/server/src/openapi.ts index 29ddcbb..f07f457 100644 --- a/packages/server/src/openapi.ts +++ b/packages/server/src/openapi.ts @@ -1,7 +1,7 @@ /** * Node-safe entry for OpenAPI emission (`scripts/emit-openapi.mjs`): pulls - * only the route/app graph — nothing that imports `cloudflare:workers` at - * runtime — so the document can be generated outside workerd and vendored + * only the route/app graph, nothing that imports `cloudflare:workers` at + * runtime, so the document can be generated outside workerd and vendored * into the Dart repo for client codegen. */ diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 32618b9..8f38207 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -1,6 +1,6 @@ /** * The worker ⇄ DO ⇄ client protocol types: commands - * are self-contained, pre-authenticated VALUES — loggable, replayable, and a + * are self-contained, pre-authenticated VALUES: loggable, replayable, and a * CI fixture is a JSON array of them. The worker verifies the Firebase token * and runs every policy check BEFORE minting a command; the DO enforces * integrity (seat occupancy, status, versions) under its input gate. @@ -9,7 +9,7 @@ import type { GameStatus, RatingDelta, RejectCode, Seat } from "@eigeninteractive/kernel"; import type { JsonObject, LifecycleType, OutcomeEntry } from "@eigeninteractive/rules"; -/** Re-exported from the kernel — rating math and its shapes live there. */ +/** Re-exported from the kernel, where rating math and its shapes live. */ export type { RatingDelta }; /** Who a command acts as, resolved at the edge. Exactly one id is set. */ @@ -30,13 +30,13 @@ export type Command = gameId: string; commandId: string; actor: Principal; - /** The acting seat — carried uniformly by humans and bots. The + /** The acting seat, carried uniformly by humans and bots. The * DO verifies it belongs to the actor (user id from the token, bot id * from the HMAC claim) against its own roster and rejects otherwise, so * a client can never act on a seat it does not hold. Required because * one bot id may hold several seats, and uniform for one code path. */ seat: number; - /** The version the client computed the move against — a lower value is + /** The version the client computed the move against; a lower value is * arbitrated by the same-view rule. */ expectedVersion: number; data: unknown; @@ -54,18 +54,18 @@ export type Command = seat?: number; }; -/** Why the DO refused a waiting-room command — the integrity column. +/** Why the DO refused a waiting-room command: the integrity column. * These are *expected* refusals (accepted lobby staleness: the lobby may show * a game that just filled), returned as values exactly like kernel * rejections; the worker maps them to HTTP. Genuine protocol violations * (acting on a seat you don't own) still throw. */ export type LobbyRejectCode = - /** No game with this id exists — the DO is authoritative, so commands + /** No game with this id exists. The DO is authoritative, so commands * skip the worker-side D1 existence read entirely. */ | "unknownGame" /** The game is no longer in a lobby status (`waiting`/`ready`). */ | "notJoinable" - /** Every seat is taken — the accepted lobby race. */ + /** Every seat is taken: the accepted lobby race. */ | "gameFull" /** The actor already holds a seat. */ | "alreadyJoined" @@ -73,11 +73,11 @@ export type LobbyRejectCode = | "notParticipant" /** A creator-only command (`cancel`, `add-bot`, `start`) from a non-creator. */ | "notCreator" - /** The creator cannot leave — they cancel instead. */ + /** The creator cannot leave; they cancel instead. */ | "creatorCannotLeave"; /** The unversioned pre-game snapshot: pushed to every socket on any - * roster change, idempotent — a reconnect just gets the current one. Also the + * roster change, idempotent, so a reconnect just gets the current one. Also the * response body of an accepted waiting-room command. */ export interface RosterSnapshot { type: "roster"; @@ -85,7 +85,7 @@ export interface RosterSnapshot { players: Seat[]; } -/** One seat's versioned frame on the wire — the socket fan-out payload, and +/** One seat's versioned frame on the wire: the socket fan-out payload, and * (for the acting seat) the command-response ride-along. `ratings` appears * only on the post-finish ratings transition. */ export interface FrameMessage { @@ -104,12 +104,12 @@ export interface FrameMessage { * * The pre-game equivalent is the {@link RosterSnapshot} that rides the open; * from v0 onward the roster is frozen, so this carries the one thing that does - * still move — the newest committed version. + * still move: the newest committed version. * * It exists so a client can reconcile in one step instead of guessing. A cold * open learns which version to load without replaying the whole game, and a * reconnect can compare against its own cursor and skip the catch-up fetch - * entirely when nothing was missed — the common case on a flaky connection, + * entirely when nothing was missed, the common case on a flaky connection, * where reconnects are frequent but usually miss nothing. */ export interface SyncMessage { type: "sync"; @@ -118,12 +118,12 @@ export interface SyncMessage { /** What `GameDO.handle()` returns; accepted results are stored for commandId * dedupe and replayed verbatim to a retry. Rejections are computed - * fresh each time — re-evaluating one is always sound. State-transitioning + * fresh each time, since re-evaluating one is always sound. State-transitioning * commands answer with a version (+ the acting seat's frame); waiting-room * commands answer with the post-commit roster snapshot. */ export type CommandResult = { ok: true; version: number; frame: FrameMessage | null } | { ok: true; roster: RosterSnapshot } | { ok: false; code: RejectCode | LobbyRejectCode; message: string }; -/** The DO surface the worker calls — structurally the RPC stub of any +/** The DO surface the worker calls: structurally the RPC stub of any * `BaseGameDO` subclass. Lives here (not in `engine.ts`) so the lifecycle * paths (purge, cron reap) can depend on it without importing the app * factory. */ @@ -132,7 +132,7 @@ export interface GameStub { frames(args: { seat: number | null; from: number; to: number; isReplay?: boolean }): Promise; repokeFinish(): Promise; /** Unconditional teardown: mark the game aborted, drop DO storage. - * Used by the cron reap for abandoned lobbies / untimed games — no creator + * Used by the cron reap for abandoned lobbies / untimed games, with no creator * gate, unlike the `cancel` command. */ abort(gameId: string): Promise; fetch(request: Request): Promise; diff --git a/packages/server/src/rate-limit.ts b/packages/server/src/rate-limit.ts index 1fac6e6..5f41b5d 100644 --- a/packages/server/src/rate-limit.ts +++ b/packages/server/src/rate-limit.ts @@ -3,23 +3,23 @@ * expensive (or abusive) in bulk. Backed by the Workers `ratelimit` binding. * * The engine owns the WIRING, not the numbers. The platform enforces the - * `limit`/`period` written in `wrangler.jsonc` — Worker code can neither set nor - * read those — so the numbers live there, once, per app. What the engine owns is + * `limit`/`period` written in `wrangler.jsonc` (Worker code can neither set nor + * read those) so the numbers live there, once, per app. What the engine owns is * the set of limiters ({@link RateLimitName}) and the conventional binding name * for each ({@link RATE_LIMIT_BINDING}): an app declares a `ratelimits` entry * under each name and rate limiting is on, with nothing to wire in code. The * names are `EIGEN_`-prefixed so an engine binding never collides with one the - * game defines. `namespace_id` is the app's to choose — it must be a positive + * game defines. `namespace_id` is the app's to choose: it must be a positive * integer and is account-scoped, so two Workers sharing an id share counters; a * shared engine constant could not pick ids unique within each account. A name - * bound nowhere is unlimited — the local/dev/test default. + * bound nowhere is unlimited, which is the local/dev/test default. * - * The binding is per-colo, eventually consistent, and — in Cloudflare's own - * words — "not an accounting system": an abuse dampener, not a hard quota. + * The binding is per-colo, eventually consistent, and, in Cloudflare's own + * words, "not an accounting system": an abuse dampener, not a hard quota. * Reads are never limited; a popular public read (an avatar, a lobby page) is a * caching problem, and rejecting legitimate callers would be the wrong tool. * - * Keys are always the caller's stable user id (never an IP — many legitimate + * Keys are always the caller's stable user id (never an IP, since many legitimate * users share one, per Cloudflare's guidance). */ @@ -40,7 +40,7 @@ export const RATE_LIMIT_BINDING: Record = { /** Advisory `Retry-After` (seconds) on a 429. Flat and conservative on purpose: * the engine cannot read the binding's configured window, so it does not - * pretend to — one sensible "wait a bit" beats a per-limiter guess that goes + * pretend to: one sensible "wait a bit" beats a per-limiter guess that goes * stale the moment an app tunes a `period`. */ export const RATE_LIMIT_RETRY_AFTER_SECONDS = 60; @@ -52,14 +52,14 @@ export interface RateLimiter { limit(options: { key: string }): Promise<{ success: boolean }>; } -/** Whether an env value is a usable limiter — the structural test the resolver +/** Whether an env value is a usable limiter: the structural test the resolver * uses before treating a binding as one. */ export function isRateLimiter(value: unknown): value is RateLimiter { return typeof value === "object" && value !== null && typeof (value as RateLimiter).limit === "function"; } /** Resolve the limiter for a name off `env` by its conventional binding name, - * or null when the app did not bind it (unlimited — the dev/test default). */ + * or null when the app did not bind it (unlimited, the dev/test default). */ export function resolveRateLimiter(env: unknown, name: RateLimitName): RateLimiter | null { const binding = (env as Record)[RATE_LIMIT_BINDING[name]]; return isRateLimiter(binding) ? binding : null; @@ -73,6 +73,6 @@ export async function enforceRateLimit(env: unknown, name: RateLimitName, key: s if (limiter === null) return; const { success } = await limiter.limit({ key }); if (!success) { - throw new HttpError(429, "Too many requests in a short window — slow down and try again.", "rateLimited", RATE_LIMIT_RETRY_AFTER_SECONDS); + throw new HttpError(429, "Too many requests in a short window. Slow down and try again.", "rateLimited", RATE_LIMIT_RETRY_AFTER_SECONDS); } } diff --git a/packages/server/src/routes/account.ts b/packages/server/src/routes/account.ts index d43c909..6fd1e74 100644 --- a/packages/server/src/routes/account.ts +++ b/packages/server/src/routes/account.ts @@ -1,10 +1,10 @@ /** - * Account lifecycle — the caller deleting their own + * Account lifecycle: the caller deleting their own * account. Runs the shared {@link purgeUser} path: forfeit/cancel/leave the * caller's live games, delete the Firebase account, then purge D1. * * A Firebase-delete failure throws BEFORE the D1 purge (see purge.ts ordering), - * so the account is left fully intact and retriable — we surface that to the + * so the account is left fully intact and retriable, and we surface that to the * client as a 502 rather than half-deleting. */ @@ -19,19 +19,19 @@ import { purgeUser } from "../lifecycle/purge.js"; import { invalidateAvatarCache } from "./avatars.js"; import { displayNameBody, errorShape, usernameBody } from "./wire.js"; -/** The username charset — the same one provisioning sanitizes to: lowercase +/** The username charset, the same one provisioning sanitizes to: lowercase * letters, digits, underscore, and dot, 3–20 chars. */ const USERNAME_RE = /^[a-z0-9_.]{3,20}$/; /** The only UNIQUE column this UPDATE can violate is `username`, so the - * un-narrowed {@link isUniqueViolation} is exactly right here — no column name + * un-narrowed {@link isUniqueViolation} is exactly right here, with no column name * to keep in sync with the schema. */ const isUsernameCollision = isUniqueViolation; export function registerAccountRoutes(app: EngineApp, ctx: RouteContext): void { // The caller changes their own username (the stable handle; distinct from the - // provider display name). Charset-validated for a precise error, and unique — - // a clash is a clean 409. + // provider display name). Charset-validated for a precise error, and unique, + // so a clash is a clean 409. app.openapi( createRoute({ method: "put", @@ -62,7 +62,7 @@ export function registerAccountRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // The caller changes their own display name — the free-form label shown + // The caller changes their own display name: the free-form label shown // beside their moves, seeded from the identity provider at provisioning. // Unlike the username it is neither unique nor charset-constrained, so there // is no failure here beyond the length bound the schema already enforces. @@ -95,7 +95,7 @@ export function registerAccountRoutes(app: EngineApp, ctx: RouteContext): void { responses: { 204: { description: "The account and its data were deleted" }, 401: { content: { "application/json": { schema: errorShape } }, description: "Missing or invalid token" }, - 502: { content: { "application/json": { schema: errorShape } }, description: "Deletion failed — the account is intact; retry" }, + 502: { content: { "application/json": { schema: errorShape } }, description: "Deletion failed; the account is intact, retry" }, }, }), async (c) => { @@ -105,7 +105,7 @@ export function registerAccountRoutes(app: EngineApp, ctx: RouteContext): void { await purgeUser({ d1: ctx.d1(c.env), stub: (gameId) => ctx.stub(c.env, gameId), firebaseAdmin: ctx.firebaseAdmin(c.env), avatarBucket: ctx.avatars === null ? null : ctx.avatars.bucket(c.env) }, userId); } catch (error) { console.error(`delete-account for ${userId} failed`, error); - throw new HttpError(502, "Account deletion failed — please try again"); + throw new HttpError(502, "Account deletion failed. Please try again"); } // The R2 object is gone; drop any edge-cached copy so the (immutable) // versioned URL stops serving it. Awaited so deletion completes fully. diff --git a/packages/server/src/routes/avatars.ts b/packages/server/src/routes/avatars.ts index 3d5603e..3266e8f 100644 --- a/packages/server/src/routes/avatars.ts +++ b/packages/server/src/routes/avatars.ts @@ -1,6 +1,6 @@ /** - * Opt-in avatar uploads. Clients never write to R2 directly — there is no way - * to scope a bucket credential to one user — so uploads go through the + * Opt-in avatar uploads. Clients never write to R2 directly, because there is no + * way to scope a bucket credential to one user, so uploads go through the * worker, which authenticates the caller and owns the key: a raw-binary * `PUT /api/engine/me/avatar` (authed) streams the image to R2 under key = * uid, and a public `GET /avatars/:uid` serves it with a long immutable cache. @@ -35,14 +35,14 @@ function avatarUrl(publicBase: string | undefined, uid: string, version: number) export function registerAvatarUpload(engine: EngineApp, ctx: RouteContext): void { const avatars = ctx.avatars as ResolvedAvatars; // Plain route (not OpenAPI): a raw binary body doesn't model cleanly in the - // spec, so — like the game socket — it is hand-written on the client rather + // spec, so, like the game socket, it is hand-written on the client rather // than generated. Clients PUT the image bytes directly with an image/* // content-type (never multipart) and read `{ avatarUrl }` off the 200. engine.put("/me/avatar", async (c) => { await enforceRateLimit(c.env, "avatar_upload", c.var.auth.user.id); const contentType = (c.req.header("content-type") ?? "").split(";")[0]?.trim() ?? ""; if (!ALLOWED_TYPES.has(contentType)) { - throw new HttpError(415, `Unsupported image type '${contentType}' — use image/jpeg, image/png, or image/webp`, "unsupportedImageType"); + throw new HttpError(415, `Unsupported image type '${contentType}'. Use image/jpeg, image/png, or image/webp`, "unsupportedImageType"); } const body = await c.req.arrayBuffer(); if (body.byteLength === 0) throw new HttpError(400, "Empty upload"); @@ -60,8 +60,8 @@ export function registerAvatarUpload(engine: EngineApp, ctx: RouteContext): void /** * Drop a served avatar from the Worker's edge cache so a cached 200 does not * outlive the object. The serve route below treats a versioned URL as immutable - * (the `?v` only changes on re-upload, which mints a new key), so deletion — - * which removes the bytes without changing the URL — is the one case the cache + * (the `?v` only changes on re-upload, which mints a new key), so deletion, + * which removes the bytes without changing the URL, is the one case the cache * must be told about. A no-op when the stored URL is absolute (a bucket custom * domain serves those reads, so the Worker never cached them) or absent. * Per-colo, like every Cache API write: it clears the colo that handled the @@ -75,21 +75,21 @@ export async function invalidateAvatarCache(requestUrl: string, avatarUrl: strin export function registerAvatarServe(app: EngineApp, ctx: RouteContext): void { const avatars = ctx.avatars as ResolvedAvatars; - // Public, unauthed — avatars are world-readable. The `?v` query is a + // Public, unauthed, since avatars are world-readable. The `?v` query is a // client cache-buster; the object key is the uid alone. // // Fronted by the Worker's own edge cache (`caches.default`): a Worker - // response is NOT edge-cached automatically — the immutable `Cache-Control` + // response is NOT edge-cached automatically; the immutable `Cache-Control` // below only reaches the device and any downstream CDN, so without this every // cold viewer would run the Worker and read R2. The full request URL is the // cache key, so the `?v={ts}` bumped on each upload makes a re-upload a // natural miss and ages old versions out; the stored object is overwritten in // place under the uid, so there is never a stale hit to invalidate. (When // `avatars.publicBaseUrl` points at a bucket custom domain, reads bypass the - // Worker entirely and this route is unused — the production fast path.) + // Worker entirely and this route is unused: the production fast path.) app.get("/avatars/:uid", async (c) => { const cache = caches.default; - // A GET Request over the exact URL — the cache key. `Request` defaults to GET. + // A GET Request over the exact URL: the cache key. `Request` defaults to GET. const cacheKey = new Request(c.req.url); const hit = await cache.match(cacheKey); if (hit !== undefined) return hit; diff --git a/packages/server/src/routes/devices.ts b/packages/server/src/routes/devices.ts index 58284e6..8f08137 100644 --- a/packages/server/src/routes/devices.ts +++ b/packages/server/src/routes/devices.ts @@ -1,12 +1,12 @@ /** - * Device registration — the ingress that makes FCM pushes deliverable. A + * Device registration: the ingress that makes FCM pushes deliverable. A * client upserts its Firebase Installation ID (FID) and deletes the * installation on sign-out. The send side reads and prunes this table; these * two routes are the only writers. * * Keyed on the FID (one row per app install): signing in on a device reassigns * that FID from any prior user, so a FID always maps to exactly one user and no - * stale association lingers — otherwise a shared device would push one user's + * stale association lingers, or else a shared device would push one user's * turn alerts to whoever signed in after them. */ @@ -34,7 +34,7 @@ export function registerDeviceRoutes(app: EngineApp, ctx: RouteContext): void { tags: ["Me"], request: { body: { content: { "application/json": { schema: deviceBody } }, required: true } }, responses: { - 204: { description: "Registered — the caller's pushes will reach this install" }, + 204: { description: "Registered: the caller's pushes will reach this install" }, 400: { content: { "application/json": { schema: errorShape } }, description: "Invalid request" }, 401: { content: { "application/json": { schema: errorShape } }, description: "Missing or invalid token" }, }, diff --git a/packages/server/src/routes/games.ts b/packages/server/src/routes/games.ts index 3ba3fbb..e26b48b 100644 --- a/packages/server/src/routes/games.ts +++ b/packages/server/src/routes/games.ts @@ -32,7 +32,7 @@ const errorResponses = { 401: error("Missing or invalid token"), 403: error("Not allowed"), 404: error("Not found"), - 409: error("State conflict — resync and retry"), + 409: error("State conflict; resync and retry"), 422: error("Assertion mismatch"), } as const; @@ -41,7 +41,7 @@ function responses(schema: T, description: string) { return { 200: { content: { "application/json": { schema } }, description }, ...errorResponses } as const; } -/** A 201 Created carrying `schema` — for the endpoints that create a resource. */ +/** A 201 Created carrying `schema`, for the endpoints that create a resource. */ function createdResponses(schema: T, description: string) { return { 201: { content: { "application/json": { schema } }, description }, ...errorResponses } as const; } @@ -49,7 +49,7 @@ function createdResponses(schema: T, description: string) { const gameIdParam = z.object({ gameId: z.string().min(1) }); /** Narrow an accepted result to the lobby (roster) variant, stripping the - * internal `ok` discriminator — success is the HTTP 200, not a body field. */ + * internal `ok` discriminator; success is the HTTP 200, not a body field. */ function lobbyResult(result: CommandResult) { const value = unwrap(result); if (!("roster" in value)) throw new HttpError(500, "engine bug: expected a roster response"); @@ -57,7 +57,7 @@ function lobbyResult(result: CommandResult) { } /** Narrow an accepted result to the versioned (frame) variant, stripping the - * internal `ok` discriminator — success is the HTTP 200, not a body field. */ + * internal `ok` discriminator; success is the HTTP 200, not a body field. */ function commandResult(result: CommandResult) { const value = unwrap(result); if (!("version" in value)) throw new HttpError(500, "engine bug: expected a versioned response"); @@ -71,7 +71,7 @@ function rulesFor(ctx: RouteContext, schemaVersion: number): GameRules { } /** The bot-seating gates, shared by `add-bot` and create-solo. `game` is - * anything with the game's timing/rated/schema/config — a stored row or a + * anything with the game's timing/rated/schema/config: a stored row or a * to-be-created spec. Throws an `HttpError` on any failed gate. */ interface BotSeatingGame { schemaVersion: number; @@ -84,7 +84,7 @@ interface BotSeatingGame { function assertBotSeatable(ctx: RouteContext, game: BotSeatingGame, bot: BotRow): void { // SERVER-seated bots ⇒ timed. Dispatch is single-attempt, so a brain that // throws, an external webhook that never answers, or a DO evicted mid-turn is - // resolved only by the turn deadline firing the alarm — the one liveness + // resolved only by the turn deadline firing the alarm, the one liveness // backstop. Untimed means no deadline, no alarm, and a game wedged forever. // // Scoped to *server* seating on purpose: a client-driven bot has no dispatch @@ -134,7 +134,7 @@ async function loadGame(ctx: RouteContext, env: unknown, gameId: string): Promis // ── Short codes (D1 UNIQUE + retry loop) ──────────────────────────────── -/** No 0/O/1/I/L — these codes are read aloud and typed. */ +/** No 0/O/1/I/L, because these codes are read aloud and typed. */ const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; const CODE_LENGTH = 6; const CODE_ATTEMPTS = 5; @@ -147,7 +147,7 @@ function generateShortCode(): string { // ── Routes ──────────────────────────────────────────────────────────────────── export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { - // Create — the one worker-direct write, and the only place game policy is + // Create: the one worker-direct write, and the only place game policy is // decided outside a DO: guest gates, config parse, ratingPool, and the // client's `rated` assertion (validated, never coerced). app.openapi( @@ -183,7 +183,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { config, }); // `rated` is a concrete assertion the client also computes (the Dart - // twin) — a mismatch means twin drift or a forged client; reject it + // twin). A mismatch means twin drift or a forged client; reject it // rather than silently coercing. There is no forced-rated, so a sent // `false` is always valid. const canBeRated = pool !== null && !auth.claims.isAnonymous; @@ -217,7 +217,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { now, }); // Friends-access game: fan out an invite push to the creator's - // accepted friends. Best-effort and off the response path — a friend + // accepted friends. Best-effort and off the response path: a friend // with notifications off (or none at all) costs nothing, and a push // failure never affects the create. if (body.access === "friends") { @@ -233,7 +233,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // create-solo — create a private game seated with the caller plus bots, + // create-solo: create a private game seated with the caller plus bots, // and start it, in one call. Guests may play bots (unrated); the same // create policy and bot-seating gates apply, then an immediate `start`. app.openapi( @@ -254,7 +254,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { if (!parsedConfig.ok) throw new HttpError(400, parsedConfig.message); const config = parsedConfig.value as JsonObject; - // Solo games are always private — no lobby, no invites. + // Solo games are always private: no lobby, no invites. const pool = rules.ratingPool({ access: "private", turnSeconds: body.turnSeconds, @@ -317,7 +317,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { // Start immediately: the DO lazy-inits from D1 (bots included) and // commits v0; a bot due to open plays via its in-DO brain post-commit // (arriving over the socket). A start has no single acting seat, so its - // response carries no frame — read the creator's opening projection back + // response carries no frame, so read the creator's opening projection back // so the client has the initial board without a round trip. const stub = ctx.stub(c.env, gameId); const started = commandResult(await stub.handle(mint(auth, "start", gameId, undefined))); @@ -326,7 +326,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // join — worker policy: guest-vs-rated, friends access, schema gate. + // join: worker policy is guest-vs-rated, friends access, schema gate. const join = async (c: { env: unknown; var: { auth: Authed } }, game: GameWithRoster, clientSchemaVersion: number, commandId: string | undefined) => { const auth = c.var.auth; if (game.rated && auth.claims.isAnonymous) { @@ -343,7 +343,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { } // The seating boundary: the caller and anyone they have blocked (either // direction) must never share a game. Answered as `unknownGame`, not a - // "blocked" code — the lobby already hides this game from the pair, so a + // "blocked" code, because the lobby already hides this game from the pair, so a // direct attempt (a shared code, a deep link) sees the same "no such game" // a genuine miss would, leaking nothing and needing no new wire code. const seatedUserIds = game.participants.flatMap((p) => (p.userId !== null ? [p.userId] : [])); @@ -360,7 +360,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "joinGame", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(joinBody) }, - responses: responses(joinedShape, "Seated — the game's id and the post-join roster"), + responses: responses(joinedShape, "Seated: the game's id and the post-join roster"), }), async (c) => { const body = c.req.valid("json"); @@ -377,7 +377,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "joinGameByCode", tags: ["Games"], request: { body: jsonBody(joinByCodeBody) }, - responses: responses(joinedShape, "Seated — the game's id and the post-join roster"), + responses: responses(joinedShape, "Seated: the game's id and the post-join roster"), }), async (c) => { const body = c.req.valid("json"); @@ -395,7 +395,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "leaveGame", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(lobbyCommandBody) }, - responses: responses(lobbyAcceptedShape, "Left — the post-leave roster"), + responses: responses(lobbyAcceptedShape, "Left: the post-leave roster"), }), async (c) => { const { gameId } = c.req.valid("param"); @@ -420,7 +420,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // add-bot — worker policy: registry gates (schema, rated eligibility, + // add-bot: worker policy is the registry gates (schema, rated eligibility, // timed invariant, brain-or-webhook) and botSeatable. Guests may add bots // (unrated only, enforced at create/join); the timed invariant and rated // gate are shared with create-solo via `assertBotSeatable`. @@ -431,7 +431,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "addBot", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(addBotBody) }, - responses: responses(lobbyAcceptedShape, "Bot seated — the new roster"), + responses: responses(lobbyAcceptedShape, "Bot seated: the new roster"), }), async (c) => { const auth = c.var.auth; @@ -453,7 +453,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "startGame", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(lobbyCommandBody) }, - responses: responses(commandAcceptedShape, "Started — version 0 committed"), + responses: responses(commandAcceptedShape, "Started: version 0 committed"), }), async (c) => { const { gameId } = c.req.valid("param"); @@ -462,9 +462,9 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // Act — a player's move. The client sends its own seat (uniform with bots); + // Act: a player's move. The client sends its own seat (uniform with bots); // the DO verifies it belongs to the caller against its own roster (the - // authoritative copy — the D1 participants mirror only displays) and the + // authoritative copy, since the D1 participants mirror only displays) and the // caller's committed frame rides the response. No D1 read on this path. app.openapi( createRoute({ @@ -473,7 +473,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "submitAction", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(actionBody) }, - responses: responses(commandAcceptedShape, "Committed — the acting seat's frame"), + responses: responses(commandAcceptedShape, "Committed: the acting seat's frame"), }), async (c) => { const body = c.req.valid("json"); @@ -515,7 +515,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // Transitions — the range fetch: live gap recovery AND finished-game replay, one + // Transitions, the range fetch: live gap recovery AND finished-game replay, one // path. Participants read their own seat; a finished PUBLIC game is // replayable by anyone as the null-seat viewer projection. app.openapi( @@ -545,16 +545,16 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { } const page = 1000; const cappedTo = Math.min(to ?? from + page - 1, from + page - 1); - // Finished games replay through the HistoryStore seam — DO-backed + // Finished games replay through the HistoryStore seam: DO-backed // in v1, R2-backed later; live gap recovery stays a direct DO fetch. const frames = finished ? await ctx.history(c.env).replay(gameId, { seat: mySeat, from, to: cappedTo }) : await ctx.stub(c.env, gameId).frames({ seat: mySeat, from, to: cappedTo, isReplay: false }); return c.json({ frames }, 200); }, ); - // The WebSocket — one socket for the game's whole lifetime. + // The WebSocket: one socket for the game's whole lifetime. // Not an OpenAPI route (documents can't describe the upgrade); auth rides - // the `?token=` query. The worker stamps the principal header itself — + // the `?token=` query. The worker stamps the principal header itself; // inbound x-eigen-* headers are dropped wholesale. app.get("/games/:gameId/socket", async (c) => { if (c.req.header("upgrade")?.toLowerCase() !== "websocket") { diff --git a/packages/server/src/routes/links.tsx b/packages/server/src/routes/links.tsx index 134e509..330cf31 100644 --- a/packages/server/src/routes/links.tsx +++ b/packages/server/src/routes/links.tsx @@ -1,14 +1,14 @@ /** - * Deep linking & share pages — the game worker is the link host. Three + * Deep linking & share pages, where the game worker is the link host. Three * unauthed, non-OpenAPI routes, generated from the `deepLink` config so there * is one source of truth and no hand-maintained JSON: * - * - `GET /.well-known/assetlinks.json` — Android App Links verification. - * - `GET /.well-known/apple-app-site-association` — iOS Universal Links - * (extensionless, served as `application/json` — the historic gotcha a + * - `GET /.well-known/assetlinks.json`: Android App Links verification. + * - `GET /.well-known/apple-app-site-association`: iOS Universal Links + * (extensionless, served as `application/json`, the historic gotcha a * static file gets wrong). - * - `GET /join/:shortCode` — the invite/share landing page. - * - `GET /game/:gameId` — a specific game's landing page (replay / spectate). + * - `GET /join/:shortCode`: the invite/share landing page. + * - `GET /game/:gameId`: a specific game's landing page (replay / spectate). * * Both return Flutter's SPA shell when an `ASSETS` binding is present, * enriched with real OG tags from the D1 summary. An installed native app @@ -22,9 +22,9 @@ * invite/share landing; `/game/:id` is a specific game (the app's replay links * and its push-notification deep links). `assetlinks.json` grants * `handle_all_urls` for the whole host, so the app claims only the paths its - * `` declares — `android:pathPrefix="/join"` and - * `android:pathPrefix="/game"`. Everything else on the host — the `site` - * group's `/`, `/terms`, `/privacy`, `/delete-account` — is deliberately left + * `` declares: `android:pathPrefix="/join"` and + * `android:pathPrefix="/game"`. Everything else on the host (the `site` + * group's `/`, `/terms`, `/privacy`, `/delete-account`) is deliberately left * unclaimed so it opens in the browser. iOS mirrors this in the AASA `paths` * below. */ @@ -37,9 +37,9 @@ import { NEW_TAB, Page, renderDocument } from "../site/page.js"; /** Native-only fallback when no Flutter asset binding exists. It still carries * the OG tags a chat client unfurls. `noindex` because it is ephemeral and - * per-game — unfurl scrapers still read the OG tags, which is what matters. + * per-game; unfurl scrapers still read the OG tags, which is what matters. * - * `origin` is the request origin, so the OG image URL is absolute — which + * `origin` is the request origin, so the OG image URL is absolute, which * unfurl scrapers require. */ function SharePage({ appName, title, description, stores, ctx, origin }: { appName: string; title: string; description: string; stores: { label: string; url: string }[]; ctx: RouteContext; origin: string }) { return ( @@ -87,7 +87,7 @@ export function registerLinkRoutes(app: EngineApp, ctx: RouteContext): void { } if (cfg?.apple !== undefined) { - // Extensionless AASA — the content type MUST be application/json. Legacy + // Extensionless AASA: the content type MUST be application/json. Legacy // `paths` form, broadly supported. Both app prefixes are listed; the site // group's pages are deliberately absent, which is what keeps Universal // Links off `/terms` and friends. @@ -142,14 +142,14 @@ export function registerLinkRoutes(app: EngineApp, ctx: RouteContext): void { if (game === undefined) { return renderAppLinkResponse(c.req.raw, assets, appName, "This game link is no longer valid.", 404); } - // A private game's roster must not leak to an unauthenticated visitor — the + // A private game's roster must not leak to an unauthenticated visitor. The // app authorizes the viewer before showing a replay, this page cannot. Show // a generic card and let the app do the gating. if (game.access !== "public") { return renderAppLinkResponse(c.req.raw, assets, appName, `Open this game in ${appName}.`, 200); } const vs = await versusLine(ctx.d1(c.env), game); - const suffix = vs === null ? "" : ` — ${vs}`; + const suffix = vs === null ? "" : `, ${vs}`; const description = game.status === "finished" ? `See how this game of ${appName} played out${suffix}.` : `Watch this game of ${appName}${suffix}.`; return renderAppLinkResponse(c.req.raw, assets, vs === null ? appName : `${vs} · ${appName}`, description, 200); }); diff --git a/packages/server/src/routes/reads.ts b/packages/server/src/routes/reads.ts index c00857b..9bb56eb 100644 --- a/packages/server/src/routes/reads.ts +++ b/packages/server/src/routes/reads.ts @@ -1,5 +1,5 @@ /** - * The read routes: worker → D1, never a DO — lobby, + * The read routes: worker → D1, never a DO. Lobby, * my-games (through the participants index), the game summary, the batch * players endpoint, the bot catalog, and the caller's profile + ratings. */ @@ -69,7 +69,7 @@ export function registerReadRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "getGame", tags: ["Games"], request: { params: z.object({ gameId: z.string().min(1) }) }, - responses: okResponse(gameSummaryShape, "The game summary — never state"), + responses: okResponse(gameSummaryShape, "The game summary, never state"), }), async (c) => { const game = await readGame(ctx.d1(c.env), c.req.valid("param").gameId); @@ -78,7 +78,7 @@ export function registerReadRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // The batch identity endpoint — why games rows carry no denormalized + // The batch identity endpoint, and why games rows carry no denormalized // identity: a renamed user is correct everywhere on the next fetch, with no // history rewrite. The client's persisted cache keeps this warm. app.openapi( @@ -140,7 +140,7 @@ export function registerReadRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // Any player's finished public games — the replay list on a profile. Public + // Any player's finished public games: the replay list on a profile. Public // and finished only, so this exposes nothing about someone that was not // already replayable by anyone holding the game's id. app.openapi( @@ -159,7 +159,7 @@ export function registerReadRoutes(app: EngineApp, ctx: RouteContext): void { }, ); - // Any player's ratings, human or bot — the profile sheet shown for an + // Any player's ratings, human or bot: the profile sheet shown for an // opponent, and the rating line on a bot in the picker. Display ratings are // public (they are visible on every finished game), so this needs no // relationship check; it is tagged `Players` alongside the batch identity diff --git a/packages/server/src/routes/site.tsx b/packages/server/src/routes/site.tsx index 7bc8d1f..aa26520 100644 --- a/packages/server/src/routes/site.tsx +++ b/packages/server/src/routes/site.tsx @@ -1,12 +1,12 @@ /** - * The game's public web surface — everything a deployed game serves on its own + * The game's public web surface: everything a deployed game serves on its own * host besides the API. Unauthed, outside `/api`, and absent from the OpenAPI * document, exactly like the deep-link routes in `links.tsx`: * - * - `GET /download` — the native app download page, rendered from `site`. - * - `GET /terms`, `/privacy`, `/delete-account` — the legal documents. - * - `GET /sitemap.xml`, `GET /robots.txt` — crawler directives. - * - `GET /site.webmanifest` — the web app manifest. + * - `GET /download`: the native app download page, rendered from `site`. + * - `GET /terms`, `/privacy`, `/delete-account`: the legal documents. + * - `GET /sitemap.xml`, `GET /robots.txt`: crawler directives. + * - `GET /site.webmanifest`: the web app manifest. * * The scaffold lists these in Static Assets' `run_worker_first`, keeping * Flutter's SPA fallback from swallowing legal, download, and crawler routes. @@ -15,7 +15,7 @@ * **Path note.** These live on the game's own host alongside the app's deep * links (`/join/:code`, `/game/:id`), so the app's Android App Links * intent-filter must be scoped with `android:pathPrefix="/join"` and - * `android:pathPrefix="/game"` — otherwise Android claims *every* path on the + * `android:pathPrefix="/game"`, or else Android claims *every* path on the * host and a tap on "Terms of Service" bounces back into the app, which has no * such route. iOS is already scoped by the AASA `paths` entry. */ @@ -42,7 +42,7 @@ function storeLinks(deepLink: DeepLinkConfig | null): { label: string; url: stri } /** The first action is the page's call to action and takes the filled button; - * the rest are outlined. Which one comes first depends on what the game has — + * the rest are outlined. Which one comes first depends on what the game has: * a game with no web build leads with its store link. */ function Actions({ links }: { links: { label: string; url: string }[] }) { return ( @@ -57,7 +57,7 @@ function Actions({ links }: { links: { label: string; url: string }[] }) { } /** The EigenInteractive mark, shown when the game has no icon of its own to - * show yet — which is every game until a Flutter web build reaches `public/`. + * show yet, which is every game until a Flutter web build reaches `public/`. * * Inline rather than a served file: it is two paths, so a request for it would * cost more than the bytes it saves, and inline is what lets it be *drawn* in @@ -87,7 +87,7 @@ function Screenshots({ site }: { site: ResolvedSite }) { ); } -/** `SoftwareApplication`/`GameApplication` rather than `Organization` — this +/** `SoftwareApplication`/`GameApplication` rather than `Organization`, because this * page is a game, and that is what a search engine should understand it to be. */ function jsonLdFor(site: ResolvedSite, deepLink: DeepLinkConfig | null, origin: string, ogImage: string): string { const os = [deepLink?.android !== undefined ? "Android" : null, deepLink?.apple !== undefined ? "iOS" : null].filter((v) => v !== null); @@ -122,7 +122,7 @@ export function registerSiteRoutes(app: EngineApp, ctx: RouteContext): void { app.get(path, (c) => c.html( renderDocument( - + , ), @@ -221,7 +221,7 @@ export function registerDownloadRoute(app: EngineApp, ctx: RouteContext): void { // Whether a Flutter web build is actually deployed, which two things on the // page depend on: the "Play on the web" button, and the app icon. // - // The ASSETS binding alone cannot answer this — the scaffold binds it whether + // The ASSETS binding alone cannot answer this: the scaffold binds it whether // or not `public/` has anything in it, so it is bound from the first // `wrangler dev`. Offering "Play on the web" on that evidence sends the // visitor to `/`, which has no asset to serve and so redirects back here. @@ -229,7 +229,7 @@ export function registerDownloadRoute(app: EngineApp, ctx: RouteContext): void { // `index.html` there is nothing for the fallback to serve, so it 404s. // // The icons are the same question, because `flutter build web` emits - // `favicon.png` and `icons/` beside `index.html` — one bundle, so one probe. + // `favicon.png` and `icons/` beside `index.html`: one bundle, so one probe. const hasWebBuild = async (env: unknown, origin: string): Promise => { const assets = ctx.webAssets(env); if (assets === null) return false; @@ -249,7 +249,7 @@ export function registerDownloadRoute(app: EngineApp, ctx: RouteContext): void { return c.html( renderDocument( ((v) => typeof v === "object" && v ! * fails to compile, and a code listed here that no longer exists does too. */ const errorCodeDocs: Record = { - // Kernel rejections — the command reached the game and it refused. + // Kernel rejections: the command reached the game and it refused. notActive: "The game is not in a status that accepts this intent", notReady: "Start was requested but the game is not ready", expired: "The turn deadline (plus grace) had already passed", notPending: "The acting seat is not in the pending set", - stateUpdated: "The board advanced past the version acted against — resync and retry", + stateUpdated: "The board advanced past the version acted against; resync and retry", invalidPayload: "The action payload failed the version unit's action schema", illegalMove: "The game's applyAction refused the move", - // Lobby rejections — the waiting-room commands. + // Lobby rejections: the waiting-room commands. unknownGame: "No game with this id exists", notJoinable: "The game is no longer in a lobby status", gameFull: "Every seat is taken", @@ -56,7 +56,7 @@ const errorCodeDocs: Record = { registrationRequired: "The action needs a registered account; the caller is a guest", imageTooLarge: "The uploaded avatar exceeds the size limit", unsupportedImageType: "The uploaded avatar is not an accepted image type", - rateLimited: "Too many requests in a short window — retry after the interval in the Retry-After header", + rateLimited: "Too many requests in a short window; retry after the interval in the Retry-After header", }; /** The closed set of stable error codes, published as an enum so a client can @@ -72,7 +72,7 @@ export const errorCodeShape = z.enum(Object.keys(errorCodeDocs) as [ErrorCode, . /** The one error envelope for every non-2xx response: a human message plus an * optional stable `code` the client keys typed handling off. Named * `ErrorResponse` (not `Error`) to avoid colliding with Dart's `dart:core.Error` - * in the generated client. The message is display copy and may be reworded — + * in the generated client. The message is display copy and may be reworded, so * dispatch on `code`, never on `error`. */ export const errorShape = z.object({ error: z.string(), code: errorCodeShape.optional() }).openapi("ErrorResponse"); @@ -104,7 +104,7 @@ export const outcomeShape = z export const ratingDeltaShape = z .object({ - /** Exactly one id is set — the same nullable-pair shape as `Seat`. */ + /** Exactly one id is set: the same nullable-pair shape as `Seat`. */ identity: z.object({ userId: z.string().nullable(), botId: z.string().nullable() }).openapi("RatingIdentity"), pool: z.string(), muBefore: z.number(), @@ -121,7 +121,7 @@ export const frameShape = z .object({ type: z.literal("frame"), version: z.number().int(), - /** The seat's projected observation — game-defined, never raw state. */ + /** The seat's projected observation: game-defined, never raw state. */ data: jsonObjectShape, pendingPlayers: z.array(z.number().int()), deadline: z.number().int().nullable(), @@ -157,7 +157,7 @@ export const lobbyAcceptedShape = z /** An accepted join, by id or by short code. * - * Both forms answer identically — they are the same operation from the caller's + * Both forms answer identically; they are the same operation from the caller's * side ("seat me in this game"), and only differ in how the game was named. The * id is echoed rather than assumed because the by-code caller never had it, and * a single shape means one client path from either entry point instead of two @@ -216,7 +216,7 @@ export const profileShape = playerShape }) .openapi("Profile"); -/** The other user's public identity plus when the relationship formed — the +/** The other user's public identity plus when the relationship formed: the * shared base of an accepted friend and a pending request. */ const friendBase = playerShape.extend({ userId: z.string(), since: z.number().int() }).omit({ id: true }); @@ -234,7 +234,7 @@ export const friendTargetBody = z.object({ targetUserId: z.string().min(1) }).op export const usernameBody = z.object({ username: z.string().min(3).max(20) }).openapi("UsernameUpdate"); /** A display-name change. Free-form (it seeds from the identity provider's - * name), so only length is constrained; uniqueness is deliberately not — two + * name), so only length is constrained; uniqueness is deliberately not, since two * players may share a display name, which is what the username disambiguates. */ export const displayNameBody = z.object({ displayName: z.string().trim().min(1).max(40) }).openapi("DisplayNameUpdate"); @@ -273,8 +273,8 @@ export const createGameBody = z config: jsonObjectShape, minPlayers: z.number().int().min(1), maxPlayers: z.number().int().min(1), - /** The client's concrete rated assertion (Dart twin of `ratingPool`) — - * validated, never coerced. Absent ⇒ rated when eligible. */ + /** The client's concrete rated assertion (Dart twin of `ratingPool`), + * validated and never coerced. Absent ⇒ rated when eligible. */ rated: z.boolean().optional(), ...timingFields, }) @@ -287,7 +287,7 @@ export const createdShape = z.object({ gameId: z.string(), shortCode: z.string() /** Create-solo: a private game seated with the caller plus one or more * bots, created and started in one call. Same timing/config fields as - * `createGameBody` (no `access` — solo games are always private) plus the + * `createGameBody` (no `access`, since solo games are always private) plus the * bots to seat. */ export const createSoloBody = z .object({ @@ -313,13 +313,13 @@ export const createSoloBody = z * same ride-along an action response carries). */ export const soloStartedShape = z.object({ gameId: z.string(), shortCode: z.string(), version: z.number().int(), frame: frameShape.nullable() }).openapi("SoloStarted"); -/** Client retries reuse the same commandId — the DO replays the stored +/** Client retries reuse the same commandId, so the DO replays the stored * response instead of re-executing. */ const commandId = z.string().min(1).max(128).optional(); export const joinBody = z .object({ - /** The newest schemaVersion this client build ships rules for — the + /** The newest schemaVersion this client build ships rules for: the * schema gate (an old app cannot join a newer game). */ clientSchemaVersion: z.number().int(), commandId: commandId, @@ -334,7 +334,7 @@ export const addBotBody = z.object({ botId: z.string(), commandId: commandId }). export const actionBody = z .object({ - /** The caller's own seat — verified against the roster at the DO; + /** The caller's own seat, verified against the roster at the DO; * a seat the caller doesn't hold is rejected. Carried uniformly with bots. */ seat: z.number().int().min(0), /** Game-defined move payload; parsed by the version unit's action schema. */ @@ -382,8 +382,8 @@ export function playerOf(u: Pick { diff --git a/packages/server/src/runtime-globals.d.ts b/packages/server/src/runtime-globals.d.ts index 5b9c301..8ef9f71 100644 --- a/packages/server/src/runtime-globals.d.ts +++ b/packages/server/src/runtime-globals.d.ts @@ -3,7 +3,7 @@ * `ES2024` lib does not yet declare. Remove entries here as they land in the * standard lib (then bump `lib` and delete the shim). * - * `Uint8Array` base64 codec — the TC39 "Uint8Array to/from base64" methods + * `Uint8Array` base64 codec: the TC39 "Uint8Array to/from base64" methods * (https://github.com/tc39/proposal-arraybuffer-base64), shipped in workerd but * still in TS's `esnext` lib, not `es2024`. Used by `bot/bot-auth.ts`. */ diff --git a/packages/server/src/site/config.ts b/packages/server/src/site/config.ts index b10ca59..ac2ec29 100644 --- a/packages/server/src/site/config.ts +++ b/packages/server/src/site/config.ts @@ -14,11 +14,11 @@ export interface OperatorConfig { /** Support and privacy contact address. */ contactEmail: string; /** Effective date of the legal documents, as displayed. A plain string, not a - * Date — it is prose, and its format is the operator's choice. */ + * Date, since it is prose and its format is the operator's choice. */ effectiveDate: string; } -/** Legal document overrides. Each is an HTML **fragment** — body content only, +/** Legal document overrides. Each is an HTML **fragment**: body content only, * no document wrapper; the engine supplies the shell, styling and footer. * Omitted documents fall back to the engine's generic templates. * @@ -64,7 +64,7 @@ export interface SiteConfig { /** Path under `public/` to the 1200x630 OG image. Defaults to * `/og-image.png`, the name the * {@link https://eigeninteractive.com/docs/ship-it/branding | branding guide} - * prescribes for the Flutter app's own share card — one image, both + * prescribes for the Flutter app's own share card: one image, both * surfaces. The engine never generates images. */ ogImage?: string; /** Footer credit line. Defaults to {@link DEFAULT_CREDIT}; `null` removes it. */ @@ -74,7 +74,7 @@ export interface SiteConfig { } /** {@link SiteConfig} with every default applied and every legal document - * already rendered to an HTML fragment — what the routes see. Rendering happens + * already rendered to an HTML fragment: what the routes see. Rendering happens * once, at startup, so a request never builds prose. */ export interface ResolvedSite { name: string; diff --git a/packages/server/src/site/fonts.ts b/packages/server/src/site/fonts.ts index be118a0..6a3a8a2 100644 --- a/packages/server/src/site/fonts.ts +++ b/packages/server/src/site/fonts.ts @@ -28,7 +28,7 @@ import spaceGroteskWoff2 from "./fonts/space-grotesk.js"; * Bumped when a font file is replaced. * * The URLs below are served `immutable`, which tells a browser never to - * revalidate — correct only while a URL's bytes never change. Replacing a file + * revalidate, correct only while a URL's bytes never change. Replacing a file * without bumping this would leave every returning visitor on the old face * until their cache expired, a year later. */ diff --git a/packages/server/src/site/fonts/README.md b/packages/server/src/site/fonts/README.md index 315adf2..90fcce8 100644 --- a/packages/server/src/site/fonts/README.md +++ b/packages/server/src/site/fonts/README.md @@ -18,7 +18,7 @@ unstyled in the suite; for a font it would be a route that answers 500 in every test while working in production. A vite `load` plugin, a vite `transform` plugin, a workerd `Text` module rule -and a wrangler `rules` entry were all tried. None reach it — wrangler's `rules` +and a wrangler `rules` entry were all tried. None reach it; wrangler's `rules` are documented as not applying under the Vite plugin. A string literal in a `.ts` file is the one representation every pipeline agrees on. @@ -27,7 +27,7 @@ are documented as not applying under the Vite plugin. A string literal in a The `.woff2` is the source of truth; the `.ts` beside it is generated from it by `scripts/encode-fonts.mjs` and verified in CI, so the two cannot drift. -Download the family from Google Fonts — [Inter], [Space Grotesk] — and take the +Download the family from Google Fonts ([Inter], [Space Grotesk]) and take the `*-VariableFont_*.ttf` from the top level of the archive, not from `static/`. Then subset, convert, and re-encode: @@ -42,7 +42,7 @@ python3 -m fontTools.subset Inter-VariableFont_opsz,wght.ttf \ pnpm run fonts ``` -Then bump `VERSION` in `../fonts.ts` — the URLs are served `immutable`, so a +Then bump `VERSION` in `../fonts.ts`, since the URLs are served `immutable`, so a returning visitor keeps the old bytes for a year otherwise. The subset is latin, punctuation, currency and arrows. `tnum` is kept because @@ -50,7 +50,7 @@ the share page renders game codes. Inter's `opsz` axis is retained and nothing sets it, so it rests at its default of 14, the text optical size. The docs site serves the same two files from its own `static/fonts/`. They are -separate repositories, so nothing enforces that — replace both together. +separate repositories, so nothing enforces that. Replace both together. Both faces are SIL Open Font License 1.1; the notices are in `OFL-Inter.txt` and `OFL-SpaceGrotesk.txt`. diff --git a/packages/server/src/site/legal/delete-account.tsx b/packages/server/src/site/legal/delete-account.tsx index ea3fef1..a7c3c35 100644 --- a/packages/server/src/site/legal/delete-account.tsx +++ b/packages/server/src/site/legal/delete-account.tsx @@ -9,7 +9,7 @@ * The steps below match the reference Flutter shell, where "Delete Account" is * a standalone tile at the bottom of Settings opening a confirmation dialog. If * your app puts it elsewhere, pass your own fragment as - * `site.legal.deleteAccount` — a wrong path here is an app-store rejection. + * `site.legal.deleteAccount`; a wrong path here is an app-store rejection. */ import type { LegalProps } from "./props.js"; @@ -53,7 +53,7 @@ export function DeleteAccount({ appName, operator }: LegalProps) {

What is retained

- Records of completed games are retained in a form that no longer identifies you — your seat shows as a deleted user. Those games are also part of your opponents' history and ratings, so they cannot be removed without altering another player's record. We may also retain limited information where the law requires + Records of completed games are retained in a form that no longer identifies you: your seat shows as a deleted user. Those games are also part of your opponents' history and ratings, so they cannot be removed without altering another player's record. We may also retain limited information where the law requires it, or to resolve disputes and prevent abuse.

diff --git a/packages/server/src/site/legal/index.ts b/packages/server/src/site/legal/index.ts index 3a4bec2..43574e8 100644 --- a/packages/server/src/site/legal/index.ts +++ b/packages/server/src/site/legal/index.ts @@ -7,7 +7,7 @@ * regex/fail-fast machinery that guarded the old scheme is gone. * * Each is rendered to an HTML fragment once, at startup, so a request never - * builds prose — and `LegalConfig` overrides stay plain HTML strings, which + * builds prose, and `LegalConfig` overrides stay plain HTML strings, which * need no props because the implementor writes their own values in directly. */ @@ -22,7 +22,7 @@ import { Terms } from "./terms.js"; export function renderLegal(legal: LegalConfig | undefined, props: LegalProps): { terms: string; privacy: string; deleteAccount: string } { const override = legal ?? {}; // Every default is a synchronous component, so the JSX node stringifies - // synchronously — no await, and nothing to resolve per request. + // synchronously: no await, and nothing to resolve per request. const render = (Component: (p: LegalProps) => unknown): string => String(Component(props)); return { terms: override.terms ?? render(Terms), diff --git a/packages/server/src/site/legal/privacy.tsx b/packages/server/src/site/legal/privacy.tsx index 5695035..1635e6b 100644 --- a/packages/server/src/site/legal/privacy.tsx +++ b/packages/server/src/site/legal/privacy.tsx @@ -1,7 +1,7 @@ /** * Default Privacy Policy. * - * A neutral starting template, NOT legal advice — the operator publishing the + * A neutral starting template, NOT legal advice. The operator publishing the * game is responsible for its accuracy and for having it reviewed. It describes * ONLY what the engine itself collects; if you add analytics, advertising, * payments, or any other processing, you must say so here. To replace it, pass @@ -10,7 +10,7 @@ * Two entries to check against what you actually ship: the “Diagnostics” bullet * assumes crash reporting (remove it if you have none), and “What we do not * collect” is deliberately scoped to advertising rather than denying analytics - * outright — do not widen it into a claim your app cannot support. + * outright, so do not widen it into a claim your app cannot support. */ import type { LegalProps } from "./props.js"; @@ -71,8 +71,8 @@ export function Privacy({ appName, operator }: LegalProps) {

Sharing

- We do not sell your personal data. We share it only with service providers who operate the Service on our behalf — authentication, hosting and storage, and push-notification delivery — and only as far as needed to provide it. We may also disclose data where legally required, or to protect the rights and safety - of players and {operator.name}. + We do not sell your personal data. We share it only with service providers who operate the Service on our behalf (authentication, hosting and storage, and push-notification delivery) and only as far as needed to provide it. We may also disclose data where legally required, or to protect the rights and safety of + players and {operator.name}.

Retention

@@ -87,7 +87,7 @@ export function Privacy({ appName, operator }: LegalProps) { Access and correction. You can view and edit your display name and profile image in the app.
  • - Deletion. You can delete your account from within the app — see Delete Account. + Deletion. You can delete your account from within the app; see Delete Account.
  • Notifications. You can turn notifications off in the app or in your device settings. diff --git a/packages/server/src/site/legal/props.ts b/packages/server/src/site/legal/props.ts index fc78e4e..3293543 100644 --- a/packages/server/src/site/legal/props.ts +++ b/packages/server/src/site/legal/props.ts @@ -1,7 +1,7 @@ import type { OperatorConfig } from "../config.js"; /** What every default legal document takes. These props are what the old - * `{{token}}` placeholders stood in for — with the compiler checking them. */ + * `{{token}}` placeholders stood in for, with the compiler checking them. */ export interface LegalProps { /** The game's public name. */ appName: string; diff --git a/packages/server/src/site/legal/terms.tsx b/packages/server/src/site/legal/terms.tsx index 3e8f378..368ac4a 100644 --- a/packages/server/src/site/legal/terms.tsx +++ b/packages/server/src/site/legal/terms.tsx @@ -1,7 +1,7 @@ /** * Default Terms of Service. * - * A neutral starting template, NOT legal advice — the operator publishing the + * A neutral starting template, NOT legal advice. The operator publishing the * game is responsible for its accuracy and for having it reviewed. To depart * from these terms, pass your own HTML fragment as `site.legal.terms`. * @@ -42,14 +42,14 @@ export function Terms({ appName, operator }: LegalProps) {

    Content you provide

    - You keep ownership of the display name, profile image, and other content you provide. You grant {operator.name} a non-exclusive, worldwide, royalty-free licence to store, reproduce, and display that content for the limited purpose of operating the Service — for example, showing your name and avatar to your + You keep ownership of the display name, profile image, and other content you provide. You grant {operator.name} a non-exclusive, worldwide, royalty-free licence to store, reproduce, and display that content for the limited purpose of operating the Service, for example showing your name and avatar to your opponents.

    You confirm that you have the rights necessary to grant this licence for anything you upload.

    Our content

    - The Service — including its software, design, artwork, and text — belongs to {operator.name} or its licensors and is protected by intellectual-property law. These terms give you a personal, non-exclusive, non-transferable right to use the Service for personal, non-commercial purposes, and nothing more. You may + The Service, including its software, design, artwork, and text, belongs to {operator.name} or its licensors and is protected by intellectual-property law. These terms give you a personal, non-exclusive, non-transferable right to use the Service for personal, non-commercial purposes, and nothing more. You may not copy, modify, reverse engineer, or redistribute any part of it, and nothing here grants you rights in {operator.name}'s trademarks or logos.

    @@ -62,7 +62,7 @@ export function Terms({ appName, operator }: LegalProps) {

    Ending your use

    - You may stop using the Service and delete your account at any time — see Delete Account. We may suspend or end your access if you breach these terms, if required by law, or if we discontinue the Service. + You may stop using the Service and delete your account at any time; see Delete Account. We may suspend or end your access if you breach these terms, if required by law, or if we discontinue the Service.

    Disclaimers

    diff --git a/packages/server/src/site/page.tsx b/packages/server/src/site/page.tsx index 3b77f1a..a3a2761 100644 --- a/packages/server/src/site/page.tsx +++ b/packages/server/src/site/page.tsx @@ -1,5 +1,5 @@ /** - * The shared document shell for every engine-rendered public page — the landing + * The shared document shell for every engine-rendered public page: the landing * page, the legal documents, and the `/j` share page. * * hono/jsx rather than string concatenation, so interpolated values are escaped @@ -8,8 +8,8 @@ * distinction is a security property, not a style preference. hono is already a * dependency; no new runtime is added. * - * Raw markup — an implementor's legal fragment, the JSON-LD block, the - * stylesheet — goes through `dangerouslySetInnerHTML`, which is honest: those + * Raw markup (an implementor's legal fragment, the JSON-LD block, the + * stylesheet) goes through `dangerouslySetInnerHTML`, which is honest: those * are trusted, build-time strings, and every other value on the page is * escaped. */ @@ -60,7 +60,7 @@ export interface PageProps { } /** Every link the engine renders leaves the page it is on rather than replacing - * it — the legal pages, the stores, the credit. A visitor reading Terms is + * it: the legal pages, the stores, the credit. A visitor reading Terms is * mid-download, and a store link on iOS hands the tab to the store app. * * `noopener` because `_blank` otherwise gives the opened page a handle back to @@ -72,8 +72,8 @@ export const NEW_TAB = { target: "_blank", rel: "noopener" } as const; /** The engine's footer: the legal links every page must carry, the operator's * copyright, and the credit. * - * The two halves are independent. Legal links need `site` — without it the - * routes are not mounted and linking to them would be a 404 — but the credit + * The two halves are independent. Legal links need `site`, since without it the + * routes are not mounted and linking to them would be a 404, but the credit * does not, so a worker that has configured nothing still ends its pages with * a line rather than with whitespace. */ function Footer({ operatorName, credit }: { operatorName?: string; credit: string | null }) { @@ -104,7 +104,7 @@ function Footer({ operatorName, credit }: { operatorName?: string; credit: strin * * A whole-line link would make "Build with" clickable too, which is not what it * points at. Splitting on the brand keeps the sentence as prose and marks only - * the name — and leaves a custom credit that never mentions the engine as plain + * the name, and leaves a custom credit that never mentions the engine as plain * text, rather than silently linking someone else's words to us. */ function Credit({ credit }: { credit: string }) { const at = credit.indexOf(CREDIT_BRAND); @@ -125,14 +125,14 @@ function Credit({ credit }: { credit: string }) { * * The stylesheet pairs its own primary with an `--on-primary` per colour * scheme, but a game that configures `site.primaryColor` overrides only half of - * that pair — and the two schemes disagree about which ink is right, so no + * that pair, and the two schemes disagree about which ink is right, so no * single fallback works. Deriving the partner here keeps the call-to-action * legible whatever colour an implementor picks, including colours light enough * that white-on-primary would fail. * * WCAG relative luminance. The threshold is where contrast against white and * against the near-black below cross, both a little above 4.5:1. A malformed - * hex yields `NaN`, which fails the comparison and lands on white — today's + * hex yields `NaN`, which fails the comparison and lands on white, today's * unconditional behaviour. */ export function onPrimary(hex: string): string { @@ -208,7 +208,7 @@ export function renderDocument(node: unknown): string { /** Insert a trusted, already-rendered HTML fragment. Used for legal documents, * whose source is either the engine's own components or an implementor's - * build-time file — never anything a user submits. */ + * build-time file, never anything a user submits. */ export function RawHtml({ html }: { html: string }) { return
    ; } diff --git a/packages/server/src/site/site.css b/packages/server/src/site/site.css index 783a7fd..e7967ba 100644 --- a/packages/server/src/site/site.css +++ b/packages/server/src/site/site.css @@ -1,5 +1,5 @@ /* - * The stylesheet for every engine-rendered public page — the landing page, the + * The stylesheet for every engine-rendered public page: the landing page, the * legal documents, and the `/j` share page. * * A real .css file rather than a template literal in TypeScript: it highlights, @@ -22,7 +22,7 @@ /* * The EigenInteractive palette, which is the Material 3 scheme generated from - * `Colors.teal` — the same seed the Flutter shell defaults to, so a game that + * `Colors.teal`, the same seed the Flutter shell defaults to, so a game that * has configured nothing looks like one product across the app and its pages. * These are `ColorScheme.fromSeed` output rather than hand-picked neighbours, * including the neutrals, which carry the seed's hue and are why the greys here @@ -145,7 +145,7 @@ strong { } /* - * The download page only — the legal documents are prose and stay left-aligned. + * The download page only; the legal documents are prose and stay left-aligned. * It is a short page with one job, so it takes the leftover height and centres * itself in it rather than clinging to the top of a mostly empty viewport. */ @@ -187,7 +187,7 @@ strong { } /* A raster launcher icon is drawn edge to edge and needs the whole plate. The - mark is not — it is a bare glyph, so it needs the margin an icon designer + mark is not: it is a bare glyph, so it needs the margin an icon designer would have drawn into the file. */ svg.logo { padding: 1.15rem; @@ -253,7 +253,7 @@ svg.logo { } /* `margin-top: auto` pins this to the bottom of `.wrap`, which is at least a - viewport tall — so a short page ends in a footer rather than in a void. */ + viewport tall, so a short page ends in a footer rather than in a void. */ footer { margin-top: auto; padding-top: 1.5rem; @@ -273,14 +273,14 @@ footer a { /* Pushed to the trailing edge, away from the operator's own links: it is the engine's line, not theirs. It wraps under them on a narrow screen, where `auto` collapses to nothing. - Only when there is something to be pushed away from — on a game with no + Only when there is something to be pushed away from. On a game with no `site` yet the credit is the whole footer, and a lone right-aligned line reads as a leftover rather than a choice. */ .credit:not(:first-child) { margin-left: auto; } -/* Only the brand inside the line is a link, and it is marked by colour alone — +/* Only the brand inside the line is a link, and it is marked by colour alone; an underline here would compete with the operator's legal links, which are the footer's actual navigation. Colour-only is legible because the two are the accent and the muted body tone, far enough apart in both schemes to read diff --git a/packages/server/src/testing.ts b/packages/server/src/testing.ts index 4cfae9d..10fb15e 100644 --- a/packages/server/src/testing.ts +++ b/packages/server/src/testing.ts @@ -1,9 +1,9 @@ /** - * `@eigeninteractive/server/testing` — the test-auth recipe, for the engine's own + * `@eigeninteractive/server/testing`: the test-auth recipe, for the engine's own * suite and for implementor test workers alike: * * ```ts - * // test/worker.ts — your production entry with explicit Firebase fakes: + * // test/worker.ts, your production entry with explicit Firebase fakes: * export default createEngine({ * ...sameConfig, * testing: { @@ -17,11 +17,11 @@ * ``` * * (`exports.default` is the loopback binding to the test worker's default - * export — the supported replacement for the deprecated `SELF` fetcher. It + * export, the supported replacement for the deprecated `SELF` fetcher. It * needs `Cloudflare.GlobalProps` to declare `mainModule`; see the engine's * own `test/env.d.ts` for the hand-rolled version, or use `wrangler types`.) * - * Tokens are verified through the SAME jose code path production uses — only + * Tokens are verified through the SAME jose code path production uses; only * the JWKS is local. The RS256 keypair below is a public fixture (checked in, * shipped in the package); it protects nothing and must never reach a * production config: pass `testing` ONLY in test workers. diff --git a/packages/server/test/apply-migrations.ts b/packages/server/test/apply-migrations.ts index 0a837b3..cefb453 100644 --- a/packages/server/test/apply-migrations.ts +++ b/packages/server/test/apply-migrations.ts @@ -1,6 +1,6 @@ import { applyD1Migrations } from "cloudflare:test"; import { env } from "cloudflare:workers"; -// Each isolate starts with an empty simulated D1 — bring it to the current +// Each isolate starts with an empty simulated D1, so bring it to the current // schema exactly the way production does (the generated .sql migrations). await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/packages/server/test/blocks.spec.ts b/packages/server/test/blocks.spec.ts index 153d373..149dbc5 100644 --- a/packages/server/test/blocks.spec.ts +++ b/packages/server/test/blocks.spec.ts @@ -1,8 +1,8 @@ /** * Blocking's interaction and visibility effects: a blocked pair never sees each * other's games in the lobby or friends' games, and never gets seated in the - * same game (join, join-by-code, either direction). All bidirectional — the - * blocker and the blocked are affected the same way — and reversible by + * same game (join, join-by-code, either direction). All bidirectional (the + * blocker and the blocked are affected the same way) and reversible by * unblocking. * * The seating refusal answers `unknownGame` on purpose: the lobby already @@ -55,7 +55,7 @@ async function block(blocker: TestTokenOptions, target: TestTokenOptions): Promi expect((await api(blocker, "POST", `/friends/${target.uid}/block`)).status).toBe(204); } -describe("blocking — lobby visibility", () => { +describe("blocking: lobby visibility", () => { it("hides a blocked user's game from the lobby, both directions, and restores on unblock", async () => { const a = await user("a"); const b = await user("b"); @@ -83,12 +83,12 @@ describe("blocking — lobby visibility", () => { const b = await user("b"); const game = await createGame(a); await block(b, a); // B blocks A this time. - // B still cannot see A's game — the effect does not depend on direction. + // B still cannot see A's game; the effect does not depend on direction. expect(await lobbyIds(b)).not.toContain(game.gameId); }); }); -describe("blocking — the seating boundary", () => { +describe("blocking: the seating boundary", () => { it("refuses a blocked user joining the game as unknownGame (by id and by code)", async () => { const a = await user("a"); const b = await user("b"); @@ -104,7 +104,7 @@ describe("blocking — the seating boundary", () => { expect(((await byCode.json()) as { code: string }).code).toBe("unknownGame"); }); - it("refuses in the other direction too — the blocker cannot join the blocked user's game", async () => { + it("refuses in the other direction too: the blocker cannot join the blocked user's game", async () => { const a = await user("a"); const b = await user("b"); const game = await createGame(b); // B hosts. @@ -124,7 +124,7 @@ describe("blocking — the seating boundary", () => { }); }); -describe("blocking — friends' open games", () => { +describe("blocking: friends' open games", () => { it("hides a friend's game once a blocked user takes a seat in it", async () => { const a = await user("a"); const c = await user("c"); // A's friend, the host diff --git a/packages/server/test/devices.spec.ts b/packages/server/test/devices.spec.ts index 1212b22..6b3c9a4 100644 --- a/packages/server/test/devices.spec.ts +++ b/packages/server/test/devices.spec.ts @@ -1,7 +1,7 @@ /** - * Device registration — the FCM push-target ingress that + * Device registration: the FCM push-target ingress that * makes `notify/push.ts` deliverable. Verifies the upsert-on-FID reassignment - * and the caller-scoped delete — the two behaviours that keep one app install + * and the caller-scoped delete: the two behaviours that keep one app install * mapped to exactly one user. */ diff --git a/packages/server/test/engine.spec.ts b/packages/server/test/engine.spec.ts index 9c66c77..2d9ab57 100644 --- a/packages/server/test/engine.spec.ts +++ b/packages/server/test/engine.spec.ts @@ -1,5 +1,5 @@ /** - * The createEngine HTTP drive — the deployed shape end to end over the worker: + * The createEngine HTTP drive: the deployed shape end to end over the worker: * create (policy + short code) waiting room (join/leave/cancel/ * add-bot/start, roster snapshots over the socket, D1 mirror) active * play (action with the own-frame ride-along, forfeit) frames, and the @@ -19,7 +19,7 @@ const BOT_SECRET = "test-bot-signing-secret"; const db = orm(env.DB); let userCounter = 0; -/** Fresh identities per test — provisioning is exercised implicitly. */ +/** Fresh identities per test; provisioning is exercised implicitly. */ function makeUsers() { const n = ++userCounter; return { a: `alice-${n}-${crypto.randomUUID()}`, b: `bob-${n}-${crypto.randomUUID()}`, c: `cesar-${n}-${crypto.randomUUID()}` }; @@ -69,7 +69,7 @@ describe("create", () => { }); // The short-code retry loop. Codes are random and 31^6 wide, so a natural - // collision is never observed in a test run — it has to be forced. This is + // collision is never observed in a test run; it has to be forced. This is // the only coverage of `isShortCodeCollision`, which classifies the SQLite // UNIQUE failure; if it stops matching (a physical column rename, or a // change in how `createGame` issues the statement that re-wraps the error), @@ -220,7 +220,7 @@ describe("active play & frames", () => { async function readyGame(u: ReturnType, overrides: Record = {}) { const { gameId } = await createGame(u.a, overrides); await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); - // No mirror wait: the DO resolves seats from its own roster — the + // No mirror wait: the DO resolves seats from its own roster, so the // joiner can act the moment the join response lands. await json(await api(u.a, "POST", `/games/${gameId}/start`, {})); return gameId; @@ -271,7 +271,7 @@ describe("active play & frames", () => { expect(ratings.ratings).toHaveLength(1); // The summary carries every identity's delta, so a history list needs no - // second read to annotate a row — and the same projection is correct when + // second read to annotate a row, and the same projection is correct when // viewing someone else's games, since this is per-game not per-viewer. const summary = await json<{ ratings?: { identity: { userId: string | null }; displayChange: number }[] }>(await api(u.c, "GET", `/games/${gameId}`)); expect((summary.ratings ?? []).map((r) => r.identity.userId).sort()).toEqual([u.a, u.b].sort()); @@ -337,7 +337,7 @@ describe("socket (roster snapshots → frames)", () => { expect(messages[0]).toMatchObject({ type: "roster", status: "waiting" }); expect(messages[0]?.players).toHaveLength(1); - // Joining pushes the new snapshot to every socket — including this one, + // Joining pushes the new snapshot to every socket, including this one, // whose principal just became seat 1. await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); await vi.waitFor(() => expect(messages).toHaveLength(2)); @@ -354,18 +354,18 @@ describe("socket (roster snapshots → frames)", () => { describe("bots", () => { // Fixed registry rows: engine brains are keyed by username, so the engine // bot's username must match the test game's `botActions`. Seeded once - // (idempotent) — bots are global registry data, seatable across games. + // (idempotent); bots are global registry data, seatable across games. const ENGINE = "test-engine-bot"; const EXTERNAL = "test-external-bot"; const LOCAL = "test-local-bot"; // The external bot's wake target. Intercepted below so the DO's fire-and-forget // wake resolves instantly (202) instead of making a real outbound fetch to an - // unresolvable host — that call fails nondeterministically slowly under load, + // unresolvable host, because that call fails nondeterministically slowly under load, // and until it settles the bot's follow-up `bot/action` command queues behind // it on the same DO, so the move sometimes doesn't land before `vi.waitFor` // gives up. This version of vitest-pool-workers dropped the `fetchMock` // MockAgent, so we wrap `globalThis.fetch` exactly as the pool itself does for - // MSW — the test and the DO share one workerd isolate, so the DO's `fetch` + // MSW. The test and the DO share one workerd isolate, so the DO's `fetch` // picks up the override. const WAKE_ORIGIN = "https://wake.test"; const realFetch = globalThis.fetch; @@ -462,18 +462,18 @@ describe("bots", () => { it("deriveBotKey yields exactly the key a bot owner signs with", async () => { // The operator utility: what you hand a bot's owner at registration. This - // asserts the contract from the OWNER's side — sign with nothing but the + // asserts the contract from the OWNER's side: sign with nothing but the // derived key, using plain WebCrypto the way their own code would, and the // engine must accept it. If this passes, the documented onboarding works. const derived = await deriveBotKey(BOT_SECRET, EXTERNAL); - // It is HMAC-SHA256(master, botId), base64 — the same bytes the documented + // It is HMAC-SHA256(master, botId), base64: the same bytes the documented // `openssl dgst -sha256 -hmac` one-liner produces. const master = await crypto.subtle.importKey("raw", new TextEncoder().encode(BOT_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const expected = new Uint8Array(await crypto.subtle.sign("HMAC", master, new TextEncoder().encode(EXTERNAL))).toBase64(); expect(derived).toBe(expected); - // Sign a body with the derived key alone — no master secret in sight. + // Sign a body with the derived key alone, no master secret in sight. const u = makeUsers(); const solo = await json<{ gameId: string }>(await api(u.a, "POST", "/games/solo", soloBody(EXTERNAL)), 201); await json(await api(u.a, "POST", `/games/${solo.gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); diff --git a/packages/server/test/env.d.ts b/packages/server/test/env.d.ts index 6a0fd50..439c9f4 100644 --- a/packages/server/test/env.d.ts +++ b/packages/server/test/env.d.ts @@ -2,7 +2,7 @@ * Test bindings for `import { env } from "cloudflare:workers"`, which is * typed as the global `Cloudflare.Env` (the same namespace `wrangler types` * populates for a deployable worker). Declared by hand here so the DO - * namespace carries the RPC surface — mirrors test/wrangler.jsonc plus the + * namespace carries the RPC surface, mirroring test/wrangler.jsonc plus the * TEST_MIGRATIONS binding injected by vitest.config.mts. */ @@ -16,7 +16,7 @@ declare global { AVATARS: R2Bucket; TEST_MIGRATIONS: D1Migration[]; } - /** Types `exports` from `cloudflare:workers` — the loopback bindings for + /** Types `exports` from `cloudflare:workers`: the loopback bindings for * the main module's top-level exports. `exports.default.fetch()` is the * supported replacement for the deprecated `SELF` fetcher, and without * this declaration it would be typed `{}`. Same hand-rolled approach as diff --git a/packages/server/test/errors.spec.ts b/packages/server/test/errors.spec.ts index 08b2d65..9412159 100644 --- a/packages/server/test/errors.spec.ts +++ b/packages/server/test/errors.spec.ts @@ -1,7 +1,7 @@ /** - * The D1 failure predicates. D1 exposes no structured error code — the thrown + * The D1 failure predicates. D1 exposes no structured error code: the thrown * Error carries only `message` and a nested `cause` (verified against the live - * binding; Cloudflare documents `e.message` as the only thing to inspect) — so + * binding; Cloudflare documents `e.message` as the only thing to inspect) so * these match text, and these tests pin the exact shapes they must classify. * * The message shapes below are transcribed from real failures raised by the @@ -21,7 +21,7 @@ function batchShape(constraint: string): Error { } /** The real query-builder shape: drizzle's `DrizzleQueryError` message carries - * the SQL — which names `short_code` as a column — and does NOT carry the + * the SQL, which names `short_code` as a column, and does NOT carry the * constraint text. That only appears one `cause` deeper. */ function queryBuilderShape(constraint: string): Error { const sql = 'insert into "games" ("id", "status", "short_code", "created_at") values (?, ?, ?, ?)'; diff --git a/packages/server/test/factories.ts b/packages/server/test/factories.ts index e8963e2..a038b62 100644 --- a/packages/server/test/factories.ts +++ b/packages/server/test/factories.ts @@ -3,7 +3,7 @@ import type { users } from "../src/d1/schema.js"; type UserRow = typeof users.$inferInsert; /** - * A complete `users` row with sensible defaults — override only what a test + * A complete `users` row with sensible defaults; override only what a test * actually cares about (usually just the display name). Centralises the * eight-field shape that every spec was building inline, so a new NOT NULL * column is one edit here instead of a hunt across the seed blocks. diff --git a/packages/server/test/game-do.spec.ts b/packages/server/test/game-do.spec.ts index 2623c28..7e215d1 100644 --- a/packages/server/test/game-do.spec.ts +++ b/packages/server/test/game-do.spec.ts @@ -1,5 +1,5 @@ /** - * GameDO skeleton suite — the folded-in Phase 0 spike, minus the two + * GameDO skeleton suite: the folded-in Phase 0 spike, minus the two * checks only a real deploy can make (hibernation billing, forced eviction): * lazy init, the gated command loop, dedupe, the deadline alarm, hibernating * socket fan-out, and the finish sequence (compaction → outbox → D1 apply → @@ -19,7 +19,7 @@ import { type Command, createGame, type FrameMessage } from "../src/index.js"; import type { SyncMessage } from "../src/protocol.js"; import { userRow } from "./factories.js"; -/** Typed D1 access for seeds and assertions — the DO's own SQLite is +/** Typed D1 access for seeds and assertions. The DO's own SQLite is * inspected raw (`state.storage.sql.exec`) on purpose: those checks verify * what is physically on disk beneath the ORM. */ const db = orm(env.DB); @@ -36,7 +36,7 @@ let gameCounter = 0; * (the waiting room is a later milestone). */ async function seedGame(opts: SeedOptions = {}): Promise { const gameId = `game-${++gameCounter}-${crypto.randomUUID()}`; - // Both seats have real `users` rows, as any authed player would — the finish + // Both seats have real `users` rows, as any authed player would, so the finish // apply's purge guard only rates identities that still exist. await db .insert(users) @@ -274,7 +274,7 @@ describe("finish sequence", () => { await db.delete(games).where(eq(games.id, gameId)); await playToFinish(gameId, stub); - // The waitUntil apply fails (no games row); outbox must survive — and + // The waitUntil apply fails (no games row); outbox must survive, and // with it the uncompacted live tables (they drain together). await runInDurableObject(stub, async (_i, state) => { await vi.waitFor(() => { diff --git a/packages/server/test/leak.spec.ts b/packages/server/test/leak.spec.ts index 2a8e5f8..572a635 100644 --- a/packages/server/test/leak.spec.ts +++ b/packages/server/test/leak.spec.ts @@ -1,9 +1,9 @@ /** * The leak test: the kernel's per-seat projection is the ONLY thing keeping - * hidden state server-side — nothing downstream re-filters it. This + * hidden state server-side; nothing downstream re-filters it. This * drives a full lifecycle of the hidden-info version-2 game (whose raw state * carries `LEAK_SENTINEL`, stripped by `computeObservation`) and asserts the - * sentinel escapes through no response body and no socket frame — live play, + * sentinel escapes through no response body and no socket frame: live play, * command responses, the summary read, and post-finish replay alike. */ @@ -50,7 +50,7 @@ describe("leak test", () => { // Play to a finish: seat 0 (A) opens, then seat 1 (B) closes it out. await clean(await api(a, "POST", `/games/${gameId}/action`, { seat: 0, expectedVersion: 0, data: { add: 2 } }), "action A"); - // A pulls its own live frames mid-game — the gap-recovery path. + // A pulls its own live frames mid-game: the gap-recovery path. await clean(await api(a, "GET", `/games/${gameId}/frames?from=0`), "frames (live)"); await clean(await api(b, "POST", `/games/${gameId}/action`, { seat: 1, expectedVersion: 1, data: { add: 2 } }), "action B"); @@ -63,7 +63,7 @@ describe("leak test", () => { ws.close(); for (const frame of socketFrames) expect(frame, "socket frame leaked the hidden state").not.toContain(LEAK_SENTINEL); - // Sanity: the sentinel really was in play (else the test proves nothing) — + // Sanity: the sentinel really was in play (else the test proves nothing): // the hidden game reveals `count`, never `secret`. expect(socketFrames.some((f) => f.includes('"count"'))).toBe(true); }); diff --git a/packages/server/test/purge.spec.ts b/packages/server/test/purge.spec.ts index c0bbb07..0f0d52e 100644 --- a/packages/server/test/purge.spec.ts +++ b/packages/server/test/purge.spec.ts @@ -61,7 +61,7 @@ describe("delete-account", () => { expect(game?.createdBy).toBeNull(); }); - it("forfeits an active game — the seat is anonymized, the opponent plays on", async () => { + it("forfeits an active game: the seat is anonymized, the opponent plays on", async () => { const a = uid("del-a"); const b = uid("del-b"); const { gameId } = await json<{ gameId: string }>(await api(a, "POST", "/games", { ...createBody, rated: false }), 201); diff --git a/packages/server/test/rate-limit.spec.ts b/packages/server/test/rate-limit.spec.ts index 2f82b72..547505a 100644 --- a/packages/server/test/rate-limit.spec.ts +++ b/packages/server/test/rate-limit.spec.ts @@ -2,7 +2,7 @@ * Rate limiting is a small pure surface: resolve the limiter for a name off env * by its conventional binding, and enforce it (throw the shared 429 on * refusal). The local runtime does not implement the `ratelimit` binding, so - * there is nothing real to integration-test — these unit-test the logic + * there is nothing real to integration-test, so these unit-test the logic * directly with a fake limiter, which is exactly what the routes and * `createEngine` call. */ @@ -52,7 +52,7 @@ describe("resolveRateLimiter", () => { expect(resolveRateLimiter(env, "game_create")).toBe(limiter); }); - it("returns null when the name is unbound (unlimited — the dev default)", () => { + it("returns null when the name is unbound (unlimited, the dev default)", () => { expect(resolveRateLimiter({}, "avatar_upload")).toBeNull(); }); diff --git a/packages/server/test/ratings-cas.spec.ts b/packages/server/test/ratings-cas.spec.ts index f1049d2..9611731 100644 --- a/packages/server/test/ratings-cas.spec.ts +++ b/packages/server/test/ratings-cas.spec.ts @@ -3,7 +3,7 @@ * * The hazard is a lost update across two DIFFERENT games that share a player * and finish at the same moment: both read the same prior, both compute from - * it, and whichever commits second overwrites the first — the player's rating + * it, and whichever commits second overwrites the first, so the player's rating * moves as if only one game happened. * * The guard is `idx_rating_history_{user,bot}_cas`: every history row is @@ -80,7 +80,7 @@ function finish(gameId: string, a: string, b: string) { } describe("rating CAS", () => { - // NOTE: this does not reproduce the race — local D1 serializes the two + // NOTE: this does not reproduce the race; local D1 serializes the two // applies, so both observe the other's commit and neither has to retry. // It pins the *shape* the CAS produces (a gapless revision chain, each // delta computed from the previous one's result), which is what a lost @@ -118,7 +118,7 @@ describe("rating CAS", () => { }); it("rejects a second history row computed against an already-consumed revision", async () => { - // The index in isolation — the mechanism every recompute above depends on. + // The index in isolation: the mechanism every recompute above depends on. const user = `dup-${crypto.randomUUID()}`; const now = Date.now(); const row = { @@ -137,7 +137,7 @@ describe("rating CAS", () => { }; await db.insert(ratingHistory).values({ ...row, id: crypto.randomUUID(), gameId: `g1-${user}`, finishId: `f1-${user}` }); - // A different game, same identity+pool, same revision — the lost-update + // A different game, same identity+pool, same revision: the lost-update // shape. It must not be storable, and `applyFinish` must recognise the // rejection as retryable. Note the constraint text is NOT in the // top-level message: drizzle rethrows with its own "Failed query: ...", diff --git a/packages/server/test/reads.spec.ts b/packages/server/test/reads.spec.ts index df63bfe..be1283f 100644 --- a/packages/server/test/reads.spec.ts +++ b/packages/server/test/reads.spec.ts @@ -1,11 +1,11 @@ /** - * The batched single-game readers — `readGame` (by id) and `readGameByCode` + * The batched single-game readers: `readGame` (by id) and `readGameByCode` * (by short code). Both fold the games row and its roster into one D1 round * trip; `readGameByCode` does it through a subquery because it does not hold * the id. This pins that the roster each returns is correct, not just that it * is non-empty (the join-by-code route asserts the DO's roster, and the - * deep-link landing page — the one consumer of `readGameByCode.participants`, - * for its "seats open" copy — is not otherwise covered). + * deep-link landing page, the one consumer of `readGameByCode.participants` + * for its "seats open" copy, is not otherwise covered). */ import { env } from "cloudflare:workers"; @@ -59,7 +59,7 @@ describe("single-game readers", () => { const { gameId, shortCode, a, b } = await seedTwoSeatGame(); const game = await readGameByCode(env.DB, shortCode); expect(game?.id).toBe(gameId); - // The subquery must resolve this game's seats — not none, not another + // The subquery must resolve this game's seats: not none, not another // game's. Both seats, in playerIndex order. expect(game?.participants).toEqual([ { playerIndex: 0, userId: a, botId: null, type: "human" }, diff --git a/packages/server/test/retry.spec.ts b/packages/server/test/retry.spec.ts index 9b27951..86c8889 100644 --- a/packages/server/test/retry.spec.ts +++ b/packages/server/test/retry.spec.ts @@ -1,5 +1,5 @@ /** - * `withRetry` and its transient-D1 predicate — the durability wrapper around + * `withRetry` and its transient-D1 predicate: the durability wrapper around * the fire-and-forget summary mirrors. */ @@ -28,7 +28,7 @@ describe("isTransientD1Error", () => { expect(isTransientD1Error("a bare string")).toBe(false); }); - it("does not retry an overloaded or resource-exhausted DB — the remedy is to shed load, not retry", () => { + it("does not retry an overloaded or resource-exhausted DB, since the remedy is to shed load, not retry", () => { for (const message of ["D1 DB is overloaded. Requests queued for too long", "D1 DB is overloaded. Too many requests queued", "D1 DB's isolate exceeded its memory limit and was reset", "D1 DB exceeded its CPU time limit and was reset"]) { expect(isTransientD1Error(wrapped(message)), message).toBe(false); } diff --git a/packages/server/test/site.spec.ts b/packages/server/test/site.spec.ts index 362f667..4f360dc 100644 --- a/packages/server/test/site.spec.ts +++ b/packages/server/test/site.spec.ts @@ -11,7 +11,7 @@ * KNOWN GAP: `site.css` is not covered by any of this. `tsup` inlines it as * text at build time via its `.css` loader, but under `vitest-pool-workers` * `import styles from "./site.css"` resolves to an empty module, so every page - * here renders with an empty `