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.
- package/bin/create-vimp-game.js +15 -0
- package/package.json +40 -0
- package/src/cli.js +227 -0
- package/src/generator.js +196 -0
- package/src/preflight.js +48 -0
- package/src/prompts.js +79 -0
- package/src/tokens.js +99 -0
- package/src/ui.js +66 -0
- package/src/versions.generated.json +4 -0
- package/src/versions.js +87 -0
- package/templates/default/CLAUDE.md.tpl +51 -0
- package/templates/default/Cargo.toml.tpl +27 -0
- package/templates/default/LICENSE.tpl +21 -0
- package/templates/default/README.md.tpl +61 -0
- package/templates/default/_gitignore +8 -0
- package/templates/default/assets/audio-raw/death.wav +0 -0
- package/templates/default/assets/audio-raw/shot.wav +0 -0
- package/templates/default/assets/sounds/death.mp3 +0 -0
- package/templates/default/assets/sounds/death.webm +0 -0
- package/templates/default/assets/sounds/shot.mp3 +0 -0
- package/templates/default/assets/sounds/shot.webm +0 -0
- package/templates/default/core/Cargo.toml.tpl +19 -0
- package/templates/default/core/src/actor.rs +407 -0
- package/templates/default/core/src/body_tag.rs +56 -0
- package/templates/default/core/src/client/mod.rs +385 -0
- package/templates/default/core/src/client/predictor.rs +552 -0
- package/templates/default/core/src/config.rs +236 -0
- package/templates/default/core/src/game.rs +692 -0
- package/templates/default/core/src/lib.rs +118 -0
- package/templates/default/core/src/motion.rs +126 -0
- package/templates/default/core/tests/sim.rs +351 -0
- package/templates/default/dev/main.js +28 -0
- package/templates/default/eslint.config.js +84 -0
- package/templates/default/index.html.tpl +35 -0
- package/templates/default/package.json.tpl +48 -0
- package/templates/default/scripts/build-game-manifest.js +188 -0
- package/templates/default/scripts/copy-game-images.js +35 -0
- package/templates/default/scripts/copy-game-sounds.js +55 -0
- package/templates/default/scripts/export-maps.js +19 -0
- package/templates/default/scripts/lib/rangeToPattern.js +74 -0
- package/templates/default/scripts/process-audio.js +115 -0
- package/templates/default/src/client/bakers/actorTexture.js +40 -0
- package/templates/default/src/client/bakers/index.js +8 -0
- package/templates/default/src/client/index.js +67 -0
- package/templates/default/src/client/parts/Actor.js +69 -0
- package/templates/default/src/client/parts/Map.js +74 -0
- package/templates/default/src/client/parts/ShotEffect.js +107 -0
- package/templates/default/src/client/parts/index.js +13 -0
- package/templates/default/src/client/style.css +26 -0
- package/templates/default/src/config/auth.js +61 -0
- package/templates/default/src/config/client.js +195 -0
- package/templates/default/src/config/game.js +170 -0
- package/templates/default/src/config/snapshot.js +70 -0
- package/templates/default/src/config/sounds.js +17 -0
- package/templates/default/src/data/maps/arena.js +69 -0
- package/templates/default/src/data/maps/index.js +7 -0
- package/templates/default/src/data/models.js +39 -0
- package/templates/default/src/data/weapons.js +37 -0
- package/templates/default/src/host/ScriptedManager.js +142 -0
- package/templates/default/src/host/createModules.js +12 -0
- package/templates/default/src/host/index.js +56 -0
- package/templates/default/src/host/nodeCore.js +23 -0
- package/templates/default/src/host/spawnCommand.js +32 -0
- package/templates/default/src/host/systemMessages.js +10 -0
- package/templates/default/tests/client/parts.test.js +128 -0
- package/templates/default/tests/config/contract.test.js +132 -0
- package/templates/default/tests/config/game.test.js +45 -0
- package/templates/default/tests/core/nodeCore.test.js +61 -0
- package/templates/default/tests/host/hostPlugin.test.js +198 -0
- package/templates/default/tests/stubs/wasmCore.js +19 -0
- package/templates/default/vite.config.js +74 -0
- package/templates/default/vitest.config.js +55 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// System chat codes of the game. A code is 'group:index' and carries no text:
|
|
2
|
+
// the text lives on the client, at the same index of
|
|
3
|
+
// modules.chat.params.messages[group] (src/config/client.js).
|
|
4
|
+
//
|
|
5
|
+
// Groups s, v, m, c and n belong to the engine. Registration is a blind
|
|
6
|
+
// Object.assign into its registry, so a code in one of them replaces an engine
|
|
7
|
+
// message with no warning at all — the game picks its own letter instead.
|
|
8
|
+
export default {
|
|
9
|
+
BOTS_SPAWNED: 'g:0', // client text: '{0} bot(s) spawned'
|
|
10
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Texture } from 'pixi.js';
|
|
3
|
+
import parts from '../../src/client/parts/index.js';
|
|
4
|
+
import actorTexture from '../../src/client/bakers/actorTexture.js';
|
|
5
|
+
import mapData from '../../src/data/maps/arena.js';
|
|
6
|
+
|
|
7
|
+
// Parts are constructed inside the render tick, on a path with no try/catch
|
|
8
|
+
// anywhere: an exception in a constructor aborts the whole frame, so every
|
|
9
|
+
// entity of that frame is lost with it. These cases only build them — no
|
|
10
|
+
// WebGL context is created in happy-dom.
|
|
11
|
+
const { Map: MapPart, Actor, ShotEffect } = parts;
|
|
12
|
+
|
|
13
|
+
// [x, y, angle, vx, vy, health, team] — the a1 block of src/config/snapshot.js
|
|
14
|
+
const actorRow = [100, 200, 1.5, 0, 0, 100, 1];
|
|
15
|
+
|
|
16
|
+
describe('Actor', () => {
|
|
17
|
+
const assets = { actorTexture: Texture.EMPTY };
|
|
18
|
+
|
|
19
|
+
it('places itself by the snapshot row', () => {
|
|
20
|
+
const actor = new Actor(actorRow, assets);
|
|
21
|
+
|
|
22
|
+
expect(actor.x).toBe(100);
|
|
23
|
+
expect(actor.y).toBe(200);
|
|
24
|
+
expect(actor.zIndex).toBe(3);
|
|
25
|
+
|
|
26
|
+
actor.update([10, 20, 0, 0, 0, 50, 2]);
|
|
27
|
+
|
|
28
|
+
expect(actor.x).toBe(10);
|
|
29
|
+
expect(actor.y).toBe(20);
|
|
30
|
+
|
|
31
|
+
actor.destroy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('tints the two teams differently', () => {
|
|
35
|
+
const one = new Actor(actorRow, assets);
|
|
36
|
+
const two = new Actor([0, 0, 0, 0, 0, 100, 2], assets);
|
|
37
|
+
|
|
38
|
+
expect(one.children[0].tint).not.toBe(two.children[0].tint);
|
|
39
|
+
|
|
40
|
+
one.destroy();
|
|
41
|
+
two.destroy();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('Map', () => {
|
|
46
|
+
it('draws the tiles of its own layer', () => {
|
|
47
|
+
const [layer, tiles] = Object.entries(mapData.layers)[0];
|
|
48
|
+
const part = new MapPart({
|
|
49
|
+
type: 'static',
|
|
50
|
+
map: mapData.map,
|
|
51
|
+
step: mapData.step,
|
|
52
|
+
layer,
|
|
53
|
+
tiles,
|
|
54
|
+
physicsStatic: mapData.physicsStatic,
|
|
55
|
+
scale: mapData.scale,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(part.zIndex).toBe(Number(layer));
|
|
59
|
+
expect(part.children.length).toBe(1);
|
|
60
|
+
|
|
61
|
+
part.destroy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('survives a dynamic body it has nothing to draw for', () => {
|
|
65
|
+
const part = new MapPart({ type: 'dynamic', layer: 2, angle: 0 });
|
|
66
|
+
|
|
67
|
+
expect(part.children.length).toBe(0);
|
|
68
|
+
|
|
69
|
+
part.destroy();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('ShotEffect', () => {
|
|
74
|
+
// [startX, startY, endX, endY, wasHit, author] — the e1 block
|
|
75
|
+
const row = [0, 0, 100, 0, 1, 7];
|
|
76
|
+
|
|
77
|
+
it('plays a positional sound and releases it on destroy', () => {
|
|
78
|
+
const soundId = Symbol('shot');
|
|
79
|
+
const soundManager = {
|
|
80
|
+
registerSound: vi.fn(() => soundId),
|
|
81
|
+
releaseSound: vi.fn(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const effect = new ShotEffect(row, {}, { soundManager });
|
|
85
|
+
|
|
86
|
+
effect.run();
|
|
87
|
+
|
|
88
|
+
expect(soundManager.registerSound).toHaveBeenCalledWith('shot', {
|
|
89
|
+
position: { x: 0, y: 0 },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
effect.destroy();
|
|
93
|
+
|
|
94
|
+
expect(soundManager.releaseSound).toHaveBeenCalledWith(soundId);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('destroys itself once its lifetime is over', () => {
|
|
98
|
+
const effect = new ShotEffect(row, {}, { soundManager: null });
|
|
99
|
+
|
|
100
|
+
effect.run();
|
|
101
|
+
// the engine drives this through Ticker.shared; a direct call keeps the
|
|
102
|
+
// test free of real time
|
|
103
|
+
effect._update(1000);
|
|
104
|
+
|
|
105
|
+
expect(effect.destroyed).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// the effect destroys itself from the ticker, so the scene tearing down
|
|
109
|
+
// afterwards calls destroy() a second time
|
|
110
|
+
it('survives a second destroy()', () => {
|
|
111
|
+
const effect = new ShotEffect(row, {}, { soundManager: null });
|
|
112
|
+
|
|
113
|
+
effect.run();
|
|
114
|
+
effect._update(1000);
|
|
115
|
+
|
|
116
|
+
expect(() => effect.destroy()).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('actorTexture baker', () => {
|
|
121
|
+
it('bakes through the renderer it is given', () => {
|
|
122
|
+
const renderer = { generateTexture: vi.fn(() => Texture.EMPTY) };
|
|
123
|
+
const texture = actorTexture({ size: 32, color: 0xffffff }, renderer);
|
|
124
|
+
|
|
125
|
+
expect(renderer.generateTexture).toHaveBeenCalled();
|
|
126
|
+
expect(texture).toBe(Texture.EMPTY);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ENGINE_API_VERSION } from 'vimp-engine/config/opcodes.js';
|
|
3
|
+
import { assertGameConfigShape } from 'vimp-engine/lib/gamePlugin.js';
|
|
4
|
+
import hostPlugin from '../../src/host/index.js';
|
|
5
|
+
import clientPlugin from '../../src/client/index.js';
|
|
6
|
+
|
|
7
|
+
// Local safety net over `npm run check:contract`: the engine-side validator
|
|
8
|
+
// is the source of truth, but it runs as a separate command — these cases
|
|
9
|
+
// fail the ordinary test run, in the same change that broke them.
|
|
10
|
+
const clientConfig = hostPlugin.buildClientGameConfig();
|
|
11
|
+
|
|
12
|
+
describe('game config', () => {
|
|
13
|
+
it('passes the engine gate that runs on plugin load', () => {
|
|
14
|
+
expect(() => assertGameConfigShape(hostPlugin)).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('declares one API version in both halves', () => {
|
|
18
|
+
expect(hostPlugin.engineApi).toBe(ENGINE_API_VERSION);
|
|
19
|
+
expect(clientPlugin.engineApi).toBe(ENGINE_API_VERSION);
|
|
20
|
+
expect(hostPlugin.id).toBe(clientPlugin.id);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('snapshot schema', () => {
|
|
25
|
+
const { snapshot } = hostPlugin.gameConfig;
|
|
26
|
+
|
|
27
|
+
it('gives every block a unique id', () => {
|
|
28
|
+
const ids = Object.values(snapshot).map(block => block.id);
|
|
29
|
+
|
|
30
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps class 'hot' on indexed blocks only", () => {
|
|
34
|
+
for (const [key, block] of Object.entries(snapshot)) {
|
|
35
|
+
if (block.class === 'hot') {
|
|
36
|
+
expect(['indexed8', 'indexedNoNull8'], key).toContain(block.kind);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('interpolates f32 fields of hot blocks only', () => {
|
|
42
|
+
for (const block of Object.values(snapshot)) {
|
|
43
|
+
for (const field of block.fields) {
|
|
44
|
+
if (field.interp !== undefined) {
|
|
45
|
+
expect(block.class).toBe('hot');
|
|
46
|
+
expect(field.ty).toBe('f32');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('keys every block after a model, a weapon or a map set', () => {
|
|
53
|
+
const { models, weapons } = hostPlugin.gameConfig.parts;
|
|
54
|
+
const setIds = new Set(
|
|
55
|
+
Object.values(hostPlugin.gameConfig.maps).map(
|
|
56
|
+
map => map.setId ?? hostPlugin.gameConfig.mapSetId,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// the core builds a block per model and per weapon under their own names,
|
|
61
|
+
// and the engine one for the dynamic map bodies under the map setId — a
|
|
62
|
+
// block keyed by none of them is one nothing ever fills, and a MISSING one
|
|
63
|
+
// makes the packer reject the frame
|
|
64
|
+
for (const key of Object.keys(snapshot)) {
|
|
65
|
+
expect(key in models || key in weapons || setIds.has(key), key).toBe(true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const setId of setIds) {
|
|
69
|
+
expect(Object.keys(snapshot), setId).toContain(setId);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('client config', () => {
|
|
75
|
+
const { gameSets, entitiesOnCanvas, bakedAssets } = clientConfig.parts;
|
|
76
|
+
|
|
77
|
+
it('has a gameSets entry for every snapshot key and map setId', () => {
|
|
78
|
+
for (const key of Object.keys(hostPlugin.gameConfig.snapshot)) {
|
|
79
|
+
expect(Object.keys(gameSets)).toContain(key);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const map of Object.values(hostPlugin.gameConfig.maps)) {
|
|
83
|
+
expect(Object.keys(gameSets)).toContain(
|
|
84
|
+
map.setId ?? hostPlugin.gameConfig.mapSetId,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('registers every part of a set on a canvas and exports its class', () => {
|
|
90
|
+
for (const names of Object.values(gameSets)) {
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
expect(entitiesOnCanvas[name], name).toBeDefined();
|
|
93
|
+
expect(clientPlugin.parts[name], name).toBeDefined();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('bakes only assets that have a baker', () => {
|
|
99
|
+
for (const entries of Object.values(bakedAssets)) {
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
expect(clientPlugin.bakers[entry.name], entry.name).toBeDefined();
|
|
102
|
+
expect(entitiesOnCanvas[entry.component], entry.component).toBeDefined();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('binds exactly the player keys the host declares', () => {
|
|
108
|
+
const bound = new Set(
|
|
109
|
+
Object.values(clientConfig.modules.controls.keySetList[1]),
|
|
110
|
+
);
|
|
111
|
+
const declared = new Set(Object.keys(hostPlugin.gameConfig.playerKeys));
|
|
112
|
+
|
|
113
|
+
expect([...bound].sort()).toEqual([...declared].sort());
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("maps the engine time key 't' to a time field", () => {
|
|
117
|
+
const { keys, fields } = clientConfig.modules.panel;
|
|
118
|
+
const field = fields.find(item => item.name === keys.t);
|
|
119
|
+
|
|
120
|
+
expect(field?.type).toBe('time');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('gives every system message code a text', () => {
|
|
124
|
+
const texts = clientConfig.modules.chat.params.messages;
|
|
125
|
+
|
|
126
|
+
for (const code of Object.values(hostPlugin.systemMessages)) {
|
|
127
|
+
const [group, index] = code.split(':');
|
|
128
|
+
|
|
129
|
+
expect(texts[group]?.[Number(index)], code).toBeDefined();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import gameConfig from '../../src/config/game.js';
|
|
3
|
+
import maps from '../../src/data/maps/index.js';
|
|
4
|
+
|
|
5
|
+
// Starter test of the config invariants the build itself depends on. Keep it
|
|
6
|
+
// growing with the config: a silent mismatch here surfaces as a game missing
|
|
7
|
+
// from the lobby, not as a stack trace.
|
|
8
|
+
describe('gameConfig', () => {
|
|
9
|
+
it('names an existing current map', () => {
|
|
10
|
+
expect(Object.keys(maps)).toContain(gameConfig.currentMap);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('has a spectator team among the teams', () => {
|
|
14
|
+
expect(gameConfig.teams).toHaveProperty(gameConfig.spectatorTeam);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('gives every playing team respawns for a full room', () => {
|
|
18
|
+
const playing = Object.keys(gameConfig.teams).filter(
|
|
19
|
+
team => team !== gameConfig.spectatorTeam,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
for (const map of Object.values(maps)) {
|
|
23
|
+
for (const team of playing) {
|
|
24
|
+
expect(map.respawns[team]?.length ?? 0).toBeGreaterThanOrEqual(
|
|
25
|
+
gameConfig.roomDefaults.maxPlayers,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('builds the room form out of the fields the host honours', () => {
|
|
32
|
+
const honoured = [
|
|
33
|
+
'maps',
|
|
34
|
+
'maxPlayers',
|
|
35
|
+
'map',
|
|
36
|
+
'roundTime',
|
|
37
|
+
'mapTime',
|
|
38
|
+
'friendlyFire',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
for (const field of gameConfig.roomForm) {
|
|
42
|
+
expect(honoured).toContain(field.name);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { buildCoreConfig } from 'vimp-engine/lib/coreConfig.js';
|
|
5
|
+
import gameConfig from '../../src/config/game.js';
|
|
6
|
+
import mapData from '../../src/data/maps/arena.js';
|
|
7
|
+
|
|
8
|
+
// The `integration` project (node environment): the REAL Rust core, driven
|
|
9
|
+
// through the --target nodejs build. That build is not part of `npm test`, so
|
|
10
|
+
// without `npm run core:build:node` there is nothing to import and the whole
|
|
11
|
+
// file is skipped — a fresh checkout must not fail here, it must tell you the
|
|
12
|
+
// core is missing.
|
|
13
|
+
const gluePath = fileURLToPath(
|
|
14
|
+
new URL('../../core/pkg-node/{{CRATE_SNAKE}}.js', import.meta.url),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const hasCore = existsSync(gluePath);
|
|
18
|
+
|
|
19
|
+
describe.skipIf(!hasCore)('the Rust core through pkg-node', () => {
|
|
20
|
+
it('boots from the config the engine assembles', async () => {
|
|
21
|
+
const { GameCore } = await import(gluePath);
|
|
22
|
+
const core = new GameCore(JSON.stringify(buildCoreConfig(gameConfig)));
|
|
23
|
+
|
|
24
|
+
expect(core).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('simulates one actor for a second of steps', async () => {
|
|
28
|
+
const { GameCore } = await import(gluePath);
|
|
29
|
+
const core = new GameCore(
|
|
30
|
+
// a fixed seed: two runs of this test must produce the same match
|
|
31
|
+
JSON.stringify(buildCoreConfig(gameConfig, { seed: 1 })),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
core.load_map(JSON.stringify(mapData));
|
|
35
|
+
|
|
36
|
+
const [x, y, angle] = mapData.respawns.team1[0];
|
|
37
|
+
|
|
38
|
+
core.spawn_actor(1, 'a1', 1, x, y, angle);
|
|
39
|
+
|
|
40
|
+
expect(core.is_alive(1)).toBe(true);
|
|
41
|
+
|
|
42
|
+
core.apply_input(1, 1, 'down', 'forward');
|
|
43
|
+
|
|
44
|
+
// 120 steps of 1/120 s — the engine's own fixed step
|
|
45
|
+
for (let i = 0; i < 120; i += 1) {
|
|
46
|
+
core.step(1 / 120);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const moved = core.position_of(1);
|
|
50
|
+
|
|
51
|
+
// it drove forward: the angle of respawn 0 is +X, and the wall is far
|
|
52
|
+
expect(moved[0]).toBeGreaterThan(x);
|
|
53
|
+
expect(Math.abs(moved[1] - y)).toBeLessThan(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe.skipIf(hasCore)('the Rust core', () => {
|
|
58
|
+
it('is not built — run `npm run core:build:node`', () => {
|
|
59
|
+
expect(hasCore).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import hostPlugin from '../../src/host/index.js';
|
|
3
|
+
import ScriptedManager from '../../src/host/ScriptedManager.js';
|
|
4
|
+
import spawnCommand from '../../src/host/spawnCommand.js';
|
|
5
|
+
import mapData from '../../src/data/maps/arena.js';
|
|
6
|
+
|
|
7
|
+
// The engine dereferences the fields below without a guard and calls the five
|
|
8
|
+
// scripted methods by name — both are contracts no browser run announces
|
|
9
|
+
// before it crashes.
|
|
10
|
+
describe('HostPlugin surface', () => {
|
|
11
|
+
it('exports every field the engine reads', () => {
|
|
12
|
+
expect(typeof hostPlugin.id).toBe('string');
|
|
13
|
+
expect(typeof hostPlugin.engineApi).toBe('number');
|
|
14
|
+
expect(typeof hostPlugin.createCore).toBe('function');
|
|
15
|
+
expect(typeof hostPlugin.gameConfig).toBe('object');
|
|
16
|
+
expect(typeof hostPlugin.authSchema).toBe('object');
|
|
17
|
+
expect(typeof hostPlugin.createModules).toBe('function');
|
|
18
|
+
expect(typeof hostPlugin.buildClientGameConfig).toBe('function');
|
|
19
|
+
expect(Array.isArray(hostPlugin.chatCommands)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not shadow an engine chat command', () => {
|
|
23
|
+
const reserved = ['/name', '/nr', '/timeleft', '/mapname', '/rank'];
|
|
24
|
+
|
|
25
|
+
for (const command of hostPlugin.chatCommands) {
|
|
26
|
+
expect(command.name.startsWith('/')).toBe(true);
|
|
27
|
+
expect(reserved).not.toContain(command.name);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('keeps its system message codes out of the engine groups', () => {
|
|
32
|
+
// engine groups and their last index
|
|
33
|
+
const reserved = { s: 6, v: 5, m: 1, c: 1, n: 1 };
|
|
34
|
+
|
|
35
|
+
for (const code of Object.values(hostPlugin.systemMessages)) {
|
|
36
|
+
const [group, index] = code.split(':');
|
|
37
|
+
|
|
38
|
+
expect(Number(index) > (reserved[group] ?? -1)).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Minimal doubles of the engine modules: the manager only ever talks to these
|
|
44
|
+
// five, and a real one would drag the whole host runtime into a unit test.
|
|
45
|
+
function createContext({ teamSizes = { team1: 0, team2: 0 }, isFull = false }) {
|
|
46
|
+
const created = [];
|
|
47
|
+
const sizes = { ...teamSizes };
|
|
48
|
+
let nextId = 10;
|
|
49
|
+
|
|
50
|
+
const participants = {
|
|
51
|
+
isFull,
|
|
52
|
+
getPlayableTeams: () => Object.keys(sizes),
|
|
53
|
+
getTeamSize: team => sizes[team] ?? 0,
|
|
54
|
+
createScripted: ({ team, model }) => {
|
|
55
|
+
const gameId = (nextId += 1);
|
|
56
|
+
|
|
57
|
+
created.push({ gameId, team, model, isScripted: true, teamId: 1 });
|
|
58
|
+
sizes[team] += 1;
|
|
59
|
+
|
|
60
|
+
return gameId;
|
|
61
|
+
},
|
|
62
|
+
get: gameId => created.find(item => item.gameId === gameId),
|
|
63
|
+
getScripted: () => created,
|
|
64
|
+
remove: gameId => {
|
|
65
|
+
const index = created.findIndex(item => item.gameId === gameId);
|
|
66
|
+
|
|
67
|
+
if (index !== -1) {
|
|
68
|
+
sizes[created[index].team] -= 1;
|
|
69
|
+
created.splice(index, 1);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
participants,
|
|
76
|
+
coreAdapter: { removePlayer: vi.fn() },
|
|
77
|
+
panel: { addUser: vi.fn(), removeUser: vi.fn() },
|
|
78
|
+
stat: { addUser: vi.fn(), removeUser: vi.fn() },
|
|
79
|
+
scripted: hostPlugin.gameConfig.scripted,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('ScriptedManager', () => {
|
|
84
|
+
let ctx;
|
|
85
|
+
let manager;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
ctx = createContext({});
|
|
89
|
+
manager = new ScriptedManager(ctx);
|
|
90
|
+
manager.createMap(mapData);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('creates nothing before a map is known', () => {
|
|
94
|
+
const fresh = new ScriptedManager(createContext({}));
|
|
95
|
+
|
|
96
|
+
expect(fresh.createScripted(2)).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('spreads bots over the emptiest team', () => {
|
|
100
|
+
expect(manager.createScripted(4)).toBe(4);
|
|
101
|
+
expect(manager.getCountsPerTeam()).toEqual({ team1: 2, team2: 2 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('stops at the respawn capacity of a team', () => {
|
|
105
|
+
const capacity = mapData.respawns.team1.length;
|
|
106
|
+
|
|
107
|
+
expect(manager.createScripted(capacity + 3, 'team1')).toBe(capacity);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// the emptiest team can be the one out of respawn points; before the
|
|
111
|
+
// fallback that case burned every iteration and created nobody
|
|
112
|
+
it('falls back to a team that still has respawn points', () => {
|
|
113
|
+
const capacity = mapData.respawns.team1.length;
|
|
114
|
+
const crowded = new ScriptedManager(
|
|
115
|
+
createContext({ teamSizes: { team1: capacity, team2: 0 } }),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
crowded.createMap(mapData);
|
|
119
|
+
|
|
120
|
+
expect(crowded.createScripted(1)).toBe(1);
|
|
121
|
+
expect(crowded.getCountsPerTeam()).toEqual({ team2: 1 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('creates nothing once the room is full', () => {
|
|
125
|
+
const full = new ScriptedManager(createContext({ isFull: true }));
|
|
126
|
+
|
|
127
|
+
full.createMap(mapData);
|
|
128
|
+
|
|
129
|
+
expect(full.createScripted(3)).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('frees exactly one slot for a human', () => {
|
|
133
|
+
manager.createScripted(2, 'team1');
|
|
134
|
+
|
|
135
|
+
expect(manager.removeOneForHuman('team1')).toBe(true);
|
|
136
|
+
expect(manager.getCountsPerTeam()).toEqual({ team1: 1 });
|
|
137
|
+
expect(manager.removeOneForHuman('team2')).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('removes bots of one team or of all of them', () => {
|
|
141
|
+
manager.createScripted(2, 'team1');
|
|
142
|
+
manager.createScripted(1, 'team2');
|
|
143
|
+
|
|
144
|
+
manager.removeScripted('team1');
|
|
145
|
+
expect(manager.getCountsPerTeam()).toEqual({ team2: 1 });
|
|
146
|
+
|
|
147
|
+
manager.removeScripted();
|
|
148
|
+
expect(manager.getCountsPerTeam()).toEqual({});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('/spawn', () => {
|
|
153
|
+
const spawnContext = (created, maxPlayers = 16) => ({
|
|
154
|
+
chat: { pushSystem: vi.fn() },
|
|
155
|
+
roundManager: { initiateNewRound: vi.fn() },
|
|
156
|
+
participants: { maxPlayers },
|
|
157
|
+
scripted: { createScripted: vi.fn(() => created) },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reports the number actually created and restarts the round', () => {
|
|
161
|
+
const ctx = spawnContext(2);
|
|
162
|
+
|
|
163
|
+
spawnCommand.handler(ctx, 1, ['3']);
|
|
164
|
+
|
|
165
|
+
expect(ctx.scripted.createScripted).toHaveBeenCalledWith(3);
|
|
166
|
+
expect(ctx.chat.pushSystem).toHaveBeenCalledWith('BOTS_SPAWNED', [2]);
|
|
167
|
+
expect(ctx.roundManager.initiateNewRound).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('defaults to a single bot', () => {
|
|
171
|
+
const ctx = spawnContext(1);
|
|
172
|
+
|
|
173
|
+
spawnCommand.handler(ctx, 1, []);
|
|
174
|
+
|
|
175
|
+
expect(ctx.scripted.createScripted).toHaveBeenCalledWith(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// the argument comes straight from a chat line
|
|
179
|
+
it('clamps the count to [1, maxPlayers]', () => {
|
|
180
|
+
const negative = spawnContext(1);
|
|
181
|
+
const huge = spawnContext(1);
|
|
182
|
+
|
|
183
|
+
spawnCommand.handler(negative, 1, ['-3']);
|
|
184
|
+
spawnCommand.handler(huge, 1, ['1e9']);
|
|
185
|
+
|
|
186
|
+
expect(negative.scripted.createScripted).toHaveBeenCalledWith(1);
|
|
187
|
+
expect(huge.scripted.createScripted).toHaveBeenCalledWith(16);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not restart the round when nothing was created', () => {
|
|
191
|
+
const ctx = spawnContext(0);
|
|
192
|
+
|
|
193
|
+
spawnCommand.handler(ctx, 1, ['3']);
|
|
194
|
+
|
|
195
|
+
expect(ctx.chat.pushSystem).toHaveBeenCalledWith('BOTS_SPAWNED', [0]);
|
|
196
|
+
expect(ctx.roundManager.initiateNewRound).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Stand-in for core/pkg-web/ in the unit tests (see the alias in
|
|
2
|
+
// vitest.config.js). The web build of the core is a product of
|
|
3
|
+
// `npm run core:build:web`; a unit test never instantiates it, but Vite
|
|
4
|
+
// resolves the dynamic import of src/host/nodeCore.js at transform time, so
|
|
5
|
+
// without this module every test that touches a plugin half would fail in a
|
|
6
|
+
// fresh checkout with "Failed to resolve import".
|
|
7
|
+
//
|
|
8
|
+
// It throws rather than pretending to be a core: a test that DOES need the
|
|
9
|
+
// simulation belongs in tests/core/, against the real Node build.
|
|
10
|
+
const notBuilt = () => {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'the WASM core is not available in unit tests — run `npm run core:build` ' +
|
|
13
|
+
'and use tests/core/ (the integration project) for the real thing',
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default notBuilt;
|
|
18
|
+
export const GameCore = notBuilt;
|
|
19
|
+
export const ClientCore = notBuilt;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Three modes in one file (docs/ai/02-packaging.md):
|
|
5
|
+
//
|
|
6
|
+
// vite — dev harness of `npm run dev`: index.html -> dev/main.js,
|
|
7
|
+
// a match against bots in the tab, no master;
|
|
8
|
+
// vite build --mode client|host
|
|
9
|
+
// — the published halves, built by TWO independent runs:
|
|
10
|
+
// a chunk shared between them would drag DOM code
|
|
11
|
+
// (PixiJS) into the Worker-safe host bundle.
|
|
12
|
+
//
|
|
13
|
+
// PixiJS is a singleton supplied by the engine through an import map, so it
|
|
14
|
+
// stays external in the builds and deduped in dev — two PixiJS copies mean
|
|
15
|
+
// two extension registries, and objects of one copy fail inside the other.
|
|
16
|
+
const entries = {
|
|
17
|
+
client: path.resolve(import.meta.dirname, 'src/client/index.js'),
|
|
18
|
+
host: path.resolve(import.meta.dirname, 'src/host/index.js'),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default defineConfig(({ command, mode }) => {
|
|
22
|
+
if (command === 'serve') {
|
|
23
|
+
return {
|
|
24
|
+
server: {
|
|
25
|
+
open: true,
|
|
26
|
+
// `npm link vimp-engine` resolves the engine outside this package:
|
|
27
|
+
// without the allowance Vite refuses to serve its sources
|
|
28
|
+
fs: { allow: ['..'] },
|
|
29
|
+
},
|
|
30
|
+
resolve: { dedupe: ['pixi.js'] },
|
|
31
|
+
optimizeDeps: {
|
|
32
|
+
// the engine ships ESM sources: pre-bundling breaks its dynamic
|
|
33
|
+
// imports and the boot config it shares with the SDK
|
|
34
|
+
exclude: ['vimp-engine'],
|
|
35
|
+
// ...but its npm dependencies must stay pre-bundled: as transitive
|
|
36
|
+
// imports of an excluded package they would be served as CJS
|
|
37
|
+
// sources, which the browser cannot resolve
|
|
38
|
+
include: ['pixi.js', 'pixi.js/unsafe-eval', 'howler'],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entry = entries[mode];
|
|
44
|
+
|
|
45
|
+
if (!entry) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`{{GAME_ID}} build: unknown --mode "${mode}" (expected "client" or "host")`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
build: {
|
|
53
|
+
outDir: 'dist',
|
|
54
|
+
// both runs write into the same dist/ — the second must not wipe the first
|
|
55
|
+
emptyOutDir: false,
|
|
56
|
+
// the .wasm must stay a separate asset with a URL: base64 inlining costs
|
|
57
|
+
// +33 %, breaks instantiateStreaming and duplicates the binary in both
|
|
58
|
+
// bundles. build.lib is not used at all — it inlines assets regardless.
|
|
59
|
+
assetsInlineLimit: 0,
|
|
60
|
+
rollupOptions: {
|
|
61
|
+
input: entry,
|
|
62
|
+
// without it Vite tree-shakes the entry's default export (the plugin)
|
|
63
|
+
preserveEntrySignatures: 'strict',
|
|
64
|
+
external: [/^pixi\.js(\/.*)?$/],
|
|
65
|
+
output: {
|
|
66
|
+
format: 'es',
|
|
67
|
+
entryFileNames: `${mode}-[hash].js`,
|
|
68
|
+
assetFileNames: 'assets/[name]-[hash][extname]',
|
|
69
|
+
inlineDynamicImports: true,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
});
|