@homie-rocks/studio 0.30.2 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.30.2",
3
+ "version": "0.31.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -50,6 +50,14 @@
50
50
  "types": "./assets/animate.ts",
51
51
  "default": "./assets/animate.ts"
52
52
  },
53
+ "./personas": {
54
+ "types": "./port/personas.ts",
55
+ "default": "./port/personas.ts"
56
+ },
57
+ "./powerups": {
58
+ "types": "./port/powerups.ts",
59
+ "default": "./port/powerups.ts"
60
+ },
53
61
  "./worker": "./worker/index.mjs",
54
62
  "./package.json": "./package.json"
55
63
  },
@@ -69,6 +77,7 @@
69
77
  "media/",
70
78
  "site/",
71
79
  "chat/",
80
+ "parts/",
72
81
  "README.md",
73
82
  "CHANGELOG.md",
74
83
  "LICENSE",
package/parts/PARTS.md ADDED
@@ -0,0 +1,169 @@
1
+ # Game parts
2
+
3
+ Games build on each other by sharing **parts**: a piece of a game that its studio chooses to share, so somebody
4
+ making another game can use it. A creature from one game, a level from another, a bot brain from a third. That is
5
+ the whole idea. Without parts, a creator's AI has to come up with everything on its own.
6
+
7
+ Mashing up is not a command: it is making a game with parts from several games.
8
+
9
+ ## The rules
10
+
11
+ 1. **A part is a piece of a game, never the whole game.** Remix, which handed over an entire game, is retired;
12
+ parts are what replaces it.
13
+ 2. **A part can take any form** that suits the piece: code, assets, data, a JSON description, a tuned config, or a
14
+ mix. Form is not restricted.
15
+ 3. **Private until shared.** A studio shares a part on purpose, with a licence (an SPDX identifier) and an
16
+ attribution. A part records the game and the studio it came from.
17
+ 4. **Homie packages are not parts.** The `@homie-rocks/*` packages are general mechanisms (camera, input, audio,
18
+ effects, netplay), installed from npm. They are never in the catalogue. A part may say which packages it builds
19
+ on; npm installs and resolves them. There is no resolver, no version ranges, no lockfile and no registry of
20
+ Homie's: nothing here rebuilds what already works.
21
+ 5. **Creators work in chat.** A person never types a command. The tools are few and named like the asset tools:
22
+ `parts_find`, `part_add`, `part_new` and `part_share` (sharing and un-sharing). Anything else is plumbing behind
23
+ those.
24
+ 6. **Agents look for parts first.** When planning or making a game, the agent checks the packages for general
25
+ mechanisms and `parts_find` for pieces other studios shared, before writing from scratch; it records in the
26
+ game's CODEX and its credits what came from where.
27
+
28
+ ## In chat
29
+
30
+ - "What parts are there for a chase camera?" (`parts_find`)
31
+ - "Add the Pickup field part from owls.example/pickup-field to my game." (`part_add`)
32
+ - "Make the creature in my game a part." (`part_new`)
33
+ - "Share the creature part under CC BY." / "Stop sharing it." (`part_share`)
34
+
35
+ ## How a part moves
36
+
37
+ **Share.** The creator asks. `part_new` lifts a piece out of one of their games into `parts/<id>/`: the files
38
+ move, each module's old path keeps a line that re-exports the part's, and the game still builds and plays the same.
39
+ A file that reaches into the rest of the game is refused by name (pass what it needs in as an argument first), and
40
+ so is the game's own entry, page or `game.json`: a part is never the whole game. `part_share` marks it shared with
41
+ its licence. It is **live after the studio's next deploy**, not before. The studio's site lists its shared parts at
42
+ `/.well-known/homie-parts.json` and serves their content; **a private part is never reachable**.
43
+
44
+ **Catalogue.** `https://homie.rocks/parts/` indexes the shared parts of studios that are already listed (it never
45
+ lists a studio), searchable by kind, tag, licence and what a part builds on; each part shows its game, studio,
46
+ licence, attribution and a preview. `/parts/index.json` there is what `parts_find` reads. When it cannot be
47
+ reached, `parts_find` says so plainly and still answers with the studio's own parts: an empty list never stands in
48
+ for "could not look".
49
+
50
+ **Use.** The creator asks for a piece. `part_add` fetches **one exact version** (the latest shared unless one is
51
+ named), checks its integrity (every file's SHA-256 and size, before a byte is written), copies it into the studio
52
+ at `parts/_vendor/<host>/<id>/` (theirs to tune from then on), records where it came from in `parts/origins.json`,
53
+ adds the credit to the game's `credits.json`, and lets npm install any packages it builds on. Adding it again
54
+ later brings the newer version: what changed is shown, local tuning (`tuning.json`) is kept, and a file the studio
55
+ edited is never replaced without saying so.
56
+
57
+ **Publishing a game that uses parts** shows their licences and attributions, and says plainly when licences cannot
58
+ be combined.
59
+
60
+ ## A part on disk
61
+
62
+ `parts/<id>/part.json`, with the part's files beside it (`id`: lowercase letters, digits and hyphens; the folder is
63
+ named after it):
64
+
65
+ ```json
66
+ {
67
+ "id": "pickup-field",
68
+ "name": "Pickup field",
69
+ "kind": "mechanic",
70
+ "version": "1.0.0",
71
+ "summary": "Host-authoritative pickups on spawn spots.",
72
+ "license": "CC-BY-4.0",
73
+ "attribution": "Night Owls",
74
+ "share": false,
75
+ "tags": ["pickups", "netplay"],
76
+ "from": { "game": "gem-cave", "studio": "Night Owls" },
77
+ "entry": "src/index.ts",
78
+ "files": [{ "path": "src/index.ts", "sha256": "…", "bytes": 1234 }],
79
+ "preview": { "page": "preview/index.html", "image": "preview/cover.jpg", "aspect": "4:3", "phoneAspect": "3:4" },
80
+ "requires": { "packages": { "@homie-rocks/camera": "^0.2.0" }, "parts": ["other-studio.example/some-part"] },
81
+ "physical": { "units": "metres", "scale": 1, "pivot": "feet", "collision": "collision.json" },
82
+ "skeleton": { "rig": "humanoid-v1", "clips": ["idle", "run"] },
83
+ "contract": { "inputs": {}, "state": {}, "netplay": "host-authoritative" },
84
+ "cost": { "triangles": 0, "drawCalls": 0, "textureMB": 0, "bytes": 0, "measuredOn": "…" },
85
+ "provenance": { "source": "original", "notes": "" }
86
+ }
87
+ ```
88
+
89
+ | Field | What it is |
90
+ | --- | --- |
91
+ | `id`, `name`, `kind`, `version`, `summary` | `version` is three numbers; a shared version never changes, so a change is a new version. `summary` is one sentence. |
92
+ | `kind` | `character`, `rig`, `clips`, `environment`, `effect`, `sound`, `ui`, `mechanic`, `shader`, `level-generator`, `bot-brain`, `audio-pack`, `set-piece`. There is no kind for a whole game. |
93
+ | `license`, `attribution` | Needed to share: an SPDX identifier, and who a game using the part credits. |
94
+ | `share` | **Default `false`.** The part's own switch, and the only thing that decides whether it is shared. |
95
+ | `from` | The game and studio the part came out of. Written when a part is lifted out of a game. |
96
+ | `entry` | For a part with code: the module a game imports. A part of assets or data has none. |
97
+ | `files` | Every file with its SHA-256 and size, **written by the tool, never by hand**. A file may carry `"rights"` (below). |
98
+ | `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. `aspect`: the frame's shape as `"W:H"` (whole numbers 1 to 32, no flatter than 3:1, no taller than 1:2; default `"16:9"`). `phoneAspect`: the shape when the frame is narrower than 600 px. |
99
+ | `requires.packages` | The npm packages it builds on, as `package.json` would name them. npm reads them; nothing else does. |
100
+ | `requires.parts` | Other parts it needs: a plain list of `"<host>/<id>"`. Named to the person, never solved. |
101
+ | `physical`, `skeleton`, `contract`, `cost` | Optional descriptions for whoever uses the piece: its scale, pivot and collision data; its rig and clips; its inputs, state and who decides in a room (`host-authoritative`, `replicated`, `local`); device costs that were measured (never guessed). |
102
+ | `provenance` | `source`: `original`, `generated` or `imported` (with `origin`). Needed to share. |
103
+
104
+ A field this file does not name is kept and ignored. A field whose name starts with `_` never leaves the studio.
105
+ `tuning.json` in a part holds its tunables: it ships as the author's defaults and is the one file a newer version
106
+ never overwrites (the new defaults arrive beside it as `tuning.upstream.json`).
107
+
108
+ **Sharing is refused**, in plain words, when: there is no SPDX licence; the licence asks for credit and nobody is
109
+ named; the part does not say where it came from; or the rights to a file are unknown. A file's rights are its own
110
+ `"rights"` (`{ "license": "<SPDX>" }` or `"unknown"`), else the record of a game asset with the same bytes
111
+ (`games/<id>/assets/manifest.json`: a store-licensed or bought file cannot be shared inside a part), else the
112
+ part's `provenance`.
113
+
114
+ **Licences that cannot be combined** (shown when a part is added and before a game goes online): two parts under
115
+ different share-alike licences in one game; a non-commercial part in a studio with a shop; a no-derivatives part
116
+ whose files were changed locally; a part with no licence; credit owed and missing from the game's credits. A
117
+ share-alike part is pointed out for the person's decision, and a licence the toolkit does not know is said to be
118
+ one to read.
119
+
120
+ ## What a studio's site serves
121
+
122
+ `sharedPartsOf(studio)` (`lib/parts.mjs`) is the single answer to what a studio shares: its parts with
123
+ `share: true` that have a packed version. The build writes exactly that into the site, and the site, the
124
+ well-known file and through them the hub read nothing else.
125
+
126
+ - `GET /.well-known/homie-parts.json` → `{ "v": 1, "studio": { "name", "url" }, "parts": [ … ] }`. Each entry is the
127
+ part's `part.json` (latest version), plus `"url": "/parts/<id>/<version>/"`, `"page": "/parts/<id>/"`,
128
+ `"versions": [ … ]`, `"add": "<host>/<id>"`, and for a part from one of the studio's public games
129
+ `"from": { "game", "name", "studio", "page", "play" }`, so a catalogue can show and group by game. CORS open for
130
+ GET. No shared part: the same shape with `"parts": []`.
131
+ - `GET /parts/<id>/<version>/part.json` and `/parts/<id>/<version>/<file path>`: the content exactly as hashed,
132
+ immutable (a year's cache), CORS open, GET and HEAD only. An HTML file among them (a preview) is served
133
+ sandboxed, so it runs without the site's cookies or storage.
134
+ - `GET /parts/` and `/parts/<id>/`: the studio's own pages in its own look (each part's game, licence,
135
+ attribution, preview, what it builds on and costs, and what to say in chat to add it), and a **Parts from this
136
+ game** band on a game's landing page.
137
+
138
+ **Declare the shape of an interactive preview.** The default frame is 16:9, and across a 390 px phone that is
139
+ about 197 px tall: a heading and two buttons fill it, and what the preview draws below them is cut off with no
140
+ error to notice. Say `"aspect"` and `"phoneAspect"` in `preview` for a preview somebody plays with. With neither, a
141
+ narrow frame is at least 320 px tall (70% of a short screen), and every preview has an **Open the preview** link
142
+ that shows it in a tab of its own.
143
+
144
+ Sharing packs the part: the exact bytes of that version are kept in `parts/_packed/<id>/<version>/`, and every
145
+ packed version of a shared part is served, so "one exact version" stays true after a newer one ships.
146
+
147
+ ## Using a part in a game
148
+
149
+ ```ts
150
+ import { createPickupField } from '@parts/owls.example/pickup-field'; // a part brought in
151
+ import { chasePose } from '@parts/chase-camera'; // one of the studio's own
152
+ import table from '@parts/owls.example/loot-table/table.json'; // any file of a part
153
+ ```
154
+
155
+ The build resolves `@parts/<host>/<id>` to the part's `entry` in `parts/_vendor/<host>/<id>/`, and `@parts/<id>`
156
+ to `parts/<id>/`, and bundles it into the game. A game that imports a brought-in part is credited for it by the
157
+ build, whether or not the add named the game.
158
+
159
+ ## For whoever works on the toolkit
160
+
161
+ - `lib/parts.mjs` the format, hashes, lifting, sharing checks, `sharedPartsOf`, licences; `lib/parts-store.mjs`
162
+ find, add, share (one injectable `fetch`, one injectable npm runner: tests touch neither the network nor the
163
+ registry); `lib/parts-build.mjs` the build's three call-outs; `lib/parts-tools.mjs` the four chat tools;
164
+ `worker/parts.mjs` everything the site serves.
165
+ - Packages: `npm ls <name>@<spec>` says whether the studio has one; `npm install --save-exact <name>@<spec>`
166
+ installs what is missing, after one line saying what and why; npm's output and errors are relayed as they are.
167
+ A package the studio's own `package.json` already names at a version npm says does not fit is explained and left
168
+ alone.
169
+ - The plumbing behind the tools is `homie-studio parts find | new | add | share | unshare | check`.
package/port/index.ts CHANGED
@@ -15,6 +15,10 @@
15
15
  * createHud a minimal clock / scores / results overlay
16
16
  * BotBrain, seek, nearest, rubberBand
17
17
  * bot scaffolds; `skill` makes a bot play at the room's dial (NETPLAY.md section 17)
18
+ * createPersonaBrain, PERSONAS, botStats
19
+ * bots that want different things (port/personas.ts; also @homie-rocks/studio/personas, with no port shims)
20
+ * createPowerups, stepPowerups, collectPowerups, resolveHit, packPowerups
21
+ * host-owned pickups whose timers survive a host change (port/powerups.ts; also @homie-rocks/studio/powerups)
18
22
  * jitter, engages, standoff
19
23
  * the dial for the rest of a bot's decisions (port/skill.ts)
20
24
  * createSaves player accounts and cloud saves (a character that lasts days, on every device; saves/SAVES.md)
@@ -32,10 +36,12 @@ export { createControls, type Controls, type ControlsOptions } from './controls'
32
36
  export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLike } from './camera';
33
37
  export { fitView, easeView, toScreen, createLabels, type Fit, type FitOptions, type LabelIn, type LabelOut, type LabelOptions, type LabelBox } from './view';
34
38
  export { createBubbles, paintBubbles, wrapText, BUBBLE_FONT, type Bubbles, type BubbleOptions, type BubbleIn, type BubbleOut, type BubbleStyle } from './view';
35
- export { exposePort, type PortProbeOptions, type View } from './probe';
36
- export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
39
+ export { exposePort, PORT_EXTRA_NAMES, type PortExtra, type PortProbeOptions, type View } from './probe';
40
+ export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt, type TakeoverInfo, type Fallback, type Standing } from './room';
37
41
  export { createHud, type HudOptions } from './hud';
38
42
  export { BotBrain, brainOf, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
39
43
  export { jitter, engages, standoff } from './skill';
44
+ export * from './personas';
45
+ export * from './powerups';
40
46
  export { lab, type TunableSpec, type TunableFile, type Tuned } from '../lab/lab';
41
47
  export { createSaves, SAVES_VERSION, type Saves, type SavesOptions, type Player, type SaveEntry, type SetResult, type Memorial, type SavesStatus, type Conflict } from '../saves/saves';
@@ -0,0 +1,275 @@
1
+ /*
2
+ * @homie-rocks/studio/personas: bots that want different things.
3
+ * =============================================================================
4
+ *
5
+ * A room of bots that all run the same "go to the nearest thing" code reads as one bot drawn several times. What
6
+ * makes them read as characters is not smarter code, it is DIFFERENT WANTS: one crosses the map for the biggest
7
+ * prize, one hunts other players, one keeps its speed up, one tidies up scraps, one stays away from everybody.
8
+ *
9
+ * So a persona here is a row of weights and nothing else, over one scorer:
10
+ *
11
+ * score = kind weight x value^valuePow / (distance + near)^distPow x facing x sight x company x size
12
+ *
13
+ * facing a target behind costs up to `heading` of its score (a racer will not turn round; a glutton will)
14
+ * sight a target with a wall in the way costs up to `blind` of it (line of sight over the game's own
15
+ * `solid(x, y)`: the same function its walls already answer)
16
+ * company every rival near the target raises (a bully) or lowers (a sneak) it by `crowd`
17
+ * size a rival bigger than the bot costs up to `bigger` of it: nobody picks a fight they lose
18
+ *
19
+ * FIVE ARE BUILT IN (glutton, bully, racer, scavenger, sneak) and a game extends one with `persona('sneak', {...})`
20
+ * or writes its own row. The kinds they weigh ('food', 'scrap', 'rival', 'power', 'gate') are only names: a game
21
+ * tags its candidates with whichever fit, and any other kind scores at the persona's `other`.
22
+ *
23
+ * IT FITS THE PORT KIT'S BotBrain, it does not replace it. BotBrain is the bot's LIMITS (it reacts late, aims a
24
+ * little off, commits for a while: the room's skill dial); a persona is what it WANTS. `brain.chooser(...)` is the
25
+ * `choose` function BotBrain.think takes, so the dial keeps working:
26
+ *
27
+ * const who = createPersonaBrain(PERSONAS.bully, { rng, arena: { solid } });
28
+ * const stick = botBrain.think(now, pos, who.chooser(() => self, () => candidates, () => rivals));
29
+ * const push = who.steer(self, stick); // the same push, turned off any wall ahead
30
+ *
31
+ * TELEMETRY IS PART OF IT, because "the personas differ" is a claim, and a weight table that produces five bots
32
+ * who all end up eating the nearest pellet is the usual result of tuning by eye. Every brain counts what it picked
33
+ * (`stats()`), `botStats` puts brains side by side, and `personaSpread` is one number for how unlike each other
34
+ * they were. A game's own test runs its arena and asserts on those.
35
+ *
36
+ * DETERMINISTIC: the only randomness is the `rng` you pass (`seededRng(seed)` is one), drawn once a pick and only
37
+ * when the persona is `fickle`. The host owns the bots, so the same seed and the same world give the same game.
38
+ *
39
+ * Positions are the game's own flat units: `x, y` on the ground plane (a 3D game passes x and z).
40
+ * =============================================================================
41
+ */
42
+ import type { V2 } from './bots';
43
+
44
+ /** Something a bot could go for. `value` is the game's own measure (points, mass, seconds saved): more is better. */
45
+ export interface Candidate extends V2 {
46
+ kind: string;
47
+ value: number;
48
+ id?: string | number;
49
+ /** For a `rival`: how big it is, in the same measure as the bot's own `size`. */
50
+ size?: number;
51
+ }
52
+ /** Another body in the arena, for `crowd`: a player or a bot, not the bot itself. */
53
+ export interface Rival extends V2 { size?: number }
54
+ /** The bot: where it is, which way it faces (radians, `atan2(y, x)`), and how big it is. */
55
+ export interface BotSelf extends V2 { heading: number; size?: number }
56
+
57
+ /** A row of weights. Every field is a number a game may change. */
58
+ export interface Persona {
59
+ name: string;
60
+ /** How much each kind of candidate is wanted. A kind not listed scores at `other`. */
61
+ kinds: Record<string, number>;
62
+ other: number;
63
+ /** How much a bigger prize matters: 0 ignores value, 1 is proportional, 2 chases the jackpot. */
64
+ valuePow: number;
65
+ /** How much distance matters: 0 ignores it, 1 is value per metre, 2 hardly leaves the spot. */
66
+ distPow: number;
67
+ /** 0 to 1: the share of a target's score lost when it is directly behind. */
68
+ heading: number;
69
+ /** 0 to 1: the share lost when a wall is between the bot and the target. */
70
+ blind: number;
71
+ /** For each rival near the target: above 0 raises its score by this share, below 0 lowers it. */
72
+ crowd: number;
73
+ /** 0 to 1: the share of a rival's score lost when the rival is bigger than the bot. */
74
+ bigger: number;
75
+ /** How hard it turns away from a wall ahead (0 not at all, 1 fully). */
76
+ wall: number;
77
+ /** 0 to 1: random spread on every score, from the injected rng. 0 draws nothing. */
78
+ fickle: number;
79
+ }
80
+
81
+ export type PersonaName = 'glutton' | 'bully' | 'racer' | 'scavenger' | 'sneak';
82
+
83
+ /** The five. Frozen: extend one with `persona()`. */
84
+ export const PERSONAS: Readonly<Record<PersonaName, Persona>> = Object.freeze({
85
+ /** Crosses the arena for the biggest prize and barely notices anybody. */
86
+ glutton: Object.freeze({ name: 'glutton', kinds: { food: 1, scrap: 0.5, power: 0.6, rival: 0.05, gate: 0.2 }, other: 0.3, valuePow: 2, distPow: 0.5, heading: 0.1, blind: 0.2, crowd: 0, bigger: 0.9, wall: 0.7, fickle: 0 }),
87
+ /** Goes for other bodies, smaller ones first, and for whatever they are near. */
88
+ bully: Object.freeze({ name: 'bully', kinds: { rival: 4, power: 1, food: 0.3, scrap: 0.4, gate: 0.1 }, other: 0.3, valuePow: 1, distPow: 1, heading: 0.3, blind: 0.5, crowd: 0.4, bigger: 0.9, wall: 0.7, fickle: 0 }),
89
+ /** Keeps its line: what is ahead beats what is better, and gates beat everything. */
90
+ racer: Object.freeze({ name: 'racer', kinds: { gate: 4, power: 1.2, food: 0.5, scrap: 0.2, rival: 0.05 }, other: 0.3, valuePow: 0.5, distPow: 1, heading: 0.95, blind: 0.6, crowd: 0, bigger: 0.9, wall: 1, fickle: 0 }),
91
+ /** Takes the nearest small thing, and scraps before anything fresh. */
92
+ scavenger: Object.freeze({ name: 'scavenger', kinds: { scrap: 4, food: 1, power: 0.5, rival: 0.02, gate: 0.1 }, other: 0.5, valuePow: 0.25, distPow: 2, heading: 0.2, blind: 0.7, crowd: -0.15, bigger: 1, wall: 0.7, fickle: 0 }),
93
+ /** Wants whatever nobody is near, and power-ups most of all. */
94
+ sneak: Object.freeze({ name: 'sneak', kinds: { power: 2.5, food: 1, scrap: 1.5, rival: 0.3, gate: 0.2 }, other: 0.5, valuePow: 0.6, distPow: 1, heading: 0.2, blind: 0.1, crowd: -0.9, bigger: 1, wall: 0.8, fickle: 0 }),
95
+ });
96
+
97
+ /** A built-in persona with some weights changed (its `kinds` are merged, not replaced). */
98
+ export function persona(base: PersonaName | Persona, change: Partial<Persona> = {}): Persona {
99
+ const b = typeof base === 'string' ? PERSONAS[base] : base;
100
+ return { ...b, ...change, kinds: { ...b.kinds, ...(change.kinds ?? {}) } };
101
+ }
102
+
103
+ /** What a persona's choices are made in. */
104
+ export interface Arena {
105
+ /** The game's own collision: true where a body cannot be. Without it nothing is ever blind or avoided. */
106
+ solid?: (x: number, y: number) => boolean;
107
+ /** Units between samples along a line of sight (default 0.5). Smaller than the thinnest wall. */
108
+ sightStep?: number;
109
+ /** Units added to every distance, so a thing underfoot does not score infinitely (default 1). */
110
+ near?: number;
111
+ /** How close to a target a rival counts as company (default 6). */
112
+ crowdRadius?: number;
113
+ /** How far ahead a bot feels for walls (default 3). */
114
+ look?: number;
115
+ }
116
+
117
+ /** Whether a straight line between two points is free of `solid`, sampled every `step`. The ends are not tested. */
118
+ export function lineOfSight(a: V2, b: V2, solid: (x: number, y: number) => boolean, step = 0.5): boolean {
119
+ const d = Math.hypot(b.x - a.x, b.y - a.y);
120
+ const n = Math.ceil(d / Math.max(1e-6, step));
121
+ for (let i = 1; i < n; i += 1) if (solid(a.x + (b.x - a.x) * (i / n), a.y + (b.y - a.y) * (i / n))) return false;
122
+ return true;
123
+ }
124
+
125
+ /** One candidate's score for one persona, with the parts it was made from (they are what the telemetry counts). */
126
+ export interface Scored { c: Candidate; score: number; dist: number; facing: number; seen: boolean; company: number }
127
+
128
+ export function scoreCandidate(self: BotSelf, c: Candidate, p: Persona, arena: Arena = {}, rivals: readonly Rival[] = []): Scored {
129
+ const dx = c.x - self.x; const dy = c.y - self.y; const dist = Math.hypot(dx, dy);
130
+ // 1 dead ahead, -1 directly behind; a thing underfoot is "ahead".
131
+ const facing = dist < 1e-9 ? 1 : (dx * Math.cos(self.heading) + dy * Math.sin(self.heading)) / dist;
132
+ const seen = arena.solid ? lineOfSight(self, c, arena.solid, arena.sightStep ?? 0.5) : true;
133
+ const r = arena.crowdRadius ?? 6;
134
+ let company = 0;
135
+ // A rival that IS the candidate (the same spot) is not its own company.
136
+ for (const o of rivals) if (!(o.x === c.x && o.y === c.y) && Math.hypot(o.x - c.x, o.y - c.y) <= r) company += 1;
137
+ let score = (p.kinds[c.kind] ?? p.other) * Math.pow(Math.max(0, c.value), p.valuePow) / Math.pow(dist + (arena.near ?? 1), p.distPow);
138
+ score *= 1 - p.heading * (1 - facing) / 2;
139
+ if (!seen) score *= 1 - p.blind;
140
+ score *= Math.max(0, 1 + p.crowd * company);
141
+ if (c.kind === 'rival' && c.size !== undefined && self.size !== undefined && c.size > self.size) score *= 1 - p.bigger;
142
+ return { c, score, dist, facing, seen, company };
143
+ }
144
+
145
+ /** The best candidate for a persona, or null when there is none worth anything. Ties go to the one listed first. */
146
+ export function pickCandidate(self: BotSelf, candidates: readonly Candidate[], p: Persona, arena: Arena = {}, rivals: readonly Rival[] = [], rng?: () => number, keep?: Candidate | null): Scored | null {
147
+ let best: Scored | null = null;
148
+ for (const c of candidates) {
149
+ const s = scoreCandidate(self, c, p, arena, rivals);
150
+ // A little loyalty to the target it already has, so two near-equal prizes do not make it dither between them.
151
+ if (keep && (c === keep || (c.id !== undefined && c.id === keep.id))) s.score *= 1.25;
152
+ if (p.fickle > 0 && rng) s.score *= 1 + (rng() - 0.5) * 2 * p.fickle;
153
+ if (s.score > 0 && (!best || s.score > best.score)) best = s;
154
+ }
155
+ return best;
156
+ }
157
+
158
+ /**
159
+ * Turn a push away from a wall ahead: three feelers (ahead, and 40 degrees to each side) `look` units long over the
160
+ * game's `solid`. A blocked feeler turns the push toward the open side by `strength`. `steered` says it had to.
161
+ */
162
+ export function avoidWalls(pos: V2, push: V2, solid: (x: number, y: number) => boolean, look = 3, strength = 1): V2 & { steered: boolean } {
163
+ const m = Math.hypot(push.x, push.y);
164
+ if (m < 1e-9 || strength <= 0) return { x: push.x, y: push.y, steered: false };
165
+ const a = Math.atan2(push.y, push.x);
166
+ // How far each feeler gets before something solid, as a share of its length.
167
+ const feel = (off: number): number => {
168
+ for (let i = 1; i <= 6; i += 1) { const d = (look * i) / 6; if (solid(pos.x + Math.cos(a + off) * d, pos.y + Math.sin(a + off) * d)) return (i - 1) / 6; }
169
+ return 1;
170
+ };
171
+ const ahead = feel(0); const left = feel(0.7); const right = feel(-0.7);
172
+ if (ahead >= 1 && left >= 1 && right >= 1) return { x: push.x, y: push.y, steered: false };
173
+ // Turn toward whichever side is more open; with nothing to choose between them, left, every time.
174
+ const turn = (left >= right ? 1 : -1) * strength * (1 - Math.min(ahead, left, right)) * 1.2;
175
+ return { x: Math.cos(a + turn) * m, y: Math.sin(a + turn) * m, steered: true };
176
+ }
177
+
178
+ /** What one brain did, counted. The numbers a game's test asserts on to show its personas differ. */
179
+ export interface BotStats {
180
+ persona: string;
181
+ /** Times it was asked, and times it changed to a different target. */
182
+ thinks: number; picks: number;
183
+ /** Picks of each kind. */
184
+ byKind: Record<string, number>;
185
+ /** Means over its picks: the prize's value, the distance to it, how far ahead it was (1 ahead, -1 behind), rivals near it. */
186
+ meanValue: number; meanDist: number; meanFacing: number; meanCompany: number;
187
+ /** Picks made with a wall in the way, and pushes that had to be turned off a wall. */
188
+ blindPicks: number; wallSteers: number;
189
+ }
190
+
191
+ export interface PersonaBrain {
192
+ readonly persona: Persona;
193
+ /** The target in force (the last pick), or null. */
194
+ readonly target: Candidate | null;
195
+ /** Pick now. Counted as a pick only when the target changes. */
196
+ choose(self: BotSelf, candidates: readonly Candidate[], rivals?: readonly Rival[]): Candidate | null;
197
+ /** The same, shaped as the `choose` BotBrain.think takes. */
198
+ chooser(self: () => BotSelf, candidates: () => readonly Candidate[], rivals?: () => readonly Rival[]): () => Candidate | null;
199
+ /** A push (a stick vector) turned away from walls, by this persona's `wall` weight. */
200
+ steer(self: V2, push: V2): V2;
201
+ stats(): BotStats;
202
+ /** Forget the target and the counts (a new round). */
203
+ reset(): void;
204
+ }
205
+
206
+ export function createPersonaBrain(p: Persona, opts: { rng?: () => number; arena?: Arena } = {}): PersonaBrain {
207
+ const arena = opts.arena ?? {};
208
+ let target: Candidate | null = null;
209
+ let n = { thinks: 0, picks: 0, value: 0, dist: 0, facing: 0, company: 0, blind: 0, steers: 0, byKind: {} as Record<string, number> };
210
+ const same = (a: Candidate | null, b: Candidate | null): boolean => a === b || (!!a && !!b && a.id !== undefined && a.id === b.id);
211
+ const brain: PersonaBrain = {
212
+ persona: p,
213
+ get target() { return target; },
214
+ choose(self, candidates, rivals = []) {
215
+ n.thinks += 1;
216
+ // A target that is gone (eaten, expired) is not kept: it is no longer among the candidates.
217
+ const keep = target && candidates.some((c) => same(c, target)) ? target : null;
218
+ const best = pickCandidate(self, candidates, p, arena, rivals, opts.rng, keep);
219
+ if (best && !same(best.c, target)) {
220
+ n.picks += 1; n.value += best.c.value; n.dist += best.dist; n.facing += best.facing; n.company += best.company;
221
+ if (!best.seen) n.blind += 1;
222
+ n.byKind[best.c.kind] = (n.byKind[best.c.kind] ?? 0) + 1;
223
+ }
224
+ target = best ? best.c : null;
225
+ return target;
226
+ },
227
+ chooser(self, candidates, rivals) { return () => brain.choose(self(), candidates(), rivals ? rivals() : []); },
228
+ steer(self, push) {
229
+ if (!arena.solid) return push;
230
+ const out = avoidWalls(self, push, arena.solid, arena.look ?? 3, p.wall);
231
+ if (out.steered) n.steers += 1;
232
+ return { x: out.x, y: out.y };
233
+ },
234
+ stats() {
235
+ const k = Math.max(1, n.picks);
236
+ return { persona: p.name, thinks: n.thinks, picks: n.picks, byKind: { ...n.byKind }, meanValue: n.value / k, meanDist: n.dist / k, meanFacing: n.facing / k, meanCompany: n.company / k, blindPicks: n.blind, wallSteers: n.steers };
237
+ },
238
+ reset() { target = null; n = { thinks: 0, picks: 0, value: 0, dist: 0, facing: 0, company: 0, blind: 0, steers: 0, byKind: {} }; },
239
+ };
240
+ return brain;
241
+ }
242
+
243
+ /** Brains side by side: one row each, in the order given. */
244
+ export function botStats(brains: readonly PersonaBrain[]): BotStats[] { return brains.map((b) => b.stats()); }
245
+
246
+ /** The share of a brain's picks that were of one kind (0 with no picks). */
247
+ export const kindShare = (s: BotStats, kind: string): number => (s.picks ? (s.byKind[kind] ?? 0) / s.picks : 0);
248
+
249
+ /**
250
+ * How unlike each other a set of bots behaved: for every pair, the distance between what they picked (the share of
251
+ * each kind), 0 for identical and 1 for nothing in common; the answer is the SMALLEST pair's. A value near 0 means
252
+ * two of them are the same bot under two names, whatever their weights say.
253
+ */
254
+ export function personaSpread(rows: readonly BotStats[]): number {
255
+ let least = Infinity;
256
+ for (let i = 0; i < rows.length; i += 1) for (let j = i + 1; j < rows.length; j += 1) {
257
+ const a = rows[i] as BotStats; const b = rows[j] as BotStats;
258
+ let d = 0;
259
+ for (const k of new Set([...Object.keys(a.byKind), ...Object.keys(b.byKind)])) d += Math.abs(kindShare(a, k) - kindShare(b, k));
260
+ least = Math.min(least, d / 2);
261
+ }
262
+ return Number.isFinite(least) ? least : 0;
263
+ }
264
+
265
+ /** A small seeded generator (mulberry32): the same seed, the same numbers, on every browser. */
266
+ export function seededRng(seed: number): () => number {
267
+ let a = seed >>> 0;
268
+ return () => {
269
+ a = (a + 0x6d2b79f5) >>> 0;
270
+ let t = a;
271
+ t = Math.imul(t ^ (t >>> 15), t | 1);
272
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
273
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
274
+ };
275
+ }