@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.
Files changed (166) hide show
  1. package/bin/create-game.js +213 -0
  2. package/package.json +18 -0
  3. package/template/.claude/skills/aigdtk-create-game-stories/SKILL.md +177 -0
  4. package/template/.claude/skills/aigdtk-create-game-stories/story-template.md +85 -0
  5. package/template/.claude/skills/aigdtk-implement-game-stories/SKILL.md +129 -0
  6. package/template/.claude/skills/aigdtk-new-game/SKILL.md +126 -0
  7. package/template/.claude/skills/aigdtk-shared/ascii-grammar.md +133 -0
  8. package/template/.claude/skills/aigdtk-shared/enemies.md +112 -0
  9. package/template/.claude/skills/aigdtk-shared/framework.md +93 -0
  10. package/template/.claude/skills/aigdtk-shared/visuals.md +125 -0
  11. package/template/apps/client/index.html +14 -0
  12. package/template/apps/client/package.json +31 -0
  13. package/template/apps/client/public/assets/audio/enemy_killed.wav +0 -0
  14. package/template/apps/client/src/components/App.svelte +290 -0
  15. package/template/apps/client/src/components/CraftingPanel.svelte +253 -0
  16. package/template/apps/client/src/components/DevPanel.svelte +180 -0
  17. package/template/apps/client/src/components/DungeonClearOverlay.svelte +53 -0
  18. package/template/apps/client/src/components/EquipmentPanel.svelte +191 -0
  19. package/template/apps/client/src/components/HealthBar.svelte +50 -0
  20. package/template/apps/client/src/components/Hub/BackToHubButton.svelte +37 -0
  21. package/template/apps/client/src/components/Hub/ExperienceCard.svelte +115 -0
  22. package/template/apps/client/src/components/Hub/Hub.svelte +88 -0
  23. package/template/apps/client/src/components/Inventory.svelte +174 -0
  24. package/template/apps/client/src/components/Runner/RunnerDeathScreen.svelte +182 -0
  25. package/template/apps/client/src/components/Runner/RunnerHUD.svelte +157 -0
  26. package/template/apps/client/src/components/Shooter/DamageNumbers.svelte +96 -0
  27. package/template/apps/client/src/components/Shooter/GameOverScreen.svelte +109 -0
  28. package/template/apps/client/src/components/Shooter/ShooterHUD.svelte +95 -0
  29. package/template/apps/client/src/components/SkillBar.svelte +146 -0
  30. package/template/apps/client/src/components/ToastSystem.svelte +158 -0
  31. package/template/apps/client/src/game.ts +918 -0
  32. package/template/apps/client/src/input.ts +92 -0
  33. package/template/apps/client/src/lib/audio/CombatDetector.test.ts +59 -0
  34. package/template/apps/client/src/lib/audio/CombatDetector.ts +53 -0
  35. package/template/apps/client/src/lib/audio/MusicManager.ts +137 -0
  36. package/template/apps/client/src/lib/audio/SoundManager.ts +59 -0
  37. package/template/apps/client/src/lib/audio/index.ts +9 -0
  38. package/template/apps/client/src/main.ts +32 -0
  39. package/template/apps/client/src/renderer/basecamp.ts +126 -0
  40. package/template/apps/client/src/renderer/dungeon.ts +250 -0
  41. package/template/apps/client/src/renderer/dungeonPortal.ts +73 -0
  42. package/template/apps/client/src/renderer/dungeonZone.ts +301 -0
  43. package/template/apps/client/src/renderer/entities.ts +197 -0
  44. package/template/apps/client/src/renderer/runnerTrack.ts +221 -0
  45. package/template/apps/client/src/renderer/shaders/SkyShader.ts +190 -0
  46. package/template/apps/client/src/renderer/shaders/TerrainShaderMaterial.ts +133 -0
  47. package/template/apps/client/src/renderer/shaders/floor.frag.glsl.ts +17 -0
  48. package/template/apps/client/src/renderer/shaders/shaderConfig.ts +18 -0
  49. package/template/apps/client/src/renderer/shaders/spawn.frag.glsl.ts +19 -0
  50. package/template/apps/client/src/renderer/shaders/terrain.frag.glsl.ts +314 -0
  51. package/template/apps/client/src/renderer/shaders/terrain.vert.glsl.ts +16 -0
  52. package/template/apps/client/src/renderer/shaders/wall.frag.glsl.ts +20 -0
  53. package/template/apps/client/src/renderer/shooterArena.ts +102 -0
  54. package/template/apps/client/src/renderer/voxelChunkStreamer.ts +79 -0
  55. package/template/apps/client/src/renderer/voxelMesh.ts +86 -0
  56. package/template/apps/client/src/renderer/voxelTerrain.ts +74 -0
  57. package/template/apps/client/src/socket.ts +268 -0
  58. package/template/apps/client/src/store.ts +74 -0
  59. package/template/apps/client/src/style.css +60 -0
  60. package/template/apps/client/tsconfig.json +11 -0
  61. package/template/apps/client/vite.config.ts +10 -0
  62. package/template/apps/client/vitest.config.ts +8 -0
  63. package/template/apps/experiences/diablo/index.ts +94 -0
  64. package/template/apps/experiences/diablo/systems/dungeonClearSystem.ts +60 -0
  65. package/template/apps/experiences/diablo/systems/enemyAISystem.ts +11 -0
  66. package/template/apps/experiences/diablo/systems/entitySyncSystem.ts +80 -0
  67. package/template/apps/experiences/diablo/systems/itemPickupSystem.ts +11 -0
  68. package/template/apps/experiences/diablo/systems/movementSystem.ts +13 -0
  69. package/template/apps/experiences/diablo/systems/physicsSystem.ts +92 -0
  70. package/template/apps/experiences/diablo/systems/transitionSystem.ts +105 -0
  71. package/template/apps/experiences/runner/data/runner-config.ts +54 -0
  72. package/template/apps/experiences/runner/index.ts +143 -0
  73. package/template/apps/experiences/runner/systems/collectibleSystem.ts +157 -0
  74. package/template/apps/experiences/runner/systems/deathSystem.ts +42 -0
  75. package/template/apps/experiences/runner/systems/entitySyncSystem.ts +59 -0
  76. package/template/apps/experiences/runner/systems/obstacleSystem.ts +91 -0
  77. package/template/apps/experiences/runner/systems/runnerPhysicsSystem.ts +82 -0
  78. package/template/apps/experiences/runner/systems/trackStreamSystem.ts +19 -0
  79. package/template/apps/experiences/runner/track/laneSystem.ts +53 -0
  80. package/template/apps/experiences/runner/track/segmentTypes.ts +141 -0
  81. package/template/apps/experiences/runner/track/trackGenerator.ts +292 -0
  82. package/template/apps/experiences/shooter/ai/aiStateMachine.ts +394 -0
  83. package/template/apps/experiences/shooter/ai/lineOfSight.ts +32 -0
  84. package/template/apps/experiences/shooter/arena/arenaGenerator.ts +101 -0
  85. package/template/apps/experiences/shooter/arena/arenaTypes.ts +49 -0
  86. package/template/apps/experiences/shooter/arena/hitscan.ts +101 -0
  87. package/template/apps/experiences/shooter/data/enemy-types.ts +108 -0
  88. package/template/apps/experiences/shooter/data/wave-definitions.ts +40 -0
  89. package/template/apps/experiences/shooter/data/weapon-config.ts +80 -0
  90. package/template/apps/experiences/shooter/index.ts +127 -0
  91. package/template/apps/experiences/shooter/systems/enemyAISystem.ts +113 -0
  92. package/template/apps/experiences/shooter/systems/entitySyncSystem.ts +68 -0
  93. package/template/apps/experiences/shooter/systems/shooterPhysicsSystem.ts +89 -0
  94. package/template/apps/experiences/shooter/systems/waveSpawnerSystem.ts +87 -0
  95. package/template/apps/experiences/shooter/systems/weaponSystem.ts +157 -0
  96. package/template/apps/game-data/src/areas/area-manifest.json +18 -0
  97. package/template/apps/game-data/src/assets/migration.test.ts +291 -0
  98. package/template/apps/game-data/src/audio/music-config.json +21 -0
  99. package/template/apps/game-data/src/audio/sound-config.json +11 -0
  100. package/template/apps/game-data/src/combat/action-types.ts +2 -0
  101. package/template/apps/game-data/src/combat/enemy-def.ts +12 -0
  102. package/template/apps/game-data/src/combat/hitboxes.ts +23 -0
  103. package/template/apps/game-data/src/dungeon/cell-types.ts +20 -0
  104. package/template/apps/game-data/src/dungeon/cell-visuals.ts +13 -0
  105. package/template/apps/game-data/src/dungeon/door-directions.ts +2 -0
  106. package/template/apps/game-data/src/enemies/enemy-defs.json +32 -0
  107. package/template/apps/game-data/src/equipment/slots.json +5 -0
  108. package/template/apps/game-data/src/events/event-defs.ts +20 -0
  109. package/template/apps/game-data/src/events/event-types.ts +10 -0
  110. package/template/apps/game-data/src/events/toast-config.json +49 -0
  111. package/template/apps/game-data/src/items/item-pool.json +13 -0
  112. package/template/apps/game-data/src/loot/item-pool.ts +14 -0
  113. package/template/apps/game-data/src/loot/rarities.ts +2 -0
  114. package/template/apps/game-data/src/physics/dungeon-physics-config.ts +12 -0
  115. package/template/apps/game-data/src/physics/jump-config.ts +17 -0
  116. package/template/apps/game-data/src/recipes/recipe-book.json +68 -0
  117. package/template/apps/game-data/src/rooms/room_basecamp.json +16 -0
  118. package/template/apps/game-data/src/rooms/room_corridor_ew.json +9 -0
  119. package/template/apps/game-data/src/rooms/room_corridor_ns.json +11 -0
  120. package/template/apps/game-data/src/rooms/room_crossroads.json +11 -0
  121. package/template/apps/game-data/src/rooms/room_dead_end.json +10 -0
  122. package/template/apps/game-data/src/rooms/room_staircase.json +12 -0
  123. package/template/apps/game-data/src/rooms/room_start.json +11 -0
  124. package/template/apps/game-data/src/skills/skill-book.json +20 -0
  125. package/template/apps/game-data/src/voxel/biome-terrain.ts +76 -0
  126. package/template/apps/game-data/src/voxel/materials.ts +45 -0
  127. package/template/apps/game-data/src/voxel/sandbox-terrain-config.ts +19 -0
  128. package/template/apps/game-data/src/world/area-config.ts +33 -0
  129. package/template/apps/game-data/src/world/biome-def.ts +15 -0
  130. package/template/apps/game-data/src/world/biomes.json +57 -0
  131. package/template/apps/game-data/src/world/movement.ts +2 -0
  132. package/template/apps/game-data/src/world/overworld-layout.test.ts +93 -0
  133. package/template/apps/game-data/src/world/overworld-layout.ts +127 -0
  134. package/template/apps/server/data/game.db +0 -0
  135. package/template/apps/server/package.json +30 -0
  136. package/template/apps/server/src/areaManager.ts +346 -0
  137. package/template/apps/server/src/db/client.ts +45 -0
  138. package/template/apps/server/src/db/schema.ts +40 -0
  139. package/template/apps/server/src/gameLoop.ts +267 -0
  140. package/template/apps/server/src/gameState.ts +3 -0
  141. package/template/apps/server/src/handlers/actionEvent.ts +55 -0
  142. package/template/apps/server/src/handlers/craftHandler.ts +59 -0
  143. package/template/apps/server/src/handlers/equipHandler.ts +73 -0
  144. package/template/apps/server/src/handlers/raycastHandler.ts +97 -0
  145. package/template/apps/server/src/handlers/skillHandler.ts +87 -0
  146. package/template/apps/server/src/handlers/terraformHandler.ts +74 -0
  147. package/template/apps/server/src/index.ts +597 -0
  148. package/template/apps/server/src/persistence.ts +135 -0
  149. package/template/apps/server/src/rooms.ts +20 -0
  150. package/template/apps/server/src/systems/dungeonPhysics.test.ts +32 -0
  151. package/template/apps/server/src/systems/dungeonPhysics.ts +16 -0
  152. package/template/apps/server/src/systems/enemyAI.ts +129 -0
  153. package/template/apps/server/src/systems/itemPickup.ts +31 -0
  154. package/template/apps/server/src/tests/areaManager.test.ts +77 -0
  155. package/template/apps/server/src/tests/diablo-experience.test.ts +60 -0
  156. package/template/apps/server/src/tests/runner-experience.test.ts +273 -0
  157. package/template/apps/server/src/tests/runner-powerups-scoring.test.ts +221 -0
  158. package/template/apps/server/src/tests/server.integration.test.ts +92 -0
  159. package/template/apps/server/src/tests/shooter-enemy-ai.test.ts +328 -0
  160. package/template/apps/server/src/tests/shooter-experience.test.ts +281 -0
  161. package/template/apps/server/src/tests/voxelChunkCache.test.ts +29 -0
  162. package/template/apps/server/src/tests/voxelSandbox.test.ts +133 -0
  163. package/template/apps/server/src/voxelChunkCache.ts +31 -0
  164. package/template/apps/server/src/voxelPlayerState.ts +23 -0
  165. package/template/apps/server/tsconfig.json +17 -0
  166. 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
+ });