@loonylabs/create-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-game.js +213 -0
- package/package.json +18 -0
- package/template/.claude/skills/aigdtk-create-game-stories/SKILL.md +177 -0
- package/template/.claude/skills/aigdtk-create-game-stories/story-template.md +85 -0
- package/template/.claude/skills/aigdtk-implement-game-stories/SKILL.md +129 -0
- package/template/.claude/skills/aigdtk-new-game/SKILL.md +126 -0
- package/template/.claude/skills/aigdtk-shared/ascii-grammar.md +133 -0
- package/template/.claude/skills/aigdtk-shared/enemies.md +112 -0
- package/template/.claude/skills/aigdtk-shared/framework.md +93 -0
- package/template/.claude/skills/aigdtk-shared/visuals.md +125 -0
- package/template/apps/client/index.html +14 -0
- package/template/apps/client/package.json +31 -0
- package/template/apps/client/public/assets/audio/enemy_killed.wav +0 -0
- package/template/apps/client/src/components/App.svelte +290 -0
- package/template/apps/client/src/components/CraftingPanel.svelte +253 -0
- package/template/apps/client/src/components/DevPanel.svelte +180 -0
- package/template/apps/client/src/components/DungeonClearOverlay.svelte +53 -0
- package/template/apps/client/src/components/EquipmentPanel.svelte +191 -0
- package/template/apps/client/src/components/HealthBar.svelte +50 -0
- package/template/apps/client/src/components/Hub/BackToHubButton.svelte +37 -0
- package/template/apps/client/src/components/Hub/ExperienceCard.svelte +115 -0
- package/template/apps/client/src/components/Hub/Hub.svelte +88 -0
- package/template/apps/client/src/components/Inventory.svelte +174 -0
- package/template/apps/client/src/components/Runner/RunnerDeathScreen.svelte +182 -0
- package/template/apps/client/src/components/Runner/RunnerHUD.svelte +157 -0
- package/template/apps/client/src/components/Shooter/DamageNumbers.svelte +96 -0
- package/template/apps/client/src/components/Shooter/GameOverScreen.svelte +109 -0
- package/template/apps/client/src/components/Shooter/ShooterHUD.svelte +95 -0
- package/template/apps/client/src/components/SkillBar.svelte +146 -0
- package/template/apps/client/src/components/ToastSystem.svelte +158 -0
- package/template/apps/client/src/game.ts +918 -0
- package/template/apps/client/src/input.ts +92 -0
- package/template/apps/client/src/lib/audio/CombatDetector.test.ts +59 -0
- package/template/apps/client/src/lib/audio/CombatDetector.ts +53 -0
- package/template/apps/client/src/lib/audio/MusicManager.ts +137 -0
- package/template/apps/client/src/lib/audio/SoundManager.ts +59 -0
- package/template/apps/client/src/lib/audio/index.ts +9 -0
- package/template/apps/client/src/main.ts +32 -0
- package/template/apps/client/src/renderer/basecamp.ts +126 -0
- package/template/apps/client/src/renderer/dungeon.ts +250 -0
- package/template/apps/client/src/renderer/dungeonPortal.ts +73 -0
- package/template/apps/client/src/renderer/dungeonZone.ts +301 -0
- package/template/apps/client/src/renderer/entities.ts +197 -0
- package/template/apps/client/src/renderer/runnerTrack.ts +221 -0
- package/template/apps/client/src/renderer/shaders/SkyShader.ts +190 -0
- package/template/apps/client/src/renderer/shaders/TerrainShaderMaterial.ts +133 -0
- package/template/apps/client/src/renderer/shaders/floor.frag.glsl.ts +17 -0
- package/template/apps/client/src/renderer/shaders/shaderConfig.ts +18 -0
- package/template/apps/client/src/renderer/shaders/spawn.frag.glsl.ts +19 -0
- package/template/apps/client/src/renderer/shaders/terrain.frag.glsl.ts +314 -0
- package/template/apps/client/src/renderer/shaders/terrain.vert.glsl.ts +16 -0
- package/template/apps/client/src/renderer/shaders/wall.frag.glsl.ts +20 -0
- package/template/apps/client/src/renderer/shooterArena.ts +102 -0
- package/template/apps/client/src/renderer/voxelChunkStreamer.ts +79 -0
- package/template/apps/client/src/renderer/voxelMesh.ts +86 -0
- package/template/apps/client/src/renderer/voxelTerrain.ts +74 -0
- package/template/apps/client/src/socket.ts +268 -0
- package/template/apps/client/src/store.ts +74 -0
- package/template/apps/client/src/style.css +60 -0
- package/template/apps/client/tsconfig.json +11 -0
- package/template/apps/client/vite.config.ts +10 -0
- package/template/apps/client/vitest.config.ts +8 -0
- package/template/apps/experiences/diablo/index.ts +94 -0
- package/template/apps/experiences/diablo/systems/dungeonClearSystem.ts +60 -0
- package/template/apps/experiences/diablo/systems/enemyAISystem.ts +11 -0
- package/template/apps/experiences/diablo/systems/entitySyncSystem.ts +80 -0
- package/template/apps/experiences/diablo/systems/itemPickupSystem.ts +11 -0
- package/template/apps/experiences/diablo/systems/movementSystem.ts +13 -0
- package/template/apps/experiences/diablo/systems/physicsSystem.ts +92 -0
- package/template/apps/experiences/diablo/systems/transitionSystem.ts +105 -0
- package/template/apps/experiences/runner/data/runner-config.ts +54 -0
- package/template/apps/experiences/runner/index.ts +143 -0
- package/template/apps/experiences/runner/systems/collectibleSystem.ts +157 -0
- package/template/apps/experiences/runner/systems/deathSystem.ts +42 -0
- package/template/apps/experiences/runner/systems/entitySyncSystem.ts +59 -0
- package/template/apps/experiences/runner/systems/obstacleSystem.ts +91 -0
- package/template/apps/experiences/runner/systems/runnerPhysicsSystem.ts +82 -0
- package/template/apps/experiences/runner/systems/trackStreamSystem.ts +19 -0
- package/template/apps/experiences/runner/track/laneSystem.ts +53 -0
- package/template/apps/experiences/runner/track/segmentTypes.ts +141 -0
- package/template/apps/experiences/runner/track/trackGenerator.ts +292 -0
- package/template/apps/experiences/shooter/ai/aiStateMachine.ts +394 -0
- package/template/apps/experiences/shooter/ai/lineOfSight.ts +32 -0
- package/template/apps/experiences/shooter/arena/arenaGenerator.ts +101 -0
- package/template/apps/experiences/shooter/arena/arenaTypes.ts +49 -0
- package/template/apps/experiences/shooter/arena/hitscan.ts +101 -0
- package/template/apps/experiences/shooter/data/enemy-types.ts +108 -0
- package/template/apps/experiences/shooter/data/wave-definitions.ts +40 -0
- package/template/apps/experiences/shooter/data/weapon-config.ts +80 -0
- package/template/apps/experiences/shooter/index.ts +127 -0
- package/template/apps/experiences/shooter/systems/enemyAISystem.ts +113 -0
- package/template/apps/experiences/shooter/systems/entitySyncSystem.ts +68 -0
- package/template/apps/experiences/shooter/systems/shooterPhysicsSystem.ts +89 -0
- package/template/apps/experiences/shooter/systems/waveSpawnerSystem.ts +87 -0
- package/template/apps/experiences/shooter/systems/weaponSystem.ts +157 -0
- package/template/apps/game-data/src/areas/area-manifest.json +18 -0
- package/template/apps/game-data/src/assets/migration.test.ts +291 -0
- package/template/apps/game-data/src/audio/music-config.json +21 -0
- package/template/apps/game-data/src/audio/sound-config.json +11 -0
- package/template/apps/game-data/src/combat/action-types.ts +2 -0
- package/template/apps/game-data/src/combat/enemy-def.ts +12 -0
- package/template/apps/game-data/src/combat/hitboxes.ts +23 -0
- package/template/apps/game-data/src/dungeon/cell-types.ts +20 -0
- package/template/apps/game-data/src/dungeon/cell-visuals.ts +13 -0
- package/template/apps/game-data/src/dungeon/door-directions.ts +2 -0
- package/template/apps/game-data/src/enemies/enemy-defs.json +32 -0
- package/template/apps/game-data/src/equipment/slots.json +5 -0
- package/template/apps/game-data/src/events/event-defs.ts +20 -0
- package/template/apps/game-data/src/events/event-types.ts +10 -0
- package/template/apps/game-data/src/events/toast-config.json +49 -0
- package/template/apps/game-data/src/items/item-pool.json +13 -0
- package/template/apps/game-data/src/loot/item-pool.ts +14 -0
- package/template/apps/game-data/src/loot/rarities.ts +2 -0
- package/template/apps/game-data/src/physics/dungeon-physics-config.ts +12 -0
- package/template/apps/game-data/src/physics/jump-config.ts +17 -0
- package/template/apps/game-data/src/recipes/recipe-book.json +68 -0
- package/template/apps/game-data/src/rooms/room_basecamp.json +16 -0
- package/template/apps/game-data/src/rooms/room_corridor_ew.json +9 -0
- package/template/apps/game-data/src/rooms/room_corridor_ns.json +11 -0
- package/template/apps/game-data/src/rooms/room_crossroads.json +11 -0
- package/template/apps/game-data/src/rooms/room_dead_end.json +10 -0
- package/template/apps/game-data/src/rooms/room_staircase.json +12 -0
- package/template/apps/game-data/src/rooms/room_start.json +11 -0
- package/template/apps/game-data/src/skills/skill-book.json +20 -0
- package/template/apps/game-data/src/voxel/biome-terrain.ts +76 -0
- package/template/apps/game-data/src/voxel/materials.ts +45 -0
- package/template/apps/game-data/src/voxel/sandbox-terrain-config.ts +19 -0
- package/template/apps/game-data/src/world/area-config.ts +33 -0
- package/template/apps/game-data/src/world/biome-def.ts +15 -0
- package/template/apps/game-data/src/world/biomes.json +57 -0
- package/template/apps/game-data/src/world/movement.ts +2 -0
- package/template/apps/game-data/src/world/overworld-layout.test.ts +93 -0
- package/template/apps/game-data/src/world/overworld-layout.ts +127 -0
- package/template/apps/server/data/game.db +0 -0
- package/template/apps/server/package.json +30 -0
- package/template/apps/server/src/areaManager.ts +346 -0
- package/template/apps/server/src/db/client.ts +45 -0
- package/template/apps/server/src/db/schema.ts +40 -0
- package/template/apps/server/src/gameLoop.ts +267 -0
- package/template/apps/server/src/gameState.ts +3 -0
- package/template/apps/server/src/handlers/actionEvent.ts +55 -0
- package/template/apps/server/src/handlers/craftHandler.ts +59 -0
- package/template/apps/server/src/handlers/equipHandler.ts +73 -0
- package/template/apps/server/src/handlers/raycastHandler.ts +97 -0
- package/template/apps/server/src/handlers/skillHandler.ts +87 -0
- package/template/apps/server/src/handlers/terraformHandler.ts +74 -0
- package/template/apps/server/src/index.ts +597 -0
- package/template/apps/server/src/persistence.ts +135 -0
- package/template/apps/server/src/rooms.ts +20 -0
- package/template/apps/server/src/systems/dungeonPhysics.test.ts +32 -0
- package/template/apps/server/src/systems/dungeonPhysics.ts +16 -0
- package/template/apps/server/src/systems/enemyAI.ts +129 -0
- package/template/apps/server/src/systems/itemPickup.ts +31 -0
- package/template/apps/server/src/tests/areaManager.test.ts +77 -0
- package/template/apps/server/src/tests/diablo-experience.test.ts +60 -0
- package/template/apps/server/src/tests/runner-experience.test.ts +273 -0
- package/template/apps/server/src/tests/runner-powerups-scoring.test.ts +221 -0
- package/template/apps/server/src/tests/server.integration.test.ts +92 -0
- package/template/apps/server/src/tests/shooter-enemy-ai.test.ts +328 -0
- package/template/apps/server/src/tests/shooter-experience.test.ts +281 -0
- package/template/apps/server/src/tests/voxelChunkCache.test.ts +29 -0
- package/template/apps/server/src/tests/voxelSandbox.test.ts +133 -0
- package/template/apps/server/src/voxelChunkCache.ts +31 -0
- package/template/apps/server/src/voxelPlayerState.ts +23 -0
- package/template/apps/server/tsconfig.json +17 -0
- package/template/apps/server/vitest.config.ts +8 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { RunnerExperience, createRunnerSession, addRunnerPlayer, removeRunnerPlayer } from '../../../experiences/runner/index.js';
|
|
3
|
+
import {
|
|
4
|
+
createTrackGenerator,
|
|
5
|
+
updateTrack,
|
|
6
|
+
getTrackGroundHeight,
|
|
7
|
+
} from '../../../experiences/runner/track/trackGenerator.js';
|
|
8
|
+
import {
|
|
9
|
+
createLaneState,
|
|
10
|
+
requestLaneSwitch,
|
|
11
|
+
updateLane,
|
|
12
|
+
LANE_WIDTH,
|
|
13
|
+
} from '../../../experiences/runner/track/laneSystem.js';
|
|
14
|
+
import { getSpeedForDistance, BASE_SPEED, MAX_SPEED } from '../../../experiences/runner/data/runner-config.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// RunnerExperience
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('RunnerExperience', () => {
|
|
21
|
+
test('implements ExperienceDefinition', () => {
|
|
22
|
+
const exp = new RunnerExperience();
|
|
23
|
+
expect(exp.id).toBe('runner');
|
|
24
|
+
expect(exp.name).toBeDefined();
|
|
25
|
+
expect(exp.description).toBeDefined();
|
|
26
|
+
expect(exp.defaultCameraMode).toBe('follow');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('createSystems returns non-empty list', () => {
|
|
30
|
+
const exp = new RunnerExperience();
|
|
31
|
+
const systems = exp.createSystems();
|
|
32
|
+
expect(systems.length).toBe(6);
|
|
33
|
+
expect(systems.map(s => s.id)).toEqual([
|
|
34
|
+
'runner-track-stream', 'runner-physics', 'runner-collectibles', 'runner-obstacles', 'runner-death', 'runner-entity-sync',
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('systems have unique ids', () => {
|
|
39
|
+
const exp = new RunnerExperience();
|
|
40
|
+
const ids = exp.createSystems().map(s => s.id);
|
|
41
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// RunnerSession
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe('RunnerSession', () => {
|
|
50
|
+
test('create session with seed', () => {
|
|
51
|
+
const session = createRunnerSession(42);
|
|
52
|
+
expect(session.seed).toBe(42);
|
|
53
|
+
expect(session.players.size).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('add and remove player', () => {
|
|
57
|
+
const session = createRunnerSession(42);
|
|
58
|
+
const player = addRunnerPlayer(session, 'sock1', 'player1');
|
|
59
|
+
expect(player.entityId).toBe(1);
|
|
60
|
+
expect(session.players.size).toBe(1);
|
|
61
|
+
expect(player.body.x).toBe(0);
|
|
62
|
+
expect(player.body.z).toBe(0);
|
|
63
|
+
expect(player.dead).toBe(false);
|
|
64
|
+
|
|
65
|
+
removeRunnerPlayer(session, 'sock1');
|
|
66
|
+
expect(session.players.size).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Track Generator
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
describe('TrackGenerator', () => {
|
|
75
|
+
test('generates segments ahead of player', () => {
|
|
76
|
+
const state = createTrackGenerator(42);
|
|
77
|
+
updateTrack(state, 0, 200, 50);
|
|
78
|
+
expect(state.segments.length).toBeGreaterThan(0);
|
|
79
|
+
expect(state.segments[state.segments.length - 1].endZ).toBeGreaterThanOrEqual(200);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('disposes segments behind player', () => {
|
|
83
|
+
const state = createTrackGenerator(42);
|
|
84
|
+
updateTrack(state, 0, 200, 50);
|
|
85
|
+
// Move player far ahead — segments with endZ < (playerZ - bufferBehind) should be removed
|
|
86
|
+
updateTrack(state, 300, 200, 50);
|
|
87
|
+
// All remaining segments should have endZ > 300 - 50 = 250
|
|
88
|
+
expect(state.segments.every(s => s.endZ > 250)).toBe(true);
|
|
89
|
+
// And there should be segments ahead of playerZ
|
|
90
|
+
expect(state.segments.some(s => s.endZ >= 500)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('same seed produces same track', () => {
|
|
94
|
+
const s1 = createTrackGenerator(42);
|
|
95
|
+
const s2 = createTrackGenerator(42);
|
|
96
|
+
updateTrack(s1, 0, 500, 0);
|
|
97
|
+
updateTrack(s2, 0, 500, 0);
|
|
98
|
+
expect(s1.segments.map(s => s.def.type)).toEqual(s2.segments.map(s => s.def.type));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('different seeds produce different tracks', () => {
|
|
102
|
+
const s1 = createTrackGenerator(42);
|
|
103
|
+
const s2 = createTrackGenerator(999);
|
|
104
|
+
updateTrack(s1, 0, 1000, 0);
|
|
105
|
+
updateTrack(s2, 0, 1000, 0);
|
|
106
|
+
const types1 = s1.segments.map(s => s.def.type).join(',');
|
|
107
|
+
const types2 = s2.segments.map(s => s.def.type).join(',');
|
|
108
|
+
expect(types1).not.toEqual(types2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('first 3 segments are straight (grace period)', () => {
|
|
112
|
+
const state = createTrackGenerator(42);
|
|
113
|
+
updateTrack(state, 0, 200, 0);
|
|
114
|
+
expect(state.segments[0].def.type).toBe('straight');
|
|
115
|
+
expect(state.segments[1].def.type).toBe('straight');
|
|
116
|
+
expect(state.segments[2].def.type).toBe('straight');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('difficulty increases with index (more non-straight segments later)', () => {
|
|
120
|
+
const state = createTrackGenerator(42);
|
|
121
|
+
updateTrack(state, 0, 3000, 0);
|
|
122
|
+
const earlyNonStraight = state.segments.filter(s => s.index >= 3 && s.index < 15 && s.def.type !== 'straight').length;
|
|
123
|
+
const lateNonStraight = state.segments.filter(s => s.index >= 40 && s.def.type !== 'straight').length;
|
|
124
|
+
// Late segments should have more variety (or at least not fewer)
|
|
125
|
+
expect(lateNonStraight).toBeGreaterThanOrEqual(earlyNonStraight);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('segments connect seamlessly (no gaps between segments)', () => {
|
|
129
|
+
const state = createTrackGenerator(42);
|
|
130
|
+
updateTrack(state, 0, 500, 0);
|
|
131
|
+
for (let i = 1; i < state.segments.length; i++) {
|
|
132
|
+
expect(state.segments[i].startZ).toBeCloseTo(state.segments[i - 1].endZ, 5);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Ground Height
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
describe('getTrackGroundHeight', () => {
|
|
142
|
+
test('returns 0 for straight segment center lane', () => {
|
|
143
|
+
const state = createTrackGenerator(42);
|
|
144
|
+
updateTrack(state, 0, 100, 0);
|
|
145
|
+
const height = getTrackGroundHeight(state, 0, 5); // center lane, z=5
|
|
146
|
+
expect(height).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('returns null outside any segment', () => {
|
|
150
|
+
const state = createTrackGenerator(42);
|
|
151
|
+
updateTrack(state, 0, 100, 0);
|
|
152
|
+
const height = getTrackGroundHeight(state, 0, -10);
|
|
153
|
+
expect(height).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('returns null in gap', () => {
|
|
157
|
+
const state = createTrackGenerator(42);
|
|
158
|
+
// Generate until we find a gap segment
|
|
159
|
+
updateTrack(state, 0, 3000, 0);
|
|
160
|
+
const gap = state.segments.find(s => s.def.type === 'gap');
|
|
161
|
+
if (gap) {
|
|
162
|
+
const midZ = (gap.startZ + gap.endZ) / 2;
|
|
163
|
+
const height = getTrackGroundHeight(state, 0, midZ);
|
|
164
|
+
expect(height).toBeNull();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('returns > 0 on ramp peak', () => {
|
|
169
|
+
const state = createTrackGenerator(42);
|
|
170
|
+
updateTrack(state, 0, 3000, 0);
|
|
171
|
+
const ramp = state.segments.find(s => s.def.type === 'ramp');
|
|
172
|
+
if (ramp) {
|
|
173
|
+
const midZ = (ramp.startZ + ramp.endZ) / 2;
|
|
174
|
+
const height = getTrackGroundHeight(state, 0, midZ);
|
|
175
|
+
expect(height).not.toBeNull();
|
|
176
|
+
expect(height!).toBeGreaterThan(0);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('returns null for missing lane in narrow segment', () => {
|
|
181
|
+
const state = createTrackGenerator(42);
|
|
182
|
+
updateTrack(state, 0, 3000, 0);
|
|
183
|
+
const narrow = state.segments.find(s => s.def.type === 'narrow-left');
|
|
184
|
+
if (narrow) {
|
|
185
|
+
const midZ = (narrow.startZ + narrow.endZ) / 2;
|
|
186
|
+
// Left lane (x = -LANE_WIDTH) should be missing in narrow-left
|
|
187
|
+
const height = getTrackGroundHeight(state, -LANE_WIDTH, midZ);
|
|
188
|
+
expect(height).toBeNull();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Lane System
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
describe('LaneSystem', () => {
|
|
198
|
+
test('starts at center lane', () => {
|
|
199
|
+
const state = createLaneState();
|
|
200
|
+
expect(state.currentLane).toBe(0);
|
|
201
|
+
expect(state.laneOffset).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('lane switch moves to target', () => {
|
|
205
|
+
const state = createLaneState();
|
|
206
|
+
requestLaneSwitch(state, 1); // switch right
|
|
207
|
+
expect(state.targetLane).toBe(1);
|
|
208
|
+
// Simulate to completion
|
|
209
|
+
for (let i = 0; i < 20; i++) updateLane(state, 0.05);
|
|
210
|
+
expect(state.currentLane).toBe(1);
|
|
211
|
+
expect(state.laneOffset).toBeCloseTo(LANE_WIDTH, 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('cannot switch beyond lane bounds (right)', () => {
|
|
215
|
+
const state = createLaneState();
|
|
216
|
+
state.currentLane = 1;
|
|
217
|
+
state.targetLane = 1;
|
|
218
|
+
state.laneOffset = LANE_WIDTH;
|
|
219
|
+
requestLaneSwitch(state, 1); // try to go further right
|
|
220
|
+
expect(state.targetLane).toBe(1); // stays at 1
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('cannot switch beyond lane bounds (left)', () => {
|
|
224
|
+
const state = createLaneState();
|
|
225
|
+
state.currentLane = -1;
|
|
226
|
+
state.targetLane = -1;
|
|
227
|
+
state.laneOffset = -LANE_WIDTH;
|
|
228
|
+
requestLaneSwitch(state, -1); // try to go further left
|
|
229
|
+
expect(state.targetLane).toBe(-1); // stays at -1
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('double switch reaches lane 1 from -1', () => {
|
|
233
|
+
const state = createLaneState();
|
|
234
|
+
state.currentLane = -1;
|
|
235
|
+
state.targetLane = -1;
|
|
236
|
+
state.laneOffset = -LANE_WIDTH;
|
|
237
|
+
|
|
238
|
+
requestLaneSwitch(state, 1);
|
|
239
|
+
for (let i = 0; i < 20; i++) updateLane(state, 0.05);
|
|
240
|
+
expect(state.currentLane).toBe(0);
|
|
241
|
+
|
|
242
|
+
requestLaneSwitch(state, 1);
|
|
243
|
+
for (let i = 0; i < 20; i++) updateLane(state, 0.05);
|
|
244
|
+
expect(state.currentLane).toBe(1);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Speed Curve
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
describe('getSpeedForDistance', () => {
|
|
253
|
+
test('starts at base speed', () => {
|
|
254
|
+
expect(getSpeedForDistance(0)).toBe(BASE_SPEED);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('caps at max speed', () => {
|
|
258
|
+
expect(getSpeedForDistance(100000)).toBe(MAX_SPEED);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('increases with distance', () => {
|
|
262
|
+
expect(getSpeedForDistance(1000)).toBeGreaterThan(getSpeedForDistance(0));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('monotonically increases up to cap', () => {
|
|
266
|
+
let prev = 0;
|
|
267
|
+
for (let d = 0; d <= 10000; d += 500) {
|
|
268
|
+
const speed = getSpeedForDistance(d);
|
|
269
|
+
expect(speed).toBeGreaterThanOrEqual(prev);
|
|
270
|
+
prev = speed;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { createRunnerSession, addRunnerPlayer } from '../../../experiences/runner/index.js';
|
|
3
|
+
import {
|
|
4
|
+
createTrackGenerator,
|
|
5
|
+
updateTrack,
|
|
6
|
+
} from '../../../experiences/runner/track/trackGenerator.js';
|
|
7
|
+
import {
|
|
8
|
+
getComboMultiplier,
|
|
9
|
+
isInPickupRange,
|
|
10
|
+
applyPowerup,
|
|
11
|
+
} from '../../../experiences/runner/systems/collectibleSystem.js';
|
|
12
|
+
import {
|
|
13
|
+
checkObstacleCollision,
|
|
14
|
+
} from '../../../experiences/runner/systems/obstacleSystem.js';
|
|
15
|
+
import { updateModifiers } from '../../../../packages/core/src/physics/modifiers.js';
|
|
16
|
+
import type { PlacedObstacle } from '../../../experiences/runner/track/segmentTypes.js';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Combo System
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
describe('getComboMultiplier', () => {
|
|
23
|
+
test('x1 for 0-4 consecutive', () => {
|
|
24
|
+
expect(getComboMultiplier(0)).toBe(1);
|
|
25
|
+
expect(getComboMultiplier(4)).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('x2 for 5-9', () => {
|
|
29
|
+
expect(getComboMultiplier(5)).toBe(2);
|
|
30
|
+
expect(getComboMultiplier(9)).toBe(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('x3 for 10-19', () => {
|
|
34
|
+
expect(getComboMultiplier(10)).toBe(3);
|
|
35
|
+
expect(getComboMultiplier(19)).toBe(3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('x5 for 20+', () => {
|
|
39
|
+
expect(getComboMultiplier(20)).toBe(5);
|
|
40
|
+
expect(getComboMultiplier(100)).toBe(5);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Pickup Range
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('isInPickupRange', () => {
|
|
49
|
+
test('picks up when close', () => {
|
|
50
|
+
expect(isInPickupRange(0, 10, 0, 10.5, false)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('does not pick up when far', () => {
|
|
54
|
+
expect(isInPickupRange(0, 10, 0, 15, false)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('magnet extends range', () => {
|
|
58
|
+
expect(isInPickupRange(0, 10, 4, 10, false)).toBe(false);
|
|
59
|
+
expect(isInPickupRange(0, 10, 4, 10, true)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Powerups
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('applyPowerup', () => {
|
|
68
|
+
function makePlayer() {
|
|
69
|
+
const session = createRunnerSession(42);
|
|
70
|
+
return addRunnerPlayer(session, 'sock1', 'p1');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('speed-boost adds CharacterModifier', () => {
|
|
74
|
+
const player = makePlayer();
|
|
75
|
+
applyPowerup(player, 'speed-boost');
|
|
76
|
+
expect(player.modifiers).toHaveLength(1);
|
|
77
|
+
expect(player.modifiers[0].id).toBe('speed-boost');
|
|
78
|
+
expect(player.modifiers[0].multipliers.walkSpeed).toBe(1.5);
|
|
79
|
+
expect(player.modifiers[0].remainingTime).toBe(5);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('low-gravity sets gravityModifier', () => {
|
|
83
|
+
const player = makePlayer();
|
|
84
|
+
applyPowerup(player, 'low-gravity');
|
|
85
|
+
expect(player.gravityModifier).not.toBeNull();
|
|
86
|
+
expect(player.gravityModifier!.multiplier).toBe(0.3);
|
|
87
|
+
expect(player.gravityModifier!.remainingTime).toBe(5);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('magnet activates with timer', () => {
|
|
91
|
+
const player = makePlayer();
|
|
92
|
+
applyPowerup(player, 'magnet');
|
|
93
|
+
expect(player.magnetActive).toBe(true);
|
|
94
|
+
expect(player.magnetTimer).toBe(8);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('speed-boost modifier expires via updateModifiers', () => {
|
|
98
|
+
const player = makePlayer();
|
|
99
|
+
applyPowerup(player, 'speed-boost');
|
|
100
|
+
player.modifiers = updateModifiers(player.modifiers, 6);
|
|
101
|
+
expect(player.modifiers).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Obstacle Collision
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe('checkObstacleCollision', () => {
|
|
110
|
+
const wall: PlacedObstacle = { id: 1, type: 'wall', lane: 0, x: 0, z: 50, height: 3 };
|
|
111
|
+
const lowBarrier: PlacedObstacle = { id: 2, type: 'low-barrier', lane: 0, x: 0, z: 50, height: 1 };
|
|
112
|
+
const highBarrier: PlacedObstacle = { id: 3, type: 'high-barrier', lane: 0, x: 0, z: 50, height: 3 };
|
|
113
|
+
|
|
114
|
+
test('wall always kills on contact', () => {
|
|
115
|
+
expect(checkObstacleCollision(0, 0, 50, wall)).toBe(true);
|
|
116
|
+
expect(checkObstacleCollision(0, 5, 50, wall)).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('wall does not kill if far away', () => {
|
|
120
|
+
expect(checkObstacleCollision(0, 0, 55, wall)).toBe(false);
|
|
121
|
+
expect(checkObstacleCollision(4, 0, 50, wall)).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('low barrier kills if on ground', () => {
|
|
125
|
+
expect(checkObstacleCollision(0, 0, 50, lowBarrier)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('low barrier survived by jumping over', () => {
|
|
129
|
+
expect(checkObstacleCollision(0, 2, 50, lowBarrier)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('high barrier kills if in air', () => {
|
|
133
|
+
expect(checkObstacleCollision(0, 2, 50, highBarrier)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('high barrier survived by staying low', () => {
|
|
137
|
+
expect(checkObstacleCollision(0, 0.3, 50, highBarrier)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Track Generator — Collectibles & Obstacles
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe('TrackGenerator with items', () => {
|
|
146
|
+
test('segments have collectibles array', () => {
|
|
147
|
+
const state = createTrackGenerator(42);
|
|
148
|
+
updateTrack(state, 0, 500, 0);
|
|
149
|
+
// All segments should have a collectibles array (may be empty)
|
|
150
|
+
for (const seg of state.segments) {
|
|
151
|
+
expect(Array.isArray(seg.collectibles)).toBe(true);
|
|
152
|
+
expect(Array.isArray(seg.obstacles)).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('some segments have coins', () => {
|
|
157
|
+
const state = createTrackGenerator(42);
|
|
158
|
+
updateTrack(state, 0, 2000, 0);
|
|
159
|
+
const withCoins = state.segments.filter(s => s.collectibles.length > 0);
|
|
160
|
+
expect(withCoins.length).toBeGreaterThan(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('some segments have obstacles at higher indices', () => {
|
|
164
|
+
const state = createTrackGenerator(42);
|
|
165
|
+
updateTrack(state, 0, 3000, 0);
|
|
166
|
+
const withObstacles = state.segments.filter(s => s.obstacles.length > 0);
|
|
167
|
+
expect(withObstacles.length).toBeGreaterThan(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('some segments have powerups', () => {
|
|
171
|
+
const state = createTrackGenerator(42);
|
|
172
|
+
updateTrack(state, 0, 3000, 0);
|
|
173
|
+
const withPowerups = state.segments.filter(s => s.powerup !== null);
|
|
174
|
+
expect(withPowerups.length).toBeGreaterThan(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('collectibles have world positions', () => {
|
|
178
|
+
const state = createTrackGenerator(42);
|
|
179
|
+
updateTrack(state, 0, 500, 0);
|
|
180
|
+
const seg = state.segments.find(s => s.collectibles.length > 0);
|
|
181
|
+
if (seg) {
|
|
182
|
+
for (const c of seg.collectibles) {
|
|
183
|
+
expect(c.z).toBeGreaterThanOrEqual(seg.startZ);
|
|
184
|
+
expect(c.z).toBeLessThanOrEqual(seg.endZ);
|
|
185
|
+
expect(c.collected).toBe(false);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('obstacles never block all lanes', () => {
|
|
191
|
+
const state = createTrackGenerator(42);
|
|
192
|
+
updateTrack(state, 0, 5000, 0);
|
|
193
|
+
for (const seg of state.segments) {
|
|
194
|
+
if (seg.obstacles.length > 0) {
|
|
195
|
+
const availableLanes = seg.def.lanes.filter(Boolean).length;
|
|
196
|
+
const blockedLanes = new Set(seg.obstacles.map(o => o.lane)).size;
|
|
197
|
+
expect(blockedLanes).toBeLessThan(availableLanes);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// RunnerPlayer initial state
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
describe('RunnerPlayer scoring fields', () => {
|
|
208
|
+
test('new player starts with zero score', () => {
|
|
209
|
+
const session = createRunnerSession(42);
|
|
210
|
+
const player = addRunnerPlayer(session, 'sock1', 'p1');
|
|
211
|
+
expect(player.score).toBe(0);
|
|
212
|
+
expect(player.coins).toBe(0);
|
|
213
|
+
expect(player.gems).toBe(0);
|
|
214
|
+
expect(player.bestCombo).toBe(0);
|
|
215
|
+
expect(player.combo.count).toBe(0);
|
|
216
|
+
expect(player.modifiers).toHaveLength(0);
|
|
217
|
+
expect(player.magnetActive).toBe(false);
|
|
218
|
+
expect(player.gravityModifier).toBeNull();
|
|
219
|
+
expect(player.highScore).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { io as Client, type Socket } from 'socket.io-client';
|
|
3
|
+
import { createServer, type GameServer } from '../index.js';
|
|
4
|
+
|
|
5
|
+
const TEST_PORT = 3099;
|
|
6
|
+
|
|
7
|
+
describe('Server Integration', () => {
|
|
8
|
+
let server: GameServer;
|
|
9
|
+
let client: Socket;
|
|
10
|
+
const received: Record<string, unknown>[] = [];
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
server = await createServer(TEST_PORT, 42); // fixed seed for determinism
|
|
14
|
+
|
|
15
|
+
client = Client(`http://localhost:${TEST_PORT}`);
|
|
16
|
+
|
|
17
|
+
// Capture all messages from the start
|
|
18
|
+
client.on('message', (msg: Record<string, unknown>) => {
|
|
19
|
+
received.push(msg);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Wait for connection + EXPERIENCE_LIST, then join Diablo experience
|
|
23
|
+
await new Promise<void>((resolve, reject) => {
|
|
24
|
+
const timer = setTimeout(() => reject(new Error('Connection timeout')), 3000);
|
|
25
|
+
client.on('connect', () => {
|
|
26
|
+
// Wait for EXPERIENCE_LIST, then join diablo
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
client.emit('message', { type: 'EXPERIENCE_JOIN', experienceId: 'diablo' });
|
|
29
|
+
// Wait for AREA_CHANGE to arrive
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolve();
|
|
33
|
+
}, 300);
|
|
34
|
+
}, 100);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
client.disconnect();
|
|
41
|
+
try {
|
|
42
|
+
await server.close();
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore ERR_SERVER_NOT_RUNNING on test cleanup
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('sends EXPERIENCE_LIST on connect', () => {
|
|
49
|
+
const expList = received.find(m => m.type === 'EXPERIENCE_LIST');
|
|
50
|
+
expect(expList).toBeDefined();
|
|
51
|
+
const experiences = (expList as any).experiences as Array<{ id: string; name: string }>;
|
|
52
|
+
expect(experiences.length).toBeGreaterThan(0);
|
|
53
|
+
expect(experiences.find(e => e.id === 'diablo')).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('sends AREA_CHANGE with overworld on connect', () => {
|
|
57
|
+
const areaChange = received.find(m => m.type === 'AREA_CHANGE');
|
|
58
|
+
expect(areaChange).toBeDefined();
|
|
59
|
+
expect(areaChange!.targetArea).toBe('overworld');
|
|
60
|
+
expect(areaChange!.voxelConfig).toBeDefined();
|
|
61
|
+
expect(typeof areaChange!.spawnX).toBe('number');
|
|
62
|
+
expect(typeof areaChange!.spawnY).toBe('number');
|
|
63
|
+
expect(typeof areaChange!.localEntityId).toBe('number');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('broadcasts ENTITY_SYNC at ~20Hz', async () => {
|
|
67
|
+
const before = received.filter(m => m.type === 'ENTITY_SYNC').length;
|
|
68
|
+
await new Promise((res) => setTimeout(res, 400));
|
|
69
|
+
const after = received.filter(m => m.type === 'ENTITY_SYNC').length;
|
|
70
|
+
expect(after - before).toBeGreaterThanOrEqual(3);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('ENTITY_SYNC has correct shape', () => {
|
|
74
|
+
const sync = received.find(m => m.type === 'ENTITY_SYNC') as {
|
|
75
|
+
type: string;
|
|
76
|
+
entities: unknown[];
|
|
77
|
+
tick: number;
|
|
78
|
+
} | undefined;
|
|
79
|
+
expect(sync).toBeDefined();
|
|
80
|
+
expect(Array.isArray(sync!.entities)).toBe(true);
|
|
81
|
+
expect(typeof sync!.tick).toBe('number');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('accepts PLAYER_MOVE message without crashing', async () => {
|
|
85
|
+
const countBefore = received.filter(m => m.type === 'ENTITY_SYNC').length;
|
|
86
|
+
client.emit('message', { type: 'PLAYER_MOVE', targetX: 10, targetY: 5 });
|
|
87
|
+
await new Promise((res) => setTimeout(res, 200));
|
|
88
|
+
const countAfter = received.filter(m => m.type === 'ENTITY_SYNC').length;
|
|
89
|
+
// Server still sends ENTITY_SYNC — didn't crash
|
|
90
|
+
expect(countAfter).toBeGreaterThan(countBefore);
|
|
91
|
+
});
|
|
92
|
+
});
|