diff --git a/.github/workflows/deploy-game.yml b/.github/workflows/deploy-game.yml new file mode 100644 index 0000000..691e507 --- /dev/null +++ b/.github/workflows/deploy-game.yml @@ -0,0 +1,142 @@ +name: Deploy game + +# The game skin, deployed on its own Worker. +# +# It exists so that the game and the console do not share a blast radius. +# `shell-online-app` serves the session list, the terminal and the vault; while +# the game shipped inside it, a bad game build was a bad console build. This +# builds and deploys only the game, only when the game changed. +# +# There is no database step and no migration step, because there is nothing to +# migrate: the Worker this deploys serves static files and holds no bindings, +# no secrets and no cron. The game's tables belong to the console's Worker, +# which is what answers /api/*, and they are migrated by deploy-app.yml. +# +# Read docs/deploy-game.md before switching the route on. Until a route exists +# this deploys something reachable by nothing, on purpose. + +on: + push: + branches: [main] + # Only what this Worker is built from. A change to the console, the CLI or + # the relay must not redeploy the game, which is the entire point. + paths: + - "app/src/game/**" + - "app/src/styles/game.css" + - "app/public/game/**" + - "app/game.html" + - "app/vite.game.config.ts" + - "app/wrangler.game.jsonc" + - ".github/workflows/deploy-game.yml" + workflow_dispatch: + +permissions: + contents: read + +# One at a time. Two overlapping deploys of the same Worker is a race over +# which build is left serving. +concurrency: + group: deploy-game + cancel-in-progress: false + +env: + # Pinned, like the console's: a deploy should not change because a new + # wrangler was published this morning. + WRANGLER_VERSION: "4.131.0" + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: production + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # A dispatch runs against whatever ref was chosen. Production is built + # from main, so anything else is a mistake worth catching before it ships. + - name: Refuse to deploy anything but main + env: + REF: ${{ github.ref }} + run: | + set -eu + if [ "$REF" != "refs/heads/main" ]; then + echo "this workflow deploys main; asked for $REF" >&2 + exit 1 + fi + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: app/package-lock.json + + - run: npm ci + working-directory: app + + # Fails here, naming what is missing, rather than further in where the + # symptom no longer resembles the cause. Values arrive through the + # environment rather than ${{ }} inside the script, so nothing configured + # elsewhere can be read as shell. + - name: Check the deploy has what it needs + env: + VITE_OIDC_ISSUER: ${{ vars.VITE_OIDC_ISSUER }} + VITE_OIDC_CLIENT_ID: ${{ vars.VITE_OIDC_CLIENT_ID }} + VITE_FIREBASE_API_KEY: ${{ vars.VITE_FIREBASE_API_KEY }} + VITE_FIREBASE_AUTH_DOMAIN: ${{ vars.VITE_FIREBASE_AUTH_DOMAIN }} + VITE_FIREBASE_PROJECT_ID: ${{ vars.VITE_FIREBASE_PROJECT_ID }} + VITE_FIREBASE_APP_ID: ${{ vars.VITE_FIREBASE_APP_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -eu + missing= + for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID; do + eval "value=\${$name-}" + if [ -z "$value" ]; then missing="$missing $name"; fi + done + # The client is compiled against one sign-in configuration or the + # other, exactly as the console is. Neither is a build that cannot + # sign anybody in. + oidc=0; firebase=0 + if [ -n "${VITE_OIDC_ISSUER-}" ] && [ -n "${VITE_OIDC_CLIENT_ID-}" ]; then oidc=1; fi + if [ -n "${VITE_FIREBASE_API_KEY-}" ] && [ -n "${VITE_FIREBASE_PROJECT_ID-}" ]; then firebase=1; fi + if [ "$oidc" = 0 ] && [ "$firebase" = 0 ]; then + missing="$missing SIGN_IN_CONFIGURATION" + fi + if [ -n "$missing" ]; then + echo "the game deploy is missing:$missing" >&2 + exit 1 + fi + + # The same tests the console runs, narrowed to what this Worker ships. + # A game that does not typecheck or breaks its own readability rules is + # not deployed, however green the console is. + - name: Check the game + working-directory: app + run: | + set -eu + npm run typecheck + npx vitest run src/game + node scripts/check-game-ui.mjs + + - name: Build the game + working-directory: app + env: + VITE_OIDC_ISSUER: ${{ vars.VITE_OIDC_ISSUER }} + VITE_OIDC_CLIENT_ID: ${{ vars.VITE_OIDC_CLIENT_ID }} + VITE_FIREBASE_API_KEY: ${{ vars.VITE_FIREBASE_API_KEY }} + VITE_FIREBASE_AUTH_DOMAIN: ${{ vars.VITE_FIREBASE_AUTH_DOMAIN }} + VITE_FIREBASE_PROJECT_ID: ${{ vars.VITE_FIREBASE_PROJECT_ID }} + VITE_FIREBASE_APP_ID: ${{ vars.VITE_FIREBASE_APP_ID }} + VITE_FIREBASE_MESSAGING_SENDER_ID: ${{ vars.VITE_FIREBASE_MESSAGING_SENDER_ID }} + VITE_RELAY_URL: ${{ vars.VITE_RELAY_URL }} + run: npm run build:game + + - name: Deploy the game Worker + working-directory: app + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: npx --yes wrangler@${{ env.WRANGLER_VERSION }} deploy --config wrangler.game.jsonc diff --git a/.gitignore b/.gitignore index 1965590..af36fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ worker-configuration.d.ts # Marketing renders are maintained outside the source tree. /promo/ + +app/dist-game/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad69e4..423b994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ## Unreleased +### Added + +- A game skin over the web app, reached from a controller at the right of the + top bar and left again through a pause screen whose last item is "Quit to + boring UI". The Marches are nine holdings spread over open country, each one + a rename of a part of the product: the Forge is where features are built, the + Watch is where faults are met, the Chronicle is the audit log, the Vault + holds session passwords the service cannot open. Live sessions are wrights + standing in them, their class is the harness each one runs, and what they are + doing is read from what the session is called. Click one to see where it is + posted, which machine it came from and how long it has been out. Drag to + move, wheel to zoom, and a road book in the pause menu rides you to any + holding. + + Levels come from work that has already happened, counted by the service from + your own sessions: how many ran and finished, on how many days, from how many + machines. Marks come from levelling, and the pedlar sells cloth and dye and + nothing that changes a number. + + The elixir vial shows what statistics gathering has cost, itemised by run and + by machine. It is off until you turn it on, and the notice explaining what + your machine would read — and what it never reads — is one press from the + vial. The reading happens on your machine rather than on the service, because + sessions are encrypted end to end and the service holds no key; `shell stats` + prints exactly what would be sent, and sends nothing. + + It costs the session list nothing: the whole game is one lazily imported + chunk, and the build fails if any of it reaches the bundle everybody else + downloads. It honours reduced motion — on the map as well as in the + interface — offers a safe-area inset for televisions, an interface-size + slider and colourblind palettes, and is navigable with a keyboard or a pad + throughout. + ## [0.16.0] — 2026-09-15 ### Changed diff --git a/app/game-preview.html b/app/game-preview.html new file mode 100644 index 0000000..d83c762 --- /dev/null +++ b/app/game-preview.html @@ -0,0 +1,25 @@ + + + + + + + + Shell Keep — preview + + + +
+ + + diff --git a/app/game.html b/app/game.html new file mode 100644 index 0000000..8b9a0ca --- /dev/null +++ b/app/game.html @@ -0,0 +1,16 @@ + + + + + + + + + + Shell Keep + + +
+ + + diff --git a/app/package-lock.json b/app/package-lock.json index b7c2b50..43f31aa 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -15,6 +15,8 @@ "jose": "^6.2.12", "oidc-client-ts": "^3.5.0", "pg": "^8.23.0", + "pixi-viewport": "^6.0.3", + "pixi.js": "^8.20.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.3" @@ -601,7 +603,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.16.2.tgz", "integrity": "sha512-fyCLPahl3r0Zb7Wzm+JLWcOOH3SaDgAyJzJ8EWIDiv51aNiv+2Pjbpa6myEuP7iXxjmJqrcbVm0GUF7h229T1Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.5", "@firebase/logger": "0.5.2", @@ -669,7 +670,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.18.tgz", "integrity": "sha512-77H57WuGEsgrv59HdtuhHG+eQdP8di6myz7O93yW6yTZlR/tLSmsCxCq5vHp6AWWDwSV0EVtKUvizXQiQZVBWA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.16.2", "@firebase/component": "0.7.5", @@ -686,7 +686,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.6.tgz", "integrity": "sha512-yPLahy7Esfu2w/yme3msVK4xTkDXQqq6szfQn8yVOQpCKiT5GVFjqNpLbuz6NkX0WuTwUidkmPewW3r4xkvpeg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/logger": "0.5.2" } @@ -1161,7 +1160,6 @@ "integrity": "sha512-c/z/gaIlaaLZEuGbE6sLUuJ61tskg1JghvhcNQzW948ASBinbVBBRnZTC4b4yt4LaEtJQkYlyzqLHcutFwEIvA==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -1580,6 +1578,12 @@ "react-dom": ">= 16.8" } }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1917,6 +1921,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1951,7 +1961,6 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2002,7 +2011,6 @@ "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/istanbul-lib-coverage": "^1.0.0", @@ -2087,6 +2095,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz", + "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -2527,6 +2550,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2779,6 +2808,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3006,6 +3041,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -3576,6 +3620,12 @@ "dev": true, "license": "ISC" }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, "node_modules/jose": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", @@ -3585,6 +3635,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -4198,6 +4254,12 @@ "node": ">=4" } }, + "node_modules/parse-svg-path": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", + "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", + "license": "MIT" + }, "node_modules/path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -4233,7 +4295,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -4361,6 +4422,41 @@ "node": ">=4" } }, + "node_modules/pixi-viewport": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/pixi-viewport/-/pixi-viewport-6.0.3.tgz", + "integrity": "sha512-2+qPJ0/n+8hQYhWvY+795+x9y3MiUrCOWacK0DY53whowWaGdx9iDocy7z1pBwjkZhC52YvrJQuZKK0sdVLtBw==", + "license": "MIT", + "peerDependencies": { + "pixi.js": ">=8" + } + }, + "node_modules/pixi.js": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz", + "integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==", + "license": "MIT", + "workspaces": [ + "examples", + "playground" + ], + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.15", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.2.0", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4476,7 +4572,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4486,7 +4581,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5145,6 +5239,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", @@ -5204,7 +5307,6 @@ "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -5352,7 +5454,6 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -5431,7 +5532,6 @@ "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", diff --git a/app/package.json b/app/package.json index d8b838c..c6304b1 100644 --- a/app/package.json +++ b/app/package.json @@ -5,12 +5,13 @@ "type": "module", "scripts": { "build": "tsc -b && vite build && npm run build:server && node scripts/check-bundle.mjs", + "build:game": "tsc -b && vite build --config vite.game.config.ts", "build:server": "esbuild server/index.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist-server/index.js && cp -R server/lib/migrations dist-server/migrations", "check:protocol": "node scripts/check-protocol.mjs", "db:import": "tsx server/import-json.ts", "db:migrate": "tsx server/migrate.ts", - "db:verify": "tsx server/verify-schema.ts", "db:up": "docker run -d --name shell-online-pg -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=shell_online -p 5433:5432 postgres:16-alpine", + "db:verify": "tsx server/verify-schema.ts", "dev": "vite", "dev:accounts": "tsx watch --env-file-if-exists=.env.local server/index.ts", "dev:all": "npm-run-all --parallel dev dev:accounts", @@ -18,12 +19,13 @@ "preview": "vite preview", "render:deploy-config": "node scripts/render-deploy-config.mjs", "start": "node dist-server/index.js", - "test": "vitest run && node scripts/check-protocol.mjs", + "test": "vitest run && node scripts/check-protocol.mjs && node scripts/check-game-ui.mjs", "test:pg": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://postgres:dev@localhost:5433/shell_online} vitest run", "test:watch": "vitest", "typecheck": "tsc -b --force", + "verify:bundle": "node scripts/check-bundle.mjs", "verify:deploy": "node scripts/verify-deploy.mjs", - "verify:bundle": "node scripts/check-bundle.mjs" + "verify:game-ui": "node scripts/check-game-ui.mjs" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", @@ -33,6 +35,8 @@ "jose": "^6.2.12", "oidc-client-ts": "^3.5.0", "pg": "^8.23.0", + "pixi-viewport": "^6.0.3", + "pixi.js": "^8.20.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.3" diff --git a/app/public/fonts/OFL-Pirata-One.txt b/app/public/fonts/OFL-Pirata-One.txt new file mode 100644 index 0000000..9556702 --- /dev/null +++ b/app/public/fonts/OFL-Pirata-One.txt @@ -0,0 +1,93 @@ +Copyright (c) 2012, Rodrigo Fuenzalida, Nicolas Massi (www.taip.com.ar / abc.taip.com.ar), with Reserved Font Name 'Pirata' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/public/fonts/pirata-one.woff2 b/app/public/fonts/pirata-one.woff2 new file mode 100644 index 0000000..3a3accf Binary files /dev/null and b/app/public/fonts/pirata-one.woff2 differ diff --git a/app/public/game/fx-circle_05.png b/app/public/game/fx-circle_05.png new file mode 100644 index 0000000..8de3f69 Binary files /dev/null and b/app/public/game/fx-circle_05.png differ diff --git a/app/public/game/fx-flare_01.png b/app/public/game/fx-flare_01.png new file mode 100644 index 0000000..fb452a1 Binary files /dev/null and b/app/public/game/fx-flare_01.png differ diff --git a/app/public/game/fx-light_01.png b/app/public/game/fx-light_01.png new file mode 100644 index 0000000..71f8f0f Binary files /dev/null and b/app/public/game/fx-light_01.png differ diff --git a/app/public/game/fx-magic_05.png b/app/public/game/fx-magic_05.png new file mode 100644 index 0000000..a46b215 Binary files /dev/null and b/app/public/game/fx-magic_05.png differ diff --git a/app/public/game/fx-smoke_01.png b/app/public/game/fx-smoke_01.png new file mode 100644 index 0000000..1cd418e Binary files /dev/null and b/app/public/game/fx-smoke_01.png differ diff --git a/app/public/game/fx-smoke_04.png b/app/public/game/fx-smoke_04.png new file mode 100644 index 0000000..74a2695 Binary files /dev/null and b/app/public/game/fx-smoke_04.png differ diff --git a/app/public/game/fx-spark_04.png b/app/public/game/fx-spark_04.png new file mode 100644 index 0000000..98d0f0a Binary files /dev/null and b/app/public/game/fx-spark_04.png differ diff --git a/app/public/game/fx-star_04.png b/app/public/game/fx-star_04.png new file mode 100644 index 0000000..5e241c6 Binary files /dev/null and b/app/public/game/fx-star_04.png differ diff --git a/app/public/game/kingdom/banner-a.png b/app/public/game/kingdom/banner-a.png new file mode 100644 index 0000000..dc813e1 Binary files /dev/null and b/app/public/game/kingdom/banner-a.png differ diff --git a/app/public/game/kingdom/banner-b.png b/app/public/game/kingdom/banner-b.png new file mode 100644 index 0000000..5bf2e4c Binary files /dev/null and b/app/public/game/kingdom/banner-b.png differ diff --git a/app/public/game/kingdom/banner-c.png b/app/public/game/kingdom/banner-c.png new file mode 100644 index 0000000..f9a0279 Binary files /dev/null and b/app/public/game/kingdom/banner-c.png differ diff --git a/app/public/game/kingdom/banner-d.png b/app/public/game/kingdom/banner-d.png new file mode 100644 index 0000000..4a7cda4 Binary files /dev/null and b/app/public/game/kingdom/banner-d.png differ diff --git a/app/public/game/kingdom/castle.svg b/app/public/game/kingdom/castle.svg new file mode 100755 index 0000000..cccfe6f --- /dev/null +++ b/app/public/game/kingdom/castle.svg @@ -0,0 +1,531 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/goblet.svg b/app/public/game/kingdom/goblet.svg new file mode 100755 index 0000000..d34af99 --- /dev/null +++ b/app/public/game/kingdom/goblet.svg @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/grass-band-a.png b/app/public/game/kingdom/grass-band-a.png new file mode 100644 index 0000000..aa5b17c Binary files /dev/null and b/app/public/game/kingdom/grass-band-a.png differ diff --git a/app/public/game/kingdom/grass-band-b.png b/app/public/game/kingdom/grass-band-b.png new file mode 100644 index 0000000..c2807cd Binary files /dev/null and b/app/public/game/kingdom/grass-band-b.png differ diff --git a/app/public/game/kingdom/grass-band-c.png b/app/public/game/kingdom/grass-band-c.png new file mode 100644 index 0000000..63f98bc Binary files /dev/null and b/app/public/game/kingdom/grass-band-c.png differ diff --git a/app/public/game/kingdom/grass-band-d.png b/app/public/game/kingdom/grass-band-d.png new file mode 100644 index 0000000..a1bb70a Binary files /dev/null and b/app/public/game/kingdom/grass-band-d.png differ diff --git a/app/public/game/kingdom/grass-tuft-a.png b/app/public/game/kingdom/grass-tuft-a.png new file mode 100644 index 0000000..497dfe5 Binary files /dev/null and b/app/public/game/kingdom/grass-tuft-a.png differ diff --git a/app/public/game/kingdom/grass-tuft-b.png b/app/public/game/kingdom/grass-tuft-b.png new file mode 100644 index 0000000..a2f5525 Binary files /dev/null and b/app/public/game/kingdom/grass-tuft-b.png differ diff --git a/app/public/game/kingdom/hero-banner.png b/app/public/game/kingdom/hero-banner.png new file mode 100644 index 0000000..1a0e345 Binary files /dev/null and b/app/public/game/kingdom/hero-banner.png differ diff --git a/app/public/game/kingdom/lance.svg b/app/public/game/kingdom/lance.svg new file mode 100755 index 0000000..d74a277 --- /dev/null +++ b/app/public/game/kingdom/lance.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/map.svg b/app/public/game/kingdom/map.svg new file mode 100755 index 0000000..560adde --- /dev/null +++ b/app/public/game/kingdom/map.svg @@ -0,0 +1,396 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/pine-broad.svg b/app/public/game/kingdom/pine-broad.svg new file mode 100644 index 0000000..875891f --- /dev/null +++ b/app/public/game/kingdom/pine-broad.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/pine-dark.svg b/app/public/game/kingdom/pine-dark.svg new file mode 100644 index 0000000..260829f --- /dev/null +++ b/app/public/game/kingdom/pine-dark.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/app/public/game/kingdom/pine-light-a.svg b/app/public/game/kingdom/pine-light-a.svg new file mode 100644 index 0000000..a76f65a --- /dev/null +++ b/app/public/game/kingdom/pine-light-a.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/pine-light-b.svg b/app/public/game/kingdom/pine-light-b.svg new file mode 100644 index 0000000..eed3d6e --- /dev/null +++ b/app/public/game/kingdom/pine-light-b.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/pine-tall.svg b/app/public/game/kingdom/pine-tall.svg new file mode 100644 index 0000000..581b525 --- /dev/null +++ b/app/public/game/kingdom/pine-tall.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/shield.svg b/app/public/game/kingdom/shield.svg new file mode 100755 index 0000000..10d0cfe --- /dev/null +++ b/app/public/game/kingdom/shield.svg @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/siege.png b/app/public/game/kingdom/siege.png new file mode 100644 index 0000000..9454f7e Binary files /dev/null and b/app/public/game/kingdom/siege.png differ diff --git a/app/public/game/kingdom/torch.svg b/app/public/game/kingdom/torch.svg new file mode 100755 index 0000000..de83906 --- /dev/null +++ b/app/public/game/kingdom/torch.svg @@ -0,0 +1,361 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/tower.svg b/app/public/game/kingdom/tower.svg new file mode 100755 index 0000000..29047ae --- /dev/null +++ b/app/public/game/kingdom/tower.svg @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/kingdom/wagon.svg b/app/public/game/kingdom/wagon.svg new file mode 100755 index 0000000..29a0b2e --- /dev/null +++ b/app/public/game/kingdom/wagon.svg @@ -0,0 +1,400 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/public/game/medieval-rts.json b/app/public/game/medieval-rts.json new file mode 100644 index 0000000..93c2dd1 --- /dev/null +++ b/app/public/game/medieval-rts.json @@ -0,0 +1,2534 @@ +{ + "frames": { + "Environment_01": { + "frame": { + "x": 1029, + "y": 636, + "w": 38, + "h": 92 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 38, + "h": 92 + }, + "sourceSize": { + "w": 38, + "h": 92 + } + }, + "Environment_02": { + "frame": { + "x": 1030, + "y": 728, + "w": 37, + "h": 66 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 37, + "h": 66 + }, + "sourceSize": { + "w": 37, + "h": 66 + } + }, + "Environment_03": { + "frame": { + "x": 1023, + "y": 0, + "w": 53, + "h": 96 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 53, + "h": 96 + }, + "sourceSize": { + "w": 53, + "h": 96 + } + }, + "Environment_04": { + "frame": { + "x": 838, + "y": 736, + "w": 28, + "h": 30 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 28, + "h": 30 + }, + "sourceSize": { + "w": 28, + "h": 30 + } + }, + "Environment_05": { + "frame": { + "x": 766, + "y": 736, + "w": 72, + "h": 30 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 72, + "h": 30 + }, + "sourceSize": { + "w": 72, + "h": 30 + } + }, + "Environment_06": { + "frame": { + "x": 804, + "y": 603, + "w": 36, + "h": 32 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 36, + "h": 32 + }, + "sourceSize": { + "w": 36, + "h": 32 + } + }, + "Environment_07": { + "frame": { + "x": 976, + "y": 963, + "w": 64, + "h": 56 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 56 + }, + "sourceSize": { + "w": 64, + "h": 56 + } + }, + "Environment_08": { + "frame": { + "x": 950, + "y": 535, + "w": 76, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 64 + }, + "sourceSize": { + "w": 76, + "h": 64 + } + }, + "Environment_09": { + "frame": { + "x": 950, + "y": 465, + "w": 76, + "h": 70 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 70 + }, + "sourceSize": { + "w": 76, + "h": 70 + } + }, + "Environment_10": { + "frame": { + "x": 950, + "y": 395, + "w": 76, + "h": 70 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 70 + }, + "sourceSize": { + "w": 76, + "h": 70 + } + }, + "Environment_11": { + "frame": { + "x": 950, + "y": 325, + "w": 76, + "h": 70 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 70 + }, + "sourceSize": { + "w": 76, + "h": 70 + } + }, + "Environment_12": { + "frame": { + "x": 903, + "y": 255, + "w": 40, + "h": 46 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 40, + "h": 46 + }, + "sourceSize": { + "w": 40, + "h": 46 + } + }, + "Environment_13": { + "frame": { + "x": 768, + "y": 603, + "w": 36, + "h": 32 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 36, + "h": 32 + }, + "sourceSize": { + "w": 36, + "h": 32 + } + }, + "Environment_14": { + "frame": { + "x": 951, + "y": 190, + "w": 64, + "h": 56 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 56 + }, + "sourceSize": { + "w": 64, + "h": 56 + } + }, + "Environment_15": { + "frame": { + "x": 900, + "y": 950, + "w": 76, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 64 + }, + "sourceSize": { + "w": 76, + "h": 64 + } + }, + "Environment_16": { + "frame": { + "x": 900, + "y": 1014, + "w": 76, + "h": 70 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 70 + }, + "sourceSize": { + "w": 76, + "h": 70 + } + }, + "Environment_17": { + "frame": { + "x": 951, + "y": 120, + "w": 76, + "h": 70 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 70 + }, + "sourceSize": { + "w": 76, + "h": 70 + } + }, + "Environment_18": { + "frame": { + "x": 950, + "y": 255, + "w": 76, + "h": 70 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 76, + "h": 70 + }, + "sourceSize": { + "w": 76, + "h": 70 + } + }, + "Environment_19": { + "frame": { + "x": 863, + "y": 255, + "w": 40, + "h": 47 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 40, + "h": 47 + }, + "sourceSize": { + "w": 40, + "h": 47 + } + }, + "Environment_20": { + "frame": { + "x": 1026, + "y": 318, + "w": 43, + "h": 96 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 43, + "h": 96 + }, + "sourceSize": { + "w": 43, + "h": 96 + } + }, + "Environment_21": { + "frame": { + "x": 1076, + "y": 0, + "w": 28, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 28, + "h": 64 + }, + "sourceSize": { + "w": 28, + "h": 64 + } + }, + "Structure_01": { + "frame": { + "x": 957, + "y": 699, + "w": 72, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 72, + "h": 84 + }, + "sourceSize": { + "w": 72, + "h": 84 + } + }, + "Structure_02": { + "frame": { + "x": 384, + "y": 1024, + "w": 120, + "h": 76 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 120, + "h": 76 + }, + "sourceSize": { + "w": 120, + "h": 76 + } + }, + "Structure_03": { + "frame": { + "x": 608, + "y": 1024, + "w": 104, + "h": 68 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 104, + "h": 68 + }, + "sourceSize": { + "w": 104, + "h": 68 + } + }, + "Structure_04": { + "frame": { + "x": 812, + "y": 1013, + "w": 88, + "h": 72 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 72 + }, + "sourceSize": { + "w": 88, + "h": 72 + } + }, + "Structure_05": { + "frame": { + "x": 862, + "y": 399, + "w": 88, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 84 + }, + "sourceSize": { + "w": 88, + "h": 84 + } + }, + "Structure_06": { + "frame": { + "x": 128, + "y": 1024, + "w": 120, + "h": 76 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 120, + "h": 76 + }, + "sourceSize": { + "w": 120, + "h": 76 + } + }, + "Structure_07": { + "frame": { + "x": 504, + "y": 1024, + "w": 104, + "h": 68 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 104, + "h": 68 + }, + "sourceSize": { + "w": 104, + "h": 68 + } + }, + "Structure_08": { + "frame": { + "x": 959, + "y": 0, + "w": 64, + "h": 80 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 80 + }, + "sourceSize": { + "w": 64, + "h": 80 + } + }, + "Structure_09": { + "frame": { + "x": 760, + "y": 893, + "w": 112, + "h": 120 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 112, + "h": 120 + }, + "sourceSize": { + "w": 112, + "h": 120 + } + }, + "Structure_10": { + "frame": { + "x": 862, + "y": 555, + "w": 88, + "h": 80 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 80 + }, + "sourceSize": { + "w": 88, + "h": 80 + } + }, + "Structure_11": { + "frame": { + "x": 863, + "y": 175, + "w": 88, + "h": 80 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 80 + }, + "sourceSize": { + "w": 88, + "h": 80 + } + }, + "Structure_12": { + "frame": { + "x": 1026, + "y": 514, + "w": 40, + "h": 72 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 40, + "h": 72 + }, + "sourceSize": { + "w": 40, + "h": 72 + } + }, + "Structure_13": { + "frame": { + "x": 768, + "y": 0, + "w": 103, + "h": 103 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 103, + "h": 103 + }, + "sourceSize": { + "w": 103, + "h": 103 + } + }, + "Structure_14": { + "frame": { + "x": 640, + "y": 768, + "w": 125, + "h": 125 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 125, + "h": 125 + }, + "sourceSize": { + "w": 125, + "h": 125 + } + }, + "Structure_15": { + "frame": { + "x": 640, + "y": 640, + "w": 126, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 128 + }, + "sourceSize": { + "w": 126, + "h": 128 + } + }, + "Structure_16": { + "frame": { + "x": 869, + "y": 736, + "w": 88, + "h": 120 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 120 + }, + "sourceSize": { + "w": 88, + "h": 120 + } + }, + "Structure_17": { + "frame": { + "x": 871, + "y": 0, + "w": 88, + "h": 120 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 120 + }, + "sourceSize": { + "w": 88, + "h": 120 + } + }, + "Structure_18": { + "frame": { + "x": 862, + "y": 303, + "w": 88, + "h": 96 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 96 + }, + "sourceSize": { + "w": 88, + "h": 96 + } + }, + "Structure_19": { + "frame": { + "x": 765, + "y": 768, + "w": 104, + "h": 120 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 104, + "h": 120 + }, + "sourceSize": { + "w": 104, + "h": 120 + } + }, + "Structure_20": { + "frame": { + "x": 766, + "y": 640, + "w": 104, + "h": 96 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 104, + "h": 96 + }, + "sourceSize": { + "w": 104, + "h": 96 + } + }, + "Structure_21": { + "frame": { + "x": 640, + "y": 893, + "w": 120, + "h": 120 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 120, + "h": 120 + }, + "sourceSize": { + "w": 120, + "h": 120 + } + }, + "Structure_22": { + "frame": { + "x": 976, + "y": 883, + "w": 64, + "h": 80 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 80 + }, + "sourceSize": { + "w": 64, + "h": 80 + } + }, + "Structure_23": { + "frame": { + "x": 862, + "y": 483, + "w": 88, + "h": 72 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 88, + "h": 72 + }, + "sourceSize": { + "w": 88, + "h": 72 + } + }, + "Tile_01": { + "frame": { + "x": 0, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_02": { + "frame": { + "x": 0, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_03": { + "frame": { + "x": 0, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_04": { + "frame": { + "x": 128, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_05": { + "frame": { + "x": 128, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_06": { + "frame": { + "x": 128, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_07": { + "frame": { + "x": 128, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_08": { + "frame": { + "x": 1026, + "y": 190, + "w": 60, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 128 + }, + "sourceSize": { + "w": 60, + "h": 128 + } + }, + "Tile_09": { + "frame": { + "x": 0, + "y": 1024, + "w": 128, + "h": 60 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 60 + }, + "sourceSize": { + "w": 128, + "h": 60 + } + }, + "Tile_10": { + "frame": { + "x": 256, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_11": { + "frame": { + "x": 256, + "y": 256, + "w": 128, + "h": 94 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 94 + }, + "sourceSize": { + "w": 128, + "h": 94 + } + }, + "Tile_12": { + "frame": { + "x": 256, + "y": 606, + "w": 128, + "h": 94 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 94 + }, + "sourceSize": { + "w": 128, + "h": 94 + } + }, + "Tile_13": { + "frame": { + "x": 256, + "y": 700, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_14": { + "frame": { + "x": 640, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_15": { + "frame": { + "x": 640, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_16": { + "frame": { + "x": 640, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_17": { + "frame": { + "x": 640, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_18": { + "frame": { + "x": 384, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_19": { + "frame": { + "x": 0, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_20": { + "frame": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_21": { + "frame": { + "x": 384, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_22": { + "frame": { + "x": 768, + "y": 303, + "w": 94, + "h": 86 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 94, + "h": 86 + }, + "sourceSize": { + "w": 94, + "h": 86 + } + }, + "Tile_23": { + "frame": { + "x": 870, + "y": 635, + "w": 86, + "h": 94 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 86, + "h": 94 + }, + "sourceSize": { + "w": 86, + "h": 94 + } + }, + "Tile_24": { + "frame": { + "x": 712, + "y": 1013, + "w": 100, + "h": 72 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 100, + "h": 72 + }, + "sourceSize": { + "w": 100, + "h": 72 + } + }, + "Tile_25": { + "frame": { + "x": 768, + "y": 389, + "w": 94, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 94, + "h": 128 + }, + "sourceSize": { + "w": 94, + "h": 128 + } + }, + "Tile_26": { + "frame": { + "x": 768, + "y": 175, + "w": 95, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 95, + "h": 128 + }, + "sourceSize": { + "w": 95, + "h": 128 + } + }, + "Tile_27": { + "frame": { + "x": 384, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_28": { + "frame": { + "x": 512, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_29": { + "frame": { + "x": 0, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_30": { + "frame": { + "x": 0, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_31": { + "frame": { + "x": 512, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_32": { + "frame": { + "x": 512, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_33": { + "frame": { + "x": 512, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_34": { + "frame": { + "x": 640, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_35": { + "frame": { + "x": 512, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_36": { + "frame": { + "x": 872, + "y": 856, + "w": 86, + "h": 94 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 86, + "h": 94 + }, + "sourceSize": { + "w": 86, + "h": 94 + } + }, + "Tile_37": { + "frame": { + "x": 768, + "y": 517, + "w": 94, + "h": 86 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 94, + "h": 86 + }, + "sourceSize": { + "w": 94, + "h": 86 + } + }, + "Tile_38": { + "frame": { + "x": 768, + "y": 103, + "w": 100, + "h": 72 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 100, + "h": 72 + }, + "sourceSize": { + "w": 100, + "h": 72 + } + }, + "Tile_39": { + "frame": { + "x": 956, + "y": 599, + "w": 72, + "h": 100 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 72, + "h": 100 + }, + "sourceSize": { + "w": 72, + "h": 100 + } + }, + "Tile_40": { + "frame": { + "x": 958, + "y": 783, + "w": 72, + "h": 100 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 72, + "h": 100 + }, + "sourceSize": { + "w": 72, + "h": 100 + } + }, + "Tile_41": { + "frame": { + "x": 512, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_42": { + "frame": { + "x": 512, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_43": { + "frame": { + "x": 256, + "y": 478, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_44": { + "frame": { + "x": 512, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_45": { + "frame": { + "x": 256, + "y": 350, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_46": { + "frame": { + "x": 256, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_47": { + "frame": { + "x": 128, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_48": { + "frame": { + "x": 128, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_49": { + "frame": { + "x": 128, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_50": { + "frame": { + "x": 384, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_51": { + "frame": { + "x": 128, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_52": { + "frame": { + "x": 0, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_53": { + "frame": { + "x": 384, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_54": { + "frame": { + "x": 384, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_55": { + "frame": { + "x": 384, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_56": { + "frame": { + "x": 384, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_57": { + "frame": { + "x": 256, + "y": 956, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Tile_58": { + "frame": { + "x": 256, + "y": 828, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "Unit_01": { + "frame": { + "x": 1067, + "y": 96, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_02": { + "frame": { + "x": 909, + "y": 120, + "w": 41, + "h": 52 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 41, + "h": 52 + }, + "sourceSize": { + "w": 41, + "h": 52 + } + }, + "Unit_03": { + "frame": { + "x": 1030, + "y": 794, + "w": 36, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 36, + "h": 50 + }, + "sourceSize": { + "w": 36, + "h": 50 + } + }, + "Unit_04": { + "frame": { + "x": 1026, + "y": 464, + "w": 40, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 40, + "h": 50 + }, + "sourceSize": { + "w": 40, + "h": 50 + } + }, + "Unit_05": { + "frame": { + "x": 1066, + "y": 794, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_06": { + "frame": { + "x": 1058, + "y": 994, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_07": { + "frame": { + "x": 1058, + "y": 1042, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_08": { + "frame": { + "x": 868, + "y": 120, + "w": 41, + "h": 52 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 41, + "h": 52 + }, + "sourceSize": { + "w": 41, + "h": 52 + } + }, + "Unit_09": { + "frame": { + "x": 1040, + "y": 844, + "w": 36, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 36, + "h": 50 + }, + "sourceSize": { + "w": 36, + "h": 50 + } + }, + "Unit_10": { + "frame": { + "x": 1027, + "y": 96, + "w": 40, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 40, + "h": 50 + }, + "sourceSize": { + "w": 40, + "h": 50 + } + }, + "Unit_11": { + "frame": { + "x": 1066, + "y": 462, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_12": { + "frame": { + "x": 1068, + "y": 558, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_13": { + "frame": { + "x": 1066, + "y": 414, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_14": { + "frame": { + "x": 1017, + "y": 1019, + "w": 41, + "h": 52 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 41, + "h": 52 + }, + "sourceSize": { + "w": 41, + "h": 52 + } + }, + "Unit_15": { + "frame": { + "x": 1040, + "y": 894, + "w": 36, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 36, + "h": 50 + }, + "sourceSize": { + "w": 36, + "h": 50 + } + }, + "Unit_16": { + "frame": { + "x": 1026, + "y": 414, + "w": 40, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 40, + "h": 50 + }, + "sourceSize": { + "w": 40, + "h": 50 + } + }, + "Unit_17": { + "frame": { + "x": 1066, + "y": 510, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_18": { + "frame": { + "x": 1067, + "y": 636, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_19": { + "frame": { + "x": 1067, + "y": 684, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_20": { + "frame": { + "x": 976, + "y": 1019, + "w": 41, + "h": 52 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 41, + "h": 52 + }, + "sourceSize": { + "w": 41, + "h": 52 + } + }, + "Unit_21": { + "frame": { + "x": 1040, + "y": 944, + "w": 36, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 36, + "h": 50 + }, + "sourceSize": { + "w": 36, + "h": 50 + } + }, + "Unit_22": { + "frame": { + "x": 1028, + "y": 586, + "w": 40, + "h": 50 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 40, + "h": 50 + }, + "sourceSize": { + "w": 40, + "h": 50 + } + }, + "Unit_23": { + "frame": { + "x": 1067, + "y": 732, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + }, + "Unit_24": { + "frame": { + "x": 1069, + "y": 318, + "w": 33, + "h": 48 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 33, + "h": 48 + }, + "sourceSize": { + "w": 33, + "h": 48 + } + } + }, + "meta": { + "image": "medieval-rts.png", + "format": "RGBA8888", + "size": { + "w": 1104, + "h": 1100 + }, + "scale": "1", + "app": "kenney.nl medieval-rts, CC0" + } +} diff --git a/app/public/game/medieval-rts.png b/app/public/game/medieval-rts.png new file mode 100644 index 0000000..e67ff83 Binary files /dev/null and b/app/public/game/medieval-rts.png differ diff --git a/app/public/game/ui/frame-heavy.png b/app/public/game/ui/frame-heavy.png new file mode 100644 index 0000000..007278c Binary files /dev/null and b/app/public/game/ui/frame-heavy.png differ diff --git a/app/public/game/ui/frame-panel.png b/app/public/game/ui/frame-panel.png new file mode 100644 index 0000000..fded4b3 Binary files /dev/null and b/app/public/game/ui/frame-panel.png differ diff --git a/app/public/game/ui/pointer-press.svg b/app/public/game/ui/pointer-press.svg new file mode 100644 index 0000000..911e305 --- /dev/null +++ b/app/public/game/ui/pointer-press.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/app/public/game/ui/pointer.svg b/app/public/game/ui/pointer.svg new file mode 100644 index 0000000..2dcb091 --- /dev/null +++ b/app/public/game/ui/pointer.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/app/qa.html b/app/qa.html new file mode 100644 index 0000000..a9f732e --- /dev/null +++ b/app/qa.html @@ -0,0 +1,21 @@ + + + + + + + shell.online — QA + + + +
+ + + diff --git a/app/scripts/check-bundle.mjs b/app/scripts/check-bundle.mjs index 535fec0..238b4a6 100644 --- a/app/scripts/check-bundle.mjs +++ b/app/scripts/check-bundle.mjs @@ -85,4 +85,59 @@ if (blank.length > 0) { process.exit(1); } +// The game skin must not be in what the corporate view downloads. +// +// src/game carries a renderer, a sprite atlas and a stylesheet of its own, for +// a screen most sessions never open. It is reached through a dynamic import so +// that none of it is fetched until somebody asks for it, and that is the sort +// of property which holds right up until a convenient-looking direct import +// puts it back. So it is checked rather than trusted. +// +// What is checked is everything the browser loads before first paint: the entry +// script, plus the chunks Vite preloads alongside it because the entry imports +// them statically. A lazily-imported chunk appears in neither, which is exactly +// the point. +const KEEP_MARKER = "__SHELL_KEEP__"; + +async function eagerChunks(dir) { + let html; + try { + html = await readFile(join(dir, "index.html"), "utf8"); + } catch { + // No index.html means this is not a client build; nothing to check. + return []; + } + const paths = new Set(); + for (const [, src] of html.matchAll(/]+src="([^"]+\.js)"/gu)) paths.add(src); + for (const [, href] of html.matchAll( + /]+rel="modulepreload"[^>]+href="([^"]+\.js)"/gu, + )) { + paths.add(href); + } + // Written as absolute URLs against the site root; read them against dist. + return [...paths].map((path) => join(dir, path.replace(/^\//u, ""))); +} + +const leaked = []; +for (const path of await eagerChunks(directory)) { + let body; + try { + body = await readFile(path, "utf8"); + } catch { + continue; + } + if (body.includes(KEEP_MARKER)) leaked.push(path); +} + +if (leaked.length > 0) { + console.error("check-bundle: the game skin is in the bundle the session list loads"); + for (const path of leaked) console.error(` ${path}`); + console.error(" src/game is meant to be reached only through the dynamic import in"); + console.error(" src/App.tsx. Something now imports it directly, so every visitor pays"); + console.error(" for a renderer and a sprite atlas to look at a list of sessions."); + console.error(" Find the static import and make it lazy again."); + process.exit(1); +} + console.log("check-bundle: no loopback addresses in the production build."); +console.log("check-bundle: the game skin is not in the entry bundle."); diff --git a/app/scripts/check-game-ui.mjs b/app/scripts/check-game-ui.mjs new file mode 100644 index 0000000..482d97e --- /dev/null +++ b/app/scripts/check-game-ui.mjs @@ -0,0 +1,219 @@ +// Holds the game skin to the rules that make a game playable for everybody. +// +// These come from the game-ui-design skill's validations. They are checked +// rather than remembered because every one of them is invisible on the machine +// it was written on: a 12px label is fine on a laptop and unreadable across a +// living room, a 24px button is fine under a mouse and unhittable under a +// thumb, and "Press A" is fine until somebody picks up a PlayStation pad. +// +// Where a rule is implemented differently from the skill's own regex, it is +// because the literal pattern produced false positives that would have trained +// everybody to ignore the output. `border: 4px solid` contains "width: 4px" +// once the shorthand is expanded, and a lint nobody believes is worse than no +// lint. Each departure is noted at the rule. +// +// node scripts/check-game-ui.mjs +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const ROOTS = ["src/game", "src/styles/game.css"]; + +/** Minimums, at the reference scale. */ +const MIN_FONT_PX = 16; +const MIN_HIT_PX = 44; +const MAX_MOTION_MS = 300; +const MAX_Z_INDEX = 400; + +/** Selectors that describe something a player points at, clicks or focuses. */ +const INTERACTIVE = /button|input|select|textarea|\ba\b|\[role=|menu-item|keep-button|keep-choice|keep-key/i; + +async function* walk(path) { + let entries; + try { + entries = await readdir(path, { withFileTypes: true }); + } catch { + // A file rather than a directory. + yield path; + return; + } + for (const entry of entries) { + const next = join(path, entry.name); + if (entry.isDirectory()) yield* walk(next); + else yield next; + } +} + +const problems = []; +const note = (file, line, rule, message, fix) => + problems.push({ file, line, rule, message, fix }); + +/** The 1-based line a character offset falls on. */ +const lineAt = (body, index) => body.slice(0, index).split("\n").length; + +/* + * Splits a stylesheet into { selector, body, line } blocks. + * + * Deliberately simple: this only has to understand the one stylesheet in this + * repository, and a real CSS parser is a dependency for a build check. + * At-rules are kept, because their contents still need checking. + */ +function rules(css) { + const found = []; + const pattern = /([^{}]+)\{([^{}]*)\}/g; + for (const match of css.matchAll(pattern)) { + found.push({ + selector: match[1].trim(), + body: match[2], + line: lineAt(css, match.index ?? 0), + }); + } + return found; +} + +function checkStylesheet(file, css) { + // --- text nobody can read ------------------------------------------------ + // + // Literal pixel sizes only. Everything in game.css is written as + // calc(16px * var(--keep-scale)) so the interface-size slider moves it, and + // those are the sizes that are already correct. + for (const match of css.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)) { + const size = Number(match[1]); + if (size >= MIN_FONT_PX) continue; + note( + file, lineAt(css, match.index), "font-too-small", + `font-size: ${size}px is below the ${MIN_FONT_PX}px floor`, + "Game text is read across a room and on a handheld. Use var(--keep-text) or larger.", + ); + } + + // --- things too small to hit --------------------------------------------- + // + // Scoped to interactive rules, unlike the skill's own pattern, which matches + // any width or height and so flags every border shorthand in the file. + for (const rule of rules(css)) { + if (!INTERACTIVE.test(rule.selector)) continue; + for (const match of rule.body.matchAll(/(?:^|[;\s])(min-)?(width|height):\s*(\d+(?:\.\d+)?)px/g)) { + const size = Number(match[3]); + if (size >= MIN_HIT_PX) continue; + note( + file, rule.line, "touch-target-too-small", + `${rule.selector} sets ${match[2]}: ${size}px`, + `Anything you can hit needs ${MIN_HIT_PX}px. Use var(--keep-hit), or expand the hit area around a smaller visual.`, + ); + } + } + + // --- motion that makes people ill --------------------------------------- + for (const match of css.matchAll(/(?:animation|transition)(?:-duration)?:[^;]*?(\d+(?:\.\d+)?)(ms|s)\b/g)) { + const ms = match[2] === "s" ? Number(match[1]) * 1000 : Number(match[1]); + if (ms <= MAX_MOTION_MS) continue; + note( + file, lineAt(css, match.index), "motion-too-long", + `a ${ms}ms duration is over the ${MAX_MOTION_MS}ms ceiling`, + "Long interface motion reads as lag and can cause nausea. Shorten it.", + ); + } + + // --- a layering scale, not a layering war -------------------------------- + for (const match of css.matchAll(/z-index:\s*(\d+)/g)) { + const value = Number(match[1]); + if (value <= MAX_Z_INDEX) continue; + note( + file, lineAt(css, match.index), "z-index-war", + `z-index: ${value} is above the ${MAX_Z_INDEX} ceiling`, + "Use the named scale on .keep: --keep-z-hud, --keep-z-modal, --keep-z-tooltip, --keep-z-toast.", + ); + } + + // --- focus somebody can see --------------------------------------------- + // + // Removing the outline is fine; removing it with nothing in its place means + // a pad can move focus somewhere invisible, which is the same as losing it. + if (/outline:\s*(none|0)\b/.test(css) && !/:focus-visible/.test(css)) { + const match = css.match(/outline:\s*(none|0)\b/); + note( + file, lineAt(css, match.index), "focus-invisible", + "the outline is removed and nothing replaces it", + "Add a :focus-visible rule with a visible ring.", + ); + } + + // --- a way to turn the movement off -------------------------------------- + if (/@keyframes|animation:/.test(css) && !/prefers-reduced-motion/.test(css)) { + note( + file, 1, "no-reduced-motion", + "this stylesheet animates but never checks prefers-reduced-motion", + "Add a @media (prefers-reduced-motion: reduce) block that stops it.", + ); + } +} + +function checkSource(file, source) { + // --- buttons the player may not have ------------------------------------- + // + // The one rule with a deliberate exception: input.ts is the table that + // resolves an action to whatever this player is holding, so it is the only + // place allowed to name a button at all. + if (!file.endsWith("engine/input.ts")) { + for (const match of source.matchAll( + /["'`](?:Press|Hit|Tap|Push)\s+(?:A|B|X|Y|Start|Select|Space|Enter|Esc|LB|RB|LT|RT|L1|R1|L2|R2)\b/g, + )) { + note( + file, lineAt(source, match.index), "hardcoded-button-prompt", + `${match[0].slice(1)}… names a physical button`, + "Use , which resolves to the device in the player's hands.", + ); + } + } + + // --- inline text too small to read --------------------------------------- + for (const match of source.matchAll(/fontSize:\s*["']?(\d+)(?:px)?["']?/g)) { + const size = Number(match[1]); + if (size >= MIN_FONT_PX) continue; + note( + file, lineAt(source, match.index), "font-too-small", + `fontSize: ${size} is below the ${MIN_FONT_PX}px floor`, + "Style it in game.css against the --keep-text scale instead.", + ); + } + + // --- layering, again, for anything set inline ---------------------------- + for (const match of source.matchAll(/zIndex:\s*["']?(\d+)/g)) { + const value = Number(match[1]); + if (value <= MAX_Z_INDEX) continue; + note( + file, lineAt(source, match.index), "z-index-war", + `zIndex: ${value} is above the ${MAX_Z_INDEX} ceiling`, + "Use the named scale on .keep.", + ); + } +} + +for (const root of ROOTS) { + for await (const file of walk(root)) { + if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue; + let body; + try { + body = await readFile(file, "utf8"); + } catch { + continue; + } + if (file.endsWith(".css")) checkStylesheet(file, body); + else if (/\.tsx?$/.test(file)) checkSource(file, body); + } +} + +if (problems.length > 0) { + console.error(`check-game-ui: ${problems.length} problem(s) in the game skin\n`); + for (const { file, line, rule, message, fix } of problems) { + console.error(` ${file}:${line} [${rule}]`); + console.error(` ${message}`); + console.error(` ${fix}\n`); + } + console.error(" These come from the game-ui-design skill. Each one is something"); + console.error(" that works on the machine it was written on and fails on somebody"); + console.error(" else's television, handheld, or controller."); + process.exit(1); +} + +console.log("check-game-ui: the game skin meets the readability and input rules."); diff --git a/app/scripts/import-fantasy-ui.mjs b/app/scripts/import-fantasy-ui.mjs new file mode 100644 index 0000000..eec6fef --- /dev/null +++ b/app/scripts/import-fantasy-ui.mjs @@ -0,0 +1,106 @@ +// Turns Kenney's Fantasy UI Borders into the tinted 9-slice frames the game uses. +// +// The frames in public/game/ui/ are Kenney's Fantasy UI Borders pack, which is +// CC0 and may be used commercially. They ship as white line art on transparency +// and the keep is brass on warm stone, so each one is recoloured on the way in. +// +// 1. Download https://kenney.nl/assets/fantasy-ui-borders +// 2. node scripts/import-fantasy-ui.mjs +// +// Recolouring costs three bytes and a checksum, which is the whole reason this +// script is short: every file in the pack is a 1-bit paletted PNG whose palette +// is exactly two entries, transparent and white. Changing the ink means +// rewriting the second PLTE entry and fixing that chunk's CRC. Nothing is +// inflated, no pixels are touched, and the output is byte-for-byte the same +// image in a different colour. +// +// Provenance and licence are recorded in docs/third-party-notices.md. +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const source = process.argv[2]; +if (!source) { + console.error("usage: node scripts/import-fantasy-ui.mjs "); + process.exit(1); +} + +const out = "public/game/ui"; + +/** + * The frames the interface actually uses, and what each one is for. + * + * Two, not ninety-six. A pack this size is an invitation to give every panel + * its own frame, and an interface where nothing is framed the same way twice + * reads as a sampler rather than a place. One frame means "over the map", the + * other means "the game has stopped", and that is the whole vocabulary. + */ +const FRAMES = [ + { + to: "frame-panel.png", + from: ["PNG", "Default", "Border", "panel-border-014.png"], + // A double rule with corner ticks: everything laid over the map. + ink: "#e8b44a", + }, + { + to: "frame-heavy.png", + from: ["PNG", "Double", "Border", "panel-border-014.png"], + // Cut corners, doubled again: anything that has stopped the game to ask. + ink: "#e8b44a", + }, +]; + +/** PNG's CRC-32, which every chunk carries and every editor must recompute. */ +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(bytes) { + let c = 0xffffffff; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +/** Walks the chunk list, which is the only structure this script needs. */ +function* chunks(png) { + let at = 8; + while (at < png.length) { + const length = png.readUInt32BE(at); + yield { at, length, type: png.toString("ascii", at + 4, at + 8) }; + at += 12 + length; + } +} + +/** + * Repaints the ink of a two-entry paletted PNG. + * + * Entry 0 is the transparent one -- tRNS says so -- and entry 1 is the line + * art. Only entry 1 moves, so the transparency survives untouched and so does + * every pixel; this is a palette swap in the original sense of the phrase. + */ +function repaint(png, hex) { + const plte = [...chunks(png)].find((chunk) => chunk.type === "PLTE"); + if (!plte) throw new Error("not a paletted PNG: no PLTE chunk"); + if (plte.length !== 6) throw new Error(`expected a two-colour palette, found ${plte.length / 3}`); + + const copy = Buffer.from(png); + const ink = plte.at + 8 + 3; + copy[ink] = Number.parseInt(hex.slice(1, 3), 16); + copy[ink + 1] = Number.parseInt(hex.slice(3, 5), 16); + copy[ink + 2] = Number.parseInt(hex.slice(5, 7), 16); + + /* The CRC covers the type and the data, and not the length before them. */ + copy.writeUInt32BE(crc32(copy.subarray(plte.at + 4, plte.at + 8 + plte.length)), plte.at + 8 + plte.length); + return copy; +} + +await mkdir(out, { recursive: true }); + +for (const frame of FRAMES) { + const png = await readFile(join(source, ...frame.from)); + await writeFile(join(out, frame.to), repaint(png, frame.ink)); + console.log(`${frame.to} ${frame.ink} from ${frame.from.join("/")}`); +} + +console.log(`\n${FRAMES.length} frames written to ${out}`); diff --git a/app/scripts/import-grass.py b/app/scripts/import-grass.py new file mode 100755 index 0000000..30177b2 --- /dev/null +++ b/app/scripts/import-grass.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Brings a few grass tufts into public/game/kingdom/. + + python3 scripts/import-grass.py + +Six out of fourteen, and the choice is the point. The wide low ones read as a +band of grass when they are set side by side, which is what is wanted round the +foot of the castle; the tall ones with flowers on them are single clumps and are +used sparingly among them so the band is not the same tuft repeated. + +They ship at two thousand pixels for print and are drawn here about sixty wide, +so they are trimmed to their own bounds and resized hard -- the same reason the +banners in import-kingdom.mjs are downscaled and the results committed. + +Pillow only, and not a dependency of the application: this regenerates committed +assets and is run by hand when the source art changes. +""" + +import sys +from pathlib import Path + +from PIL import Image + +if len(sys.argv) < 2: + sys.exit("usage: python3 scripts/import-grass.py ") + +source = Path(sys.argv[1]) / "Green Grass Illustrations Set" +out = Path("public/game/kingdom") + +# The wide low bands first, then two taller clumps for the ones that stand out +# of the band. Named by what they are rather than by the number they shipped +# under, because "Green Grass Illustration 12" tells nobody anything. +TAKE = [ + ("Green Grass Illustration 11.png", "grass-band-a.png"), + ("Green Grass Illustration 12.png", "grass-band-b.png"), + ("Green Grass Illustration 13.png", "grass-band-c.png"), + ("Green Grass Illustration 14.png", "grass-band-d.png"), + ("Green Grass Illustration 4.png", "grass-tuft-a.png"), + ("Green Grass Illustration 6.png", "grass-tuft-b.png"), +] + +# How wide a tuft is kept, in pixels. +# +# Small, because these are pixel art by the time they are committed and the +# width *is* the pixel size. At forty across, drawn at about a tile and a half, +# a blade of grass is two or three screen pixels wide -- the same grain as +# Kenney's sprites beside it. +WIDE = 40 +# How many colours survive. The set is painted with soft gradients down every +# blade; left alone next to flat sprites it reads as a photograph someone has +# dropped onto a cartoon, and the palette is most of that. Ten is enough for a +# light side, a dark side and a stem. +COLOURS = 10 +# Anything less opaque than this is cut away rather than feathered: a soft edge +# on a sprite scaled up is a halo. +ALPHA_CUT = 120 + +out.mkdir(parents=True, exist_ok=True) +for name, to in TAKE: + path = source / name + if not path.exists(): + print(f"missing, skipped: {name}") + continue + tuft = Image.open(path).convert("RGBA") + tuft = tuft.crop(tuft.getbbox()) + width, height = tuft.size + scale = WIDE / width + # BOX rather than LANCZOS: averaging the block is what makes a pixel, and a + # sharpening filter puts ringing on every blade before it is quantised. + tuft = tuft.resize((WIDE, max(1, round(height * scale))), Image.BOX) + + # Then flattened, the same two steps the castle went through. Resizing alone + # keeps the gradients and just makes them big soft blocks. + alpha = tuft.getchannel("A").point(lambda value: 255 if value > ALPHA_CUT else 0) + flat = tuft.convert("RGB").quantize( + colors=COLOURS, method=Image.MEDIANCUT, dither=Image.Dither.NONE + ) + tuft = flat.convert("RGBA") + tuft.putalpha(alpha) + + tuft.save(out / to) + print(f"{to} <- {name} ({tuft.width}x{tuft.height}, {COLOURS} colours)") diff --git a/app/scripts/import-kenney.mjs b/app/scripts/import-kenney.mjs new file mode 100644 index 0000000..b7ea517 --- /dev/null +++ b/app/scripts/import-kenney.mjs @@ -0,0 +1,73 @@ +// Turns Kenney's spritesheet XML into the JSON atlas PixiJS reads. +// +// The art in public/game/ is Kenney's Medieval RTS pack, which is CC0 and may +// be used commercially. It ships as one PNG plus an XML atlas in Kenney's own +// format; Pixi wants TexturePacker JSON. Rather than hand-convert once and +// leave a mystery file in the repository, the conversion lives here so the +// next person can re-run it against a newer version of the pack. +// +// 1. Download https://kenney.nl/assets/medieval-rts +// 2. node scripts/import-kenney.mjs +// +// Provenance and licence are recorded in docs/third-party-notices.md. +import { copyFile, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const source = process.argv[2]; +if (!source) { + console.error("usage: node scripts/import-kenney.mjs "); + process.exit(1); +} + +const sheet = join(source, "Spritesheet", "medievalRTS_spritesheet@2"); +const out = "public/game"; + +/** The PNG header carries the dimensions the atlas has to declare. */ +async function pngSize(path) { + const data = await readFile(path); + return { w: data.readUInt32BE(16), h: data.readUInt32BE(20) }; +} + +const xml = await readFile(`${sheet}.xml`, "utf8"); +const size = await pngSize(`${sheet}.png`); + +const frames = {}; +const pattern = / ${out}/medieval-rts.json`); diff --git a/app/scripts/import-kingdom.mjs b/app/scripts/import-kingdom.mjs new file mode 100644 index 0000000..fcc225c --- /dev/null +++ b/app/scripts/import-kingdom.mjs @@ -0,0 +1,168 @@ +// Brings the medieval art pack into public/game/kingdom/. +// +// node scripts/import-kingdom.mjs +// +// The pack is not vendored whole. It is roughly two hundred files, most of a +// gigabyte of it in three-thousand-pixel renders, and the game uses about a +// dozen; copying the rest would put art nobody loads into a chunk somebody +// downloads. This script records exactly which files were taken and what was +// done to them, so the choice can be revisited without anybody having to guess. +// +// Provenance is recorded in docs/third-party-notices.md. The pack arrived with +// no licence file, so it is recorded there as supplied by the repository owner +// -- which is a statement about where it came from, not a licence. +// +// The castle over the Keep is not here either, and is no longer imported at +// all. It was this pack's Castle.png, which is rendered square to the camera -- +// wrong for a diamond grid, and unfixable: a shear lays a picture's horizontals +// onto one of the map's axes, and the second shear needed for the other axis +// leans every tower, because a shear cannot rotate a three-dimensional render. +// The Keep is built out of Kenney's own castle pieces instead, which were drawn +// isometric to begin with. See `pixi/scene.ts`. +// +// The grass round its foot does come from outside, and is flattened to pixel +// art the same way: see `scripts/import-grass.py`. +// +// The flags are downscaled with `sips`, which is macOS-only. That is a real +// limitation and the reason the downscaled results are committed rather than +// generated at build time: a build that only works on one operating system is +// worse than a script that only re-runs on one. +import { mkdir, copyFile, stat } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +const source = process.argv[2]; +if (!source) { + console.error("usage: node scripts/import-kingdom.mjs "); + process.exit(1); +} + +const out = "public/game/kingdom"; + +/** + * The banners that stand at a hero's camp. + * + * Downscaled hard. They ship at three thousand pixels for print; on the map a + * camp banner is about sixty pixels tall, so anything past a few hundred is + * bytes nobody sees. + */ +const FLAGS = [ + ["flag-medieval-realistic_01/flag-medieval-realistic_04/flag-medieval-realistic_04.png", "banner-a.png"], + ["flag-medieval-realistic_01/flag-medieval-realistic_05/flag-medieval-realistic_05.png", "banner-b.png"], + ["flag-medieval-realistic_01/flag-medieval-realistic_06/flag-medieval-realistic_06.png", "banner-c.png"], + ["flag-medieval-realistic_01/flag-medieval-realistic_07/flag-medieval-realistic_07.png", "banner-d.png"], +]; + +/** How wide a banner is kept. See the note above. */ +const BANNER_WIDTH = 360; + +/** + * Conifers for the border wood. + * + * Only the conifers. The set is called "palm tree and pine forest" and most of + * it is palms, which would be a strange thing to find at the edge of a map of + * keeps and barrows. The flat silhouette is the one that reads at a distance, + * which is the only distance any of these are seen from. + */ +const TREES = [ + ["green-nature-palm-tree-and-pine-forest-2026-02-24-00-33-38-utc/SVG/palmtreeart-16.svg", "pine-dark.svg"], + ["green-nature-palm-tree-and-pine-forest-2026-02-24-00-33-38-utc/SVG/palmtreeart-05.svg", "pine-tall.svg"], + ["green-nature-palm-tree-and-pine-forest-2026-02-24-00-33-38-utc/SVG/palmtreeart-08.svg", "pine-broad.svg"], + /* + * Two more, and not silhouettes. + * + * The three above are a flat near-black shape, which reads at a distance but + * gives a wood made only of them no depth at all -- at dusk the whole edge of + * the map went to one black band. These two are drawn in sage, so the + * treeline has something in it besides its own outline. + */ + ["green-nature-palm-tree-and-pine-forest-2026-02-24-00-33-38-utc/SVG/palmtreeart-06.svg", "pine-light-a.svg"], + ["green-nature-palm-tree-and-pine-forest-2026-02-24-00-33-38-utc/SVG/palmtreeart-07.svg", "pine-light-b.svg"], +]; + +/** + * The marks the interface uses, in place of the typographic glyphs it had. + * + * One per thing that needed naming, and no more. A hundred and twenty icons is + * an invitation to decorate, and an interface where every line has a picture + * beside it is one where none of the pictures mean anything. + */ +const ICONS = [ + ["medieval-icons/Filled/SVG/Filled 2_Medieval Castle.svg", "castle.svg"], + ["medieval-icons/Filled/SVG/Filled 2_Medieval Shield.svg", "shield.svg"], + ["medieval-icons/Filled/SVG/Filled 2_Medieval Tower.svg", "tower.svg"], + ["medieval-icons/Filled/SVG/Filled 2_Medieval Torch.svg", "torch.svg"], + ["medieval-icons/Filled/SVG/Filled 2_Medieval Cart Wagon.svg", "wagon.svg"], + ["medieval-icons/Filled/SVG/Filled 2_Medieval Goblet.svg", "goblet.svg"], + ["medieval-icons/Filled/SVG/Filled 2_Old Maps.svg", "map.svg"], + ["medieval-icons/Filled/SVG/Filled 2_Medieval Lance.svg", "lance.svg"], +]; + +/** + * The rendered pieces, which are landmarks rather than icons. + * + * Grey and gold, which is what makes them usable: the flat banners in this pack + * are red on gold and cannot be tinted to anybody's colours without going + * muddy, and `banner` here is grey, so it takes a hero's colour cleanly. That + * is the whole reason it is in this list. + * + * They ship at three thousand pixels. The castle is a building on the map and + * gets the most; the rest are smaller things and get less. + */ +const RENDERS = [ + ["medieval-kingdom-3d-icons/Siege Weapon.png", "siege.png", 340], + ["medieval-kingdom-3d-icons/Banner.png", "hero-banner.png", 300], +]; + +await mkdir(out, { recursive: true }); + +let taken = 0; + +for (const [from, to] of [...TREES, ...ICONS]) { + try { + await copyFile(join(source, from), join(out, to)); + taken += 1; + console.log(`${to} <- ${from}`); + } catch { + console.warn(`missing, skipped: ${from}`); + } +} + +for (const [from, to] of FLAGS) { + const input = join(source, from); + try { + await stat(input); + } catch { + console.warn(`missing, skipped: ${from}`); + continue; + } + try { + await run("sips", ["--resampleWidth", String(BANNER_WIDTH), input, "--out", join(out, to)]); + taken += 1; + console.log(`${to} <- ${from} (resampled to ${BANNER_WIDTH}px)`); + } catch (error) { + console.warn(`sips failed for ${from}: ${error instanceof Error ? error.message : error}`); + } +} + +for (const [from, to, width] of RENDERS) { + const input = join(source, from); + try { + await stat(input); + } catch { + console.warn(`missing, skipped: ${from}`); + continue; + } + try { + await run("sips", ["--resampleWidth", String(width), input, "--out", join(out, to)]); + taken += 1; + console.log(`${to} <- ${from} (resampled to ${width}px)`); + } catch (error) { + console.warn(`sips failed for ${from}: ${error instanceof Error ? error.message : error}`); + } +} + +console.log(`\n${taken} files written to ${out}`); diff --git a/app/server/app.ts b/app/server/app.ts index 61f15d0..b4510ae 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -49,6 +49,9 @@ import { recordAudit, assignSession, auditCsv, SEALED_KINDS } from "./routes/aud import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social"; import { deleteAccount } from "./routes/account"; import { submitFeedback } from "./routes/feedback"; +import { emptyProfile, profileForApi, readProfile } from "./routes/game"; +import { deriveStats } from "./lib/game-stats"; +import { readRun, reachable, runForApi, runFrom } from "./routes/gathering"; import { accountStats, dayStart, isStatsRange, rangeStart } from "./routes/stats"; import { timingSafeEqual } from "node:crypto"; import { callerAddress, rateLimiter } from "./lib/rate-limit"; @@ -692,6 +695,143 @@ export function createApp(options: AppOptions) { * `missing` names the members with a vault and no copy yet, so any * teammate who holds the key can seal one for them. */ + /* + * The saved game: who the player chose to be, what they are wearing, + * what they have bought and what they have spent. + * + * Scoped to the caller's own account rather than to their team, because + * a keep is one person's progress. Everything else about it -- the + * level, how fortified it is, the purse -- is worked out again from the + * work that earned it, so none of that is stored and none of it can + * disagree with itself. + */ + if (route === "GET /api/game") { + const caller = await requireUser(request); + if (!caller) return send(response, 401, { error: "sign in first" }); + const profile = (await store.gameProfile(caller.uid)) ?? emptyProfile(caller.uid, Date.now()); + return send(response, 200, { game: profileForApi(profile) }); + } + + /* + * What a level is worth, counted from the caller's own sessions. + * + * Derived here rather than stored, and derived rather than sent up by + * the browser, so the experience bar is a read-out of work that happened + * and not of a tab left open. Nothing in the reply came from inside a + * session: these are counts of rows and the names people gave their own + * sessions, which is all the service can see of an encrypted session and + * all it should ever want to. + */ + if (route === "GET /api/game/stats") { + const caller = await requireUser(request); + if (!caller) return send(response, 401, { error: "sign in first" }); + const sessions = await store.listSessions(caller.uid); + return send(response, 200, { stats: deriveStats(sessions) }); + } + + /* + * What the gathering has cost, itemised. + * + * The vial shows a total, and a total on its own is a figure somebody has + * to take on trust. This is what it is made of: which machine ran, when, + * what it spent and what it found. Every number, and not one name. + */ + if (route === "GET /api/game/runs") { + const caller = await requireUser(request); + if (!caller) return send(response, 401, { error: "sign in first" }); + const runs = await store.listCollectionRuns(caller.uid, 20); + return send(response, 200, { runs: runs.map(runForApi) }); + } + + /* + * Asks the machines to gather, now. + * + * Refused unless the account has said yes, which is checked here rather + * than trusted from the caller: consent is the whole basis on which any + * of this is allowed to run, and a button is not where it should be + * enforced. + */ + if (route === "POST /api/game/gather") { + const caller = await requireUser(request); + if (!caller) return send(response, 401, { error: "sign in first" }); + + const profile = await store.gameProfile(caller.uid); + if (!profile?.gathering) { + return send(response, 403, { error: "the gathering has not been agreed to" }); + } + + const now = Date.now(); + const machines = reachable(await store.listDevices(caller.uid), now, AGENT_ONLINE_MS); + if (machines.length === 0) { + return send(response, 409, { + error: + "no machine is listening. Sign in on one with 'shell login' and " + + "leave 'shell agent' running.", + }); + } + + for (const machine of machines) { + await store.putCommand({ + id: mintSecret("cmd"), + uid: caller.uid, + deviceId: machine.id, + kind: "probe", + createdAt: now, + }); + } + return send(response, 202, { asked: machines.map((machine) => machine.label) }); + } + + /* + * A machine reporting what a run cost and found. + * + * Authenticated as the machine, not as the browser: this is the one + * number in the game the browser may not set, because it stands for real + * money. Narrowed hard on the way in -- an agent is a program on + * somebody's laptop, and these figures are summed and never recomputed, + * so one absurd report would make the vial meaningless for good. + */ + if (route === "POST /api/agent/stats") { + const token = await requireCli(request); + if (!token) return send(response, 401, { error: "not signed in" }); + + const profile = await store.gameProfile(token.uid); + if (!profile?.gathering) { + return send(response, 403, { error: "the gathering has not been agreed to" }); + } + + const body = (await readBody(request)) as Record; + /* + * The agent names the run, so reporting twice after a lost reply costs + * nothing. Without an id from the machine there is no way to tell a + * retry from a second run, and the safe reading of that ambiguity is + * the one that charges somebody twice. + */ + const id = typeof body.id === "string" && /^run_[A-Za-z0-9_-]{1,64}$/.test(body.id) + ? body.id + : mintSecret("run"); + const device = (await store.listDevices(token.uid)).find((entry) => entry.id === token.id); + const run = runFrom( + id, + token.uid, + { id: token.id, label: device?.label ?? "a machine" }, + readRun(body), + Date.now(), + ); + await store.recordCollectionRun(run); + return send(response, 202, { run: runForApi(run) }); + } + + if (route === "PUT /api/game") { + const caller = await requireUser(request); + if (!caller) return send(response, 401, { error: "sign in first" }); + const body = (await readBody(request)) as Record; + const previous = await store.gameProfile(caller.uid); + const profile = readProfile(caller.uid, body, previous, Date.now()); + await store.putGameProfile(profile); + return send(response, 200, { game: profileForApi(profile) }); + } + if (route === "GET /api/team-key") { const membership = await requireMember(request); if (!membership) return send(response, 401, { error: "sign in first" }); diff --git a/app/server/lib/game-stats.test.ts b/app/server/lib/game-stats.test.ts new file mode 100644 index 0000000..997dbf5 --- /dev/null +++ b/app/server/lib/game-stats.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { deriveStats, NO_STATS } from "./game-stats"; +import type { SessionRecord } from "./types"; + +/** + * The counted work. + * + * Every assertion here is really the same one: the game's ladder is a read-out + * of sessions that happened, and nothing else can move it. That is the claim + * `state/progress.ts` makes at the top of the file, and it was untrue for a + * while -- the experience bar was fed the simulation's tally, so a tab left + * open overnight levelled you up. + */ + +const DAY = 24 * 60 * 60 * 1000; +const MONDAY = Date.parse("2026-09-14T09:00:00Z"); + +function session(over: Partial = {}): SessionRecord { + return { + id: `s-${Math.random().toString(36).slice(2)}`, + uid: "u1", + shareUrl: "https://example.invalid/s", + command: "npm run dev", + readOnly: false, + encrypted: true, + persistent: false, + host: "laptop", + startedAt: MONDAY, + closedAt: MONDAY + 60_000, + exitCode: 0, + ...over, + }; +} + +describe("counting nothing", () => { + it("counts nothing for an account with no sessions", () => { + expect(deriveStats([])).toEqual(NO_STATS); + }); +}); + +describe("what counts as finished", () => { + it("counts a session that closed cleanly", () => { + expect(deriveStats([session()]).sessions).toBe(1); + }); + + it("does not count one that is still running", () => { + /* An open session is the open loop the field is drawing, not an earning. */ + const running = session({ closedAt: undefined, exitCode: undefined }); + expect(deriveStats([running]).sessions).toBe(0); + expect(deriveStats([running]).started).toBe(1); + }); + + it("does not count one that ended badly", () => { + expect(deriveStats([session({ exitCode: 1 })]).sessions).toBe(0); + }); + + it("counts an older record that closed without saying how", () => { + /* + * Records from before exit codes were stored have no code at all. Refusing + * to count them would quietly rewrite somebody's history, and the service + * has nothing better to go on than the fact that the session ended. + */ + expect(deriveStats([session({ exitCode: undefined })]).sessions).toBe(1); + }); +}); + +describe("days", () => { + it("counts a day once however many sessions ran on it", () => { + const stats = deriveStats([session(), session(), session()]); + expect(stats.days).toBe(1); + expect(stats.sessions).toBe(3); + }); + + it("counts separate days separately", () => { + const stats = deriveStats([ + session({ startedAt: MONDAY }), + session({ startedAt: MONDAY + DAY }), + session({ startedAt: MONDAY + 2 * DAY }), + ]); + expect(stats.days).toBe(3); + }); + + it("counts the day a session started even when it never finished", () => { + /* Turning up is turning up, whatever became of the session. */ + const stats = deriveStats([session({ closedAt: undefined, exitCode: undefined })]); + expect(stats.days).toBe(1); + expect(stats.sessions).toBe(0); + }); +}); + +describe("machines", () => { + it("counts each machine once", () => { + const stats = deriveStats([ + session({ host: "laptop" }), + session({ host: "laptop" }), + session({ host: "workshop" }), + ]); + expect(stats.machines).toBe(2); + }); +}); + +describe("what the session was", () => { + it("reads fixing and making from the name", () => { + const stats = deriveStats([ + session({ name: "fix: audit seal" }), + session({ name: "feat: session board" }), + session({ name: "npm run dev" }), + ]); + expect(stats.mended).toBe(1); + expect(stats.made).toBe(1); + expect(stats.sessions).toBe(3); + }); + + it("falls back to the command when there is no name", () => { + /* The same rule the session list uses to decide what to print. */ + const stats = deriveStats([session({ name: undefined, command: "git commit -m fix" })]); + expect(stats.mended).toBe(1); + }); + + it("prefers the name over the command, as the list does", () => { + const stats = deriveStats([session({ name: "feat: the board", command: "fix things" })]); + expect(stats.made).toBe(1); + expect(stats.mended).toBe(0); + }); + + it("does not classify a session that did not finish", () => { + const stats = deriveStats([ + session({ name: "fix: audit seal", closedAt: undefined, exitCode: undefined }), + ]); + expect(stats.mended).toBe(0); + }); + + it("puts a session in at most one column", () => { + const stats = deriveStats([session({ name: "fix the new importer" })]); + expect(stats.mended + stats.made).toBe(1); + }); +}); + +describe("what it does not do", () => { + it("never looks inside a session", () => { + /* + * Sessions are end-to-end encrypted and the service could not read one if + * it wanted to. What is counted here is rows and the names people gave + * their own sessions, which is the whole of tier one. The guard is that + * `SessionRecord` carries no output at all -- if that ever changes, this + * test is where somebody should have to think about it. + */ + const record = session(); + expect(Object.keys(record)).not.toContain("output"); + expect(Object.keys(record)).not.toContain("text"); + }); + + it("survives a record with a broken timestamp", () => { + const stats = deriveStats([session({ startedAt: Number.NaN })]); + expect(stats.days).toBe(0); + expect(stats.started).toBe(1); + }); +}); diff --git a/app/server/lib/game-stats.ts b/app/server/lib/game-stats.ts new file mode 100644 index 0000000..a2248f2 --- /dev/null +++ b/app/server/lib/game-stats.ts @@ -0,0 +1,98 @@ +import { workFor } from "../../src/game/world/work"; +import type { SessionRecord } from "./types"; + +/** + * What a level is worth, counted from sessions the service already stores. + * + * This is tier one of the stats the game runs on, and it is the whole reason + * the game is honest: every number below is a fact about work that happened, + * counted here rather than reported by the browser. Before this existed the + * experience bar was fed by the *simulation* — faults put down by figures on + * the map — which meant a tab left open overnight levelled you up. That is the + * opposite of what the game is for, and the comment at the top of + * state/progress.ts had been claiming otherwise for some time. + * + * Nothing here reads a session's contents. Sessions are end-to-end encrypted + * and the service could not read them if it wanted to; these are counts of + * rows, and the names people gave their own sessions. Anything richer — pull + * requests opened, lines changed, tokens spent — has to be gathered on the + * machine where the plaintext already is, which is tier two and is announced + * to the operator before it happens. + * + * It lives in `server/lib` and reaches into `src/game/world/work.ts` for the + * one thing both sides must agree on. That module has no imports of its own + * for exactly this reason: the field draws a wright walking to the garrison + * its work belongs to, and this counts the same sessions, and two copies of + * those patterns would drift until the map and the ladder disagreed. + */ +export interface GameStats { + /** Sessions that ran and ended cleanly. */ + sessions: number; + /** Distinct days on which anything at all was started. */ + days: number; + /** Distinct machines that have answered the muster. */ + machines: number; + /** Sessions that read as fixing something, and finished. */ + mended: number; + /** Sessions that read as making something, and finished. */ + made: number; + /** Every session on record, finished or not, for the roster line. */ + started: number; +} + +export const NO_STATS: GameStats = { + sessions: 0, + days: 0, + machines: 0, + mended: 0, + made: 0, + started: 0, +}; + +/** + * A session counts as finished when it closed without an error. + * + * A session still running has not earned anything yet -- that is the open loop + * the field is drawing -- and one that ended badly is not work that reached + * completion. An older record with no exit code at all is treated as finished + * if it closed, because that is all the service knows about it and refusing to + * count it would quietly rewrite somebody's history. + */ +function finished(session: SessionRecord): boolean { + if (session.closedAt === undefined) return false; + return session.exitCode === undefined || session.exitCode === 0; +} + +/** The day a moment falls on, in UTC, as a key to count distinct ones. */ +function dayOf(at: number): string { + return new Date(at).toISOString().slice(0, 10); +} + +export function deriveStats(sessions: SessionRecord[]): GameStats { + const days = new Set(); + const machines = new Set(); + const stats: GameStats = { ...NO_STATS }; + + for (const session of sessions) { + stats.started += 1; + if (Number.isFinite(session.startedAt)) days.add(dayOf(session.startedAt)); + if (session.host) machines.add(session.host); + + if (!finished(session)) continue; + stats.sessions += 1; + + /* + * The name if there is one, and the command if there is not -- which is + * the same rule the session list uses to decide what to print, so the + * garrison a session is counted towards is the garrison you can see it + * walking to. + */ + const work = workFor(session.name || session.command || ""); + if (work === "bug") stats.mended += 1; + if (work === "feature") stats.made += 1; + } + + stats.days = days.size; + stats.machines = machines.size; + return stats; +} diff --git a/app/server/lib/migrations/014_game.sql b/app/server/lib/migrations/014_game.sql new file mode 100644 index 0000000..5696ede --- /dev/null +++ b/app/server/lib/migrations/014_game.sql @@ -0,0 +1,31 @@ +-- The saved game, one row per account. +-- +-- Small on purpose. Everything the game can work out again is worked out +-- again: the level from the experience, how fortified the keep is from the +-- level, the purse from the level less what has been spent. What is stored is +-- only what cannot be derived -- who the player chose to be, what they are +-- wearing, what they have bought, what they have spent, and whether they have +-- agreed to the statistics being gathered. +-- +-- A row holding both the experience and the level would hold two facts that +-- can disagree, and the day they disagreed somebody would have to decide which +-- one was true. There is no such day here. +-- +-- Nothing in this table is private beyond the account it belongs to, and +-- nothing in it comes from a terminal: the service cannot read session output +-- and this does not change that. +CREATE TABLE IF NOT EXISTS game_profiles ( + uid TEXT PRIMARY KEY, + character_class TEXT NOT NULL DEFAULT '', + skin_id TEXT NOT NULL DEFAULT '', + -- Skins bought, as a JSON array of ids. A handful of short strings; a table + -- of its own would be three joins to answer "what does this person own". + owned TEXT NOT NULL DEFAULT '[]', + spent BIGINT NOT NULL DEFAULT 0, + gathering BOOLEAN NOT NULL DEFAULT FALSE, + -- Counted from what the agent reported, so the elixir vial has something + -- true to show. Zero until anybody has agreed to the gathering. + tokens BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/app/server/lib/migrations/015_game_gathering.sql b/app/server/lib/migrations/015_game_gathering.sql new file mode 100644 index 0000000..24c3372 --- /dev/null +++ b/app/server/lib/migrations/015_game_gathering.sql @@ -0,0 +1,37 @@ +-- What the gathering has cost, one row per run. +-- +-- The elixir vial in the game shows a total, and a total on its own is a number +-- somebody has to take on trust. This is what it is made of: which machine ran, +-- when, how much it spent, and what it found. Clicking the vial shows these. +-- +-- It exists because the gathering is the one part of the game that spends real +-- money and reads real work, and a person who has agreed to that is owed an +-- itemised account of it rather than a running total. Turning the gathering off +-- stops new rows; it does not delete these, because the record of what was +-- already spent on somebody's behalf is theirs to look at. +-- +-- Nothing here came from inside a session. The agent that writes these rows +-- runs on the operator's own machine, reads git and the harness's own local +-- files, and reports counts. Sessions are end-to-end encrypted and this service +-- could not read one if it wanted to. +CREATE TABLE IF NOT EXISTS game_collection_runs ( + id TEXT PRIMARY KEY, + uid TEXT NOT NULL, + -- The machine that ran it, so "which of my laptops spent that" has an answer. + device_id TEXT NOT NULL DEFAULT '', + device_name TEXT NOT NULL DEFAULT '', + ran_at BIGINT NOT NULL, + -- What the run cost, which is the number the vial is showing. + tokens BIGINT NOT NULL DEFAULT 0, + -- What it found. Counts only; never a branch name, a diff or a message. + pull_requests BIGINT NOT NULL DEFAULT 0, + commits BIGINT NOT NULL DEFAULT 0, + insertions BIGINT NOT NULL DEFAULT 0, + deletions BIGINT NOT NULL DEFAULT 0, + -- Empty when the run succeeded. A run that failed is still a run that + -- happened, and hiding it would make the vial quietly wrong. + error TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS game_collection_runs_uid_ran_at + ON game_collection_runs (uid, ran_at DESC); diff --git a/app/server/lib/migrations/016_game_livery.sql b/app/server/lib/migrations/016_game_livery.sql new file mode 100644 index 0000000..c3c5f35 --- /dev/null +++ b/app/server/lib/migrations/016_game_livery.sql @@ -0,0 +1,12 @@ +-- What a player's soldiers wear, as distinct from what the player wears. +-- +-- The shop sells two things now: a hero skin, which dresses your own figure, +-- and a retinue livery, which washes over the soldiers that stand for your +-- sessions. They are separate columns because they are separate choices, and +-- packing both into the existing `skin_id` would be storing two facts in one +-- field and then parsing them apart for ever after. +-- +-- Additive, like every migration here: it never edits a shipped one. An account +-- from before this has no livery, which is the same as not having bought one. +ALTER TABLE game_profiles + ADD COLUMN IF NOT EXISTS livery_id TEXT NOT NULL DEFAULT ''; diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index 60e867e..2a71b31 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -147,6 +147,22 @@ function feedback(overrides: Partial = {}): Feedback { } const TABLES = [ + /* + * The game's two tables truncate with the rest. + * + * They were missed when the game was added, and the way that showed was a + * failure nowhere near the cause: `recordCollectionRun` makes a profile row + * when there is not one, so a run recorded in one test left a `game_profiles` + * row behind, and the next test's `putGameProfile` upserted onto it. The + * upsert deliberately keeps the original `created_at` -- which is correct, and + * meant the leaked row's timestamp came back instead of the one the test had + * just written. + * + * Only against Postgres, because MemoryStore is built fresh per test. So it + * passed locally for anybody without a database and failed in CI. + */ + "game_collection_runs", + "game_profiles", "feedback", "account_activity", "app_events", @@ -572,6 +588,162 @@ for (const implementation of implementations) { }); }); + describe("the saved game", () => { + const profile = (overrides: Record = {}) => ({ + uid: "uid-1", + characterClass: "codex", + skinId: "gilt", + liveryId: "moss", + owned: ["ash", "gilt"], + spent: 300, + gathering: true, + tokens: 12_345, + createdAt: 1000, + updatedAt: 1000, + ...overrides, + }); + + it("has nothing for somebody who has never opened it", async () => { + expect(await store.gameProfile("uid-nobody")).toBeNull(); + }); + + /* ---- what the gathering cost ---- */ + + const run = (overrides: Record = {}) => ({ + id: "run-1", + uid: "uid-1", + deviceId: "dev-1", + deviceName: "laptop", + ranAt: 5000, + tokens: 1200, + pullRequests: 3, + commits: 9, + insertions: 410, + deletions: 88, + error: "", + ...overrides, + }); + + it("has no runs for somebody who has never gathered", async () => { + expect(await store.listCollectionRuns("uid-nobody")).toEqual([]); + }); + + it("keeps a run and hands it back whole", async () => { + await store.recordCollectionRun(run()); + expect(await store.listCollectionRuns("uid-1")).toEqual([run()]); + }); + + it("adds what a run cost to the account's total", async () => { + /* + * The figure in the vial is the sum of these rows. A writer that could + * record a run without adding its cost would let the vial disagree with + * the breakdown behind it, which is the one thing the breakdown exists + * to prevent. + */ + await store.putGameProfile(profile({ tokens: 0 })); + await store.recordCollectionRun(run({ tokens: 500 })); + await store.recordCollectionRun(run({ id: "run-2", tokens: 700, ranAt: 6000 })); + expect((await store.gameProfile("uid-1"))?.tokens).toBe(1200); + }); + + it("records a run for an account that has not opened the game", async () => { + /* + * The agent reporting is proof the account is real. Losing somebody's + * first run because they had not opened the keep yet would be a hole in + * the account that nobody could explain afterwards. + */ + await store.recordCollectionRun(run({ uid: "uid-new", tokens: 90 })); + const made = await store.gameProfile("uid-new"); + expect(made?.tokens).toBe(90); + expect(await store.listCollectionRuns("uid-new")).toHaveLength(1); + }); + + it("hands back the newest run first", async () => { + await store.recordCollectionRun(run({ id: "old", ranAt: 1000 })); + await store.recordCollectionRun(run({ id: "new", ranAt: 9000 })); + await store.recordCollectionRun(run({ id: "middle", ranAt: 5000 })); + expect((await store.listCollectionRuns("uid-1")).map((entry) => entry.id)) + .toEqual(["new", "middle", "old"]); + }); + + it("keeps one account's runs out of another's", async () => { + await store.recordCollectionRun(run()); + await store.recordCollectionRun(run({ id: "run-2", uid: "uid-2" })); + expect(await store.listCollectionRuns("uid-1")).toHaveLength(1); + expect(await store.listCollectionRuns("uid-2")).toHaveLength(1); + }); + + it("keeps a run that failed", async () => { + /* A run that failed still happened, and hiding it makes the vial wrong. */ + await store.recordCollectionRun(run({ tokens: 40, error: "no git on PATH" })); + const [only] = await store.listCollectionRuns("uid-1"); + expect(only.error).toBe("no git on PATH"); + expect((await store.gameProfile("uid-1"))?.tokens).toBe(40); + }); + + it("takes the same run twice without charging for it twice", async () => { + /* + * An agent that reports, loses the reply and retries must not double + * somebody's bill, and the bill is the one number in this game that + * stands for real money. The run's own id is what makes the write + * idempotent -- and the total has to be idempotent with it, which is + * the half that is easy to miss: skipping the duplicate row while + * adding its tokens anyway looks correct and is not. + */ + await store.recordCollectionRun(run({ tokens: 300 })); + await store.recordCollectionRun(run({ tokens: 300 })); + expect(await store.listCollectionRuns("uid-1")).toHaveLength(1); + expect((await store.gameProfile("uid-1"))?.tokens).toBe(300); + }); + + it("limits how many runs it hands back", async () => { + for (let index = 0; index < 8; index += 1) { + await store.recordCollectionRun(run({ id: `run-${index}`, ranAt: 1000 + index })); + } + expect(await store.listCollectionRuns("uid-1", 3)).toHaveLength(3); + }); + + it("keeps a profile and hands it back whole", async () => { + await store.putGameProfile(profile()); + expect(await store.gameProfile("uid-1")).toEqual(profile()); + }); + + it("replaces rather than adding a second one", async () => { + await store.putGameProfile(profile()); + await store.putGameProfile(profile({ skinId: "moss", spent: 400, updatedAt: 2000 })); + const stored = await store.gameProfile("uid-1"); + expect(stored?.skinId).toBe("moss"); + expect(stored?.spent).toBe(400); + /* Created at is the first save, not the latest. */ + expect(stored?.createdAt).toBe(1000); + }); + + it("keeps one account's keep out of another's", async () => { + await store.putGameProfile(profile()); + await store.putGameProfile(profile({ uid: "uid-2", skinId: "wine" })); + expect((await store.gameProfile("uid-1"))?.skinId).toBe("gilt"); + expect((await store.gameProfile("uid-2"))?.skinId).toBe("wine"); + }); + + it("does not hand back a list that can be changed underneath it", async () => { + /* + * The in-memory store is the one that can get this wrong, by handing + * out the array it is holding. A caller that then pushed to it would + * be editing the database. + */ + await store.putGameProfile(profile()); + const first = await store.gameProfile("uid-1"); + first?.owned.push("smuggled"); + expect((await store.gameProfile("uid-1"))?.owned).toEqual(["ash", "gilt"]); + }); + + it("goes when the account goes", async () => { + await store.putGameProfile(profile({ uid: "uid-7" })); + await store.deleteAccount("uid-7", { dissolve: false }, 5000); + expect(await store.gameProfile("uid-7")).toBeNull(); + }); + }); + describe("team audit key", () => { type TeamKeyRecord = Parameters[0]; type ShareRecord = Parameters[0][number]; diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index 060ef63..dbe7b76 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -24,6 +24,8 @@ import type { Comment, Device, Feedback, + GameCollectionRun, + GameProfile, Notification, SessionKeyShare, SessionRecord, @@ -77,6 +79,8 @@ interface Shape { accountActivity: { uid: string; day: number }[]; appEvents: { event: AppEvent; day: number; count: number }[]; teamKeys: TeamKey[]; + gameProfiles: GameProfile[]; + collectionRuns: GameCollectionRun[]; teamKeyShares: TeamKeyShare[]; } @@ -84,7 +88,7 @@ const EMPTY: Shape = { codes: [], tokens: [], sessions: [], commands: [], organizations: [], memberships: [], invites: [], audit: [], comments: [], notifications: [], feedback: [], accountKeys: [], deletedAccounts: [], - accountActivity: [], appEvents: [], teamKeys: [], teamKeyShares: [], + accountActivity: [], appEvents: [], teamKeys: [], teamKeyShares: [], gameProfiles: [], collectionRuns: [], }; /** @@ -153,6 +157,8 @@ export class MemoryStore implements Store { accountActivity: parsed.accountActivity ?? [], appEvents: parsed.appEvents ?? [], teamKeys: parsed.teamKeys ?? [], + gameProfiles: parsed.gameProfiles ?? [], + collectionRuns: parsed.collectionRuns ?? [], teamKeyShares: parsed.teamKeyShares ?? [], }; } catch { @@ -346,6 +352,8 @@ export class MemoryStore implements Store { if (invite.acceptedBy === uid) delete invite.email; } data.accountKeys = data.accountKeys.filter((entry) => entry.uid !== uid); + /* The keep goes with the account. It is nobody else's progress. */ + data.gameProfiles = data.gameProfiles.filter((entry) => entry.uid !== uid); data.accountActivity = data.accountActivity.filter((entry) => entry.uid !== uid); data.comments = data.comments.filter((entry) => entry.authorUid !== uid); data.notifications = data.notifications.filter( @@ -714,6 +722,66 @@ export class MemoryStore implements Store { Team audit key --------------------------------------------------------------- */ + async gameProfile(uid: string): Promise { + const found = this.data.gameProfiles.find((entry) => entry.uid === uid); + return found ? { ...found, owned: [...found.owned] } : null; + } + + async putGameProfile(profile: GameProfile): Promise { + const stored = { ...profile, owned: [...profile.owned] }; + const at = this.data.gameProfiles.findIndex((entry) => entry.uid === profile.uid); + if (at >= 0) this.data.gameProfiles[at] = stored; + else this.data.gameProfiles.push(stored); + await this.flush(); + } + + async recordCollectionRun(run: GameCollectionRun): Promise { + /* + * The run's own id makes this idempotent. An agent that reports, loses the + * reply and retries must not double somebody's bill -- and the bill is the + * one number in this game that stands for real money. + */ + if (this.data.collectionRuns.some((entry) => entry.id === run.id)) return; + this.data.collectionRuns.push({ ...run }); + /* + * The total and the rows move together, so the vial can never disagree + * with the breakdown behind it. A profile that does not exist yet is made + * here rather than refusing: the agent reporting is proof the account is + * real, and losing the first run because nobody had opened the game would + * be a hole in the account nobody could explain. + */ + const at = this.data.gameProfiles.findIndex((entry) => entry.uid === run.uid); + if (at >= 0) { + this.data.gameProfiles[at] = { + ...this.data.gameProfiles[at], + tokens: this.data.gameProfiles[at].tokens + run.tokens, + updatedAt: run.ranAt, + }; + } else { + this.data.gameProfiles.push({ + uid: run.uid, + characterClass: "", + skinId: "", + liveryId: "", + owned: [], + spent: 0, + gathering: true, + tokens: run.tokens, + createdAt: run.ranAt, + updatedAt: run.ranAt, + }); + } + await this.flush(); + } + + async listCollectionRuns(uid: string, limit = 20): Promise { + return this.data.collectionRuns + .filter((run) => run.uid === uid) + .sort((a, b) => b.ranAt - a.ranAt) + .slice(0, limit) + .map((run) => ({ ...run })); + } + async teamKey(orgId: string): Promise { const found = this.data.teamKeys.find((entry) => entry.orgId === orgId); return found ? { ...found } : null; diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index fc7a8f7..98810de 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -26,6 +26,8 @@ import type { Comment, Device, Feedback, + GameCollectionRun, + GameProfile, Notification, SessionKeyShare, SessionRecord, @@ -348,6 +350,26 @@ function toFeedback(row: Row): Feedback { * atomic -- claiming queued work, marking a code consumed -- are single * statements, so two instances of the service can serve the same database. */ +/** + * The skins an account owns, from the JSON text they are stored as. + * + * A value that will not parse is read as owning nothing rather than throwing. + * A save that cannot be read should cost somebody their hats, not their + * ability to open the game at all. + */ +function readOwned(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string"); + if (typeof value !== "string") return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : []; + } catch { + return []; + } +} + export class PostgresStore implements Store { private constructor(private readonly pool: pg.Pool) {} @@ -1015,6 +1037,8 @@ export class PostgresStore implements Store { "DELETE FROM session_key_shares WHERE uid = $1", "DELETE FROM account_keys WHERE uid = $1", "DELETE FROM account_activity WHERE uid = $1", + /* The keep goes with the account. It is nobody else's progress. */ + "DELETE FROM game_profiles WHERE uid = $1", "DELETE FROM comments WHERE author_uid = $1", "DELETE FROM notifications WHERE uid = $1 OR actor_uid = $1", /* The address an invite was sent to is theirs once they accepted it. */ @@ -1643,6 +1667,131 @@ export class PostgresStore implements Store { })); } + /* ---- The saved game ---- */ + + async gameProfile(uid: string): Promise { + const rows = await this.rows("SELECT * FROM game_profiles WHERE uid = $1", [uid]); + const row = rows[0]; + if (!row) return null; + return { + uid: row.uid as string, + characterClass: row.character_class as string, + skinId: row.skin_id as string, + liveryId: (row.livery_id as string) ?? "", + owned: readOwned(row.owned), + spent: Number(row.spent), + gathering: row.gathering === true, + tokens: Number(row.tokens), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; + } + + async putGameProfile(profile: GameProfile): Promise { + await this.pool.query( + `INSERT INTO game_profiles + (uid, character_class, skin_id, livery_id, owned, spent, gathering, tokens, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (uid) DO UPDATE SET + character_class = EXCLUDED.character_class, + skin_id = EXCLUDED.skin_id, + livery_id = EXCLUDED.livery_id, + owned = EXCLUDED.owned, + spent = EXCLUDED.spent, + gathering = EXCLUDED.gathering, + tokens = EXCLUDED.tokens, + updated_at = EXCLUDED.updated_at`, + [ + profile.uid, + profile.characterClass, + profile.skinId, + profile.liveryId, + JSON.stringify(profile.owned), + profile.spent, + profile.gathering, + profile.tokens, + profile.createdAt, + profile.updatedAt, + ], + ); + } + + /** + * One run, and its cost, in a single transaction. + * + * The total on the profile is the sum of these rows, so the two have to move + * together or the vial can disagree with the breakdown behind it. The insert + * makes the profile when there is not one: the agent reporting is proof the + * account is real, and losing somebody's first run because they had not + * opened the game yet would be a hole in the account nobody could explain. + */ + async recordCollectionRun(run: GameCollectionRun): Promise { + /* + * One statement, so the row and the total cannot come apart. + * + * The total on the profile is the sum of these rows, and the second insert + * draws its numbers from what the first one actually wrote -- so a run that + * was already recorded inserts nothing and therefore adds nothing. Written + * as two statements in a transaction, the conflict clause skipped the + * duplicate row and the token add ran anyway, which doubled the bill of any + * agent that reported, lost the reply and retried. The bill is the one + * number in this game that stands for real money. + * + * The profile is made when there is not one: the agent reporting is proof + * the account is real, and losing somebody's first run because they had not + * opened the keep yet would be a hole nobody could explain afterwards. + */ + await this.pool.query( + `WITH recorded AS ( + INSERT INTO game_collection_runs + (id, uid, device_id, device_name, ran_at, tokens, + pull_requests, commits, insertions, deletions, error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO NOTHING + RETURNING uid, tokens, ran_at + ) + INSERT INTO game_profiles (uid, gathering, tokens, created_at, updated_at) + SELECT uid, TRUE, tokens, ran_at, ran_at FROM recorded + ON CONFLICT (uid) DO UPDATE SET + tokens = game_profiles.tokens + EXCLUDED.tokens, + updated_at = EXCLUDED.updated_at`, + [ + run.id, + run.uid, + run.deviceId, + run.deviceName, + run.ranAt, + run.tokens, + run.pullRequests, + run.commits, + run.insertions, + run.deletions, + run.error, + ], + ); + } + + async listCollectionRuns(uid: string, limit = 20): Promise { + const rows = await this.rows( + `SELECT * FROM game_collection_runs + WHERE uid = $1 ORDER BY ran_at DESC LIMIT $2`, + [uid, limit], + ); + return rows.map((row) => ({ + id: row.id as string, + uid: row.uid as string, + deviceId: row.device_id as string, + deviceName: row.device_name as string, + ranAt: Number(row.ran_at), + tokens: Number(row.tokens), + pullRequests: Number(row.pull_requests), + commits: Number(row.commits), + insertions: Number(row.insertions), + deletions: Number(row.deletions), + error: row.error as string, + })); + } + /* ---- App events ---- */ async recordAppEvent(event: AppEvent, now = Date.now()): Promise { diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 2ff83c8..28564e3 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -5,12 +5,14 @@ import type { AppEventCount, AccountKey, AgentCommand, + GameCollectionRun, AuditEvent, AuthorizationCode, CliToken, Comment, Device, Feedback, + GameProfile, Notification, SessionKeyShare, SessionRecord, @@ -174,6 +176,28 @@ export interface Store { /** Whether this uid was deleted at or after `since`. */ recentlyDeleted(uid: string, since: number): Promise; + /* ---- The saved game ---- */ + /** + * One account's saved game, or nothing if they have never opened it. + * + * Scoped by uid rather than by organization: the keep is a person's own + * progress, and two people on the same team have their own. + */ + gameProfile(uid: string): Promise; + /** Writes the whole profile. Creates it on first save. */ + putGameProfile(profile: GameProfile): Promise; + /** + * Records one gathering run, and adds what it cost to the account's total. + * + * One call rather than two, because the total in the profile is the sum of + * these rows: a writer that could record a run without adding its cost, or + * add a cost without recording the run, is a writer that can make the vial + * disagree with the breakdown behind it. + */ + recordCollectionRun(run: GameCollectionRun): Promise; + /** The most recent runs, newest first, for the breakdown behind the vial. */ + listCollectionRuns(uid: string, limit?: number): Promise; + /* ---- Team audit key ---- */ teamKey(orgId: string): Promise; /** Creates the team's audit key, and only if it has none. False when one exists. */ diff --git a/app/server/lib/types.ts b/app/server/lib/types.ts index 248d990..5f1d0c8 100644 --- a/app/server/lib/types.ts +++ b/app/server/lib/types.ts @@ -267,7 +267,15 @@ export interface AgentCommand { id: string; uid: string; deviceId: string; - kind: "start" | "kill"; + /** + * "probe" asks the machine to gather statistics and report numbers back. + * + * It carries no arguments at all, which is the point: a command that could + * name a directory, a repository or a file would be a way to ask somebody's + * machine to look somewhere on behalf of a browser. The agent decides what it + * reads, on the machine, from its own configuration. + */ + kind: "start" | "kill" | "probe"; /** For "start": the command line to wrap. */ command?: string; /** For "start": what to call the session in the UI. */ @@ -292,3 +300,58 @@ export interface AgentCommand { * this interface so the production implementation (Firestore, D1) can drop in * without touching route code. */ + +/** + * One gathering run, and what it cost. + * + * The elixir vial shows a total, and a total on its own is a number somebody + * has to take on trust. This is what it is made of. A person who has agreed to + * their machine being read is owed an itemised account of it rather than a + * running figure. + * + * Every field is a count. There is no branch name here, no commit message, no + * diff and no session output -- the agent that fills these in runs on the + * operator's own machine and reports numbers, and this service could not read + * a session if it wanted to. + */ +export interface GameCollectionRun { + id: string; + uid: string; + deviceId: string; + /** What the machine calls itself, so the breakdown reads as places. */ + deviceName: string; + ranAt: number; + /** What the run spent. The figure in the vial is the sum of these. */ + tokens: number; + pullRequests: number; + commits: number; + insertions: number; + deletions: number; + /** Empty when it worked. A run that failed still happened. */ + error: string; +} + +/** + * One account's saved game. + * + * See `server/lib/migrations/014_game.sql` for why this is so short: anything + * the game can work out again is worked out again, so what is kept is only + * what cannot be. Deleting an account deletes this with it. + */ +export interface GameProfile { + uid: string; + /** The class the player chose. Empty until they have chosen. */ + characterClass: string; + skinId: string; + /** What this player's soldiers wear. See migration 016. */ + liveryId: string; + owned: string[]; + /** Marks spent. The purse is what the level earned, less this. */ + spent: number; + /** Whether they have agreed to their statistics being gathered. */ + gathering: boolean; + /** Tokens that gathering has cost so far. What the elixir vial shows. */ + tokens: number; + createdAt: number; + updatedAt: number; +} diff --git a/app/server/routes/game.test.ts b/app/server/routes/game.test.ts new file mode 100644 index 0000000..6a3b215 --- /dev/null +++ b/app/server/routes/game.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { emptyProfile, profileForApi, readProfile } from "./game"; + +/** + * The saved game, on the way in. + * + * A body arriving from a browser can say anything at all. Most of what it says + * here is harmless -- the game sells hats -- but two things are not the + * browser's to say, and this file is mostly about those two: the token count, + * which is the one figure in the game that stands for real money, and the + * moment the profile was created. + */ + +const NOW = Date.parse("2026-09-16T10:00:00Z"); +const EARLIER = NOW - 86_400_000; + +const saved = (over: Partial> = {}) => ({ + ...emptyProfile("u1", EARLIER), + ...over, +}); + +describe("an empty profile", () => { + it("has chosen nothing and owns nothing", () => { + const profile = emptyProfile("u1", NOW); + expect(profile.characterClass).toBe(""); + expect(profile.owned).toEqual([]); + expect(profile.spent).toBe(0); + expect(profile.tokens).toBe(0); + expect(profile.gathering).toBe(false); + }); + + it("is not gathering until somebody says so", () => { + /* Collection is announced and opted into; off is the only safe default. */ + expect(emptyProfile("u1", NOW).gathering).toBe(false); + }); +}); + +describe("what a browser may not set", () => { + it("ignores a token count sent by the client", () => { + /* + * The line this file exists for. Tokens are written by the agent's report + * of what a collection run cost, and shown on the elixir vial. A client + * that could set this could tell somebody they had spent nothing. + */ + const profile = readProfile("u1", { tokens: 0 }, saved({ tokens: 1_200_000 }), NOW); + expect(profile.tokens).toBe(1_200_000); + }); + + it("ignores a token count on a profile that did not exist yet", () => { + expect(readProfile("u1", { tokens: 999 }, null, NOW).tokens).toBe(0); + }); + + it("keeps the uid it was called with, not one from the body", () => { + const profile = readProfile("u1", { uid: "u2" }, null, NOW); + expect(profile.uid).toBe("u1"); + }); + + it("keeps the original creation time", () => { + const profile = readProfile("u1", { created_at: NOW }, saved(), NOW); + expect(profile.createdAt).toBe(EARLIER); + expect(profile.updatedAt).toBe(NOW); + }); +}); + +describe("narrowing what it does accept", () => { + it("takes one of the five classes and refuses anything else", () => { + expect(readProfile("u1", { character_class: "codex" }, null, NOW).characterClass).toBe("codex"); + expect(readProfile("u1", { character_class: "dragon" }, null, NOW).characterClass).toBe(""); + }); + + it("keeps the class already chosen rather than clearing it", () => { + /* A save that omits the class is a save of something else, not a reset. */ + const profile = readProfile("u1", {}, saved({ characterClass: "hermes" }), NOW); + expect(profile.characterClass).toBe("hermes"); + }); + + it("drops anything in `owned` that is not a string", () => { + const profile = readProfile("u1", { owned: ["ash", 7, null, "gilt"] }, null, NOW); + expect(profile.owned).toEqual(["ash", "gilt"]); + }); + + it("bounds how much one account can be said to own", () => { + const many = Array.from({ length: 5000 }, (_, index) => `skin-${index}`); + expect(readProfile("u1", { owned: many }, null, NOW).owned.length).toBeLessThanOrEqual(200); + }); + + it("refuses a negative or nonsense spend", () => { + expect(readProfile("u1", { spent: -500 }, null, NOW).spent).toBe(0); + expect(readProfile("u1", { spent: "lots" }, null, NOW).spent).toBe(0); + expect(readProfile("u1", { spent: Number.NaN }, null, NOW).spent).toBe(0); + expect(readProfile("u1", { spent: Number.POSITIVE_INFINITY }, null, NOW).spent).toBe(0); + }); + + it("bounds the spend, so one bad number cannot make the purse meaningless", () => { + expect(readProfile("u1", { spent: 1e30 }, null, NOW).spent).toBeLessThanOrEqual(1_000_000_000); + }); + + it("treats anything but true as not gathering", () => { + /* Consent is a yes, not the absence of a no. */ + expect(readProfile("u1", { gathering: "yes" }, null, NOW).gathering).toBe(false); + expect(readProfile("u1", { gathering: 1 }, null, NOW).gathering).toBe(false); + expect(readProfile("u1", { gathering: true }, null, NOW).gathering).toBe(true); + }); + + it("cuts an over-long skin id rather than storing it", () => { + const profile = readProfile("u1", { skin_id: "x".repeat(500) }, null, NOW); + expect(profile.skinId.length).toBeLessThanOrEqual(32); + }); + + it("keeps what you wear apart from what your soldiers wear", () => { + /* + * Two choices, two fields. Packing both into one would be storing two facts + * in one place and parsing them apart for ever after. + */ + const profile = readProfile("u1", { skin_id: "gilt", livery_id: "moss" }, null, NOW); + expect(profile.skinId).toBe("gilt"); + expect(profile.liveryId).toBe("moss"); + }); + + it("survives a body that is empty", () => { + expect(() => readProfile("u1", {}, null, NOW)).not.toThrow(); + }); +}); + +describe("what the client reads back", () => { + it("is snake case, like the rest of this API", () => { + const wire = profileForApi(saved({ characterClass: "codex", tokens: 12 })); + expect(Object.keys(wire).sort()).toEqual([ + "character_class", + "gathering", + "livery_id", + "owned", + "skin_id", + "spent", + "tokens", + "updated_at", + ]); + expect(wire.character_class).toBe("codex"); + }); + + it("does not hand back the uid", () => { + /* The caller knows who they are; repeating it is a field to keep in step. */ + expect(profileForApi(saved())).not.toHaveProperty("uid"); + }); + + it("round-trips through a save without drifting", () => { + const first = readProfile("u1", { character_class: "codex", owned: ["ash"], spent: 30 }, null, NOW); + const again = readProfile("u1", profileForApi(first), first, NOW); + expect(again.characterClass).toBe(first.characterClass); + expect(again.owned).toEqual(first.owned); + expect(again.spent).toBe(first.spent); + }); +}); diff --git a/app/server/routes/game.ts b/app/server/routes/game.ts new file mode 100644 index 0000000..dadd560 --- /dev/null +++ b/app/server/routes/game.ts @@ -0,0 +1,99 @@ +import type { GameProfile } from "../lib/types"; + +/** + * The saved game. + * + * Two endpoints and no cleverness: read the profile, write the profile. The + * game is a skin over a session list, not a competitive one, and there is + * nothing here worth defending against its own owner — everything the profile + * holds is cosmetic or is a preference. + * + * What *is* defended against is nonsense. A body arriving from a browser can + * say anything at all, and a profile that stored it verbatim would be a + * profile that could be handed back as something the game cannot draw. So + * every field is narrowed here, on the way in, exactly as the client narrows + * what it reads out of storage. + */ + +/** The five classes, matching the harnesses in src/lib/session-kinds.ts. */ +const CLASSES = ["claude-code", "codex", "hermes", "openclaw", "terminal"]; + +/** A bound on how many skins one account can be said to own. */ +const MAX_OWNED = 200; +/** A bound on the spend, so a bad number cannot make the purse meaningless. */ +const MAX_SPENT = 1_000_000_000; + +export function emptyProfile(uid: string, now: number): GameProfile { + return { + uid, + characterClass: "", + skinId: "", + liveryId: "", + owned: [], + spent: 0, + gathering: false, + tokens: 0, + createdAt: now, + updatedAt: now, + }; +} + +function text(value: unknown, limit = 64): string { + return typeof value === "string" ? value.slice(0, limit) : ""; +} + +function count(value: unknown, limit: number): number { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.min(limit, Math.max(0, Math.floor(numeric))); +} + +/** + * A profile from whatever the browser sent. + * + * `previous` supplies the fields a save is not allowed to set for itself. The + * token count is the important one: it is what the elixir vial shows, it is + * the one figure in the game that corresponds to real money, and it is written + * by the agent's report rather than by the browser. A client that could set it + * could tell somebody they had spent nothing. + */ +export function readProfile( + uid: string, + body: Record, + previous: GameProfile | null, + now: number, +): GameProfile { + const before = previous ?? emptyProfile(uid, now); + const characterClass = text(body.character_class, 32); + const owned = Array.isArray(body.owned) + ? body.owned.filter((entry): entry is string => typeof entry === "string").slice(0, MAX_OWNED) + : before.owned; + + return { + uid, + characterClass: CLASSES.includes(characterClass) ? characterClass : before.characterClass, + skinId: text(body.skin_id, 32), + liveryId: text(body.livery_id, 32), + owned, + spent: count(body.spent, MAX_SPENT), + gathering: body.gathering === true, + /* Not the browser's to set. See above. */ + tokens: before.tokens, + createdAt: before.createdAt, + updatedAt: now, + }; +} + +/** The shape the client reads. Snake case, like the rest of this API. */ +export function profileForApi(profile: GameProfile) { + return { + character_class: profile.characterClass, + skin_id: profile.skinId, + livery_id: profile.liveryId, + owned: profile.owned, + spent: profile.spent, + gathering: profile.gathering, + tokens: profile.tokens, + updated_at: profile.updatedAt, + }; +} diff --git a/app/server/routes/gathering.test.ts b/app/server/routes/gathering.test.ts new file mode 100644 index 0000000..fc513be --- /dev/null +++ b/app/server/routes/gathering.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { reachable, readRun, runForApi, runFrom } from "./gathering"; +import type { Device } from "../lib/types"; + +/** + * What a machine is allowed to say, and how hard it is believed. + * + * This is the one part of the game that reads real work and spends real money, + * so it is the one part written defensively. An agent is a program on + * somebody's laptop; these figures are summed and never recomputed, so a single + * absurd report would make the vial meaningless for good. + */ + +const NOW = Date.parse("2026-09-16T12:00:00Z"); +const MINUTE = 60_000; + +const device = (over: Partial = {}): Device => ({ + id: "dev-1", + label: "laptop", + createdAt: NOW - 1000, + lastSeenAt: NOW, + agentSeenAt: NOW, + ...over, +}); + +describe("reading a report", () => { + it("takes the counts it was given", () => { + const run = readRun({ + tokens: 1200, + pull_requests: 3, + commits: 9, + insertions: 410, + deletions: 88, + }); + expect(run).toEqual({ + tokens: 1200, + pullRequests: 3, + commits: 9, + insertions: 410, + deletions: 88, + error: "", + }); + }); + + it("refuses a negative count", () => { + /* A negative token count would run somebody's total backwards. */ + expect(readRun({ tokens: -500 }).tokens).toBe(0); + expect(readRun({ commits: -1 }).commits).toBe(0); + }); + + it("refuses something that is not a number", () => { + expect(readRun({ tokens: "lots" }).tokens).toBe(0); + expect(readRun({ tokens: Number.NaN }).tokens).toBe(0); + expect(readRun({ tokens: Number.POSITIVE_INFINITY }).tokens).toBe(0); + expect(readRun({ tokens: null }).tokens).toBe(0); + }); + + it("caps a figure that would make the vial absurd", () => { + /* + * These are summed and never recomputed. One report of a quadrillion + * tokens is not a display bug that can be refreshed away; it is the + * account's total, for good. + */ + expect(readRun({ tokens: 1e18 }).tokens).toBeLessThanOrEqual(10_000_000_000); + }); + + it("does not clip a heavy but honest week", () => { + /* + * A real machine reported eighty-three million tokens for three days of + * ordinary work. A cap that clips an honest report is worse than no cap: + * the number it produces is wrong and looks reasonable. + */ + expect(readRun({ tokens: 83_000_000 }).tokens).toBe(83_000_000); + }); + + it("rounds rather than storing a fraction of a token", () => { + expect(readRun({ tokens: 12.7 }).tokens).toBe(12); + }); + + it("takes a failure as a sentence, and a short one", () => { + expect(readRun({ error: "no git on PATH" }).error).toBe("no git on PATH"); + expect(readRun({ error: "x".repeat(5000) }).error.length).toBeLessThanOrEqual(200); + expect(readRun({ error: { deeply: "nested" } }).error).toBe(""); + }); + + it("survives an empty body", () => { + expect(readRun({})).toEqual({ + tokens: 0, + pullRequests: 0, + commits: 0, + insertions: 0, + deletions: 0, + error: "", + }); + }); + + it("has nowhere to put anything but numbers", () => { + /* + * The guard this whole file exists for. There is no field for a branch + * name, a commit message, a path, a diff or a line of output, and a shape + * that cannot carry those cannot leak them because somebody later found it + * convenient. If this test has to change, that should be argued about. + */ + const run = readRun({ + tokens: 5, + branch: "fix/the-thing", + message: "fix: the thing", + path: "/Users/someone/work/secret", + diff: "- password = hunter2", + }); + expect(Object.keys(run).sort()).toEqual([ + "commits", + "deletions", + "error", + "insertions", + "pullRequests", + "tokens", + ]); + expect(JSON.stringify(run)).not.toContain("hunter2"); + expect(JSON.stringify(run)).not.toContain("fix/the-thing"); + }); +}); + +describe("recording it", () => { + it("names the machine it came from", () => { + const run = runFrom("run-1", "uid-1", { id: "dev-1", label: "workshop" }, readRun({ tokens: 7 }), NOW); + expect(run.deviceId).toBe("dev-1"); + expect(run.deviceName).toBe("workshop"); + expect(run.ranAt).toBe(NOW); + expect(run.uid).toBe("uid-1"); + }); + + it("hands the client snake case, like the rest of this API", () => { + const run = runFrom("run-1", "uid-1", { id: "dev-1", label: "laptop" }, readRun({ tokens: 7 }), NOW); + expect(Object.keys(runForApi(run)).sort()).toEqual([ + "commits", + "deletions", + "device", + "error", + "id", + "insertions", + "pull_requests", + "ran_at", + "tokens", + ]); + }); + + it("does not hand back the uid or the device id", () => { + /* + * The caller knows who they are, and a device id is an address a browser + * has no use for. Both are fields to keep in step for nothing. + */ + const wire = runForApi(runFrom("run-1", "uid-1", { id: "dev-1", label: "laptop" }, readRun({}), NOW)); + expect(wire).not.toHaveProperty("uid"); + expect(wire).not.toHaveProperty("device_id"); + }); +}); + +describe("which machines can be asked", () => { + it("takes one whose agent is polling", () => { + expect(reachable([device()], NOW, 5 * MINUTE)).toHaveLength(1); + }); + + it("leaves out one that has never run an agent", () => { + expect(reachable([device({ agentSeenAt: undefined })], NOW, 5 * MINUTE)).toHaveLength(0); + }); + + it("leaves out one that stopped listening", () => { + /* + * A command queued for a machine that is not listening sits there until it + * is. For a laptop shut for a week that means a run firing at a moment + * nobody asked for it, long after the person who pressed the button was + * told nothing had happened. + */ + expect(reachable([device({ agentSeenAt: NOW - 60 * MINUTE })], NOW, 5 * MINUTE)).toHaveLength(0); + }); + + it("leaves out one that has been unlinked", () => { + expect(reachable([device({ revokedAt: NOW - 1000 })], NOW, 5 * MINUTE)).toHaveLength(0); + }); + + it("takes several", () => { + const machines = reachable( + [device(), device({ id: "dev-2", label: "workshop" }), device({ id: "dev-3", agentSeenAt: undefined })], + NOW, + 5 * MINUTE, + ); + expect(machines.map((entry) => entry.label)).toEqual(["laptop", "workshop"]); + }); +}); diff --git a/app/server/routes/gathering.ts b/app/server/routes/gathering.ts new file mode 100644 index 0000000..a6ec292 --- /dev/null +++ b/app/server/routes/gathering.ts @@ -0,0 +1,115 @@ +import type { Device, GameCollectionRun } from "../lib/types"; + +/** + * The gathering: what a machine is allowed to report, and what it costs. + * + * This is the one part of the game that reads real work and spends real money, + * so it is the one part written defensively. + * + * Sessions are end-to-end encrypted. The service derives no key and holds no + * password, so it cannot read terminal output and never will. Anything richer + * than counting rows -- pull requests opened, lines changed, tokens spent -- + * exists only where the plaintext already is, which is the operator's own + * machine. So the agent gathers it there and sends numbers. + * + * Numbers, and nothing else. There is no field here for a branch name, a commit + * message, a file path, a diff or a line of output, and that is deliberate + * rather than incidental: a shape that cannot carry those cannot leak them by + * somebody later deciding it would be convenient. If that ever has to change, + * it should be hard, and it should be argued about here. + */ + +/** + * A cap on any single figure, so one bad report cannot make the vial absurd. + * + * Ten billion, not a hundred million. A real machine reported eighty-three + * million tokens for three days of ordinary work the first time this was run + * against one, which is most of the way to the tighter cap -- and a cap that + * clips honest reports is worse than no cap at all, because the number it + * produces is wrong and looks reasonable. This one exists to stop nonsense, not + * to bound real use. + */ +const MOST = 10_000_000_000; + +/** What a run may say, before any of it is believed. */ +export interface ReportedRun { + tokens: number; + pullRequests: number; + commits: number; + insertions: number; + deletions: number; + error: string; +} + +function count(value: unknown): number { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.min(MOST, Math.max(0, Math.floor(numeric))); +} + +/** + * Reads a report from a machine. + * + * Everything is narrowed on the way in. An agent is a program on somebody's + * laptop, and a laptop is not a place this service gets to trust arithmetic + * from: a negative token count would run the total backwards, and one absurd + * figure would make the vial meaningless for good, because these are summed + * and never recomputed. + */ +export function readRun(body: Record): ReportedRun { + return { + tokens: count(body.tokens), + pullRequests: count(body.pull_requests), + commits: count(body.commits), + insertions: count(body.insertions), + deletions: count(body.deletions), + /* A failure is a sentence for a person to read, not a payload. */ + error: typeof body.error === "string" ? body.error.slice(0, 200) : "", + }; +} + +export function runFrom( + id: string, + uid: string, + device: { id: string; label: string }, + reported: ReportedRun, + now: number, +): GameCollectionRun { + return { + id, + uid, + deviceId: device.id, + deviceName: device.label, + ranAt: now, + ...reported, + }; +} + +/** The shape the client reads. Snake case, like the rest of this API. */ +export function runForApi(run: GameCollectionRun) { + return { + id: run.id, + device: run.deviceName, + ran_at: run.ranAt, + tokens: run.tokens, + pull_requests: run.pullRequests, + commits: run.commits, + insertions: run.insertions, + deletions: run.deletions, + error: run.error, + }; +} + +/** + * Which machines a gathering run can actually be asked of. + * + * Only ones with an agent currently polling. A command queued for a machine + * that is not listening sits there until it is, which for a laptop that has + * been shut for a week means a run firing at a moment nobody asked for it -- + * and the person who pressed the button was told nothing had happened. + */ +export function reachable(devices: Device[], now: number, within: number): Device[] { + return devices.filter( + (device) => !device.revokedAt && device.agentSeenAt && now - device.agentSeenAt <= within, + ); +} diff --git a/app/server/verify-schema.ts b/app/server/verify-schema.ts index caf88be..2663810 100644 --- a/app/server/verify-schema.ts +++ b/app/server/verify-schema.ts @@ -35,6 +35,13 @@ const REQUIRED_TABLES = [ "comments", "deleted_accounts", "feedback", + /* + * The game's two. The Worker serves /api/game, /api/game/runs and the + * gathering from these, so a deploy that reached production without them + * would verify clean and then fail on the first request to the keep. + */ + "game_collection_runs", + "game_profiles", "invites", "memberships", "notifications", diff --git a/app/src/App.tsx b/app/src/App.tsx index 7609896..c52f469 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,5 +1,7 @@ +import { Suspense, lazy } from "react"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./auth/AuthProvider"; +import { Booting } from "./components/Booting"; import { RequireAuth, RedirectIfAuthed } from "./auth/RequireAuth"; import { SignIn } from "./routes/SignIn"; import AuthCallback from "./routes/AuthCallback"; @@ -20,10 +22,34 @@ import { VaultProvider } from "./vault/VaultProvider"; import { TeamKeyProvider } from "./vault/TeamKeyProvider"; import { FeedbackProvider } from "./feedback/FeedbackProvider"; -export default function App() { +/* + * The game skin, and the only reference to it anywhere outside src/game. + * + * Imported this way on purpose: the keep carries an engine, a sprite atlas and + * a stylesheet of its own, and none of that belongs in the bundle somebody + * downloads to look at a list of sessions. The dynamic import puts all of it in + * a separate chunk that is fetched the first time somebody asks for it, and + * `npm run verify:bundle` fails the build if it ever leaks back into the entry + * chunk. + * + * The fallback is the app's ordinary Booting card rather than something + * game-shaped, for the same reason: anything prettier would have to be imported + * here, and then it would not be in the game's chunk either. + */ +const GameRoute = lazy(() => import("./game/GameRoute")); + +/** + * Everything below sign-in: the providers that need an identity, and the + * routes. + * + * Split out from App so the development QA harness can mount the same tree + * with a stand-in identity on a machine that has no sign-in provider + * configured. Production mounts it through App below, under the real provider, + * and nothing about the guards changes either way. + */ +export function SignedInApp() { return ( - - + <> @@ -103,6 +129,17 @@ export default function App() { } /> + {/* The same product, in armour. See src/game/GameRoute.tsx. */} + + }> + + + + } + /> {/* No guard here. CliAuthorize handles the signed-out case itself so it can send the user back to this exact URL, query string included. @@ -113,6 +150,15 @@ export default function App() { + + ); +} + +export default function App() { + return ( + + + ); diff --git a/app/src/auth/AuthProvider.tsx b/app/src/auth/AuthProvider.tsx index 407bd72..cef6f14 100644 --- a/app/src/auth/AuthProvider.tsx +++ b/app/src/auth/AuthProvider.tsx @@ -63,7 +63,18 @@ interface AuthValue { deleteAccount: (confirmEmail: string, password?: string) => Promise; } -const AuthContext = createContext(null); +/* + * Exported for one reason: the development QA harness at /qa.html mounts the + * whole application on a machine that has no sign-in provider configured, and + * supplies a stand-in identity here. + * + * This weakens nothing. The production build always mounts a real provider + * below, every guard still asks this context whether there is a user, and the + * service on the other side still checks a real token on every request -- a + * browser that puts a name in here gets a nicely rendered page and a 401 from + * anything that matters. The harness is not an input to `vite build`. + */ +export const AuthContext = createContext(null); function FirebaseAuthProvider({ children }: { children: ReactNode }) { const firebaseAuth = auth; diff --git a/app/src/components/AppShell.tsx b/app/src/components/AppShell.tsx index 4bb677c..3bdb3d2 100644 --- a/app/src/components/AppShell.tsx +++ b/app/src/components/AppShell.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { Link, NavLink, useNavigate } from "react-router-dom"; import { Terminal, Desktop, User, UsersThree, ClockCounterClockwise, SignOut, Copy, Check, Warning, ChatCircleDots, + GameController, } from "@phosphor-icons/react"; import { Inbox } from "./Inbox"; import { Avatar } from "./Avatar"; @@ -48,6 +49,87 @@ export function LinkHint({ className = "rail-hint" }: { className?: string }) { ); } +/* + * A tooltip is not worth a flash. Hovering across a row of controls on the way + * somewhere else should not leave a trail of labels behind it, so the label + * waits to see whether the pointer meant to stop. + */ +const TOOLTIP_DELAY_MS = 300; + +/** + * The way into the game skin. + * + * Deliberately the last thing in the bar: it is a different way to look at the + * same product rather than another destination within it, and putting it in the + * rail with Sessions and Machines would have said otherwise. + * + * Pointing at it fetches the game's chunk. The click then has nothing to wait + * for, while somebody who never points at it never downloads it -- which is the + * whole reason the game is a separate chunk in the first place. + */ +function LaunchGame() { + const [hinting, setHinting] = useState(false); + const timer = useRef(0); + const warmed = useRef(false); + + const warm = () => { + if (warmed.current) return; + warmed.current = true; + /* + * The same specifier App.tsx lazily imports, so this warms that chunk + * rather than fetching a second copy of it. A failure here is not worth + * reporting: the click will simply load it the ordinary way. + */ + void import("../game/GameRoute").catch(() => { + warmed.current = false; + }); + }; + + const show = () => { + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setHinting(true), TOOLTIP_DELAY_MS); + }; + + const hide = () => { + window.clearTimeout(timer.current); + setHinting(false); + }; + + useEffect(() => () => window.clearTimeout(timer.current), []); + + return ( +
+ { + warm(); + show(); + }} + onMouseLeave={hide} + onFocus={() => { + warm(); + setHinting(true); + }} + onBlur={hide} + > + + + {hinting && ( + + Launch Game + + )} +
+ ); +} + function AccountMenu() { const { user, signOutUser } = useAuth(); const [open, setOpen] = useState(false); @@ -204,6 +286,8 @@ export function AppShell({ title, aside, children }: AppShellProps) { + {/* Last in the bar, on purpose: see LaunchGame. */} + diff --git a/app/src/game/GameRoute.tsx b/app/src/game/GameRoute.tsx new file mode 100644 index 0000000..fc406e2 --- /dev/null +++ b/app/src/game/GameRoute.tsx @@ -0,0 +1,441 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { usePageTitle } from "../lib/page-title"; +import { PixiStage } from "./pixi/PixiStage"; +import { buildKeepScene, createSim, type KeepHandle } from "./pixi/keepScene"; +import { WrightPanel } from "./ui/WrightPanel"; +import type { Actor } from "./world/sim"; +import { useInputDevice } from "./engine/use-input-device"; +import { useGamepadActions } from "./engine/use-gamepad"; +import { KEEP_TITLE, SHELL_KEEP_MARKER } from "./keep"; +import { GameShellContext, type GameShell } from "./state/context"; +import { motionReduced, optionsToStyle, readOptions, writeOptions, type GameOptions } from "./state/options"; +import { DEMO_EARNED, DEMO_ROSTER } from "./state/demo-garrison"; +import { useGarrison } from "./state/use-garrison"; +import { buy, skinById, tintFor } from "./state/shop"; +import { experienceFrom, marksEarnedTo, standing } from "./state/progress"; +import { useEarned } from "./state/use-earned"; +import { hasChosen, marksLeft, readSave, writeSave, type Save } from "./state/save"; +import { loadSave, reconcile, storeSave } from "./state/remote"; +import { ChooseCharacter } from "./ui/ChooseCharacter"; +import { Hud } from "./ui/Hud"; +import { PauseMenu } from "./ui/PauseMenu"; +import { Prompt } from "./ui/Prompt"; +import "../styles/game.css"; + +/** + * The keep. + * + * This module is the only thing `App.tsx` knows about the game, and it is + * reached through a dynamic import, so none of it -- not the engine, not the + * sprites, not this stylesheet -- is in the bundle somebody gets when they + * open the session list. `scripts/check-bundle.mjs` fails the build if that + * ever stops being true. + * + * What it owns is the frame around the game: the options that decide whether + * the thing is legible on this screen, which device is in the player's hands, + * and whether the simulation is running. The field itself is drawn by scenes + * mounted inside it. + */ +export default function GameRoute() { + usePageTitle(KEEP_TITLE); + + const [options, setOptionsState] = useState(readOptions); + /* Who you are, what you are wearing, and what you have bought. */ + const [save, setSaveState] = useState(readSave); + const [paused, setPaused] = useState(false); + const device = useInputDevice(); + + /* + * The OS setting is watched rather than read once. Somebody who turns + * "reduce motion" on because the game is making them ill should not have to + * reload the game to get the benefit of it. + */ + const [systemReduced, setSystemReduced] = useState( + () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, + ); + useEffect(() => { + const query = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!query) return; + const onChange = (event: MediaQueryListEvent) => setSystemReduced(event.matches); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + + const setOptions = useCallback((next: GameOptions) => { + setOptionsState(next); + writeOptions(next); + }, []); + + /* + * Saved on every change rather than on a timer or on the way out. The game + * is a browser tab: it is closed, not exited, and a save that waits for a + * clean shutdown is a save that is sometimes lost. + */ + const setSave = useCallback((next: Save) => { + setSaveState(next); + writeSave(next); + /* + * Sent up as well, and not waited for. A purchase should land the instant + * it is made; whether the service also heard about it is not something the + * player should be made to watch a spinner for. + */ + void storeSave(next); + }, []); + + /* Tokens the gathering has cost, which only the service knows. */ + const [elixir, setElixir] = useState(0); + + /* + * On arrival, the service's copy is merged with this browser's. + * + * Merged rather than replaced, and merged by what cannot go backwards: a + * class once chosen, skins once bought, marks once spent. Taking whichever + * was written most recently would let a tab somebody opened on a borrowed + * laptop and abandoned overwrite months of progress. + */ + useEffect(() => { + let live = true; + void loadSave().then((remote) => { + if (!live || !remote) return; + setElixir(remote.tokens); + setSaveState((current) => { + const merged = reconcile(current, remote.save); + writeSave(merged); + return merged; + }); + }); + return () => { + live = false; + }; + }, []); + + const reducedMotion = motionReduced(options, systemReduced); + + /* + * Told to the scene as well as to the stylesheet. The canvas is not styled by + * CSS, so without this the setting stopped at the edge of it. + * + * Also kept in a ref, because the scene is built asynchronously: this effect + * runs against the placeholder handle long before `buildKeepScene` has + * replaced it, so arriving with reduced motion already on would otherwise + * open a map full of drifting particles and never be corrected. + */ + const motionWanted = useRef(reducedMotion); + motionWanted.current = reducedMotion; + useEffect(() => { + handle.current.still(reducedMotion); + }, [reducedMotion]); + + /* + * What the garrison has done, read off the world a few times a second rather + * than every frame. + * + * The world changes thirty times a second and the HUD has four numbers on + * it; re-rendering React at frame rate to move a progress bar by a pixel is + * the sort of thing that makes a game feel heavy for no reason anybody can + * see. Twice a second is faster than anyone reads. + */ + const [tally, setTally] = useState({ felled: 0, raised: 0, wrights: [] as Actor[] }); + useEffect(() => { + const timer = window.setInterval(() => { + setTally({ + felled: sim.current.felled, + raised: sim.current.raised, + /* Only the wrights that stand for real sessions are counted. */ + wrights: sim.current.actors.filter((actor) => actor.session !== undefined), + }); + }, 500); + return () => window.clearInterval(timer); + }, []); + + /* + * Experience from work that actually happened, counted by the service. + * + * It used to be fed the field's own tally of faults put down, which meant a + * tab left open overnight levelled you up -- the exact thing progress.ts + * promises at the top of the file that nothing here does. The simulation is + * spectacle now and earns nothing; what earns is a session that finished. + */ + const { earned: counted, known } = useEarned(); + /* + * The example team's example history, when the field is showing the example + * team. Labelled as an example everywhere it appears -- and it is what makes + * the shop, the Barrow and levelling reachable at all without a working + * service and a week of sessions behind you. + */ + const earned = known ? counted : DEMO_EARNED; + const rank = standing(experienceFrom(earned)); + + /* Earned by levelling, less what has been spent with the pedlar. */ + const purse = { + marks: marksLeft(marksEarnedTo(rank.level), save), + owned: save.owned, + }; + + /* + * The simulation, in a ref rather than in state. + * + * It changes thirty times a second; putting it in state would re-render the + * whole route at that rate to redraw a canvas React does not manage anyway. + * The loop mutates it and the renderer reads it, and React is told only when + * something it actually draws in the DOM changes. + */ + const sim = useRef(createSim()); + /* The one clicked wright, which is the only game state React needs. */ + const [picked, setPicked] = useState(); + /* + * Which pane the pause menu should open on, set by whatever opened it. The + * vial on the field leads straight to the account of what the gathering has + * cost; everything else opens the menu where it was left. + */ + const [pauseAt, setPauseAt] = useState<"gathering" | undefined>(); + + const handle = useRef({ + sim: sim.current, + select: () => {}, + lookAt: () => {}, + wear: () => {}, + still: () => {}, + }); + handle.current.onPick = setPicked; + /* + * The inspect card follows the figure it is about. + * + * Its position is written straight onto the node by the scene, every frame, + * rather than kept in state: a card that re-rendered the route sixty times a + * second to move one box would be paying a component tree for arithmetic. + * It is also clamped to the window here, because a card about somebody + * standing at the edge of the view is a card half off the screen. + */ + const cardRef = useRef(null); + handle.current.onTrack = (at) => { + const card = cardRef.current; + if (!card) return; + if (!at) { + card.style.visibility = "hidden"; + return; + } + const box = card.getBoundingClientRect(); + const GAP = 28; + const left = Math.min( + Math.max(12, at.x + GAP), + Math.max(12, window.innerWidth - box.width - 12), + ); + const top = Math.min( + Math.max(12, at.y - box.height / 2), + Math.max(12, window.innerHeight - box.height - 12), + ); + card.style.visibility = "visible"; + card.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`; + }; + + /* + * Who is on the field: the account's live sessions, polled, with the + * stand-in garrison when there are none or the service cannot be reached. + */ + const garrison = useGarrison(sim.current, DEMO_ROSTER, save.characterClass); + + /* + * The game takes the window. The corporate shell scrolls; a field that + * scrolls underneath a fixed HUD is a field somebody loses their heroes off + * the bottom of, so the body is held still for as long as this is mounted. + */ + useEffect(() => { + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; + }; + }, []); + + /* Escape pauses, and pauses again out of whatever the pause menu opened. */ + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape" || paused) return; + event.preventDefault(); + setPaused(true); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [paused]); + + /* + * Start on the pad opens the pause menu. Only while play is running: the + * menu handles its own input once it is up, and two listeners fighting over + * the same button is a menu that opens and closes on one press. + */ + useGamepadActions( + useCallback((action) => { + if (action === "pause") setPaused(true); + }, []), + !paused, + ); + + const shell = useMemo( + () => ({ options, setOptions, reducedMotion, device, paused, setPaused }), + [options, setOptions, reducedMotion, device, paused], + ); + + const style = optionsToStyle(options, systemReduced) as React.CSSProperties; + + return ( + +
+ {/* + * The field fills the window, edge to edge and under everything else. + * There is no title over it: the game is the picture, and a wordmark + * across the top of it is a browser tab's job. + */} +
+ { + const scene = await buildKeepScene(app, viewport, handle.current); + handle.current.still(motionWanted.current); + return scene; + }} + /> +
+ + {/* + * Everything that must survive a television sits inside this, laid + * over the field rather than beside it. The picture may bleed into + * the crop; the things you need to read may not. + */} +
+ { + setPauseAt("gathering"); + setPaused(true); + }} + onOpenRoster={() => setPaused(true)} + /> + + {/* + * The pause button holds the corner the HUD leaves for it, and the + * key prompt the one below. Both belong to the same ring of things + * around the edge of the eye; they are here rather than in `Hud` + * only because they are the route's to open and to label. + */} +
+ +
+ +
+ +
+
+ + {picked && ( + { + setPicked(undefined); + handle.current.select(undefined); + }} + /> + )} + + {paused && ( + { + setPaused(false); + /* + * Forgotten on the way out, or every later press of Escape would + * reopen the pane the vial last asked for rather than the menu. + */ + setPauseAt(undefined); + }} + purse={purse} + characterClass={save.characterClass || "terminal"} + wearing={save.skinId} + livery={save.liveryId} + shopOpen={rank.level >= 2} + elixir={elixir} + garrison={tally.wrights.length} + onTravel={(id) => handle.current.lookAt(id)} + gathering={save.gathering} + earned={earned} + counted={known} + onGathering={(on) => setSave({ ...save, gathering: on })} + openAt={pauseAt} + onBuy={(skinId) => { + const result = buy(purse, skinId); + if (!result.ok) return; + const bought = skinById(skinId); + /* + * What is stored is the spend, not the purse. The purse is + * derived from the level that earned it, so storing both would + * be two facts that can disagree. + * + * A first purchase in a slot is worn at once, because nobody buys + * a colour in order to not wear it. + */ + const next = { + ...save, + spent: save.spent + (purse.marks - result.purse.marks), + owned: result.purse.owned, + skinId: + bought?.wears === "hero" ? save.skinId || skinId : save.skinId, + liveryId: + bought?.wears === "retinue" ? save.liveryId || skinId : save.liveryId, + }; + setSave(next); + handle.current.wear(tintFor(next.skinId), tintFor(next.liveryId)); + }} + onWear={(skinId) => { + const chosen = skinById(skinId); + const next = + chosen?.wears === "retinue" + ? { ...save, liveryId: skinId } + : { ...save, skinId }; + setSave(next); + /* The field shows it at once, rather than on the next reload. */ + handle.current.wear(tintFor(next.skinId), tintFor(next.liveryId)); + }} + /> + )} + + {/* + * The one decision the game asks for, over the top of everything. + * Shown until it has been made; the field carries on behind it. + */} + {!hasChosen(save) && ( + setSave({ ...save, characterClass })} + /> + )} +
+
+ ); +} diff --git a/app/src/game/README.md b/app/src/game/README.md new file mode 100644 index 0000000..fb773cd --- /dev/null +++ b/app/src/game/README.md @@ -0,0 +1,139 @@ +# Shell Keep + +The game skin over `app.shell.online`. Everything the game is lives in this +directory and in `src/styles/game.css`. Nothing else in the application knows +it exists, except at the handful of seams listed below. + +## Why it is isolated + +Two reasons, and the second is the one that bites. + +**Cost.** The keep carries a renderer, a sprite atlas and a stylesheet of its +own. None of that belongs in the bundle somebody downloads to look at a list of +sessions, so it is reached through one dynamic import and travels in its own +chunk. `npm run verify:bundle` fails the build if any of it reaches the entry +bundle — it looks for `SHELL_KEEP_MARKER`, which the game writes onto the DOM. + +**Blast radius.** A game is a large amount of code that no paying use of this +product depends on. Keeping it behind one import means it can be changed, +broken or removed without touching the console, and it means a reviewer can see +at a glance that a change to the keep cannot have changed how a session starts. + +## The seams + +Six, all of them small, all of them deliberate. If you are adding a seventh, +that is worth a conversation first. + +| File | What it does | +|---|---| +| `src/App.tsx` | One lazy route at `/game`, and `SignedInApp` split out so the QA harness can mount the app under a stand-in identity | +| `src/components/AppShell.tsx` | The `LaunchGame` button at the right of the top bar | +| `src/styles/shell.css` | That button's styles, `.launch-game*` | +| `src/auth/AuthProvider.tsx` | `AuthContext` is exported so the QA harness can supply an identity. No guard changed | +| `src/lib/api.ts` | `request` is exported, so the game calls its own endpoints without a second copy of the token handling and the error sentences | +| `scripts/check-bundle.mjs` | The guard that keeps all of the above honest | + +There is one seam the other way, and it is worth knowing about because it is +the only place the service reaches into the game: + +| File | What it does | +|---|---| +| `src/game/world/work.ts` | Decides whether a session reads as mending or making. `server/lib/game-stats.ts` imports it | + +It has no imports of its own, which is why it can be shared. The map walks a +wright to the garrison its work belongs to and the service counts the same +session towards the same column; two copies of those patterns would drift until +the map and the ladder disagreed about what a session was. It is reached only +from the game chunk and from the service, so it stays out of the corporate +bundle, and `check-bundle.mjs` still says so. + +The game also owns a slice of the service, which is not a seam so much as its +own corner. Nothing else on the server reads any of it: + +- `server/routes/game.ts` — the saved game, and what a browser may not set +- `server/lib/game-stats.ts` — what a level is worth, counted from sessions +- `server/routes/gathering.ts` — what a machine may report, and how hard it is believed +- `game_profiles` in migration 014, `game_collection_runs` in migration 015 +- four methods on the `Store` interface + +And a slice of the CLI, for the half of the gathering that has to happen where +the plaintext is: + +- `internal/stats` — reads git, `gh` and the agents' own history files +- `cmd/shell/stats.go` — `shell stats`, which prints what would be sent and sends nothing +- the `"probe"` case in `cmd/shell/agent_loop.go` + +Nothing in this directory imports from `src/routes`, and nothing outside it +imports from here except through the seams above. + +## Layout + +``` +world/ the map, the projection, the simulation, the scatter, the border +pixi/ what gets drawn, and the camera: scene, actors, plates, ground, ambience +engine/ input, the focus grid, the fixed-step loop +state/ options, progress, the shop, the roster, what is saved +ui/ the DOM interface over the canvas: HUD, menus, shop, the gathering +lore/ names, flavour, and the codex +``` + +**`world/` imports nothing from `pixi/`, and nothing from `pixi.js`.** That is +the rule the layout is for, and it is what lets a thousand ticks of the +simulation be run in a test and looked at -- which is how the shaking was found, +by counting direction reversals rather than by watching. + +The projection lives in `world/iso.ts` for that reason. It was in `pixi/` at +first, which was wrong twice over: it is pure arithmetic about the shape of the +map with no Pixi in it, and once the border and the camps needed it, `world/` +was importing upwards out of the renderer while this file claimed it did not. + +## Running it + +```sh +npm run dev +``` + +- `http://localhost:5173/qa.html` — **the whole app, signed in.** This is what + to test: the route inside the product, the controller in the top bar, and the + way back out to the session list. Calls to the service will fail, because the + identity is a stand-in and the server checks a real token; those error states + are worth seeing. +- `http://localhost:5173/game-preview.html` — the game on its own, for working + on the artwork without the app around it. + +Neither page is an input to `vite build`, which builds `index.html` and nothing +else, so both exist in development and in no deployment. + +## Working on the art + +Kenney's packs, all CC0, vendored into `public/game/` with provenance in +`docs/third-party-notices.md`. An earlier version of this drew everything as +palette-indexed text sprites, which was small and diffable and produced +structures nobody could identify; recognisable art was worth more than a clever +pipeline. + +Two import scripts keep the vendoring honest rather than leaving mystery files +in the repository: + +```sh +node scripts/import-kenney.mjs # the XML atlas, converted to Pixi's JSON +node scripts/import-fantasy-ui.mjs # the panel frames, recoloured to brass +``` + +The second one is three bytes and a checksum: the frames are 1-bit paletted, so +it rewrites the single palette entry that is not transparent and leaves every +pixel where Kenney drew it. + +The map itself is not art. `world/marches.ts` is where the holdings, the roads +and the water are; `world/scatter.ts` decides where a tree may stand, and its +test is mostly about the three places one must never be — in a road, inside a +holding, or in the river — because each of those reads as a collision fault +rather than as scenery. + +## The rules the game is held to + +`scripts/check-game-ui.mjs` runs in `npm test` and enforces the +`game-ui-design` skill: a 16px floor for text, 44px for anything you can hit, +motion at or under 300ms, a bounded z-index scale, a visible focus ring, a +reduced-motion escape, and no physical button named anywhere except the one +table that knows what the player is holding. diff --git a/app/src/game/engine/input.test.ts b/app/src/game/engine/input.test.ts new file mode 100644 index 0000000..ed3a841 --- /dev/null +++ b/app/src/game/engine/input.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + DEVICE_SWITCH_GRACE_MS, + actionForKey, + actionForPadButton, + deviceForGamepad, + promptFor, + promptLabel, + shouldSwitchDevice, + type GameAction, + type InputDevice, +} from "./input"; + +const DEVICES: InputDevice[] = ["keyboard", "xbox", "playstation", "nintendo", "touch"]; +const ACTIONS: GameAction[] = [ + "confirm", "cancel", "pause", "inspect", + "up", "down", "left", "right", "tabPrev", "tabNext", +]; + +describe("naming the button", () => { + it("has a name for every action on every device", () => { + /* + * The point of the whole module: no combination may come back empty, or a + * prompt somewhere reads "Press to muster". + */ + for (const device of DEVICES) { + for (const action of ACTIONS) { + expect(promptFor(action, device)).toBeTruthy(); + } + } + }); + + it("names the button each platform actually has", () => { + expect(promptFor("confirm", "playstation")).toBe("✕"); + expect(promptFor("cancel", "playstation")).toBe("◯"); + expect(promptFor("confirm", "xbox")).toBe("A"); + expect(promptFor("confirm", "keyboard")).toBe("Enter"); + }); + + it("never tells a touch player to press anything", () => { + expect(promptLabel("confirm", "touch", "muster")).toBe("Tap to muster"); + expect(promptLabel("confirm", "touch", "muster")).not.toContain("Press"); + }); + + it("puts the verb after the button, so the sentence reads", () => { + expect(promptLabel("confirm", "xbox", "muster")).toBe("Press A to muster"); + }); +}); + +describe("recognising a pad", () => { + it("reads the vendor out of the id", () => { + expect(deviceForGamepad("Wireless Controller (STANDARD GAMEPAD Vendor: 054c)")).toBe("xbox"); + expect(deviceForGamepad("DualSense Wireless Controller")).toBe("playstation"); + expect(deviceForGamepad("Xbox Wireless Controller")).toBe("xbox"); + expect(deviceForGamepad("Pro Controller (Nintendo)")).toBe("nintendo"); + }); + + it("guesses the commonest layout for a pad it does not know", () => { + /* + * Better than refusing to show a prompt at all: confirm and cancel are in + * the same two physical places on almost everything. + */ + expect(deviceForGamepad("Generic USB Joystick")).toBe("xbox"); + expect(deviceForGamepad("")).toBe("xbox"); + }); +}); + +describe("switching between devices", () => { + it("ignores an input from the device already in use", () => { + expect(shouldSwitchDevice("xbox", "xbox", 10_000)).toBe(false); + }); + + it("refuses a switch while the current device is still being used", () => { + /* A mouse brushed on the desk should not relabel a pad player's screen. */ + expect(shouldSwitchDevice("xbox", "keyboard", 10)).toBe(false); + }); + + it("switches once the old device has gone quiet", () => { + expect(shouldSwitchDevice("xbox", "keyboard", DEVICE_SWITCH_GRACE_MS)).toBe(true); + }); +}); + +describe("what a press means", () => { + it("maps the keys a menu needs", () => { + expect(actionForKey("Enter")).toBe("confirm"); + expect(actionForKey(" ")).toBe("confirm"); + expect(actionForKey("Escape")).toBe("cancel"); + expect(actionForKey("ArrowDown")).toBe("down"); + }); + + it("ignores a key that is not bound", () => { + expect(actionForKey("F7")).toBeUndefined(); + expect(actionForKey("z")).toBeUndefined(); + }); + + it("maps the standard pad buttons, and nothing beyond them", () => { + expect(actionForPadButton(0)).toBe("confirm"); + expect(actionForPadButton(1)).toBe("cancel"); + expect(actionForPadButton(9)).toBe("pause"); + expect(actionForPadButton(13)).toBe("down"); + expect(actionForPadButton(17)).toBeUndefined(); + }); +}); diff --git a/app/src/game/engine/input.ts b/app/src/game/engine/input.ts new file mode 100644 index 0000000..eef5633 --- /dev/null +++ b/app/src/game/engine/input.ts @@ -0,0 +1,195 @@ +/** + * What the player can ask for, and what to call the button that asks for it. + * + * Nothing in the game writes "Press A". It writes `promptFor("confirm", device)` + * and gets back whatever that player's hardware actually has, because "Press A" + * is wrong for a PlayStation pad, wrong for a keyboard, wrong for a phone, and + * wrong for anybody who has rebound it. Showing a button somebody does not have + * is how a tutorial becomes unfollowable. + * + * The device is whichever one was used last. Games that pick a device at launch + * and keep it get this wrong the moment somebody puts the pad down. + */ + +export type GameAction = + | "confirm" + | "cancel" + | "pause" + | "inspect" + | "up" + | "down" + | "left" + | "right" + | "tabPrev" + | "tabNext"; + +export type InputDevice = "keyboard" | "xbox" | "playstation" | "nintendo" | "touch"; + +/** The default binding per device. Rebinding replaces the entry, not the call site. */ +const BINDINGS: Record> = { + keyboard: { + confirm: "Enter", + cancel: "Esc", + pause: "Esc", + inspect: "Shift", + up: "↑", + down: "↓", + left: "←", + right: "→", + tabPrev: "Q", + tabNext: "E", + }, + xbox: { + confirm: "A", + cancel: "B", + pause: "Menu", + inspect: "Y", + up: "D-pad ↑", + down: "D-pad ↓", + left: "D-pad ←", + right: "D-pad →", + tabPrev: "LB", + tabNext: "RB", + }, + playstation: { + confirm: "✕", + cancel: "◯", + pause: "Options", + inspect: "△", + up: "D-pad ↑", + down: "D-pad ↓", + left: "D-pad ←", + right: "D-pad →", + tabPrev: "L1", + tabNext: "R1", + }, + nintendo: { + confirm: "A", + cancel: "B", + pause: "+", + inspect: "X", + up: "D-pad ↑", + down: "D-pad ↓", + left: "D-pad ←", + right: "D-pad →", + tabPrev: "L", + tabNext: "R", + }, + touch: { + confirm: "Tap", + cancel: "Back", + pause: "Pause", + inspect: "Hold", + up: "Swipe up", + down: "Swipe down", + left: "Swipe left", + right: "Swipe right", + tabPrev: "Swipe left", + tabNext: "Swipe right", + }, +}; + +/** What to call the button for an action on the device in the player's hands. */ +export function promptFor(action: GameAction, device: InputDevice): string { + return BINDINGS[device][action]; +} + +/** + * A whole prompt, verb first. + * + * "Press Enter to muster" reads better to somebody who has never played this + * than "Enter — muster", and a new player is the only one who needs it. + */ +export function promptLabel(action: GameAction, device: InputDevice, verb: string): string { + const button = promptFor(action, device); + if (device === "touch") return `${button} to ${verb}`; + return `Press ${button} to ${verb}`; +} + +/** + * Which pad this is, from the id the browser reports. + * + * Gamepad ids are vendor strings with no schema, so this is a best guess that + * falls back to the most common layout rather than to nothing. Guessing Xbox + * for an unknown pad is right far more often than it is wrong, and the labels + * for confirm and cancel are in the same physical places either way. + */ +export function deviceForGamepad(id: string): InputDevice { + const lower = id.toLowerCase(); + if (/playstation|dualshock|dualsense|sony|\bps[345]\b/.test(lower)) return "playstation"; + if (/nintendo|switch|joy-con|joycon|pro controller/.test(lower)) return "nintendo"; + return "xbox"; +} + +/** + * How long a device stays "current" after it was used. + * + * A mouse that brushes the desk while a pad is held should not flip every + * prompt on screen for one frame, so a switch has to be deliberate: the new + * device wins only once the old one has been quiet this long. + */ +export const DEVICE_SWITCH_GRACE_MS = 400; + +/** + * Whether an input from `next` should take over from `current`. + * + * Pure, so the flapping rule is testable without wiring up real hardware. + */ +export function shouldSwitchDevice( + current: InputDevice, + next: InputDevice, + msSinceCurrentUsed: number, +): boolean { + if (current === next) return false; + return msSinceCurrentUsed >= DEVICE_SWITCH_GRACE_MS; +} + +/** Standard gamepad button indices, named so the mapping below reads. */ +const PAD_BUTTON: Partial> = { + 0: "confirm", + 1: "cancel", + 3: "inspect", + 4: "tabPrev", + 5: "tabNext", + 9: "pause", + 12: "up", + 13: "down", + 14: "left", + 15: "right", +}; + +export function actionForPadButton(index: number): GameAction | undefined { + return PAD_BUTTON[index]; +} + +/** + * The action a key press means, or nothing when the key is not bound. + * + * Escape is both cancel and pause; which one it is depends on whether anything + * is open to cancel, and that is the caller's question rather than this one's. + */ +export function actionForKey(key: string): GameAction | undefined { + switch (key) { + case "Enter": + case " ": + return "confirm"; + case "Escape": + return "cancel"; + case "ArrowUp": + return "up"; + case "ArrowDown": + return "down"; + case "ArrowLeft": + return "left"; + case "ArrowRight": + return "right"; + case "q": + case "Q": + return "tabPrev"; + case "e": + case "E": + return "tabNext"; + default: + return undefined; + } +} diff --git a/app/src/game/engine/menu.test.ts b/app/src/game/engine/menu.test.ts new file mode 100644 index 0000000..afa5be0 --- /dev/null +++ b/app/src/game/engine/menu.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { firstEnabled, nextIndex, restoreIndex } from "./menu"; + +/* + * The rule these protect is "a pad can always get out of a menu". Every case + * below is a dead end somebody would otherwise find by picking up a controller + * and discovering the game had stopped responding. + */ + +const all = (count: number) => Array.from({ length: count }, () => true); + +describe("moving through a menu", () => { + it("steps forward and back", () => { + expect(nextIndex(0, 1, all(3))).toBe(1); + expect(nextIndex(2, -1, all(3))).toBe(1); + }); + + it("wraps at both ends, so neither is a wall", () => { + expect(nextIndex(2, 1, all(3))).toBe(0); + expect(nextIndex(0, -1, all(3))).toBe(2); + }); + + it("steps over a disabled item rather than landing on it", () => { + /* Resume, [unaffordable], Quit. */ + expect(nextIndex(0, 1, [true, false, true])).toBe(2); + expect(nextIndex(2, 1, [true, false, true])).toBe(0); + }); + + it("steps over a run of disabled items", () => { + expect(nextIndex(0, 1, [true, false, false, false, true])).toBe(4); + }); + + it("stays put when nothing at all can be selected", () => { + /* Rather than looping forever looking for somewhere to go. */ + expect(nextIndex(1, 1, [false, false, false])).toBe(1); + }); + + it("stays put in an empty menu", () => { + expect(nextIndex(0, 1, [])).toBe(0); + }); + + it("does not get stuck on the only selectable item", () => { + expect(nextIndex(1, 1, [false, true, false])).toBe(1); + }); +}); + +describe("where a menu opens", () => { + it("lands on the first thing that can be chosen", () => { + expect(firstEnabled([false, false, true])).toBe(2); + }); + + it("falls back to the top when nothing can be chosen", () => { + expect(firstEnabled([false, false])).toBe(0); + }); +}); + +describe("returning to a menu", () => { + it("puts you back where you were", () => { + expect(restoreIndex(2, all(4))).toBe(2); + }); + + it("clamps a position the menu has since outgrown", () => { + /* Items were removed while this menu was closed. */ + expect(restoreIndex(9, all(3))).toBe(2); + }); + + it("moves on when the remembered item has become unaffordable", () => { + expect(restoreIndex(1, [true, false, true])).toBe(2); + }); + + it("survives a menu that has emptied", () => { + expect(restoreIndex(3, [])).toBe(0); + }); +}); diff --git a/app/src/game/engine/menu.ts b/app/src/game/engine/menu.ts new file mode 100644 index 0000000..45edd58 --- /dev/null +++ b/app/src/game/engine/menu.ts @@ -0,0 +1,53 @@ +/** + * Menu navigation that a pad can actually drive. + * + * The rule the skill is emphatic about is that every interactive element must + * be reachable and nothing may trap focus. That is mostly index arithmetic, so + * it lives here as pure functions: a dead end is a bug you want a test to + * catch, not one you want to find by picking up a controller. + */ + +/** + * The next selectable index in a direction, wrapping at the ends. + * + * Wrapping matters more than it sounds: without it the last item is a wall, + * and on a pad a wall reads as the menu having stopped responding. Disabled + * items are stepped over rather than landed on, so a greyed-out purchase never + * swallows the stick. + * + * Returns the current index when nothing is selectable, which is the only + * honest answer and keeps the caller from looping forever. + */ +export function nextIndex(current: number, delta: number, enabled: boolean[]): number { + const count = enabled.length; + if (count === 0) return current; + if (!enabled.some(Boolean)) return current; + + let index = current; + for (let step = 0; step < count; step += 1) { + index = (index + delta + count) % count; + if (enabled[index]) return index; + } + return current; +} + +/** The first thing a menu should land on when it opens. */ +export function firstEnabled(enabled: boolean[]): number { + const index = enabled.findIndex(Boolean); + return index === -1 ? 0 : index; +} + +/** + * Keeps a remembered position usable after the menu behind it has changed. + * + * Coming back to a menu should put you where you were -- but "where you were" + * may since have been removed, or become unaffordable and therefore disabled. + * Clamping and then re-seeking is what stops a remembered index from selecting + * nothing at all. + */ +export function restoreIndex(remembered: number, enabled: boolean[]): number { + if (enabled.length === 0) return 0; + const clamped = Math.min(Math.max(remembered, 0), enabled.length - 1); + if (enabled[clamped]) return clamped; + return nextIndex(clamped, 1, enabled); +} diff --git a/app/src/game/engine/use-gamepad.ts b/app/src/game/engine/use-gamepad.ts new file mode 100644 index 0000000..2ad66af --- /dev/null +++ b/app/src/game/engine/use-gamepad.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef } from "react"; +import { actionForPadButton, type GameAction } from "./input"; + +/** How far the stick must move before it counts as a direction, not a wobble. */ +const STICK_THRESHOLD = 0.6; +/** Held-direction repeat, matching a comfortable key-repeat rather than frame rate. */ +const REPEAT_FIRST_MS = 420; +const REPEAT_NEXT_MS = 120; + +const DIRECTIONS: GameAction[] = ["up", "down", "left", "right"]; + +/** + * Turns a gamepad into the same actions a keyboard produces. + * + * Menus should not know which of the two they are being driven by; they get + * "down" and "confirm" either way. Doing the translation here is what makes + * "every menu is navigable with a pad" a property of the app rather than + * something each screen has to remember to implement. + * + * Polls, because the Gamepad API has no events for button state. Runs only + * while mounted, does nothing while the document is hidden, and allocates + * nothing per frame when no pad is connected. + */ +export function useGamepadActions(onAction: (action: GameAction) => void, active = true): void { + /* Through a ref so a changing handler does not restart the polling loop. */ + const handler = useRef(onAction); + handler.current = onAction; + + useEffect(() => { + if (!active) return; + if (typeof navigator.getGamepads !== "function") return; + + let frame = 0; + /* Which actions were held on the previous frame, to find the edges. */ + let previous = new Set(); + /* When a held direction is due to fire again. */ + const repeatAt = new Map(); + + const poll = () => { + frame = requestAnimationFrame(poll); + if (document.hidden) return; + + const now = performance.now(); + /* + * Gathered across every pad and every source first, then compared. The + * d-pad and the left stick both produce "up", and two passes that each + * set the held state would cancel each other out -- the pad would fire + * "up" on every single frame, which reads as a menu that has gone mad. + */ + const held = new Set(); + const pads = navigator.getGamepads?.() ?? []; + + for (const pad of pads) { + if (!pad) continue; + + pad.buttons.forEach((button, index) => { + if (!button.pressed) return; + const action = actionForPadButton(index); + if (action) held.add(action); + }); + + /* The left stick does what the d-pad does; players expect both. */ + const [x = 0, y = 0] = pad.axes; + if (x < -STICK_THRESHOLD) held.add("left"); + if (x > STICK_THRESHOLD) held.add("right"); + if (y < -STICK_THRESHOLD) held.add("up"); + if (y > STICK_THRESHOLD) held.add("down"); + } + + /* Newly pressed fires immediately and arms the repeat. */ + for (const action of held) { + if (!previous.has(action)) { + handler.current(action); + repeatAt.set(action, now + REPEAT_FIRST_MS); + } + } + + /* + * Only directions repeat while held. A held confirm that fired every + * 120ms would buy the whole shop, which is the sort of thing a player + * discovers only once the gold has gone. + */ + for (const action of DIRECTIONS) { + if (!held.has(action)) { + repeatAt.delete(action); + continue; + } + const due = repeatAt.get(action); + if (due !== undefined && now >= due) { + handler.current(action); + repeatAt.set(action, now + REPEAT_NEXT_MS); + } + } + + previous = held; + }; + + poll(); + return () => cancelAnimationFrame(frame); + }, [active]); +} diff --git a/app/src/game/engine/use-input-device.ts b/app/src/game/engine/use-input-device.ts new file mode 100644 index 0000000..503cf4d --- /dev/null +++ b/app/src/game/engine/use-input-device.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; +import { + deviceForGamepad, + shouldSwitchDevice, + type InputDevice, +} from "./input"; + +/** + * Which device the player is using right now. + * + * Listens rather than asks: there is no way to enquire what somebody is + * holding, only to notice what they last touched. A pad that is connected but + * resting should not win over the keyboard being typed on, so connection alone + * does not count -- a button has to move. + */ +export function useInputDevice(initial: InputDevice = "keyboard"): InputDevice { + const [device, setDevice] = useState(initial); + /* When the current device was last used, for the anti-flapping grace. */ + const lastUsed = useRef(0); + const currentRef = useRef(device); + currentRef.current = device; + + useEffect(() => { + const now = () => performance.now(); + lastUsed.current = now(); + + const offer = (next: InputDevice) => { + const elapsed = now() - lastUsed.current; + if (currentRef.current === next) { + lastUsed.current = now(); + return; + } + if (!shouldSwitchDevice(currentRef.current, next, elapsed)) return; + lastUsed.current = now(); + setDevice(next); + }; + + const onKey = () => offer("keyboard"); + const onPointer = (event: PointerEvent) => { + offer(event.pointerType === "touch" || event.pointerType === "pen" ? "touch" : "keyboard"); + }; + + window.addEventListener("keydown", onKey); + window.addEventListener("pointerdown", onPointer); + + /* + * Pads do not raise events. The only way to know a button moved is to look, + * so this polls -- but only while the game is mounted and visible, and only + * at a rate a menu needs. The game loop polls its own copy at frame rate + * for actual play; this is just for keeping the prompts honest. + */ + let frame = 0; + const pressed = new Map(); + const poll = () => { + frame = window.setTimeout(poll, 120); + const pads = navigator.getGamepads?.() ?? []; + for (const pad of pads) { + if (!pad) continue; + let moved = false; + pad.buttons.forEach((button, index) => { + const was = pressed.get(index) ?? false; + if (button.pressed && !was) moved = true; + pressed.set(index, button.pressed); + }); + if (pad.axes.some((axis) => Math.abs(axis) > 0.5)) moved = true; + if (moved) offer(deviceForGamepad(pad.id)); + } + }; + poll(); + + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("pointerdown", onPointer); + window.clearTimeout(frame); + }; + }, []); + + return device; +} diff --git a/app/src/game/keep.ts b/app/src/game/keep.ts new file mode 100644 index 0000000..b62d65a --- /dev/null +++ b/app/src/game/keep.ts @@ -0,0 +1,29 @@ +/** + * Identity of the game chunk. + * + * SHELL_KEEP_MARKER is load-bearing: `scripts/check-bundle.mjs` asserts that + * this string does NOT appear in the entry chunk the corporate view loads. The + * game is a lazily-imported route and it has to stay that way, so the build + * fails rather than quietly shipping a game engine to somebody who only wanted + * a session list. + * + * It is written onto the DOM in GameRoute rather than left in a constant a + * minifier could fold away, because a marker that gets tree-shaken makes the + * check pass for the wrong reason. + */ +export const SHELL_KEEP_MARKER = "__SHELL_KEEP__"; + +/** Shown in the pause menu, so a bug report can say which build it came from. */ +export const KEEP_BUILD = __SHELL_ONLINE_VERSION__; + +/** + * Where "Quit to boring UI" goes. + * + * The session list, not whatever route was visited last: the game can be + * entered from anywhere, and leaving it should land on the page the game is a + * skin over rather than back on, say, the terms page. + */ +export const BORING_UI = "/sessions"; + +/** The name on the tab and the banner. */ +export const KEEP_TITLE = "Shell Keep"; diff --git a/app/src/game/lore/world.ts b/app/src/game/lore/world.ts new file mode 100644 index 0000000..a9dd8ce --- /dev/null +++ b/app/src/game/lore/world.ts @@ -0,0 +1,118 @@ +/** + * What the keep is, who is in it, and what is trying to get in. + * + * The rule for all of this: every piece of lore has to be a *rename of + * something real*, never an invention on top of it. A session is a wright + * because a session really is a thing your machine sent to do work. The Prompt + * must not go out because a shell really does die when its process does. Token + * spend is elixir because it really is the thing being consumed. + * + * Lore that describes the product is flavour. Lore that describes a fiction + * nobody can check is noise, and noise is what makes a small game feel + * complicated. So there is not much here, and all of it points at something. + */ + +export const WORLD = { + /** The age. Nobody alive remembers the last reboot. */ + era: "the Long Uptime", + /** Where the holdings stand: border country, at the edge of what still runs. */ + region: "the Marches", + /** The player. One per person on the team. */ + role: "Castellan", + /** The team. */ + garrison: "the Garrison", + /** The amber light in the hall. While it burns, the machine is up. */ + flame: "the Prompt", + /** What comes out of rotted code. */ + foe: "the Unmade", + /** A session: something a machine sent to do work. */ + unit: "wright", + /** A linked machine. */ + outpost: "outpost", + /** Currency. */ + coin: "marks", + /** The resource the stat-gathering spends. */ + essence: "elixir", +} as const; + +/** + * The one paragraph anyone is ever made to read, shown once on first launch. + * + * Deliberately short. A wall of fiction in front of a game somebody opened out + * of curiosity is a wall they close. + */ +export const OPENING = [ + `It is ${WORLD.era}, and nothing has been rebooted in living memory.`, + `Your keep stands on ${WORLD.region}, built around ${WORLD.flame} — an amber light in the hall that must not go out.`, + /* One sentence, so the foe's name is never asked to start one in lower case. */ + `Each outpost you have linked sends a wright when there is work; ${WORLD.foe} come when there is rot.`, +] as const; + +/** Flavour for each class of wright, keyed by the session kind it comes from. */ +export const CLASS_LORE: Record = { + "claude-code": { + title: "Artificer", + motto: "Measure once. Build the thing that lasts.", + note: "Raises walls faster than anyone and argues about where they should go.", + }, + codex: { + title: "Arcanist", + motto: "Every fault has a name, and a name is a hold.", + note: "Strikes hardest at a wave already in front of it.", + }, + hermes: { + title: "Herald", + motto: "News first. Everything follows news.", + note: "Fast on the ground; steadies whoever is standing nearby.", + }, + openclaw: { + title: "Beastmaster", + motto: "Hold on and do not let go.", + note: "Slow to start, and still swinging long after the others have stopped.", + }, + terminal: { + title: "Footman", + motto: "Someone has to.", + note: "No talents worth the name. Turns up, every time, for anything.", + }, +}; + +/** The three kinds of Unmade, and what each is a rename of. */ +export const FOE_LORE = [ + { + id: "mite", + name: "Glitch-mite", + note: "Small, many, and never the real problem. Something let them in.", + }, + { + id: "crawler", + name: "Null-crawler", + note: "Goes for whatever was left unchecked. Found the gap before you did.", + }, + { + id: "heisenbug", + name: "Heisenbug", + note: "Not there while you are looking at it. Ask anyone who has lost a night to one.", + }, +] as const; + +/** + * A line of flavour for a moment, chosen without repeating the last one. + * + * Takes the previous line rather than keeping state, so the caller owns when + * the line changes and the same moment can be replayed in a test. + */ +export function flavour(lines: readonly string[], previous?: string): string { + const choices = lines.filter((line) => line !== previous); + const pool = choices.length > 0 ? choices : lines; + return pool[Math.floor(Math.random() * pool.length)] ?? ""; +} + +/** Shown while the keep is loading. Short, and about the product underneath. */ +export const BOOT_LINES = [ + "Lighting the Prompt…", + "Counting the garrison…", + "Reading the Marches…", + "Waking the outposts…", + "Checking the walls…", +] as const; diff --git a/app/src/game/main.tsx b/app/src/game/main.tsx new file mode 100644 index 0000000..2c0dada --- /dev/null +++ b/app/src/game/main.tsx @@ -0,0 +1,48 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "../auth/AuthProvider"; +import { RequireAuth } from "../auth/RequireAuth"; +import GameRoute from "./GameRoute"; +import "../styles/tokens.css"; +import "../styles/base.css"; +import "../styles/auth.css"; + +/** + * The game, as a page of its own. + * + * `main.tsx` next door boots the console and reaches the game through a lazy + * route; this boots the game and nothing else. The two exist so the game can be + * *deployed* on its own -- see docs/deploy-game.md -- and the thing that makes + * that worth doing is that a bad game build then cannot take the session list + * down with it. + * + * Same origin as the console, deliberately. Sign-in is a Firebase ID token and + * Firebase persists per origin, so a second hostname would be a second sign-in + * and an API on the far side of CORS. The isolation wanted here is of + * deployments, not of identity. + * + * Only the three stylesheets the game actually needs. The console's terminal, + * vault, audit and people sheets are not loaded, which is most of the reason + * this page is smaller than the route it replaces. + */ +createRoot(document.getElementById("root")!).render( + + + + + + + + } + /> + {/* Anything else under /game is still the game. */} + } /> + + + + , +); diff --git a/app/src/game/pixi/PixiStage.tsx b/app/src/game/pixi/PixiStage.tsx new file mode 100644 index 0000000..cd2bcfc --- /dev/null +++ b/app/src/game/pixi/PixiStage.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from "react"; +import { Application, Container } from "pixi.js"; +/* + * Pixi builds its shader and uniform plumbing with `new Function` by default, + * which this application's Content-Security-Policy forbids -- `script-src + * 'self'` with no `unsafe-eval`, which is the correct policy and one worth + * keeping. Importing this replaces every one of those generated functions with + * a hand-written equivalent; it is slightly slower to set up and identical + * afterwards. + * + * Without it the renderer refuses to start and the game is a black rectangle + * with a console warning. The strict policy on the dev server is what caught + * it here rather than after a deploy. + */ +import "pixi.js/unsafe-eval"; +import { Viewport } from "pixi-viewport"; +import { MAP } from "../world/marches"; +import { TILE_H, TILE_W } from "../world/iso"; +import { widestZoom } from "./scene"; + +export interface Scene { + /** Everything in world space. The viewport moves this, not the camera. */ + world: Container; + /** Advance the scene. `delta` is in milliseconds. */ + tick?: (delta: number) => void; + /** Called when the scene is torn down. */ + destroy?: () => void; +} + +/** + * A Pixi application in a React element, with a map you can move and zoom. + * + * Pixi rather than the hand-rolled canvas that was here before, for three + * reasons that all turned out to matter: it batches thousands of sprites into + * a handful of draw calls, which is what makes a map of this size possible at + * all; it has a scene graph with depth sorting, which an isometric view needs + * and which I was doing by hand and badly; and pixi-viewport gives drag, wheel + * zoom and pinch for free, which is most of what "make it interactive" means. + * + * React owns the element and nothing else. Everything inside the canvas is + * Pixi's, updated by the ticker, and never re-rendered by React — a scene + * graph re-created on every render is the classic way to make a Pixi app + * stutter. + */ +export function PixiStage({ + build, + paused = false, + label, +}: { + /** Builds the scene once the renderer exists. */ + build: (app: Application, viewport: Viewport) => Promise | Scene; + paused?: boolean; + label: string; +}) { + const host = useRef(null); + const [failed, setFailed] = useState(""); + /* Read by the ticker without restarting it. */ + const pausedRef = useRef(paused); + pausedRef.current = paused; + + useEffect(() => { + const element = host.current; + if (!element) return; + + let app: Application | undefined; + let scene: Scene | undefined; + let stopped = false; + + const start = async () => { + const created = new Application(); + await created.init({ + resizeTo: element, + antialias: true, + /* + * The art is drawn at twice size, so on an ordinary display it is + * downsampled and on a dense one it is native. Capped at 2 because + * beyond that the cost is real and the gain is not visible. + */ + resolution: Math.min(2, window.devicePixelRatio || 1), + autoDensity: true, + /* The canopy colour, so any sliver the wood does not reach matches it. */ + background: 0x1f3318, + preference: "webgl", + }); + if (stopped) { + created.destroy(true); + return; + } + app = created; + element.appendChild(created.canvas); + created.canvas.setAttribute("role", "img"); + created.canvas.setAttribute("aria-label", label); + + const viewport = new Viewport({ + events: created.renderer.events, + worldWidth: MAP.width * TILE_W, + worldHeight: MAP.height * TILE_H, + }); + created.stage.addChild(viewport); + + /* + * Drag to move, wheel or pinch to zoom, with the map kept on screen. + * + * The zoom range is chosen from what is legible: below about half, + * buildings are specks and the names have already gone; above two, the + * art is visibly enlarged past the resolution it was drawn at. + * + * `clamp` is what stops the country sliding off into an empty corner. + * It only works because the projection is shifted so that no tile has a + * negative coordinate -- see ORIGIN_X in iso.ts -- which makes the map + * exactly the box pixi-viewport thinks the world is. + */ + viewport + .drag({ mouseButtons: "left" }) + .pinch() + .wheel({ smooth: 4 }) + .decelerate({ friction: 0.92 }) + /* + * The floor is whatever shows the whole country, worked out from the + * map rather than picked, so that making the Marches bigger again + * cannot leave a corner of them unreachable. The ceiling is where the + * art is visibly enlarged past the resolution it was drawn at. + */ + .clampZoom({ minScale: widestZoom(created.screen.width, created.screen.height), maxScale: 2 }) + .clamp({ direction: "all", underflow: "center" }); + + scene = await build(created, viewport); + if (stopped) return; + viewport.addChild(scene.world); + + created.ticker.add((ticker) => { + if (pausedRef.current) return; + scene?.tick?.(ticker.deltaMS); + }); + }; + + void start().catch((error: unknown) => { + /* + * A machine with no WebGL, or a texture that would not load, should say + * so rather than showing an empty rectangle and leaving somebody to + * guess whether the game is broken or simply dark. + */ + setFailed(error instanceof Error ? error.message : "The keep could not be drawn."); + }); + + return () => { + stopped = true; + scene?.destroy?.(); + app?.destroy(true, { children: true }); + }; + /* Once. The scene is Pixi's from here; React does not re-render into it. */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ {failed && ( +

+ {failed} +

+ )} +
+ ); +} diff --git a/app/src/game/pixi/actors.ts b/app/src/game/pixi/actors.ts new file mode 100644 index 0000000..fb4614a --- /dev/null +++ b/app/src/game/pixi/actors.ts @@ -0,0 +1,445 @@ +import { Container, Graphics, Sprite, Texture } from "pixi.js"; +import { atWork, heroPlate, soldierPlate, type Plate } from "./plates"; +import type { Sigils } from "./sigils"; +import { makeUnmade, walkUnmade, type Unmade } from "./unmade"; +import { depthOf, TILE_H, toScreen } from "../world/iso"; +import type { Loaded } from "./scene"; +import type { Actor, Sim } from "../world/sim"; + +/** + * The people on the map, and the things that got in. + * + * Pixi keeps a display object per actor for as long as that actor exists, + * rather than rebuilding the scene each frame. That is the whole reason for + * using a scene graph: moving a sprite is setting two numbers, where drawing + * it again is uploading geometry. + */ + +import { UNIT_FOR } from "./units"; + +/** How much bigger than drawn a soldier is. See `make`. */ +const FIGURE = 1.85; + +/** + * How much bigger again a hero is. + * + * Two thirds again on top of a soldier, and both have grown. A hero is a person + * and everything around them is their work; drawn at the same size they were + * indistinguishable from their own retinue, which is the one thing about this + * map that has to be legible at a glance. + * + * Everybody is larger than the pack intends. Kenney's units are scaled to stand + * beside Kenney's buildings, which is correct and useless here: the country is + * a hundred and twenty-eight tiles across, and a figure sized for a courtyard + * is a speck on it. + */ +const HERO = 3.1; + +/** A small deterministic offset, so two bugs do not step in lockstep. */ +function hashOf(id: string): number { + let value = 0; + for (let index = 0; index < id.length; index += 1) { + value = (Math.imul(value, 31) + id.charCodeAt(index)) | 0; + } + return (Math.abs(value) % 100) / 16; +} + +/** + * A deterministic vertical stagger, so boards do not stack. + * + * Wider than it was, because a camp gathers a whole company into a few tiles + * and three steps of thirteen pixels is not enough separation for a dozen + * boards. Deterministic so a name does not hop when the list is re-ordered. + */ +/** + * How far a soldier's name is lifted above its head, so two of them standing + * together do not write over each other. + * + * Six bands rather than five, and thirty-six apart rather than twenty-two. A + * plate is about thirty pixels tall, so at twenty-two the bands overlapped each + * other by a third before any two figures had even met -- the stagger was + * shuffling the collision around rather than preventing it. Thirty-six clears a + * plate outright, and six bands means a camp has to hold seven sessions before + * two can land on the same line. + * + * Deterministic from the id, so a session's name does not hop to a different + * height every time the roster is polled. + */ +function lift(id: string): number { + let value = 0; + for (let index = 0; index < id.length; index += 1) { + value = (Math.imul(value, 31) + id.charCodeAt(index)) | 0; + } + return (Math.abs(value) % 6) * 36; +} + +/** + * And a hero's name sits above every band a soldier can reach. + * + * A hero used to be pinned at zero, which is also the lowest band a soldier can + * draw at -- so the one board on a camp that has to be findable was the one + * most likely to have a session's name written across it. Their own retinue is + * what they collide with, so the fix is to put them over the top of it. + */ +const HERO_LIFT = 6 * 36 + 24; + +interface Piece { + root: Container; + /** + * The figure itself: a sprite for anybody human, a drawn creature for the + * Unmade. Kenney's pack has no insects, and six legs settles what a thing is + * in a way no amount of tinting could. + */ + sprite: Sprite | undefined; + bug: Unmade | undefined; + /** Whichever of the two is on screen, for facing and for the lunge. */ + figure: Container; + shadow: Graphics; + bar: Graphics; + plate?: Plate; + /** How far above the feet this figure's plate hangs. See `make`. */ + headroom: number; + /** + * The figure's drawn extent, for picking: half its width, and how far it + * rises above the point it stands on. Kept rather than measured, because + * `getBounds` walks the display list and this is asked per actor per click. + */ + halfWidth: number; + rise: number; + lastHp: number; +} + +export class ActorLayer { + private readonly pieces = new Map(); + + /** + * Two colours, because the shop sells two things. + * + * `skinTint` is what the player's own hero is drawn in; `liveryTint` washes + * over the soldiers that stand for their sessions, so a map with several + * companies on it reads as several companies. Neither ever touches anybody + * else's figures. + */ + private skinTint = 0xffffff; + private liveryTint = 0xffffff; + + constructor( + private readonly art: Loaded, + private readonly parent: Container, + /* Name boards go here, above the world, so nothing can stand in front. */ + private readonly labels: Container, + /** Each harness's own mark, for the badge on a soldier's plate. */ + private readonly sigils: Sigils, + ) {} + + /** The last zoom the plates were sized for; see `zoomed`. */ + private plateScale = 1; + + /** Called when a skin or a livery is bought or changed in the shop. */ + wear(skin: number, livery: number): void { + this.skinTint = skin; + this.liveryTint = livery; + } + + /** + * Keeps the name boards a readable size, and takes them away when they stop + * being names and start being clutter. + * + * A board that scales with the map is illegible zoomed out and enormous + * zoomed in; one that never scales covers the map at the far end. So it + * scales against the zoom, within bounds, and below the point where a name + * would be smaller than the floor this game holds itself to, there is no + * name -- which is also the zoom at which you are looking at the country + * rather than at anybody in it. + */ + zoomed(scale: number): void { + /* + * Against the zoom, and allowed to grow further than it shrinks. + * + * Zoomed out is exactly when a plate matters most -- it is how you find + * your own company among several at the far end of a large map -- and it is + * also when the figure under it is smallest. So the far end of the range is + * generous. Close up it settles to about its drawn size, because at that + * zoom the figure is doing the identifying. + */ + this.plateScale = Math.min(2.6, Math.max(0.75, 1 / scale)); + /* + * Every board is shown at every zoom the wheel allows, heroes and soldiers + * alike. A dozen of them over one camp do interleave; the answer to that is + * the stagger in `lift` and the scaling above, not taking the names away at + * exactly the distance you need them. + */ + for (const piece of this.pieces.values()) { + if (!piece.plate) continue; + piece.plate.root.scale.set(this.plateScale); + } + } + + private make(actor: Actor, yourUid: string | undefined): Piece { + const root = new Container(); + const size = actor.role === "hero" ? HERO : FIGURE; + + const shadow = new Graphics(); + shadow + .ellipse(0, 0, (16 * size) / FIGURE, (7 * size) / FIGURE) + .fill({ color: 0x1a1008, alpha: 0.32 }); + root.addChild(shadow); + + if (actor.side === "unmade") { + const bug = makeUnmade(actor.kind); + bug.root.position.set(0, TILE_H * 0.25); + root.addChild(bug.root); + + /* Health over the creature, shown only once something is off it. */ + const hurt = new Graphics(); + hurt.position.set(0, -24); + hurt.visible = false; + root.addChild(hurt); + + this.parent.addChild(root); + return { + root, + sprite: undefined, + bug, + figure: bug.root, + shadow, + bar: hurt, + plate: undefined, + headroom: 0, + halfWidth: 18, + rise: 24, + lastHp: actor.hp, + }; + } + + const texture = this.art.frame(UNIT_FOR[actor.kind] ?? UNIT_FOR.terminal); + const sprite = new Sprite(texture); + sprite.anchor.set(0.5, 1); + /* + * Drawn larger than the pack intends. Kenney's units are scaled to sit + * beside Kenney's buildings, and at that ratio a wright on this map was a + * speck next to a church -- correct, and useless, because the wrights are + * the thing the game is about and the buildings are where they stand. + */ + sprite.scale.set(size); + sprite.position.set(0, TILE_H * 0.25); + root.addChild(sprite); + + /* + * Health over the sprite, shown only once something has been taken off it, + * and only for figures whose plate does not already carry one. + * + * A hero's plate has its own health bar, so drawing this as well gave them + * two -- at different widths, in slightly different places, showing the + * same number. + */ + const bar = new Graphics(); + bar.position.set(0, -sprite.height - 4); + bar.visible = false; + if (actor.role !== "hero") root.addChild(bar); + + /* + * Heroes and soldiers carry a name; the watch and the Unmade do not. + * Scenery with a label on it is a label that stands for nothing, and a map + * covered in those is a map nobody reads. + */ + /* + * The board above the head, which is a different thing for each of them. + * + * A hero gets a shield with their initials, their name, a health bar and a + * bar for how much of their company is at work. A soldier gets its class + * and the session it is. The watch and the Unmade get nothing: a name over + * scenery is a name that stands for nothing, and a map of those is a map + * nobody reads. + */ + let plate: Plate | undefined; + if (actor.role === "hero" || actor.role === "soldier") { + plate = + actor.role === "hero" + ? heroPlate(actor, actor.heroUid !== undefined && actor.heroUid === yourUid) + : soldierPlate(actor, this.sigils); + /* + * Hung by its bottom edge, so the board grows upwards out of the head + * rather than downwards into it. A container positioned by its top would + * push the board further down the taller it got, which would make a + * hero -- whose board is the tallest -- the one whose name covered them. + */ + plate.root.pivot.set(0, plate.height); + plate.root.scale.set(this.plateScale); + /* + * Staggered by the id, so two figures standing together do not lay their + * boards on top of one another. Deterministic, so a name does not hop to + * a different height when the list is re-ordered. + */ + plate.root.y = lift(actor.id); + this.labels.addChild(plate.root); + } + + this.parent.addChild(root); + /* + * Measured from the sprite rather than fixed, so a hero at twice the size + * gets twice the clearance and nobody's name sits on their own head. + */ + const headroom = texture.height * size + 12; + return { + root, + sprite, + bug: undefined, + figure: sprite, + shadow, + bar, + plate, + headroom, + /* A little wider than drawn, because a click aims at a body, not a pixel. */ + halfWidth: (texture.width * size) / 2 + 4, + rise: texture.height * size, + lastHp: actor.hp, + }; + } + + /** + * Whose figure is under a point on the map, if anybody's. + * + * Tested against the *drawn* body rather than against the tile the figure + * stands on. Picking used to measure tile distance from the feet, which meant + * only the lower body answered a click: a figure rises a hundred-odd pixels + * out of the tile it occupies, and in tile space its own head is several + * tiles away from it. + * + * The topmost match wins, which for an isometric map means the one drawn + * last -- the figure actually on top where they overlap. + */ + hit(x: number, y: number, pickable: (id: string) => boolean): string | undefined { + let found: string | undefined; + let bestDepth = -Infinity; + + for (const [id, piece] of this.pieces) { + if (!pickable(id)) continue; + const foot = piece.root.y + TILE_H * 0.25; + if (x < piece.root.x - piece.halfWidth || x > piece.root.x + piece.halfWidth) continue; + if (y > foot + 6 || y < foot - piece.rise) continue; + if (piece.root.zIndex <= bestDepth) continue; + bestDepth = piece.root.zIndex; + found = id; + } + + return found; + } + + /** Brings the display in line with the simulation. */ + sync(sim: Sim, selectedId?: string): void { + const seen = new Set(); + + for (const actor of sim.actors) { + seen.add(actor.id); + let piece = this.pieces.get(actor.id); + if (!piece) { + piece = this.make(actor, sim.youUid); + this.pieces.set(actor.id, piece); + } + + const { x, y } = toScreen(actor.x, actor.y); + piece.root.position.set(x, y); + piece.root.zIndex = depthOf(actor.x, actor.y, 10); + if (piece.plate) { + /* + * Heroes are not staggered. There is one per camp, so there is nothing + * for them to collide with, and lifting them adds up to another eighty + * pixels of empty sky between a person and their own name. + */ + const stagger = actor.role === "hero" ? HERO_LIFT : lift(actor.id); + piece.plate.root.position.set(x, y - piece.headroom - stagger); + /* + * The same depth as the figure it belongs to, so a name in front + * covers a name behind. A hero's plate is lifted above their own + * retinue's: it is the one board on a camp that has to be findable. + */ + piece.plate.root.zIndex = depthOf(actor.x, actor.y, actor.role === "hero" ? 60 : 10); + } + + /* Facing, as a mirror rather than a second drawing. */ + if (piece.sprite) { + const size = actor.role === "hero" ? HERO : FIGURE; + piece.sprite.scale.x = actor.facing === 1 ? size : -size; + } else { + piece.figure.scale.x = actor.facing === 1 ? 1 : -1; + } + + /* + * A blow is a lunge rather than a different drawing. Kenney's units have + * no attack frame, and a small forward shove reads as a strike far + * better than a static figure with a number popping off it. + */ + const lunging = actor.action === "attack"; + piece.figure.position.x = lunging ? actor.facing * 5 : 0; + piece.figure.position.y = TILE_H * 0.25 + (actor.moving ? Math.sin(sim.clock / 3) * 1.5 : 0); + + /* Six legs, walking in alternating tripods, which is how insects walk. */ + if (piece.bug) walkUnmade(piece.bug, sim.clock / 2.6 + hashOf(actor.id), actor.moving); + + /* + * White when struck. Never colour alone: a number flies off as well. + * Otherwise the Unmade are cold, a real session wears whatever skin has + * been bought, and the garrison's own soldiers are as drawn. + */ + /* + * White when struck. Never colour alone: a number flies off as well. The + * Unmade carry their own colours in how they are drawn, so all they take + * from this is the flinch. + */ + if (piece.sprite) { + piece.sprite.tint = actor.hurt > 0 + ? 0xffffff + : actor.heroUid && actor.heroUid === sim.youUid + ? actor.role === "hero" + ? this.skinTint + : this.liveryTint + : 0xffffff; + } + piece.figure.alpha = actor.hurt > 0 ? 0.6 : 1; + + if (actor.hp !== piece.lastHp) { + piece.lastHp = actor.hp; + const hurt = actor.hp < actor.maxHp && actor.role !== "hero"; + piece.bar.visible = hurt; + if (hurt) { + piece.bar.clear(); + piece.bar.rect(-13, 0, 26, 4).fill({ color: 0x1a1008, alpha: 0.85 }); + piece.bar + .rect(-12, 1, 24 * Math.max(0, actor.hp / actor.maxHp), 2) + .fill({ color: actor.side === "unmade" ? 0x48d6c0 : 0x8fd05a }); + } + } + + if (piece.plate) { + const chosen = actor.id === selectedId; + piece.plate.root.tint = chosen ? 0xffd27a : 0xffffff; + piece.shadow.tint = chosen ? 0xf0a03c : 0xffffff; + piece.shadow.alpha = chosen ? 0.9 : 1; + /* A hero's two bars, which only redraw when a number actually moves. */ + piece.plate.bars?.set(actor.hp / actor.maxHp, atWork(actor, sim.actors)); + } + } + + /* Anybody who has left the simulation leaves the scene with them. */ + for (const [id, piece] of this.pieces) { + if (seen.has(id)) continue; + piece.root.destroy({ children: true }); + piece.plate?.bars?.destroy(); + piece.plate?.root.destroy({ children: true }); + this.pieces.delete(id); + } + } + + destroy(): void { + for (const piece of this.pieces.values()) { + piece.root.destroy({ children: true }); + piece.plate?.bars?.destroy(); + piece.plate?.root.destroy({ children: true }); + } + this.pieces.clear(); + } +} + +export { UNIT_FOR }; +export type { Texture }; diff --git a/app/src/game/pixi/ambience.ts b/app/src/game/pixi/ambience.ts new file mode 100644 index 0000000..a1a6dc8 --- /dev/null +++ b/app/src/game/pixi/ambience.ts @@ -0,0 +1,421 @@ +import { Assets, Container, Graphics, Sprite, Texture } from "pixi.js"; +import { depthOf, toScreen } from "../world/iso"; +import { GARRISONS, groundTiles, MAP, type Ground } from "../world/marches"; +import type { Actor, Effect, Mark, Sim } from "../world/sim"; + +/** + * Everything that is there to be looked at rather than played. + * + * Birds, smoke, dust off a walker's heels, the flash where a blow lands and the + * numbers that come off it. None of it is a mechanic and none of it can be + * interacted with. It is here because a map where the only thing moving is the + * thing you are watching reads as a diagram of a place rather than a place. + * + * All of it answers to `still()`. Drifting particles and things that flap are + * named in the game-ui-design rules as motion-sickness triggers, and until this + * existed the reduced-motion setting reached the interface and stopped at the + * edge of the canvas -- so somebody who had asked for less motion got a still + * HUD over a map full of it, which is the setting doing nothing where it + * matters most. + */ + +/** Particle textures, vendored from Kenney's CC0 pack. See the notices file. */ +const FX_TEXTURES = ["fx-smoke_01", "fx-star_04", "fx-flare_01", "fx-spark_04", "fx-magic_05"]; + +export async function loadEffects(): Promise> { + const loaded: Record = {}; + await Promise.all( + FX_TEXTURES.map(async (name) => { + loaded[name] = await Assets.load(`/game/${name}.png`); + }), + ); + return loaded; +} + +/* ---- birds --------------------------------------------------------------- */ + +interface Bird { + sprite: Graphics; + x: number; + y: number; + vx: number; + vy: number; + phase: number; +} + +/** + * Birds, drawn rather than sprited. + * + * A bird at this distance is two strokes that open and close. Kenney has no + * bird and a five-pixel drawing of one would be a smudge; two lines that flap + * read as a bird from any distance and cost a handful of vertices. + * + * They fly above everything, cast no shadow, and are pushed to a depth beyond + * anything on the ground, which is what makes them read as being in the air + * rather than walking about on it. + */ +export class Birds { + private readonly birds: Bird[] = []; + private readonly layer = new Container(); + private moving = true; + + /** Off, and out of the sky: a frozen bird is stranger than no bird. */ + still(stop: boolean): void { + this.moving = !stop; + this.layer.visible = !stop; + } + + constructor(parent: Container, count = 14) { + parent.addChild(this.layer); + this.layer.zIndex = depthOf(MAP.width, MAP.height, 9_000); + + for (let index = 0; index < count; index += 1) { + const sprite = new Graphics(); + this.layer.addChild(sprite); + /* Spread over the whole map, drifting on roughly the same wind. */ + this.birds.push({ + sprite, + x: Math.random() * MAP.width, + y: Math.random() * MAP.height, + vx: 0.35 + Math.random() * 0.5, + vy: -0.12 + Math.random() * 0.24, + phase: Math.random() * Math.PI * 2, + }); + } + } + + tick(deltaMs: number): void { + if (!this.moving) return; + const seconds = deltaMs / 1000; + for (const bird of this.birds) { + bird.x += bird.vx * seconds; + bird.y += bird.vy * seconds; + bird.phase += seconds * 9; + + /* Off one edge and back on the other, so the sky is never empty. */ + if (bird.x > MAP.width + 4) bird.x = -4; + if (bird.y < -4) bird.y = MAP.height + 4; + if (bird.y > MAP.height + 4) bird.y = -4; + + const { x, y } = toScreen(bird.x, bird.y); + /* Height above the ground, which is what the projection cannot give us. */ + const lift = 54 + Math.sin(bird.phase / 3) * 6; + const flap = Math.sin(bird.phase) * 4; + + bird.sprite.clear(); + bird.sprite + .moveTo(x - 6, y - lift) + .lineTo(x - 2, y - lift - flap) + .lineTo(x + 2, y - lift) + .stroke({ color: 0x2b1d16, width: 2, alpha: 0.75 }); + } + } + + destroy(): void { + this.layer.destroy({ children: true }); + } +} + +/* ---- chimney smoke ------------------------------------------------------- */ + +/** + * Smoke over the holdings, so the map looks inhabited from a distance. + * + * One drifting column per garrison, made of a handful of sprites recycled + * rather than created and destroyed — a particle system that allocates is a + * particle system that stutters. + */ +export class Smoke { + private readonly puffs: { sprite: Sprite; life: number; x: number; y: number; from: number }[] = []; + private readonly layer = new Container(); + private moving = true; + + /** + * Held rather than hidden. + * + * Smoke standing still over a chimney still says the holding is lived in, + * which is the whole job; it is the drift that is the problem. So the column + * stops where it is instead of disappearing. + */ + still(stop: boolean): void { + this.moving = !stop; + } + + constructor(parent: Container, texture: Texture, perGarrison = 5) { + parent.addChild(this.layer); + this.layer.zIndex = depthOf(MAP.width, MAP.height, 8_000); + + GARRISONS.forEach((garrison, index) => { + for (let puff = 0; puff < perGarrison; puff += 1) { + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + sprite.alpha = 0; + this.layer.addChild(sprite); + this.puffs.push({ + sprite, + /* Staggered, so a chimney does not cough all its smoke at once. */ + life: (puff / perGarrison) * 100, + x: garrison.x, + y: garrison.y - 1, + from: index, + }); + } + }); + } + + tick(deltaMs: number): void { + if (!this.moving) return; + const step = deltaMs / 1000; + for (const puff of this.puffs) { + puff.life += step * 22; + if (puff.life > 100) puff.life = 0; + + const progress = puff.life / 100; + const { x, y } = toScreen(puff.x, puff.y); + puff.sprite.position.set(x + progress * 26, y - 40 - progress * 46); + puff.sprite.scale.set(0.12 + progress * 0.3); + puff.sprite.alpha = Math.max(0, 0.38 * (1 - progress)); + puff.sprite.tint = 0xd9c9b0; + } + } + + destroy(): void { + this.layer.destroy({ children: true }); + } +} + +/* ---- dust ---------------------------------------------------------------- */ + +/** What the ground gives up when it is walked on. Water gives up nothing. */ +const DUST_TINT: Record = { + /* + * Lighter than the ground each comes off, not the same colour as it. Dust + * matching the road exactly is dust you cannot see; what makes it read is + * that it is the ground caught in the light. + */ + dirt: 0xe3c79b, + sand: 0xf4e8c4, + stone: 0xd8d2c8, + grass: 0xcfdbb0, + water: 0x000000, +}; + +interface Puff { + sprite: Sprite; + /** Counts down. At or below zero the puff is free to be used again. */ + life: number; + maxLife: number; + x: number; + y: number; + driftX: number; + driftY: number; +} + +/** + * Dust off the heels of anything walking. + * + * This is the cheapest thing in the game that most changes how it feels. A + * figure crossing open ground with nothing coming off it is a sprite being + * moved; the same figure trailing a little dust is somebody walking, and the + * difference is about forty lines. + * + * The pool is fixed and allocated once. A fight with thirty walkers in it can + * ask for a puff several times a second, and a particle system that allocates + * is a particle system that stutters -- when the pool is empty the request is + * simply dropped, which nobody can see and which cannot cost anything. + * + * Each puff is tinted by the ground under the foot that raised it, so crossing + * from a road onto grass changes the colour of what comes up. That is a detail + * almost nobody will notice, and the reason to do it anyway is that the ones + * nobody notices are what the noticeable ones are made of. + */ +export class Dust { + private readonly puffs: Puff[] = []; + private readonly cooldown = new Map(); + private moving = true; + + constructor( + private readonly parent: Container, + texture: Texture, + size = 80, + ) { + for (let index = 0; index < size; index += 1) { + const sprite = new Sprite(texture); + sprite.anchor.set(0.5, 0.5); + sprite.alpha = 0; + sprite.visible = false; + parent.addChild(sprite); + this.puffs.push({ sprite, life: 0, maxLife: 1, x: 0, y: 0, driftX: 0, driftY: 0 }); + } + } + + /** Off entirely. Dust is drift, and drift is the thing being asked about. */ + still(stop: boolean): void { + this.moving = !stop; + if (!stop) return; + for (const puff of this.puffs) { + puff.life = 0; + puff.sprite.visible = false; + } + } + + tick(sim: Sim, deltaMs: number): void { + const seconds = Math.min(0.1, deltaMs / 1000); + + for (const puff of this.puffs) { + if (puff.life <= 0) continue; + puff.life -= seconds; + if (puff.life <= 0) { + puff.sprite.visible = false; + continue; + } + puff.x += puff.driftX * seconds; + puff.y += puff.driftY * seconds; + + const progress = 1 - puff.life / puff.maxLife; + const { x, y } = toScreen(puff.x, puff.y); + puff.sprite.position.set(x, y + 4 - progress * 10); + puff.sprite.scale.set(0.08 + progress * 0.2); + puff.sprite.alpha = 0.5 * (1 - progress); + /* Under the feet that raised it, and over the ground it came off. */ + puff.sprite.zIndex = depthOf(puff.x, puff.y, 5); + } + + if (!this.moving) return; + + for (const actor of sim.actors) { + const left = (this.cooldown.get(actor.id) ?? 0) - seconds; + if (!actor.moving) { + /* Standing still: hold the timer at zero so the next step raises dust. */ + this.cooldown.set(actor.id, 0); + continue; + } + if (left > 0) { + this.cooldown.set(actor.id, left); + continue; + } + this.cooldown.set(actor.id, 0.16 + Math.random() * 0.1); + this.raise(actor); + } + } + + private raise(actor: Actor): void { + const tiles = groundTiles(); + const tx = Math.round(actor.x); + const ty = Math.round(actor.y); + if (tx < 0 || ty < 0 || tx >= MAP.width || ty >= MAP.height) return; + const ground = tiles[ty * MAP.width + tx]; + if (ground === "water") return; + + const puff = this.puffs.find((candidate) => candidate.life <= 0); + /* Nothing free: drop it. A missing puff is invisible; a stutter is not. */ + if (!puff) return; + + puff.maxLife = 0.5 + Math.random() * 0.3; + puff.life = puff.maxLife; + puff.x = actor.x; + puff.y = actor.y; + /* Backwards from the way they are facing, and drifting apart as it rises. */ + puff.driftX = -actor.facing * (0.25 + Math.random() * 0.3); + puff.driftY = (Math.random() - 0.5) * 0.3; + + puff.sprite.visible = true; + puff.sprite.tint = DUST_TINT[ground]; + puff.sprite.alpha = 0.42; + puff.sprite.rotation = Math.random() * Math.PI; + } + + destroy(): void { + for (const puff of this.puffs) puff.sprite.destroy(); + this.puffs.length = 0; + this.cooldown.clear(); + this.parent.sortDirty = true; + } +} + +/* ---- blows, and the numbers that come off them --------------------------- */ + +/** + * The flash where something was struck, and the number that rises off it. + * + * Both are pooled: a fight can produce a dozen a second, and creating a Text + * object per hit is the fastest way to make a Pixi scene stutter, because each + * one uploads a new texture. + */ +export class Blows { + private readonly layer = new Container(); + private readonly flashes = new Map(); + private readonly numbers = new Map(); + + constructor( + parent: Container, + private readonly textures: Record, + private readonly makeNumber: (text: string, kind: Mark["kind"]) => Container, + ) { + parent.addChild(this.layer); + this.layer.zIndex = depthOf(MAP.width, MAP.height, 7_000); + } + + sync(sim: Sim): void { + const liveEffects = new Set(); + for (const effect of sim.effects) { + liveEffects.add(effect.id); + let sprite = this.flashes.get(effect.id); + if (!sprite) { + sprite = new Sprite(this.pick(effect)); + sprite.anchor.set(0.5); + sprite.blendMode = "add"; + this.layer.addChild(sprite); + this.flashes.set(effect.id, sprite); + } + const progress = 1 - effect.life / effect.maxLife; + const { x, y } = toScreen(effect.x, effect.y); + sprite.position.set(x, y - 18); + sprite.scale.set(0.16 + progress * 0.4); + sprite.alpha = 1 - progress; + sprite.rotation = progress * 1.2; + } + for (const [id, sprite] of this.flashes) { + if (liveEffects.has(id)) continue; + sprite.destroy(); + this.flashes.delete(id); + } + + const liveMarks = new Set(); + for (const mark of sim.marks) { + liveMarks.add(mark.id); + let node = this.numbers.get(mark.id); + if (!node) { + node = this.makeNumber(mark.text, mark.kind); + this.layer.addChild(node); + this.numbers.set(mark.id, node); + } + const progress = 1 - mark.life / mark.maxLife; + const { x, y } = toScreen(mark.x, mark.y); + node.position.set(x, y - 30 - progress * 34); + node.alpha = Math.min(1, (1 - progress) * 2.2); + } + for (const [id, node] of this.numbers) { + if (liveMarks.has(id)) continue; + node.destroy({ children: true }); + this.numbers.delete(id); + } + } + + private pick(effect: Effect): Texture { + switch (effect.kind) { + case "cast": + return this.textures["fx-magic_05"] ?? this.textures["fx-star_04"]; + case "fell": + return this.textures["fx-flare_01"]; + case "build": + return this.textures["fx-spark_04"]; + default: + return this.textures["fx-star_04"]; + } + } + + destroy(): void { + this.layer.destroy({ children: true }); + } +} diff --git a/app/src/game/pixi/banners.ts b/app/src/game/pixi/banners.ts new file mode 100644 index 0000000..94887de --- /dev/null +++ b/app/src/game/pixi/banners.ts @@ -0,0 +1,152 @@ +import { Container, Graphics } from "pixi.js"; +import { BANNER_RADIUS } from "../world/banners"; +import { TILE_H, TILE_W, toScreen } from "../world/iso"; +import type { Sim } from "../world/sim"; + +/** + * How far an Unmade reaches, drawn small and red. + * + * Tiny on purpose. A hero's ring says "this is a company and it is mine"; this + * one says "this thing bites, and only this close". At the same weight the map + * would be a field of overlapping circles and neither would mean anything. + */ +const FOE_RADIUS = 1.6; + +/** + * The mark left where the ground was clicked. + * + * It exists because an order is otherwise invisible for the second it takes the + * hero to turn round: you press, nothing appears to happen, and you press + * again. A ring that opens and fades is the smallest thing that answers the + * press immediately, and it says *where* rather than merely *yes*. + */ +export class OrderMark { + private readonly shape = new Graphics(); + private at = { x: 0, y: 0 }; + private life = 0; + + constructor(parent: Container) { + parent.addChild(this.shape); + } + + /** Tile coordinates, because that is what a click is turned into. */ + show(x: number, y: number): void { + this.at = { x, y }; + this.life = 1; + } + + tick(deltaMs: number): void { + if (this.life <= 0) { + this.shape.visible = false; + return; + } + /* Six tenths of a second: long enough to see, short enough not to litter. */ + this.life = Math.max(0, this.life - deltaMs / 600); + + const open = 1 - this.life; + const { x, y } = toScreen(this.at.x, this.at.y); + const across = TILE_W * (0.35 + open * 0.75); + + this.shape.visible = true; + this.shape.clear(); + this.shape + .ellipse(x, y, across, across * (TILE_H / TILE_W)) + .stroke({ color: 0xf0d9a8, width: 3, alpha: this.life * 0.9 }); + /* A second, tighter ring a beat behind, so it reads as a pulse. */ + this.shape + .ellipse(x, y, across * 0.55, across * 0.55 * (TILE_H / TILE_W)) + .stroke({ color: 0xe8b44a, width: 2, alpha: this.life * 0.6 }); + } + + destroy(): void { + this.shape.destroy(); + } +} + +/** + * The ground each hero commands, washed in their own colour. + * + * One ellipse per hero, following them. It exists so that a company reads as an + * area rather than as a crowd, and so that your own is the one you can find + * without reading anything -- which was the actual complaint behind "I cannot + * control my hero". The controls worked; the map was unreadable, and from the + * other side of the screen those are the same bug. + * + * Drawn on the ground plane, so it is an ellipse at the tile ratio rather than + * a circle. A circle would read as a bubble floating over the field; a squashed + * one reads as light on the grass. + * + * Redrawn only when somebody has moved far enough to matter. A hero walks at + * under three tiles a second and this is nine tiles across, so redrawing it + * every frame is redrawing the same shape sixty times to move it a pixel. + */ +export class Banners { + private readonly layer = new Container(); + private readonly rings = new Map(); + + constructor(parent: Container) { + parent.addChild(this.layer); + } + + sync(sim: Sim): void { + const seen = new Set(); + + for (const actor of sim.actors) { + const foe = actor.side === "unmade"; + if (!foe && (actor.role !== "hero" || !actor.heroUid)) continue; + seen.add(actor.id); + + let ring = this.rings.get(actor.id); + if (!ring) { + ring = { shape: new Graphics(), x: Number.NaN, y: Number.NaN }; + this.layer.addChild(ring.shape); + this.rings.set(actor.id, ring); + } + + /* A tenth of a tile: below what anybody can see it move by. */ + if (Math.abs(ring.x - actor.x) < 0.1 && Math.abs(ring.y - actor.y) < 0.1) continue; + + ring.x = actor.x; + ring.y = actor.y; + + const { x, y } = toScreen(actor.x, actor.y); + ring.shape.clear(); + + if (foe) { + /* + * Small, but not faint. The first version was nearly transparent on the + * theory that a crowd of them would be noise; what it actually did was + * make the one thing on the map that can hurt you the hardest thing on + * it to see. + */ + ring.shape + .ellipse(x, y, (FOE_RADIUS * TILE_W) / 2, (FOE_RADIUS * TILE_H) / 2) + .fill({ color: 0xd4553f, alpha: 0.42 }) + .stroke({ color: 0xff6a4d, width: 2.5, alpha: 1 }); + continue; + } + + const colour = sim.banners.get(actor.heroUid ?? "") ?? 0xffffff; + const yours = actor.heroUid === sim.youUid; + ring.shape + .ellipse(x, y, (BANNER_RADIUS * TILE_W) / 2, (BANNER_RADIUS * TILE_H) / 2) + /* + * Your own company is washed a little stronger and rimmed a little + * brighter. Never colour alone: yours is also the one the view opens + * on, the one the HUD names, and the only one that answers a click. + */ + .fill({ color: colour, alpha: yours ? 0.22 : 0.13 }) + .stroke({ color: colour, width: yours ? 3 : 2, alpha: yours ? 0.85 : 0.5 }); + } + + for (const [id, ring] of this.rings) { + if (seen.has(id)) continue; + ring.shape.destroy(); + this.rings.delete(id); + } + } + + destroy(): void { + this.layer.destroy({ children: true }); + } +} diff --git a/app/src/game/pixi/border.ts b/app/src/game/pixi/border.ts new file mode 100644 index 0000000..b4a9e6e --- /dev/null +++ b/app/src/game/pixi/border.ts @@ -0,0 +1,196 @@ +import { Container, Graphics, Sprite } from "pixi.js"; +import { depthAtScreenY } from "../world/iso"; +import type { Application } from "pixi.js"; +import { borderTrees, canopyBlobs, canopyBounds, CANOPY_TONES } from "../world/border"; +import type { Kingdom, Loaded } from "./scene"; + +/** + * The wood that closes the Marches in, as one layer under everything. + * + * It is not depth sorted and never will be. Nothing can walk out here -- the + * camera clamp keeps the view on the country and the simulation keeps everybody + * inside it -- so there is nothing for these trees to be in front of or behind. + * + * The far wood is baked into a single low-resolution texture at start-up and + * drawn as one sprite for the rest of the session. + * + * That is not premature. The first version scattered tree sprites over the + * whole canopy: four and a half thousand of them, and the frame rate went from + * fifty-six to eighteen. Cutting it back to a thicket at the edge and drawing + * the rest as blobs recovered thirty-nine, and measuring the pieces separately + * showed the remaining cost was not object count at all -- it was overdraw. Two + * shapes the size of the whole canopy, plus thousands of overlapping ellipses, + * all of it behind a map that then painted over the middle of it. + * + * Baking removes every bit of that. The texture is deliberately coarse: it is + * distant canopy, seen at the only zoom where any of it is visible, and blur is + * what it should look like anyway. + */ + +/** + * How much smaller the baked canopy is than the ground it covers. + * + * A twelfth. The canopy is over thirteen thousand units across, so a full-size + * bake would be a texture no machine should be asked for; at this scale it is + * about eleven hundred pixels wide, and it looks like a wood. + */ +const BAKE = 1 / 12; + +export interface Border { + /** The far wood, which goes under the map. */ + canopy: Container; +} + +export function buildBorder( + app: Application, + art: Loaded, + kingdom: Kingdom, + /* + * The sorted layer the edge trees join, rather than a container of their own. + * + * They used to be one sibling drawn before every building, which meant a tree + * at the near corner of the diamond -- the corner the camera looks at, so the + * nearest thing on the map -- was painted behind a building at the far one. + * Depth on this map is `x + y`, and it can only decide anything between + * siblings, so everything standing on the ground has to be one. + */ + into: Container, +): Border { + const layer = new Container(); + const bounds = canopyBounds(); + + /* + * Everything that never changes, drawn once into one shape. + * + * The rectangle is deliberately larger than the world: at the widest zoom the + * country is shorter than the window and the viewport centres it, so what is + * beyond the country is what fills the bands above and below. Stopping at the + * world bounds would put the void back one step further out, which is no + * better for being further away. + */ + const drawn = new Graphics(); + drawn.rect(bounds.x, bounds.y, bounds.width, bounds.height).fill({ color: 0x1f3318 }); + + const blobs = canopyBlobs(); + /* + * Four greens, spread wide enough to see. + * + * The first set were within a few points of each other and of the fill behind + * them, which made the far wood one flat colour at every zoom -- exactly the + * flat field the border exists to replace, in a different green. Canopy is + * read by its mottling, so the mottling has to be visible. + */ + /* + * Four greens, spread wide enough to see. + * + * The first set were within a few points of each other and of the fill behind + * them, which made the far wood one flat colour -- exactly the flat field the + * border exists to replace, in a different green. Canopy is read by its + * mottling, so the mottling has to be visible. + */ + const TONES = [0x395d2a, 0x2c4921, 0x203819, 0x182b12]; + for (let tone = 0; tone < CANOPY_TONES; tone += 1) { + let drew = false; + for (const blob of blobs) { + if (blob.tone !== tone) continue; + drawn.ellipse(blob.x, blob.y, blob.radius, blob.radius * 0.62); + drew = true; + } + if (drew) drawn.fill({ color: TONES[tone] }); + } + + /* + * There is deliberately no darker ring cut around the country here. + * + * There was one, drawn as this rectangle with the country's diamond `cut()` + * out of it, and it quietly destroyed everything above: the whole far wood + * rendered as one flat colour, the last one filled. Every tone was being + * built correctly -- a count of the blobs showed all four evenly spread -- + * and none of them survived into the picture. + * + * Whatever the exact mechanism inside the path builder, the lesson is the + * cheap one: a boolean path operation in a context that already holds + * thousands of filled subpaths is not a local edit, and this context holds + * about fourteen thousand. The recession it was drawing is done by the tone + * gradient instead, which costs nothing and cannot reach backwards. + */ + + const baked = app.renderer.generateTexture({ target: drawn, resolution: BAKE }); + const canopy = new Sprite(baked); + canopy.position.set(bounds.x, bounds.y); + canopy.width = bounds.width; + canopy.height = bounds.height; + layer.addChild(canopy); + /* The shape has been photographed; keeping it would be keeping it twice. */ + drawn.destroy(); + + /* + * The thicket, as real sprites: this is the part doing work a flat shape + * cannot, which is breaking up the straight edge the projection makes. + * + * Two sets of conifers rather than one, because a wood of a single silhouette + * repeated a thousand times reads as wallpaper however well it is drawn. + */ + const conifers = [ + "pine-dark", + "pine-tall", + "pine-broad", + /* + * And two more from the same nature pack, in sage rather than near-black. + * + * The wood was three dark silhouettes repeated, which at dusk turned the + * whole edge of the map into one black band -- a wall rather than a wood. + * These are drawn in sage rather than near-black, so the treeline has some + * depth in it and the eye can still tell one trunk from the next. + */ + "pine-light-a", + "pine-light-b", + ] + .map((name) => kingdom.get(name)) + .filter((texture): texture is NonNullable => texture !== undefined); + + borderTrees().forEach((tree, index) => { + const outsider = conifers.length > 0 && tree.kind === "tree" && index % 3 === 0; + const sprite = new Sprite( + outsider ? conifers[index % conifers.length] : art.frame(tree.sprite), + ); + sprite.anchor.set(0.5, 1); + /* + * The two sets are drawn at very different sizes -- Kenney's are sprites + * cut to a tile, these are vector art at whatever the artboard was -- so + * the imported ones are scaled against their own height rather than sharing + * a number that happens to suit the others. + */ + /* + * Three times over for the imported conifers. They were drawn to the same + * height as Kenney's, which wasted what they are for: these are the tall + * dark shapes that give the wood its depth, and at the same height as + * everything else they were just more trees. + */ + sprite.scale.set(outsider ? (tree.scale * 330) / sprite.texture.height : tree.scale); + sprite.position.set(tree.x, tree.y); + /* + * Darkened with distance. A wood lit exactly like the field it surrounds + * reads as more field; the eye needs the edge of the map to be the edge of + * the light. It also breaks up the grid the trees were placed on. + */ + const shade = 1 - tree.depth * 0.34; + /* + * Stone takes the light differently from leaves, and a ruin is darker again + * -- it is meant to be half-seen between trunks rather than presented. + */ + const base = tree.kind === "tree" ? 0x9fbf8a : tree.kind === "rock" ? 0xa8a79c : 0x6f6f68; + const channel = (shift: number) => Math.round(((base >> shift) & 0xff) * shade); + sprite.tint = (channel(16) << 16) | (channel(8) << 8) | channel(0); + /* + * Placed in screen space, so the depth comes from where it landed. The + * trees straddle the edge of the country on purpose; one at the bottom of + * that edge is nearer the camera than anything inland and now draws like + * it. + */ + sprite.zIndex = depthAtScreenY(tree.y); + into.addChild(sprite); + }); + + return { canopy: layer }; +} diff --git a/app/src/game/pixi/bridge.ts b/app/src/game/pixi/bridge.ts new file mode 100644 index 0000000..ce4d6d4 --- /dev/null +++ b/app/src/game/pixi/bridge.ts @@ -0,0 +1,152 @@ +import { Container, Graphics } from "pixi.js"; +import { crossings, type Crossing } from "../world/marches"; +import { depthOf, TILE_H, TILE_W, toScreen } from "../world/iso"; + +/** + * The bridges, where a road runs into the river. + * + * Drawn rather than imported, like the fences and the bales beside them: there + * is no bridge in either art pack, and a deck is a handful of boards in a + * projection this simple. + * + * Built from `crossings()`, which reads the road and the ground rather than a + * written-down position -- so if the river bends somewhere else tomorrow the + * bridge goes with it, instead of leaving a road stopping at the water and a + * deck standing in a field. + */ + +const DECK = 0x8a6236; +const DECK_LIT = 0xa97b48; +const DECK_DARK = 0x5e4123; +const RAIL = 0x6f4a29; +const RAIL_LIT = 0x9c7245; + +/** A point in tile space, as the screen sees it. */ +function screenAt(x: number, y: number): { x: number; y: number } { + return toScreen(x, y); +} + +function drawBridge(crossing: Crossing): Graphics { + const deck = new Graphics(); + + /* Along the road, and across it. Both in tile space, projected as we go. */ + const ax = crossing.dx; + const ay = crossing.dy; + const bx = -crossing.dy; + const by = crossing.dx; + + const half = crossing.span / 2; + /** Half the roadway, in tiles. Wide enough for the lane it carries. */ + const wide = 1.25; + + const middle = screenAt(crossing.x, crossing.y); + const at = (along: number, across: number) => { + const point = screenAt( + crossing.x + ax * along + bx * across, + crossing.y + ay * along + by * across, + ); + return { x: point.x - middle.x, y: point.y - middle.y }; + }; + + const nearLeft = at(-half, -wide); + const nearRight = at(-half, wide); + const farRight = at(half, wide); + const farLeft = at(half, -wide); + + /* + * The trestles first, so the deck sits on them. Two piers standing in the + * water, drawn before the boards for the same reason the rails are drawn + * after: what is behind goes down first. + */ + for (const along of [-half * 0.42, half * 0.42]) { + for (const across of [-wide * 0.72, wide * 0.72]) { + const post = at(along, across); + deck.rect(post.x - 3, post.y, 6, TILE_H * 0.85).fill({ color: DECK_DARK }); + } + } + + /* The deck: one board-coloured slab with a lit near edge. */ + deck + .poly([ + nearLeft.x, nearLeft.y, + nearRight.x, nearRight.y, + farRight.x, farRight.y, + farLeft.x, farLeft.y, + ]) + .fill({ color: DECK }); + + /* + * The planks, across the run rather than along it. + * + * A flat slab is a ramp; the boards are what say this is something somebody + * built out of timber. Spaced by the tile so they stay the same size as the + * ground they cross. + */ + const planks = Math.max(4, Math.round(crossing.span * 2.2)); + for (let plank = 1; plank < planks; plank += 1) { + const along = -half + (crossing.span * plank) / planks; + const left = at(along, -wide); + const right = at(along, wide); + deck + .moveTo(left.x, left.y) + .lineTo(right.x, right.y) + .stroke({ color: DECK_DARK, width: 1.4, alpha: 0.55 }); + } + + /* The near edge catches the light, which is what gives the deck a thickness. */ + deck + .moveTo(nearLeft.x, nearLeft.y) + .lineTo(nearRight.x, nearRight.y) + .stroke({ color: DECK_LIT, width: 2.4 }); + + /* + * Rails down both sides, with posts. + * + * Drawn the same way as the roadside fences, because they are the same thing + * doing the same job, and a bridge whose rail is a different timber from the + * fence twenty tiles away reads as two different countries. + */ + for (const side of [-1, 1]) { + const POSTS = Math.max(3, Math.round(crossing.span)); + const RAIL_H = 15; + for (let post = 0; post <= POSTS; post += 1) { + const along = -half + (crossing.span * post) / POSTS; + const foot = at(along, wide * side); + deck.rect(foot.x - 2, foot.y - RAIL_H, 4, RAIL_H).fill({ color: RAIL }); + deck.rect(foot.x - 2, foot.y - RAIL_H, 1.6, RAIL_H).fill({ color: RAIL_LIT }); + } + const start = at(-half, wide * side); + const end = at(half, wide * side); + deck + .moveTo(start.x, start.y - RAIL_H) + .lineTo(end.x, end.y - RAIL_H) + .stroke({ color: RAIL, width: 3.4, cap: "round" }); + deck + .moveTo(start.x, start.y - RAIL_H - 1.2) + .lineTo(end.x, end.y - RAIL_H - 1.2) + .stroke({ color: RAIL_LIT, width: 1.2, cap: "round" }); + } + + return deck; +} + +/** + * Every bridge on the map, into the layer everything standing on the ground + * shares, each at the depth of the water it crosses. + */ +export function buildBridges(into: Container): number { + const found = crossings(); + for (const crossing of found) { + const deck = drawBridge(crossing); + const at = toScreen(crossing.x, crossing.y); + deck.position.set(at.x, at.y); + /* + * Just under the depth of its own middle. A bridge is walked over, so + * anybody on it has to draw in front of it, and they are at the depth of + * wherever on it they are standing. + */ + deck.zIndex = depthOf(crossing.x, crossing.y, -TILE_W); + into.addChild(deck); + } + return found.length; +} diff --git a/app/src/game/pixi/ground.ts b/app/src/game/pixi/ground.ts new file mode 100644 index 0000000..886d73c --- /dev/null +++ b/app/src/game/pixi/ground.ts @@ -0,0 +1,135 @@ +import { Container, Graphics } from "pixi.js"; +import { GARRISONS, groundTiles, MAP, type Ground } from "../world/marches"; +import { diamond, TILE_H, TILE_W, toScreen } from "../world/iso"; + +/** + * The ground of the Marches, drawn once into a handful of objects. + * + * Sixteen thousand tiles is far too many to leave as sixteen thousand sprites: + * even doing nothing, that is sixteen thousand transforms to update every + * frame. They are baked into a few Graphics instead, which the renderer uploads + * once and afterwards draws as a few things however far the view is moved. + * + * The ground is drawn rather than textured because Kenney's terrain tiles are + * square, meant for a square grid, and the map is diamonds. Flat diamonds with + * a lit north-west edge read as a tilted floor at any zoom and cost nothing. + * + * Two decisions here are about the size of the map rather than the look of it, + * and both stopped mattering only once it was four times bigger: + * + * The tiles are grouped by colour before anything is drawn. A separate fill per + * tile is sixteen thousand fill instructions to build and to hold; quantising + * the per-tile variation into a few steps and collecting every tile that shares + * a colour into one path brings that to a couple of dozen, and the picture is + * the same one. + * + * And the layer is not cached as a texture. It used to be, which was right when + * the map was 64 tiles across and the cache was a 4096x2048 bitmap. At 128 it + * would be 8192x4096 -- 134 MB of video memory, and past the maximum texture + * size on a good many machines, on which it would silently fail. Static + * geometry is uploaded once and redrawn for almost nothing; a bitmap that large + * is not. + */ + +/** Two tones per ground: the face, and the edge that catches the light. */ +const COLOURS: Record = { + grass: { face: 0x5c8f3a, lit: 0x74ad4a, dark: 0x3f6b28 }, + dirt: { face: 0xa97b46, lit: 0xc2934f, dark: 0x825c33 }, + stone: { face: 0x8d8478, lit: 0xa79d8f, dark: 0x6b6459 }, + sand: { face: 0xd6bd82, lit: 0xe8d29b, dark: 0xb39c68 }, + water: { face: 0x35688f, lit: 0x4581ad, dark: 0x27506e }, +}; + +/** How many tones each ground is allowed. See the note about grouping above. */ +const STEPS = 5; + +/** + * A little variation per tile, so a field of grass is not one flat colour. + * + * Deterministic, from the tile's own position, so the map looks the same every + * time it is opened. A field that reshuffles itself on reload is unsettling in + * a way nobody can quite name. + */ +function stepFor(tileX: number, tileY: number): number { + /* The murmur3 finaliser. One round of mixing bands visibly across a field. */ + let h = Math.imul(tileX, 0x27d4eb2d) ^ Math.imul(tileY, 0x165667b1); + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) % STEPS; +} + +/** The colour of one of those steps: a few points either side of the base. */ +function toneOf(base: number, step: number): number { + const lift = Math.round(((step - (STEPS - 1) / 2) / (STEPS - 1)) * 18); + const r = Math.min(255, Math.max(0, ((base >> 16) & 255) + lift)); + const g = Math.min(255, Math.max(0, ((base >> 8) & 255) + lift)); + const b = Math.min(255, Math.max(0, (base & 255) + lift)); + return (r << 16) | (g << 8) | b; +} + +export function buildGroundLayer(): Container { + const layer = new Container(); + const tiles = groundTiles(); + + /* + * One path per (ground, tone), filled once at the end. Building the paths + * first and filling afterwards is what turns sixteen thousand instructions + * into twenty-five. + */ + const faces = new Graphics(); + const byTone = new Map(); + + for (let y = 0; y < MAP.height; y += 1) { + for (let x = 0; x < MAP.width; x += 1) { + const ground = tiles[y * MAP.width + x]; + const step = stepFor(x, y); + const key = `${ground}:${step}`; + let bucket = byTone.get(key); + if (!bucket) { + bucket = { ground, step, tiles: [] }; + byTone.set(key, bucket); + } + bucket.tiles.push(diamond(x, y)); + } + } + + for (const bucket of byTone.values()) { + for (const points of bucket.tiles) faces.poly(points); + faces.fill({ color: toneOf(COLOURS[bucket.ground].face, bucket.step) }); + } + + /* + * The north-west edge of each tile, one path per ground. Outlining every + * diamond turns the map into graph paper; catching the light on one side is + * what makes the floor read as tilted rather than as a pattern. + */ + const edges = new Graphics(); + const byGround = new Map(); + for (let y = 0; y < MAP.height; y += 1) { + for (let x = 0; x < MAP.width; x += 1) { + const ground = tiles[y * MAP.width + x]; + const { x: sx, y: sy } = toScreen(x, y); + const lines = byGround.get(ground) ?? []; + lines.push([sx - TILE_W / 2, sy, sx, sy - TILE_H / 2]); + byGround.set(ground, lines); + } + } + for (const [ground, lines] of byGround) { + for (const [ax, ay, bx, by] of lines) edges.moveTo(ax, ay).lineTo(bx, by); + edges.stroke({ color: COLOURS[ground].lit, width: 1, alpha: 0.35 }); + } + + /* + * A darker apron under each holding, so a garrison reads as a place that has + * been cleared and settled rather than as a patch of different grass. + */ + const aprons = new Graphics(); + for (const garrison of GARRISONS) { + const { x, y } = toScreen(garrison.x, garrison.y); + aprons.ellipse(x, y, garrison.radius * TILE_W * 0.62, garrison.radius * TILE_H * 0.62); + } + aprons.fill({ color: 0x000000, alpha: 0.1 }); + + layer.addChild(faces, aprons, edges); + return layer; +} diff --git a/app/src/game/pixi/keepScene.ts b/app/src/game/pixi/keepScene.ts new file mode 100644 index 0000000..d48a8e3 --- /dev/null +++ b/app/src/game/pixi/keepScene.ts @@ -0,0 +1,367 @@ +import { Container, Text } from "pixi.js"; +import type { Application } from "pixi.js"; +import type { Viewport } from "pixi-viewport"; +import { buildWorld, homeView, loadArt, loadKingdom } from "./scene"; +import { toScreen, toTile } from "../world/iso"; +import { ActorLayer } from "./actors"; +import { Birds, Blows, Dust, loadEffects, Smoke } from "./ambience"; +import { Banners, OrderMark } from "./banners"; +import { loadSigils } from "./sigils"; +import type { Scene } from "./PixiStage"; +import { GARRISONS } from "../world/marches"; +import { createSim, garrisonSoldiers, orderHero, tickSim, yourHero, type Actor, type Mark, type Sim } from "../world/sim"; + +/** + * The Marches, assembled and running. + * + * The simulation and the scene are kept apart on purpose: `world/sim.ts` knows + * nothing about Pixi and can be run a thousand ticks deep in a test, and this + * file only ever reads it and draws what it finds. That split is what made the + * shaking findable — it was a question about numbers, not about pixels. + */ + +/** How far above a holding the camera sits, so the HUD does not cover it. */ +const RIDE_LIFT = 150; + +export interface KeepHandle { + sim: Sim; + /** Called when a hero or a soldier is clicked. */ + onPick?: (actor: Actor | undefined) => void; + /** Called when the ground is clicked and the player's hero was sent there. */ + onOrder?: (x: number, y: number) => void; + /** + * Where the inspected figure is on the canvas, every frame. + * + * Called rather than returned because the card follows a figure that walks: + * it has to be told sixty times a second, and routing that through React + * state would re-render the whole route at frame rate to move one box. + * `undefined` means nothing is inspected. + */ + onTrack?: (at: { x: number; y: number } | undefined) => void; + select(id: string | undefined): void; + /** What the player's own hero and their soldiers are drawn in. */ + wear(skin: number, livery: number): void; + /** Stops everything that drifts or flaps, for reduced motion. */ + still(stop: boolean): void; + /** Rides the camera to a holding, named by id. See the road book. */ + lookAt(garrisonId: string): void; +} + +/** The style of a floating number. Built here so Blows stays about pooling. */ +function numberFor(text: string, kind: Mark["kind"]): Container { + const node = new Text({ + text, + style: { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 20, + fontWeight: "700", + fill: kind === "damage" ? 0xff8fb0 : 0xe8c65a, + stroke: { color: 0x1a1008, width: 5 }, + }, + }); + node.anchor.set(0.5); + return node; +} + +export async function buildKeepScene( + app: Application, + viewport: Viewport, + handle: KeepHandle, +): Promise { + const [art, fx, sigils, kingdom] = await Promise.all([ + loadArt(), + loadEffects(), + loadSigils(), + loadKingdom(), + /* + * The signs' face, waited for before anything is drawn. + * + * Pixi rasterises a Text when the object is made, not when it is shown, so + * a webfont that arrives a moment later arrives too late: the signs come + * out in the fallback serif and stay that way until the scene is rebuilt. + * `font-display: swap` fixes this for the DOM and does nothing for a canvas. + * + * It fails soft. A sign in Georgia is a sign; a map that would not open + * because a font did not is not. + */ + document.fonts?.load('26px "Pirata One"').catch(() => undefined), + ]); + const { root, camps, campBanners, campLights, lanterns, banners, things, labels, signs } = + buildWorld(app, art, kingdom); + + const world = new Container(); + world.addChild(root); + + const sim = handle.sim; + garrisonSoldiers(sim); + + let selected: string | undefined; + const actors = new ActorLayer(art, things, labels, sigils); + const companies = new Banners(banners); + const orderMark = new OrderMark(banners); + + const birds = new Birds(things); + const smoke = new Smoke(things, fx["fx-smoke_01"]); + const dust = new Dust(things, fx["fx-smoke_01"]); + const blows = new Blows(things, fx, numberFor); + + handle.select = (id) => { + selected = id; + }; + handle.wear = (skin, livery) => actors.wear(skin, livery); + /* + * The reduced-motion setting reached the interface and stopped at the edge of + * the canvas, so somebody who had asked for less motion got a still HUD over + * a map full of drifting particles and flapping birds -- the setting doing + * nothing in the one place it was most needed. + */ + handle.still = (stop) => { + birds.still(stop); + smoke.still(stop); + dust.still(stop); + /* + * The lamps hold at full brightness rather than going out. Everything else + * that moves here is ornament; a lamp is what makes the ground under it + * legible, so the setting takes the flicker and leaves the light. + */ + lanterns.still(stop); + }; + handle.lookAt = (garrisonId) => { + const garrison = GARRISONS.find((holding) => holding.id === garrisonId); + if (!garrison) return; + const { x, y } = toScreen(garrison.x, garrison.y); + /* + * Animated rather than cut, and not because it is prettier: a cut across a + * map this size leaves you somewhere that looks like where you were, with + * no idea which way you came from. The ride is short enough not to be a + * wait and long enough to show the direction. + */ + viewport.animate({ + position: { x, y: y - RIDE_LIFT }, + scale: 0.95, + time: 600, + ease: "easeInOutSine", + }); + }; + + /* + * Picking is done by finding the nearest wright to where the map was + * clicked, rather than by giving every figure its own hit area. + * + * Two reasons. A wright is about twenty pixels tall and zooms down to seven, + * and asking somebody to hit that exactly is asking them to miss; a generous + * radius around the click is what makes small figures selectable at all. + * And it means one hit test against the world instead of one display object + * per actor in the interaction tree, which is cheaper and, unlike per-sprite + * hit areas, actually worked. + */ + /* + * The listener goes on the stage, with a hit area the size of the screen. + * + * A Pixi container only hit-tests its children unless it is given one of its + * own, so taps on open grass -- which is most of the map -- reached nothing + * and the handler on the viewport never fired. A stage-wide hit area means + * every click inside the canvas arrives, and where it landed is then a + * question about coordinates rather than about the display list. + */ + app.stage.eventMode = "static"; + app.stage.hitArea = app.screen; + const onTap = (event: { global: { x: number; y: number } }) => { + const world = viewport.toWorld(event.global.x, event.global.y); + const tile = toTile(world.x, world.y); + + /* + * Ask the renderer what is under the point, because only the renderer knows + * how big anybody is drawn. Heroes and soldiers can be inspected; the watch + * and the Unmade cannot, so they are not offered. + */ + const inspectable = new Map( + sim.actors + .filter((actor) => actor.role === "hero" || actor.role === "soldier") + .map((actor) => [`${actor.id}`, actor] as const), + ); + const hit = actors.hit(world.x, world.y, (id) => inspectable.has(id)); + const nearest = hit ? inspectable.get(hit) : undefined; + + if (nearest) { + selected = nearest.id; + handle.onPick?.(nearest); + return; + } + + /* + * Nothing under the click: it is an order, not an inspection. + * + * Clicking a figure inspects it and clicking the ground moves your hero, + * which is the arrangement every game of this shape uses and the one + * nobody has to be taught. Ordering also clears the inspect panel, because + * the panel is about a thing you pointed at and you have just pointed + * somewhere else. + */ + if (orderHero(sim, tile.x, tile.y)) { + selected = undefined; + handle.onPick?.(undefined); + handle.onOrder?.(tile.x, tile.y); + /* Answer the press at once, and say where. */ + orderMark.show(tile.x, tile.y); + } + }; + app.stage.on("pointertap", onTap); + + const home = homeView(); + viewport.setZoom(home.zoom, true); + viewport.moveCenter(home.x, home.y); + + /* + * Once the roster arrives, the view moves to the player's own hero. + * + * It opens on the Keep because that is all there is to open on: the scene is + * built before the first roster comes back, and a map this size has to start + * somewhere. But the Keep is not where the player's own company is, and + * arriving at somebody else's landmark and having to go looking for yourself + * is a poor first thirty seconds. + * + * Once only. Re-centring on every poll would drag the view back every four + * seconds, out from under whoever was reading a signpost. + */ + /* + * Which camp sites are occupied, rebuilt only when the roster changes. + * + * Comparing the set every frame would be fine and pointless; the roster moves + * on a four-second poll and this is a handful of string keys. + */ + let held = new Set(); + let heldFor = -1; + const heldCamps = () => { + if (sim.camps.size === heldFor) return; + heldFor = sim.camps.size; + held = new Set([...sim.camps.values()].map((camp) => `${camp.x},${camp.y}`)); + + /* Each standing camp flies its holder's colour, and lights its own gate. */ + for (const [uid, camp] of sim.camps) { + const key = `${camp.x},${camp.y}`; + const standard = campBanners.get(key); + if (standard) standard.tint = sim.banners.get(uid) ?? 0xffffff; + } + + /* + * The lamps, which are the reason a banner at dusk can be made out at all. + * Lit with the camp rather than always, because a lamp burning over ground + * nobody holds says somebody is standing there who is not. + */ + for (const [key, lit] of campLights) { + const on = held.has(key); + lit.glow.visible = on; + for (const lamp of lit.lamps) lamp.visible = on; + } + }; + + /* + * Where the inspected figure is, in canvas pixels, reported every frame. + * + * `toScreen` here is pixi-viewport's, which is the world transform -- not the + * projection's `toScreen`, which turns tiles into world units. The figure's + * position has to go through both, in that order. + */ + const track = () => { + if (!handle.onTrack) return; + const chosen = selected ? sim.actors.find((actor) => actor.id === selected) : undefined; + if (!chosen) { + handle.onTrack(undefined); + return; + } + const world = toScreen(chosen.x, chosen.y); + handle.onTrack(viewport.toScreen(world.x, world.y)); + }; + + let found = false; + const findYou = () => { + if (found) return; + const hero = yourHero(sim); + if (!hero) return; + found = true; + const seat = toScreen(hero.x, hero.y); + viewport.animate({ + position: { x: seat.x, y: seat.y - RIDE_LIFT }, + scale: 1, + time: 700, + ease: "easeInOutSine", + }); + }; + + /* + * Signs keep roughly the same size on screen at any zoom, and the sentence + * under the name only appears close up. A map zoomed out is otherwise a map + * covered in enormous words, and zoomed in they disappear. + */ + let detailed: boolean | undefined; + const rescaleSigns = () => { + const scale = 1 / viewport.scale.x; + const wanted = viewport.scale.x > 0.75; + for (const sign of signs.children) { + sign.scale.set(Math.min(1.6, Math.max(0.55, scale))); + } + actors.zoomed(viewport.scale.x); + /* + * Only when it changes. Showing the sentence redraws every board, and + * `moved` fires on every frame of a drag -- redrawing six boards a frame to + * arrive at the picture already on screen is the kind of cost that only + * shows up as a number in somebody else's profile. + */ + if (wanted === detailed) return; + detailed = wanted; + for (const sign of signs.children) { + (sign as Container & { setDetailed?: (on: boolean) => void }).setDetailed?.(wanted); + } + }; + rescaleSigns(); + viewport.on("zoomed", rescaleSigns); + viewport.on("moved", rescaleSigns); + + /* + * The simulation runs at a fixed thirty ticks a second whatever the display + * does, with a ceiling on catching up: a tab left in the background for ten + * minutes should resume, not replay ten minutes of battle in one frame. + */ + let owed = 0; + const TICK_MS = 1000 / 30; + + return { + world, + tick(deltaMs) { + owed = Math.min(owed + deltaMs, TICK_MS * 5); + while (owed >= TICK_MS) { + owed -= TICK_MS; + tickSim(sim); + } + findYou(); + heldCamps(); + track(); + /* Only the camps somebody is actually holding are standing. */ + for (const [key, camp] of camps) camp.visible = held.has(key); + companies.sync(sim); + orderMark.tick(deltaMs); + actors.sync(sim, selected); + blows.sync(sim); + lanterns.tick(deltaMs); + birds.tick(deltaMs); + smoke.tick(deltaMs); + dust.tick(sim, deltaMs); + }, + destroy() { + viewport.off("zoomed", rescaleSigns); + viewport.off("moved", rescaleSigns); + app.stage.off("pointertap", onTap); + companies.destroy(); + orderMark.destroy(); + actors.destroy(); + birds.destroy(); + smoke.destroy(); + dust.destroy(); + blows.destroy(); + }, + }; +} + +export { createSim }; +export type { Actor, Sim }; diff --git a/app/src/game/pixi/plates.ts b/app/src/game/pixi/plates.ts new file mode 100644 index 0000000..9bb0afa --- /dev/null +++ b/app/src/game/pixi/plates.ts @@ -0,0 +1,218 @@ +import { Container, Graphics, Text } from "pixi.js"; +import type { Actor } from "../world/sim"; +import { sigilFor, type Sigils } from "./sigils"; + +/** + * What stands above somebody's head. + * + * A hero carries a shield with their initials on it, their name, a health bar + * and a mana bar. A soldier carries its class and the name of the session it + * is. Both are built once and then only have their numbers moved, because a + * Text object rebuilt on a tick uploads a new texture and that is the fastest + * way to make a Pixi scene stutter. + * + * All of it scales *against* the zoom rather than with it. A plate that scales + * with the map is illegible zoomed out, which is exactly when you most need to + * know which of these distant figures is you; scaling the other way keeps it + * roughly a constant size on screen, and a little larger than that at the far + * end so it stays readable when the figure under it is six pixels tall. + */ + +/** Two initials from a name: "Ada Lovelace" is AL, "ada" is AD. */ +export function initialsOf(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "??"; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); +} + +/** + * How much of a hero's company is at work. + * + * This is what the blue bar shows, and it is a read-out like everything else: + * the share of that person's sessions doing something rather than sitting at a + * prompt. It is not a resource, it cannot be spent, and nothing in the game + * consumes it -- calling it mana is the skin's word for "how much is in + * flight", and the panel behind it says so in plain terms. + */ +export function atWork(actor: Actor, all: Actor[]): number { + if (actor.role !== "hero") return 0; + const theirs = all.filter( + (other) => other.role === "soldier" && other.heroUid === actor.heroUid, + ); + if (theirs.length === 0) return 0; + const busy = theirs.filter((other) => other.work !== "idle").length; + return busy / theirs.length; +} + +const FONT = "ui-monospace, SFMono-Regular, Menlo, monospace"; + +export interface Plate { + root: Container; + /** + * How tall the board is. + * + * Handed back so the caller can hang it by its bottom edge. Plates sit above + * the head, and a container positioned by its top would push the board + * further down the taller it got -- so a hero, whose board is the tallest, + * would be the one whose name covered them. + */ + height: number; + /** Redrawn only when a number actually changes. See `Bars.set`. */ + bars?: Bars; +} + +/** + * The two bars under a hero's name. + * + * Held as their own object with a memory of what they were last drawn at, so + * that the common case -- nothing changed this frame -- costs a comparison + * rather than a re-tessellation. + */ +export class Bars { + private readonly shape = new Graphics(); + private lastHealth = -1; + private lastWork = -1; + + constructor( + parent: Container, + private readonly width: number, + private readonly y: number, + ) { + parent.addChild(this.shape); + } + + set(health: number, work: number): void { + if (Math.abs(health - this.lastHealth) < 0.01 && Math.abs(work - this.lastWork) < 0.01) return; + this.lastHealth = health; + this.lastWork = work; + + const { width, y } = this; + this.shape.clear(); + /* Both bars get a full-width trough, so an empty one is still legible. */ + this.shape.rect(-width / 2, y, width, 7).fill({ color: 0x1a1008, alpha: 0.9 }); + this.shape.rect(-width / 2, y + 9, width, 7).fill({ color: 0x1a1008, alpha: 0.9 }); + if (health > 0) { + this.shape + .rect(-width / 2 + 1.5, y + 1.5, (width - 3) * health, 4) + .fill({ color: health > 0.35 ? 0x8fd05a : 0xd4553f }); + } + if (work > 0) { + this.shape + .rect(-width / 2 + 1.5, y + 10.5, (width - 3) * work, 4) + .fill({ color: 0x5aa8f0 }); + } + } + + destroy(): void { + this.shape.destroy(); + } +} + +/** + * The rim on your own hero's board. + * + * Turquoise, and nothing else on the map is. The company ring already says + * which ground is yours, but a ring is on the floor and the board is where the + * eye goes -- so at a glance across a country with four companies on it, this + * is the thing that answers "which one am I". + * + * It is a second signal, not the only one: your hero is also the one the view + * opens on, the one the HUD names, and the only one that answers a click. + */ +const YOURS_RIM = 0x3fd9c8; + +/** A hero's plate: shield, initials, name, health, and work in flight. */ +export function heroPlate(actor: Actor, yours: boolean): Plate { + const root = new Container(); + + const name = new Text({ + text: actor.name.length > 20 ? `${actor.name.slice(0, 19)}…` : actor.name, + style: { + fontFamily: FONT, + fontSize: 19, + fontWeight: "700", + fill: yours ? YOURS_RIM : 0xf0c04a, + }, + }); + name.anchor.set(0, 0); + + const SHIELD = 34; + const BARS = 18; + const board = new Graphics(); + const width = Math.max(SHIELD + 12 + name.width + 10, 110); + const height = SHIELD + 12 + BARS; + + board + .rect(-width / 2, 0, width, height) + .fill({ color: 0x241d15, alpha: 0.9 }) + .stroke({ color: yours ? YOURS_RIM : 0xe8b44a, width: yours ? 3 : 2, alignment: 1 }); + + /* + * The shield: a pointed pentagon rather than a rectangle, because a rectangle + * with letters in it is a label, and the job of this is to be a device + * somebody picks out at a glance from across the country. + */ + const left = -width / 2 + 6; + board + .poly([ + left, 6, + left + SHIELD, 6, + left + SHIELD, 6 + SHIELD * 0.6, + left + SHIELD / 2, 6 + SHIELD, + left, 6 + SHIELD * 0.6, + ]) + .fill({ color: yours ? YOURS_RIM : 0xe8b44a }) + .stroke({ color: 0x241d15, width: 2, alignment: 0 }); + + const initials = new Text({ + text: initialsOf(actor.name), + style: { fontFamily: FONT, fontSize: 17, fontWeight: "700", fill: 0x241d15 }, + }); + initials.anchor.set(0.5, 0.5); + initials.position.set(left + SHIELD / 2, 6 + SHIELD * 0.44); + + name.position.set(left + SHIELD + 10, 10); + + root.addChild(board, initials, name); + const bars = new Bars(root, width - 12, SHIELD + 10); + + return { root, height, bars }; +} + +/** + * A soldier's plate: its class as a sigil, and which session it is. + * + * The class used to be spelled out -- "ARTIFICER", "BEASTMASTER" -- which is a + * lot of letters to say the same five things over and over, and at a camp with + * a dozen soldiers in it the words were most of what was on screen. A mark says + * it in one glance and a quarter of the width, and it is the same mark the + * session list uses, so the two agree about what a tool looks like. + */ +export function soldierPlate(actor: Actor, sigils: Sigils): Plate { + const root = new Container(); + + const name = new Text({ + text: actor.name.length > 24 ? `${actor.name.slice(0, 23)}…` : actor.name, + style: { fontFamily: FONT, fontSize: 16, fill: 0xf0d9a8 }, + }); + name.anchor.set(0, 0.5); + + const PAD = 6; + const SIGIL = 22; + const width = SIGIL + PAD * 3 + name.width; + const height = SIGIL + PAD; + + const board = new Graphics(); + board + .rect(-width / 2, 0, width, height) + .fill({ color: 0x241d15, alpha: 0.85 }) + .stroke({ color: 0xc9a06a, width: 1, alignment: 1 }); + + const badge = sigilFor(actor.kind, sigils, SIGIL); + badge.position.set(-width / 2 + PAD + SIGIL / 2, height / 2); + name.position.set(-width / 2 + PAD * 2 + SIGIL, height / 2); + + root.addChild(board, badge, name); + return { root, height }; +} diff --git a/app/src/game/pixi/roadside.ts b/app/src/game/pixi/roadside.ts new file mode 100644 index 0000000..9870de0 --- /dev/null +++ b/app/src/game/pixi/roadside.ts @@ -0,0 +1,369 @@ +import { Container, Graphics, Sprite, Texture } from "pixi.js"; +import type { Application } from "pixi.js"; +import { campLanterns, roadsideProps } from "../world/roadside"; +import { depthOf, TILE_H, TILE_W, toScreen } from "../world/iso"; + +/** + * Fences, hay bales and lanterns, and the light the lanterns throw. + * + * Drawn rather than sprited, because none of the three exists in the art. + * Kenney's Medieval RTS pack is buildings, units, trees and rocks; the + * medieval pack the repository owner supplied is flags, icons and a handful of + * three-thousand-pixel renders. Neither has a rail fence, a bale or a + * lamp-post, and all three are simple enough shapes that drawing them is + * honest work rather than a stopgap: a bale is a cylinder on its side, a + * lantern is a post with a light on it. + * + * Each shape is drawn once into a texture and then used as sprites. There are + * several hundred of these and not one of them ever changes; a Graphics apiece + * would be several hundred sets of geometry to hold and to walk, and a sprite + * is one quad in a batch. + * + * The fences are baked once per direction. A rail has to run along the road, + * and the roads curve now, so a single east-west section would sit across half + * of them. Eight directions is close enough that the quantising does not show, + * and it is eight textures rather than one per section. + */ + +/** How many directions a fence section is baked in. See the note above. */ +const FACINGS = 8; + +/** The warm a lantern burns at, and what its light is tinted. */ +const FLAME = 0xffd27a; +const GLOW = 0xffb454; + +/** + * A section of post-and-rail fence, running along a direction in tile space. + * + * Drawn in the projection rather than face-on. A fence lies *along* the ground, + * unlike a tree or a person, so its run has to follow the diamond grid; drawn + * face-on it reads as a picture of a fence standing on a floor. + */ +function drawFence(dx: number, dy: number): Graphics { + const section = new Graphics(); + + /** Half the run, in tiles. A section is a little over two tiles long. */ + const HALF = 1.05; + const end = (sign: number) => ({ + x: (dx * HALF * sign - dy * HALF * sign) * (TILE_W / 2), + y: (dx * HALF * sign + dy * HALF * sign) * (TILE_H / 2), + }); + const left = end(-1); + const right = end(1); + + /* + * A post-and-rail fence, drawn as timber rather than as line art. + * + * The first version was three two-pixel posts and a pair of hairline strokes, + * which at this distance is a handful of sticks lying in the grass. What made + * it read as a fence is thickness and shading: a rail is a board with a lit + * top edge and a dark underside, a post is a squared-off piece of wood with + * one lit face and a cap, and there are enough posts that the rails look + * carried rather than floating. + */ + const POST_H = 30; + const POSTS = 5; + const RAILS = [POST_H * 0.80, POST_H * 0.44]; + + const WOOD = 0x6f4a29; + const WOOD_LIT = 0x9c7245; + const WOOD_DARK = 0x4a2f19; + + /* Rails first, so the posts read as standing in front of them. */ + for (const lift of RAILS) { + /* The underside, a touch below and darker: what gives a rail a thickness. */ + section + .moveTo(left.x, left.y - lift + 2.5) + .lineTo(right.x, right.y - lift + 2.5) + .stroke({ color: WOOD_DARK, width: 5, cap: "round" }); + section + .moveTo(left.x, left.y - lift) + .lineTo(right.x, right.y - lift) + .stroke({ color: WOOD, width: 5, cap: "round" }); + /* And the lit top edge, the same north-west light the ground is drawn with. */ + section + .moveTo(left.x, left.y - lift - 1.4) + .lineTo(right.x, right.y - lift - 1.4) + .stroke({ color: WOOD_LIT, width: 1.6, cap: "round" }); + } + + for (let post = 0; post < POSTS; post += 1) { + const t = post / (POSTS - 1); + const px = left.x + (right.x - left.x) * t; + const py = left.y + (right.y - left.y) * t; + /* Squared timber: the shaded body, one lit face, and a cap on top. */ + section.rect(px - 3.5, py - POST_H, 7, POST_H).fill({ color: WOOD_DARK }); + section.rect(px - 3.5, py - POST_H, 3.5, POST_H).fill({ color: WOOD }); + section.rect(px - 3.5, py - POST_H, 1.6, POST_H).fill({ color: WOOD_LIT }); + section.ellipse(px, py - POST_H, 3.6, 1.7).fill({ color: WOOD_LIT }); + } + + return section; +} + +/** + * A round bale on its side: a body, a lit top, and the straw showing. + * + * Kept small. A bale is waist-high on a person, and this map has already been + * through one round of things being drawn at the wrong scale. + */ +function drawBale(): Graphics { + const bale = new Graphics(); + const W = 32; + const H = 23; + + /* The drum on its side, lit from the north-west like everything else here. */ + bale.roundRect(-W / 2, -H, W, H, 9).fill({ color: 0xb08a3c }); + bale.roundRect(-W / 2, -H, W, H * 0.5, 9).fill({ color: 0xd2a84e }); + + /* Straw, as a few strokes. Enough that it is not a bean; not so many that it + * turns to noise at the size this is actually seen. */ + for (let line = 1; line < 4; line += 1) { + const at = -H + (H / 4) * line; + bale + .moveTo(-W / 2 + 4, at) + .lineTo(W / 2 - 4, at) + .stroke({ color: 0x8f6c2b, width: 1.5, alpha: 0.7 }); + } + + /* The cut end, turned towards the viewer. */ + bale.ellipse(W / 2 - 6, -H / 2, 6, H / 2 - 1).fill({ color: 0xe0bc6a }); + bale.ellipse(W / 2 - 6, -H / 2, 2.5, H / 4).fill({ color: 0xa87f32 }); + + return bale; +} + +/** A lamp on a post: the post, the arm, the case, and the pane it burns behind. */ +function drawLantern(): Graphics { + const lamp = new Graphics(); + const POST = 44; + + lamp.rect(-2.5, -POST, 5, POST).fill({ color: 0x4a3722 }); + lamp.rect(-2.5, -POST, 2, POST).fill({ color: 0x695032 }); + /* The arm the case hangs off. */ + lamp.rect(-2.5, -POST, 13, 3.5).fill({ color: 0x4a3722 }); + + lamp.rect(4, -POST + 3, 12, 15).fill({ color: 0x3a2b1a }); + lamp.rect(5.5, -POST + 5, 9, 11).fill({ color: FLAME }); + lamp.rect(2.5, -POST + 1, 15, 3).fill({ color: 0x5c452a }); + + return lamp; +} + +/** + * The pool of light a lamp throws on the ground, baked. + * + * Two things about it were arrived at the hard way, and both are worth having + * written down. + * + * It is an *ellipse* on the ground rather than a halo around the flame, and it + * is drawn into its own layer just above the terrain -- under the people, the + * buildings and the trees. That is where lamplight goes. Drawn over the top of + * everything it washes out the figures standing in it, which is the opposite + * of what a lamp does for the thing it is lighting. + * + * And it blends normally. The obvious choice is additive, and additive is what + * this had first; on the WebGPU path Pixi maps `add` to a blend whose alpha + * function is `[ONE, ONE]`, so every lamp also adds to the *canvas's* alpha + * channel, the page composites the result, and the whole map comes back milky + * -- not just the few yards near a lamp. `screen` is better and still hazes. + * A warm translucent pool over dark ground reads as light perfectly well, and + * it renders the same on every backend, which the other two do not. + * + * The falloff is a stack of concentric ellipses at small alphas. Pixi's + * Graphics has no radial gradient worth the name, the overlaps accumulate into + * a smooth edge, and as a texture the whole thing is one quad per lamp. + */ +function bakeGlow(app: Application): Texture { + const pool = new Graphics(); + /* + * Bigger and much brighter than it was. + * + * The whole argument for lighting this map at dusk is that the lamps are + * what lift it back, and at a 120px pool of four percent a step they lifted + * nothing: the ground under a lamp was the same colour as the ground twenty + * tiles away, so every lamp on the map was an ornament rather than a light. + * A wider pool with a hotter centre is what makes the verge it stands on + * readable, which is the one job it has. + */ + const R = 300; + const STEPS = 26; + for (let step = STEPS; step > 0; step -= 1) { + const t = step / STEPS; + /* Flattened to the 2:1 ground plane, like every other shadow on this map. */ + pool.ellipse(R, R / 2, R * t, (R * t) / 2).fill({ color: GLOW, alpha: 0.1 * (1 - t) ** 1.2 }); + } + const texture = app.renderer.generateTexture(pool); + pool.destroy(); + return texture; +} + +/** + * The flicker. + * + * A lamp that holds one exact brightness forever is a decal. A few percent of + * wander at a rate slow enough not to read as a strobe is the difference + * between a light and a picture of one. + * + * Each lamp gets its own phase and its own rate, so a road of them does not + * pulse in unison, which would read as the whole map breathing. + * + * Under reduced motion they hold at full brightness rather than going out. + * Everything else that moves on this map is ornament and is hidden outright; + * a lamp is what makes the ground under it legible, so the setting takes the + * movement and leaves the light. + */ +export class Lanterns { + private readonly pools: { sprite: Sprite; base: number; phase: number; rate: number }[] = []; + private elapsed = 0; + private moving = true; + + add(sprite: Sprite, seed: number): void { + const phase = (seed * 0.618) % 1; + this.pools.push({ + sprite, + base: sprite.alpha, + phase: phase * Math.PI * 2, + rate: 1.1 + phase * 1.4, + }); + } + + still(stop: boolean): void { + this.moving = !stop; + if (stop) for (const pool of this.pools) pool.sprite.alpha = pool.base; + } + + tick(deltaMs: number): void { + if (!this.moving) return; + this.elapsed += deltaMs / 1000; + for (const pool of this.pools) { + const wander = Math.sin(this.elapsed * pool.rate + pool.phase) * 0.5 + 0.5; + pool.sprite.alpha = pool.base * (0.9 + wander * 0.14); + } + } +} + +export interface Roadsides { + /** The flat shadows, handed to the ground the way the scatter's are. */ + shadows: Graphics; + /** Every camp's lamps and their light, by site key, for the scene to show. */ + campLights: Map; + /** The lamps, so the scene can let them flicker and hold them still. */ + lanterns: Lanterns; + count: number; +} + +/** + * `into` takes the sprites directly, and `lights` takes the glows. + * + * The sprites have to be siblings of the buildings and the people, because + * depth is per sprite and a container of fences would sort as one thing. The + * pools cannot be: they lie flat on the ground, under everything standing on + * it, and they are the one layer that dusk is not applied to -- light that is + * itself dimmed is not light. + */ +export function buildRoadside( + app: Application, + into: Container, + lights: Container, +): Roadsides { + const shadows = new Graphics(); + const campLights = new Map(); + const lanterns = new Lanterns(); + + /* + * Baked once each. `generateTexture` renders a Graphics to an offscreen + * target, which is why these are drawn and then thrown away. + */ + const facings: Texture[] = []; + for (let facing = 0; facing < FACINGS; facing += 1) { + const angle = (facing / FACINGS) * Math.PI * 2; + const shape = drawFence(Math.cos(angle), Math.sin(angle)); + facings.push(app.renderer.generateTexture(shape)); + shape.destroy(); + } + + const bake = (shape: Graphics) => { + const texture = app.renderer.generateTexture(shape); + shape.destroy(); + return texture; + }; + const baleTexture = bake(drawBale()); + const lampTexture = bake(drawLantern()); + const glowTexture = bakeGlow(app); + + /** The pool a lamp casts, lying on the ground at the foot of its post. */ + const lightAt = (tileX: number, tileY: number, scale: number, parent: Container) => { + const at = toScreen(tileX, tileY); + const light = new Sprite(glowTexture); + light.anchor.set(0.5); + light.scale.set(scale); + light.position.set(at.x, at.y + TILE_H * 0.15); + parent.addChild(light); + /* Seeded from where it stands, so a lamp flickers the same way every time. */ + lanterns.add(light, Math.abs(Math.round(tileX * 131 + tileY))); + }; + + /** Something standing at a tile, sorted against everything else on it. */ + const stand = (texture: Texture, tileX: number, tileY: number) => { + const at = toScreen(tileX, tileY); + const sprite = new Sprite(texture); + sprite.anchor.set(0.5, 1); + sprite.position.set(at.x, at.y + TILE_H * 0.2); + sprite.zIndex = depthOf(tileX, tileY); + into.addChild(sprite); + return sprite; + }; + + const placed = roadsideProps(); + for (const prop of placed) { + if (prop.kind === "fence") { + /* + * The facing, quantised. `atan2` gives -PI..PI; the double modulo puts a + * negative bucket back in range, which is the shortest correct way to do + * it and the reason this is not a bare `%`. + */ + const angle = Math.atan2(prop.dy, prop.dx); + const bucket = ((Math.round((angle / (Math.PI * 2)) * FACINGS) % FACINGS) + FACINGS) % FACINGS; + stand(facings[bucket], prop.x, prop.y); + } else if (prop.kind === "bale") { + stand(baleTexture, prop.x, prop.y); + } else { + stand(lampTexture, prop.x, prop.y); + lightAt(prop.x, prop.y, 1, lights); + } + } + + shadows.fill({ color: 0x1a1008, alpha: 0.22 }); + + /* + * The camps' lamps, kept apart from the rest. + * + * A camp is only there while somebody holds it, so its lamps are only lit + * while somebody holds it -- an unheld camp still burning says somebody is + * standing there who is not. Both the posts and their light are collected + * per site so the scene can show them with the camp. + */ + for (const lamp of campLanterns()) { + const sprite = stand(lampTexture, lamp.x, lamp.y); + sprite.visible = false; + + let group = campLights.get(lamp.site); + if (!group) { + const glow = new Container(); + glow.visible = false; + lights.addChild(glow); + group = { lamps: [], glow }; + campLights.set(lamp.site, group); + } + group.lamps.push(sprite); + /* + * Brighter and wider than a roadside lamp. This pair is what makes the + * banners standing beside it readable, which is the whole reason the camps + * were given lamps. + */ + lightAt(lamp.x, lamp.y, 1.45, group.glow); + } + + return { shadows, campLights, lanterns, count: placed.length }; +} diff --git a/app/src/game/pixi/scatter.ts b/app/src/game/pixi/scatter.ts new file mode 100644 index 0000000..e4ffff8 --- /dev/null +++ b/app/src/game/pixi/scatter.ts @@ -0,0 +1,60 @@ +import { Container, Graphics, Sprite } from "pixi.js"; +import { scatterProps, type Prop } from "../world/scatter"; +import { depthOf, TILE_H, toScreen } from "../world/iso"; +import type { Loaded } from "./scene"; + +/** + * The woods and the boulder fields, as sprites. + * + * Two things are done differently here from the way a building is placed, and + * both are because there are a couple of thousand of these rather than forty. + * + * Each prop is one Sprite and nothing else. `standing()` gives a building a + * Container holding a shadow and a sprite, which is three display objects for + * every tree; at this count that is thousands of transforms to walk every + * frame to draw something that never moves. + * + * And the shadows go into one Graphics handed back to the ground layer. A + * shadow lying on flat earth cannot move, cannot be walked in front of, and + * never needs sorting with anything -- so it belongs with the ground, drawn + * once, rather than with the things that are sorted by depth twice a second. + * + * What is *not* done here is culling. Pixi batches these into a handful of draw + * calls and the map is not big enough for the off-screen ones to matter; adding + * a quadtree to skip them would be work spent on a number nobody has measured. + */ + +export interface Scatter { + /** The props' shadows, flat, to go into the ground. */ + shadows: Graphics; + count: number; +} + +/** + * `into` takes the sprites directly rather than a container holding them. + * + * A container of props would sort as one thing against everything else, at + * whatever depth that container happened to have -- so every tree on the map + * would be behind every building, including the trees standing in front of + * one. Depth is per sprite, so the sprites have to be siblings of the + * buildings and the people. + */ +export function buildScatter(art: Loaded, into: Container): Scatter { + const shadows = new Graphics(); + const placed: Prop[] = scatterProps(); + + for (const prop of placed) { + const { x, y } = toScreen(prop.x, prop.y); + const texture = art.frame(prop.sprite); + + const sprite = new Sprite(texture); + sprite.anchor.set(0.5, 1); + sprite.scale.set(prop.scale); + sprite.position.set(x, y + TILE_H * 0.2); + sprite.zIndex = depthOf(prop.x, prop.y); + into.addChild(sprite); + + } + + return { shadows, count: placed.length }; +} diff --git a/app/src/game/pixi/scene.ts b/app/src/game/pixi/scene.ts new file mode 100644 index 0000000..603acfe --- /dev/null +++ b/app/src/game/pixi/scene.ts @@ -0,0 +1,640 @@ +import { Assets, Container, Graphics, Sprite, Text, Texture } from "pixi.js"; +import type { Application } from "pixi.js"; +import { GARRISONS, MAP, type Garrison } from "../world/marches"; +import { campSites } from "../world/camps"; +import { depthOf, TILE_H, TILE_W, toScreen } from "../world/iso"; +import { buildGroundLayer } from "./ground"; +import { buildBorder } from "./border"; +import { buildBridges } from "./bridge"; +import { buildScatter } from "./scatter"; +import { buildRoadside, type Lanterns } from "./roadside"; + +/** + * Everything standing on the ground: buildings, signs, trees, and the people. + * + * One container, sorted by depth, because in an isometric view what is in + * front of what is decided by position rather than by the order things were + * added. Pixi will sort it for us if we ask; `zIndex` is set from the same + * rule for everything, so a wright walking behind a tower goes behind it and + * nothing has to be told about anything else. + */ + +/** Where the art lives. See scripts/import-kenney.mjs and the notices file. */ +const ATLAS = "/game/medieval-rts.json"; + +/** + * Dusk, as three tints rather than one dark sheet over the top. + * + * A single wash across the whole picture dims the thing you are looking at by + * exactly as much as the thing you are not, which is the opposite of what + * evening does. Tinting the layers separately puts the dark where distance is: + * the far wood goes deepest and coldest, the ground behind it less so, and the + * buildings and the people least of all -- so figures stay readable while the + * country around them drops away. + * + * The tints are cold rather than merely dark. Reducing every channel equally + * gives a picture somebody has turned the brightness down on; pulling red + * hardest and leaving blue is what the eye reads as evening light. + * + * What lifts it back is the lanterns, which is what makes them worth having + * rather than ornaments: they are the only warm thing left on the map. + */ +const DUSK = { + wood: 0x8c99b5, + ground: 0xb2b9d4, + things: 0xc9cde0, +} as const; + +/** + * The banners that stand at a camp, and the conifers in the border wood. + * + * From the medieval pack, and the conifers from the nature pack; + * see scripts/import-kingdom.mjs for what was taken and what was done to it. Loaded separately from the atlas because they are a + * handful of loose files rather than a spritesheet, and because one of them + * failing to load should cost that one thing rather than the whole map. + */ +const KINGDOM = [ + "banner-a", + "banner-b", + "banner-c", + "banner-d", + "pine-dark", + "pine-tall", + "pine-broad", + "pine-light-a", + "pine-light-b", + "grass-band-a", + "grass-band-b", + "grass-band-c", + "grass-band-d", + "grass-tuft-a", + "grass-tuft-b", + "siege", + "hero-banner", +]; + +export type Kingdom = Map; + +export async function loadKingdom(): Promise { + const loaded: Kingdom = new Map(); + await Promise.all( + KINGDOM.map(async (name) => { + const file = name.startsWith("pine") ? `${name}.svg` : `${name}.png`; + try { + const texture = await Assets.load(`/game/kingdom/${file}`); + /* + * The castle is pixel art and is drawn at twice its own size, so how it + * is sampled is the whole look of it: bilinear turns a pixel castle + * into a smear, and nearest keeps every pixel a square block. It is the + * one thing on this map drawn that way, on purpose. + */ + /* + * The grass is pixel art: forty pixels across, ten colours, drawn at + * about a tile and a half. Sampled smoothly it comes back to being the + * soft painted illustration it started as, which is the one thing it + * was flattened to stop being. + */ + if (name.startsWith("grass-")) texture.source.scaleMode = "nearest"; + loaded.set(name, texture); + } catch { + /* One missing banner is one missing banner, not a blank map. */ + } + }), + ); + return loaded; +} + +export interface Loaded { + frame(name: string): Texture; +} + +export async function loadArt(): Promise { + /* + * Decode on the main thread. + * + * Pixi's loader would rather spin up a Web Worker from a blob URL, which is + * faster for a big pile of textures and which this application's + * Content-Security-Policy forbids: `worker-src` is not set, so it falls back + * to `script-src 'self'`, and a blob is not self. The symptom is textures + * that never arrive and a map that draws nothing. + * + * The alternative was adding `blob:` to the policy. That is a real widening + * of what the page may execute, in exchange for shaving milliseconds off + * loading a single 160 kB atlas, which is not a trade worth making. + */ + Assets.setPreferences({ preferWorkers: false }); + const sheet = await Assets.load(ATLAS); + return { + frame(name: string) { + const texture = sheet.textures?.[name]; + if (!texture) throw new Error(`no frame "${name}" in the atlas`); + return texture; + }, + }; +} + +/** + * Something that stands on the ground at a tile. + * + * Anchored bottom-centre, so the sprite grows upwards from the tile it + * occupies rather than being centred on it — the difference between a building + * standing on a square and a building floating over one. + */ +export function standing( + texture: Texture, + tileX: number, + tileY: number, + scale = 1, +): Container { + const group = new Container(); + const { x, y } = toScreen(tileX, tileY); + group.position.set(x, y); + group.zIndex = depthOf(tileX, tileY); + + /* + * No shadow under it. + * + * Every standing thing used to carry a soft ellipse, and a flat disc under a + * sprite in this projection does not read as contact -- it reads as a thing + * hovering over a disc, which is exactly the float it was added to prevent. + * What puts a building on the ground here is standing on its own tile and + * sorting correctly against its neighbours, and that is now what does it. + */ + const sprite = new Sprite(texture); + sprite.anchor.set(0.5, 1); + sprite.scale.set(scale); + /* Lifted by a few pixels so it sits inside its own shadow, not on its edge. */ + sprite.position.set(0, TILE_H * 0.2); + group.addChild(sprite); + + return group; +} + +/** + * The sign outside a garrison. + * + * This is where the lore lives. It used to be in a menu nobody had to open; + * now it is a board standing at the place it describes, and reading the world + * means walking it. + * + * It is a plaque and not floating text, for a reason worth recording: over a + * map of grass, fired clay and red roofs there is no text colour that reads + * everywhere, and the outline thick enough to survive the worst case turned + * every letter to mud. A board carries its own background with it. It also + * sits wholly above the holding rather than hanging off the top of it, so a + * sentence can never run down across the buildings it is describing. + */ +export function signFor(garrison: Garrison): Container { + const group = new Container(); + const { x, y } = toScreen(garrison.x, garrison.y - garrison.radius - 0.5); + group.position.set(x, y); + /* Signs are drawn over everything; they are labels, not scenery. */ + group.zIndex = depthOf(garrison.x, garrison.y - garrison.radius, 500); + + const PAD = 10; + const WIDTH = 300; + + const name = new Text({ + text: garrison.name, + style: { + /* + * The one place in the game set in blackletter: the name of a place. + * + * Everything else is monospace, because everything else is read in a + * hurry and this is not -- you stop walking to read a signpost. The + * sentence underneath stays monospace for that reason, so the two are + * doing different jobs and look like it. + */ + fontFamily: '"Pirata One", Georgia, serif', + fontSize: 26, + letterSpacing: 1, + fill: 0xf0d9a8, + align: "center", + wordWrap: true, + wordWrapWidth: WIDTH - PAD * 2, + }, + }); + name.anchor.set(0.5, 0); + + const purpose = new Text({ + text: garrison.purpose, + style: { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 16, + fill: 0xc9a06a, + align: "center", + wordWrap: true, + wordWrapWidth: WIDTH - PAD * 2, + }, + }); + purpose.anchor.set(0.5, 0); + purpose.label = "purpose"; + + /* + * The board is sized to the text rather than the text fitted to the board, + * because the names and the sentences are lore and will be rewritten, and a + * fixed box is a promise to re-measure every time somebody edits a word. + */ + const width = Math.max(name.width, purpose.width) + PAD * 2; + const tall = name.height + 4 + purpose.height + PAD * 2; + const short = name.height + PAD * 2; + + name.position.set(0, -tall + PAD); + purpose.position.set(0, -tall + PAD + name.height + 4); + + const board = new Graphics(); + const post = new Graphics(); + + /* + * Two boards, swapped rather than resized: close up the sentence is shown + * and the board is tall enough for it, far out only the name is, and the + * board shrinks to match. Redrawing on every zoom step would be the same + * picture at a cost per frame. + */ + const draw = (height: number) => { + board.clear(); + board + .rect(-width / 2, -height, width, height) + .fill({ color: 0x241d15, alpha: 0.88 }) + .stroke({ color: 0xe8b44a, width: 2, alignment: 1 }); + post.clear(); + post.rect(-3, -height, 6, height + 14).fill({ color: 0x3a2a1a, alpha: 0.9 }); + }; + draw(tall); + + group.addChild(post, board, name, purpose); + + /* Read by the scene when the zoom changes; see keepScene's rescaleSigns. */ + Object.assign(group, { + setDetailed(detailed: boolean) { + purpose.visible = detailed; + name.position.y = detailed ? -tall + PAD : -short + PAD; + draw(detailed ? tall : short); + }, + }); + + return group; +} + +/** The ground, the holdings and their signs. Everything that does not move. */ +export function buildWorld(app: Application, art: Loaded, kingdom: Kingdom): { + root: Container; + ground: Container; + /** The camps, by "x,y", so the scene can show the ones somebody holds. */ + camps: Map; + /** Each camp's dyeable standard, by the same key. */ + campBanners: Map; + /** Between the ground and the figures: the colour each hero commands. */ + banners: Container; + things: Container; + /** Every camp's lamps and their light, by the same key the camps use. */ + campLights: Map; + /** The lamps, for the scene to flicker and for reduced motion to hold still. */ + lanterns: Lanterns; + labels: Container; + signs: Container; +} { + const root = new Container(); + /* + * The far wood goes down before the ground, so the country is a clearing in + * it. The thicket goes down after, so the trees that straddle the edge are + * not sliced along it. + */ + const ground = buildGroundLayer(); + const things = new Container(); + things.sortableChildren = true; + /* + * The edge trees go into `things` with everything else that stands on the + * ground. Built after it exists, for that reason. + */ + const border = buildBorder(app, art, kingdom, things); + /* + * Names live above the world rather than in it. + * + * A name board parented to its own figure sorts at that figure's depth, so + * anything standing in front of it covers the name -- and a label you cannot + * read when somebody walks past is not a label. These are drawn over + * everything, like the garrison signs, because that is what a label is. + */ + const banners = new Container(); + const lights = new Container(); + const labels = new Container(); + /* + * Sorted, like the figures under them. + * + * The plates were added in roster order and never given a depth, so where + * two figures stood close the one at the back could paint its name straight + * over the name of the one in front -- the same fault the figures themselves + * were sorted to avoid, one layer up. The stagger in `actors.ts` keeps most + * plates off each other; this decides who wins when it cannot. + */ + labels.sortableChildren = true; + const signs = new Container(); + + /* + * The woods first, and their shadows straight into the ground. + * + * Props are added before the buildings only so that the two are not + * interleaved in the child list; what actually decides which is drawn in + * front is `zIndex`, which both set from the same rule. + */ + const scatter = buildScatter(art, things); + ground.addChild(scatter.shadows); + + /* + * And what people left beside the roads: fences, bales, and the lamps. + * + * After the scatter because it reads the same road lines, and its shadows go + * into the ground for the same reason the scatter's do -- a shadow on flat + * earth cannot move and never needs sorting against anything. + */ + const roadside = buildRoadside(app, things, lights); + + /* + * And the bridges, where a road runs into the river. After the roadside props + * for the same reason those came after the scatter: what decides the order on + * screen is depth, not the order things were added. + */ + buildBridges(things); + ground.addChild(roadside.shadows); + + /* + * The landmarks: a castle over the Keep and a siege engine at the Watch. + * + * Placed on the holdings they belong to rather than scattered, because a + * landmark that is everywhere is scenery. The Keep is the account itself and + * the middle of the map, so it gets the castle; the Watch is where faults are + * met, so it gets the engine. + */ + const keep = GARRISONS.find((holding) => holding.id === "keep"); + if (keep) { + /* + * The castle, built out of Kenney's own castle pieces rather than dropped + * in as one imported render. + * + * The render was the wrong shape for this map and no amount of transforming + * fixed it. A castle on a diamond grid has to have *both* of its ground + * axes on the grid's axes. A shear lays the horizontals over onto one of + * them; the second shear needed for the other leans every tower, because a + * shear cannot rotate a three-dimensional picture -- only a camera can, and + * there was one picture. + * + * These were drawn isometric to begin with, so both axes are right by + * construction, they are lit from the same corner as everything else here, + * and they are the same artwork as the hall and the chapel standing round + * them. + * + * Laid out in *screen* pixels rather than tiles. A castle is a thing whose + * parts sit beside each other in the picture -- a gate in the middle, a + * tower at each end -- and a tile offset walks diagonally, so placing them + * by tile put the left tower nearer the camera than the right one and the + * whole thing came out as a staircase of roofs. + */ + const atScreen = (offsetX: number, offsetY: number) => { + /* The inverse of `toScreen` without its origin. See the grass below. */ + const acrossX = offsetX / (TILE_W / 2); + const acrossY = offsetY / (TILE_H / 2); + return { + x: keep.x + (acrossX + acrossY) / 2, + y: keep.y - 1 + (acrossY - acrossX) / 2, + }; + }; + + /* + * Back to front, so the code reads in the order the picture does. Depth + * would sort them anyway. + */ + const CASTLE: { sprite: string; x: number; y: number; scale: number }[] = [ + /* The inner keep, standing behind and above the wall. */ + { sprite: "Structure_06", x: 0, y: -66, scale: 1.3 }, + /* A corner tower at each end of the front, clear of the gatehouse. */ + { sprite: "Structure_01", x: -136, y: -8, scale: 1.2 }, + { sprite: "Structure_01", x: 136, y: -8, scale: 1.2 }, + /* And the gatehouse, which is the face of the whole thing. */ + { sprite: "Structure_02", x: 0, y: 0, scale: 1.6 }, + ]; + + for (const piece of CASTLE) { + const at = atScreen(piece.x, piece.y); + things.addChild(standing(art.frame(piece.sprite), at.x, at.y, piece.scale)); + } + + /* + * And grass along the foot of the walls. + * + * Each piece is anchored bottom-centre, so the line it meets the ground + * along is its own width laid across the screen at its own feet. + */ + const FRONT = CASTLE.filter((piece) => piece.sprite !== "Structure_06"); + + /* + * How far down-screen of a wall's foot a tuft is set. On the line exactly it + * sorts behind the sprite and the wall covers it; a few pixels forward puts + * the grass in front of the stone it is growing against, which is where + * grass at the foot of a wall actually is. + */ + const FORWARD = 9; + + let planting = 0; + for (const piece of FRONT) { + const texture = art.frame(piece.sprite); + const halfWidth = (texture.width * piece.scale) / 2; + /* A tuft about every eighteen screen pixels along the foot. */ + const tufts = Math.max(3, Math.round((halfWidth * 2) / 18)); + + for (let step = 0; step <= tufts; step += 1) { + const jitter = ((planting * 2654435761) % 1000) / 1000; + planting += 1; + /* Skip a few, so the band is a verge and not a hedge. */ + if (jitter > 0.84) continue; + + const bands = ["grass-band-a", "grass-band-b", "grass-band-c", "grass-band-d"]; + const name = jitter > 0.72 ? "grass-tuft-a" : bands[step % bands.length]; + const blade = kingdom.get(name); + if (!blade) continue; + + const at = atScreen( + piece.x - halfWidth + ((halfWidth * 2) * step) / tufts, + piece.y + FORWARD, + ); + /* + * Against the tuft's own forty pixels, not the two hundred and twenty + * it was first imported at. Flattening the grass to pixel art shrank + * the file by five and a half times, and a scale tuned to the old file + * put ten-pixel weeds at the foot of the wall. + */ + things.addChild(standing(blade, at.x, at.y, 1.25 + jitter * 0.5)); + } + } + + /* And the shrubs and the trees, standing out beyond the grass. */ + const SKIRT = [ + { x: -212, y: 24, sprite: "Environment_01", scale: 0.95 }, + { x: -104, y: 48, sprite: "Environment_02", scale: 0.85 }, + { x: 104, y: 48, sprite: "Environment_03", scale: 0.9 }, + { x: 212, y: 24, sprite: "Environment_21", scale: 1 }, + ]; + for (const plant of SKIRT) { + const at = atScreen(plant.x, plant.y); + things.addChild(standing(art.frame(plant.sprite), at.x, at.y, plant.scale)); + } + } + + const watch = GARRISONS.find((holding) => holding.id === "watch"); + const siege = kingdom.get("siege"); + if (watch && siege) { + things.addChild(standing(siege, watch.x - 4, watch.y + 3, 0.3)); + things.addChild(standing(siege, watch.x + 4.5, watch.y + 3.5, 0.26)); + } + + const camps = new Map(); + /** A camp's own banner, kept so the scene can dye it its hero's colour. */ + const campBanners = new Map(); + const flags = ["banner-a", "banner-b", "banner-c", "banner-d"]; + campSites().forEach((site, index) => { + const camp = new Container(); + camp.addChild(standing(art.frame("Structure_16"), site.x, site.y - 1.5, 1.1)); + camp.addChild(standing(art.frame("Structure_01"), site.x - 3.5, site.y + 1.5, 0.9)); + camp.addChild(standing(art.frame("Structure_08"), site.x + 3.5, site.y + 1.5, 0.85)); + + /* + * Banners at the gate. A camp is a barracks, a tent and a gate, which from + * above is three roofs -- indistinguishable from any other cluster of + * buildings on the map. Flags are what say somebody holds this ground. + * + * A different set per site, so two camps side by side are not the same + * picture twice, and chosen by position rather than at random so the same + * camp always flies the same colours. + */ + const flag = kingdom.get(flags[index % flags.length]); + if (flag) { + /* + * Three times the size it was first drawn at. A banner the height of a + * soldier is a banner nobody sees from across a camp, and the whole job + * of it is to be seen from across a camp. + */ + camp.addChild(standing(flag, site.x - 3.5, site.y + 3.6, 0.78)); + } + + /* + * And one banner in the holder's own colour. + * + * This is the grey render rather than the red flags beside it, and that is + * the whole reason it is here: grey takes a tint, red does not. The flags + * say "a camp"; this one says whose. + */ + const dyed = kingdom.get("hero-banner"); + if (dyed) { + const standard = new Sprite(dyed); + standard.anchor.set(0.5, 1); + standard.scale.set(0.42); + const at = toScreen(site.x + 3.2, site.y + 3.2); + standard.position.set(at.x, at.y + TILE_H * 0.2); + camp.addChild(standard); + campBanners.set(`${site.x},${site.y}`, standard); + } + camp.visible = false; + /* + * Sorted as its own group rather than by each building's depth. A camp is + * a handful of structures standing together on cleared ground, and nobody + * walks between them closely enough for the difference to show. + */ + camp.zIndex = depthOf(site.x, site.y); + things.addChild(camp); + camps.set(`${site.x},${site.y}`, camp); + }); + + for (const garrison of GARRISONS) { + for (const building of garrison.buildings) { + things.addChild( + standing(art.frame(building.sprite), building.x, building.y, building.scale ?? 1), + ); + } + signs.addChild(signFor(garrison)); + } + + /* + * Dusk. Applied to the containers rather than painted over them, so that a + * sprite added later cannot miss it -- a wash is a thing you can forget to + * put something under, and a tint on the parent is not. + */ + border.canopy.tint = DUSK.wood; + ground.tint = DUSK.ground; + things.tint = DUSK.things; + + /* + * The lamps' pools go straight on top of the terrain and under everything + * that stands on it. Lamplight falls on the ground; drawn over the top of + * the map it washes out the very figures it is supposed to be lighting. + * + * They are the one layer with no dusk on them, which is what makes them + * read as the only warm thing left out there. + */ + root.addChild( + border.canopy, + ground, + lights, + banners, + things, + labels, + signs, + ); + return { + root, + ground, + camps, + campBanners, + campLights: roadside.campLights, + lanterns: roadside.lanterns, + banners, + things, + labels, + signs, + }; +} + +/** Where the view should start: on the Keep, which is the middle of the map. */ +export function homeView(): { x: number; y: number; zoom: number } { + /* + * On the Keep, at a zoom you can read. + * + * This used to fit every holding on screen at once, which was right when + * there were six of them on a grid 64 tiles across. There are nine now on a + * grid of 128, and fitting them all means a zoom at which a wright is three + * pixels tall -- a picture of a country rather than a place you are standing + * in. A big map is one you arrive somewhere on and travel across, so the + * game opens where the roads meet, and the road book in the pause menu is + * how you get anywhere else without walking. + */ + const keep = GARRISONS[0]; + const middle = toScreen(keep.x, keep.y); + + /* + * The HUD lies across the top of the view, so the camera looks a little + * above the Keep to put it under the strip rather than behind it. The camera + * moves the opposite way to the picture: to push the Keep down the screen, + * the camera looks higher up the country. + */ + const zoom = 0.75; + const HUD = 250; + + return { + x: middle.x, + y: middle.y - HUD / 2 / zoom, + zoom, + }; +} + +/** + * How far out the view may go: far enough to see the whole country. + * + * Worked out from the map rather than chosen, so that making the Marches + * bigger again cannot quietly leave a corner of them unreachable. + */ +export function widestZoom(screenWidth: number, screenHeight: number): number { + const across = MAP.width * TILE_W; + const down = MAP.height * TILE_H; + return Math.min(screenWidth / across, screenHeight / down); +} + +export { TILE_W, TILE_H }; diff --git a/app/src/game/pixi/sigils.ts b/app/src/game/pixi/sigils.ts new file mode 100644 index 0000000..7e5620b --- /dev/null +++ b/app/src/game/pixi/sigils.ts @@ -0,0 +1,109 @@ +import { Assets, Container, Graphics, Sprite, Texture } from "pixi.js"; +import { SESSION_KINDS } from "../../lib/session-kinds"; + +/** + * The class sigils: each harness's own mark, mounted as a device. + * + * These are the real logos, taken from `public/icons/`, which is where the + * session list already gets them. That matters more than drawing something + * prettier: a soldier is a session, and the mark over its head is the same mark + * beside that session in the console. Two sets of icons for one thing would + * drift the first time one of them was updated, and the game would start + * disagreeing with the product about what a tool looks like. + * + * What makes them belong to this game is the mounting rather than the artwork. + * Each one is set on a brass-rimmed disc in the class's colour, which is the + * same treatment the panels get, so a row of them reads as heraldry rather than + * as a toolbar that wandered onto a map. + * + * Loading may fail -- a missing icon, a policy that refuses an SVG -- and that + * is not fatal. A sigil that would not load falls back to a lettered disc, + * which is what the shield above a hero does anyway. + */ + +/** One colour per class, matching the unit sprites they are drawn with. */ +export const CLASS_COLOUR: Record = { + "claude-code": 0xe8a355, + codex: 0x7fd0e8, + hermes: 0xc9a6e8, + openclaw: 0xe88a8a, + terminal: 0xc9c2b4, +}; + +export type Sigils = Map; + +/** + * Loads each harness's icon. + * + * Every one is loaded on its own and a failure is swallowed, because these are + * five separate files of three different formats and one of them being missing + * should cost that one sigil rather than every sigil. + */ +export async function loadSigils(): Promise { + const loaded: Sigils = new Map(); + await Promise.all( + SESSION_KINDS.map(async (kind) => { + if (!kind.icon) return; + try { + loaded.set(kind.id, await Assets.load(kind.icon)); + } catch { + /* One missing icon costs one sigil, not all of them. */ + } + }), + ); + return loaded; +} + +/** + * A class's mark on a disc, sized to `size` across. + * + * Returns a Container rather than a Sprite because it is three things: the + * disc, its rim, and the mark. Built once per figure and never rebuilt. + */ +export function sigilFor(kind: string, sigils: Sigils, size: number): Container { + const badge = new Container(); + const radius = size / 2; + const colour = CLASS_COLOUR[kind] ?? CLASS_COLOUR.terminal; + + const disc = new Graphics(); + disc + .circle(0, 0, radius) + .fill({ color: colour }) + .stroke({ color: 0x241d15, width: 2, alignment: 0.5 }); + badge.addChild(disc); + + const texture = sigils.get(kind); + if (texture) { + const mark = new Sprite(texture); + mark.anchor.set(0.5); + /* + * Fitted to the disc by its longest side, so a wide mark and a tall one end + * up the same visual weight. The icons are not a matched set -- they are + * five companies' logos -- so anything else makes one of them dominate. + */ + const fit = (size * 0.66) / Math.max(texture.width, texture.height); + mark.scale.set(fit); + badge.addChild(mark); + } else { + /* No icon: the first letter, which is what a shield does anyway. */ + badge.addChild(letterFor(kind, radius)); + } + + return badge; +} + +function letterFor(kind: string, radius: number): Graphics { + /* + * Drawn rather than set as text, because a Text object here would mean a + * texture upload per figure for a single character, and this is the fallback + * path -- it should cost less than the thing it is standing in for, not more. + */ + const mark = new Graphics(); + const bar = radius * 0.7; + mark + .rect(-bar / 2, -bar / 2, bar, bar * 0.28) + .rect(-bar / 2, bar / 2 - bar * 0.28, bar, bar * 0.28) + .fill({ color: 0x241d15, alpha: 0.75 }); + void kind; + return mark; +} diff --git a/app/src/game/pixi/units.ts b/app/src/game/pixi/units.ts new file mode 100644 index 0000000..779a239 --- /dev/null +++ b/app/src/game/pixi/units.ts @@ -0,0 +1,30 @@ +/** + * Which unit sprite stands for which class. + * + * Kenney's pack has four colours of unit; they are used here to tell the + * classes apart at a glance, which is what a colour is for on a map where + * everything is the same size. + * + * Its own file because two places need it now. The renderer draws the figure on + * the map, and the shop draws the same figure in the list so that what is on + * sale is the thing that will be standing out there. Two copies of this map + * would drift, and the drift would be a shop showing one soldier and delivering + * another. + */ +export const UNIT_FOR: Record = { + "claude-code": "Unit_05", + codex: "Unit_01", + hermes: "Unit_11", + openclaw: "Unit_07", + terminal: "Unit_17", + soldier: "Unit_19", + /* The Unmade get the darkest units, tinted so they read as wrong. */ + mite: "Unit_21", + crawler: "Unit_23", + heisenbug: "Unit_09", +}; + +/** The sprite for a class, falling back to the plain terminal figure. */ +export function unitFor(kind: string): string { + return UNIT_FOR[kind] ?? UNIT_FOR.terminal; +} diff --git a/app/src/game/pixi/unmade.ts b/app/src/game/pixi/unmade.ts new file mode 100644 index 0000000..671c0c6 --- /dev/null +++ b/app/src/game/pixi/unmade.ts @@ -0,0 +1,135 @@ +import { Container, Graphics } from "pixi.js"; + +/** + * The Unmade, drawn rather than sprited. + * + * Kenney's pack has no insects, so they were dark-tinted soldiers -- which read + * as "the enemy team" rather than as vermin, and the whole conceit is that + * these are faults crawling out of the ground. Six legs settles it in a way no + * amount of tinting could: nothing with six legs is a person. + * + * Drawn as a few Graphics per creature rather than one redrawn each frame. The + * legs are children that rotate, so walking costs six transforms instead of a + * re-tessellation, which at forty of them on a besieged camp is the difference + * between free and not. + */ + +interface Build { + /** Body length, front to back. */ + length: number; + /** How fat it is. */ + girth: number; + /** How many chunks the body is made of. */ + segments: number; + body: number; + shell: number; + /** Long feelers, for the big ones. */ + antennae: boolean; + eyes: number; +} + +/** + * One build per kind, so a glance tells you which you are looking at. + * + * The colours are all cold, because everything else on this map is warm. That + * is the same rule the tint followed; what has changed is that it is now + * carried by a shape as well. + */ +const BUILDS: Record = { + mite: { length: 15, girth: 10, segments: 1, body: 0x4f8f86, shell: 0x6fd6c0, antennae: false, eyes: 2 }, + crawler: { length: 23, girth: 13, segments: 2, body: 0x3f7f95, shell: 0x63b6cc, antennae: false, eyes: 2 }, + heisenbug: { length: 32, girth: 18, segments: 3, body: 0x5b4b8a, shell: 0x9b86d6, antennae: true, eyes: 4 }, +}; + +export interface Unmade { + root: Container; + /** The six of them, rotated as it walks. */ + legs: Graphics[]; +} + +export function makeUnmade(kind: string): Unmade { + const build = BUILDS[kind] ?? BUILDS.mite; + const root = new Container(); + const legs: Graphics[] = []; + + /* + * Legs first, so the body sits over the joints. Three a side, splayed front + * to back -- the middle pair square, the outer pairs angled -- because six + * legs all pointing the same way reads as a centipede. + */ + for (let index = 0; index < 6; index += 1) { + const side = index < 3 ? -1 : 1; + const along = (index % 3) - 1; + const leg = new Graphics(); + const reach = build.girth * 1.1; + leg + .moveTo(0, 0) + .lineTo(side * reach * 0.6, -build.girth * 0.25) + .lineTo(side * reach, build.girth * 0.2) + .stroke({ color: build.body, width: Math.max(2, build.girth * 0.16), cap: "round", join: "round" }); + leg.position.set(along * build.length * 0.26, -build.girth * 0.3); + root.addChild(leg); + legs.push(leg); + } + + const body = new Graphics(); + for (let index = 0; index < build.segments; index += 1) { + /* Back to front, each a little smaller, so it tapers to the head. */ + const t = build.segments === 1 ? 0 : index / (build.segments - 1); + const at = build.length * (0.5 - t) * 0.72; + const size = build.girth * (0.72 + t * 0.28); + body.ellipse(at, -build.girth * 0.45, size * 0.62, size * 0.5).fill({ color: build.body }); + } + /* The shell: a lighter cap on the back half, which is what makes it gleam. */ + body + .ellipse(build.length * 0.1, -build.girth * 0.6, build.girth * 0.5, build.girth * 0.34) + .fill({ color: build.shell, alpha: 0.75 }); + root.addChild(body); + + const head = new Graphics(); + const nose = -build.length * 0.46; + head + .ellipse(nose, -build.girth * 0.5, build.girth * 0.42, build.girth * 0.38) + .fill({ color: build.body }); + for (let index = 0; index < build.eyes; index += 1) { + const row = Math.floor(index / 2); + const side = index % 2 === 0 ? -1 : 1; + head + .circle( + nose - build.girth * 0.08 + row * build.girth * 0.22, + -build.girth * (0.62 - row * 0.22) + side * build.girth * 0.12, + Math.max(1.4, build.girth * 0.1), + ) + .fill({ color: 0xffe9a8 }); + } + if (build.antennae) { + for (const side of [-1, 1]) { + head + .moveTo(nose, -build.girth * 0.75) + .lineTo(nose - build.girth * 0.5, -build.girth * (1.2 + 0.1 * side)) + .stroke({ color: build.body, width: 2, cap: "round" }); + } + } + root.addChild(head); + + return { root, legs }; +} + +/** + * Moves the legs. + * + * Alternating tripods, which is how a real insect walks: front and back on one + * side with the middle of the other, then the reverse. It costs one sine per + * leg and it is the difference between a bug and a drawing of one being slid + * across the floor. + */ +export function walkUnmade(unmade: Unmade, phase: number, moving: boolean): void { + for (let index = 0; index < unmade.legs.length; index += 1) { + const side = index < 3 ? 0 : 1; + const along = index % 3; + /* The tripod an odd leg belongs to: 0 or 1. */ + const tripod = (along + side) % 2; + const swing = moving ? Math.sin(phase + tripod * Math.PI) * 0.34 : 0; + unmade.legs[index].rotation = swing; + } +} diff --git a/app/src/game/preview.tsx b/app/src/game/preview.tsx new file mode 100644 index 0000000..abb5a5f --- /dev/null +++ b/app/src/game/preview.tsx @@ -0,0 +1,29 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import GameRoute from "./GameRoute"; + +/** + * The keep on its own, for looking at. + * + * The real route is behind RequireAuth, which needs a configured sign-in + * provider; a machine with no .env.local has none, and weakening the guard so + * the game can be admired would be trading a real protection for a + * convenience. This mounts the same component with nothing else around it. + * + * Reached at /game-preview.html while `npm run dev` is running. It is not an + * input to `vite build` -- only index.html is -- so it exists in development + * and in no deployment. The bundle check would fail the build if it did. + * + * A MemoryRouter rather than a BrowserRouter: the pause menu navigates to + * /sessions on the way out, and here there is no session list to land on. In + * memory that navigation is recorded and changes nothing, so "Quit to boring + * UI" can be clicked without the page going blank. + */ +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/app/src/game/qa.tsx b/app/src/game/qa.tsx new file mode 100644 index 0000000..632bcbb --- /dev/null +++ b/app/src/game/qa.tsx @@ -0,0 +1,74 @@ +import { StrictMode, useMemo } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { SignedInApp } from "../App"; +import { AuthContext } from "../auth/AuthProvider"; +/* The same stylesheets main.tsx loads, in the same order. */ +import "../styles/tokens.css"; +import "../styles/base.css"; +import "../styles/auth.css"; +import "../styles/shell.css"; +import "../styles/terminal.css"; +import "../styles/people.css"; +import "../styles/collab.css"; +import "../styles/audit.css"; +import "../styles/terms.css"; +import "../styles/vault.css"; +import "../styles/feedback.css"; + +/** + * The whole application, signed in, for QA on a machine with no provider. + * + * The preview harness next door mounts the game on its own, which is useful + * while drawing but is not the thing anyone needs to test: the game is a skin + * over a product, so what has to be exercised is the route inside the app, the + * controller in the top bar that opens it, and the way back out of it to the + * session list. None of that exists without a signed-in page to hang it on. + * + * So this mounts the real App with a stand-in identity in the auth context. + * Every guard still asks that context the same question it always asks; the + * only difference is who answers. The service is not fooled -- it checks a + * real token and will refuse anything that reaches it -- so the parts of the + * app that talk to the server will show their error states, which is itself + * worth seeing. + * + * Reached at /qa.html while `npm run dev` is running. Not an input to + * `vite build`, which builds index.html and nothing else, so it exists in + * development and in no deployment. + */ +function SignedIn({ children }: { children: React.ReactNode }) { + const value = useMemo( + () => ({ + mode: "firebase" as const, + user: { + uid: "qa-local", + email: "qa@shell.online", + displayName: "QA", + emailVerified: true, + providerData: [], + }, + initializing: false, + signIn: async () => {}, + signUp: async () => {}, + signInWithGoogle: async () => {}, + signInWithProvider: async () => {}, + resetPassword: async () => {}, + resendVerification: async () => {}, + signOutUser: async () => {}, + deleteAccount: async () => {}, + }), + [], + ); + + return {children}; +} + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/app/src/game/state/context.ts b/app/src/game/state/context.ts new file mode 100644 index 0000000..f3f7479 --- /dev/null +++ b/app/src/game/state/context.ts @@ -0,0 +1,35 @@ +import { createContext, useContext } from "react"; +import type { InputDevice } from "../engine/input"; +import { DEFAULT_OPTIONS, type GameOptions } from "./options"; + +/** + * The few things every screen in the keep needs to draw itself correctly: + * what the player is holding, how big they want the interface, and whether + * they want it to move. + * + * Deliberately small. Game state proper (heroes, the base, the purse) is + * fetched and owned separately; this is presentation, and presentation is + * needed by the boot screen before there is any game state at all. + */ +export interface GameShell { + options: GameOptions; + setOptions: (next: GameOptions) => void; + /** Resolved against the OS setting, so callers do not repeat that decision. */ + reducedMotion: boolean; + device: InputDevice; + paused: boolean; + setPaused: (paused: boolean) => void; +} + +export const GameShellContext = createContext({ + options: DEFAULT_OPTIONS, + setOptions: () => {}, + reducedMotion: false, + device: "keyboard", + paused: false, + setPaused: () => {}, +}); + +export function useGameShell(): GameShell { + return useContext(GameShellContext); +} diff --git a/app/src/game/state/demo-garrison.ts b/app/src/game/state/demo-garrison.ts new file mode 100644 index 0000000..3cb0a86 --- /dev/null +++ b/app/src/game/state/demo-garrison.ts @@ -0,0 +1,87 @@ +import type { Roster } from "./sessions"; +import type { Earned } from "./progress"; +import type { Work } from "../world/work"; + +/** + * A team to show when there is nothing running. + * + * An empty map is the correct picture of an account with nothing on it, and it + * is also a terrible first impression: a country with nobody in it and no way + * to tell whether that is the point or a fault. So this stands in, and the HUD + * says plainly that it is standing in. + * + * Three heroes rather than one, because the thing worth showing is the shape of + * the model -- several people, each with their own retinue of their own + * sessions, in their own camps. One hero with five soldiers would demonstrate + * half of it and leave the half that is actually novel unexplained. + * + * The soldiers carry plausible session facts, so clicking one shows the same + * panel a real session would rather than a panel with holes in it. + */ + +const PEOPLE = [ + { uid: "demo-ada", name: "Ada", characterClass: "claude-code" }, + { uid: "demo-grace", name: "Grace", characterClass: "codex" }, + { uid: "demo-alan", name: "Alan", characterClass: "openclaw" }, +]; + +const WORK: { id: string; name: string; kind: string; work: Work; owner: string }[] = [ + /* Ada, with a retinue of two classes: the case the model is built around. */ + { id: "demo-1", name: "fix: audit seal", kind: "claude-code", work: "bug", owner: "demo-ada" }, + { id: "demo-2", name: "fix: relay reconnect", kind: "claude-code", work: "bug", owner: "demo-ada" }, + { id: "demo-3", name: "feat: session board", kind: "claude-code", work: "feature", owner: "demo-ada" }, + { id: "demo-4", name: "chore: rotate keys", kind: "openclaw", work: "idle", owner: "demo-ada" }, + + { id: "demo-5", name: "feat: the road book", kind: "codex", work: "feature", owner: "demo-grace" }, + { id: "demo-6", name: "fix: the shaking", kind: "codex", work: "bug", owner: "demo-grace" }, + { id: "demo-7", name: "npm run dev", kind: "terminal", work: "idle", owner: "demo-grace" }, + + { id: "demo-8", name: "feat: the border wood", kind: "openclaw", work: "feature", owner: "demo-alan" }, + { id: "demo-9", name: "debug the importer", kind: "hermes", work: "bug", owner: "demo-alan" }, +]; + +/** + * What the example team is supposed to have done. + * + * The stand-in used to be a roster and nothing else, which left the rest of the + * game unreachable whenever the service could not be reached: no finished + * sessions means no experience, no experience means level one, level one means + * no marks and a shop that will not open. Somebody looking at the example + * garrison could see the map and none of what the map is for. + * + * So the example has an example history too. It is labelled everywhere the + * roster is -- the HUD says "Example garrison" and the Barrow says the service + * did not answer -- because a number that looks real and is not is worse than + * no number. What it buys is the ability to open the shop, spend, and see a + * skin land on a figure, which cannot otherwise be tried at all without a + * working service and a week of sessions behind it. + */ +export const DEMO_EARNED: Earned = { + sessions: 34, + days: 11, + machines: 3, + mended: 14, + made: 9, +}; + +export const DEMO_ROSTER: Roster = { + heroes: PEOPLE, + soldiers: WORK.map((entry, index) => ({ + id: entry.id, + name: entry.name, + kind: entry.kind, + work: entry.work, + heroUid: entry.owner, + session: { + id: entry.id, + startedAt: Date.now() - (index + 1) * 11 * 60_000, + host: ["laptop", "workshop", "builder-01"][index % 3], + command: entry.name, + }, + })), + /* The first of them is "you", so the point-and-click has a hero to order. */ + youUid: "demo-ada", + /* The stand-in is small enough that no cap ever touches it. */ + heroTotal: PEOPLE.length, + soldierTotal: WORK.length, +}; diff --git a/app/src/game/state/gathering.ts b/app/src/game/state/gathering.ts new file mode 100644 index 0000000..3652084 --- /dev/null +++ b/app/src/game/state/gathering.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from "react"; +import { request } from "../../lib/api"; + +/** + * The gathering, from the browser's side. + * + * The browser asks for a run and reads the account of what runs have cost. It + * does not collect anything and it could not: sessions are end-to-end + * encrypted and the plaintext only exists on the machine the session is running + * on. That is the whole reason this is shaped as it is -- the agent on that + * machine gathers and reports numbers, and this reads the bill afterwards. + */ + +export interface Run { + id: string; + device: string; + ranAt: number; + tokens: number; + pullRequests: number; + commits: number; + insertions: number; + deletions: number; + error: string; +} + +interface Wire { + id?: string; + device?: string; + ran_at?: number; + tokens?: number; + pull_requests?: number; + commits?: number; + insertions?: number; + deletions?: number; + error?: string; +} + +const number = (value: unknown) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + +function toRun(wire: Wire): Run { + return { + id: typeof wire.id === "string" ? wire.id : "", + device: typeof wire.device === "string" ? wire.device : "a machine", + ranAt: number(wire.ran_at), + tokens: number(wire.tokens), + pullRequests: number(wire.pull_requests), + commits: number(wire.commits), + insertions: number(wire.insertions), + deletions: number(wire.deletions), + error: typeof wire.error === "string" ? wire.error : "", + }; +} + +export type Asking = "idle" | "asking" | "asked" | "failed"; + +export function useGathering(on: boolean): { + runs: Run[]; + asking: Asking; + /** Why the last attempt failed, in the service's own words. */ + refusal: string; + /** Which machines were asked, so the answer names places rather than a count. */ + asked: string[]; + gatherNow: () => void; + refresh: () => void; +} { + const [runs, setRuns] = useState([]); + const [asking, setAsking] = useState("idle"); + const [refusal, setRefusal] = useState(""); + const [asked, setAsked] = useState([]); + + const refresh = useCallback(() => { + void (async () => { + try { + const reply = await request<{ runs?: Wire[] }>("/api/game/runs"); + setRuns((reply.runs ?? []).map(toRun)); + } catch { + /* An unreadable bill is not an empty one; leave what is already shown. */ + } + })(); + }, []); + + /* Only once there is anything to read. Nothing has run before consent. */ + useEffect(() => { + if (on) refresh(); + }, [on, refresh]); + + const gatherNow = useCallback(() => { + setAsking("asking"); + setRefusal(""); + void (async () => { + try { + const reply = await request<{ asked?: string[] }>("/api/game/gather", { method: "POST" }); + setAsked(Array.isArray(reply.asked) ? reply.asked : []); + setAsking("asked"); + /* + * The machines were asked, not answered. A run takes as long as it + * takes, and the bill appears when the agent reports; this looks again + * shortly afterwards rather than claiming anything has finished. + */ + window.setTimeout(refresh, 4000); + } catch (error) { + setRefusal(error instanceof Error ? error.message : "The service did not answer."); + setAsking("failed"); + } + })(); + }, [refresh]); + + return { runs, asking, refusal, asked, gatherNow, refresh }; +} diff --git a/app/src/game/state/options.test.ts b/app/src/game/state/options.test.ts new file mode 100644 index 0000000..c5db57b --- /dev/null +++ b/app/src/game/state/options.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_OPTIONS, + motionReduced, + normaliseOptions, + optionsToStyle, + SAFE_ZONE_RANGE, + UI_SCALE_RANGE, +} from "./options"; + +describe("bringing stored options into range", () => { + it("keeps values that are already sensible", () => { + const options = { safeZone: 7, uiScale: 150, motion: "reduced", colour: "protanopia" }; + expect(normaliseOptions(options)).toEqual(options); + }); + + it("clamps a safe area beyond what the slider offers", () => { + /* + * A safe area of 80% is a game played through a letterbox. Whatever wrote + * it -- an old build, a hand-edited value -- the renderer has to be handed + * something it can draw. + */ + expect(normaliseOptions({ safeZone: 80 }).safeZone).toBe(SAFE_ZONE_RANGE.max); + expect(normaliseOptions({ safeZone: -20 }).safeZone).toBe(SAFE_ZONE_RANGE.min); + }); + + it("clamps an interface scale to something readable", () => { + expect(normaliseOptions({ uiScale: 5 }).uiScale).toBe(UI_SCALE_RANGE.min); + expect(normaliseOptions({ uiScale: 1000 }).uiScale).toBe(UI_SCALE_RANGE.max); + }); + + it("falls back for a setting it does not recognise", () => { + expect(normaliseOptions({ motion: "spinny" }).motion).toBe(DEFAULT_OPTIONS.motion); + expect(normaliseOptions({ colour: "beige" }).colour).toBe(DEFAULT_OPTIONS.colour); + }); + + it("survives anything at all, because localStorage can hold anything at all", () => { + expect(normaliseOptions(null)).toEqual(DEFAULT_OPTIONS); + expect(normaliseOptions("not an object")).toEqual(DEFAULT_OPTIONS); + expect(normaliseOptions(42)).toEqual(DEFAULT_OPTIONS); + expect(normaliseOptions({ safeZone: Number.NaN })).toEqual(DEFAULT_OPTIONS); + }); + + it("defaults to a safe area a television will not eat", () => { + /* + * The recoverable mistake is a little wasted margin on a monitor. The + * unrecoverable one is a player who cannot see the menu they would need in + * order to fix it, so the default errs towards the monitor's loss. + */ + expect(DEFAULT_OPTIONS.safeZone).toBeGreaterThan(0); + }); +}); + +describe("deciding whether to animate", () => { + it("follows the system when asked to", () => { + const options = { ...DEFAULT_OPTIONS, motion: "system" as const }; + expect(motionReduced(options, true)).toBe(true); + expect(motionReduced(options, false)).toBe(false); + }); + + it("lets an explicit choice override the system either way", () => { + expect(motionReduced({ ...DEFAULT_OPTIONS, motion: "full" }, true)).toBe(false); + expect(motionReduced({ ...DEFAULT_OPTIONS, motion: "reduced" }, false)).toBe(true); + }); +}); + +describe("handing the options to the stylesheet", () => { + it("writes the custom properties game.css reads", () => { + const style = optionsToStyle({ safeZone: 8, uiScale: 125, motion: "full", colour: "default" }, false); + expect(style["--keep-safe"]).toBe("8%"); + expect(style["--keep-scale"]).toBe("1.25"); + expect(style["--keep-motion"]).toBe("1"); + }); + + it("collapses every duration to nothing when motion is reduced", () => { + /* game.css multiplies its durations by this, so zero stops all of them. */ + const style = optionsToStyle({ ...DEFAULT_OPTIONS, motion: "reduced" }, false); + expect(style["--keep-motion"]).toBe("0"); + }); +}); diff --git a/app/src/game/state/options.ts b/app/src/game/state/options.ts new file mode 100644 index 0000000..8a818d4 --- /dev/null +++ b/app/src/game/state/options.ts @@ -0,0 +1,127 @@ +/** + * Display options for the game, kept per browser. + * + * These are not preferences in the ordinary product sense; every one of them + * exists because a game that ignores it is unplayable for somebody: + * + * safeZone a TV cuts 3-10% off every edge, so a HUD pinned to the corner + * is a HUD that person never sees. Adjustable, because how much + * is cut varies by set and nobody can detect it from script. + * uiScale the same layout is viewed from a phone at arm's length and a + * 4K display across a room. One pixel size cannot serve both. + * motion parallax, shake and particle drift trigger motion sickness. + * colour roughly 8% of men cannot separate the red and green that games + * lean on. Every state carries an icon and a label as well, but + * the palette should not fight them either. + * + * Stored locally rather than on the account, deliberately: which display you + * are sitting at is a property of the browser, not of who you are. Sign in on + * the TV and the phone and each keeps its own. + */ + +export type MotionSetting = "system" | "full" | "reduced"; +export type ColourSetting = "default" | "deuteranopia" | "protanopia" | "tritanopia"; + +export interface GameOptions { + /** Percent of each edge treated as unsafe. 0 for a monitor, 5+ for a TV. */ + safeZone: number; + /** Percent. 100 is the reference design. */ + uiScale: number; + motion: MotionSetting; + colour: ColourSetting; +} + +export const DEFAULT_OPTIONS: GameOptions = { + /* + * Conservative by default. A 5% inset on a monitor costs a little room; a + * 0% default on a television costs the player their health bar, and only one + * of those two mistakes is recoverable by someone who cannot see the menu + * they would need to fix it in. + */ + safeZone: 5, + uiScale: 100, + motion: "system", + colour: "default", +}; + +export const SAFE_ZONE_RANGE = { min: 0, max: 10 } as const; +export const UI_SCALE_RANGE = { min: 50, max: 200 } as const; + +const MOTION_VALUES: MotionSetting[] = ["system", "full", "reduced"]; +const COLOUR_VALUES: ColourSetting[] = ["default", "deuteranopia", "protanopia", "tritanopia"]; + +const STORAGE_KEY = "shell-online-keep-options"; + +function clampNumber(value: unknown, fallback: number, min: number, max: number): number { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.round(numeric))); +} + +function oneOf(value: unknown, allowed: T[], fallback: T): T { + return allowed.includes(value as T) ? (value as T) : fallback; +} + +/** + * Brings anything at all into the shape the renderer can trust. + * + * Separate from the storage read so it can be tested without a DOM, and so a + * value that arrives from the service later goes through the same gate as one + * that came out of localStorage. + */ +export function normaliseOptions(input: unknown): GameOptions { + const raw = (typeof input === "object" && input !== null ? input : {}) as Partial; + return { + safeZone: clampNumber(raw.safeZone, DEFAULT_OPTIONS.safeZone, SAFE_ZONE_RANGE.min, SAFE_ZONE_RANGE.max), + uiScale: clampNumber(raw.uiScale, DEFAULT_OPTIONS.uiScale, UI_SCALE_RANGE.min, UI_SCALE_RANGE.max), + motion: oneOf(raw.motion, MOTION_VALUES, DEFAULT_OPTIONS.motion), + colour: oneOf(raw.colour, COLOUR_VALUES, DEFAULT_OPTIONS.colour), + }; +} + +export function readOptions(): GameOptions { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return normaliseOptions(stored ? JSON.parse(stored) : null); + } catch { + /* Private window, blocked storage, or something that is not JSON. */ + return { ...DEFAULT_OPTIONS }; + } +} + +export function writeOptions(options: GameOptions): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(normaliseOptions(options))); + } catch { + /* The choice is lost on reload, which is survivable; failing is not. */ + } +} + +/** + * Whether animation should be held back, resolving "system" against the OS. + * + * Taken as an argument rather than read here so the decision stays pure: the + * caller owns the media query and can re-run this when it changes. + */ +export function motionReduced(options: GameOptions, systemPrefersReduced: boolean): boolean { + if (options.motion === "reduced") return true; + if (options.motion === "full") return false; + return systemPrefersReduced; +} + +/** + * The CSS custom properties the game's stylesheet reads. + * + * Returned as a plain record so the caller can apply it to whichever element + * scopes the game, and so a test can assert on the values without a browser. + */ +export function optionsToStyle( + options: GameOptions, + systemPrefersReduced: boolean, +): Record { + return { + "--keep-safe": `${options.safeZone}%`, + "--keep-scale": String(options.uiScale / 100), + "--keep-motion": motionReduced(options, systemPrefersReduced) ? "0" : "1", + }; +} diff --git a/app/src/game/state/progress.test.ts b/app/src/game/state/progress.test.ts new file mode 100644 index 0000000..633aa5f --- /dev/null +++ b/app/src/game/state/progress.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { + AWARD, + experienceFrom, + fortification, + levelFor, + marksEarnedTo, + marksForLevel, + MAX_LEVEL, + nextUnlock, + NOTHING_EARNED, + standing, + totalForLevel, +} from "./progress"; + +describe("what work is worth", () => { + it("counts nothing for nothing", () => { + expect(experienceFrom(NOTHING_EARNED)).toBe(0); + expect(levelFor(0)).toBe(1); + }); + + it("pays more for making something than for mending something", () => { + /* Shipping a feature is a larger piece of work than closing one fault. */ + expect(AWARD.made).toBeGreaterThan(AWARD.mended); + }); + + it("pays most for coming back another day", () => { + /* + * Days are the one thing on this list that cannot be farmed by starting + * five sessions in a minute, so a day is worth more than a session. + */ + expect(AWARD.day).toBeGreaterThan(AWARD.session); + }); + + it("adds up every kind of work", () => { + const earned = { sessions: 3, days: 4, machines: 2, mended: 5, made: 1 }; + expect(experienceFrom(earned)).toBe( + 3 * AWARD.session + 4 * AWARD.day + 2 * AWARD.machine + 5 * AWARD.mended + AWARD.made, + ); + }); + + it("cannot be earned by anything the simulation does", () => { + /* + * The rule the file is written around, asserted rather than promised. Every + * field of `Earned` is a fact the service counted from finished sessions; + * an earlier version took the field's own tally of faults put down, and a + * tab left open overnight levelled you up. + */ + expect(Object.keys(NOTHING_EARNED).sort()).toEqual( + ["days", "machines", "made", "mended", "sessions"], + ); + }); +}); + +describe("the curve", () => { + it("starts at level one and climbs", () => { + expect(levelFor(0)).toBe(1); + expect(levelFor(totalForLevel(2))).toBe(2); + expect(levelFor(totalForLevel(5))).toBe(5); + }); + + it("never goes backwards as experience grows", () => { + let last = 0; + for (let experience = 0; experience < 200_000; experience += 137) { + const level = levelFor(experience); + expect(level).toBeGreaterThanOrEqual(last); + last = level; + } + }); + + it("gets harder, but never so hard that the next level is out of sight", () => { + /* + * A flat curve makes level forty meaningless; an exponential one makes + * level six unreachable. Each step should cost more than the last and no + * more than double the one eight levels back. + */ + for (let level = 3; level < 30; level += 1) { + const step = totalForLevel(level + 1) - totalForLevel(level); + const previous = totalForLevel(level) - totalForLevel(level - 1); + expect(step).toBeGreaterThanOrEqual(previous); + } + }); + + it("stops at the cap rather than running away", () => { + expect(levelFor(999_999_999)).toBe(MAX_LEVEL); + expect(standing(999_999_999).fraction).toBe(1); + }); +}); + +describe("the bar", () => { + it("is empty at a new level and full just before the next", () => { + const fresh = standing(totalForLevel(4)); + expect(fresh.level).toBe(4); + expect(fresh.into).toBe(0); + expect(fresh.fraction).toBe(0); + + const nearly = standing(totalForLevel(5) - 1); + expect(nearly.level).toBe(4); + expect(nearly.fraction).toBeGreaterThan(0.9); + }); + + it("stays between nothing and full, whatever it is handed", () => { + for (const value of [-500, 0, 1, 12_345, Number.NaN]) { + const result = standing(value); + expect(result.fraction).toBeGreaterThanOrEqual(0); + expect(result.fraction).toBeLessThanOrEqual(1); + expect(result.level).toBeGreaterThanOrEqual(1); + } + }); + + it("always has something named on the end of it", () => { + /* A bar filling towards nothing in particular is decoration. */ + for (let level = 1; level < 15; level += 1) { + const unlock = nextUnlock(level); + if (unlock) expect(unlock.level).toBeGreaterThan(level); + } + }); +}); + +describe("marks", () => { + it("pays nothing for the level everybody starts on", () => { + expect(marksForLevel(1)).toBe(0); + }); + + it("pays a fixed amount, not a roll", () => { + /* + * A currency that arrives in random amounts turns every level-up into a + * disappointment somebody could have avoided by waiting. That is the shape + * of a slot machine, and this is a tool people use for work. + */ + expect(marksForLevel(5)).toBe(marksForLevel(5)); + expect(marksForLevel(6)).toBeGreaterThan(marksForLevel(5)); + }); + + it("adds up over the levels reached", () => { + expect(marksEarnedTo(1)).toBe(0); + expect(marksEarnedTo(3)).toBe(marksForLevel(2) + marksForLevel(3)); + }); +}); + +describe("how fortified the holding is", () => { + it("starts small and never shrinks", () => { + let previous = fortification(1); + for (let level = 2; level <= MAX_LEVEL; level += 1) { + const current = fortification(level); + expect(current.wallTier).toBeGreaterThanOrEqual(previous.wallTier); + expect(current.keepTier).toBeGreaterThanOrEqual(previous.keepTier); + expect(current.towerTier).toBeGreaterThanOrEqual(previous.towerTier); + previous = current; + } + }); + + it("never asks for artwork that does not exist", () => { + /* Three tiers of wall and tower, two of keep. See assets/structures.ts. */ + const top = fortification(MAX_LEVEL); + expect(top.wallTier).toBeLessThanOrEqual(3); + expect(top.towerTier).toBeLessThanOrEqual(3); + expect(top.keepTier).toBeLessThanOrEqual(2); + }); +}); diff --git a/app/src/game/state/progress.ts b/app/src/game/state/progress.ts new file mode 100644 index 0000000..878069d --- /dev/null +++ b/app/src/game/state/progress.ts @@ -0,0 +1,183 @@ +/** + * Levels, marks, and what earns them. + * + * The rule this whole file exists to hold: **the game rewards work that has + * already happened.** Every point of experience below comes from something a + * session actually did — a fault put down, a structure raised, a session run + * to completion. Nothing here can be earned by playing the game, because + * there is nothing in the game to play at. It is a read-out with a costume on. + * + * That is also why there is no way to lose. No decay while you are away, no + * streak to break, no keep falling over because somebody took a weekend. A + * progress bar that punishes absence is a progress bar that makes people open + * a session they did not need, and this is a tool people use for work. + */ + +/** + * The counted work, exactly as the service derives it. + * + * These field names are the service's: see `server/lib/game-stats.ts`, which + * counts them from the caller's own sessions. Nothing in this shape can be + * produced by the game, which is the point -- an earlier version fed this the + * *simulation's* tally of faults put down, so a tab left open overnight + * levelled you up, which is the exact opposite of what the paragraph above + * promises. + */ +export interface Earned { + /** Sessions that ran and ended cleanly. */ + sessions: number; + /** Days on which anything at all was started. */ + days: number; + /** Machines that have answered the muster. */ + machines: number; + /** Finished sessions that read as fixing something. */ + mended: number; + /** Finished sessions that read as making something. */ + made: number; +} + +export const NOTHING_EARNED: Earned = { + sessions: 0, + days: 0, + machines: 0, + mended: 0, + made: 0, +}; + +/** + * What each kind of work is worth. + * + * Mending and making are worth more than a session on its own, and a session + * is counted for them as well: finishing a fix is finishing a session and also + * putting a fault down. Days are worth the most of any single unit, because + * coming back on another day is the only one of these that cannot be farmed by + * starting five sessions in a minute. + */ +export const AWARD = { + session: 10, + day: 15, + machine: 20, + mended: 14, + made: 25, +} as const; + +export function experienceFrom(earned: Earned): number { + return ( + earned.sessions * AWARD.session + + earned.days * AWARD.day + + earned.machines * AWARD.machine + + earned.mended * AWARD.mended + + earned.made * AWARD.made + ); +} + +/** + * Experience needed to reach a level, from the one before it. + * + * Gently super-linear. A flat curve makes level forty meaningless and an + * exponential one makes level six unreachable; this doubles roughly every + * eight levels, which keeps the next one always in sight. + */ +export function costOfLevel(level: number): number { + if (level <= 1) return 0; + return Math.round(60 * (level - 1) ** 1.35); +} + +/** Total experience to have reached a level. */ +export function totalForLevel(level: number): number { + let total = 0; + for (let step = 2; step <= level; step += 1) total += costOfLevel(step); + return total; +} + +/** The highest level a given amount of experience has reached. */ +export const MAX_LEVEL = 50; + +export function levelFor(experience: number): number { + let level = 1; + while (level < MAX_LEVEL && experience >= totalForLevel(level + 1)) level += 1; + return level; +} + +export interface Standing { + level: number; + /** Experience earned since reaching this level. */ + into: number; + /** Experience this level needs in total. 0 at the cap. */ + needed: number; + /** 0 to 1, for the bar. 1 at the cap. */ + fraction: number; + experience: number; +} + +export function standing(experience: number): Standing { + /* + * Guarded against a non-number, which Math.max does not catch: floor(NaN) is + * NaN and max(0, NaN) is NaN, so an experience total that arrived broken + * from storage or from a half-loaded save would have been drawn as a bar of + * NaN pixels — which the canvas silently declines to draw at all. + */ + const safe = Number.isFinite(experience) ? Math.max(0, Math.floor(experience)) : 0; + const level = levelFor(safe); + if (level >= MAX_LEVEL) { + return { level, into: 0, needed: 0, fraction: 1, experience: safe }; + } + const base = totalForLevel(level); + const needed = costOfLevel(level + 1); + const into = safe - base; + return { level, into, needed, fraction: needed === 0 ? 1 : into / needed, experience: safe }; +} + +/** + * Marks awarded for reaching a level. + * + * Deterministic, not a roll. The shop is bought from, not gambled at: a + * currency that arrives in random amounts turns every level-up into a + * disappointment somebody could have avoided by waiting, which is the shape of + * a slot machine and has no place in a tool people use for work. + */ +export function marksForLevel(level: number): number { + if (level <= 1) return 0; + return 40 + (level - 2) * 10; +} + +/** Everything earned up to a level, for a purse that was never spent. */ +export function marksEarnedTo(level: number): number { + let total = 0; + for (let step = 2; step <= level; step += 1) total += marksForLevel(step); + return total; +} + +/** + * The next thing that unlocks, so the bar always has a name on the end of it. + * + * A bar filling towards nothing in particular is decoration. A bar filling + * towards "Watchtower II" is a reason to look at it. + */ +export const UNLOCKS: { level: number; name: string; note: string }[] = [ + { level: 2, name: "The shop", note: "A pedlar starts calling at the gate." }, + { level: 3, name: "Rampart II", note: "Dressed stone, and the walk is flagged." }, + { level: 5, name: "Watchtower II", note: "A wider rim and a brighter lamp." }, + { level: 7, name: "Keep II", note: "Gold along the ridge, and every light burning." }, + { level: 9, name: "Rampart III", note: "Braziers along the wall walk." }, + { level: 12, name: "Watchtower III", note: "A turret mounted on the rim." }, + { level: 15, name: "Skins", note: "The garrison may be dressed to taste." }, +]; + +export function nextUnlock(level: number): { level: number; name: string; note: string } | undefined { + return UNLOCKS.find((unlock) => unlock.level > level); +} + +/** + * What the holding looks like at a level. + * + * The one place that decides how fortified the base is, so the field and the + * shop cannot disagree about it. + */ +export function fortification(level: number): { wallTier: number; keepTier: number; towerTier: number } { + return { + wallTier: level >= 9 ? 3 : level >= 3 ? 2 : 1, + keepTier: level >= 7 ? 2 : 1, + towerTier: level >= 12 ? 3 : level >= 5 ? 2 : 1, + }; +} diff --git a/app/src/game/state/remote.test.ts b/app/src/game/state/remote.test.ts new file mode 100644 index 0000000..ebf337a --- /dev/null +++ b/app/src/game/state/remote.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { reconcile } from "./remote"; +import { NEW_SAVE, type Save } from "./save"; + +const save = (overrides: Partial = {}): Save => ({ ...NEW_SAVE, ...overrides }); + +describe("reconciling two saves", () => { + it("keeps a class that has been chosen anywhere", () => { + expect(reconcile(save(), save({ characterClass: "codex" })).characterClass).toBe("codex"); + expect(reconcile(save({ characterClass: "hermes" }), save()).characterClass).toBe("hermes"); + }); + + it("keeps every skin bought on either machine", () => { + const merged = reconcile(save({ owned: ["ash"] }), save({ owned: ["gilt"] })); + expect(merged.owned.sort()).toEqual(["ash", "gilt"]); + }); + + it("keeps the larger spend, because hats cannot become unbought", () => { + expect(reconcile(save({ spent: 100 }), save({ spent: 300 })).spent).toBe(300); + expect(reconcile(save({ spent: 300 }), save({ spent: 100 })).spent).toBe(300); + }); + + it("never lets a fresh browser wipe a keep", () => { + /* + * The failure this exists for. Taking "most recently written" would mean a + * tab somebody opened on a borrowed laptop, played for nothing and left, + * could overwrite months of progress. Nothing here goes backwards. + */ + const played = save({ characterClass: "openclaw", owned: ["ash", "gilt"], spent: 300 }); + const fresh = save(); + const merged = reconcile(fresh, played); + expect(merged.characterClass).toBe("openclaw"); + expect(merged.owned.sort()).toEqual(["ash", "gilt"]); + expect(merged.spent).toBe(300); + }); + + it("wears what this browser was wearing, if it is owned", () => { + const merged = reconcile( + save({ skinId: "ash", owned: ["ash"] }), + save({ skinId: "gilt", owned: ["gilt"] }), + ); + expect(merged.skinId).toBe("ash"); + }); + + it("falls back when this browser is wearing something it does not own", () => { + /* A hand-edited local save should not dress you in something unbought. */ + const merged = reconcile(save({ skinId: "smuggled" }), save({ skinId: "gilt", owned: ["gilt"] })); + expect(merged.skinId).toBe("gilt"); + }); + + it("keeps consent once it has been given", () => { + expect(reconcile(save(), save({ gathering: true })).gathering).toBe(true); + }); +}); diff --git a/app/src/game/state/remote.ts b/app/src/game/state/remote.ts new file mode 100644 index 0000000..d7067ab --- /dev/null +++ b/app/src/game/state/remote.ts @@ -0,0 +1,103 @@ +import { request } from "../../lib/api"; +import { normaliseSave, type Save } from "./save"; + +/** + * The saved game, on the service. + * + * Local storage is where the game reads from and writes to; this is what makes + * it follow you to another browser. The two are not equal partners: the local + * copy is authoritative *while playing*, because it is the one that cannot + * fail mid-purchase, and the service is authoritative *on arrival*, because it + * is the one that knows about the laptop you used yesterday. + * + * Everything here fails soft. A keep that will not open because the service is + * unreachable is worse than a keep that opens with yesterday's hats. + */ + +interface Wire { + character_class: string; + skin_id: string; + livery_id: string; + owned: string[]; + spent: number; + gathering: boolean; + tokens: number; + updated_at: number; +} + +function toSave(wire: Wire): Save { + return normaliseSave({ + characterClass: wire.character_class, + skinId: wire.skin_id, + liveryId: wire.livery_id ?? "", + owned: wire.owned, + spent: wire.spent, + gathering: wire.gathering, + }); +} + +export interface RemoteSave { + save: Save; + /** Tokens the gathering has spent. Read-only here; the agent reports it. */ + tokens: number; +} + +/** Fetches the saved game, or nothing when it cannot be reached. */ +export async function loadSave(): Promise { + try { + const body = await request<{ game: Wire }>("/api/game"); + return { save: toSave(body.game), tokens: Number(body.game.tokens) || 0 }; + } catch { + return null; + } +} + +/** + * Writes the saved game. + * + * Returns whether it landed, so the caller can say "saved here only" rather + * than claiming something it does not know. The token count is not sent: it is + * the one figure that stands for real money, and it is written by what the + * agent reports rather than by whatever a browser asserts. + */ +export async function storeSave(save: Save): Promise { + try { + await request<{ game: Wire }>("/api/game", { + method: "PUT", + body: JSON.stringify({ + character_class: save.characterClass, + skin_id: save.skinId, + owned: save.owned, + spent: save.spent, + gathering: save.gathering, + }), + }); + return true; + } catch { + return false; + } +} + +/** + * Which of two saves to keep when they differ. + * + * The one that has got further, measured by what cannot go backwards: a class + * once chosen, skins once bought, marks once spent. Taking the newest instead + * would let a browser that had never been played wipe a keep, and "most + * recently written" is the wrong question when one of the writers is a tab + * somebody opened and abandoned. + */ +export function reconcile(local: Save, remote: Save): Save { + const ownedBoth = [...new Set([...local.owned, ...remote.owned])]; + return { + characterClass: local.characterClass || remote.characterClass, + /* Whatever this browser was last wearing, if it is something owned. */ + skinId: ownedBoth.includes(local.skinId) ? local.skinId : remote.skinId, + liveryId: ownedBoth.includes(local.liveryId) ? local.liveryId : remote.liveryId, + owned: ownedBoth, + /* The larger spend: hats already bought cannot become unbought. */ + spent: Math.max(local.spent, remote.spent), + gathering: local.gathering || remote.gathering, + version: local.version, + }; +} diff --git a/app/src/game/state/save.ts b/app/src/game/state/save.ts new file mode 100644 index 0000000..4d918d5 --- /dev/null +++ b/app/src/game/state/save.ts @@ -0,0 +1,106 @@ +/** + * The saved game. + * + * Small on purpose. Everything that can be *derived* is derived — the level + * from the experience, the fortification from the level, the marks earned from + * the level — so what is actually stored is only the handful of things that + * cannot be worked out again: who you chose to be, what you are wearing, what + * you have bought, and what you have spent. + * + * That matters more than it sounds. A save that stores the level as well as + * the experience has two facts that can disagree, and the day they disagree + * somebody has to decide which one is true. A save that stores only the + * experience cannot have that bug. + */ + +export interface Save { + /** Which class the player chose. Empty until they have chosen. */ + characterClass: string; + /** The skin worn, if any. */ + skinId: string; + /** What your soldiers wear. Separate from what you wear. */ + liveryId: string; + /** Skins bought. */ + owned: string[]; + /** Marks spent, so the purse is (earned by level) minus this. */ + spent: number; + /** Whether the player has agreed to the stat-gathering. */ + gathering: boolean; + /** Bumped when the shape changes, so an old save can be read or dropped. */ + version: number; +} + +export const SAVE_VERSION = 1; + +export const NEW_SAVE: Save = { + characterClass: "", + skinId: "", + liveryId: "", + owned: [], + spent: 0, + gathering: false, + version: SAVE_VERSION, +}; + +const STORAGE_KEY = "shell-online-keep-save"; + +const CLASSES = ["claude-code", "codex", "hermes", "openclaw", "terminal"]; + +/** + * Brings anything at all into a save the game can run on. + * + * Saves arrive from localStorage, from the service, and from an older version + * of this file. All three can be wrong in different ways, and a game that + * throws on a malformed save is a game somebody cannot get back into. + */ +export function normaliseSave(input: unknown): Save { + const raw = (typeof input === "object" && input !== null ? input : {}) as Partial; + const owned = Array.isArray(raw.owned) + ? raw.owned.filter((id): id is string => typeof id === "string").slice(0, 200) + : []; + return { + characterClass: CLASSES.includes(raw.characterClass as string) ? (raw.characterClass as string) : "", + skinId: typeof raw.skinId === "string" ? raw.skinId : "", + liveryId: typeof raw.liveryId === "string" ? raw.liveryId : "", + owned, + spent: Number.isFinite(raw.spent) ? Math.max(0, Math.floor(raw.spent as number)) : 0, + gathering: raw.gathering === true, + version: SAVE_VERSION, + }; +} + +export function readSave(): Save { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return normaliseSave(stored ? JSON.parse(stored) : null); + } catch { + return { ...NEW_SAVE }; + } +} + +export function writeSave(save: Save): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(normaliseSave(save))); + } catch { + /* + * A private window cannot store it. The game still runs; it simply starts + * again next time, which is survivable. Failing here would not be. + */ + } +} + +/** Whether the game has been played before on this account. */ +export function hasChosen(save: Save): boolean { + return save.characterClass !== ""; +} + +/** + * The purse, from what has been earned and what has been spent. + * + * Derived rather than stored, so it cannot drift from the level that earned + * it. Clamped at zero because a save that has been edited by hand should make + * the shop unaffordable, not make it free. + */ +export function marksLeft(earned: number, save: Save): number { + return Math.max(0, earned - save.spent); +} diff --git a/app/src/game/state/sessions.test.ts b/app/src/game/state/sessions.test.ts new file mode 100644 index 0000000..de82748 --- /dev/null +++ b/app/src/game/state/sessions.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; +import { classFor, nameOf, ownerOf, rosterFrom, workFor } from "./sessions"; +import type { Member, SessionRecord } from "../../lib/api"; + +/** + * Turning a team and its sessions into a field. + * + * The join everything else rests on: a soldier belongs to the hero who owns its + * session. Ten Claude Code sessions and three OpenClaw ones owned by one person + * are thirteen soldiers of two classes, all of them hers -- which is the + * worked example in the specification and the first test below. + */ + +const session = (over: Partial = {}): SessionRecord => ({ + id: `s-${Math.random().toString(36).slice(2)}`, + shareUrl: "https://example.invalid/s", + command: "npm run dev", + readOnly: false, + encrypted: true, + persistent: false, + host: "laptop", + startedAt: 1000, + ...over, +}); + +const member = (uid: string, over: Partial = {}): Member => ({ + orgId: "org-1", + uid, + email: `${uid}@example.invalid`, + name: "", + role: "member", + joinedAt: 1000, + ...over, +}); + +describe("reading the work", () => { + it("reads mending first", () => { + /* + * "fix the new importer" is a fix. Reading it as a feature because it + * contains "new" would be exactly backwards, and fixes are the more + * specific claim. + */ + expect(workFor("fix: audit seal")).toBe("bug"); + expect(workFor("fix the new importer")).toBe("bug"); + expect(workFor("feat: session board")).toBe("feature"); + expect(workFor("npm run dev")).toBe("idle"); + }); +}); + +describe("whose session it is", () => { + it("takes the owner when there is one", () => { + expect(ownerOf(session({ ownerUid: "ada" }), "viewer")).toBe("ada"); + }); + + it("falls back to whoever it was handed to", () => { + expect(ownerOf(session({ assigneeUids: ["grace"] }), "viewer")).toBe("grace"); + expect(ownerOf(session({ assigneeUid: "alan" }), "viewer")).toBe("alan"); + }); + + it("falls back to the viewer for a row too old to say", () => { + /* + * A soldier with no hero is a figure standing in open country with nobody + * to follow. Rows from before sessions recorded an owner have none, and the + * person looking can only be seeing it because it is theirs or their team's + * -- of which the first is much the likelier for a row that old. + */ + expect(ownerOf(session(), "viewer")).toBe("viewer"); + }); +}); + +describe("what to call somebody", () => { + it("uses their name", () => { + expect(nameOf(member("ada", { name: "Ada Lovelace" }))).toBe("Ada Lovelace"); + }); + + it("falls back to the part of the address before the at sign", () => { + expect(nameOf(member("ada"))).toBe("ada"); + }); + + it("falls back to a short id when there is neither", () => { + expect(nameOf({ uid: "0123456789abcdef" })).toBe("01234567"); + }); +}); + +describe("which class a hero is drawn as", () => { + it("is the harness they run most", () => { + /* + * Their own chosen class lives in their own saved game, which this account + * cannot read for anybody else. What everybody can see is what a colleague + * is running, so that is what decides how they are drawn -- and it has the + * advantage of being true. + */ + expect( + classFor([ + session({ command: "claude" }), + session({ command: "claude --resume abc" }), + session({ command: "codex" }), + ]), + ).toBe("claude-code"); + }); + + it("is a footman when they are running nothing", () => { + expect(classFor([])).toBe("terminal"); + }); + + it("does not depend on the order sessions came back in", () => { + const one = session({ command: "codex" }); + const two = session({ command: "claude" }); + expect(classFor([one, two])).toBe(classFor([two, one])); + }); +}); + +describe("the roster", () => { + it("gives one person thirteen soldiers of two classes", () => { + const sessions = [ + ...Array.from({ length: 10 }, () => session({ command: "claude", ownerUid: "ada" })), + ...Array.from({ length: 3 }, () => session({ command: "openclaw", ownerUid: "ada" })), + ]; + const roster = rosterFrom(sessions, [member("ada")], { uid: "ada" }); + + expect(roster.soldiers).toHaveLength(13); + expect(roster.soldiers.every((soldier) => soldier.heroUid === "ada")).toBe(true); + expect(new Set(roster.soldiers.map((soldier) => soldier.kind))).toEqual( + new Set(["claude-code", "openclaw"]), + ); + }); + + it("makes a hero of every member, running or not", () => { + const roster = rosterFrom([], [member("ada"), member("grace")], { uid: "ada" }); + expect(roster.heroes.map((hero) => hero.uid)).toEqual(["ada", "grace"]); + expect(roster.soldiers).toHaveLength(0); + }); + + it("makes a hero of somebody who has left but whose session is still up", () => { + /* + * Otherwise that session's soldier stands in open country with nobody to + * follow. The name says what happened rather than pretending they are on + * the team. + */ + const roster = rosterFrom( + [session({ ownerUid: "departed", command: "claude" })], + [member("ada")], + { uid: "ada" }, + ); + expect(roster.heroes.map((hero) => hero.uid).sort()).toEqual(["ada", "departed"]); + expect(roster.heroes.find((hero) => hero.uid === "departed")?.name).toContain("left the team"); + }); + + it("leaves closed sessions off the field", () => { + /* + * A closed session is work that is finished. It counts towards experience, + * which the service works out separately; it does not stand on the field. + */ + const roster = rosterFrom( + [ + session({ ownerUid: "ada", command: "claude" }), + session({ ownerUid: "ada", command: "claude", closedAt: 2000 }), + ], + [member("ada")], + { uid: "ada" }, + ); + expect(roster.soldiers).toHaveLength(1); + }); + + it("draws your own hero as the class you chose", () => { + /* + * Your choice lives in your own saved game and only this browser can read + * it, so it wins for your own hero and nobody else's. A colleague is drawn + * as the harness they run most, which is the only thing about them that is + * both visible and true. + */ + const roster = rosterFrom( + [ + session({ ownerUid: "ada", command: "claude" }), + session({ ownerUid: "grace", command: "claude" }), + ], + [member("ada"), member("grace")], + { uid: "ada" }, + "hermes", + ); + expect(roster.heroes.find((hero) => hero.uid === "ada")?.characterClass).toBe("hermes"); + expect(roster.heroes.find((hero) => hero.uid === "grace")?.characterClass).toBe("claude-code"); + }); + + it("falls back to what you run when you have chosen nothing", () => { + const roster = rosterFrom( + [session({ ownerUid: "ada", command: "codex" })], + [member("ada")], + { uid: "ada" }, + "", + ); + expect(roster.heroes[0].characterClass).toBe("codex"); + }); + + it("marks which hero is yours", () => { + const roster = rosterFrom([], [member("ada"), member("grace")], { uid: "grace" }); + expect(roster.youUid).toBe("grace"); + }); + + it("names a soldier after the session, falling back to the command", () => { + const roster = rosterFrom( + [ + session({ ownerUid: "ada", name: "fix: audit seal", command: "claude" }), + session({ ownerUid: "ada", command: "npm run dev" }), + ], + [member("ada")], + { uid: "ada" }, + ); + expect(roster.soldiers.map((soldier) => soldier.name).sort()).toEqual([ + "fix: audit seal", + "npm run dev", + ]); + }); + + it("reads a soldier's work from its name before its command", () => { + const roster = rosterFrom( + [session({ ownerUid: "ada", name: "fix: the thing", command: "claude" })], + [member("ada")], + { uid: "ada" }, + ); + expect(roster.soldiers[0].work).toBe("bug"); + }); + + it("gives each soldier the session facts the panel needs", () => { + const roster = rosterFrom( + [session({ ownerUid: "ada", command: "claude", host: "workshop", startedAt: 4242 })], + [member("ada")], + { uid: "ada" }, + ); + expect(roster.soldiers[0].session).toMatchObject({ host: "workshop", startedAt: 4242 }); + }); + + it("puts the heroes in a stable order", () => { + /* So that a camp does not move because the service replied differently. */ + const members = [member("grace"), member("ada"), member("alan")]; + const first = rosterFrom([], members, { uid: "ada" }); + const again = rosterFrom([], [...members].reverse(), { uid: "ada" }); + expect(again.heroes.map((hero) => hero.uid)).toEqual(first.heroes.map((hero) => hero.uid)); + }); +}); + +describe("what the field will draw", () => { + /** + * The caps exist so a large organisation cannot exhaust a browser: every + * figure costs a container, a sprite and a name board, and a name board is a + * texture. What they must never do is take the player off their own map, or + * make a team's own statistics wrong to protect its GPU. + */ + const bigTeam = (people: number, each: number) => { + const members = Array.from({ length: people }, (_, index) => ({ + uid: `member-${index}`, + email: `person${index}@example.com`, + })); + const sessions = members.flatMap((member, index) => + Array.from({ length: each }, (_, session) => ({ + id: `s-${index}-${session}`, + name: `feat: thing ${session}`, + command: "claude", + host: "laptop", + startedAt: Date.now(), + ownerUid: member.uid, + status: "running", + })), + ); + return { members, sessions }; + }; + + it("draws no more than the ceiling, however large the team", () => { + const { members, sessions } = bigTeam(60, 20); + const roster = rosterFrom(sessions as never, members as never, { uid: "member-7" }); + expect(roster.soldiers.length).toBeLessThanOrEqual(240); + expect(roster.heroes.length).toBeLessThanOrEqual(60); + }); + + it("still reports the whole team, which is the read-out", () => { + const { members, sessions } = bigTeam(60, 20); + const roster = rosterFrom(sessions as never, members as never, { uid: "member-7" }); + expect(roster.soldierTotal).toBe(1200); + expect(roster.heroTotal).toBe(60); + }); + + it("never drops your own company to make room", () => { + const { members, sessions } = bigTeam(60, 20); + const roster = rosterFrom(sessions as never, members as never, { uid: "member-59" }); + expect(roster.heroes.some((hero) => hero.uid === "member-59")).toBe(true); + expect(roster.soldiers.some((soldier) => soldier.heroUid === "member-59")).toBe(true); + }); + + it("does not let one person fill the field", () => { + const members = [{ uid: "hog", email: "hog@example.com" }, { uid: "quiet", email: "q@example.com" }]; + const sessions = Array.from({ length: 300 }, (_, index) => ({ + id: `s-${index}`, + name: "feat: thing", + command: "claude", + host: "laptop", + startedAt: Date.now(), + ownerUid: "hog", + status: "running", + })); + const roster = rosterFrom(sessions as never, members as never, { uid: "quiet" }); + const hogs = roster.soldiers.filter((soldier) => soldier.heroUid === "hog").length; + expect(hogs).toBeLessThanOrEqual(14); + }); +}); diff --git a/app/src/game/state/sessions.ts b/app/src/game/state/sessions.ts new file mode 100644 index 0000000..404da61 --- /dev/null +++ b/app/src/game/state/sessions.ts @@ -0,0 +1,221 @@ +import { kindForCommand } from "../../lib/session-kinds"; +import type { Member, SessionRecord } from "../../lib/api"; +import { workFor, type Work } from "../world/work"; +import type { HeroInput, SoldierInput } from "../world/sim"; + +export { workFor }; +export type { Work }; + +/** + * The team and its sessions, as the field needs them. + * + * The join this whole file exists for: **a soldier belongs to the hero who owns + * its session.** Ten Claude Code sessions and three OpenClaw sessions owned by + * one person are thirteen soldiers of two classes, all of them hers. + * + * `kindForCommand` is reused rather than reimplemented. It already knows that + * `claude --resume abc` is Claude Code and that `npm run claude-thing` is not, + * including how to see through the `sh -c` wrapper a browser-started session + * arrives in. A second copy of that knowledge here would drift, and the game + * would start disagreeing with the session list about what things are. + */ + +export interface Roster { + heroes: HeroInput[]; + soldiers: SoldierInput[]; + youUid?: string; + /** + * How many there really are, before the drawing caps below. + * + * The field is capped and the count is not. What the HUD reports is the team + * and its sessions, which is the read-out this whole game exists to be; the + * caps decide how many figures are drawn, which is a budget and nothing else. + * Reporting the capped number would make a large team's own statistics wrong + * to protect its browser, which is the wrong thing to protect. + */ + heroTotal: number; + soldierTotal: number; +} + +/* + * What the field will draw at once. + * + * Every figure on this map costs a container, a sprite and a name board, and a + * name board is a Pixi `Text` -- which rasterises a texture of its own. That is + * fine for a team of six with a few sessions each and it is not fine without a + * ceiling: an organisation of sixty people running twenty sessions apiece is + * twelve hundred figures and twelve hundred text textures, which is how a tab + * runs out of GPU memory. + * + * Per hero as well as overall, because the two protect against different + * shapes of large. The overall cap stops a big organisation; the per-hero cap + * stops one person with a hundred sessions open from filling the field on + * their own while everybody else's camp shows nothing. + */ +const MAX_HEROES = 60; +const MAX_SOLDIERS = 240; +const MAX_PER_HERO = 14; + +/** + * Whether a session is still running, and so still has a soldier. + * + * A closed session is work that is finished. It counts towards experience, + * which the service works out separately; it does not stand on the field. + */ +export function isOnTheField(session: SessionRecord): boolean { + return session.closedAt === undefined; +} + +/** + * Whose session this is. + * + * Rows from before sessions recorded an owner have none, and a soldier with no + * hero is a figure standing in open country with nobody to follow. So: the + * owner if there is one, else whoever it was handed to, else the person looking + * at it -- who can only be seeing it because it is theirs or their team's, and + * of those two the first is much the likelier for a row this old. + */ +export function ownerOf(session: SessionRecord, viewerUid: string): string { + return ( + session.ownerUid ?? + session.assigneeUids?.[0] ?? + session.assigneeUid ?? + viewerUid + ); +} + +/** What to call somebody: their name, or the part of their address before the @. */ +export function nameOf(member: { name?: string; email?: string; uid: string }): string { + const named = member.name?.trim(); + if (named) return named; + const email = member.email ?? ""; + const local = email.slice(0, email.indexOf("@")); + return local || member.uid.slice(0, 8); +} + +/** + * Which class a hero is drawn as. + * + * The harness they run most. Their own chosen class is in their own saved game, + * which this account cannot read for anybody else -- and asking the service to + * publish it would be storing a second fact that can disagree with the first. + * What everybody *can* see is what a colleague is running, so that is what + * decides how they are drawn, and it has the advantage of being true. + * + * Your own choice still wins for your own hero; `GameRoute` applies it, because + * only your browser knows it. + */ +export function classFor(sessions: SessionRecord[]): string { + const counts = new Map(); + for (const session of sessions) { + const kind = kindForCommand(session.command).id; + counts.set(kind, (counts.get(kind) ?? 0) + 1); + } + let best = "terminal"; + let most = 0; + /* Sorted, so a tie does not depend on the order sessions came back in. */ + for (const kind of [...counts.keys()].sort()) { + const count = counts.get(kind) ?? 0; + if (count > most) { + most = count; + best = kind; + } + } + return best; +} + +export function rosterFrom( + sessions: SessionRecord[], + members: Member[], + you?: { uid: string }, + /** + * The class this browser's owner chose for themselves. + * + * It wins for their own hero and nobody else's, because it lives in their own + * saved game and only this browser can read it. Everybody else is drawn as + * the harness they run most, which is the only thing about a colleague that + * is both visible and true. + */ + yourClass?: string, +): Roster { + const viewerUid = you?.uid ?? ""; + const live = sessions.filter(isOnTheField); + + const byOwner = new Map(); + for (const session of live) { + const owner = ownerOf(session, viewerUid); + const held = byOwner.get(owner) ?? []; + held.push(session); + byOwner.set(owner, held); + } + + /* + * Every member is a hero, whether or not they have anything running. A + * colleague with nothing open is still on the team, so they are still + * somewhere on the map; it is the soldiers around them that come and go. + * + * An owner who is not on the member list gets a hero too. That happens when + * somebody has left the team and their session is still running, and a + * session with no hero would be a soldier standing in open country with + * nobody to follow. + */ + const uids = new Set([...members.map((member) => member.uid), ...byOwner.keys()]); + const named = new Map(members.map((member) => [member.uid, member])); + + const heroes: HeroInput[] = [...uids].sort().map((uid) => { + const member = named.get(uid); + return { + uid, + name: member ? nameOf(member) : `${uid.slice(0, 8)} (left the team)`, + characterClass: + uid === you?.uid && yourClass ? yourClass : classFor(byOwner.get(uid) ?? []), + }; + }); + + const soldiers: SoldierInput[] = live.map((session) => ({ + id: session.id, + name: session.name?.trim() || session.command, + kind: kindForCommand(session.command).id, + work: workFor(session.name || session.command || ""), + heroUid: ownerOf(session, viewerUid), + session: { + id: session.id, + startedAt: session.startedAt, + host: session.host, + command: session.command, + }, + })); + + /* + * Yours first, so a cap never takes your own company off the field. + * + * The one thing the player has to be able to find is themselves: the view + * opens on their hero, the HUD names them, and theirs is the only one that + * answers a click. A ceiling that could drop them is a ceiling that breaks + * the game rather than protecting it. + */ + const yoursFirst = (list: T[]) => + [...list].sort((left, right) => { + const leftYours = (left.uid ?? left.heroUid) === viewerUid ? 0 : 1; + const rightYours = (right.uid ?? right.heroUid) === viewerUid ? 0 : 1; + return leftYours - rightYours; + }); + + const perHero = new Map(); + const drawnSoldiers: SoldierInput[] = []; + for (const soldier of yoursFirst(soldiers)) { + if (drawnSoldiers.length >= MAX_SOLDIERS) break; + const already = perHero.get(soldier.heroUid) ?? 0; + if (already >= MAX_PER_HERO) continue; + perHero.set(soldier.heroUid, already + 1); + drawnSoldiers.push(soldier); + } + + return { + heroes: yoursFirst(heroes).slice(0, MAX_HEROES), + soldiers: drawnSoldiers, + youUid: you?.uid, + heroTotal: heroes.length, + soldierTotal: soldiers.length, + }; +} diff --git a/app/src/game/state/shop.test.ts b/app/src/game/state/shop.test.ts new file mode 100644 index 0000000..bfca049 --- /dev/null +++ b/app/src/game/state/shop.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { buy, fitsClass, SKINS, skinById, swatchFor, tintFor, type Purse } from "./shop"; + +const purse = (marks: number, owned: string[] = []): Purse => ({ marks, owned }); + +describe("the catalogue", () => { + it("sells something for you and something for your soldiers", () => { + /* + * Two slots, because they are two choices. A catalogue with only one kind + * in it would quietly make the other half of the shop unreachable. + */ + expect(SKINS.some((skin) => skin.wears === "hero")).toBe(true); + expect(SKINS.some((skin) => skin.wears === "retinue")).toBe(true); + }); + + it("never cuts a retinue colour for one class", () => { + /* + * A livery is worn by a company of mixed classes, so it cannot be cut for + * one of them. Only what the hero wears is ever class-bound. + */ + for (const skin of SKINS) { + if (skin.wears !== "retinue") continue; + expect(fitsClass(skin, "codex")).toBe(true); + expect(fitsClass(skin, "terminal")).toBe(true); + } + }); + + it("sells nothing that changes a number", () => { + /* + * The line this whole file is drawn around. The moment a purchase makes a + * wright hit harder or experience arrive faster, the game stops being a + * read-out of work that happened and becomes something you can play wrong. + * A skin is a set of colours, and this asserts that it stays that way. + */ + for (const skin of SKINS) { + expect(Object.keys(skin)).toEqual( + expect.arrayContaining(["id", "name", "note", "cost", "fits", "wears", "tint"]), + ); + /* A colour and nothing else: no stats, no reach, no damage. */ + expect(skin.tint).toBeGreaterThanOrEqual(0); + expect(skin.tint).toBeLessThanOrEqual(0xffffff); + } + }); + + it("gives every item a name, a price and something to read", () => { + for (const skin of SKINS) { + expect(skin.name).toBeTruthy(); + expect(skin.note).toBeTruthy(); + expect(skin.cost).toBeGreaterThan(0); + } + }); + + it("has no two things with the same id", () => { + expect(new Set(SKINS.map((skin) => skin.id)).size).toBe(SKINS.length); + }); + + it("knows which class a thing fits", () => { + const anyone = SKINS.find((skin) => skin.fits === "any")!; + const arcane = SKINS.find((skin) => skin.fits === "codex")!; + expect(fitsClass(anyone, "terminal")).toBe(true); + expect(fitsClass(arcane, "codex")).toBe(true); + expect(fitsClass(arcane, "terminal")).toBe(false); + }); +}); + +describe("buying", () => { + it("takes the marks and hands over the goods", () => { + const result = buy(purse(100), "ash"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.purse.marks).toBe(100 - skinById("ash")!.cost); + expect(result.purse.owned).toContain("ash"); + }); + + it("leaves the old purse alone", () => { + /* So a shop can show what a purchase would cost before committing to it. */ + const before = purse(100); + buy(before, "ash"); + expect(before.marks).toBe(100); + expect(before.owned).toHaveLength(0); + }); + + it("refuses, with a reason, when there is not enough", () => { + const result = buy(purse(1), "gilt"); + expect(result).toEqual({ ok: false, reason: "poor" }); + }); + + it("refuses to sell the same thing twice", () => { + const result = buy(purse(1000, ["ash"]), "ash"); + expect(result).toEqual({ ok: false, reason: "owned" }); + }); + + it("refuses something that does not exist", () => { + expect(buy(purse(1000), "dragon")).toEqual({ ok: false, reason: "unknown" }); + }); + + it("cannot be spent into debt, however many times it is called", () => { + let current = purse(100); + for (const skin of SKINS) { + const result = buy(current, skin.id); + if (result.ok) current = result.purse; + } + expect(current.marks).toBeGreaterThanOrEqual(0); + }); +}); + +describe("wearing it", () => { + it("washes the figure in the colour it advertises", () => { + expect(tintFor("gilt")).toBe(skinById("gilt")!.tint); + }); + + it("leaves the art as drawn when nothing is equipped", () => { + /* White multiplied over a sprite is the sprite. */ + expect(tintFor(undefined)).toBe(0xffffff); + expect(tintFor("nonsense")).toBe(0xffffff); + }); + + it("shows the same colour in the shop as on the field", () => { + /* + * The swatch is not a decorative approximation of the skin: it is the + * skin. A preview that drifts from the thing being sold is a small lie. + */ + for (const skin of SKINS) { + expect(swatchFor(skin.id)).toBe(`#${skin.tint.toString(16).padStart(6, "0")}`); + } + }); +}); diff --git a/app/src/game/state/shop.ts b/app/src/game/state/shop.ts new file mode 100644 index 0000000..e984486 --- /dev/null +++ b/app/src/game/state/shop.ts @@ -0,0 +1,200 @@ +/** + * What marks can be spent on. + * + * Everything here is cosmetic. Nothing in the shop makes a wright hit harder, + * a wall hold longer or experience arrive faster, and that is a deliberate + * line rather than an oversight: the moment a purchase changes the numbers, + * the game stops being a read-out of work that happened and starts being + * something you could play wrong. There is no way to play this wrong. + * + * The fortification of the keep is *not* sold. It is earned by levelling, + * because the base growing is the reward for the work, and selling it would + * let somebody buy the thing the work is supposed to have bought them. + */ + +export interface Skin { + id: string; + name: string; + /** One line, in the world's voice, so the shop reads as a place. */ + note: string; + cost: number; + /** Which class it dresses, or "any" for anyone. */ + fits: string | "any"; + /** + * Who wears it: you, or the people who work for you. + * + * Two different things, sold side by side. A hero skin dresses your own + * figure; a retinue colour washes over your soldiers, so that a map with + * several companies on it reads as several companies rather than as one + * crowd. Neither touches anybody else's -- you cannot dress a colleague, and + * a shop that let you would be the only thing in this game that changes what + * somebody else sees. + */ + wears: "hero" | "retinue"; + /** + * The colour the wright is washed in. + * + * A skin used to be a sixteen-slot palette swap, because the artwork was + * palette-indexed text authored in this repository. The artwork is now + * Kenney's, which is ordinary PNG, so a skin is a tint: one number + * multiplied over the sprite by the renderer. Simpler, and it survives the + * artwork being replaced again. + */ + tint: number; +} + +/** + * The catalogue. + * + * Costs rise with how loud the skin is, which is the only balancing this + * needs: the quiet ones are affordable early, and the one that turns your + * whole garrison gold is a thing you save for. + */ +export const SKINS: Skin[] = [ + /* ---- what you wear ---- */ + { + id: "ash", + name: "Ashen", + note: "Whatever it was before, it has been through a fire since.", + cost: 40, + fits: "any", + wears: "hero", + tint: 0x9a9a9a, + }, + { + id: "wine", + name: "Winefast", + note: "Dyed properly, once, by somebody who was owed a favour.", + cost: 80, + fits: "any", + wears: "hero", + tint: 0xc96a92, + }, + { + id: "frost", + name: "Frostbound", + note: "Cold colours on a warm march. It does not help.", + cost: 80, + fits: "codex", + wears: "hero", + tint: 0x7fd0e8, + }, + { + id: "forge", + name: "Forgelit", + note: "The Artificers had these made. Nobody asked for them.", + cost: 120, + fits: "claude-code", + wears: "hero", + tint: 0xff9a3c, + }, + { + id: "gilt", + name: "Gilt", + note: "The Castellan's own colours. Wear them and mean it.", + cost: 260, + fits: "any", + wears: "hero", + tint: 0xf2d357, + }, + + /* ---- what your soldiers wear ---- */ + { + id: "moss", + name: "Moss livery", + note: "Issued to whoever was last through the gate.", + cost: 40, + fits: "any", + wears: "retinue", + tint: 0x76b055, + }, + { + id: "slate", + name: "Slate livery", + note: "Hard to see at dusk, which is half the point of it.", + cost: 60, + fits: "any", + wears: "retinue", + tint: 0x8fa3b8, + }, + { + id: "ember", + name: "Ember livery", + note: "A company you can find across a field, for better or worse.", + cost: 120, + fits: "any", + wears: "retinue", + tint: 0xe2703a, + }, + { + id: "indigo", + name: "Indigo livery", + note: "Expensive dye on people who will be in a ditch by Thursday.", + cost: 200, + fits: "any", + wears: "retinue", + tint: 0x7f7fe0, + }, +]; + +export function skinById(id: string): Skin | undefined { + return SKINS.find((skin) => skin.id === id); +} + +/** Whether a skin can be worn by a given class. */ +export function fitsClass(skin: Skin, kind: string): boolean { + /* + * A retinue colour is worn by a company of mixed classes, so it cannot be cut + * for one of them. Only what the hero wears is ever class-bound. + */ + if (skin.wears === "retinue") return true; + return skin.fits === "any" || skin.fits === kind; +} + +export interface Purse { + /** Marks earned, less marks spent. */ + marks: number; + owned: string[]; +} + +export type Refusal = "unknown" | "owned" | "poor"; + +export type PurchaseResult = { ok: true; purse: Purse } | { ok: false; reason: Refusal }; + +/** + * Buying something. + * + * Pure, and returns a new purse rather than changing the old one, so the + * caller can show the result of a purchase before committing to it and the + * service can run exactly the same function to check the client was telling + * the truth. + * + * Every refusal has a reason the interface can put into words. "Nothing + * happened" is the worst possible answer to a button press. + */ +export function buy(purse: Purse, skinId: string): PurchaseResult { + const skin = skinById(skinId); + if (!skin) return { ok: false, reason: "unknown" }; + if (purse.owned.includes(skinId)) return { ok: false, reason: "owned" }; + if (purse.marks < skin.cost) return { ok: false, reason: "poor" }; + return { + ok: true, + purse: { marks: purse.marks - skin.cost, owned: [...purse.owned, skinId] }, + }; +} + +export const REFUSALS: Record = { + unknown: "The pedlar has never heard of it.", + owned: "You have one already.", + poor: "Not enough marks.", +}; + +/** The tint a wright wears. White is "as drawn", which is no skin at all. */ +export function tintFor(skinId?: string): number { + return (skinId ? skinById(skinId)?.tint : undefined) ?? 0xffffff; +} + +/** The same number as a CSS colour, for the swatch in the shop. */ +export function swatchFor(skinId: string): string { + return `#${tintFor(skinId).toString(16).padStart(6, "0")}`; +} diff --git a/app/src/game/state/use-earned.ts b/app/src/game/state/use-earned.ts new file mode 100644 index 0000000..ce3f895 --- /dev/null +++ b/app/src/game/state/use-earned.ts @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { request } from "../../lib/api"; +import { NOTHING_EARNED, type Earned } from "./progress"; + +/** + * What the account has actually earned, asked of the service. + * + * The one number in the game that matters is computed where it cannot be + * argued with. `server/lib/game-stats.ts` counts the caller's own sessions -- + * how many finished, on how many days, from how many machines -- and this + * fetches the answer. The browser does not add anything up, because a browser + * that added this up could tell you it had earned whatever it liked. + * + * It fails soft, to nothing earned. A keep that will not open because the + * service is unreachable is worse than a keep that opens at level one and + * fills in a moment later. + */ + +interface Wire { + sessions?: number; + days?: number; + machines?: number; + mended?: number; + made?: number; +} + +/** Narrowed on the way in, exactly as everything else crossing this line is. */ +function toEarned(wire: Wire | undefined): Earned { + const count = (value: unknown) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + return { + sessions: count(wire?.sessions), + days: count(wire?.days), + machines: count(wire?.machines), + mended: count(wire?.mended), + made: count(wire?.made), + }; +} + +/** + * Asked for once on arrival, and again on a slow beat. + * + * Slow because none of this moves quickly: a session has to finish before any + * of these numbers change, and polling a ladder every second to watch it not + * move is a request per second spent on nothing. + */ +const EVERY = 60_000; + +export function useEarned(): { earned: Earned; known: boolean } { + const [earned, setEarned] = useState(NOTHING_EARNED); + const [known, setKnown] = useState(false); + + useEffect(() => { + let stopped = false; + + const ask = async () => { + try { + const reply = await request<{ stats?: Wire }>("/api/game/stats"); + if (stopped) return; + setEarned(toEarned(reply.stats)); + setKnown(true); + } catch { + /* Unreachable is not zero; it is unknown, and the HUD says so. */ + if (!stopped) setKnown(false); + } + }; + + void ask(); + const timer = window.setInterval(() => void ask(), EVERY); + return () => { + stopped = true; + window.clearInterval(timer); + }; + }, []); + + return { earned, known }; +} diff --git a/app/src/game/state/use-garrison.ts b/app/src/game/state/use-garrison.ts new file mode 100644 index 0000000..406bbef --- /dev/null +++ b/app/src/game/state/use-garrison.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from "react"; +import { fetchSessions } from "../../lib/api"; +import { rosterFrom, type Roster } from "./sessions"; +import { setRoster, type Sim } from "../world/sim"; + +/** The same interval the session list polls on, so the two agree. */ +const POLL_MS = 4000; + +export interface GarrisonState { + /** True until the first answer arrives, so nothing flashes. */ + loading: boolean; + /** Set when the service could not be reached, shown plainly rather than hidden. */ + error: string; + /** True when the field is showing the stand-in roster. */ + demo: boolean; + /** How many heroes and how many soldiers are on the field. */ + heroes: number; + soldiers: number; +} + +/** + * Keeps the field in step with the team and its sessions. + * + * Polling rather than pushing, at the same interval the session list uses, so + * the two never disagree for long about what is running. + * + * All the diffing that used to be here is gone. `setRoster` is idempotent: it + * musters what is new, dismisses what has left, and leaves everybody else + * exactly where they were standing. Two places deciding what had changed -- + * this hook and the simulation -- was two places that could disagree, and the + * way that showed was wrights teleporting back to the gate every four seconds, + * which looks like a rendering bug and is a data one. + * + * On a machine with no sign-in, or a team with nothing running, the field falls + * back to a stand-in and the HUD says so. An empty map is the honest picture of + * an account with no sessions, and it is also a terrible first impression: a + * country with nobody in it and no way to tell whether that is the point. + */ +export function useGarrison(sim: Sim, standIn: Roster, yourClass: string): GarrisonState { + const [state, setState] = useState({ + loading: true, + error: "", + demo: false, + heroes: 0, + soldiers: 0, + }); + /* Read by the poll without restarting it when the stand-in is rebuilt. */ + const fallback = useRef(standIn); + fallback.current = standIn; + /* Read by the poll without restarting it when the player changes class. */ + const chosen = useRef(yourClass); + chosen.current = yourClass; + + useEffect(() => { + let live = true; + + const load = async () => { + try { + const result = await fetchSessions(); + if (!live) return; + const roster = rosterFrom(result.sessions, result.members, result.you, chosen.current); + const demo = roster.soldiers.length === 0; + const shown = demo ? fallback.current : roster; + setRoster(sim, shown); + setState({ + loading: false, + error: "", + demo, + /* + * The totals, not the number of figures drawn. The field is capped so + * a large organisation cannot exhaust a browser; the read-out is not, + * because the read-out is the whole point of the game. + */ + heroes: shown.heroTotal, + soldiers: shown.soldierTotal, + }); + } catch (caught) { + if (!live) return; + /* + * A failure here is not fatal to the game. The keep is still worth + * looking at, and saying so beats an empty screen with no explanation. + */ + setRoster(sim, fallback.current); + setState({ + loading: false, + error: caught instanceof Error ? caught.message : "The service did not answer.", + demo: true, + heroes: fallback.current.heroTotal, + soldiers: fallback.current.soldierTotal, + }); + } + }; + + void load(); + const timer = window.setInterval(() => void load(), POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + /* + * Once. `sim` is a ref's contents and never changes identity; re-running + * this would restart the poll and re-muster the whole field. + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return state; +} diff --git a/app/src/game/ui/Barrow.tsx b/app/src/game/ui/Barrow.tsx new file mode 100644 index 0000000..842fbbe --- /dev/null +++ b/app/src/game/ui/Barrow.tsx @@ -0,0 +1,113 @@ +import { AWARD, type Earned } from "../state/progress"; +import { Mark, type MarkName } from "./Mark"; + +/** + * The Barrow: what the finished sessions came to. + * + * Every soldier on the map is a session that is running. When one closes its + * figure walks here and is taken off the field, and what it leaves behind is + * the only thing in this game that makes a level: a count. + * + * So this screen is the ledger behind the experience bar, written out. It is + * the honest answer to "where did that number come from", and the reason the + * bar can be trusted at all -- a progress bar with no derivation behind it is a + * progress bar you have to take on faith. + */ +export function Barrow({ + earned, + counted, + onBack, +}: { + earned: Earned; + /** False when the service did not answer. Unknown is not the same as none. */ + counted: boolean; + onBack: () => void; +}) { + const rows = [ + { + mark: "finished" as MarkName, + label: "Sessions run to the end", + count: earned.sessions, + each: AWARD.session, + note: "Started, finished, and closed without an error.", + }, + { + mark: "elapsed" as MarkName, + label: "Days anything was started", + count: earned.days, + each: AWARD.day, + note: "Worth the most of any of these, and the only one nobody can farm.", + }, + { + mark: "machine" as MarkName, + label: "Machines that answered", + count: earned.machines, + each: AWARD.machine, + note: "Each outpost that has ever sent a wright.", + }, + { + mark: "company" as MarkName, + label: "Faults mended", + count: earned.mended, + each: AWARD.mended, + note: "Finished sessions whose name reads as fixing something.", + }, + { + mark: "made" as MarkName, + label: "Things made", + count: earned.made, + each: AWARD.made, + note: "Finished sessions whose name reads as building something.", + }, + ]; + + const total = rows.reduce((sum, row) => sum + row.count * row.each, 0); + + return ( +
+

+ Every soldier on the field is a session that is running. When one ends, + it walks to the Barrow. This is what they have left behind. +

+ + {!counted && ( +

+ The service did not answer, so none of this has been counted yet. + Unknown is not the same as none. +

+ )} + +
    + {rows.map((row) => ( +
  • + + + {row.label} + {row.note} + + + {row.count.toLocaleString()} + {/* What each is worth, so the sum below can be checked by hand. */} + × {row.each} + +
  • + ))} +
+ +

+ Experience + {total.toLocaleString()} +

+ +

+ Nothing here can be earned by playing. Every figure above is something a + session did, counted by the service from its own records — which is why + walking your hero about the map changes none of it. +

+ + +
+ ); +} diff --git a/app/src/game/ui/ChooseCharacter.tsx b/app/src/game/ui/ChooseCharacter.tsx new file mode 100644 index 0000000..fb013e0 --- /dev/null +++ b/app/src/game/ui/ChooseCharacter.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { CLASS_LORE, OPENING, WORLD } from "../lore/world"; +import { Menu, type MenuItem } from "./Menu"; +import { Prompt } from "./Prompt"; + +/** + * The one thing the game asks before it starts. + * + * Shown once, on first launch, and never again unless somebody asks to change + * it. Everything else about the keep is derived from work that already + * happened; this is the single decision the player actually makes, which is + * why it gets a screen of its own rather than a dropdown in a settings panel. + * + * The opening is three lines. A wall of fiction in front of a game somebody + * opened out of curiosity is a wall they close. + */ +export function ChooseCharacter({ onChoose }: { onChoose: (kind: string) => void }) { + const [focused, setFocused] = useState(0); + const kinds = Object.keys(CLASS_LORE); + const current = CLASS_LORE[kinds[focused]] ?? CLASS_LORE.terminal; + + const items: MenuItem[] = kinds.map((kind) => ({ + id: kind, + label: CLASS_LORE[kind].title, + detail: CLASS_LORE[kind].motto, + onSelect: () => onChoose(kind), + })); + + return ( +
+
+
+

{WORLD.era}

+ {OPENING.map((line) => ( +

{line}

+ ))} +
+ +
+

Which are you?

+ + {/* + * The description sits beside the list rather than inside each row, + * so moving through five classes does not reflow the whole panel + * under the cursor. + */} + +
+ +
+ + This can be changed later. +
+
+
+ ); +} diff --git a/app/src/game/ui/Codex.tsx b/app/src/game/ui/Codex.tsx new file mode 100644 index 0000000..06a16f2 --- /dev/null +++ b/app/src/game/ui/Codex.tsx @@ -0,0 +1,106 @@ +import { GARRISONS } from "../world/marches"; +import { CLASS_LORE, FOE_LORE, WORLD } from "../lore/world"; + +/** + * The Chronicle: what everything in the keep is, and what it is a name for. + * + * Every entry does the same two things — says the fictional name, then says + * plainly what it corresponds to in the product. That second half is the whole + * point. Lore that only describes a fiction is a thing to be skipped; lore + * that quietly teaches somebody that a wright is a session and the Prompt is + * the shell process is doing a job. + * + * Read-only, and reached from the pause menu. Nothing here is a mechanic and + * nothing here can be missed by not reading it. + */ +export function Codex({ onBack }: { onBack: () => void }) { + return ( +
+
+

The Marches

+
+
+ {/* Capitalised here: these are headings, not mid-sentence mentions. */} +
The Prompt
+
+ The amber light in the hall. While it burns, the machine is up — it is the + shell process, and it goes out the way a process does. +
+
+
+
A wright
+
+ One session. Your machine sent it to do a piece of work, and it is on the + field for as long as that work is running. +
+
+
+
An outpost
+
A linked machine. The garrison musters from whichever ones are awake.
+
+
+
Elixir
+
+ Tokens spent gathering your statistics. It is the one figure in the keep + that is real money, which is why it has a vessel of its own. +
+
+
+
+ +
+

The garrison

+
+ {Object.entries(CLASS_LORE).map(([kind, lore]) => ( +
+
{lore.title}
+
+ {lore.motto} {lore.note} Mustered from a {kind}{" "} + session. +
+
+ ))} +
+
+ +
+

{WORLD.foe}

+

+ They come when a session is working on a fault, and not otherwise. A keep whose + sessions are all building things is a quiet keep. +

+
+ {FOE_LORE.map((foe) => ( +
+
{foe.name}
+
{foe.note}
+
+ ))} +
+
+ +
+

The holdings

+

+ Every one of these is somewhere on the map, with its name and its purpose on a + board outside it. This is the index, not the source: the Marches are meant to be + walked. +

+
+ {GARRISONS.map((garrison) => ( +
+
{garrison.name}
+
+ {garrison.purpose} {garrison.truth} +
+
+ ))} +
+
+ + +
+ ); +} diff --git a/app/src/game/ui/Gathering.tsx b/app/src/game/ui/Gathering.tsx new file mode 100644 index 0000000..e8c05dc --- /dev/null +++ b/app/src/game/ui/Gathering.tsx @@ -0,0 +1,160 @@ +import { useGathering } from "../state/gathering"; +import { WORLD } from "../lore/world"; + +/** + * The gathering: what it reads, what it costs, and how to stop it. + * + * This screen is written plainly and deliberately out of character. Everywhere + * else the game calls tokens essence and machines outposts, because that is the + * skin and the skin is the point. Here it does not, because somebody deciding + * whether to let a program read their work is not playing along, and a consent + * notice in fantasy voice is a consent notice designed not to be understood. + * + * Off until it is switched on, and one press turns it off again. The list below + * is the bill: a total on its own is a number to be taken on trust, and a person + * who has agreed to this is owed the itemised version. + */ +function when(at: number, now: number): string { + const ago = Math.max(0, now - at); + const minutes = Math.round(ago / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +export function Gathering({ + on, + tokens, + onAgree, + onStop, + onBack, +}: { + on: boolean; + /** The total in the vial, which is the sum of the runs below. */ + tokens: number; + onAgree: () => void; + onStop: () => void; + onBack: () => void; +}) { + const { runs, asking, refusal, asked, gatherNow } = useGathering(on); + const now = Date.now(); + + return ( +
+

+ {on + ? `Gathering is on. ${tokens.toLocaleString()} tokens spent so far.` + : "Gathering is off. Nothing is being read."} +

+ + {/* + * What is read and what is not, before the button rather than after it. + * The second list is the one that matters, and it is second because it + * is the reassurance: you cannot be reassured about a thing you have not + * been told yet. + */} +
+

What your machine would read

+
    +
  • Git in the folder a session is running in: how many commits, and how many lines changed.
  • +
  • How many pull requests you have open, if the GitHub CLI is installed.
  • +
  • How many tokens your coding agent has spent, from its own local files.
  • +
+ +

What it never reads

+
    +
  • Terminal output. Nothing that appears in a session is read, sent or stored.
  • +
  • File contents, diffs, commit messages, branch names, prompts or replies.
  • +
+ +

Where it goes

+

+ Your machine does the reading and sends counts — numbers and nothing + else. This service cannot read a session: they are encrypted end to + end and it holds no key. That is why the reading happens on your + machine rather than here. +

+ +

+ Reading your agent's token totals costs nothing. Any run that spends + tokens is listed below, to the token. +

+
+ +
+ {on ? ( + <> + + + + ) : ( + + )} +
+ + {asking === "asked" && ( +

+ {asked.length > 0 + ? `Asked ${asked.join(", ")}. The bill appears when they answer.` + : "Asked. The bill appears when a machine answers."} +

+ )} + {asking === "failed" && ( +

+ {refusal} +

+ )} + + {on && ( + <> +

What it has cost

+ {runs.length === 0 ? ( +

+ Nothing has run yet. A machine has to be signed in with{" "} + shell agent running for there to be anything to ask. +

+ ) : ( +
    + {runs.map((run) => ( +
  • + + {run.device} + {when(run.ranAt, now)} + + {run.error ? ( + /* A run that failed still happened, and still may have cost. */ + Failed: {run.error} + ) : ( + + {run.pullRequests} open {run.pullRequests === 1 ? "PR" : "PRs"} ·{" "} + {run.commits} commits · +{run.insertions.toLocaleString()} − + {run.deletions.toLocaleString()} + + )} + + {run.tokens.toLocaleString()} tokens + +
  • + ))} +
+ )} +

+ Those totals are what fills the {WORLD.essence.toLowerCase()} vial on the field. +

+ + )} + + +
+ ); +} diff --git a/app/src/game/ui/Hud.tsx b/app/src/game/ui/Hud.tsx new file mode 100644 index 0000000..eb61e38 --- /dev/null +++ b/app/src/game/ui/Hud.tsx @@ -0,0 +1,229 @@ +import { CLASS_LORE, WORLD } from "../lore/world"; +import { nextUnlock, type Standing } from "../state/progress"; +import type { Actor } from "../world/sim"; + +/** + * What the player needs to know without opening anything. + * + * Four things, and nothing else: who they are and how far along, what they can + * spend, what the stat-gathering has cost, and who is on the field. Everything + * else lives behind the pause menu, because a heads-up display is screen space + * borrowed from the thing it is displaying over. + * + * All of it is DOM rather than canvas. It does not move with the world, it has + * to be readable by a screen reader, and it has to be reachable with a pad — + * three things the canvas is bad at and the document is good at. + */ + +export interface HudProps { + standing: Standing; + marks: number; + /** Tokens the stat-gathering has spent. See the elixir vial below. */ + elixir: number; + /** Whether any stat-gathering has been agreed to at all. */ + gathering: boolean; + characterClass: string; + wrights: Actor[]; + /** True when the field is showing a stand-in garrison, not real sessions. */ + demo: boolean; + /** + * Whether the service has told us what has been earned. + * + * Unreachable is not nothing earned, and a bar reading zero because a + * request failed is a bar telling somebody their week did not count. + */ + counted: boolean; + onOpenRoster: () => void; + /** Opens the gathering: the notice when it is off, the bill when it is on. */ + onOpenGathering: () => void; +} + +/** A bar with its numbers beside it, never colour alone. */ +function Meter({ + label, + value, + of, + tone, + detail, +}: { + label: string; + value: number; + of: number; + tone: "xp" | "elixir"; + detail?: string; +}) { + const fraction = of > 0 ? Math.min(1, Math.max(0, value / of)) : 0; + return ( +
+
+ {label} + + {value.toLocaleString()} + {of > 0 && / {of.toLocaleString()}} + +
+
+ +
+ {detail &&

{detail}

} +
+ ); +} + +/** + * How full the vial looks, which is not how many tokens there are. + * + * Logarithmic, because the range this has to cover is absurd. A light week is + * a few hundred thousand tokens and a heavy one is hundreds of millions -- a + * real machine reported eighty-three million for three days of ordinary work. + * On the linear scale this used to have, which filled at a million, that pinned + * the vial at the top on the first run and it never said anything again. + * + * A decade of tokens is a fifth of the vial: a hundred thousand is a third + * full, ten million is two thirds, a billion is the top. The exact number is + * printed beside it, which is where precision belongs; this is for the glance. + */ +function vialFill(tokens: number): number { + if (tokens <= 0) return 0; + const decades = Math.log10(tokens) / 9; + return Math.min(100, Math.max(4, decades * 100)); +} + +/** + * The elixir vial: what the stat-gathering has spent, in tokens. + * + * On the HUD rather than in a settings page because it is the one number in + * this game that costs real money. Somebody playing with a resource gauge + * should see at a glance that the gauge is their own spend, and clicking it + * says where every drop went. + */ +function Elixir({ tokens, gathering }: { tokens: number; gathering: boolean }) { + return ( +
+
+ ); +} + +export function Hud({ + standing, + marks, + elixir, + gathering, + characterClass, + wrights, + demo, + counted, + onOpenRoster, + onOpenGathering, +}: HudProps) { + const lore = CLASS_LORE[characterClass] ?? CLASS_LORE.terminal; + const unlock = nextUnlock(standing.level); + const fighting = wrights.filter((wright) => wright.work === "bug").length; + const building = wrights.filter((wright) => wright.work === "feature").length; + + /* + * Corners rather than a bar. + * + * A strip across the top was one panel wide enough to reach from edge to edge + * and tall enough to hold three rows, and what it mostly did was cover the + * map. Everything on it is glanced at rather than read, and things that are + * glanced at belong at the edges of the eye, not across the middle of what + * you are looking at. + * + * So: who you are, top left, because it is the only thing here you might read + * a whole sentence of. What you have, bottom left, where money lives in every + * game anybody has played. Who is out, bottom right, next to the key prompt. + * The top right is left for the pause button, which was already there. + */ + return ( + <> +
+
+
+ + + {lore.title} + Level {standing.level} + +
+ +
+
+ +
+
+ + + {marks.toLocaleString()} + {WORLD.coin} + +
+ + {/* + * The vial is a way in, not an ornament. A figure that stands for + * money somebody's machine has spent should be one press from the + * account of what spent it -- and while it is off, one press from the + * notice explaining what turning it on would read. + */} + +
+ +
+ +
+ + ); +} diff --git a/app/src/game/ui/Marches.tsx b/app/src/game/ui/Marches.tsx new file mode 100644 index 0000000..0ca72c0 --- /dev/null +++ b/app/src/game/ui/Marches.tsx @@ -0,0 +1,56 @@ +import { GARRISONS } from "../world/marches"; + +/** + * The road book: every holding, what it is for, and a way to go there. + * + * This exists because the map got big. On a country you can see all of at once + * there is nothing to navigate and a list of places would be furniture; on one + * several screens across there is, and walking from the Keep to the Muster Yard + * to find out whether anything is happening there is a chore rather than + * exploration. + * + * It is also the second place the lore is written down, and deliberately the + * lesser one. The sentence here is the same sentence on the board outside the + * holding, so reading the list is a way of remembering what you have seen + * rather than a substitute for going and seeing it. What the list adds is the + * line the board does not carry: what each holding is a rename of. + */ +export function Marches({ + onTravel, + onBack, +}: { + onTravel: (id: string) => void; + onBack: () => void; +}) { + return ( +
+

+ Ten holdings. The roads all meet at the Keep. +

+ +
    + {GARRISONS.map((garrison) => ( +
  • + + {garrison.name} + {garrison.purpose} + {/* The part the board outside does not say. */} + {garrison.truth} + + +
  • + ))} +
+ + +
+ ); +} diff --git a/app/src/game/ui/Mark.tsx b/app/src/game/ui/Mark.tsx new file mode 100644 index 0000000..8db0f9e --- /dev/null +++ b/app/src/game/ui/Mark.tsx @@ -0,0 +1,33 @@ +/** + * The marks the interface uses beside a label. + * + * Drawn icons from the medieval pack rather than typographic glyphs. The glyphs + * were doing the job badly: ⚒ and ⧗ and ❯ are whatever the reader's font + * happens to have for them, they sit on the baseline at sizes nobody chose, and + * three of them rendered as boxes on at least one machine this was looked at + * on. An icon is the same picture everywhere. + * + * Never on its own. Every one of these sits beside a word, because a picture + * nobody has been taught is decoration, and the word is the thing that actually + * says what the row is. + */ + +/** What each mark is for. One name per thing, not one per icon. */ +const MARKS = { + work: "lance", + company: "shield", + machine: "tower", + running: "torch", + elapsed: "map", + condition: "goblet", + finished: "castle", + made: "wagon", +} as const; + +export type MarkName = keyof typeof MARKS; + +export function Mark({ name }: { name: MarkName }) { + return ( + + ); +} diff --git a/app/src/game/ui/Menu.tsx b/app/src/game/ui/Menu.tsx new file mode 100644 index 0000000..266b322 --- /dev/null +++ b/app/src/game/ui/Menu.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { actionForKey, type GameAction } from "../engine/input"; +import { firstEnabled, nextIndex, restoreIndex } from "../engine/menu"; +import { useGamepadActions } from "../engine/use-gamepad"; + +export interface MenuItem { + id: string; + label: string; + /** A second line, for what the choice costs or what it will do. */ + detail?: string; + icon?: ReactNode; + disabled?: boolean; + /** Draws it as the way out, and never as the item focus lands on first. */ + danger?: boolean; + onSelect: () => void; +} + +/** + * A vertical menu that a keyboard, a pad and a thumb can all drive. + * + * Real ` + ))} + + ); +} diff --git a/app/src/game/ui/OptionsPanel.tsx b/app/src/game/ui/OptionsPanel.tsx new file mode 100644 index 0000000..9caf23a --- /dev/null +++ b/app/src/game/ui/OptionsPanel.tsx @@ -0,0 +1,138 @@ +import { useGameShell } from "../state/context"; +import { + SAFE_ZONE_RANGE, + UI_SCALE_RANGE, + type ColourSetting, + type MotionSetting, +} from "../state/options"; + +const MOTION_CHOICES: { value: MotionSetting; label: string; detail: string }[] = [ + { value: "system", label: "Match my system", detail: "Follows the setting on this device" }, + { value: "full", label: "Full", detail: "Shake, drift and particles" }, + { value: "reduced", label: "Reduced", detail: "Still frames, no shake, no particles" }, +]; + +const COLOUR_CHOICES: { value: ColourSetting; label: string }[] = [ + { value: "default", label: "Default" }, + { value: "deuteranopia", label: "Deuteranopia" }, + { value: "protanopia", label: "Protanopia" }, + { value: "tritanopia", label: "Tritanopia" }, +]; + +/** + * The settings that decide whether the game is playable at all on a given + * screen, for a given person. + * + * Grouped with the reason above each one rather than a bare label. "Safe area" + * means nothing to somebody whose television is eating their health bar; "if + * the corners of the board are cut off, raise this" tells them what to do. + */ +export function OptionsPanel({ onBack }: { onBack: () => void }) { + const { options, setOptions } = useGameShell(); + + return ( +
+
+ Safe area +

+ Televisions crop the edges of the picture. If the corners of the frame below are cut + off, raise this until all four are visible. +

+
+ setOptions({ ...options, safeZone: Number(event.target.value) })} + /> + {options.safeZone}% +
+ {/* + * A calibration target, not decoration. It sits exactly on the inset + * the slider sets, so "can you see all four corners" is a question + * somebody can answer by looking rather than by guessing at a number. + */} + +
+ +
+ Interface size +

+ Larger for a television across the room, smaller for a monitor at arm’s length. +

+
+ setOptions({ ...options, uiScale: Number(event.target.value) })} + /> + {options.uiScale}% +
+
+ +
+ Motion +

+ Drifting and shaking can cause nausea. Reduced keeps everything readable and still. +

+
+ {MOTION_CHOICES.map((choice) => ( + + ))} +
+
+ +
+ Colour +

+ Every state in the keep carries an icon and a word as well as a colour. These palettes + widen the gaps between the colours themselves. +

+
+ {COLOUR_CHOICES.map((choice) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/app/src/game/ui/PauseMenu.tsx b/app/src/game/ui/PauseMenu.tsx new file mode 100644 index 0000000..129b530 --- /dev/null +++ b/app/src/game/ui/PauseMenu.tsx @@ -0,0 +1,266 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { BORING_UI, KEEP_BUILD } from "../keep"; +import { CLASS_LORE } from "../lore/world"; +import type { Purse } from "../state/shop"; +import type { Earned } from "../state/progress"; +import { useGameShell } from "../state/context"; +import { Codex } from "./Codex"; +import { Barrow } from "./Barrow"; +import { Gathering } from "./Gathering"; +import { Marches } from "./Marches"; +import { Menu, type MenuItem } from "./Menu"; +import { OptionsPanel } from "./OptionsPanel"; +import { Prompt } from "./Prompt"; +import { Shop } from "./Shop"; + +type Pane = "root" | "marches" | "barrow" | "gathering" | "options" | "shop" | "codex"; + +/** + * The pause screen, and everything reached from it. + * + * The field carries four numbers and nothing else; everything a player might + * want but does not need at a glance lives behind this. That is the whole + * division: a heads-up display is screen space borrowed from the game, and a + * pause menu is space that costs nothing because the game has stopped. + * + * The way out is the last item and it says what it does in plain words rather + * than in character, because a person looking for the exit has stopped playing + * along. + */ +export function PauseMenu({ + onResume, + purse, + characterClass, + wearing, + livery, + shopOpen, + elixir, + garrison, + onBuy, + onWear, + onTravel, + gathering, + onGathering, + earned, + counted, + openAt, +}: { + onResume: () => void; + purse: Purse; + characterClass: string; + wearing: string; + /** What this player's soldiers are wearing. */ + livery: string; + /** The pedlar starts calling at level two; before that the row says so. */ + shopOpen: boolean; + /** Tokens the gathering has spent, and who is on the field. */ + elixir: number; + garrison: number; + onBuy: (skinId: string) => void; + onWear: (skinId: string) => void; + /** Rides to a holding and closes the menu. The map is too big to walk. */ + onTravel: (garrisonId: string) => void; + /** Whether the account has agreed to its machines being read. */ + gathering: boolean; + onGathering: (on: boolean) => void; + /** What the finished sessions came to, for the Barrow. */ + earned: Earned; + counted: boolean; + /** Which pane to open on, so the vial can lead straight to the bill. */ + openAt?: "gathering"; +}) { + const navigate = useNavigate(); + const [pane, setPane] = useState(openAt ?? "root"); + const { options } = useGameShell(); + const panel = useRef(null); + /* Where the root menu was, so a side trip does not reset it. */ + const rootIndex = useRef(0); + /* Focus goes back where it came from when the menu closes. */ + const returnFocus = useRef(null); + + useEffect(() => { + returnFocus.current = document.activeElement as HTMLElement | null; + return () => returnFocus.current?.focus?.(); + }, []); + + /* + * A modal that does not hold focus is a modal a keyboard can walk out of + * while it is still covering the screen, which leaves somebody typing into + * a page they cannot see. + */ + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + const focusable = panel.current?.querySelectorAll( + "button:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])", + ); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + + const lore = CLASS_LORE[characterClass] ?? CLASS_LORE.terminal; + + const rootItems: MenuItem[] = [ + { + id: "resume", + label: "Resume", + detail: "Back to the field", + onSelect: onResume, + }, + { + id: "shop", + label: "The pedlar", + detail: shopOpen + ? `${purse.marks.toLocaleString()} marks to spend` + : "Starts calling at level 2", + disabled: !shopOpen, + onSelect: () => setPane("shop"), + }, + { + id: "marches", + label: "The Marches", + detail: "Ten holdings, and the road to each", + onSelect: () => setPane("marches"), + }, + { + id: "barrow", + label: "The Barrow", + detail: counted + ? `${earned.sessions.toLocaleString()} sessions run to the end` + : "Not counted yet", + onSelect: () => setPane("barrow"), + }, + { + id: "gathering", + label: "The gathering", + detail: gathering + ? `${elixir.toLocaleString()} tokens spent` + : "Off. Nothing is being read", + onSelect: () => setPane("gathering"), + }, + { + id: "codex", + label: "The Chronicle", + detail: "What everything here is a name for", + onSelect: () => setPane("codex"), + }, + { + id: "options", + label: "Options", + detail: `Safe area ${options.safeZone}% · Interface ${options.uiScale}%`, + onSelect: () => setPane("options"), + }, + { + id: "quit", + label: "Quit to boring UI", + detail: "Back to the session list", + danger: true, + onSelect: () => navigate(BORING_UI), + }, + ]; + + const TITLES: Record = { + root: "Paused", + marches: "The Marches", + barrow: "The Barrow", + gathering: "The gathering", + options: "Options", + shop: "The pedlar", + codex: "The Chronicle", + }; + const title = TITLES[pane]; + + return ( +
+
+
+ {/* Icon and word together: never the icon alone, never the colour alone. */} + +

{title}

+ {pane === "root" && {lore.title}} +
+ + {/* + * The figures the HUD carries on a wide screen and drops on a narrow + * one. Here rather than only there, so a phone loses nothing. + */} + {pane === "root" && ( +

+ ◈ {purse.marks.toLocaleString()} marks + {garrison} on the field + {elixir > 0 ? `${elixir.toLocaleString()} tokens` : "not gathering"} +

+ )} + + {pane === "root" && ( + { + rootIndex.current = index; + }} + /> + )} + {pane === "marches" && ( + { + onTravel(id); + onResume(); + }} + onBack={() => setPane("root")} + /> + )} + {pane === "barrow" && ( + setPane("root")} /> + )} + {pane === "gathering" && ( + onGathering(true)} + onStop={() => onGathering(false)} + onBack={() => setPane("root")} + /> + )} + {pane === "options" && setPane("root")} />} + {pane === "shop" && ( + setPane("root")} + /> + )} + {pane === "codex" && setPane("root")} />} + +
+ + Build {KEEP_BUILD} +
+
+
+ ); +} diff --git a/app/src/game/ui/Prompt.tsx b/app/src/game/ui/Prompt.tsx new file mode 100644 index 0000000..9f7f01a --- /dev/null +++ b/app/src/game/ui/Prompt.tsx @@ -0,0 +1,27 @@ +import { promptFor, type GameAction } from "../engine/input"; +import { useGameShell } from "../state/context"; + +/** + * "Press + ) : ( + + )} + + ); + })} + + + ))} + + + + ); +} diff --git a/app/src/game/ui/SkinPortrait.tsx b/app/src/game/ui/SkinPortrait.tsx new file mode 100644 index 0000000..756abde --- /dev/null +++ b/app/src/game/ui/SkinPortrait.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; + +/** + * The figure a skin actually dresses, drawn at the size you would see it. + * + * The shop used to show a colour swatch, which is honest -- a skin *is* one + * number multiplied over a sprite -- but it is not what anybody is buying. A + * square of #c96a92 tells you nothing about what your own wright looks like + * wearing it, and the whole product here is how the figure reads on the map. + * + * So this draws the same sprite the renderer draws, from the same atlas, tinted + * the same way. Multiply, then the sprite again as a mask to put the + * transparency back, which is what Pixi's `tint` does and the reason the two + * agree. If they were computed differently the shop would be lying about the + * goods. + * + * Nearest-neighbour on the way up. Kenney's units are 33 pixels tall and this + * shows them at four times that; smoothed, they come out as a blur of a + * soldier rather than a soldier. + */ + +interface Frame { + frame: { x: number; y: number; w: number; h: number }; +} + +/** + * The atlas, fetched once for the whole shop rather than once per item. + * + * A module-level promise rather than state: there are a dozen portraits on + * screen and they all want the same two files, and twelve components each + * starting their own fetch is twelve requests for one picture. + */ +let atlas: Promise<{ frames: Record; image: HTMLImageElement }> | undefined; + +function load() { + atlas ??= (async () => { + const [sheet, image] = await Promise.all([ + fetch("/game/medieval-rts.json").then((response) => response.json()), + new Promise((resolve, reject) => { + const picture = new Image(); + picture.onload = () => resolve(picture); + picture.onerror = reject; + picture.src = "/game/medieval-rts.png"; + }), + ]); + return { frames: (sheet.frames ?? sheet) as Record, image }; + })(); + return atlas; +} + +export function SkinPortrait({ + unit, + tint, + label, + scale = 4, +}: { + /** The atlas frame to draw, e.g. `Unit_05`. */ + unit: string; + /** The skin's colour, as the renderer would multiply it. */ + tint: number; + /** What a reader who cannot see the picture is told instead. */ + label: string; + scale?: number; +}) { + const canvas = useRef(null); + + useEffect(() => { + let live = true; + load() + .then(({ frames, image }) => { + const node = canvas.current; + const cut = frames[unit]; + if (!live || !node || !cut) return; + + const { x, y, w, h } = cut.frame; + node.width = w * scale; + node.height = h * scale; + + const ctx = node.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, node.width, node.height); + ctx.drawImage(image, x, y, w, h, 0, 0, node.width, node.height); + + /* The tint, exactly as the renderer applies it. */ + ctx.globalCompositeOperation = "multiply"; + ctx.fillStyle = `#${tint.toString(16).padStart(6, "0")}`; + ctx.fillRect(0, 0, node.width, node.height); + + /* And the sprite again, to give the transparency back. */ + ctx.globalCompositeOperation = "destination-in"; + ctx.drawImage(image, x, y, w, h, 0, 0, node.width, node.height); + ctx.globalCompositeOperation = "source-over"; + }) + .catch(() => { + /* + * A shop with no pictures in it is still a shop. The name, the line of + * lore and the price are all beside it, so a failed sprite sheet costs + * the picture and not the ability to spend. + */ + }); + return () => { + live = false; + }; + }, [unit, tint, scale]); + + return ; +} diff --git a/app/src/game/ui/WrightPanel.tsx b/app/src/game/ui/WrightPanel.tsx new file mode 100644 index 0000000..261f316 --- /dev/null +++ b/app/src/game/ui/WrightPanel.tsx @@ -0,0 +1,188 @@ +import type React from "react"; +import { kindById } from "../../lib/session-kinds"; +import { CLASS_LORE } from "../lore/world"; +import { Mark } from "./Mark"; +import { garrisonById } from "../world/marches"; +import type { Actor } from "../world/sim"; + +/** + * Everything known about one wright, shown when it is clicked. + * + * This is the answer to "the game is non-interactive". The map was a thing you + * watched; now the figures on it are the sessions in your account and clicking + * one tells you which, on what machine, running what, since when, and where it + * has been posted. + * + * It is deliberately a panel at the side rather than a modal over the middle. + * A modal would cover the thing you just clicked, which is the one part of the + * screen you were looking at. + */ + +function since(startedAt: number, now: number): string { + const seconds = Math.max(0, Math.round((now - startedAt) / 1000)); + if (seconds < 90) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 90) return `${minutes}m`; + const hours = Math.round(minutes / 60); + return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`; +} + +const WORK_WORDS: Record = { + bug: { title: "Mending", note: "Out against the Unmade." }, + feature: { title: "Making", note: "Raising something that was not there." }, + idle: { title: "Standing to", note: "No fault named, nothing being built." }, +}; + +export function WrightPanel({ + actor, + field, + now, + onClose, + onOpenSession, + cardRef, +}: { + actor: Actor; + /** Everybody on the field, for "whose company is this". */ + field: Actor[]; + now: number; + onClose: () => void; + onOpenSession?: (sessionId: string) => void; + /** + * The card's own element, which the scene moves. + * + * It follows the figure it is about, sixty times a second, so its position is + * written straight onto the node rather than held in React state -- a card + * that re-rendered the route at frame rate to move one box would be paying a + * component tree for an arithmetic problem. + */ + cardRef?: React.Ref; +}) { + const lore = CLASS_LORE[actor.kind] ?? CLASS_LORE.terminal; + const posting = garrisonById(actor.home); + const work = WORK_WORDS[actor.work]; + const icon = kindById(actor.kind)?.icon; + /* + * Everybody on the field is handed in rather than looked up from a store, + * because the card is about one figure and this is the only thing it needs + * the rest of them for. + */ + const company = field.filter( + (one) => one.role === "soldier" && one.heroUid === actor.heroUid, + ); + const captain = field.find( + (one) => one.role === "hero" && one.heroUid === actor.heroUid, + ); + + return ( + + ); +} diff --git a/app/src/game/world/banners.ts b/app/src/game/world/banners.ts new file mode 100644 index 0000000..339ae44 --- /dev/null +++ b/app/src/game/world/banners.ts @@ -0,0 +1,89 @@ +/** + * A colour per hero, so you can tell whose company is whose. + * + * This exists because of a bug report that turned out not to be a bug. Point + * and click worked perfectly; what did not work was *seeing* that it had, since + * one figure among several dozen looks much like another and nothing on the map + * said which one was yours. The controls were fine and the map was unreadable, + * which from the other side of the screen is the same complaint. + * + * So every hero gets a colour, and the ground they command is washed in it. A + * company becomes an area rather than a crowd, and your own is the one you can + * find without reading anything. + * + * Derived from the account id rather than assigned, for the same reason camps + * are: the same person is the same colour on every machine, for everybody + * looking at the same team, with nothing stored anywhere to disagree about. + */ + +/** + * Hues spread around the wheel, avoiding the greens. + * + * The map is grass. A company washed in green is a company you cannot see, so + * the band from about 70 to 160 degrees is left out -- which is why these are + * listed rather than computed as `hash % 360`. + */ +const HUES = [212, 28, 320, 262, 8, 186, 44, 292, 166, 340, 234, 62]; + +/** How far a hero's colour reaches. Wide enough to hold their retinue. */ +export const BANNER_RADIUS = 9; + +function hashUid(uid: string): number { + let value = 0; + for (let index = 0; index < uid.length; index += 1) { + value = (Math.imul(value, 31) + uid.charCodeAt(index)) | 0; + } + return Math.abs(value); +} + +/** Hue, saturation and lightness to a packed RGB number. */ +function fromHsl(hue: number, saturation: number, lightness: number): number { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const second = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); + const match = lightness - chroma / 2; + const sector = Math.floor(hue / 60) % 6; + const [red, green, blue] = [ + [chroma, second, 0], + [second, chroma, 0], + [0, chroma, second], + [0, second, chroma], + [second, 0, chroma], + [chroma, 0, second], + ][sector]; + const to = (value: number) => Math.round((value + match) * 255); + return (to(red) << 16) | (to(green) << 8) | to(blue); +} + +/** + * The colour of one hero's banner. + * + * `order` is their place in the roster, and it breaks ties the same way camps + * do: two people whose ids happen to hash to the same hue would otherwise be + * the same colour, which is precisely the thing this is for. + */ +export function bannerFor(uid: string, order = 0): number { + const hue = HUES[(hashUid(uid) + order) % HUES.length]; + return fromHsl(hue, 0.72, 0.58); +} + +/** Every hero's colour at once, so no two in a roster come out the same. */ +export function assignBanners(uids: string[]): Map { + const taken = new Set(); + const banners = new Map(); + + for (const uid of [...uids].sort()) { + const wanted = hashUid(uid) % HUES.length; + let slot = wanted; + for (let probe = 0; probe < HUES.length; probe += 1) { + const at = (wanted + probe) % HUES.length; + if (taken.has(at)) continue; + slot = at; + break; + } + /* More heroes than hues: they share, which beats vanishing. */ + taken.add(slot); + banners.set(uid, fromHsl(HUES[slot], 0.72, 0.58)); + } + + return banners; +} diff --git a/app/src/game/world/border.ts b/app/src/game/world/border.ts new file mode 100644 index 0000000..3a7c214 --- /dev/null +++ b/app/src/game/world/border.ts @@ -0,0 +1,287 @@ +import { MAP } from "./marches"; +import { TILE_H, TILE_W } from "./iso"; + +/** + * The wood that closes the Marches in. + * + * Zoomed out, the country was a diamond of grass floating in a flat void, with + * the four corners of the view showing the colour the renderer happens to clear + * to. That reads as an unfinished map rather than as a place with edges -- the + * player is not looking at the end of the world, they are looking at the end of + * the *tiles*, and those are different things. + * + * So the country ends in forest. A dark canopy is laid under everything and + * carried well past the playable bounds, and trees are scattered over whatever + * of it is not covered by the map, thickest right against the edge so the hard + * diamond line disappears under branches. + * + * This is measured in screen units rather than tiles, unlike the rest of + * `world/`. The thing being filled is the *shape the projection makes*, which + * is a diamond inscribed in a rectangle, and the corners to be covered are not + * tiles at all -- there is no tile coordinate for them. Working in tiles here + * would mean extending the grid four times over to reach ground nobody can + * walk on. + */ + +/** The playable country, projected: a diamond this wide and this tall. */ +export const COUNTRY = { + width: MAP.width * TILE_W, + height: MAP.height * TILE_H, +}; + +/** + * How far past the country the wood is carried. + * + * Nearly twice the country's own width, which sounds absurd and is not. At the + * widest zoom the country is shorter than the window and pixi-viewport centres + * a world smaller than its view, so what is beyond the country is what fills + * the bands above and below -- and on a wide or a tall window those bands are + * large. The first number here was chosen from the arithmetic for one window + * size and left the corners of a different one showing the clear colour. + * + * It costs nothing to be generous. The whole of this is baked into one texture + * whose size is fixed by the bake scale, not by the area it covers. + */ +export const OVERHANG = 7600; + +export type Standing = "tree" | "rock" | "ruin"; + +export interface Tree { + /** Screen position, in world units. */ + x: number; + y: number; + sprite: string; + scale: number; + /** 0 against the country, 1 deep in the wood. Drives how dark it is drawn. */ + depth: number; + /** What it is, which decides how big and how dark it is drawn. */ + kind: Standing; +} + +/** + * What stands in the border wood. + * + * Old growth: the trees out here are drawn at roughly twice the size of the + * ones inside the country. That is the whole difference between a hedge and a + * forest -- a barrier you could walk through is not a barrier, and at the zoom + * where the edge of the map matters, small trees read as scrub. + */ +const TREES = ["Environment_01", "Environment_02", "Environment_03", "Environment_21"]; + +/** Boulders, the big ones. The small stones belong in the country, not here. */ +const ROCKS = ["Environment_07", "Environment_08", "Environment_09", "Environment_10", "Environment_11"]; + +/** + * Ruins: whatever stood here before the Marches did. + * + * Kenney's pack has no broken wall, so these are its stone buildings drawn dark + * and half-swallowed by the trees around them. A whole building at the edge of + * the world would read as a place you can go; one glimpsed between trunks, in + * shadow, reads as something older than the map. + */ +const RUINS = ["Structure_12", "Structure_05", "Structure_06"]; + +/** The murmur3 finaliser; see the note in world/scatter.ts about the cheap one. */ +function noise(x: number, y: number, channel: number): number { + let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1) ^ Math.imul(channel + 1, 0x9e3779b1); + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4_294_967_296; +} + +/** + * How far outside the country a point is: 0 on the edge, 1 well beyond it. + * + * The projection makes a diamond, so "outside" is the taxicab distance from the + * middle in units of the half-width and half-height, which is exactly what + * makes the four sides straight. + */ +export function beyond(x: number, y: number): number { + const halfWidth = COUNTRY.width / 2; + const halfHeight = COUNTRY.height / 2; + const reach = + Math.abs(x - halfWidth) / halfWidth + Math.abs(y - halfHeight) / halfHeight; + return reach - 1; +} + +/** + * The rectangle the canopy covers: the country's bounds, plus the overhang. + * + * Returned rather than computed twice, because the layer that fills it and the + * loop that scatters trees over it have to agree exactly. A canopy one pixel + * smaller than the trees standing on it is a hairline of void at the edge of + * the screen, which is the whole fault this file exists to fix. + */ +export function canopyBounds(): { x: number; y: number; width: number; height: number } { + return { + x: -OVERHANG, + y: -OVERHANG, + width: COUNTRY.width + OVERHANG * 2, + height: COUNTRY.height + OVERHANG * 2, + }; +} + +/** + * How far out of the country real tree sprites are placed. + * + * Narrow, and the number came from a frame counter rather than from taste. + * Scattering sprites over the whole canopy put four and a half thousand of them + * on the map on top of the two thousand already in the country, and the frame + * rate went from fifty-six to eighteen. Every one of those is a display object + * whose transform is walked each frame, whether or not it is on screen and + * whether or not it ever moves. + * + * Sprites earn their cost only where they are doing something a flat shape + * cannot: breaking up the straight edge of the projection, which happens within + * a few tree-widths of it. Beyond that the wood is drawn, not built -- see + * `canopyBlobs`. + */ +const BAND = 520; + +/** + * How big each thing in the wood is drawn, against the country's own scatter. + * + * Everything here is larger than its counterpart inside the map. The wood is + * meant to be impassable, and the reading of "impassable" is entirely a matter + * of how big the trunks are next to a figure who is thirty pixels tall. + */ +const SIZE: Record = { + tree: { from: 1.5, to: 2.4 }, + rock: { from: 1.1, to: 1.9 }, + ruin: { from: 1.0, to: 1.5 }, +}; + +const NEAR = 46; + +/** + * Where the border trees stand: in a thicket hugging the country, and nowhere + * else. + */ +export function borderTrees(): Tree[] { + const bounds = canopyBounds(); + const trees: Tree[] = []; + + for (let y = bounds.y; y < bounds.y + bounds.height; y += NEAR) { + for (let x = bounds.x; x < bounds.x + bounds.width; x += NEAR) { + /* + * The treeline wanders rather than following the diamond exactly. + * + * A wood whose inner edge is a perfect straight line does not hide a + * straight line, it draws a second one beside it. This pushes the edge in + * and out by a couple of tiles, which is enough for the eye to stop + * reading it as the boundary of a shape. + */ + const wander = (noise(Math.round(x / 90), Math.round(y / 90), 7) - 0.5) * 0.05; + const out = beyond(x, y) + wander; + + /* + * A little inside the edge as well, so the trees straddle it. A border + * that begins exactly where the grass stops draws attention to the line + * it is meant to hide. + */ + if (out < -0.02) continue; + + const distance = out * (COUNTRY.width / 2); + if (distance > BAND) continue; + + const key = Math.round(x); + const other = Math.round(y); + const what = noise(key, other, 6); + + /* + * Mostly trees, with boulders through it and the occasional ruin. The + * ruins are rare on purpose: something you notice once and then look for + * afterwards is worth more than something on every screen. + */ + let kind: Standing = "tree"; + let list = TREES; + if (what > 0.965) { + kind = "ruin"; + list = RUINS; + } else if (what > 0.84) { + kind = "rock"; + list = ROCKS; + } + + trees.push({ + x: x + (noise(key, other, 2) - 0.5) * NEAR, + y: y + (noise(key, other, 3) - 0.5) * NEAR, + sprite: list[Math.floor(noise(key, other, 4) * list.length) % list.length], + scale: SIZE[kind].from + noise(key, other, 5) * (SIZE[kind].to - SIZE[kind].from), + /* How far out it stands, so the renderer can put it further into shade. */ + depth: Math.min(1, Math.max(0, distance / BAND)), + kind, + }); + } + } + + /* + * Painter's order. Nothing walks out here, so there is no depth sorting to + * do at runtime -- sorting once, now, is the whole of it. + */ + trees.sort((first, second) => first.y - second.y); + return trees; +} + +export interface Blob { + x: number; + y: number; + radius: number; + /** Which of a few canopy tones it is drawn in. */ + tone: number; +} + +/** How many tones the far canopy is drawn in. See `canopyBlobs`. */ +export const CANOPY_TONES = 4; + +/** + * The wood beyond the thicket, as overlapping blobs rather than trees. + * + * At the zoom where any of this is visible, a tree is a few pixels of dark + * green, and a few pixels of dark green is what this draws -- for a thousandth + * of the cost, because every blob at a given tone goes into one path and the + * whole far wood is three shapes rather than four thousand objects. + * + * Grouped into tones rather than shaded individually for the same reason: a + * fill per blob would be thousands of draw instructions to build and to hold, + * where three fills over thousands of paths is three. + */ +export function canopyBlobs(): Blob[] { + const bounds = canopyBounds(); + const blobs: Blob[] = []; + const STEP = 178; + + for (let y = bounds.y; y < bounds.y + bounds.height; y += STEP) { + for (let x = bounds.x; x < bounds.x + bounds.width; x += STEP) { + const distance = beyond(x, y) * (COUNTRY.width / 2); + /* Starts inside the thicket, so the two overlap and there is no seam. */ + if (distance < BAND * 0.45) continue; + + const key = Math.round(x); + const other = Math.round(y); + blobs.push({ + x: x + (noise(key, other, 11) - 0.5) * STEP, + y: y + (noise(key, other, 12) - 0.5) * STEP, + radius: STEP * (0.46 + noise(key, other, 13) * 0.36), + /* + * The tone is mostly noise, with a nudge darker further out. + * + * It was purely distance at first, which meant everything past a couple + * of thousand units fell in the darkest tone and the far wood came out + * as one flat colour -- exactly the flat field the whole border exists + * to replace, in a different green. Noise is what makes canopy read as + * canopy; the distance term only leans it. + */ + tone: Math.min( + CANOPY_TONES - 1, + Math.floor( + (noise(key, other, 14) * 0.78 + Math.min(1, distance / 5200) * 0.22) * + CANOPY_TONES * 0.999, + ), + ), + }); + } + } + + return blobs; +} diff --git a/app/src/game/world/camps.ts b/app/src/game/world/camps.ts new file mode 100644 index 0000000..4448e29 --- /dev/null +++ b/app/src/game/world/camps.ts @@ -0,0 +1,189 @@ +import { GARRISONS, ROADS, garrisonById, groundTiles, MAP } from "./marches"; + +/** + * Where each hero holds. + * + * A camp is not a thing to be saved anywhere. It is a fact about a `uid`: the + * same member gets the same ground every time, on every machine, for everybody + * looking at the same team. Storing it would mean two places that could + * disagree about where somebody lives, and a migration the first time the map + * changed shape. + * + * The sites are solved for rather than hand-placed. Hand-placed positions were + * the obvious thing and were wrong twice over: they have to be re-checked by + * eye every time a holding moves or a road is added, and the checking is + * exactly the arithmetic below. Solving it once at load costs a few + * milliseconds and cannot drift out of step with the map. + */ + +export interface Camp { + x: number; + y: number; +} + +/** How much ground a camp takes: its barracks, its banner and its soldiers. */ +export const CAMP_RADIUS = 7; + +/** How many sites are solved for. Beyond this, heroes share ground. */ +const SITES = 14; + +/** + * The clearances, which are what decide how many camps there are room for. + * + * Generous ones left room for seven sites on a map that wants fourteen, which + * meant a team of eight had two people sharing ground. These are the smallest + * that still keep a camp from reading as part of a holding, or from having a + * road through the middle of it. + */ +const FROM_HOLDING = 3; +/** Clear of a road, so a camp never has a road through the middle of it. */ +const FROM_ROAD = 3; +/** Clear of each other, so two camps never read as one. */ +const FROM_CAMP = 4; + +function roadPoints(): { x: number; y: number }[] { + const points: { x: number; y: number }[] = []; + for (const road of ROADS) { + const from = garrisonById(road.from); + const to = garrisonById(road.to); + if (!from || !to) continue; + const steps = Math.ceil(Math.hypot(to.x - from.x, to.y - from.y)); + for (let step = 0; step <= steps; step += 1) { + const t = step / steps; + points.push({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }); + } + } + return points; +} + +/** + * The sites, in a stable order, best first. + * + * Ringed around the Keep rather than spread evenly over the country: the Keep + * is the middle of the map and a team scattered into the far corners is a team + * you have to go looking for one at a time. The scoring prefers ground about + * thirty tiles out, which is past every holding's apron and well inside the + * forest. + */ +function solveCamps(): Camp[] { + const tiles = groundTiles(); + const roads = roadPoints(); + const middle = { x: MAP.width / 2, y: MAP.height / 2 }; + const chosen: Camp[] = []; + + const candidates: { camp: Camp; score: number }[] = []; + for (let y = 12; y < MAP.height - 12; y += 2) { + for (let x = 12; x < MAP.width - 12; x += 2) { + /* Dry ground only, and dry ground all around, so nobody camps on a shore. */ + let wet = false; + for (let dy = -3; dy <= 3 && !wet; dy += 2) { + for (let dx = -3; dx <= 3 && !wet; dx += 2) { + const tx = Math.round(x + dx); + const ty = Math.round(y + dy); + if (tx < 0 || ty < 0 || tx >= MAP.width || ty >= MAP.height) continue; + if (tiles[ty * MAP.width + tx] === "water") wet = true; + } + } + if (wet) continue; + + let clear = true; + for (const garrison of GARRISONS) { + if (Math.hypot(x - garrison.x, y - garrison.y) < garrison.radius + CAMP_RADIUS + FROM_HOLDING) { + clear = false; + break; + } + } + if (!clear) continue; + + let nearestRoad = Infinity; + for (const point of roads) { + nearestRoad = Math.min(nearestRoad, Math.hypot(x - point.x, y - point.y)); + } + if (nearestRoad < CAMP_RADIUS + FROM_ROAD) continue; + + /* + * Wanted: about thirty tiles from the Keep, and as far from a road as + * that allows. The road term is what stops a whole ring of camps lining + * up along the same two highways. + */ + const outward = Math.hypot(x - middle.x, y - middle.y); + /* + * Wanted: a ring about thirty tiles out, and as far from a road as that + * allows. The distance term is weighted lightly, because weighting it + * heavily packs every camp onto one circle and then runs out of room. + */ + const score = -Math.abs(outward - 30) + Math.min(nearestRoad, 16); + candidates.push({ camp: { x, y }, score }); + } + } + + candidates.sort((first, second) => { + if (second.score !== first.score) return second.score - first.score; + /* A stable tie-break, so the list cannot depend on sort implementation. */ + return first.camp.y - second.camp.y || first.camp.x - second.camp.x; + }); + + for (const candidate of candidates) { + if (chosen.length >= SITES) break; + const tooClose = chosen.some( + (camp) => + Math.hypot(camp.x - candidate.camp.x, camp.y - candidate.camp.y) < + CAMP_RADIUS * 2 + FROM_CAMP, + ); + if (!tooClose) chosen.push(candidate.camp); + } + + return chosen; +} + +let solved: Camp[] | undefined; + +/** The camp sites, solved once. */ +export function campSites(): Camp[] { + return (solved ??= solveCamps()); +} + +/** A stable hash of an account id, so the same person gets the same ground. */ +function hashUid(uid: string): number { + let value = 0; + for (let index = 0; index < uid.length; index += 1) { + value = (Math.imul(value, 31) + uid.charCodeAt(index)) | 0; + } + return Math.abs(value); +} + +/** + * Which camp each member holds. + * + * Assigned all at once rather than one at a time, because the property that + * matters is that no two members share ground -- two camps on one spot reads as + * a rendering fault rather than as a crowded team. A hash alone cannot promise + * that; two ids that land on the same site would both take it. + * + * So: hash to a preferred site, then probe past whatever is taken. Members are + * processed in id order so the result does not depend on the order the roster + * happened to arrive in, and somebody joining moves at most the people they + * collide with rather than reshuffling the whole team. + */ +export function assignCamps(uids: string[]): Map { + const sites = campSites(); + const taken = new Set(); + const held = new Map(); + if (sites.length === 0) return held; + + for (const uid of [...uids].sort()) { + const wanted = hashUid(uid) % sites.length; + let site = wanted; + for (let probe = 0; probe < sites.length; probe += 1) { + const at = (wanted + probe) % sites.length; + if (taken.has(at)) continue; + site = at; + break; + } + /* More members than sites: they share, which is better than vanishing. */ + taken.add(site); + held.set(uid, sites[site]); + } + + return held; +} diff --git a/app/src/game/world/iso.test.ts b/app/src/game/world/iso.test.ts new file mode 100644 index 0000000..275a403 --- /dev/null +++ b/app/src/game/world/iso.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { MAP } from "./marches"; +import { depthOf, diamond, ORIGIN_X, TILE_H, TILE_W, toScreen, toTile } from "./iso"; + +/** + * The projection, checked both ways. + * + * `toTile` is the inverse of `toScreen`, and clicking anything on the map is + * that inverse being right. It is two lines of algebra that were wrong once + * already -- the shift that keeps the map out of negative coordinates has to + * be applied in one direction and undone in the other, and a version that + * shifted only one way put every click half a map away from the thing under + * the cursor. + */ + +describe("the projection", () => { + it("comes back to where it started", () => { + for (const [x, y] of [[0, 0], [1, 0], [0, 1], [12, 47], [63, 63], [31.5, 8.25]]) { + const screen = toScreen(x, y); + const back = toTile(screen.x, screen.y); + expect(back.x).toBeCloseTo(x, 6); + expect(back.y).toBeCloseTo(y, 6); + } + }); + + it("keeps the whole map at positive coordinates", () => { + /* + * What ORIGIN_X is for. pixi-viewport describes the world as a box from + * the origin, so a map running into negative x cannot be clamped and the + * camera slides off it. Every corner has to land inside the box the stage + * declares, or the clamp is against the wrong rectangle. + */ + const corners = [[0, 0], [MAP.width, 0], [0, MAP.height], [MAP.width, MAP.height]]; + for (const [x, y] of corners) { + const screen = toScreen(x, y); + expect(screen.x).toBeGreaterThanOrEqual(0); + expect(screen.y).toBeGreaterThanOrEqual(0); + expect(screen.x).toBeLessThanOrEqual(MAP.width * TILE_W); + expect(screen.y).toBeLessThanOrEqual(MAP.height * TILE_H); + } + }); + + it("puts the far corners exactly on the edges of that box", () => { + /* Not merely inside it: the map is the world, with nothing spare. */ + expect(toScreen(0, MAP.height).x).toBe(0); + expect(toScreen(MAP.width, 0).x).toBe(MAP.width * TILE_W); + expect(toScreen(0, 0).y).toBe(0); + expect(toScreen(MAP.width, MAP.height).y).toBe(MAP.height * TILE_H); + }); + + it("is the 2:1 grid the art was drawn for", () => { + const origin = toScreen(0, 0); + expect(toScreen(1, 0).x - origin.x).toBe(TILE_W / 2); + expect(toScreen(1, 0).y - origin.y).toBe(TILE_H / 2); + expect(ORIGIN_X).toBe(MAP.width * (TILE_W / 2)); + }); +}); + +describe("depth", () => { + it("draws what is further down the screen in front", () => { + expect(depthOf(4, 4)).toBeGreaterThan(depthOf(3, 4)); + expect(depthOf(4, 4)).toBeGreaterThan(depthOf(4, 3)); + }); + + it("puts a thing in the air over the thing it is passing", () => { + expect(depthOf(4, 4, 40)).toBeGreaterThan(depthOf(4, 4, 0)); + }); + + it("orders two things on the same tile the same way every time", () => { + expect(depthOf(9, 2)).toBe(depthOf(9, 2)); + /* x + y is the view axis, so these genuinely are at the same depth. */ + expect(depthOf(9, 2)).toBe(depthOf(2, 9)); + }); +}); + +describe("a tile's diamond", () => { + it("has four corners around the middle of that tile", () => { + const middle = toScreen(3, 5); + const points = diamond(3, 5); + expect(points).toHaveLength(8); + expect(points[0]).toBe(middle.x); + expect(points[1]).toBe(middle.y - TILE_H / 2); + expect(points[4]).toBe(middle.x); + expect(points[5]).toBe(middle.y + TILE_H / 2); + }); +}); diff --git a/app/src/game/world/iso.ts b/app/src/game/world/iso.ts new file mode 100644 index 0000000..8b54f62 --- /dev/null +++ b/app/src/game/world/iso.ts @@ -0,0 +1,99 @@ +/** + * The isometric projection, and the depth rule that goes with it. + * + * Tiles are diamonds twice as wide as they are tall, which is the 2:1 dimetric + * projection every base-builder uses. It is called isometric by everybody and + * by nobody who knows what isometric means. + * + * The ground is drawn in this projection. The buildings and the people on it + * are *not* rotated into it — they are upright sprites standing on the diamond, + * which is how nearly every 2D isometric game has ever worked, and what lets + * artwork drawn face-on sit convincingly on a tilted floor. + */ +import { MAP } from "./marches"; + +/** Half the width and half the height of a tile diamond, in world units. */ +export const TILE_W = 64; +export const TILE_H = 32; + +/** + * How far right the projection is pushed so that no part of the map is at a + * negative coordinate. + * + * A diamond grid laid out from the origin runs half its width into negative x, + * because tile (0, n) is as far left as tile (n, 0) is right. That is fine for + * drawing and useless for a camera: pixi-viewport describes its world as a box + * from (0, 0) to (worldWidth, worldHeight), so a map that starts at -2048 + * cannot be clamped, and zooming out sent the whole country sliding into a + * corner of an empty screen. Shifting here rather than moving a container + * means every coordinate in the game agrees, including the one a click is + * turned back into. + */ +export const ORIGIN_X = MAP.width * (TILE_W / 2); + +export interface Point { + x: number; + y: number; +} + +/** Tile coordinates to the point on screen where that tile's middle sits. */ +export function toScreen(tileX: number, tileY: number): Point { + return { + x: (tileX - tileY) * (TILE_W / 2) + ORIGIN_X, + y: (tileX + tileY) * (TILE_H / 2), + }; +} + +/** + * Back the other way: a point on the ground to the tile under it. + * + * This is what makes the map clickable. Inverting the projection is two lines + * of algebra and the alternative — hit-testing every diamond — is thousands of + * polygon tests per click. + */ +export function toTile(screenX: number, screenY: number): Point { + const halfW = TILE_W / 2; + const halfH = TILE_H / 2; + const x = screenX - ORIGIN_X; + return { + x: (x / halfW + screenY / halfH) / 2, + y: (screenY / halfH - x / halfW) / 2, + }; +} + +/** + * What decides which sprite is drawn in front of which. + * + * Depth is distance along the view axis, which in this projection is simply + * x + y: anything further down the screen is nearer the camera. Height is + * added so a bird is drawn over the building it is flying past rather than + * behind it, and a small tiebreak keeps two things on the same tile in a + * stable order rather than flickering between frames. + */ +export function depthOf(tileX: number, tileY: number, height = 0): number { + return (tileX + tileY) * 1000 + height; +} + +/** + * The same depth, for something whose screen position is known and whose tile + * is not. + * + * `toScreen` puts a tile at `y = (x + y) * TILE_H / 2`, so screen y *is* depth + * up to that constant -- which is the whole of the perspective on this map: + * the camera looks at the bottom corner of the diamond, so the further down the + * screen a thing stands, the nearer it is and the later it must be drawn. + */ +export function depthAtScreenY(screenY: number, height = 0): number { + return (screenY / (TILE_H / 2)) * 1000 + height; +} + +/** The four corners of a tile's diamond, for drawing the ground. */ +export function diamond(tileX: number, tileY: number): number[] { + const { x, y } = toScreen(tileX, tileY); + return [ + x, y - TILE_H / 2, + x + TILE_W / 2, y, + x, y + TILE_H / 2, + x - TILE_W / 2, y, + ]; +} diff --git a/app/src/game/world/marches.ts b/app/src/game/world/marches.ts new file mode 100644 index 0000000..7f1f010 --- /dev/null +++ b/app/src/game/world/marches.ts @@ -0,0 +1,650 @@ +/** + * The Marches: the region the game is played on. + * + * This replaces the single walled yard. The yard was the whole map, it was + * smaller than the window it sat in, and everything in it happened in one + * place — which is why it read as a diagram rather than a world. + * + * The map is now several garrisons spread over open country, and each one is a + * *rename of a part of the product*. That is the rule the lore is held to, and + * it is what makes a garrison worth walking to: the Forge is where features get + * built because feature work really does happen somewhere, the Watch is where + * faults are fought, the Chronicle is the audit log. A wright walks to the + * garrison that matches what its session is doing. + * + * The lore lives here, on the ground, rather than in a document. Every garrison + * carries a sign with its name and a line saying what it is for, readable by + * walking up to it, and that is the only place most of it is written down. + */ + +/** Ground under a tile. Purely how it is drawn. */ +export type Ground = "grass" | "dirt" | "stone" | "water" | "sand"; + +export interface Building { + /** A frame name in the Kenney atlas; see scripts/import-kenney.mjs. */ + sprite: string; + /** Tile position. Fractional, because buildings are not on the grid. */ + x: number; + y: number; + /** Drawn larger or smaller than its natural size. */ + scale?: number; +} + +export interface Garrison { + id: string; + /** The name on the sign. */ + name: string; + /** What it is for, in the world's voice. One line; it goes on the sign. */ + purpose: string; + /** What it is a rename of. Shown when a garrison is inspected. */ + truth: string; + /** The middle of the holding, in tiles. */ + x: number; + y: number; + /** How far its ground extends, in tiles. */ + radius: number; + ground: Ground; + buildings: Building[]; + /** + * Which work sends a wright here. A garrison with no work is somewhere + * people pass through rather than somewhere they are posted. + */ + draws: "bug" | "feature" | "idle" | "none"; +} + +/** + * The ten holdings. + * + * The Keep in the middle and eight around it, roughly on the compass points, so + * that the map has a centre and every road out of it leads somewhere. Laid out + * on a grid 128 tiles across: several screens in any direction at a zoom you + * can read, which is the point of a map you move around rather than a board you + * look at. + * + * Each holding is a *rename of a part of the product*, and that is the rule the + * lore is held to. A garrison nobody can point at a feature for would drift + * into fantasy filler the first time anybody edited it. + */ +export const GARRISONS: Garrison[] = [ + { + id: "keep", + name: "Prompt Keep", + purpose: "The hall. While the Prompt burns, the machine is up.", + truth: "Your account, and the shell process behind it.", + x: 64, + y: 62, + radius: 10, + ground: "stone", + draws: "none", + buildings: [ + /* + * No hall here: the castle from the medieval pack stands on this spot + * (see the landmarks in pixi/scene.ts), and a Structure_02 at 1.6 was + * underneath it -- two buildings claiming one tile, which is what the + * overlap at the middle of the map was. + */ + { sprite: "Structure_06", x: 64, y: 67.5, scale: 1.1 }, + { sprite: "Structure_12", x: 58.5, y: 63 }, + { sprite: "Structure_12", x: 69.5, y: 63 }, + { sprite: "Structure_04", x: 59.5, y: 66.5 }, + { sprite: "Structure_11", x: 68.5, y: 66.5 }, + { sprite: "Structure_20", x: 60.5, y: 57.5 }, + { sprite: "Structure_10", x: 67.5, y: 57.5 }, + ], + }, + { + id: "relay", + name: "The Relay", + purpose: "Every word you type crosses here, and not one of them stops.", + truth: "The relay. It carries your session and can read no part of it.", + x: 64, + y: 22, + radius: 7, + ground: "stone", + draws: "none", + buildings: [ + { sprite: "Structure_05", x: 64, y: 20, scale: 1.4 }, + { sprite: "Structure_12", x: 59.5, y: 23 }, + { sprite: "Structure_12", x: 68.5, y: 23 }, + { sprite: "Structure_08", x: 64, y: 26 }, + ], + }, + { + id: "forge", + name: "The Forge", + purpose: "Where a thing that did not exist is made to.", + truth: "Sessions building a feature. Their work raises the walls.", + x: 32, + y: 32, + radius: 8, + ground: "dirt", + draws: "feature", + buildings: [ + { sprite: "Structure_07", x: 32, y: 30, scale: 1.3 }, + { sprite: "Structure_21", x: 26.5, y: 33 }, + { sprite: "Structure_23", x: 37.5, y: 33.5 }, + { sprite: "Structure_13", x: 29.5, y: 36.5 }, + { sprite: "Structure_16", x: 35.5, y: 36.5 }, + ], + }, + { + id: "watch", + name: "Watchmen's Rise", + purpose: "The Unmade are seen from here first, and met here.", + truth: "Sessions fixing a fault. The waves come to them.", + x: 98, + y: 32, + radius: 8, + ground: "grass", + draws: "bug", + buildings: [ + { sprite: "Structure_12", x: 98, y: 29, scale: 1.4 }, + { sprite: "Structure_05", x: 92.5, y: 32 }, + { sprite: "Structure_10", x: 103.5, y: 32.5 }, + { sprite: "Structure_03", x: 95, y: 36.5 }, + { sprite: "Structure_08", x: 101.5, y: 36.5 }, + ], + }, + { + id: "vault", + name: "The Vault", + purpose: "Nine locks, and the keeper holds not one of the keys.", + truth: "Session passwords, sealed once per member. The service holds none.", + x: 108, + y: 62, + radius: 6, + ground: "stone", + draws: "none", + buildings: [ + { sprite: "Structure_06", x: 108, y: 60, scale: 1.2 }, + { sprite: "Structure_12", x: 104, y: 63.5 }, + { sprite: "Structure_12", x: 112, y: 63.5 }, + ], + }, + { + id: "muster", + name: "The Muster Yard", + purpose: "Every outpost that has answered sends its wrights through here.", + truth: "Your linked machines. A wright arrives when one starts a session.", + x: 98, + y: 92, + radius: 8, + ground: "dirt", + draws: "idle", + buildings: [ + { sprite: "Structure_08", x: 98, y: 89 }, + { sprite: "Structure_16", x: 92.5, y: 92 }, + { sprite: "Structure_17", x: 103.5, y: 92.5 }, + { sprite: "Structure_14", x: 94.5, y: 96.5 }, + { sprite: "Structure_01", x: 102, y: 96 }, + ], + }, + { + id: "pedlar", + name: "Pedlar's Gate", + purpose: "Cloth, dye, and nothing that will help you fight.", + truth: "The shop. Everything in it is cosmetic, by construction.", + x: 64, + y: 104, + radius: 7, + ground: "sand", + draws: "none", + buildings: [ + { sprite: "Structure_23", x: 64, y: 102 }, + { sprite: "Structure_19", x: 58.5, y: 105 }, + { sprite: "Structure_22", x: 69.5, y: 105 }, + { sprite: "Structure_07", x: 64, y: 108 }, + ], + }, + { + id: "chronicle", + name: "The Chronicle", + purpose: "Everything that was done here, written down and sealed.", + truth: "The audit log. Sealed to a key this service does not hold.", + x: 30, + y: 92, + radius: 7, + ground: "stone", + draws: "none", + buildings: [ + { sprite: "Structure_04", x: 30, y: 90, scale: 1.3 }, + { sprite: "Structure_09", x: 25, y: 93.5 }, + { sprite: "Structure_22", x: 35, y: 93.5 }, + { sprite: "Structure_12", x: 30, y: 96.5 }, + ], + }, + { + id: "barrow", + name: "The Barrow", + purpose: "Every session that finished. They are not gone; they are done.", + truth: "Sessions that have closed. Their work is what your levels are made of.", + x: 46, + y: 22, + radius: 6, + ground: "stone", + draws: "none", + buildings: [ + { sprite: "Structure_12", x: 44, y: 20.5 }, + { sprite: "Structure_12", x: 48, y: 20.5 }, + { sprite: "Structure_12", x: 42.5, y: 23 }, + { sprite: "Structure_12", x: 46, y: 23.5, scale: 1.2 }, + { sprite: "Structure_12", x: 49.5, y: 23 }, + { sprite: "Structure_04", x: 46, y: 19, scale: 1.1 }, + ], + }, + { + id: "roost", + name: "Ravens' Roost", + purpose: "Every raven ever sent for you is waiting in the rafters.", + truth: "Your inbox: mentions, handoffs, and sessions that ended badly.", + x: 24, + y: 62, + radius: 6, + ground: "grass", + draws: "none", + buildings: [ + { sprite: "Structure_09", x: 24, y: 60, scale: 1.2 }, + { sprite: "Structure_16", x: 19.5, y: 63.5 }, + { sprite: "Structure_10", x: 28.5, y: 63.5 }, + ], + }, +]; + +export function garrisonById(id: string): Garrison | undefined { + return GARRISONS.find((garrison) => garrison.id === id); +} + +/* + * There was a `garrisonFor(work)` here, which sent a wright to the holding that + * matched what its session was doing. It has no callers now and is deliberately + * not kept: soldiers gather at their own hero's camp, and a function that says + * otherwise is a description of a model this no longer has. `draws` survives + * because it still decides how heavy a holding's own watch is. + */ + +/** The whole map, in tiles. Bigger than any window, which is the point. */ +export const MAP = { width: 128, height: 128 } as const; + +/** + * The roads, as runs of tiles between holdings. + * + * Drawn as ground rather than as sprites, so they read as worn earth rather + * than as a decal laid over grass. Every garrison is joined to the Keep, which + * is what makes it the middle of the map rather than merely the biggest thing + * on it. + */ +export const ROADS: { from: string; to: string }[] = [ + { from: "keep", to: "relay" }, + { from: "keep", to: "forge" }, + { from: "keep", to: "watch" }, + { from: "keep", to: "vault" }, + { from: "keep", to: "muster" }, + { from: "keep", to: "pedlar" }, + { from: "keep", to: "chronicle" }, + { from: "keep", to: "roost" }, + { from: "relay", to: "barrow" }, + /* Two that do not touch the Keep, so the network is a country and not a wheel. */ + { from: "forge", to: "relay" }, + { from: "muster", to: "pedlar" }, +]; + +/** + * Deterministic noise, from whatever is fed to it. + * + * The murmur3 finaliser, the same one the scatter uses. Roads have to look the + * same every time the map is opened, for the same reason the woods do: a + * country whose lanes are somewhere else on reload tells you, below the level + * of noticing, that none of this is a place. + */ +function wobble(a: number, b: number, channel: number): number { + let h = Math.imul(a, 0x27d4eb2d) ^ Math.imul(b, 0x165667b1) ^ Math.imul(channel + 1, 0x9e3779b1); + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4_294_967_296; +} + +/** A point on a road, and how wide the road is there. */ +export interface RoadStep { + x: number; + y: number; + /** In tiles, from the middle out. Varies along the run. */ + width: number; +} + +/** + * The line a road actually takes between two holdings. + * + * Not a straight one. Every road used to be a ruled line from one gate to the + * next, and nine of them out of one Keep made a wheel with spokes -- which is + * a diagram of how the holdings are connected rather than a picture of a + * country somebody walks through. Roads in a country bend around what was in + * the way a long time ago, and the bend is most of what makes them read as + * having been worn rather than drawn. + * + * So: a quadratic bow, with the control point pushed sideways off the midpoint + * by an amount and a direction taken from the two endpoints, plus a small + * wander laid over the top of it and a width that swells and narrows. None of + * it is random -- all three come out of `wobble`, keyed on where the road + * starts and ends, so the same road is the same road forever. + * + * Both the ground and the scatter's clearance read this. They used to each + * walk their own straight line, which agreed only because two identical + * expressions cannot disagree; with a curve they could, and a tree standing in + * the middle of a lane is the kind of thing that sends somebody looking for a + * collision bug that is not there. + */ +export function roadPath(from: Garrison, to: Garrison): RoadStep[] { + /* + * Worked out in one fixed direction and reversed if it was asked for in the + * other, rather than merely seeded in a fixed order. + * + * Seeding alone is not enough and a test caught it: the bow is measured + * perpendicular to `to - from`, and the width and the wander are functions + * of how far along from `from` a step is, so all three flip when the ends + * are swapped. The same two gates came out joined by two different lanes + * depending on which one was named first. Nothing asks for it backwards + * today, which is exactly the condition under which something will. + */ + if (from.id > to.id) return roadPath(to, from).reverse(); + + const span = Math.hypot(to.x - from.x, to.y - from.y); + const steps = Math.max(8, Math.ceil(span * 2)); + + const seed = Math.round(from.x * 131 + from.y); + const seedTwo = Math.round(to.x * 131 + to.y); + + /* + * How far the middle is pushed off the straight line, as a share of the run. + * Never past a fifth of it: past that a road stops looking like it goes + * round something and starts looking like it is lost. + */ + const bow = (wobble(seed, seedTwo, 1) - 0.5) * 0.38 * span; + /* Perpendicular to the run, which is where a bow has to go to be a bow. */ + const nx = -(to.y - from.y) / (span || 1); + const ny = (to.x - from.x) / (span || 1); + const midX = (from.x + to.x) / 2 + nx * bow; + const midY = (from.y + to.y) / 2 + ny * bow; + + /* Two wanders at different rates, so the edge is rough rather than wavy. */ + const phase = wobble(seed, seedTwo, 2) * Math.PI * 2; + const phaseTwo = wobble(seed, seedTwo, 3) * Math.PI * 2; + + const path: RoadStep[] = []; + for (let step = 0; step <= steps; step += 1) { + const t = step / steps; + const u = 1 - t; + /* The quadratic through from, the pushed midpoint, and to. */ + let x = u * u * from.x + 2 * u * t * midX + t * t * to.x; + let y = u * u * from.y + 2 * u * t * midY + t * t * to.y; + + /* + * The wander is damped to nothing at both ends. A road that wobbles as it + * arrives misses the gate it was going to, and a lane that stops three + * tiles short of a holding is worse than a straight one. + */ + const damp = Math.sin(t * Math.PI); + const drift = + Math.sin(t * 9 + phase) * 1.5 + Math.sin(t * 23 + phaseTwo) * 0.6; + x += nx * drift * damp; + y += ny * drift * damp; + + /* + * Width swells and narrows along the run, between about one and a half + * tiles and three. A road of constant width is a ribbon; a road that is + * broad where it is used and thin where it is not is a road. + */ + const width = 0.95 + (Math.sin(t * 7 + phase) * 0.5 + 0.5) * 0.85; + path.push({ x, y, width }); + } + + return path; +} + +/** + * Every road's line, worked out once. + * + * Cached for the same reason the ground is: two consumers need it, it is a few + * thousand points of trigonometry, and computing it twice would make it + * possible for the two to disagree. + */ +let roadCache: RoadStep[][] | undefined; + +/** + * The west road: out of Ravens' Roost, over the river, and into the wood. + * + * The only road on the map that does not run between two holdings, and the only + * one that crosses water. Both of those are the point of it. + * + * Every other lane here joins one part of the product to another, so the + * network is a closed country -- which is right, except that it left the river + * as scenery nobody ever reached and nothing to say that anything arrives from + * outside. The Roost is the inbox. What lands there came from somewhere that is + * not on this map, so the road out of it runs west, crosses the water and stops + * at the treeline. + * + * Laid the same way as the others, as steps half a tile apart with a breathing + * width, so the scatter's clearance and the roadside props treat it as a road + * without being told about it separately. + */ +function westRoad(): RoadStep[] { + const roost = garrisonById("roost"); + if (!roost) return []; + + const fromX = roost.x - roost.radius + 0.5; + const toX = 3.5; + const span = fromX - toX; + const steps = Math.ceil(span * 2); + + const path: RoadStep[] = []; + for (let step = 0; step <= steps; step += 1) { + const t = step / steps; + const x = fromX - span * t; + /* A slack curve rather than a ruled line, like every other lane here. */ + const y = roost.y + Math.sin(t * Math.PI) * 2.6 + (wobble(Math.round(x), 0, 11) - 0.5) * 0.8; + const width = 0.85 + (Math.sin(t * 6) * 0.5 + 0.5) * 0.6; + path.push({ x, y, width }); + } + return path; +} + +export function roadPaths(): RoadStep[][] { + return (roadCache ??= [ + ...ROADS.map((road) => { + const from = garrisonById(road.from); + const to = garrisonById(road.to); + return from && to ? roadPath(from, to) : []; + }), + westRoad(), + ]); +} + +export interface Crossing { + /** The middle of the water the road has to get over, in tiles. */ + x: number; + y: number; + /** The way the road is heading there, as a unit vector in tile space. */ + dx: number; + dy: number; + /** How wide the water is at that point, in tiles. */ + span: number; +} + +/** + * Where a road runs into water, which is where a bridge goes. + * + * Found from the road and the ground rather than written down, so a river that + * moves takes its bridge with it. `buildGround` refuses to lay road over water, + * so every crossing is already a gap in a lane; this is the list of them, and + * the bridge is what fills each one in. + */ +export function crossings(): Crossing[] { + const tiles = groundTiles(); + const wet = (x: number, y: number) => { + const tx = Math.round(x); + const ty = Math.round(y); + if (tx < 0 || ty < 0 || tx >= MAP.width || ty >= MAP.height) return false; + return tiles[ty * MAP.width + tx] === "water"; + }; + + const out: Crossing[] = []; + for (const path of roadPaths()) { + let run: RoadStep[] = []; + const close = (endedAt: number) => { + if (run.length === 0) return; + const first = run[0]; + const last = run[run.length - 1]; + const before = path[Math.max(0, endedAt - run.length - 1)]; + const after = path[Math.min(path.length - 1, endedAt)]; + const dx = after.x - before.x; + const dy = after.y - before.y; + + /* + * Snapped to a tile axis, rather than laid along the road's own tangent. + * + * The road bows, so where it meets the water it is heading a few degrees + * off west -- and a deck built on that heading sits at an angle that + * matches neither the river nor the grid under it, which is exactly what + * a crooked bridge looks like. Everything else standing on this map lines + * up with the diamond; a bridge is a built thing and lines up hardest of + * all. So the crossing takes the nearer of the two tile axes and the deck + * runs straight along it. + */ + const along = + Math.abs(dx) >= Math.abs(dy) + ? { dx: Math.sign(dx) || 1, dy: 0 } + : { dx: 0, dy: Math.sign(dy) || 1 }; + + /* + * And measured along that axis rather than along the road, so the deck is + * as long as the water is wide in the direction it actually crosses. + * Three tiles of dry bank at each end, so it lands on the road rather + * than stopping at the waterline. + */ + const wet = along.dx !== 0 + ? Math.abs(last.x - first.x) + : Math.abs(last.y - first.y); + + out.push({ + x: (first.x + last.x) / 2, + y: (first.y + last.y) / 2, + dx: along.dx, + dy: along.dy, + span: wet + 6, + }); + run = []; + }; + + path.forEach((step, index) => { + if (wet(step.x, step.y)) run.push(step); + else close(index); + }); + close(path.length); + } + return out; +} + +/** + * The ground of the whole map, worked out once. + * + * A flat array rather than a function called per tile per frame: the map is + * sixteen thousand tiles and the renderer walks all of them when it is built. + */ +/** + * The ground, worked out once and kept. + * + * Two things need it -- the layer that draws it and the scatter that decides + * where a tree may stand -- and it is sixteen thousand tiles of work. Computing + * it twice would be invisible and wasteful, and worse, it would make it + * possible for the two to disagree. + */ +let groundCache: Ground[] | undefined; + +export function groundTiles(): Ground[] { + return (groundCache ??= buildGround()); +} + +export function buildGround(): Ground[] { + const tiles: Ground[] = new Array(MAP.width * MAP.height).fill("grass"); + const at = (x: number, y: number) => y * MAP.width + x; + const put = (x: number, y: number, ground: Ground) => { + if (x < 0 || y < 0 || x >= MAP.width || y >= MAP.height) return; + tiles[at(x, y)] = ground; + }; + const isWater = (x: number, y: number) => + x >= 0 && y >= 0 && x < MAP.width && y < MAP.height && tiles[at(x, y)] === "water"; + + /* + * A river down the west and a lake in the north-east, so the country has + * edges that are features rather than merely where the tiles stop. + */ + for (let y = 0; y < MAP.height; y += 1) { + const bend = 13 + Math.round(Math.sin(y / 15) * 5); + for (let x = bend; x < bend + 3; x += 1) put(x, y, "water"); + } + + /* Clear of Watchmen's Rise, so a fight there never spills onto the shore. */ + const LAKE = { x: 116, y: 11, rx: 10, ry: 7 }; + for (let y = LAKE.y - LAKE.ry; y <= LAKE.y + LAKE.ry; y += 1) { + for (let x = LAKE.x - LAKE.rx; x <= LAKE.x + LAKE.rx; x += 1) { + const dx = (x - LAKE.x) / LAKE.rx; + const dy = (y - LAKE.y) / LAKE.ry; + if (dx * dx + dy * dy <= 1) put(x, y, "water"); + } + } + + /* Shores, found from the water rather than drawn alongside it, so the two + * cannot drift apart when either is moved. */ + for (let y = 0; y < MAP.height; y += 1) { + for (let x = 0; x < MAP.width; x += 1) { + if (tiles[at(x, y)] !== "grass") continue; + const touching = + isWater(x - 1, y) || isWater(x + 1, y) || isWater(x, y - 1) || isWater(x, y + 1); + if (touching) tiles[at(x, y)] = "sand"; + } + } + + /* Each holding's own ground. */ + for (const garrison of GARRISONS) { + for (let y = -garrison.radius; y <= garrison.radius; y += 1) { + for (let x = -garrison.radius; x <= garrison.radius; x += 1) { + if (Math.hypot(x, y) > garrison.radius) continue; + const tx = Math.round(garrison.x) + x; + const ty = Math.round(garrison.y) + y; + if (isWater(tx, ty)) continue; + put(tx, ty, garrison.ground); + } + } + } + + /* + * Roads, laid after the holdings so they run up to the gates. + * + * Stamped as a disc at every step rather than a fixed three-tile block. The + * block was what made the lanes read as drawn: a constant width with two + * straight edges, which is a ribbon laid over a field rather than ground + * that has been walked flat. A disc whose radius breathes along the run + * gives an edge that is ragged at the tile scale, which is the scale the + * ground is drawn at. + */ + for (const path of roadPaths()) { + for (const step of path) { + const reach = Math.ceil(step.width); + for (let dy = -reach; dy <= reach; dy += 1) { + for (let dx = -reach; dx <= reach; dx += 1) { + const tx = Math.round(step.x) + dx; + const ty = Math.round(step.y) + dy; + /* + * The threshold is nudged per tile, so the boundary itself is rough + * rather than a clean circle drawn in pixels. + */ + const edge = step.width + (wobble(tx, ty, 4) - 0.5) * 0.7; + if (Math.hypot(dx, dy) > edge) continue; + if (isWater(tx, ty)) continue; + put(tx, ty, "dirt"); + } + } + } + } + + return tiles; +} diff --git a/app/src/game/world/roadside.test.ts b/app/src/game/world/roadside.test.ts new file mode 100644 index 0000000..08701e5 --- /dev/null +++ b/app/src/game/world/roadside.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { GARRISONS, MAP, ROADS, garrisonById, groundTiles, roadPath, roadPaths } from "./marches"; +import { roadsideProps } from "./roadside"; + +/** + * The roads, and what stands beside them. + * + * As with the scatter, almost none of this is about whether it looks good -- + * that is a question for eyes. It is about the handful of things that would + * read as faults: a lane that does not reach the gate it is named for, a + * lamp-post standing in the river, a fence through the middle of a holding, + * and a country that is somewhere else the second time it is asked for. + * + * The bow is the one exception, and it is checked because it is the whole + * point of the change: straight roads are what made nine lanes out of one Keep + * read as a wheel with spokes rather than as a country. + */ + +const paths = roadPaths(); +const props = roadsideProps(); +const tiles = groundTiles(); + +describe("the line a road takes", () => { + it("starts and ends at the holdings it joins", () => { + /* + * A lane that wanders as it arrives misses the gate, and one that stops + * three tiles short is worse than a straight one. + */ + ROADS.forEach((road, index) => { + const from = garrisonById(road.from); + const to = garrisonById(road.to); + const path = paths[index]; + expect(from && to).toBeTruthy(); + expect(path[0].x).toBeCloseTo(from!.x, 6); + expect(path[0].y).toBeCloseTo(from!.y, 6); + expect(path[path.length - 1].x).toBeCloseTo(to!.x, 6); + expect(path[path.length - 1].y).toBeCloseTo(to!.y, 6); + }); + }); + + it("bends away from the straight line", () => { + /* + * Measured as the furthest any road gets from the chord between its ends. + * Not every road has to bow -- the offset is drawn from the endpoints and + * some of them land near zero, which is realistic -- but if none of them + * does then the curve is not working and this is the wheel again. + */ + const bows = ROADS.map((road, index) => { + const from = garrisonById(road.from)!; + const to = garrisonById(road.to)!; + const span = Math.hypot(to.x - from.x, to.y - from.y); + let worst = 0; + for (const step of paths[index]) { + /* Distance from the point to the line through the two ends. */ + const area = Math.abs( + (to.x - from.x) * (from.y - step.y) - (from.x - step.x) * (to.y - from.y), + ); + worst = Math.max(worst, area / span); + } + return worst; + }); + + expect(bows.filter((bow) => bow > 2).length).toBeGreaterThanOrEqual(6); + /* And never so far that the road looks lost rather than diverted. */ + for (const bow of bows) expect(bow).toBeLessThan(26); + }); + + it("varies in width along its run", () => { + const widths = paths.flat().map((step) => step.width); + expect(Math.min(...widths)).toBeLessThan(Math.max(...widths) - 0.4); + }); + + it("is the same country every time it is asked for", () => { + const again = roadPath(GARRISONS[0], GARRISONS[1]); + const once = roadPath(GARRISONS[0], GARRISONS[1]); + expect(again).toEqual(once); + }); + + it("takes the same line whichever end it is asked from", () => { + /* + * The seed is ordered by id rather than by argument, so a road is one road + * however it is named. Without that, `keep -> relay` and `relay -> keep` + * would be two different lanes between the same two gates. + */ + const out = roadPath(GARRISONS[0], GARRISONS[1]).map((step) => step.width); + const back = roadPath(GARRISONS[1], GARRISONS[0]).map((step) => step.width); + expect(out).toEqual([...back].reverse()); + }); +}); + +describe("what stands beside a road", () => { + it("puts out all three kinds", () => { + for (const kind of ["fence", "bale", "lantern"] as const) { + expect(props.filter((prop) => prop.kind === kind).length).toBeGreaterThan(4); + } + }); + + it("stands nothing in the water", () => { + /* + * The one that would read as a bug rather than as scenery: a lamp-post in + * the middle of the river. + */ + for (const prop of props) { + const tx = Math.round(prop.x); + const ty = Math.round(prop.y); + expect(tx).toBeGreaterThanOrEqual(0); + expect(ty).toBeGreaterThanOrEqual(0); + expect(tx).toBeLessThan(MAP.width); + expect(ty).toBeLessThan(MAP.height); + expect(tiles[ty * MAP.width + tx]).not.toBe("water"); + } + }); + + it("keeps fences and bales off the holdings", () => { + /* + * The lamps are exempt: two of them are put at each holding's gates on + * purpose, which is the whole reason a holding can be found from the road + * after dark. + */ + for (const prop of props) { + if (prop.kind === "lantern") continue; + for (const garrison of GARRISONS) { + expect(Math.hypot(prop.x - garrison.x, prop.y - garrison.y)).toBeGreaterThan( + garrison.radius, + ); + } + } + }); + + it("builds fences in runs rather than as strays", () => { + /* + * A single section of rail is a stray object; six along one side of a lane + * is a field boundary, and the eye reads the second and not the first. + */ + const fences = props.filter((prop) => prop.kind === "fence"); + const near = fences.filter((fence) => + fences.some( + (other) => + other !== fence && Math.hypot(other.x - fence.x, other.y - fence.y) < 3.2, + ), + ); + expect(near.length / fences.length).toBeGreaterThan(0.8); + }); + + it("gives every fence a direction to run along", () => { + for (const fence of props.filter((prop) => prop.kind === "fence")) { + expect(Math.hypot(fence.dx, fence.dy)).toBeCloseTo(1, 3); + } + }); + + it("is the same country every time it is asked for", () => { + const again = roadsideProps(); + expect(again.length).toBe(props.length); + expect(again[0]).toEqual(props[0]); + expect(again[again.length - 1]).toEqual(props[props.length - 1]); + }); +}); diff --git a/app/src/game/world/roadside.ts b/app/src/game/world/roadside.ts new file mode 100644 index 0000000..6fe4c8c --- /dev/null +++ b/app/src/game/world/roadside.ts @@ -0,0 +1,198 @@ +import { GARRISONS, MAP, groundTiles, roadPaths, type RoadStep } from "./marches"; +import { campSites } from "./camps"; + +/** + * The things people leave beside a road. + * + * Fences, hay bales and lanterns. None of them is a mechanic and none of them + * can be touched; they are here because a lane running through open grass is a + * line on a floor, and the same lane with a rail along one side, a stack of + * bales at the corner and a lamp at the junction is somewhere that is used. + * + * The scatter says what grows on the Marches; this says what was *put* there, + * and the difference decides where each goes. A tree stands wherever the wood + * reaches. A fence follows a road, because a fence with nothing on either side + * of it is a fence nobody built. + * + * Deterministic, like everything else on this map, and for the same reason: a + * country whose lamp-posts are somewhere else on reload is not a place. + */ + +export type RoadsideKind = "fence" | "bale" | "lantern"; + +export interface Roadside { + kind: RoadsideKind; + x: number; + y: number; + /** + * Which way it faces, as a unit vector in tile space. Only the fences use + * it -- a rail has to run along the road rather than across it -- but it is + * cheap to carry and it keeps the shape of the list the same for all three. + */ + dx: number; + dy: number; +} + +/** The murmur3 finaliser again. See `world/scatter.ts` for why not something cheaper. */ +function noise(a: number, b: number, channel: number): number { + let h = Math.imul(a, 0x27d4eb2d) ^ Math.imul(b, 0x165667b1) ^ Math.imul(channel + 1, 0x9e3779b1); + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4_294_967_296; +} + +/** The direction a road is heading at one of its steps. */ +function tangent(path: RoadStep[], index: number): { dx: number; dy: number } { + const back = path[Math.max(0, index - 1)]; + const on = path[Math.min(path.length - 1, index + 1)]; + const dx = on.x - back.x; + const dy = on.y - back.y; + const length = Math.hypot(dx, dy) || 1; + return { dx: dx / length, dy: dy / length }; +} + +/** + * Whether a point is on a holding's own ground. + * + * Roadside things are pushed off the holdings, which are already dense with + * buildings. A lantern in the middle of the Keep's courtyard is hidden behind + * a roof; the same lantern on the road outside the gate is the thing that + * tells you where the gate is. + */ +function insideHolding(x: number, y: number, margin: number): boolean { + for (const garrison of GARRISONS) { + if (Math.hypot(x - garrison.x, y - garrison.y) < garrison.radius + margin) return true; + } + return false; +} + +/** Nothing stands in a river. The scatter checks this too, for the same reason. */ +function onLand(x: number, y: number): boolean { + const tiles = groundTiles(); + const tx = Math.round(x); + const ty = Math.round(y); + if (tx < 0 || ty < 0 || tx >= MAP.width || ty >= MAP.height) return false; + return tiles[ty * MAP.width + tx] !== "water"; +} + +/** + * Everything beside the roads, worked out once. + * + * The three kinds are placed by three different rules, because they are three + * different things: + * + * **Fences** come in runs. A single section of rail is a stray object; six of + * them along one side of a lane is a field boundary, and the eye reads the + * second and not the first. A run picks a side and stays on it. + * + * **Bales** come in twos and threes, off the verge. They are what a field is + * for, so they sit back from the road rather than on it. + * + * **Lanterns** are spaced along each run and set at every gate, because a lamp + * is a thing somebody put where it was needed rather than where it fitted. + * They are also what lights this map now that it is dusk, so where they go + * decides where anything can be seen. + */ +export function roadsideProps(): Roadside[] { + const out: Roadside[] = []; + + const put = (kind: RoadsideKind, x: number, y: number, dx = 0, dy = 0) => { + if (!onLand(x, y)) return; + out.push({ kind, x, y, dx, dy }); + }; + + roadPaths().forEach((path, road) => { + if (path.length < 6) return; + + /* Where the current fence run ends, so a run stays on one side of the lane. */ + let fenceUntil = -1; + let fenceSide = 1; + + for (let index = 2; index < path.length - 2; index += 1) { + const step = path[index]; + const { dx, dy } = tangent(path, index); + /* The verge: perpendicular to the road, just clear of the worn earth. */ + const nx = -dy; + const ny = dx; + const verge = step.width + 1.1; + + if (insideHolding(step.x, step.y, 1)) continue; + + /* Lanterns: one every twenty-two steps, alternating sides. */ + if (index % 22 === 8) { + const side = index % 44 === 8 ? 1 : -1; + put("lantern", step.x + nx * verge * side, step.y + ny * verge * side); + } + + /* + * Half as many runs, and not half as many sections in each. + * + * There was too much fence on this map, but thinning every run would have + * been the wrong half to take: a run with gaps in it is not a field + * boundary, it is litter, and the whole reason a fence reads as a fence is + * that it is continuous. So the *number of runs* drops and their length + * does not -- fewer veins, each one still joined end to end. + */ + if (index > fenceUntil && noise(road, index, 1) < 0.05) { + /* In steps, and a section now costs four of them, so a run of three + * to seven sections is twelve to twenty-eight steps rather than five + * to eleven -- which at the new spacing was one section and a gap. */ + fenceUntil = index + 12 + Math.floor(noise(road, index, 2) * 16); + fenceSide = noise(road, index, 3) < 0.5 ? 1 : -1; + } + /* + * Every fourth step while a run is going. + * + * A road step is half a tile -- `steps` is `span * 2` -- and a section is + * drawn 2.1 tiles long, so every other step put each section a single + * tile from the last and stacked it more than halfway through its + * neighbour. Four steps is 2.0 tiles: the rails meet end to end with a + * tenth of a tile of overlap, which is a fence rather than a pile of + * them. + */ + if (index <= fenceUntil && index % 4 === 0) { + put("fence", step.x + nx * verge * fenceSide, step.y + ny * verge * fenceSide, dx, dy); + } + + /* Bales: rarely, further off the verge, in twos and threes. */ + if (noise(road, index, 4) < 0.04) { + const side = noise(road, index, 5) < 0.5 ? 1 : -1; + const back = verge + 1.6 + noise(road, index, 6) * 2; + const bx = step.x + nx * back * side; + const by = step.y + ny * back * side; + const many = 2 + Math.floor(noise(road, index, 7) * 2); + for (let n = 0; n < many; n += 1) { + put("bale", bx + n * 0.95, by + (n % 2) * 0.85, dx, dy); + } + } + } + }); + + /* + * A lamp at a holding's north and south gates, so it can be found from the + * road. Two rather than four: in this projection the east and west gates sit + * on the widest part of the holding, where they are furthest from anything + * and light the least. + */ + for (const garrison of GARRISONS) { + const reach = garrison.radius + 0.5; + put("lantern", garrison.x, garrison.y + reach); + put("lantern", garrison.x, garrison.y - reach); + } + + return out; +} + +/** + * The camps' own lamps, kept apart because a camp is lit only while it is held. + * + * They are there for one job. The banners stand at a camp's gate, and a banner + * nobody can make out is a banner that does not say whose ground this is. + * Lighting them is a better answer than making them bigger a second time. + */ +export function campLanterns(): { site: string; x: number; y: number }[] { + return campSites().flatMap((site) => [ + { site: `${site.x},${site.y}`, x: site.x - 4.8, y: site.y + 3.4 }, + { site: `${site.x},${site.y}`, x: site.x + 4.6, y: site.y + 3.2 }, + ]); +} diff --git a/app/src/game/world/scatter.test.ts b/app/src/game/world/scatter.test.ts new file mode 100644 index 0000000..5c4f9c1 --- /dev/null +++ b/app/src/game/world/scatter.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { GARRISONS, groundTiles, MAP } from "./marches"; +import { scatterProps, WOODS } from "./scatter"; + +/** + * The woods, checked for the things that would look like bugs. + * + * Almost nothing here is about whether the scatter looks good, which is a + * question for eyes. It is about the three places a prop must never be: in the + * middle of a road, inside a holding, or standing in the river. Each of those + * reads as a collision fault rather than as scenery, and each would send + * somebody looking for a bug that is not there. + */ + +const props = scatterProps(); +const tiles = groundTiles(); + +describe("how much there is", () => { + it("fills the country without carpeting it", () => { + /* + * The bounds are wide on purpose: this is a check that the density did not + * collapse to nothing or explode to one prop per tile, not a promise about + * a number somebody will want to tune. + */ + expect(props.length).toBeGreaterThan(400); + expect(props.length).toBeLessThan(MAP.width * MAP.height * 0.2); + }); + + it("is the same country every time it is asked for", () => { + /* + * A wood that is somewhere else on reload tells you, below the level of + * noticing, that none of this is a place. + */ + const again = scatterProps(); + expect(again.length).toBe(props.length); + expect(again[0]).toEqual(props[0]); + expect(again[again.length - 1]).toEqual(props[props.length - 1]); + }); + + it("uses more than one kind of thing", () => { + expect(new Set(props.map((prop) => prop.sprite)).size).toBeGreaterThan(8); + }); +}); + +describe("where nothing may grow", () => { + it("never stands in water", () => { + for (const prop of props) { + const tile = tiles[Math.round(prop.y) * MAP.width + Math.round(prop.x)]; + expect(tile).not.toBe("water"); + } + }); + + it("never stands inside a holding", () => { + for (const prop of props) { + for (const garrison of GARRISONS) { + const distance = Math.hypot(prop.x - garrison.x, prop.y - garrison.y); + expect(distance).toBeGreaterThan(garrison.radius); + } + } + }); + + it("never stands in a road", () => { + /* + * The one that matters most. A tree in the middle of a road is not a + * charming detail; it is the thing that makes somebody look for the + * collision bug that is not there. + * + * Checked against the worn earth itself rather than against the line the + * roads take. This used to walk its own copy of that line, which agreed + * with the map only because two identical straight-line expressions cannot + * disagree; when the roads were given a curve, the copy went on describing + * the old ones and the test failed on props that were nowhere near a lane. + * + * Reading `groundTiles` keeps the check independent of `roadPaths` -- it + * asks whether a prop is standing on ground the renderer draws as earth, + * which is the thing that would actually be seen -- while removing the + * duplicate that broke. + */ + for (const prop of props) { + const tx = Math.round(prop.x); + const ty = Math.round(prop.y); + expect(tiles[ty * MAP.width + tx]).not.toBe("dirt"); + } + }); + + it("stays on the map", () => { + for (const prop of props) { + expect(prop.x).toBeGreaterThanOrEqual(-1); + expect(prop.y).toBeGreaterThanOrEqual(-1); + expect(prop.x).toBeLessThanOrEqual(MAP.width + 1); + expect(prop.y).toBeLessThanOrEqual(MAP.height + 1); + } + }); +}); + +describe("how it is placed", () => { + it("never sits dead on the grid", () => { + /* Props on tile centres read as a grid, which is what scatter is for. */ + const onCentre = props.filter( + (prop) => Number.isInteger(prop.x) && Number.isInteger(prop.y), + ); + expect(onCentre.length).toBe(0); + }); + + it("varies the size, within reason", () => { + const scales = props.map((prop) => prop.scale); + expect(new Set(scales).size).toBeGreaterThan(20); + for (const scale of scales) { + expect(scale).toBeGreaterThan(0.2); + expect(scale).toBeLessThan(1.5); + } + }); + + it("draws a tree taller than a shrub", () => { + /* + * One scale for everything suited the boulders and made a full-grown pine + * half the height of a cottage, which reads as a herb garden rather than a + * wood. Size is by what the thing is. + */ + const heightOf = (names: string[]) => { + const matching = props.filter((prop) => names.includes(prop.sprite)); + return matching.reduce((sum, prop) => sum + prop.scale, 0) / matching.length; + }; + const trees = ["Environment_01", "Environment_02", "Environment_03", "Environment_21"]; + const shrubs = ["Environment_12", "Environment_19"]; + expect(heightOf(trees)).toBeGreaterThan(heightOf(shrubs) * 1.5); + }); + + it("makes a wood denser than open country", () => { + /* + * A forest is a place you can be inside or outside of; evenly-spread trees + * are a texture. Measured where the claim actually lives -- inside a wood + * against outside one -- rather than over quarters of the map, which are + * far larger than any wood and came out even however the trees fell. + */ + const inWood = (x: number, y: number) => + WOODS.some((wood) => Math.hypot(x - wood.x, y - wood.y) < wood.r); + + let woodTiles = 0; + let openTiles = 0; + for (let y = 0; y < MAP.height; y += 1) { + for (let x = 0; x < MAP.width; x += 1) { + if (tiles[y * MAP.width + x] !== "grass") continue; + if (inWood(x, y)) woodTiles += 1; + else openTiles += 1; + } + } + + const woodProps = props.filter((prop) => inWood(prop.x, prop.y)).length; + const openProps = props.length - woodProps; + + expect(woodProps / woodTiles).toBeGreaterThan((openProps / openTiles) * 3); + }); +}); diff --git a/app/src/game/world/scatter.ts b/app/src/game/world/scatter.ts new file mode 100644 index 0000000..3ee163a --- /dev/null +++ b/app/src/game/world/scatter.ts @@ -0,0 +1,244 @@ +import { GARRISONS, groundTiles, MAP, roadPaths } from "./marches"; +import { campSites, CAMP_RADIUS } from "./camps"; + +/** + * Everything growing on the Marches that nobody built. + * + * A country four times the size was, at first, four times as much empty grass. + * Distance only reads as distance if there is something between here and there + * to pass; a wright walking across an unbroken field looks like a sprite + * sliding over a texture, and the same walk past a wood, a boulder field and a + * fallen log looks like a journey. + * + * Everything here is deterministic, from the tile's own position. The Marches + * look the same every time they are opened, which matters more than it sounds: + * a wood that is somewhere else on reload tells you, at a level below noticing, + * that none of this is a place. + * + * It is data rather than display objects. `pixi/scatter.ts` turns it into + * sprites and puts their shadows into the ground, where they belong -- a + * shadow on flat earth never moves, so there is no reason to pay for it twice + * a frame. + */ + +export interface Prop { + /** A frame name in the Kenney atlas; see scripts/import-kenney.mjs. */ + sprite: string; + x: number; + y: number; + scale: number; +} + +/** + * The woods, as centres and how far they reach. + * + * Named clumps rather than a noise field over the whole map, because a forest + * is a place you can be inside or outside of and evenly-spread trees are a + * texture. They are put in the gaps between holdings, so that a road from one + * to another has something to run through. + */ +export const WOODS = [ + { x: 48, y: 12, r: 15 }, + { x: 26, y: 46, r: 14 }, + { x: 86, y: 58, r: 13 }, + { x: 46, y: 88, r: 15 }, + { x: 84, y: 114, r: 14 }, + { x: 114, y: 44, r: 12 }, + { x: 18, y: 108, r: 13 }, + { x: 78, y: 16, r: 11 }, + { x: 40, y: 64, r: 10 }, + { x: 108, y: 104, r: 12 }, + { x: 62, y: 44, r: 9 }, + { x: 92, y: 74, r: 10 }, +]; + +/** Rocky ground, which is where the boulders and the ore come from. */ +const SCREE = [ + { x: 108, y: 30, r: 10 }, + { x: 16, y: 78, r: 9 }, + { x: 72, y: 120, r: 10 }, +]; + +const TREES = ["Environment_01", "Environment_02", "Environment_03", "Environment_21"]; +const SCRUB = ["Environment_12", "Environment_19"]; +const STONES = ["Environment_06", "Environment_07", "Environment_08", "Environment_13"]; +const BOULDERS = ["Environment_14", "Environment_15", "Environment_16"]; +/** Ore and the one crystal, rare enough that finding one is worth the look. */ +const SEAMS = ["Environment_10", "Environment_11", "Environment_17", "Environment_18"]; +const DEADFALL = ["Environment_04", "Environment_05"]; + +/** How big each kind is drawn, against Kenney's buildings at their own size. */ +const SIZE = { + tree: { from: 0.75, to: 1.05 }, + scrub: { from: 0.4, to: 0.6 }, + deadfall: { from: 0.5, to: 0.75 }, + stone: { from: 0.45, to: 0.8 }, +} as const; + +/** + * Deterministic, and different per channel so two decisions do not correlate. + * + * The murmur3 finaliser rather than a single multiply-and-shift. The cheap + * version was cheap enough and badly distributed: picking from a list of four + * trees, it chose three of them and never the fourth, anywhere on the map, and + * the scatter used eight of the twenty things it had. One round of mixing is + * not enough to decorrelate the low bits, and the low bits are exactly what + * `pick` reads. + */ +function noise(x: number, y: number, channel: number): number { + let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1) ^ Math.imul(channel + 1, 0x9e3779b1); + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4_294_967_296; +} + +function pick(list: T[], roll: number): T { + return list[Math.min(list.length - 1, Math.floor(roll * list.length))]; +} + +/** How deep inside a clump a tile is: 1 at the middle, 0 at the edge and out. */ +function within(clumps: { x: number; y: number; r: number }[], x: number, y: number): number { + let best = 0; + for (const clump of clumps) { + const distance = Math.hypot(x - clump.x, y - clump.y); + if (distance >= clump.r) continue; + best = Math.max(best, 1 - distance / clump.r); + } + return best; +} + +/** + * Where nothing may grow. + * + * Holdings and the ground they have cleared, plus a margin either side of every + * road. A tree in the middle of a road is not a charming detail; it is the + * thing that makes somebody look for the collision bug that is not there. + */ +function cleared(): Set { + const out = new Set(); + const mark = (x: number, y: number) => { + const tx = Math.round(x); + const ty = Math.round(y); + if (tx < 0 || ty < 0 || tx >= MAP.width || ty >= MAP.height) return; + out.add(ty * MAP.width + tx); + }; + + for (const garrison of GARRISONS) { + const reach = garrison.radius + 2; + for (let y = -reach; y <= reach; y += 1) { + for (let x = -reach; x <= reach; x += 1) { + if (Math.hypot(x, y) > reach) continue; + mark(garrison.x + x, garrison.y + y); + } + } + } + + /* + * The camps too. They are fixed ground whether or not anybody is holding + * them, so the wood is cleared off them once here rather than when a hero + * turns up -- the scatter is static and the roster is not. + */ + for (const site of campSites()) { + const reach = CAMP_RADIUS + 2; + for (let y = -reach; y <= reach; y += 1) { + for (let x = -reach; x <= reach; x += 1) { + if (Math.hypot(x, y) > reach) continue; + mark(site.x + x, site.y + y); + } + } + } + + /* + * The roads, along the line they actually take. This reads `roadPaths` for + * the same reason the ground does -- the two used to each walk their own + * copy of a straight line, which agreed because identical expressions + * cannot disagree. A curve can, and the failure is a tree standing in the + * middle of a lane. + */ + for (const path of roadPaths()) { + for (const step of path) { + const reach = Math.ceil(step.width) + 1; + for (let dy = -reach; dy <= reach; dy += 1) { + for (let dx = -reach; dx <= reach; dx += 1) mark(step.x + dx, step.y + dy); + } + } + } + + return out; +} + +export function scatterProps(): Prop[] { + const tiles = groundTiles(); + const off = cleared(); + const props: Prop[] = []; + + for (let y = 0; y < MAP.height; y += 1) { + for (let x = 0; x < MAP.width; x += 1) { + const index = y * MAP.width + x; + if (off.has(index)) continue; + + const ground = tiles[index]; + if (ground === "water" || ground === "stone" || ground === "dirt") continue; + + const wood = within(WOODS, x, y); + const scree = within(SCREE, x, y); + const roll = noise(x, y, 1); + + /* + * One decision per tile, in order of how much each thing wants to be + * here. A tile in deep woods is very likely a tree; the same tile out on + * open grass is very unlikely to be anything at all, which is what makes + * the open ground read as open rather than as thinly wooded. + */ + let sprite: string | undefined; + + if (wood > 0 && roll < 0.1 + wood * 0.62) { + const kind = noise(x, y, 2); + sprite = kind < 0.78 ? pick(TREES, noise(x, y, 3)) + : kind < 0.92 ? pick(SCRUB, noise(x, y, 4)) + : pick(DEADFALL, noise(x, y, 5)); + } else if (scree > 0 && roll < 0.06 + scree * 0.45) { + const kind = noise(x, y, 6); + sprite = kind < 0.6 ? pick(BOULDERS, noise(x, y, 7)) + : kind < 0.9 ? pick(STONES, noise(x, y, 8)) + /* The seams: about one tile in thirty of rocky ground. */ + : kind < 0.99 ? pick(SEAMS, noise(x, y, 9)) + : "Environment_20"; + } else if (ground === "sand" && roll < 0.09) { + sprite = pick(STONES, noise(x, y, 10)); + } else if (roll < 0.045) { + /* Open country: the occasional lone tree, bush or stone. */ + const kind = noise(x, y, 11); + sprite = kind < 0.4 ? pick(TREES, noise(x, y, 12)) + : kind < 0.75 ? pick(SCRUB, noise(x, y, 13)) + : pick(STONES, noise(x, y, 14)); + } + + if (!sprite) continue; + + /* + * Nudged off the centre of the tile and sized a little differently each + * time. Props sitting dead on the grid read as a grid, which is the one + * thing the scatter exists to break up. + * + * Size is by what the thing is rather than one range for everything. At + * a single scale that suited the boulders, a full-grown pine came out + * half the height of a cottage, and a wood of those reads as a herb + * garden. A tree stands over a roof; a shrub comes up to a knee. + */ + const range = TREES.includes(sprite) ? SIZE.tree + : SCRUB.includes(sprite) ? SIZE.scrub + : DEADFALL.includes(sprite) ? SIZE.deadfall + : SIZE.stone; + + props.push({ + sprite, + x: x + (noise(x, y, 15) - 0.5) * 0.7, + y: y + (noise(x, y, 16) - 0.5) * 0.7, + scale: range.from + noise(x, y, 17) * (range.to - range.from), + }); + } + } + + return props; +} diff --git a/app/src/game/world/sim.test.ts b/app/src/game/world/sim.test.ts new file mode 100644 index 0000000..e49515c --- /dev/null +++ b/app/src/game/world/sim.test.ts @@ -0,0 +1,532 @@ +import { describe, expect, it } from "vitest"; +import { GARRISONS } from "./marches"; +import { assignCamps, CAMP_RADIUS, campSites } from "./camps"; +import { + besieged, + createSim, + garrisonSoldiers, + orderHero, + setRoster, + tickSim, + yourHero, + type Sim, + type Work, +} from "./sim"; + +/** + * The simulation, run deep and looked at. + * + * Most of this is about one sentence: a hero is a person, a soldier is a + * session, and a soldier belongs to the hero who owns it. The rest is about the + * shaking -- a wright that reverses direction thirty times a second looks like a + * rendering fault and is not one, and the only way to tell is to run the numbers + * without Pixi in the way and count. + */ + +const session = (id: string) => ({ + id, + startedAt: Date.now(), + host: "laptop", + command: "npm run dev", +}); + +/** One person with ten Claude sessions and three OpenClaw ones: the worked example. */ +function theExample(): Sim { + const sim = createSim(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "claude-code" }], + soldiers: [ + ...Array.from({ length: 10 }, (_, index) => ({ + id: `claude-${index}`, + name: `claude ${index}`, + kind: "claude-code", + work: "feature" as Work, + heroUid: "ada", + session: session(`claude-${index}`), + })), + ...Array.from({ length: 3 }, (_, index) => ({ + id: `claw-${index}`, + name: `claw ${index}`, + kind: "openclaw", + work: "idle" as Work, + heroUid: "ada", + session: session(`claw-${index}`), + })), + ], + youUid: "ada", + }); + return sim; +} + +function run(sim: Sim, ticks: number): Sim { + for (let index = 0; index < ticks; index += 1) tickSim(sim); + return sim; +} + +describe("the camps", () => { + it("finds somewhere for everybody to hold", () => { + expect(campSites().length).toBeGreaterThan(6); + }); + + it("never puts a camp in a holding", () => { + for (const camp of campSites()) { + for (const garrison of GARRISONS) { + expect(Math.hypot(camp.x - garrison.x, camp.y - garrison.y)) + .toBeGreaterThan(garrison.radius + CAMP_RADIUS); + } + } + }); + + it("never puts two camps on the same ground", () => { + /* + * Two heroes handed one spot reads as a rendering fault rather than as a + * crowded team, which is why camps are assigned for the whole roster at + * once instead of one hash at a time. + */ + const sites = campSites(); + for (let first = 0; first < sites.length; first += 1) { + for (let second = first + 1; second < sites.length; second += 1) { + expect(Math.hypot(sites[first].x - sites[second].x, sites[first].y - sites[second].y)) + .toBeGreaterThan(CAMP_RADIUS * 2); + } + } + }); + + it("gives every member their own ground", () => { + const uids = Array.from({ length: 8 }, (_, index) => `member-${index}`); + const camps = assignCamps(uids); + const seen = new Set(([...camps.values()]).map((camp) => `${camp.x},${camp.y}`)); + expect(seen.size).toBe(uids.length); + }); + + it("gives the same member the same ground every time", () => { + const uids = ["ada", "grace", "alan"]; + const first = assignCamps(uids); + const again = assignCamps([...uids].reverse()); + for (const uid of uids) expect(again.get(uid)).toEqual(first.get(uid)); + }); +}); + +describe("heroes and their soldiers", () => { + it("gives one person thirteen soldiers of two classes", () => { + /* The worked example from the specification, asserted. */ + const sim = theExample(); + const soldiers = sim.actors.filter((actor) => actor.role === "soldier"); + expect(soldiers).toHaveLength(13); + expect(soldiers.every((soldier) => soldier.heroUid === "ada")).toBe(true); + expect(new Set(soldiers.map((soldier) => soldier.kind))).toEqual( + new Set(["claude-code", "openclaw"]), + ); + }); + + it("keeps a hero on the field with nothing running", () => { + /* A colleague with nothing open is still on the team. */ + const sim = createSim(); + setRoster(sim, { heroes: [{ uid: "ada", name: "Ada", characterClass: "codex" }], soldiers: [] }); + expect(sim.actors.filter((actor) => actor.role === "hero")).toHaveLength(1); + }); + + it("starts every soldier at its own hero's camp", () => { + const sim = theExample(); + const camp = sim.camps.get("ada")!; + for (const soldier of sim.actors.filter((actor) => actor.role === "soldier")) { + expect(Math.hypot(soldier.x - camp.x, soldier.y - camp.y)).toBeLessThanOrEqual(CAMP_RADIUS); + } + }); + + it("keeps two people's retinues apart", () => { + const sim = createSim(); + setRoster(sim, { + heroes: [ + { uid: "ada", name: "Ada", characterClass: "codex" }, + { uid: "alan", name: "Alan", characterClass: "openclaw" }, + ], + soldiers: [ + { id: "a", name: "a", kind: "codex", work: "idle", heroUid: "ada" }, + { id: "b", name: "b", kind: "openclaw", work: "idle", heroUid: "alan" }, + ], + }); + run(sim, 600); + + const ada = sim.actors.find((actor) => actor.id === "soldier-a")!; + const alan = sim.actors.find((actor) => actor.id === "soldier-b")!; + const adaCamp = sim.camps.get("ada")!; + const alanCamp = sim.camps.get("alan")!; + expect(Math.hypot(ada.x - adaCamp.x, ada.y - adaCamp.y)).toBeLessThan(CAMP_RADIUS + 3); + expect(Math.hypot(alan.x - alanCamp.x, alan.y - alanCamp.y)).toBeLessThan(CAMP_RADIUS + 3); + }); + + it("takes the same roster twice without doubling anybody", () => { + /* + * The poll depends on this. The roster arrives every four seconds and is + * usually identical; before `setRoster` was idempotent the field filled up + * with copies of everybody. + */ + const sim = theExample(); + const before = sim.actors.length; + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "claude-code" }], + soldiers: sim.actors + .filter((actor) => actor.role === "soldier") + .map((actor) => ({ + id: actor.id.replace("soldier-", ""), + name: actor.name, + kind: actor.kind, + work: actor.work, + heroUid: "ada", + })), + youUid: "ada", + }); + expect(sim.actors).toHaveLength(before); + }); + + it("leaves people standing where they were when the roster repeats", () => { + const sim = theExample(); + run(sim, 200); + const before = sim.actors.map((actor) => `${actor.id}:${actor.x.toFixed(3)}`); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "claude-code" }], + soldiers: sim.actors + .filter((actor) => actor.role === "soldier") + .map((actor) => ({ + id: actor.id.replace("soldier-", ""), + name: actor.name, + kind: actor.kind, + work: actor.work, + heroUid: "ada", + })), + youUid: "ada", + }); + expect(sim.actors.map((actor) => `${actor.id}:${actor.x.toFixed(3)}`)).toEqual(before); + }); + + it("dismisses a soldier whose session has gone", () => { + const sim = theExample(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "claude-code" }], + soldiers: [], + youUid: "ada", + }); + expect(sim.actors.filter((actor) => actor.role === "soldier")).toHaveLength(0); + expect(sim.actors.filter((actor) => actor.role === "hero")).toHaveLength(1); + }); + + it("marches a finished session to the Barrow rather than blinking it out", () => { + /* + * A session ending is the most important thing that happens in this game -- + * it is where the experience comes from -- and a figure that vanishes is + * the one way of showing that which says nothing at all. + */ + const sim = theExample(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "claude-code" }], + soldiers: [], + youUid: "ada", + }); + + const marching = sim.actors.filter((actor) => actor.role === "fallen"); + expect(marching).toHaveLength(13); + expect(sim.finished).toBe(13); + + const barrow = GARRISONS.find((holding) => holding.id === "barrow")!; + for (const one of marching) { + expect(one.toX).toBe(barrow.x); + expect(one.toY).toBe(barrow.y); + } + }); + + it("takes them off the field once they arrive", () => { + const sim = theExample(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "claude-code" }], + soldiers: [], + youUid: "ada", + }); + run(sim, 3000); + expect(sim.actors.filter((actor) => actor.role === "fallen")).toHaveLength(0); + expect(sim.actors.filter((actor) => actor.role === "hero")).toHaveLength(1); + }); + + it("does not bring the finished back", () => { + /* A session that finished stays finished; only the living are set back up. */ + const sim = theExample(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "claude-code" }], + soldiers: [], + youUid: "ada", + }); + run(sim, 6000); + expect(sim.actors.filter((actor) => actor.role === "soldier")).toHaveLength(0); + }); + + it("does not dismiss the watch along with the roster", () => { + const sim = theExample(); + garrisonSoldiers(sim); + const watch = sim.actors.filter((actor) => actor.role === "watch").length; + setRoster(sim, { heroes: [], soldiers: [] }); + expect(sim.actors.filter((actor) => actor.role === "watch")).toHaveLength(watch); + }); +}); + +describe("following", () => { + it("brings the retinue along when the hero is sent somewhere", () => { + /* + * The point of the whole arrangement. Soldiers are not in formation -- they + * notice after a few paces and then hurry, which reads as people following + * somebody rather than as a parade. + */ + const sim = theExample(); + const camp = sim.camps.get("ada")!; + run(sim, 60); + + orderHero(sim, camp.x + 22, camp.y + 10); + run(sim, 900); + + const hero = yourHero(sim)!; + const soldiers = sim.actors.filter((actor) => actor.role === "soldier"); + const near = soldiers.filter( + (soldier) => Math.hypot(soldier.x - hero.x, soldier.y - hero.y) < CAMP_RADIUS + 4, + ); + expect(near.length).toBeGreaterThan(soldiers.length / 2); + }); + + it("puts the hero where it was told, and leaves them there", () => { + /* + * Two claims, and the second is the one that was broken. A hero used to + * walk to where they were sent, arrive, notice they were a long way from + * their camp and walk straight back -- which makes the one thing the player + * can do in this game pointless. Where they were sent becomes where they + * hold. + * + * The tolerance is a camp's width rather than a pixel because a hero + * standing exactly still on the spot they were sent to is a statue. They + * arrive and then mill about it, which is what everybody else does too. + */ + const sim = theExample(); + const camp = sim.camps.get("ada")!; + orderHero(sim, camp.x + 12, camp.y - 6); + run(sim, 600); + + const hero = yourHero(sim)!; + expect(hero.station).toEqual({ x: camp.x + 12, y: camp.y - 6 }); + expect(Math.hypot(hero.x - (camp.x + 12), hero.y - (camp.y - 6))).toBeLessThan(CAMP_RADIUS); + expect(Math.hypot(hero.x - camp.x, hero.y - camp.y)).toBeGreaterThan(CAMP_RADIUS); + }); + + it("refuses to order anybody else's hero", () => { + /* + * Marching a colleague around the map would be a toy, and the only thing in + * this game that changes what somebody else sees. + */ + const sim = createSim(); + setRoster(sim, { + heroes: [{ uid: "grace", name: "Grace", characterClass: "codex" }], + soldiers: [], + /* No `youUid`: the viewer is not on this team. */ + }); + expect(orderHero(sim, 10, 10)).toBe(false); + expect(sim.actors.every((actor) => !actor.ordered)).toBe(true); + }); +}); + +describe("the Unmade", () => { + it("comes for a hero whose session is on a fault", () => { + const sim = createSim(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "codex" }], + soldiers: [{ id: "a", name: "fix: it", kind: "codex", work: "bug", heroUid: "ada" }], + youUid: "ada", + }); + expect(besieged(sim)).toEqual(["ada"]); + run(sim, 300); + expect(sim.spawned).toBeGreaterThan(0); + }); + + it("shows a soldier building, so feature work is not invisible", () => { + /* + * A map where only broken things move would quietly teach everybody that + * only broken things count. + */ + const sim = theExample(); + run(sim, 300); + expect(sim.raised).toBeGreaterThan(0); + }); + + it("leaves alone a hero who is only building", () => { + const sim = theExample(); + /* Ten features and three idle: nothing broken, so nothing comes. */ + run(sim, 600); + expect(sim.actors.some((actor) => actor.side === "unmade")).toBe(false); + }); + + it("comes to the right camp", () => { + const sim = createSim(); + setRoster(sim, { + heroes: [ + { uid: "ada", name: "Ada", characterClass: "codex" }, + { uid: "alan", name: "Alan", characterClass: "codex" }, + ], + soldiers: [{ id: "a", name: "fix: it", kind: "codex", work: "bug", heroUid: "ada" }], + youUid: "ada", + }); + run(sim, 200); + const adaCamp = sim.camps.get("ada")!; + const unmade = sim.actors.filter((actor) => actor.side === "unmade"); + expect(unmade.length).toBeGreaterThan(0); + for (const foe of unmade) { + expect(foe.heroUid).toBe("ada"); + expect(Math.hypot(foe.x - adaCamp.x, foe.y - adaCamp.y)).toBeLessThan(30); + } + }); + + it("is bounded however long it runs", () => { + const sim = createSim(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "codex" }], + soldiers: [{ id: "a", name: "fix: it", kind: "codex", work: "bug", heroUid: "ada" }], + youUid: "ada", + }); + run(sim, 6000); + expect(sim.actors.filter((actor) => actor.side === "unmade").length).toBeLessThanOrEqual(40); + }); + + it("does not throw the whole wave at one camp", () => { + /* + * A team where one person is fixing everything drew every foe on the map, + * and everybody else's camp stayed quiet. Each camp takes a share. + */ + const sim = createSim(); + setRoster(sim, { + heroes: [ + { uid: "ada", name: "Ada", characterClass: "codex" }, + { uid: "alan", name: "Alan", characterClass: "codex" }, + ], + soldiers: [ + { id: "a", name: "fix: it", kind: "codex", work: "bug", heroUid: "ada" }, + { id: "b", name: "fix: that", kind: "codex", work: "bug", heroUid: "alan" }, + ], + youUid: "ada", + }); + run(sim, 2000); + + const atAda = sim.actors.filter((a) => a.side === "unmade" && a.heroUid === "ada").length; + const atAlan = sim.actors.filter((a) => a.side === "unmade" && a.heroUid === "alan").length; + expect(atAda).toBeLessThanOrEqual(7); + expect(atAlan).toBeLessThanOrEqual(7); + }); + + it("gets felled, and the count says so", () => { + const sim = createSim(); + garrisonSoldiers(sim); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "codex" }], + soldiers: Array.from({ length: 4 }, (_, index) => ({ + id: `a${index}`, + name: "fix: it", + kind: "codex", + work: "bug" as Work, + heroUid: "ada", + })), + youUid: "ada", + }); + run(sim, 3000); + expect(sim.felled).toBeGreaterThan(0); + }); + + it("never kills anybody who stands for something real", () => { + /* + * A hero is a person and a soldier is a running session. Neither stops + * existing because something bit it: they are set back on their feet at + * their camp, which reads as being driven off. Only the Unmade die. + */ + const sim = createSim(); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "codex" }], + soldiers: [{ id: "a", name: "fix: it", kind: "codex", work: "bug", heroUid: "ada" }], + youUid: "ada", + }); + run(sim, 4000); + expect(sim.actors.filter((actor) => actor.role === "hero")).toHaveLength(1); + expect(sim.actors.filter((actor) => actor.role === "soldier")).toHaveLength(1); + }); +}); + +describe("running", () => { + it("moves people when it is ticked", () => { + const sim = theExample(); + const before = sim.actors.map((actor) => `${actor.x},${actor.y}`); + run(sim, 120); + expect(sim.actors.map((actor) => `${actor.x},${actor.y}`)).not.toEqual(before); + }); + + it("does not shake", () => { + /* + * The bug this whole file was written for. A wright that cannot reach where + * it is going re-decides every tick and spends its life turning round; on + * screen that reads as violent vibration. Counting direction reversals is + * how it was found and the only honest way to say it is gone. + */ + const sim = theExample(); + garrisonSoldiers(sim); + const TICKS = 900; + const facing = new Map(sim.actors.map((actor) => [actor.id, actor.facing])); + const reversals = new Map(sim.actors.map((actor) => [actor.id, 0])); + + for (let index = 0; index < TICKS; index += 1) { + tickSim(sim); + for (const actor of sim.actors) { + const was = facing.get(actor.id); + if (was !== undefined && was !== actor.facing) { + reversals.set(actor.id, (reversals.get(actor.id) ?? 0) + 1); + } + facing.set(actor.id, actor.facing); + } + } + + expect(Math.max(...reversals.values())).toBeLessThan(TICKS / 20); + }); + + it("keeps everybody on the map", () => { + const sim = theExample(); + garrisonSoldiers(sim); + run(sim, 1200); + for (const actor of sim.actors) { + expect(Number.isFinite(actor.x)).toBe(true); + expect(Number.isFinite(actor.y)).toBe(true); + expect(actor.x).toBeGreaterThan(-8); + expect(actor.y).toBeGreaterThan(-8); + } + }); + + it("never reports an action it cannot draw", () => { + const sim = theExample(); + garrisonSoldiers(sim); + for (let index = 0; index < 900; index += 1) { + tickSim(sim); + for (const actor of sim.actors) { + expect(["stand", "walk", "attack"]).toContain(actor.action); + expect(actor.hp).toBeGreaterThan(0); + expect(actor.hp).toBeLessThanOrEqual(actor.maxHp); + } + } + }); + + it("clears its own effects rather than growing forever", () => { + const sim = createSim(); + garrisonSoldiers(sim); + setRoster(sim, { + heroes: [{ uid: "ada", name: "Ada", characterClass: "codex" }], + soldiers: Array.from({ length: 4 }, (_, index) => ({ + id: `a${index}`, + name: "fix: it", + kind: "codex", + work: "bug" as Work, + heroUid: "ada", + })), + youUid: "ada", + }); + run(sim, 3000); + expect(sim.effects.length).toBeLessThan(200); + expect(sim.marks.length).toBeLessThan(200); + }); +}); diff --git a/app/src/game/world/sim.ts b/app/src/game/world/sim.ts new file mode 100644 index 0000000..808b84f --- /dev/null +++ b/app/src/game/world/sim.ts @@ -0,0 +1,872 @@ +import { GARRISONS, type Garrison } from "./marches"; +import { assignCamps, CAMP_RADIUS, type Camp } from "./camps"; +import { assignBanners } from "./banners"; +import type { Work } from "./work"; +import { pushOut } from "./solids"; + +/** + * Who is on the Marches, and what they are doing. + * + * The shape of it: **a hero is a person, a soldier is a session, and a soldier + * belongs to the hero who owns it.** Somebody with ten Claude Code sessions and + * three OpenClaw sessions has thirteen soldiers of two classes, all of them + * theirs, all of them around their camp. That is the whole model, and every + * rule below follows from it. + * + * Heroes exist whether or not anything is running. That matters: a team member + * with nothing open is still on the team, so they are still somewhere on the + * map. It is the soldiers around them that come and go. + * + * There is no obstacle avoidance and there never will be. The map is open + * country, and the version that had some spent most of its code steering round + * the one building in the one yard -- every version of which shook, because a + * wright that cannot reach where it is going re-decides thirty times a second + * and spends its life turning round. The fix was deleting the thing that shook. + * + * Everything here is plain objects advanced by pure functions, with no Pixi in + * it, so a thousand ticks can be run in a test and looked at. + */ + +export type { Work }; +export type Side = "garrison" | "unmade"; + +/** + * What an actor is, which decides how it moves. + * + * `hero` and `soldier` stand for something in the account. `watch` is scenery: + * a keep with five people in it does not look like a keep, and a fault met by + * one wright does not look like a battle. `unmade` is the fault itself. + * + * `fallen` is a soldier whose session has closed, walking to the Barrow. It is + * a role rather than an immediate removal because a session ending is the most + * important thing that happens in this game -- it is where the experience comes + * from -- and a figure that blinks out of existence is the one way of showing + * that which says nothing at all. + */ +export type Role = "hero" | "soldier" | "watch" | "unmade" | "fallen"; + +export interface Actor { + id: string; + side: Side; + role: Role; + /** For a soldier, the session kind. For the rest, which sprite to draw. */ + kind: string; + /** Shown on the plate, and in the panel when clicked. */ + name: string; + work: Work; + x: number; + y: number; + toX: number; + toY: number; + facing: 1 | -1; + moving: boolean; + hp: number; + maxHp: number; + /** Ticks of flinch left, so a hit is visible as well as counted. */ + hurt: number; + /** What the renderer draws: standing, walking, or mid-blow. */ + action: "stand" | "walk" | "attack"; + actionUntil: number; + /** Ticks to mill about before choosing somewhere new. */ + rest: number; + /** Which holding this actor belongs to, for the watch. */ + home: string; + /** + * Whose this is. + * + * A hero's own account id, or for a soldier the id of the hero who owns it. + * This is the join the whole model turns on: it is what puts a session's + * soldier beside the right person. + */ + heroUid?: string; + /** + * A hero walking where the player pointed. + * + * Only ever true of the player's own hero. Cleared on arrival, and it is what + * makes an order outrank milling about. + */ + ordered?: boolean; + /** + * Where a hero has been told to hold. + * + * Set when an order finishes, and it becomes the ground they mill about and + * the ground their retinue gathers on. Without it a hero walked to where they + * were sent, arrived, noticed they were a long way from their camp, and + * immediately walked back -- which makes the one thing the player can do in + * this game pointless. + */ + station?: { x: number; y: number }; + /** Who it is fighting, held until that one dies or wanders off. */ + targetId?: string; + /** + * A real session, rather than a hero or one of the watch. + * + * Only these stand for something running. The rest are a person, or scenery. + */ + session?: { id: string; startedAt: number; host: string; command: string }; +} + +export interface Effect { + id: number; + kind: "hit" | "cast" | "build" | "fell"; + x: number; + y: number; + life: number; + maxLife: number; +} + +export interface Mark { + id: number; + text: string; + x: number; + y: number; + life: number; + maxLife: number; + kind: "damage" | "gain"; +} + +export interface Sim { + actors: Actor[]; + effects: Effect[]; + marks: Mark[]; + clock: number; + spawned: number; + felled: number; + raised: number; + /** Ticks until the next of the Unmade arrives. */ + nextSpawn: number; + /** How many sessions have closed while this field has been watched. */ + finished: number; + /** Where each hero holds, by account id. */ + camps: Map; + /** + * What colour each hero's company is washed in. + * + * Kept here rather than worked out in the renderer because it has to be the + * same everywhere it is used -- the ring on the ground, the plate, and the + * inspect card -- and two places computing it is two places that can drift. + */ + banners: Map; + /** Which hero is the player's, so only that one takes orders. */ + youUid?: string; +} + +/** Tiles a second. */ +const WALK = 1.9; +const CHARGE = 2.4; +/** A hero walks a little faster, so a following retinue strings out behind. */ +const HERO_WALK = 2.7; +/** Thirty ticks a second, matching the renderer's fixed step. */ +const PER_TICK = 1 / 30; + +const REACH = 0.9; +const CAST_REACH = 3.2; +const SWING_EVERY = 20; +const SWING_ANIM = 12; +const HURT_TICKS = 6; +const MARK_TICKS = 45; +const EFFECT_TICKS = 18; +/* + * How often the Unmade arrive, and how many stand at once. + * + * Both raised, because a camp with a fault being worked on should look like it. + * At the old rate one foe wandered in every three seconds and was put down + * before the next arrived, so a besieged camp looked much like a quiet one -- + * which is the opposite of what the whole arrangement is for. + */ +const SPAWN_EVERY = 34; +const MAX_UNMADE = 40; +/** How many of the Unmade one camp can have at it before the rest hold back. */ +const MAX_PER_CAMP = 7; + +/** How often a soldier building something shows that it is. */ +const BUILD_EVERY = 52; +/** Beyond this a fight is abandoned, and outside it none is started. */ +const ABANDON_AT = 9; + +/** + * How far a soldier lets its hero get before it goes after them. + * + * Loose on purpose. A retinue that holds formation reads as a parade; one that + * notices after a few paces and then hurries reads as people following someone. + */ +const LEASH = CAMP_RADIUS + 1.5; + +export function createSim(): Sim { + return { + actors: [], + effects: [], + marks: [], + clock: 0, + spawned: 0, + felled: 0, + raised: 0, + nextSpawn: 40, + finished: 0, + camps: new Map(), + banners: new Map(), + }; +} + +/** Deterministic, so the map is the same every time it is opened. */ +function noise(seed: number): number { + let value = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b); + value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); + return ((value ^ (value >>> 16)) >>> 0) / 4_294_967_296; +} + +function hashId(id: string): number { + let value = 0; + for (let index = 0; index < id.length; index += 1) { + value = (Math.imul(value, 31) + id.charCodeAt(index)) | 0; + } + return Math.abs(value); +} + +/** Somewhere to stand around a point, on the apron rather than the middle. */ +function spotAround(x: number, y: number, radius: number, seed: number): { x: number; y: number } { + const angle = noise(seed) * Math.PI * 2; + const distance = radius * (0.35 + noise(seed * 7) * 0.6); + return { x: x + Math.cos(angle) * distance, y: y + Math.sin(angle) * distance * 0.85 }; +} + +function spotIn(garrison: Garrison, seed: number): { x: number; y: number } { + return spotAround(garrison.x, garrison.y, garrison.radius, seed); +} + +let nextEffect = 1; + +function addEffect(sim: Sim, kind: Effect["kind"], x: number, y: number): void { + sim.effects.push({ id: nextEffect++, kind, x, y, life: EFFECT_TICKS, maxLife: EFFECT_TICKS }); +} + +function addMark(sim: Sim, text: string, x: number, y: number, kind: Mark["kind"]): void { + sim.marks.push({ id: nextEffect++, text, x, y, life: MARK_TICKS, maxLife: MARK_TICKS, kind }); +} + +/* ---- the roster ---------------------------------------------------------- */ + +export interface HeroInput { + uid: string; + name: string; + /** The class they chose, which is only how their own figure is drawn. */ + characterClass: string; +} + +export interface SoldierInput { + id: string; + name: string; + /** The harness this session runs, which is the soldier's class. */ + kind: string; + work: Work; + /** Whose session it is. */ + heroUid: string; + session?: Actor["session"]; +} + +/** + * Brings the field in line with the team and its sessions, in one call. + * + * All at once rather than one muster at a time, because the camps have to be + * solved for the whole roster: two members handed the same ground reads as a + * rendering fault rather than as a crowded team, and that cannot be decided one + * member at a time. + * + * It is idempotent, which the poll depends on. The roster arrives every few + * seconds and is usually identical; anybody already on the field stays exactly + * where they are, and only arrivals and departures cost anything. + */ +export function setRoster( + sim: Sim, + roster: { heroes: HeroInput[]; soldiers: SoldierInput[]; youUid?: string }, +): void { + sim.youUid = roster.youUid; + sim.camps = assignCamps(roster.heroes.map((hero) => hero.uid)); + sim.banners = assignBanners(roster.heroes.map((hero) => hero.uid)); + + const wanted = new Set(); + + for (const hero of roster.heroes) { + const id = `hero-${hero.uid}`; + wanted.add(id); + const standing = sim.actors.find((actor) => actor.id === id); + if (standing) { + standing.name = hero.name; + standing.kind = hero.characterClass || standing.kind; + continue; + } + const camp = sim.camps.get(hero.uid) ?? { x: 64, y: 62 }; + sim.actors.push({ + id, + side: "garrison", + role: "hero", + kind: hero.characterClass || "terminal", + name: hero.name, + work: "idle", + x: camp.x, + y: camp.y, + toX: camp.x, + toY: camp.y, + facing: 1, + moving: false, + /* + * A hero is a tank, and deliberately so. + * + * At sixty they had less in them than a single heisenbug has, so a camp + * with a fault on it put its own person down inside a few seconds -- and + * a hero is not a unit, it is somebody on the team. There is no death + * here and nothing to lose, so the health bar is not a stake; it is a + * read-out of how hard a camp is being hit. It has to survive a wave to + * say anything at all, and at this it does. + */ + hp: 420, + maxHp: 420, + hurt: 0, + action: "stand", + actionUntil: 0, + rest: 0, + home: "camp", + heroUid: hero.uid, + }); + } + + for (const soldier of roster.soldiers) { + const id = `soldier-${soldier.id}`; + wanted.add(id); + const standing = sim.actors.find((actor) => actor.id === id); + if (standing) { + standing.work = soldier.work; + standing.name = soldier.name; + continue; + } + const camp = sim.camps.get(soldier.heroUid) ?? { x: 64, y: 62 }; + const spot = spotAround(camp.x, camp.y, CAMP_RADIUS, hashId(id)); + sim.actors.push({ + id, + side: "garrison", + role: "soldier", + kind: soldier.kind, + name: soldier.name, + work: soldier.work, + x: spot.x, + y: spot.y, + toX: spot.x, + toY: spot.y, + facing: 1, + moving: false, + hp: 20, + maxHp: 20, + hurt: 0, + action: "stand", + actionUntil: 0, + rest: 0, + home: "camp", + heroUid: soldier.heroUid, + session: soldier.session, + }); + } + + /* + * A soldier no longer on the roster has had its session close. It does not + * vanish: it turns for the Barrow and walks there, and is taken off the field + * when it arrives. + * + * Heroes do vanish, because a member leaving a team is an administrative fact + * rather than an event on the map, and marching them to a graveyard would be + * saying something quite different and untrue. + */ + const barrow = GARRISONS.find((holding) => holding.id === "barrow"); + for (const actor of sim.actors) { + if (actor.role !== "soldier" || wanted.has(actor.id)) continue; + actor.role = "fallen"; + actor.targetId = undefined; + actor.moving = true; + actor.action = "walk"; + actor.toX = barrow?.x ?? 46; + actor.toY = barrow?.y ?? 22; + sim.finished += 1; + } + + sim.actors = sim.actors.filter( + (actor) => actor.role !== "hero" || wanted.has(actor.id), + ); +} + +/** The player's own hero, if they have one on the field. */ +export function yourHero(sim: Sim): Actor | undefined { + if (!sim.youUid) return undefined; + return sim.actors.find((actor) => actor.role === "hero" && actor.heroUid === sim.youUid); +} + +/** + * Orders a hero to walk somewhere. + * + * Only ever the player's own. Being able to march a colleague around the map + * would be a toy, and it would be the only thing in this game that changes what + * somebody else sees. + */ +export function orderHero(sim: Sim, x: number, y: number): boolean { + const hero = yourHero(sim); + if (!hero) return false; + hero.toX = x; + hero.toY = y; + hero.moving = true; + hero.ordered = true; + hero.action = "walk"; + hero.rest = 0; + return true; +} + +/** + * The garrison's own watch. + * + * Not sessions and never will be. They are here because a keep with five people + * in it does not look like a keep, and a fault met by one wright does not look + * like a battle. Clicking one says as much rather than pretending otherwise. + */ +export function garrisonSoldiers(sim: Sim): void { + for (const garrison of GARRISONS) { + const count = garrison.draws === "bug" ? 6 : 3; + for (let index = 0; index < count; index += 1) { + const id = `${garrison.id}-watch-${index}`; + /* Calling this twice must not double the watch. */ + if (sim.actors.some((actor) => actor.id === id)) continue; + const spot = spotIn(garrison, hashId(id)); + sim.actors.push({ + id, + side: "garrison", + role: "watch", + kind: "soldier", + name: `${garrison.name} watch`, + work: "idle", + x: spot.x, + y: spot.y, + toX: spot.x, + toY: spot.y, + facing: 1, + moving: false, + hp: 14, + maxHp: 14, + hurt: 0, + action: "stand", + actionUntil: 0, + rest: Math.round(noise(hashId(id)) * 60), + home: garrison.id, + }); + } + } +} + +/* ---- the Unmade ---------------------------------------------------------- */ + +/* + * How much the Unmade can take. + * + * Raised a long way. A camp has a hero hitting twice as hard as anybody else, + * several soldiers, and the holding's own watch, so at the old figures a foe + * arrived and was gone inside a second -- the fighting was a flicker of damage + * numbers rather than anything you could watch. These last long enough to be a + * fight, which is the whole point of drawing them. + */ +const UNMADE_KINDS = [ + { kind: "mite", hp: 22 }, + { kind: "crawler", hp: 48 }, + { kind: "heisenbug", hp: 90 }, +]; + +/** + * Which heroes have a fault being worked on, and so have something coming. + * + * Per hero, not per map. The Unmade are somebody's bugs: they come to the camp + * of the person whose session is fixing something. A team where nobody is + * fixing anything has a quiet map, which is the correct picture of a quiet day + * and the reason a loud one means anything. + */ +export function besieged(sim: Sim): string[] { + const under = new Set(); + for (const actor of sim.actors) { + if (actor.role !== "soldier" || actor.work !== "bug" || !actor.heroUid) continue; + under.add(actor.heroUid); + } + return [...under].sort(); +} + +function spawnUnmade(sim: Sim, heroUid: string): void { + const camp = sim.camps.get(heroUid); + if (!camp) return; + sim.spawned += 1; + + const roll = noise(sim.spawned * 977); + const choice = UNMADE_KINDS[Math.min(2, Math.floor(roll * UNMADE_KINDS.length))]; + + /* Out of the open country, from a different quarter each time. */ + const angle = noise(sim.spawned * 31) * Math.PI * 2; + const distance = CAMP_RADIUS + 6 + noise(sim.spawned * 53) * 6; + sim.actors.push({ + id: `unmade-${sim.spawned}`, + side: "unmade", + role: "unmade", + kind: choice.kind, + name: choice.kind, + work: "bug", + x: camp.x + Math.cos(angle) * distance, + y: camp.y + Math.sin(angle) * distance * 0.85, + toX: camp.x, + toY: camp.y, + facing: -1, + moving: true, + hp: choice.hp, + maxHp: choice.hp, + hurt: 0, + action: "walk", + actionUntil: 0, + rest: 0, + home: "camp", + /* Whose fault it is, so it makes for the right camp. */ + heroUid, + }); +} + +/* ---- fighting ------------------------------------------------------------ */ + +function blow(kind: string): number { + switch (kind) { + case "codex": + return 4; + case "openclaw": + return 3; + case "soldier": + return 2; + case "hermes": + return 2; + default: + return 3; + } +} + +function ranged(kind: string): boolean { + return kind === "codex"; +} + +/** The nearest enemy, held once chosen. This is what stops the shaking. */ +function chooseEnemy(sim: Sim, actor: Actor): Actor | undefined { + const held = sim.actors.find((other) => other.id === actor.targetId); + if (held && held.hp > 0 && Math.hypot(held.x - actor.x, held.y - actor.y) < ABANDON_AT) { + return held; + } + + let best: Actor | undefined; + /* + * Bounded, so nobody sets off across the map after a fight two camps away. + * Unbounded, one besieged camp pulled every soldier on the field towards it. + */ + let bestDistance = ABANDON_AT; + for (const other of sim.actors) { + if (other.side === actor.side || other.hp <= 0) continue; + /* Nobody harries the dead on their way to the Barrow. */ + if (other.role === "fallen") continue; + const distance = Math.hypot(other.x - actor.x, other.y - actor.y); + if (distance < bestDistance) { + bestDistance = distance; + best = other; + } + } + actor.targetId = best?.id; + return best; +} + +/** + * A step towards a point, and out of anything it lands inside. + * + * Still no steering. Nothing here looks ahead or picks a way round, which is + * the thing that made an earlier version shake; the step is taken exactly as it + * always was and the result is then corrected by `pushOut`, which is a function + * of position alone. Walking into a wall at an angle slides along it, because + * the correction is perpendicular to the wall and the rest of the step lives. + * + * A figure walking straight at a wall slides nowhere, so a walk that ends up + * covering almost none of its step counts as arrived rather than pressing + * against the stone forever -- which is what a person does when the way is + * shut. + */ +function stepTo(actor: Actor, toX: number, toY: number, speed: number): boolean { + const step = speed * PER_TICK; + const dx = toX - actor.x; + const dy = toY - actor.y; + const distance = Math.hypot(dx, dy); + if (distance <= step) { + const landed = pushOut(toX, toY); + actor.x = landed.x; + actor.y = landed.y; + return false; + } + + const fromX = actor.x; + const fromY = actor.y; + const wantX = actor.x + (dx / distance) * step; + const wantY = actor.y + (dy / distance) * step; + const landed = pushOut(wantX, wantY); + actor.x = landed.x; + actor.y = landed.y; + + /* Face the way travelled, with a deadband so a stopped actor does not spin. */ + if (Math.abs(dx) > 0.02) actor.facing = dx > 0 ? 1 : -1; + + const moved = Math.hypot(actor.x - fromX, actor.y - fromY); + return moved > step * 0.2; +} + +/** + * Where an actor belongs when there is nothing else to do. + * + * A soldier belongs wherever its hero is, which is what makes a retinue follow + * rather than sit in an abandoned camp. Falling back to the camp covers the + * moment between a hero leaving the roster and their soldiers going with them. + */ +function anchorFor(sim: Sim, actor: Actor, heroes: Map): { x: number; y: number } { + if (actor.role === "watch") { + const garrison = GARRISONS.find((holding) => holding.id === actor.home) ?? GARRISONS[0]; + return { x: garrison.x, y: garrison.y }; + } + if (actor.role === "hero" && actor.station) return actor.station; + if (actor.heroUid) { + if (actor.role === "soldier") { + const hero = heroes.get(actor.heroUid); + if (hero) return { x: hero.x, y: hero.y }; + } + const camp = sim.camps.get(actor.heroUid); + if (camp) return camp; + } + return { x: actor.x, y: actor.y }; +} + +function radiusFor(actor: Actor): number { + if (actor.role !== "watch") return CAMP_RADIUS; + const garrison = GARRISONS.find((holding) => holding.id === actor.home) ?? GARRISONS[0]; + return garrison.radius; +} + +export function tickSim(sim: Sim): void { + sim.clock += 1; + + /* + * An index, built once a tick. A soldier has to find its hero every tick to + * know where to stand, and scanning the whole field to do it is the sort of + * quadratic that only shows up on somebody else's large team. + */ + const heroes = new Map(); + for (const actor of sim.actors) { + if (actor.role === "hero" && actor.heroUid) heroes.set(actor.heroUid, actor); + } + + const under = besieged(sim); + if (under.length > 0 && sim.actors.filter((actor) => actor.side === "unmade").length < MAX_UNMADE) { + sim.nextSpawn -= 1; + if (sim.nextSpawn <= 0) { + sim.nextSpawn = SPAWN_EVERY; + /* + * Round the besieged camps in turn, so one is not singled out, and skip + * any that already has a crowd at it. Without the cap, a team where one + * person is fixing everything drew the whole wave while everybody else's + * camp stayed quiet. + */ + for (let step = 0; step < under.length; step += 1) { + const heroUid = under[(sim.spawned + step) % under.length]; + const already = sim.actors.filter( + (actor) => actor.side === "unmade" && actor.heroUid === heroUid, + ).length; + if (already >= MAX_PER_CAMP) continue; + spawnUnmade(sim, heroUid); + break; + } + } + } + + for (const effect of sim.effects) effect.life -= 1; + sim.effects = sim.effects.filter((effect) => effect.life > 0); + for (const mark of sim.marks) mark.life -= 1; + sim.marks = sim.marks.filter((mark) => mark.life > 0); + + for (const actor of sim.actors) { + if (actor.hurt > 0) actor.hurt -= 1; + if (actor.action !== "stand" && sim.clock >= actor.actionUntil) { + actor.action = actor.moving ? "walk" : "stand"; + } + + /* + * The fallen walk to the Barrow and are taken off when they get there. + * + * They do not fight, are not fought, and answer to nothing else. A session + * that has finished is finished. + */ + if (actor.role === "fallen") { + if (!stepTo(actor, actor.toX, actor.toY, WALK)) { + actor.hp = 0; + } + continue; + } + + /* + * An order outranks everything. + * + * A hero who stops to fight whatever wanders past is a hero you cannot + * steer, and steering is the one thing in this game the player actually + * does. Their retinue keeps fighting; the hero goes where they were sent. + */ + if (actor.ordered) { + if (stepTo(actor, actor.toX, actor.toY, HERO_WALK)) { + actor.moving = true; + if (actor.action !== "attack") actor.action = "walk"; + } else { + actor.moving = false; + actor.ordered = false; + actor.action = "stand"; + actor.rest = 30; + /* Where they were sent is where they now hold. See `station`. */ + actor.station = { x: actor.x, y: actor.y }; + } + continue; + } + + /* + * Who fights: the Unmade, heroes, the watch, and soldiers whose session is + * on a fault. A soldier building a feature does not drop its work because + * something walked past. + */ + const fights = + actor.role === "unmade" || + actor.role === "hero" || + actor.role === "watch" || + actor.work === "bug"; + + if (fights) { + const enemy = chooseEnemy(sim, actor); + if (enemy) { + const dx = enemy.x - actor.x; + const distance = Math.hypot(dx, enemy.y - actor.y); + const reach = ranged(actor.kind) ? CAST_REACH : REACH; + + if (distance > reach) { + actor.moving = stepTo(actor, enemy.x, enemy.y, CHARGE); + if (actor.action !== "attack") actor.action = "walk"; + } else { + actor.moving = false; + if (Math.abs(dx) > 0.02) actor.facing = dx > 0 ? 1 : -1; + if ((sim.clock + hashId(actor.id)) % SWING_EVERY === 0) { + /* A hero hits twice as hard as anyone they brought with them. */ + const damage = blow(actor.kind) * (actor.role === "hero" ? 2 : 1); + actor.action = "attack"; + actor.actionUntil = sim.clock + SWING_ANIM; + addEffect(sim, ranged(actor.kind) ? "cast" : "hit", enemy.x, enemy.y); + enemy.hp -= damage; + enemy.hurt = HURT_TICKS; + addMark(sim, String(damage), enemy.x, enemy.y, "damage"); + if (enemy.hp <= 0) { + addEffect(sim, "fell", enemy.x, enemy.y); + if (enemy.side === "unmade") sim.felled += 1; + } + } + } + continue; + } + } + + /* + * A soldier building something shows that it is. + * + * Not a mechanic: it raises no walls and unlocks nothing. It is here + * because a camp where half the company is on features had nothing visible + * happening in it, so feature work read as idling -- and a map where only + * broken things move would quietly teach everybody that only broken things + * count. + */ + if (actor.role === "soldier" && actor.work === "feature") { + if ((sim.clock + hashId(actor.id)) % BUILD_EVERY === 0) { + addEffect(sim, "build", actor.x, actor.y); + actor.action = "attack"; + actor.actionUntil = sim.clock + SWING_ANIM; + sim.raised += 1; + } + } + + const anchor = anchorFor(sim, actor, heroes); + const radius = radiusFor(actor); + const away = Math.hypot(actor.x - anchor.x, actor.y - anchor.y); + + /* + * Too far from where they belong: go there now, without waiting out the + * rest. For a soldier that means their hero has walked off, and this is + * the whole of what makes a retinue follow. + */ + if (away > LEASH) { + if (!actor.moving || Math.hypot(actor.toX - anchor.x, actor.toY - anchor.y) > LEASH) { + const spot = spotAround(anchor.x, anchor.y, radius, hashId(actor.id) + sim.clock); + actor.toX = spot.x; + actor.toY = spot.y; + actor.moving = true; + actor.action = "walk"; + } + } else if (!actor.moving) { + actor.rest -= 1; + if (actor.rest > 0) continue; + const spot = spotAround(anchor.x, anchor.y, radius, hashId(actor.id) + sim.clock); + actor.toX = spot.x; + actor.toY = spot.y; + actor.moving = true; + actor.action = "walk"; + continue; + } + + if (actor.moving && !stepTo(actor, actor.toX, actor.toY, actor.role === "hero" ? HERO_WALK : WALK)) { + actor.moving = false; + actor.action = "stand"; + actor.rest = Math.round(20 + noise(hashId(actor.id) + sim.clock) * 70); + } + } + + /* + * The dead are taken off after everybody has had their turn, so an actor + * removed mid-loop cannot leave somebody else holding a reference to it. + */ + const fallen = sim.actors.filter((actor) => actor.hp <= 0); + if (fallen.length > 0) { + const gone = new Set(fallen.map((actor) => actor.id)); + sim.actors = sim.actors.filter((actor) => !gone.has(actor.id)); + for (const actor of sim.actors) { + if (actor.targetId && gone.has(actor.targetId)) actor.targetId = undefined; + } + + /* + * Anybody who stands for something real comes back. + * + * A hero is a person and a soldier is a running session; neither stops + * existing because something bit it. They are set back on their feet at + * their camp, which reads as being driven off rather than killed. Only the + * Unmade actually die, and theirs is the only death the game counts. + */ + for (const dead of fallen) { + if (dead.side === "unmade") continue; + /* A session that finished stays finished. */ + if (dead.role === "fallen") continue; + const home = + dead.role === "watch" + ? (GARRISONS.find((holding) => holding.id === dead.home) ?? GARRISONS[0]) + : { ...(sim.camps.get(dead.heroUid ?? "") ?? { x: 64, y: 62 }), radius: CAMP_RADIUS }; + const spot = spotAround(home.x, home.y, home.radius, hashId(dead.id) + sim.clock); + sim.actors.push({ + ...dead, + x: spot.x, + y: spot.y, + toX: spot.x, + toY: spot.y, + hp: dead.maxHp, + hurt: 0, + action: "stand", + moving: false, + ordered: false, + targetId: undefined, + rest: Math.round(60 + noise(hashId(dead.id)) * 60), + }); + } + } +} diff --git a/app/src/game/world/solids.test.ts b/app/src/game/world/solids.test.ts new file mode 100644 index 0000000..607b04b --- /dev/null +++ b/app/src/game/world/solids.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { pushOut, solids } from "./solids"; +import { GARRISONS } from "./marches"; + +/** + * The rules the correction has to keep, rather than the numbers it happens to + * produce. What matters is that nobody stands inside a wall, that the fix never + * argues with itself, and that walking along a wall still gets you somewhere -- + * the last one being what separates this from the steering that was deleted for + * shaking. + */ +describe("solid ground", () => { + const keep = GARRISONS.find((holding) => holding.id === "keep")!; + + it("has a footprint for every building on every holding", () => { + const buildings = GARRISONS.reduce((total, holding) => total + holding.buildings.length, 0); + /* Every building, and the castle, which belongs to no holding's list. */ + expect(solids().length).toBe(buildings + 1); + }); + + it("leaves open ground alone", () => { + /* A corner of the map with nothing built on it. */ + const at = pushOut(12, 12); + expect(at.x).toBe(12); + expect(at.y).toBe(12); + }); + + it("puts somebody standing in the castle back outside it", () => { + const inside = pushOut(keep.x, keep.y - 1 + 0.2); + const dx = inside.x - keep.x; + const dy = (inside.y - (keep.y - 1)) * 2; + expect(Math.hypot(dx, dy)).toBeGreaterThan(3.3); + }); + + it("does not argue with itself", () => { + /* + * The whole reason this is a correction and not a steering decision: the + * answer depends on the position and nothing else, so applying it twice + * changes nothing. A rule that moved a figure on every tick from a settled + * position is a rule that makes it shake. + */ + const once = pushOut(keep.x + 1, keep.y); + const twice = pushOut(once.x, once.y); + expect(twice.x).toBeCloseTo(once.x, 10); + expect(twice.y).toBeCloseTo(once.y, 10); + }); + + it("never leaves anybody inside a wall, from any direction", () => { + for (let angle = 0; angle < 32; angle += 1) { + const t = (angle / 32) * Math.PI * 2; + const at = pushOut(keep.x + Math.cos(t) * 0.5, keep.y - 1 + Math.sin(t) * 0.25); + const dx = at.x - keep.x; + const dy = (at.y - (keep.y - 1)) * 2; + expect(Math.hypot(dx, dy)).toBeGreaterThanOrEqual(3.39); + } + }); + + it("lets somebody slide along a wall rather than stopping dead on it", () => { + /* + * Pressed against the castle and walking past it rather than into it. The + * correction is perpendicular to the wall, so the part of the step that + * runs along the wall survives -- which is what makes a blocked route feel + * like walking round a building instead of hitting a pane of glass. + */ + const start = pushOut(keep.x + 3.4, keep.y - 1); + const nudged = pushOut(start.x, start.y + 0.3); + expect(Math.hypot(nudged.x - start.x, nudged.y - start.y)).toBeGreaterThan(0.1); + }); +}); diff --git a/app/src/game/world/solids.ts b/app/src/game/world/solids.ts new file mode 100644 index 0000000..0155fc3 --- /dev/null +++ b/app/src/game/world/solids.ts @@ -0,0 +1,147 @@ +import { GARRISONS } from "./marches"; + +/** + * The ground a building stands on, which nobody may walk through. + * + * Why this is a list of circles and not a pathfinder, and why nothing here + * re-decides anything: + * + * `sim.ts` says there is no obstacle avoidance and there never will be, and it + * is right about the thing it is refusing. The version it is describing steered + * *before* moving -- an actor looked ahead, saw a wall, chose a way round, and + * chose again on the next tick from a slightly different place. Thirty + * decisions a second out of one wandering position is a figure that spins on + * the spot, and deleting it was the correct fix. + * + * This is the other half of the problem and it is not the same half. Nothing + * here plans, looks ahead, or changes where anybody decided to go. A step is + * taken exactly as it always was, and only then is the result checked: if it + * landed inside a wall it is pushed back out to the nearest point outside. + * That is a function of position alone, so the same position always gives the + * same answer and there is nothing for two ticks to disagree about. Walking + * into a wall at an angle slides along it, because the push-out is + * perpendicular to the wall and the rest of the step survives. + * + * Only the fixed holdings are solid. A camp is not: a camp is where a hero's + * own retinue stands, and making its three tents solid would pen the soldiers + * in against their own barracks. + */ +export interface Solid { + x: number; + y: number; + r: number; +} + +/** + * How much ground a building takes, from how big it is drawn. + * + * Kenney's structures are about a tile and a half across at scale 1, and the + * footprint wanted here is the part a person would walk into rather than the + * roof overhanging it -- so it is a little under half the width. + */ +function radiusFor(scale: number): number { + return 0.78 * scale; +} + +let cache: Solid[] | undefined; + +export function solids(): Solid[] { + if (cache) return cache; + + const out: Solid[] = []; + for (const garrison of GARRISONS) { + for (const building of garrison.buildings) { + out.push({ x: building.x, y: building.y, r: radiusFor(building.scale ?? 1) }); + } + } + + /* + * The castle, which is not in any garrison's building list because it is a + * landmark rather than one of the holding's structures. It is the biggest + * thing on the map and the one people would most obviously walk through. + */ + const keep = GARRISONS.find((holding) => holding.id === "keep"); + if (keep) out.push({ x: keep.x, y: keep.y - 1, r: 3.4 }); + + cache = out; + return out; +} + +/** + * The solids, bucketed by where they are. + * + * `pushOut` runs for every actor on every tick, and walking the whole list each + * time is fifty distance checks per figure per tick -- which on a busy map was + * enough to take the simulation from comfortably real-time to timing out a test + * that runs a few thousand ticks. Nothing here moves, so the buckets are built + * once and read forever. + * + * A building is filed under every cell its circle touches, so a lookup is one + * cell and never a neighbourhood search. + */ +const CELL = 8; + +let grid: Map | undefined; + +function key(cellX: number, cellY: number): string { + return `${cellX},${cellY}`; +} + +function buckets(): Map { + if (grid) return grid; + + grid = new Map(); + for (const solid of solids()) { + /* + * The reach in tile space. The comparison below squashes y by two, so a + * solid reaches half as far north as it does east and the cells it is filed + * under have to agree with that or a lookup misses it at the edge. + */ + const fromX = Math.floor((solid.x - solid.r) / CELL); + const toX = Math.floor((solid.x + solid.r) / CELL); + const fromY = Math.floor((solid.y - solid.r / 2) / CELL); + const toY = Math.floor((solid.y + solid.r / 2) / CELL); + for (let cellY = fromY; cellY <= toY; cellY += 1) { + for (let cellX = fromX; cellX <= toX; cellX += 1) { + const at = key(cellX, cellY); + const already = grid.get(at); + if (already) already.push(solid); + else grid.set(at, [solid]); + } + } + } + return grid; +} + +/** + * Pushes a point out of anything it has ended up inside. + * + * Returns the corrected point. Runs after a step rather than before it, and + * reads nothing but the point itself, so it cannot oscillate. + * + * The y axis is squashed to match the way the map is drawn: a figure walks a + * tile north in half the screen distance it walks a tile east, and a circular + * footprint in tile space is the ellipse the eye expects on the ground. + */ +export function pushOut(x: number, y: number): { x: number; y: number } { + const near = buckets().get(key(Math.floor(x / CELL), Math.floor(y / CELL))); + /* Open country, which is nearly all of it, costs one map lookup. */ + if (!near) return { x, y }; + + let px = x; + let py = y; + + for (const solid of near) { + const dx = px - solid.x; + /* Tile space is 2:1 on screen; compare in the shape the player sees. */ + const dy = (py - solid.y) * 2; + const distance = Math.hypot(dx, dy); + if (distance >= solid.r || distance === 0) continue; + + const push = solid.r / distance; + px = solid.x + dx * push; + py = solid.y + (dy * push) / 2; + } + + return { x: px, y: py }; +} diff --git a/app/src/game/world/work.ts b/app/src/game/world/work.ts new file mode 100644 index 0000000..84ce4ba --- /dev/null +++ b/app/src/game/world/work.ts @@ -0,0 +1,40 @@ +/** + * What a session looks like it is doing, read from what it is called. + * + * This is the one piece of the game the server also needs, which is why it + * lives on its own with no imports at all. The field draws a wright walking to + * the garrison its work belongs to, and the service counts the same sessions + * to work out what a level is worth; a second copy of these patterns would + * drift, and the map would start disagreeing with the ladder about what a + * session was. + * + * It costs the corporate view nothing: this module is reached only from the + * game chunk and from the service, and the bundle check would fail if that + * stopped being true. + */ + +export type Work = "bug" | "feature" | "idle"; + +/** + * Read from the name and the command, because that is all there is without + * asking the machine — and the conventions people already use are strong. A + * branch called `fix/...`, a session named `fix: audit seal`, a commit style + * of `feat:`; these are not guesses so much as an existing vocabulary. + * + * Anything unrecognised is neither, and shows as a wright going about the yard + * rather than being forced into a category it does not belong in. Pretending + * to know is worse than showing that you do not. + */ +const MENDING = /\b(fix|fixes|fixing|bug|bugs|hotfix|patch|repair|revert|debug|issue)\b/i; +const MAKING = /\b(feat|feature|add|adds|adding|build|implement|create|new|refactor|migrate)\b/i; + +export function workFor(text: string): Work { + /* + * Mending is checked first. "fix the new importer" is a fix; reading it as a + * feature because it contains "new" would be exactly backwards, and fixes + * are the more specific claim. + */ + if (MENDING.test(text)) return "bug"; + if (MAKING.test(text)) return "feature"; + return "idle"; +} diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 9637849..c53ff71 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -287,7 +287,13 @@ class ApiError extends Error {} export const NETWORK_FAILURE = "Could not reach shell.online. Check your connection and try again."; export const SERVER_FAILURE = "Something went wrong on our side. Try again."; -async function request(path: string, init: RequestInit = {}): Promise { +/* + * Exported so the game skin can call its own endpoints without a second copy + * of the token handling, the outage-page parsing, or the error sentences. See + * src/game/README.md, which lists this among the seams between the game and + * the rest of the application. + */ +export async function request(path: string, init: RequestInit = {}): Promise { const token = await currentIdToken(); if (!token) throw new ApiError("You are signed out. Sign in and try again."); let response: Response; diff --git a/app/src/styles/game.css b/app/src/styles/game.css new file mode 100644 index 0000000..c959cb8 --- /dev/null +++ b/app/src/styles/game.css @@ -0,0 +1,1892 @@ +@font-face { + /* + * The face the holdings' signs are lettered in. + * + * Declared here rather than in tokens.css so it travels in the lazy game + * chunk: it is worth eight kilobytes to the person who opened the game and + * nothing at all to the person who did not. + * + * It is used for the names of places and nowhere else. A whole interface set + * in blackletter is an interface nobody can read in a hurry, and the rules + * this file holds itself to are about reading in a hurry. Pirata One, SIL + * Open Font License; see public/fonts/OFL-Pirata-One.txt and the notices. + */ + font-family: "Pirata One"; + src: url("/fonts/pirata-one.woff2") format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +/* + * Shell Keep — the game skin. + * + * Imported by src/game/GameRoute.tsx and nowhere else, so it travels in the + * lazy game chunk and costs the corporate view nothing. + * + * The palette is not invented. It is the product's own tokens from tokens.css + * read as materials: --terminal is stone, --paper is parchment, --blue is + * arcane light and --acid is elixir. That is what stops this looking like a + * generic pixel game wearing our name — it is the same product in armour. + * + * The stones are the product's --terminal warmed, because the map they sit + * over is grass and fired clay and a cold green-black interface laid on top of + * it read as two pictures rather than one. Same colour, seen by firelight. + * + * Rules this file holds itself to, from the game-ui-design skill: + * · nothing readable outside the safe area, which the player can widen + * · 16px floor for body text, 24px for anything that matters in a hurry + * · 48px minimum for anything you can hit + * · every state carries an icon or a word, never colour alone + * · interface motion at or under 300ms, and none at all on request + * · z-index never above 400 + */ + +.keep { + /* ---- materials ---- */ + --keep-stone: #241d15; + --keep-stone-lit: #3e3324; + --keep-stone-dark: #120d08; + /* Brass: the frames, the rules, and anything the eye is meant to land on. */ + --keep-brass: #e8b44a; + --keep-brass-dim: #c9a06a; + --keep-parchment: #f3f1e9; + --keep-parchment-shade: #ded9c8; + --keep-ink: #191b18; + --keep-arcane: #4267f5; + --keep-elixir: #c8ff4d; + --keep-gold: #e8b44a; + --keep-blood: #c2412c; + --keep-heal: #4e9e74; + --keep-mist: #8d9382; + + /* ---- scale ---- + * Every size below is a multiple of these two, so the interface-size slider + * moves the whole thing together instead of leaving text large in boxes that + * stayed small. --keep-scale is set from options; see state/options.ts. + */ + --keep-scale: 1; + --keep-safe: 5%; + --keep-motion: 1; + + --keep-text: calc(16px * var(--keep-scale)); + --keep-text-sm: calc(16px * var(--keep-scale)); + --keep-text-lg: calc(20px * var(--keep-scale)); + --keep-text-xl: calc(26px * var(--keep-scale)); + --keep-text-huge: calc(34px * var(--keep-scale)); + + --keep-space: calc(8px * var(--keep-scale)); + --keep-space-2: calc(16px * var(--keep-scale)); + --keep-space-3: calc(24px * var(--keep-scale)); + --keep-space-4: calc(32px * var(--keep-scale)); + + /* The smallest thing a thumb or a stick should ever have to land on. */ + --keep-hit: calc(48px * var(--keep-scale)); + + /* ---- layer scale ---- + * Named, bounded, and nowhere near four digits. A z-index war is a symptom + * of not having a scale, so here is the scale. + */ + --keep-z-field: 1; + --keep-z-hud: 100; + --keep-z-modal: 200; + --keep-z-tooltip: 300; + --keep-z-toast: 400; + + --keep-fast: calc(120ms * var(--keep-motion)); + --keep-normal: calc(220ms * var(--keep-motion)); + + /* + * The game's own font stack, rather than tokens.css's --mono. + * + * This stylesheet travels in the lazy chunk and tokens.css does not, so + * borrowing a variable from it is a dependency on load order that happens to + * hold. It did not hold in the preview harness, where the whole interface + * fell back to the browser's default serif and looked nothing like a game. + * A stack of its own costs a line and cannot come apart. + * + * Monospace on purpose. A true pixel typeface at 16px is charming and hard + * to read across a room, and legibility is the rule this file is held to. + * The uppercase and the letter-spacing do the period work instead. + */ + --keep-font: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + + position: fixed; + inset: 0; + z-index: var(--keep-z-field); + overflow: hidden; + background: + radial-gradient(circle at 50% 35%, #232719 0%, var(--keep-stone-dark) 70%); + color: var(--keep-parchment); + font-family: var(--keep-font); + font-size: var(--keep-text); + line-height: 1.45; + -webkit-font-smoothing: none; + font-smooth: never; +} + +/* + * Colourblind palettes. + * + * These widen the gaps between the hues the game leans on. They are a second + * line of defence, not the first: every state in the keep already carries an + * icon and a word, because a palette cannot help somebody reading a screenshot. + */ +.keep[data-colour="deuteranopia"], +.keep[data-colour="protanopia"] { + --keep-blood: #e07a3c; + --keep-heal: #4a7fd4; + --keep-elixir: #f2e94b; +} + +.keep[data-colour="tritanopia"] { + --keep-blood: #d94a6a; + --keep-heal: #2fa8a0; + --keep-elixir: #b6f24b; + --keep-arcane: #7a4ad9; +} + +/* ---- safe area -------------------------------------------------------- */ + +/* + * Televisions crop the edges. The field is allowed to bleed into that crop -- + * it is a view of a meadow and losing a strip of it costs nothing -- but + * anything a player has to read is not, so it lives in here. + * + * Laid over the field rather than beside it, and transparent to the pointer + * except where there is actually a control, so a click meant for the ground + * does not land on an invisible box. + */ +.keep-safe { + position: absolute; + inset: 0; + z-index: var(--keep-z-hud); + pointer-events: none; +} + +.keep-safe button, +.keep-safe a, +.keep-safe input { + pointer-events: auto; +} + +/* + * Headings are lettered like the signposts; everything else is not. + * + * The interface was monospace throughout, in capitals, widely letterspaced -- + * which is a terminal's voice, and this is supposed to be a keep. Putting the + * blackletter on the *titles* alone gives it somewhere to sit without costing + * anything in a hurry: a heading is read once, a number is read at a glance, + * and the two want different faces. + */ +.keep-pause-head h2, +.keep-shop-shelf-name, +.keep-gathering-notice h3, +.keep-gathering-heading, +.keep-standing-class, +.keep-opening h2 { + font-family: "Pirata One", Georgia, serif; + font-weight: 400; + /* Blackletter is wide enough already; the tracking was for a monospace. */ + letter-spacing: 0.02em; + text-transform: none; +} + +.keep-pause-head h2, +.keep-opening h2 { + font-size: var(--keep-text-huge); +} + +.keep-standing-class { + font-size: var(--keep-text-xl); + line-height: 1.1; +} + +/* ---- pixel panels ----------------------------------------------------- */ + +/* + * A brass-framed panel on warm stone. + * + * The frame is a 9-slice from Kenney's Fantasy UI Borders, recoloured to brass + * on the way into the repository by scripts/import-fantasy-ui.mjs. Two frames + * in the whole interface, and each one means something: this is "laid over the + * map", and .keep-panel-heavy is "the game has stopped to ask you something". + * An interface where nothing is framed the same way twice reads as a sampler + * rather than a place. + * + * `border-image` rather than a background image, so the frame stays at its + * drawn resolution while the panel takes whatever size its contents need -- + * which is the whole point of a 9-slice and the reason this is not four nested + * elements. No `fill` keyword, so the middle slice is left unpainted and the + * panel's own background shows through it. + * + * The border is 16px against 16px of source, which is to say the art is drawn + * at its own size. `image-rendering: pixelated` keeps it there: the source is + * 1-bit line work, and a browser smoothing it produces exactly the soft grey + * fringe that makes pixel art look like a mistake rather than a choice. + */ +.keep-panel { + border: 16px solid transparent; + border-image: url("/game/ui/frame-panel.png") 16 / 16px stretch; + /* + * `border-box`, not `padding-box`. + * + * The frame is a sixteen-pixel border, and a background clipped to the + * padding box stops where the border begins -- so every panel was a dark + * rectangle floating inside a brass rectangle with the map showing through + * the gap between them. Filling to the border box puts the stone behind the + * frame, which is what a panel with a frame around it looks like. + */ + background: linear-gradient(180deg, var(--keep-stone-lit) 0%, var(--keep-stone) 45%) border-box; + image-rendering: pixelated; + /* Lifts it off the field, which is busy and in places the same value. */ + box-shadow: 0 calc(4px * var(--keep-scale)) 0 0 rgb(0 0 0 / 45%); +} + +/* + * The heavy frame, for anything that has stopped the game. + * + * Drawn at 96px against the other's 48, so the rule is genuinely finer relief + * rather than the same art enlarged -- which is the difference between a modal + * that feels important and one that feels zoomed. + */ +.keep-panel-heavy { + border: 32px solid transparent; + border-image: url("/game/ui/frame-heavy.png") 32 / 32px stretch; + background: linear-gradient(180deg, var(--keep-stone-lit) 0%, var(--keep-stone) 30%) border-box; + image-rendering: pixelated; + box-shadow: 0 calc(6px * var(--keep-scale)) 0 0 rgb(0 0 0 / 50%); +} + +/* ---- buttons ---------------------------------------------------------- */ + +/* + * A brass-edged stone button. + * + * The bevel is inset shadows rather than a gradient so it stays hard-edged at + * any interface scale, and the brass hairline is what separates a button from + * the panel it sits in — on a warm stone panel, stone-on-stone disappears. + */ +.keep-button { + display: inline-flex; + min-height: var(--keep-hit); + padding: 0 var(--keep-space-3); + border: 2px solid var(--keep-brass-dim); + background: var(--keep-stone); + box-shadow: + inset 0 3px 0 0 var(--keep-stone-lit), + inset 0 -3px 0 0 var(--keep-stone-dark); + color: var(--keep-parchment); + font-family: inherit; + font-size: var(--keep-text); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + align-items: center; + justify-content: center; + gap: var(--keep-space); + cursor: pointer; + transition: background var(--keep-fast) linear, transform var(--keep-fast) linear; +} + +.keep-button:hover { + background: var(--keep-stone-lit); + border-color: var(--keep-brass); +} + +/* + * Pressed moves down by the height of its own bevel, so the button looks like + * it went in rather than like it changed colour. + */ +.keep-button:active { + transform: translateY(2px); + box-shadow: inset 0 3px 0 0 var(--keep-stone-dark); +} + +.keep-pause-button { + flex: none; +} + +/* ---- field ------------------------------------------------------------ */ + +.keep-field { + position: absolute; + inset: 0; + z-index: var(--keep-z-field); +} + +/* ---- button prompts --------------------------------------------------- */ + +.keep-prompt { + display: inline-flex; + align-items: center; + gap: var(--keep-space); + color: var(--keep-mist); + font-size: var(--keep-text); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.keep-key { + display: inline-flex; + min-width: calc(44px * var(--keep-scale)); + min-height: calc(30px * var(--keep-scale)); + padding: 0 var(--keep-space); + border: 3px solid var(--keep-parchment-shade); + background: var(--keep-parchment); + color: var(--keep-ink); + font-family: inherit; + font-size: var(--keep-text-sm); + font-weight: 700; + align-items: center; + justify-content: center; +} + +/* ---- the shop's two shelves -------------------------------------------- */ + +.keep-shop-shelf { + display: flex; + flex-direction: column; + gap: var(--keep-space); +} + +.keep-shop-shelf-name { + margin: var(--keep-space) 0 0; + color: var(--keep-gold); + font-size: var(--keep-text); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* ---- the Barrow -------------------------------------------------------- */ + +.keep-barrow { + display: flex; + min-height: 0; + flex-direction: column; + gap: var(--keep-space-2); +} + +.keep-barrow-note, +.keep-barrow-unknown { + margin: 0; + color: var(--keep-mist); + font-size: var(--keep-text); + line-height: 1.5; +} + +/* Unknown is a state, so it carries a rule as well as a colour. */ +.keep-barrow-unknown { + padding: var(--keep-space); + border-left: 4px solid var(--keep-gold); + background: rgb(0 0 0 / 25%); + color: var(--keep-parchment); +} + +.keep-barrow-rows { + display: flex; + margin: 0; + padding: 0; + flex-direction: column; + gap: var(--keep-space); + list-style: none; +} + +.keep-barrow-row { + display: flex; + padding: var(--keep-space-2); + border: 2px solid rgb(232 180 74 / 25%); + background: rgb(0 0 0 / 22%); + align-items: center; + gap: var(--keep-space-2); +} + +.keep-barrow-mark { + color: var(--keep-gold); + font-size: var(--keep-text-lg); + flex: none; +} + +.keep-barrow-text { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.keep-barrow-label { + color: var(--keep-parchment); + font-weight: 700; +} + +.keep-barrow-detail { + color: var(--keep-mist); + font-size: var(--keep-text); +} + +.keep-barrow-count { + display: flex; + color: var(--keep-parchment); + font-size: var(--keep-text-lg); + font-weight: 700; + flex-direction: column; + align-items: flex-end; + flex: none; +} + +/* What each is worth, so the sum can be checked by hand. */ +.keep-barrow-each { + color: var(--keep-mist); + font-size: var(--keep-text); + font-weight: 400; +} + +.keep-barrow-total { + display: flex; + margin: 0; + padding: var(--keep-space-2); + border: 2px solid var(--keep-gold); + background: rgb(0 0 0 / 30%); + color: var(--keep-parchment); + font-size: var(--keep-text-lg); + align-items: baseline; + justify-content: space-between; +} + +.keep-barrow-total strong { + color: var(--keep-elixir); + font-size: var(--keep-text-xl); +} + +/* ---- the road book ---------------------------------------------------- */ + +/* + * A list of holdings, each with the sentence from its own board and the line + * the board does not carry. Laid out like the shop, because it is the same + * shape of thing: a row of readable text with one thing you can do to it. + */ +.keep-marches { + display: flex; + min-height: 0; + flex-direction: column; + gap: var(--keep-space-2); +} + +.keep-marches-note { + margin: 0; + color: var(--keep-mist); + font-size: var(--keep-text); +} + +.keep-marches-list { + display: flex; + margin: 0; + padding: 0; + flex-direction: column; + gap: var(--keep-space); + list-style: none; +} + +.keep-marches-item { + display: flex; + padding: var(--keep-space-2); + border: 2px solid rgb(232 180 74 / 25%); + background: rgb(0 0 0 / 22%); + align-items: center; + gap: var(--keep-space-2); +} + +.keep-marches-text { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.keep-marches-name { + color: var(--keep-parchment); + font-size: var(--keep-text-lg); + font-weight: 700; + letter-spacing: 0.06em; +} + +.keep-marches-purpose { + color: var(--keep-parchment-shade); + font-size: var(--keep-text); +} + +/* The rename, set apart, because it is the part that is not in character. */ +.keep-marches-truth { + color: var(--keep-mist); + font-size: var(--keep-text); + font-style: italic; +} + +/* ---- the gathering ----------------------------------------------------- */ + +/* + * Written plainly, and looking it. Everywhere else the game is brass and stone; + * this screen is a notice, and a consent notice dressed as a fantasy scroll is + * a consent notice designed not to be read. + */ +.keep-gathering { + display: flex; + min-height: 0; + flex-direction: column; + gap: var(--keep-space-2); +} + +.keep-gathering-state { + margin: 0; + padding: var(--keep-space-2); + border-left: 4px solid var(--keep-elixir); + background: rgb(0 0 0 / 25%); + color: var(--keep-parchment); + font-size: var(--keep-text-lg); +} + +.keep-gathering-notice { + display: flex; + flex-direction: column; + gap: var(--keep-space); +} + +.keep-gathering-notice h3, +.keep-gathering-heading { + margin: var(--keep-space) 0 0; + color: var(--keep-gold); + font-size: var(--keep-text); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.keep-gathering-notice ul { + display: flex; + margin: 0; + padding-left: var(--keep-space-3); + flex-direction: column; + gap: 4px; +} + +.keep-gathering-notice li, +.keep-gathering-notice p { + margin: 0; + color: var(--keep-parchment-shade); + font-size: var(--keep-text); + line-height: 1.5; +} + +.keep-gathering-cost { + color: var(--keep-mist) !important; +} + +.keep-gathering-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--keep-space); +} + +.keep-gathering-said, +.keep-gathering-refusal, +.keep-gathering-empty, +.keep-gathering-foot { + margin: 0; + color: var(--keep-mist); + font-size: var(--keep-text); + line-height: 1.5; +} + +/* A refusal is a state, so it carries a word as well as a colour. */ +.keep-gathering-refusal { + padding: var(--keep-space); + border-left: 4px solid var(--keep-blood); + background: rgb(0 0 0 / 25%); + color: var(--keep-parchment); +} + +.keep-gathering-runs { + display: flex; + margin: 0; + padding: 0; + flex-direction: column; + gap: var(--keep-space); + list-style: none; +} + +.keep-gathering-run { + display: grid; + padding: var(--keep-space-2); + border: 2px solid rgb(232 180 74 / 25%); + background: rgb(0 0 0 / 22%); + gap: 2px; + grid-template-columns: 1fr auto; +} + +.keep-gathering-run-head { + display: flex; + align-items: baseline; + gap: var(--keep-space); +} + +.keep-gathering-run-where { + color: var(--keep-parchment); + font-weight: 700; +} + +.keep-gathering-run-when { + color: var(--keep-mist); + font-size: var(--keep-text); +} + +.keep-gathering-run-found, +.keep-gathering-run-error { + color: var(--keep-parchment-shade); + font-size: var(--keep-text); + grid-column: 1; +} + +.keep-gathering-run-error { + color: var(--keep-blood); +} + +/* The cost, set apart, because it is the figure the vial is showing. */ +.keep-gathering-run-cost { + align-self: center; + color: var(--keep-elixir); + font-weight: 700; + grid-column: 2; + grid-row: 1 / span 2; +} + +.keep-gathering code { + padding: 1px 5px; + background: rgb(0 0 0 / 40%); + color: var(--keep-elixir); + font-family: inherit; +} + +/* ---- pause ------------------------------------------------------------ */ + +.keep-pause { + display: flex; + position: absolute; + inset: 0; + z-index: var(--keep-z-modal); + padding: var(--keep-safe); + align-items: center; + justify-content: center; + /* + * Dimmed, not hidden. Seeing the field you paused stops the menu feeling + * like it left the game, and it is the difference between a pause screen and + * a loading screen. + */ + background: rgb(9 11 7 / 78%); + animation: keep-fade var(--keep-normal) ease-out; +} + +@keyframes keep-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +/* + * Header and footer pinned, middle scrolls. + * + * Scrolling the whole panel took the footer with it, so on a long panel -- the + * Chronicle, the shop -- the way out and the button prompt were below the + * fold. A dialog whose close is off screen is a dialog somebody is stuck in. + */ +.keep-pause-panel { + display: flex; + width: min(560px, 100%); + max-height: 100%; + padding: var(--keep-space-3); + flex-direction: column; + gap: var(--keep-space-2); +} + +/* + * The scrolling child is named rather than positioned. + * + * This was a three-row grid, which broke the moment a fourth child was added: + * the row that was meant to scroll became whichever one happened to be second. + * Saying which element scrolls cannot go wrong when the panel gains a line. + */ +.keep-pause-panel > .keep-menu, +.keep-pause-panel > .keep-options, +.keep-pause-panel > .keep-shop, +.keep-pause-panel > .keep-marches, +.keep-pause-panel > .keep-barrow, +.keep-pause-panel > .keep-gathering, +.keep-pause-panel > .keep-codex { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; +} + +.keep-pause-panel > .keep-pause-head, +.keep-pause-panel > .keep-pause-stats, +.keep-pause-panel > .keep-pause-foot { + flex: none; +} + +.keep-pause-head { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +.keep-pause-head h2 { + margin: 0; + font-size: var(--keep-text-huge); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.keep-pause-glyph { + color: var(--keep-elixir); + font-size: var(--keep-text-xl); +} + +.keep-pause-foot { + display: flex; + padding-top: var(--keep-space-2); + border-top: 4px solid var(--keep-stone-dark); + align-items: center; + justify-content: space-between; + gap: var(--keep-space-2); +} + +.keep-build { + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +/* ---- menus ------------------------------------------------------------ */ + +.keep-menu { + display: flex; + flex-direction: column; + gap: var(--keep-space); +} + +.keep-menu-item { + display: flex; + min-height: var(--keep-hit); + padding: var(--keep-space) var(--keep-space-2); + border: 4px solid transparent; + background: transparent; + color: var(--keep-parchment); + font-family: inherit; + font-size: var(--keep-text); + text-align: left; + align-items: center; + gap: var(--keep-space-2); + cursor: pointer; + transition: background var(--keep-fast) linear; +} + +.keep-menu-item:hover:not(:disabled) { + background: var(--keep-stone-lit); +} + +/* + * The focused item, marked by more than colour: it gains a border and a caret + * as well, so it is still obvious in a palette somebody cannot separate and in + * a screenshot with no cursor in it. + */ +.keep-menu-item:focus-visible, +.keep-menu-item.is-current:focus { + outline: none; + border-color: var(--keep-elixir); + background: var(--keep-stone-lit); +} + +.keep-menu-item:focus-visible::before, +.keep-menu-item.is-current:focus::before { + content: "▶"; + margin-right: calc(-1 * var(--keep-space)); + color: var(--keep-elixir); +} + +.keep-menu-item:disabled { + color: var(--keep-mist); + cursor: not-allowed; + opacity: 0.6; +} + +/* Marked by a word and a rule above it, not by being red on its own. */ +.keep-menu-item.is-danger { + margin-top: var(--keep-space); + border-top: 4px solid var(--keep-stone-dark); + color: var(--keep-blood); +} + +.keep-menu-item.is-danger:focus-visible { + border-color: var(--keep-blood); +} + +.keep-menu-text { + display: flex; + min-width: 0; + flex-direction: column; +} + +.keep-menu-label { + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.keep-menu-detail { + color: var(--keep-mist); + font-size: var(--keep-text-sm); + letter-spacing: 0.02em; +} + +/* ---- options ---------------------------------------------------------- */ + +.keep-options { + display: flex; + flex-direction: column; + gap: var(--keep-space-3); +} + +.keep-option-group { + margin: 0; + padding: var(--keep-space-2); + border: 4px solid var(--keep-stone-dark); + background: rgb(0 0 0 / 18%); +} + +.keep-option-group legend { + padding: 0 var(--keep-space); + color: var(--keep-elixir); + font-size: var(--keep-text); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.keep-option-why { + margin: 0 0 var(--keep-space-2); + color: var(--keep-mist); + font-size: var(--keep-text-sm); + line-height: 1.5; +} + +.keep-slider { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +.keep-slider input[type="range"] { + min-height: var(--keep-hit); + flex: 1; + accent-color: var(--keep-elixir); +} + +.keep-slider output { + min-width: calc(64px * var(--keep-scale)); + color: var(--keep-parchment); + font-size: var(--keep-text-lg); + font-weight: 700; + text-align: right; +} + +/* + * The safe-area calibration target. It is inset by exactly the amount the + * slider sets, so "can you see all four corners" is answerable by looking. + */ +.keep-safe-test { + position: relative; + height: calc(72px * var(--keep-scale)); + margin-top: var(--keep-space-2); + border: 3px dashed var(--keep-stone-lit); +} + +.keep-safe-corner { + position: absolute; + width: calc(20px * var(--keep-scale)); + height: calc(20px * var(--keep-scale)); + border: 4px solid var(--keep-elixir); +} + +.keep-safe-corner.is-tl { top: var(--keep-safe); left: var(--keep-safe); border-right: 0; border-bottom: 0; } +.keep-safe-corner.is-tr { top: var(--keep-safe); right: var(--keep-safe); border-bottom: 0; border-left: 0; } +.keep-safe-corner.is-bl { bottom: var(--keep-safe); left: var(--keep-safe); border-top: 0; border-right: 0; } +.keep-safe-corner.is-br { bottom: var(--keep-safe); right: var(--keep-safe); border-top: 0; border-left: 0; } + +.keep-choices { + display: flex; + flex-direction: column; + gap: var(--keep-space); +} + +.keep-choices.is-inline { + flex-flow: row wrap; +} + +.keep-choice { + display: flex; + min-height: var(--keep-hit); + padding: var(--keep-space); + border: 4px solid transparent; + align-items: center; + gap: var(--keep-space-2); + cursor: pointer; +} + +.keep-choice:hover { + background: var(--keep-stone-lit); +} + +.keep-choice:focus-within { + border-color: var(--keep-elixir); +} + +.keep-choice input { + width: calc(22px * var(--keep-scale)); + height: calc(22px * var(--keep-scale)); + accent-color: var(--keep-elixir); + flex: none; +} + +.keep-choice-text { + display: flex; + flex-direction: column; +} + +.keep-choice-label { + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.keep-choice-detail { + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +/* ---- focus, everywhere ------------------------------------------------ */ + +/* + * One visible focus ring for anything the menu system does not style itself. + * Without this a pad can move focus somewhere invisible, which is the same as + * losing it. + */ +.keep :focus-visible { + outline: 4px solid var(--keep-elixir); + outline-offset: 2px; +} + +/* ---- reduced motion --------------------------------------------------- */ + +/* + * Two ways in: the player's own choice, which sets --keep-motion to 0 and + * collapses every duration above, and the system setting, which is honoured + * even before the game has read its options. + */ +@media (prefers-reduced-motion: reduce) { + .keep *, + .keep *::before, + .keep *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + } +} + +.keep[data-motion="reduced"] *, +.keep[data-motion="reduced"] *::before, +.keep[data-motion="reduced"] *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; +} + +/* ---- small screens ---------------------------------------------------- */ + +/* + * A phone has no overscan, so the safe area is wasted margin there; it is + * clamped rather than removed, because the notch is a crop of its own. + */ +@media (width <= 640px) { + .keep { + --keep-safe: max(12px, env(safe-area-inset-left)); + } + + .keep-pause-panel { + width: 100%; + } +} + +/* ---- canvas stage ----------------------------------------------------- */ + +/* + * The two canvases sit exactly on top of one another and cover the host + * completely. The backing store is a whole number of design pixels; stretching + * the element over the last sliver of the window is at most one scale factor + * and is invisible, where letterboxing would have been a black band somebody + * paid for a screen not to have. + */ +.keep-stage { + display: grid; + position: absolute; + inset: 0; +} + +.keep-canvas { + grid-area: 1 / 1; + width: 100%; + height: 100%; + image-rendering: pixelated; +} + +.keep-canvas.is-front { + pointer-events: none; +} + +/* ---- the HUD, in the corners ------------------------------------------- */ + +/* + * Four corners rather than a bar across the top. + * + * A strip was one panel wide enough to reach edge to edge and tall enough for + * three rows, and what it mostly did was cover the map. Everything on it is + * glanced at rather than read, and things that are glanced at belong at the + * edges of the eye. + * + * Each corner is pinned inside the safe area, so a television's crop takes the + * same bite out of all four. They do not take pointer events; their contents + * do, so a click on the gap between two panels reaches the map underneath. + */ +.keep-corner { + display: flex; + position: absolute; + z-index: var(--keep-z-hud); + gap: var(--keep-space); + pointer-events: none; +} + +.keep-corner > * { + pointer-events: auto; +} + +.keep-corner.is-top-left { + top: var(--keep-safe); + left: var(--keep-safe); + flex-direction: column; + align-items: flex-start; +} + +.keep-corner.is-top-right { + top: var(--keep-safe); + right: var(--keep-safe); + flex-direction: column; + align-items: flex-end; +} + +.keep-corner.is-bottom-left { + bottom: var(--keep-safe); + left: var(--keep-safe); + align-items: flex-end; +} + +.keep-corner.is-bottom-right { + right: var(--keep-safe); + bottom: var(--keep-safe); + align-items: flex-end; +} + +.keep-corner.is-bottom-centre { + bottom: var(--keep-safe); + left: 50%; + transform: translateX(-50%); +} + +/* + * A crest rather than a square. + * + * The rest of the interface is brass on stone; this is the one piece that says + * whose interface it is, so it takes the shape a device takes -- a shield, + * pointed at the foot. A clip path rather than an image, because it is four + * straight lines and a point. + */ +.keep-crest { + display: inline-flex; + width: calc(38px * var(--keep-scale)); + height: calc(42px * var(--keep-scale)); + background: var(--keep-gold); + align-items: center; + justify-content: center; + flex: none; + clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%); +} + +.keep-crest-letter { + color: var(--keep-stone-dark); + font-size: var(--keep-text-lg); + font-weight: 700; + line-height: 1; + /* Off the geometric middle, because the point below drags the eye down. */ + transform: translateY(calc(-3px * var(--keep-scale))); +} + +.keep-standing { + display: flex; + min-width: calc(240px * var(--keep-scale)); + max-width: calc(320px * var(--keep-scale)); + flex-direction: column; + gap: var(--keep-space); +} + +.keep-standing-head { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +.keep-standing-text, +.keep-purse-text, +.keep-roster-text, +.keep-elixir-text { + display: flex; + min-width: 0; + flex-direction: column; +} + +.keep-standing-class { + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-purse, +.keep-elixir-panel, +.keep-roster-button { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +.keep-roster-button, +.keep-elixir-panel { + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--keep-fast) linear; +} + +.keep-roster-button:hover, +.keep-elixir-panel:hover { + background: var(--keep-stone-lit); +} + +.keep-standing-level, +.keep-purse-label, +.keep-roster-detail, +.keep-elixir-value { + color: var(--keep-mist); + font-size: var(--keep-text); +} + +/* ---- meters ----------------------------------------------------------- */ + +.keep-meter { + display: flex; + flex-direction: column; + gap: calc(var(--keep-space) / 2); +} + +.keep-meter-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--keep-space); +} + +.keep-meter-label { + color: var(--keep-mist); + font-size: var(--keep-text-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.keep-meter-value { + font-size: var(--keep-text-sm); + font-weight: 700; +} + +.keep-meter-of { + color: var(--keep-mist); + font-weight: 400; +} + +.keep-meter-track { + height: calc(14px * var(--keep-scale)); + border: 3px solid var(--keep-stone-dark); + background: rgb(0 0 0 / 35%); + overflow: hidden; +} + +.keep-meter-fill { + display: block; + height: 100%; + background: var(--keep-gold); + transition: width var(--keep-normal) linear; +} + +.keep-meter.is-elixir .keep-meter-fill { + background: var(--keep-elixir); +} + +.keep-meter-detail { + margin: 0; + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +/* ---- purse, elixir, roster -------------------------------------------- */ + +.keep-coin { + color: var(--keep-gold); + font-size: var(--keep-text-xl); +} + +.keep-purse-value, +.keep-roster-count, +.keep-elixir-label { + font-size: var(--keep-text-lg); + font-weight: 700; +} + +.keep-elixir { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +/* + * The vial. A pixel vessel rather than a bar, because this is the one gauge + * that is not a game resource: it is real money, and it should not look like + * the experience bar next to it. + */ +/* + * The vial: glass with something green in it. + * + * It used to be a black slot that filled with green, which at zero tokens -- + * where most accounts start -- was a black slot, and read as an empty box + * somebody had forgotten to style. Glass is glass whether or not there is + * anything in it, so the vessel now has a dark green cast of its own and the + * fill is the bright thing inside it. + */ +.keep-vial { + display: block; + position: relative; + width: calc(20px * var(--keep-scale)); + height: calc(36px * var(--keep-scale)); + border: 2px solid var(--keep-brass-dim); + border-radius: calc(3px * var(--keep-scale)) calc(3px * var(--keep-scale)) + calc(8px * var(--keep-scale)) calc(8px * var(--keep-scale)); + background: linear-gradient(180deg, rgb(17 28 12 / 90%), var(--keep-elixir-dim)); + box-shadow: inset 0 0 calc(6px * var(--keep-scale)) rgb(0 0 0 / 60%); + overflow: hidden; + flex: none; +} + +.keep-vial-fill { + display: block; + position: absolute; + right: 0; + bottom: 0; + left: 0; + background: linear-gradient(180deg, #a6f56b, var(--keep-elixir)); + box-shadow: 0 0 calc(8px * var(--keep-scale)) var(--keep-elixir); + transition: height var(--keep-normal) linear; +} + +/* A highlight down one side, which is what makes a rectangle read as glass. */ +.keep-vial::after { + position: absolute; + top: 12%; + left: 16%; + width: 18%; + height: 56%; + border-radius: 40%; + background: rgb(255 255 255 / 22%); + content: ""; +} + +.keep-elixir-label { + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-roster-count { + color: var(--keep-gold); +} + +.keep-roster-label { + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@media (width <= 900px) { + .keep-standing { + min-width: calc(200px * var(--keep-scale)); + } +} + +/* + * On a phone the HUD shows less rather than smaller. + * + * Four panels at full detail stacked into a column and covered most of the + * field, which is the one thing worth looking at. The floor this file holds + * itself to is a floor -- text does not shrink to fit -- so what gives is the + * content: the bars keep their numbers, the explanations go, and the detail + * they carried is a tap away in the pause menu. + */ +@media (width <= 640px) { + /* + * The corners still do not fit 390 pixels at a 16px floor, so three of them + * leave: the purse, the vial and the garrison count are all repeated in the + * pause menu, which is one tap away and is the right home for a number you + * look up rather than glance at. + */ + .keep-purse, + .keep-elixir-panel, + .keep-roster-button { + display: none; + } + + .keep-standing { + min-width: 0; + max-width: calc(100vw - var(--keep-space-4)); + } + + /* The explanations, which are the first thing that can go. */ + .keep-standing-level, + .keep-meter-label, + .keep-meter-detail, + .keep-elixir-label, + .keep-roster-detail, + .keep-purse-label { + display: none; + } + + /* Names shorten to their first word rather than wrapping to three lines. */ + .keep-standing-class, + .keep-roster-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .keep-crest { + width: calc(28px * var(--keep-scale)); + height: calc(32px * var(--keep-scale)); + } + + .keep-vial { + width: calc(14px * var(--keep-scale)); + height: calc(26px * var(--keep-scale)); + } + + .keep-elixir-value { + display: block; + font-size: var(--keep-text-sm); + } + + /* The pause control keeps its full target; it is the way out. */ + .keep-pause-button { + padding: 0 var(--keep-space); + } + + /* The class is already named on the HUD behind this; here it only clips. */ + .keep-pause-who { + display: none; + } +} + +/* ---- the opening, the shop and the Chronicle -------------------------- */ + +/* + * A full-screen curtain for the one question the game asks. Darker than the + * pause overlay: this is not a pause over a running game, it is the game + * waiting for an answer. + */ +.keep-curtain { + display: flex; + position: absolute; + inset: 0; + z-index: var(--keep-z-modal); + padding: var(--keep-safe); + align-items: center; + justify-content: center; + background: rgb(9 7 5 / 92%); + animation: keep-fade var(--keep-normal) ease-out; +} + +/* Header and footer pinned, choices scroll. Same reason as the pause panel. */ +.keep-opening { + display: flex; + width: min(760px, 100%); + max-height: 100%; + padding: var(--keep-space-3); + flex-direction: column; + gap: var(--keep-space-2); +} + +.keep-opening > .keep-opening-head, +.keep-opening > .keep-pause-foot { + flex: none; +} + +.keep-opening > .keep-opening-choose { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; +} + +.keep-opening-head h2 { + margin: 0 0 var(--keep-space); + color: var(--keep-gold); + font-size: var(--keep-text-xl); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.keep-opening-head p { + margin: 0 0 var(--keep-space); + color: var(--keep-pale, var(--keep-mist)); + line-height: 1.6; +} + +.keep-opening-choose h3 { + margin: 0 0 var(--keep-space); + font-size: var(--keep-text-lg); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* + * The list and the description side by side, so moving through five classes + * does not reflow the panel under the player's cursor. + */ +.keep-opening-choose { + display: grid; + gap: var(--keep-space-2); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: "title title" "list detail"; +} + +.keep-opening-choose h3 { grid-area: title; } +.keep-opening-choose .keep-menu { grid-area: list; } +.keep-opening-detail { grid-area: detail; } + +.keep-opening-detail { + padding: var(--keep-space-2); + border: 4px solid var(--keep-stone-dark); + background: rgb(0 0 0 / 22%); +} + +.keep-opening-title { + margin: 0 0 var(--keep-space); + color: var(--keep-gold); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-opening-note { + margin: 0; + color: var(--keep-mist); + line-height: 1.6; +} + +.keep-pause-panel.is-wide { + width: min(760px, 100%); +} + +.keep-pause-who { + margin-left: auto; + color: var(--keep-gold); + font-size: var(--keep-text-sm); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* ---- shop ------------------------------------------------------------- */ + +.keep-shop, +.keep-codex { + display: flex; + flex-direction: column; + gap: var(--keep-space-2); +} + +.keep-shop-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--keep-space-2); + flex-flow: row wrap; +} + +.keep-shop-purse { + display: flex; + margin: 0; + align-items: center; + gap: var(--keep-space); + font-size: var(--keep-text-lg); + font-weight: 700; +} + +.keep-shop-note, +.keep-codex-intro { + margin: 0; + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +.keep-shop-refusal { + margin: 0; + padding: var(--keep-space); + border: 3px solid var(--keep-blood); + color: var(--keep-blood); + font-size: var(--keep-text-sm); +} + +.keep-shop-list { + display: flex; + margin: 0; + padding: 0; + flex-direction: column; + gap: var(--keep-space); + list-style: none; +} + +.keep-shop-item { + display: flex; + padding: var(--keep-space); + border: 4px solid var(--keep-stone-dark); + align-items: center; + gap: var(--keep-space-2); +} + +/* + * The goods: the figure the skin dresses, with its colour as a strip under it. + * + * The portrait is what is being bought and the swatch is what is legible + * running an eye down the list, so both are here and the portrait is the one + * that gets the room. + */ +.keep-shop-goods { + display: flex; + flex: none; + flex-direction: column; + align-items: center; + gap: calc(6px * var(--keep-scale)); +} + +.keep-shop-portrait { + display: block; + width: calc(56px * var(--keep-scale)); + height: auto; + /* + * Kenney's units are 33 pixels tall and this shows them at four times that, + * so the browser must not smooth them on the way up either -- the canvas is + * drawn nearest-neighbour and then scaled again by this rule. + */ + image-rendering: pixelated; +} + +.keep-shop-goods .keep-swatch { + width: calc(56px * var(--keep-scale)); + height: calc(10px * var(--keep-scale)); +} + +/* The four colours the skin changes, which are the entire product. */ +.keep-swatch { + display: grid; + width: calc(44px * var(--keep-scale)); + height: calc(44px * var(--keep-scale)); + border: 3px solid var(--keep-stone-dark); + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + flex: none; +} + +.keep-swatch span { + display: block; +} + +.keep-shop-text { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.keep-shop-name { + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.keep-shop-blurb, +.keep-shop-fits { + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +.keep-shop-fits { + color: var(--keep-gold); +} + +/* ---- the Chronicle ---------------------------------------------------- */ + +.keep-codex-section h3 { + margin: 0 0 var(--keep-space); + color: var(--keep-gold); + font-size: var(--keep-text-lg); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-codex-list { + display: flex; + margin: 0; + flex-direction: column; + gap: var(--keep-space); +} + +.keep-codex-list dt { + font-weight: 700; + letter-spacing: 0.06em; +} + +.keep-codex-list dd { + margin: 0; + color: var(--keep-mist); + font-size: var(--keep-text-sm); + line-height: 1.6; +} + +.keep-codex-list code { + color: var(--keep-elixir); + font-family: inherit; +} + +@media (width <= 760px) { + .keep-opening-choose { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "title" "list" "detail"; + } +} + +/* The figures the HUD drops on a narrow screen, repeated in the pause menu. */ +.keep-pause-stats { + display: flex; + margin: 0; + flex-flow: row wrap; + gap: var(--keep-space) var(--keep-space-3); + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +/* The Pixi canvas fills its host; the renderer sizes itself to this element. */ +.keep-stage canvas { + display: block; + width: 100%; + height: 100%; +} + +.keep-stage-failed { + display: grid; + position: absolute; + inset: 0; + margin: 0; + padding: var(--keep-space-3); + color: var(--keep-mist); + font-size: var(--keep-text-lg); + text-align: center; + place-content: center; +} + +/* ---- the wright panel -------------------------------------------------- */ + +/* + * Clicking a figure on the map opens this. At the side rather than over the + * middle, because a modal in the middle covers the thing that was just + * clicked — the one part of the screen somebody was looking at. + */ +/* + * The inspect card stands beside the figure it is about, and walks with it. + * + * It used to be pinned to the right edge, which meant reading about somebody + * while looking at a box a screen's width away from them. Positioned from the + * top-left corner and moved with a transform, because a transform is the one + * property a browser will move without laying anything out again -- and this + * moves every frame. + */ +.keep-wright { + display: flex; + position: fixed; + top: 0; + left: 0; + z-index: var(--keep-z-modal); + width: min(320px, calc(100vw - var(--keep-space-4))); + max-height: calc(100vh - var(--keep-space-4)); + padding: var(--keep-space-2); + flex-direction: column; + gap: var(--keep-space); + overflow-y: auto; + pointer-events: auto; + will-change: transform; +} + +/* The harness's own mark, at the size of the crest it replaces. */ +.keep-wright-sigil { + display: inline-flex; + width: calc(34px * var(--keep-scale)); + height: calc(34px * var(--keep-scale)); + border: 2px solid var(--keep-gold); + border-radius: 50%; + background: var(--keep-stone-dark); + color: var(--keep-gold); + font-size: var(--keep-text-lg); + font-weight: 700; + align-items: center; + justify-content: center; + flex: none; + overflow: hidden; +} + +.keep-wright-sigil img { + width: 70%; + height: 70%; + object-fit: contain; +} + +/* + * The mark beside a label. + * + * Sized in ems so it grows with the interface-size slider, and nudged down a + * little because an icon's optical centre sits above a line of text's. + */ +.keep-mark { + display: inline-block; + width: 1.7em; + height: 1.7em; + margin-right: 0.4em; + object-fit: contain; + vertical-align: -0.45em; +} + +.keep-barrow-mark .keep-mark { + width: calc(28px * var(--keep-scale)); + height: calc(28px * var(--keep-scale)); + margin-right: 0; + vertical-align: middle; +} + +@keyframes keep-slide { + from { opacity: 0; transform: translateX(12px); } + to { opacity: 1; transform: none; } +} + +.keep-wright-head { + display: flex; + align-items: center; + gap: var(--keep-space); +} + +.keep-wright-title { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.keep-wright-name { + overflow: hidden; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.keep-wright-class { + color: var(--keep-gold); + font-size: var(--keep-text-sm); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-close { + min-width: var(--keep-hit); + min-height: var(--keep-hit); + border: 4px solid var(--keep-stone-dark); + background: var(--keep-stone); + color: var(--keep-parchment); + font: inherit; + cursor: pointer; + flex: none; +} + +.keep-close:hover { + background: var(--keep-stone-lit); +} + +.keep-wright-motto { + margin: 0; + padding: var(--keep-space); + border-left: 4px solid var(--keep-gold); + color: var(--keep-pale, var(--keep-mist)); + font-style: italic; +} + +.keep-wright-facts { + display: flex; + margin: 0; + flex-direction: column; + gap: var(--keep-space-2); +} + +.keep-wright-facts dt { + color: var(--keep-mist); + font-size: var(--keep-text-sm); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-wright-facts dd { + display: flex; + margin: 0; + flex-direction: column; +} + +.keep-wright-note { + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +.keep-wright-command code { + display: block; + padding: var(--keep-space); + border: 3px solid var(--keep-stone-dark); + background: rgb(0 0 0 / 30%); + color: var(--keep-elixir); + font-family: inherit; + font-size: var(--keep-text-sm); + overflow-wrap: anywhere; +} diff --git a/app/src/styles/shell.css b/app/src/styles/shell.css index 59200ea..3a9906f 100644 --- a/app/src/styles/shell.css +++ b/app/src/styles/shell.css @@ -206,6 +206,55 @@ display: none; } +/* + * The way into the game skin, last in the bar. + * + * The button is the brand's terminal green rather than the page's blue: it is + * the one control here that leaves the console for somewhere else, and it + * should read as a door rather than as another action on this page. + */ +.launch-game { + display: flex; + position: relative; + align-items: center; +} + +.launch-game-button { + display: inline-flex; + width: 40px; + height: 40px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--terminal); + color: var(--acid); + align-items: center; + justify-content: center; + transition: transform 160ms var(--ease), border-color 160ms var(--ease); +} + +.launch-game-button:hover { + border-color: var(--line-strong); + transform: translateY(-1px); +} + +/* + * Decoration, and marked as such: the control already carries the same words + * as its accessible name, so announcing this as well would say it twice. + */ +.launch-game-hint { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 30; + padding: 6px 10px; + border-radius: var(--radius-chip); + background: var(--terminal); + color: var(--terminal-ink); + font-size: 13px; + white-space: nowrap; + pointer-events: none; +} + /* * A page action in the bar keeps its label on one line. `.btn` is built for a * form, where it is full width and wrapping is not a question; up here the row diff --git a/app/vite.config.ts b/app/vite.config.ts index fd7c6f4..1a49206 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -57,11 +57,48 @@ export default defineConfig(({ mode }) => { */ const authHeaders = browserSecurityHeaders(env.VITE_OIDC_ISSUER?.trim()); + /* + * The dev server needs one relaxation the others must not have. + * + * @vitejs/plugin-react injects its Fast Refresh preamble as an inline + *