create-vimp-game 0.1.0

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 (72) hide show
  1. package/bin/create-vimp-game.js +15 -0
  2. package/package.json +40 -0
  3. package/src/cli.js +227 -0
  4. package/src/generator.js +196 -0
  5. package/src/preflight.js +48 -0
  6. package/src/prompts.js +79 -0
  7. package/src/tokens.js +99 -0
  8. package/src/ui.js +66 -0
  9. package/src/versions.generated.json +4 -0
  10. package/src/versions.js +87 -0
  11. package/templates/default/CLAUDE.md.tpl +51 -0
  12. package/templates/default/Cargo.toml.tpl +27 -0
  13. package/templates/default/LICENSE.tpl +21 -0
  14. package/templates/default/README.md.tpl +61 -0
  15. package/templates/default/_gitignore +8 -0
  16. package/templates/default/assets/audio-raw/death.wav +0 -0
  17. package/templates/default/assets/audio-raw/shot.wav +0 -0
  18. package/templates/default/assets/sounds/death.mp3 +0 -0
  19. package/templates/default/assets/sounds/death.webm +0 -0
  20. package/templates/default/assets/sounds/shot.mp3 +0 -0
  21. package/templates/default/assets/sounds/shot.webm +0 -0
  22. package/templates/default/core/Cargo.toml.tpl +19 -0
  23. package/templates/default/core/src/actor.rs +407 -0
  24. package/templates/default/core/src/body_tag.rs +56 -0
  25. package/templates/default/core/src/client/mod.rs +385 -0
  26. package/templates/default/core/src/client/predictor.rs +552 -0
  27. package/templates/default/core/src/config.rs +236 -0
  28. package/templates/default/core/src/game.rs +692 -0
  29. package/templates/default/core/src/lib.rs +118 -0
  30. package/templates/default/core/src/motion.rs +126 -0
  31. package/templates/default/core/tests/sim.rs +351 -0
  32. package/templates/default/dev/main.js +28 -0
  33. package/templates/default/eslint.config.js +84 -0
  34. package/templates/default/index.html.tpl +35 -0
  35. package/templates/default/package.json.tpl +48 -0
  36. package/templates/default/scripts/build-game-manifest.js +188 -0
  37. package/templates/default/scripts/copy-game-images.js +35 -0
  38. package/templates/default/scripts/copy-game-sounds.js +55 -0
  39. package/templates/default/scripts/export-maps.js +19 -0
  40. package/templates/default/scripts/lib/rangeToPattern.js +74 -0
  41. package/templates/default/scripts/process-audio.js +115 -0
  42. package/templates/default/src/client/bakers/actorTexture.js +40 -0
  43. package/templates/default/src/client/bakers/index.js +8 -0
  44. package/templates/default/src/client/index.js +67 -0
  45. package/templates/default/src/client/parts/Actor.js +69 -0
  46. package/templates/default/src/client/parts/Map.js +74 -0
  47. package/templates/default/src/client/parts/ShotEffect.js +107 -0
  48. package/templates/default/src/client/parts/index.js +13 -0
  49. package/templates/default/src/client/style.css +26 -0
  50. package/templates/default/src/config/auth.js +61 -0
  51. package/templates/default/src/config/client.js +195 -0
  52. package/templates/default/src/config/game.js +170 -0
  53. package/templates/default/src/config/snapshot.js +70 -0
  54. package/templates/default/src/config/sounds.js +17 -0
  55. package/templates/default/src/data/maps/arena.js +69 -0
  56. package/templates/default/src/data/maps/index.js +7 -0
  57. package/templates/default/src/data/models.js +39 -0
  58. package/templates/default/src/data/weapons.js +37 -0
  59. package/templates/default/src/host/ScriptedManager.js +142 -0
  60. package/templates/default/src/host/createModules.js +12 -0
  61. package/templates/default/src/host/index.js +56 -0
  62. package/templates/default/src/host/nodeCore.js +23 -0
  63. package/templates/default/src/host/spawnCommand.js +32 -0
  64. package/templates/default/src/host/systemMessages.js +10 -0
  65. package/templates/default/tests/client/parts.test.js +128 -0
  66. package/templates/default/tests/config/contract.test.js +132 -0
  67. package/templates/default/tests/config/game.test.js +45 -0
  68. package/templates/default/tests/core/nodeCore.test.js +61 -0
  69. package/templates/default/tests/host/hostPlugin.test.js +198 -0
  70. package/templates/default/tests/stubs/wasmCore.js +19 -0
  71. package/templates/default/vite.config.js +74 -0
  72. package/templates/default/vitest.config.js +55 -0
@@ -0,0 +1,170 @@
1
+ import snapshot from './snapshot.js';
2
+ import models from '../data/models.js';
3
+ import weapons from '../data/weapons.js';
4
+ import maps from '../data/maps/index.js';
5
+
6
+ // HostPlugin.gameConfig — the authoritative description of the game: what the
7
+ // engine meta needs (rounds, teams, panel, stat, room form) and what it hands
8
+ // over to the Rust core untouched (models, weapons, playerKeys, snapshot).
9
+ //
10
+ // The engine asserts nine paths right after import — roomDefaults.maxPlayers,
11
+ // snapshot, parts.models, parts.weapons, parts.friendlyFire, panel.fields,
12
+ // playerKeys, teams, spectatorTeam — plus `spectatorTeam` being a key of
13
+ // `teams`. Everything else is read lazily, which is why a typo elsewhere
14
+ // surfaces as a black canvas rather than an error: run `npm run
15
+ // check:contract` after every change here.
16
+ export default {
17
+ title: '{{GAME_TITLE}}',
18
+
19
+ parts: {
20
+ models,
21
+ weapons,
22
+ // overridable per room through the lobby form below
23
+ friendlyFire: false,
24
+ },
25
+
26
+ // the binary protocol layout; the engine passes it to both cores and to the
27
+ // client, knowing nothing about the meaning of a field
28
+ snapshot,
29
+
30
+ // engine event -> name in the sound registry (src/config/sounds.js).
31
+ // All five keys exist so that filling one in is editing a value, not
32
+ // remembering the vocabulary; an unmapped cue is simply not sent, and the
33
+ // template ships only the two sounds a match cannot do without.
34
+ soundCues: {
35
+ roundStart: null,
36
+ victory: null,
37
+ defeat: null,
38
+ frag: null,
39
+ death: 'death',
40
+ },
41
+
42
+ // the vote a player is offered right after their first frame: without it a
43
+ // joining participant stays a spectator with no way to ask for a team
44
+ initialVote: 'teamChange',
45
+
46
+ maps,
47
+ currentMap: 'arena',
48
+ mapScale: 1,
49
+ // fallback for a map without its own setId
50
+ mapSetId: 'c1',
51
+ mapsInVote: 1,
52
+
53
+ // starting profile of a player with no saved record on the auth service.
54
+ // The engine keeps it as opaque JSON — the game is the only reader
55
+ playerState: {
56
+ defaultState: { kills: 0, shots: 0 },
57
+ },
58
+
59
+ spectatorTeam: 'spectators',
60
+ teams: {
61
+ team1: 1,
62
+ team2: 2,
63
+ spectators: 3,
64
+ },
65
+
66
+ // bots: the name prefix the engine numbers, and the model they spawn with
67
+ scripted: {
68
+ namePrefix: 'Bot',
69
+ defaultModel: 'a1',
70
+ },
71
+
72
+ // Action name -> bit and kind. The engine ships this table into both cores
73
+ // verbatim and never interprets it: `type: 1` (one-shot) is a CONVENTION
74
+ // core/src/actor.rs implements — `down` arms the bit, exactly one fixed step
75
+ // consumes it, `up` is ignored. Every name here must have a key in
76
+ // keySetList[1] (src/config/client.js) or it can never be pressed.
77
+ playerKeys: {
78
+ forward: { key: 1 << 0 },
79
+ back: { key: 1 << 1 },
80
+ left: { key: 1 << 2 },
81
+ right: { key: 1 << 3 },
82
+ fire: { key: 1 << 4, type: 1 },
83
+ },
84
+
85
+ // HUD, host half: `key` is the short wire key, `value` the starting amount —
86
+ // and the same number is what the core hands the actor as health and ammo.
87
+ // `health` is read by the core by that exact name; every other key must name
88
+ // a weapon (the core refuses to boot otherwise).
89
+ // The key 't' is reserved by the engine for the round time — never declare it.
90
+ panel: {
91
+ fields: {
92
+ health: { key: 'h', value: 100 },
93
+ e1: { key: 'a', value: 100 },
94
+ },
95
+ // Cell showing the active weapon. It is NOT a field: the engine writes
96
+ // the weapon name into it whenever the core reports a change, and it does
97
+ // so unconditionally — leaving this null makes the panel send the literal
98
+ // key 'null' to every client, which nothing on the other side can render.
99
+ activeKey: 'wa',
100
+ },
101
+
102
+ // Statistics table, host half. The engine writes exactly five names —
103
+ // name, status, score, deaths, latency — and nothing else; `key` is the
104
+ // column index on the wire, matched positionally by src/config/client.js.
105
+ stat: {
106
+ name: {
107
+ key: 0,
108
+ bodyMethod: '=',
109
+ headSync: true,
110
+ headMethod: '#',
111
+ },
112
+ status: {
113
+ key: 1,
114
+ bodyMethod: '=',
115
+ bodyValue: '',
116
+ headValue: '',
117
+ },
118
+ score: {
119
+ key: 2,
120
+ bodyMethod: '+',
121
+ bodyValue: 0,
122
+ headMethod: '+',
123
+ headValue: 0,
124
+ },
125
+ deaths: {
126
+ key: 3,
127
+ bodyMethod: '+',
128
+ bodyValue: 0,
129
+ headMethod: '+',
130
+ headValue: 0,
131
+ },
132
+ latency: {
133
+ key: 4,
134
+ bodyMethod: '=',
135
+ },
136
+ },
137
+
138
+ roomDefaults: {
139
+ maxPlayers: 8,
140
+ },
141
+
142
+ // The lobby "create server" form. The names must be exactly the keys the
143
+ // host honours (maxPlayers, map, roundTime, mapTime, friendlyFire) —
144
+ // anything else is accepted by the form and silently dropped. Numeric
145
+ // bounds are injected as `regExp` by scripts/build-game-manifest.js.
146
+ roomForm: [
147
+ {
148
+ name: 'maxPlayers',
149
+ control: 'text',
150
+ label: 'Max players',
151
+ numeric: true,
152
+ },
153
+ {
154
+ name: 'roundTime',
155
+ control: 'text',
156
+ label: 'Round time',
157
+ unit: 's',
158
+ numeric: true,
159
+ },
160
+ {
161
+ name: 'mapTime',
162
+ control: 'text',
163
+ label: 'Map time',
164
+ unit: 's',
165
+ numeric: true,
166
+ },
167
+ { name: 'friendlyFire', control: 'checkbox', label: 'Friendly fire' },
168
+ { name: 'map', control: 'select', label: 'Map', source: 'maps' },
169
+ ],
170
+ };
@@ -0,0 +1,70 @@
1
+ // The wire layout of the game (gameConfig.snapshot): one block per entity
2
+ // kind. The engine never learns what a field means — only how many bytes it
3
+ // takes and whether it interpolates (docs/ai/06-snapshot-protocol.md).
4
+ //
5
+ // The KEY of a block is not free: the Rust core names the actor block after
6
+ // the model (`src/data/models.js`) and the tracer block after the weapon
7
+ // (`src/data/weapons.js`) that produced it, so `a1` and `e1` below are those
8
+ // two names. Renaming a model or a weapon renames its block. The third key is
9
+ // the `setId` of the map — see the comment on `c1`.
10
+ //
11
+ // The field ORDER is positionally bound to `core/src/game.rs` — `ActorRow`
12
+ // and `TracerRow` push their values in exactly this sequence. Nothing
13
+ // validates the correspondence: swapping two entries here without swapping
14
+ // them there produces garbage instead of an error.
15
+ export default {
16
+ // the actor: continuous state, so `hot` — the only class the render-rate
17
+ // buffer carries, and it carries only indexed8 / indexedNoNull8
18
+ a1: {
19
+ id: 1,
20
+ kind: 'indexed8',
21
+ class: 'hot',
22
+ fields: [
23
+ { name: 'x', ty: 'f32', interp: 'lerp' },
24
+ { name: 'y', ty: 'f32', interp: 'lerp' },
25
+ { name: 'angle', ty: 'f32', interp: 'lerpAngle' },
26
+ // velocity is not interpolated: the client only reads it to tell a
27
+ // moving actor from a standing one, and a lerped copy of a value that
28
+ // already changes every frame buys nothing
29
+ { name: 'vx', ty: 'f32' },
30
+ { name: 'vy', ty: 'f32' },
31
+ { name: 'health', ty: 'u8' },
32
+ { name: 'team', ty: 'u8' },
33
+ ],
34
+ },
35
+
36
+ // the shot: an anonymous one-shot event, so `list16` + class 'event' —
37
+ // a frame carrying it goes over the reliable channel
38
+ e1: {
39
+ id: 2,
40
+ kind: 'list16',
41
+ class: 'event',
42
+ fields: [
43
+ { name: 'startX', ty: 'f32' },
44
+ { name: 'startY', ty: 'f32' },
45
+ { name: 'endX', ty: 'f32' },
46
+ { name: 'endY', ty: 'f32' },
47
+ { name: 'wasHit', ty: 'u8' },
48
+ // the author id is LAST by convention: the client drops the
49
+ // authoritative twin of a tracer it already drew by this field
50
+ { name: 'author', ty: 'u8' },
51
+ ],
52
+ },
53
+
54
+ // The movable bodies of the map (`physicsDynamic`). This block belongs to
55
+ // the ENGINE: it is packed by the engine half of the core, under the map's
56
+ // `setId` as the key, and its row is fixed at [x, y, angle]. The template
57
+ // has no dynamic bodies, and the block is still mandatory — the packer
58
+ // refuses a key it does not know, and `npm run sim` dies on the first tick
59
+ // with "unknown snapshot key 'c1'".
60
+ c1: {
61
+ id: 3,
62
+ kind: 'indexedNoNull8',
63
+ class: 'hot',
64
+ fields: [
65
+ { name: 'x', ty: 'f32', interp: 'lerp' },
66
+ { name: 'y', ty: 'f32', interp: 'lerp' },
67
+ { name: 'angle', ty: 'f32', interp: 'lerpAngle' },
68
+ ],
69
+ },
70
+ };
@@ -0,0 +1,17 @@
1
+ // Sound registry of the client. Every entry must ship as a webm + mp3 PAIR:
2
+ // the client walks codecList and takes the first codec the browser supports,
3
+ // so a missing .mp3 breaks Safari only — i.e. never for the author.
4
+ //
5
+ // `file` is the base name in dist/sounds/ (assets/sounds/ or, after
6
+ // `npm run audio:process`, build/sounds/). Do NOT set `path`: the engine
7
+ // overwrites it with `${assetsBase}sounds/`, and a hand-written one only
8
+ // works until the game is served from the lobby.
9
+ const sounds = {
10
+ shot: { file: 'shot', priority: 100, volume: 0.4 },
11
+ death: { file: 'death', priority: 150, volume: 0.4 },
12
+ };
13
+
14
+ export default {
15
+ codecList: ['webm', 'mp3'],
16
+ sounds,
17
+ };
@@ -0,0 +1,69 @@
1
+ // The single map of the game: a walled rectangle with four pillars, drawn
2
+ // procedurally by `src/client/parts/Map.js` — no `spriteSheet`, so the package
3
+ // ships no images at all (docs/ai/07-maps-and-assets.md).
4
+ //
5
+ // `map` is the grid of tiles (0 — empty, 1 — wall), `step` its cell size in
6
+ // world units; `physicsStatic` lists the tile values the core turns into
7
+ // colliders, and `layers` maps a render layer to the tile values drawn on it —
8
+ // WITHOUT it the client builds no map parts at all (the engine derives the
9
+ // static map data from `layers`, one entry per layer).
10
+ //
11
+ // `respawns` holds one entry per playing team, and the length of a list is the
12
+ // hard capacity of that team on this map: the engine hands the points out
13
+ // sequentially and refuses the next joiner when they run out. Keep every
14
+ // point on an EMPTY cell — the engine does not check, and an actor spawned
15
+ // inside a wall is stuck there for the round.
16
+ const W = 1;
17
+
18
+ export default {
19
+ // which parts.gameSets entry builds this map (src/config/client.js)
20
+ setId: 'c1',
21
+ scale: 1,
22
+ step: 64,
23
+ physicsStatic: [W],
24
+ physicsDynamic: [],
25
+
26
+ // render layer -> tile values drawn on it
27
+ layers: { 1: [W] },
28
+
29
+ map: [
30
+ [W, W, W, W, W, W, W, W, W, W, W, W],
31
+ [W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
32
+ [W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
33
+ [W, 0, 0, W, W, 0, 0, W, W, 0, 0, W],
34
+ [W, 0, 0, W, 0, 0, 0, 0, W, 0, 0, W],
35
+ [W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
36
+ [W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
37
+ [W, 0, 0, W, 0, 0, 0, 0, W, 0, 0, W],
38
+ [W, 0, 0, W, W, 0, 0, W, W, 0, 0, W],
39
+ [W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
40
+ [W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
41
+ [W, W, W, W, W, W, W, W, W, W, W, W],
42
+ ],
43
+
44
+ // [x, y, angleDeg] — DEGREES, not radians: the core converts them itself.
45
+ // The two teams start along the opposite short walls, in the two columns
46
+ // that are empty on every row (x = 96/160 and x = 608/672).
47
+ respawns: {
48
+ team1: [
49
+ [96, 96, 0],
50
+ [96, 224, 0],
51
+ [96, 352, 0],
52
+ [96, 480, 0],
53
+ [160, 160, 0],
54
+ [160, 288, 0],
55
+ [160, 416, 0],
56
+ [160, 544, 0],
57
+ ],
58
+ team2: [
59
+ [672, 96, 180],
60
+ [672, 224, 180],
61
+ [672, 352, 180],
62
+ [672, 480, 180],
63
+ [608, 160, 180],
64
+ [608, 288, 180],
65
+ [608, 416, 180],
66
+ [608, 544, 180],
67
+ ],
68
+ },
69
+ };
@@ -0,0 +1,7 @@
1
+ import arena from './arena.js';
2
+
3
+ // Map registry: the key is the map name the lobby shows and the file name
4
+ // scripts/export-maps.js writes into dist/maps/<name>.json.
5
+ export default {
6
+ arena,
7
+ };
@@ -0,0 +1,39 @@
1
+ // Actor classes of the game (gameConfig.parts.models). The engine passes this
2
+ // object verbatim into BOTH cores — the authoritative one and the predictor —
3
+ // so every number here is gameplay, not decoration (core/src/config.rs,
4
+ // struct ActorConfig).
5
+ //
6
+ // The key is the model name: it is the snapshot block key of the actor
7
+ // (`src/config/snapshot.js`), the value of the `model` field of the auth form
8
+ // and `gameConfig.scripted.defaultModel`. One class is enough to play; adding
9
+ // a second one is adding a key here — nothing in the core is hard-coded to a
10
+ // name.
11
+ export default {
12
+ a1: {
13
+ // key of `src/data/weapons.js` the actor spawns with
14
+ currentWeapon: 'e1',
15
+
16
+ // diameter of the body in world units (the collider is a ball of size/2)
17
+ size: 32,
18
+
19
+ // units per second; the reverse cap is deliberately lower — an actor that
20
+ // backs away as fast as it charges makes every duel a stalemate
21
+ maxSpeed: 220,
22
+ maxReverseSpeed: 110,
23
+
24
+ // speed gained per second while a drive key is held / lost with none
25
+ acceleration: 520,
26
+ braking: 700,
27
+
28
+ // radians per second
29
+ turnSpeed: 3.2,
30
+
31
+ // Rapier collider parameters. restitution 0: actors that bounce off each
32
+ // other turn a crowd into a pinball table
33
+ fixture: {
34
+ density: 1,
35
+ friction: 0.2,
36
+ restitution: 0,
37
+ },
38
+ },
39
+ };
@@ -0,0 +1,37 @@
1
+ // Weapons of the game (gameConfig.parts.weapons) — the same object reaches
2
+ // the authoritative core and the predictor (core/src/config.rs, struct
3
+ // WeaponConfig).
4
+ //
5
+ // The key is the weapon name, and the core names the tracer snapshot block
6
+ // after it: `e1` here is the `e1` block of `src/config/snapshot.js`. It is
7
+ // also the panel field holding the ammo (`src/config/game.js`) — the core
8
+ // validates that pairing at construction and refuses to boot without it.
9
+ export default {
10
+ e1: {
11
+ // hitscan: the ray is cast on the same step the trigger is consumed, so
12
+ // there is no projectile body and no contact handling in the core
13
+ damage: 25,
14
+
15
+ // ray length in world units — a bit less than the long side of the arena,
16
+ // so the map itself limits the duel range
17
+ range: 620,
18
+
19
+ // SECONDS between shots (the client predictor multiplies by 1000 itself)
20
+ fireRate: 0.35,
21
+
22
+ // radians of random spread; it goes through the engine Rng on both sides,
23
+ // never through Math.random — determinism is what makes a replay and a
24
+ // host handoff reproduce the same match
25
+ spread: 0.03,
26
+
27
+ // ammo spent per shot; the pool is the panel value of this key
28
+ consumption: 1,
29
+
30
+ // the victim's canvas shakes on a hit (needs shakeCamera on the canvas,
31
+ // `src/config/client.js`)
32
+ cameraShake: {
33
+ intensity: 4,
34
+ duration: 200,
35
+ },
36
+ },
37
+ };
@@ -0,0 +1,142 @@
1
+ // The bot manager: the game half of "scripted participants". The engine calls
2
+ // exactly five methods on it — createMap, getCountsPerTeam, createScripted,
3
+ // removeScripted, removeOneForHuman — and never anything else
4
+ // (docs/ai/03-host-plugin.md § The `scripted` module contract).
5
+ //
6
+ // It holds no state that a host handoff may not lose: a restored room
7
+ // re-creates the map and respawns everyone, and this object is rebuilt from
8
+ // scratch. Worker-safe.
9
+ export default class ScriptedManager {
10
+ constructor({ participants, coreAdapter, panel, stat, scripted }) {
11
+ this._participants = participants;
12
+ this._coreAdapter = coreAdapter;
13
+ this._panel = panel;
14
+ this._stat = stat;
15
+
16
+ // `scripted` in the context is gameConfig.scripted — a config object, not
17
+ // a module
18
+ this._model = scripted.defaultModel;
19
+ this._respawns = null;
20
+ }
21
+
22
+ // called on every map load, with the map data ALREADY scaled
23
+ createMap(mapData) {
24
+ this._respawns = mapData.respawns;
25
+ }
26
+
27
+ // { teamName: count } — the engine balances the teams by it
28
+ getCountsPerTeam() {
29
+ const counts = {};
30
+
31
+ for (const participant of this._participants.getScripted()) {
32
+ counts[participant.team] = (counts[participant.team] ?? 0) + 1;
33
+ }
34
+
35
+ return counts;
36
+ }
37
+
38
+ // returns how many were actually created — the caller reports that number,
39
+ // not the number asked for
40
+ createScripted(count, teamName = null) {
41
+ if (!this._respawns) {
42
+ return 0;
43
+ }
44
+
45
+ const playableTeams = this._participants.getPlayableTeams();
46
+ let created = 0;
47
+
48
+ for (let i = 0; i < count; i += 1) {
49
+ if (this._participants.isFull) {
50
+ break;
51
+ }
52
+
53
+ // no team asked for: fill the emptiest one that still has room, so a
54
+ // room of bots stays balanced without anyone steering it. Trying only
55
+ // the emptiest team would stall the whole loop once that one team is
56
+ // out of respawn points while its neighbour still has some.
57
+ const targetTeam = teamName ?? this._pickTeam(playableTeams);
58
+
59
+ // team sizes only grow inside this loop, so «no room» is final: the
60
+ // remaining iterations would do nothing but burn `count`
61
+ if (!targetTeam || !this._hasRoom(targetTeam)) {
62
+ break;
63
+ }
64
+
65
+ const gameId = this._participants.createScripted({
66
+ team: targetTeam,
67
+ model: this._model,
68
+ });
69
+ const participant = this._participants.get(gameId);
70
+
71
+ // a bot has no socket, so the engine never writes its latency cell —
72
+ // whatever is passed here is what the table shows for the whole match
73
+ this._stat.addUser(gameId, participant.teamId, {
74
+ name: participant.name,
75
+ status: 'dead',
76
+ latency: 'BOT',
77
+ });
78
+ this._panel.addUser(gameId);
79
+
80
+ created += 1;
81
+ }
82
+
83
+ return created;
84
+ }
85
+
86
+ // the number of respawn points is the hard capacity of a team: past it the
87
+ // engine has nowhere to put the actor
88
+ _hasRoom(teamName) {
89
+ const respawns = this._respawns[teamName];
90
+
91
+ return Boolean(
92
+ respawns && this._participants.getTeamSize(teamName) < respawns.length,
93
+ );
94
+ }
95
+
96
+ _pickTeam(playableTeams) {
97
+ return (
98
+ [...playableTeams]
99
+ .sort(
100
+ (a, b) =>
101
+ this._participants.getTeamSize(a) -
102
+ this._participants.getTeamSize(b),
103
+ )
104
+ .find(team => this._hasRoom(team)) ?? null
105
+ );
106
+ }
107
+
108
+ removeScripted(teamName = null) {
109
+ const scripted = this._participants.getScripted();
110
+ const toRemove = teamName
111
+ ? scripted.filter(participant => participant.team === teamName)
112
+ : scripted;
113
+
114
+ toRemove.forEach(participant => this._remove(participant.gameId));
115
+ }
116
+
117
+ // a human needs a slot in a full team: free one and say whether it worked
118
+ removeOneForHuman(teamName) {
119
+ for (const participant of this._participants.getScripted()) {
120
+ if (participant.team === teamName) {
121
+ this._remove(participant.gameId);
122
+
123
+ return true;
124
+ }
125
+ }
126
+
127
+ return false;
128
+ }
129
+
130
+ _remove(gameId) {
131
+ const participant = this._participants.get(gameId);
132
+
133
+ if (!participant || !participant.isScripted) {
134
+ return;
135
+ }
136
+
137
+ this._stat.removeUser(gameId, participant.teamId);
138
+ this._panel.removeUser(gameId);
139
+ this._coreAdapter.removePlayer(gameId);
140
+ this._participants.remove(gameId);
141
+ }
142
+ }
@@ -0,0 +1,12 @@
1
+ import ScriptedManager from './ScriptedManager.js';
2
+
3
+ // Factory of the game's host modules (HostPlugin.createModules). The engine
4
+ // reads exactly ONE key off the result — `scripted`; anything else returned
5
+ // here is never called by it.
6
+ //
7
+ // The context is { participants, coreAdapter, panel, stat, chat,
8
+ // socketManager, scripted } — there is no timerManager and no
9
+ // voteCoordinator in it (those exist only in a chat-command context).
10
+ export default function createModules(ctx) {
11
+ return { scripted: new ScriptedManager(ctx) };
12
+ }
@@ -0,0 +1,56 @@
1
+ import { ENGINE_API_VERSION } from 'vimp-engine/config/opcodes.js';
2
+ import gameConfig from '../config/game.js';
3
+ import authSchema from '../config/auth.js';
4
+ import clientConfig from '../config/client.js';
5
+ import createModules from './createModules.js';
6
+ import spawnCommand from './spawnCommand.js';
7
+ import systemMessages from './systemMessages.js';
8
+ import { isNodeCore, loadNodeCore, loadWebCore } from './nodeCore.js';
9
+
10
+ // HostPlugin — the game half of the authoritative match, Worker-safe: no DOM,
11
+ // no PixiJS, no Node globals. Default export of the host entry
12
+ // (vite build --mode host); the engine loads it by GameManifest.entries.host.
13
+ //
14
+ // Every field below is dereferenced by the engine without a guard, so a
15
+ // missing one is a TypeError far from its cause (docs/ai/03-host-plugin.md).
16
+ export default {
17
+ id: '{{GAME_ID}}',
18
+ // never a literal: a number written by hand agrees with the engine on the
19
+ // day it is typed and silently disagrees after the next release
20
+ engineApi: ENGINE_API_VERSION,
21
+
22
+ // wasmUrl comes from the manifest: init() loads by an explicit URL instead
23
+ // of the glue module's own import.meta.url resolution, which does not
24
+ // survive inside a Worker
25
+ async createCore(coreConfigJson, { wasmUrl } = {}) {
26
+ if (isNodeCore(wasmUrl)) {
27
+ const node = await loadNodeCore(wasmUrl);
28
+
29
+ return new node.GameCore(coreConfigJson);
30
+ }
31
+
32
+ const { default: init, GameCore } = await loadWebCore();
33
+
34
+ // module_or_path — the wasm-bindgen init() option name
35
+ await init({ module_or_path: wasmUrl });
36
+
37
+ return new GameCore(coreConfigJson);
38
+ },
39
+
40
+ gameConfig,
41
+ authSchema,
42
+
43
+ // REQUIRED array — the engine iterates it unguarded; `[]` for no commands
44
+ chatCommands: [spawnCommand],
45
+
46
+ // merged into the engine chat registry by a blind Object.assign: a code in
47
+ // an engine group would overwrite an engine message without a word
48
+ systemMessages,
49
+
50
+ // the engine calls it and reads exactly one key off the result: `scripted`
51
+ createModules,
52
+
53
+ // the client half of the config has no file of its own on the client: the
54
+ // host builds it and sends it over
55
+ buildClientGameConfig: () => clientConfig,
56
+ };
@@ -0,0 +1,23 @@
1
+ // Loading the WASM core, both shapes of it. `wasmUrl` arrives from the
2
+ // manifest and differs between the two runtimes (docs/ai/03-host-plugin.md):
3
+ //
4
+ // browser — the hashed `.wasm` asset (entries.wasm): the `--target web`
5
+ // glue is imported and `init()` fetches the binary by that URL;
6
+ // Node — a file: URL of the `--target nodejs` glue (entries.wasmNode),
7
+ // used by `npm run sim`: that build pulls the wasm in itself, and
8
+ // fetch() cannot read file: URLs anyway.
9
+ //
10
+ // Both halves of the plugin branch through THIS file: a headless run that used
11
+ // a different core than the browser would prove nothing.
12
+ //
13
+ // The web glue is loaded by a dynamic import on purpose. As a static one it
14
+ // would make `src/host/index.js` unimportable in Node until `npm run
15
+ // core:build` has run — and with it `npm run check:contract` and every unit
16
+ // test of the host half.
17
+
18
+ export const isNodeCore = wasmUrl => (wasmUrl ?? '').endsWith('.js');
19
+
20
+ // @vite-ignore: the path is a runtime value, Vite must not try to resolve it
21
+ export const loadNodeCore = wasmUrl => import(/* @vite-ignore */ wasmUrl);
22
+
23
+ export const loadWebCore = () => import('../../core/pkg-web/{{CRATE_SNAKE}}.js');
@@ -0,0 +1,32 @@
1
+ // Chat command '/spawn <count>' — fills the room with bots without a vote.
2
+ // It is also what `npm run dev` uses to get a match going (startupCommands in
3
+ // dev/main.js).
4
+ //
5
+ // The name must not collide with the engine's own commands (/name, /nr,
6
+ // /timeleft, /mapname, /rank): those are matched by a switch BEFORE the game
7
+ // registry, so a same-named command registers fine and never fires.
8
+ export default {
9
+ name: '/spawn',
10
+
11
+ // ctx = { participants, chat, scripted, roundManager, voteCoordinator,
12
+ // timerManager, playerDataSync, teams, spectatorTeam, spectatorId,
13
+ // isDevMode }
14
+ handler(ctx, gameId, args) {
15
+ // the argument is whatever a player typed: '/spawn -3' must not mean
16
+ // zero bots, and '/spawn 1e9' must not mean a billion loop iterations
17
+ const { maxPlayers } = ctx.participants;
18
+ const requested = Math.max(1, Math.trunc(Number(args[0])) || 1);
19
+ const count = maxPlayers > 0 ? Math.min(requested, maxPlayers) : requested;
20
+ const created = ctx.scripted.createScripted(count);
21
+
22
+ // the code is the game's own (src/host/systemMessages.js); the TEXT lives
23
+ // on the client, in modules.chat.params.messages
24
+ ctx.chat.pushSystem('BOTS_SPAWNED', [created]);
25
+
26
+ // restart the round so the fresh bots enter the world at once instead of
27
+ // waiting out the current one as corpses — pointless if nobody was added
28
+ if (created > 0) {
29
+ ctx.roundManager.initiateNewRound();
30
+ }
31
+ },
32
+ };