@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,597 @@
1
+ import { createGameServer, ExperienceManager, createExperienceGameLoop, createTickLoop } from '@loonylabs/gamedev-server';
2
+ import { PlayerMoveSchema, PlayerMoveDirectSchema, ActionEventSchema, PlayerConnectSchema, CraftRequestSchema, EquipRequestSchema, UnequipRequestSchema, RequestNewDungeonSchema, ActionRaycastSchema, DebugSpawnSchema, UseSkillSchema, PlayerJumpSchema, ExperienceJoinSchema, ExperienceLeaveSchema, ShootIntentSchema, ReloadSchema, ShooterMoveSchema, ShooterJumpSchema, ShooterRestartSchema, RunnerInputSchema, RunnerRestartSchema } from '@loonylabs/gamedev-protocol';
3
+ import { AreaManager } from './areaManager.js';
4
+ import { loadRooms } from './rooms.js';
5
+ import { db } from './db/client.js';
6
+ import { readFileSync } from 'node:fs';
7
+ import { resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { handleMeleeAction } from './handlers/actionEvent.js';
10
+ import { handleRaycastAction } from './handlers/raycastHandler.js';
11
+ import { handleUseSkill } from './handlers/skillHandler.js';
12
+ import { handleCraftRequest } from './handlers/craftHandler.js';
13
+ import { handleEquipRequest, handleUnequipRequest } from './handlers/equipHandler.js';
14
+ import { loadPlayerState, savePlayerState, toInventoryItems } from './persistence.js';
15
+ import type { Recipe } from '@loonylabs/gamedev-core';
16
+ import { normaliseSkillBook, tryJump, getTerrainHeight, createJumpState, createPhysicsBody } from '@loonylabs/gamedev-core';
17
+ import type { SkillBook } from '@loonylabs/gamedev-core';
18
+ import { voxelPhysicsRegistry } from './voxelPlayerState.js';
19
+ import { GAME_JUMP_CONFIG } from '../../game-data/src/physics/jump-config.js';
20
+ import { DiabloExperience, createDiabloAreaSystems } from '../../experiences/diablo/index.js';
21
+ import { ShooterExperience, createShooterSession, addShooterPlayer, removeShooterPlayer } from '../../experiences/shooter/index.js';
22
+ import { createWeaponState, DEFAULT_WEAPON_CONFIG } from '../../experiences/shooter/data/weapon-config.js';
23
+ import { SANDBOX_PHYSICS_CONFIG } from '../../game-data/src/voxel/sandbox-terrain-config.js';
24
+ import { DUNGEON_PHYSICS_CONFIG } from '../../game-data/src/physics/dungeon-physics-config.js';
25
+ import { RunnerExperience, createRunnerSession, addRunnerPlayer, removeRunnerPlayer } from '../../experiences/runner/index.js';
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const recipeBook: Recipe[] = JSON.parse(
29
+ readFileSync(resolve(__dirname, '../../game-data/src/recipes/recipe-book.json'), 'utf-8')
30
+ );
31
+ const skillBook: SkillBook = normaliseSkillBook(JSON.parse(
32
+ readFileSync(resolve(__dirname, '../../game-data/src/skills/skill-book.json'), 'utf-8')
33
+ ));
34
+
35
+ const AREA_IDS = ['overworld', 'dungeon'] as const;
36
+
37
+ export interface GameServer {
38
+ close(): Promise<void>;
39
+ }
40
+
41
+ export async function createServer(port: number, seed?: number): Promise<GameServer> {
42
+ const { io, addStopCallback, close } = await createGameServer({ port, cors: { origin: '*' } });
43
+
44
+ const rooms = loadRooms();
45
+ // db is initialized in ./db/client.ts as a singleton
46
+ const areaManager = new AreaManager(rooms, seed ?? Date.now());
47
+
48
+ // Register experiences
49
+ const experienceManager = new ExperienceManager();
50
+ experienceManager.register(new DiabloExperience());
51
+ experienceManager.register(new ShooterExperience());
52
+ experienceManager.register(new RunnerExperience());
53
+
54
+ // Shooter sessions — keyed by a session ID (one shared session for now)
55
+ const shooterSessions = new Map<string, ReturnType<typeof createShooterSession>>();
56
+ const playerShooterSession = new Map<string, string>(); // socketId -> sessionId
57
+ // Jump states for shooter players (not stored in ShooterPlayer to keep core pure)
58
+ const shooterJumpStates = new Map<string, ReturnType<typeof createJumpState>>();
59
+
60
+ // Runner sessions
61
+ const runnerSessions = new Map<string, ReturnType<typeof createRunnerSession>>();
62
+ const playerRunnerSession = new Map<string, string>(); // socketId -> sessionId
63
+
64
+ // Load area manifest for transitions
65
+ const areaManifest = JSON.parse(
66
+ readFileSync(resolve(__dirname, '../../game-data/src/areas/area-manifest.json'), 'utf-8')
67
+ );
68
+ const transitionsByArea = new Map<string, Array<{ cellType?: string; tileType?: string; targetArea: string; spawnX: number; spawnY: number; minX?: number; maxX?: number; minY?: number; maxY?: number }>>(
69
+ areaManifest.areas.map((a: { id: string; transitions: unknown[] }) => [a.id, a.transitions])
70
+ );
71
+
72
+ // Start game loops via experience system — one per area with extracted systems
73
+ for (const areaId of AREA_IDS) {
74
+ const areaSession = areaManager.getAreaSession(areaId);
75
+ if (!areaSession) continue;
76
+
77
+ const physicsConfig = areaSession.areaType === 'voxel' ? SANDBOX_PHYSICS_CONFIG : DUNGEON_PHYSICS_CONFIG;
78
+ const systems = createDiabloAreaSystems({
79
+ areaId,
80
+ areaSession,
81
+ areaManager,
82
+ physicsConfig,
83
+ jumpConfig: GAME_JUMP_CONFIG,
84
+ transitions: transitionsByArea.get(areaId) ?? [],
85
+ });
86
+
87
+ const stopLoop = createExperienceGameLoop(io, areaSession.session, systems, 'diablo');
88
+ addStopCallback(stopLoop);
89
+ }
90
+
91
+ // Wire pickup events for all sessions
92
+ const pickupHandler = (socketId: string, inventory: Array<{ id: string; name: string; rarity: string; statBonus: Record<string, number> }>) => {
93
+ const sock = io.sockets.sockets.get(socketId);
94
+ if (sock) sock.emit('message', { type: 'INVENTORY_UPDATE', items: toInventoryItems(inventory) });
95
+ };
96
+ for (const areaId of [...AREA_IDS]) {
97
+ const s = areaManager.getSession(areaId);
98
+ if (s) s.onItemPickup = pickupHandler;
99
+ }
100
+
101
+ // Convenience getter: current session for a socket
102
+ const getSession = (socketId: string) =>
103
+ areaManager.getSession(areaManager.getPlayerArea(socketId));
104
+
105
+ /** Join the Shooter experience. */
106
+ function joinShooterExperience(socket: import('socket.io').Socket) {
107
+ const sessionId = 'shooter-main';
108
+ let shooterSession = shooterSessions.get(sessionId);
109
+ if (!shooterSession) {
110
+ shooterSession = createShooterSession(Date.now());
111
+ shooterSessions.set(sessionId, shooterSession);
112
+
113
+ // Start the experience game loop with a lightweight GameSession wrapper
114
+ const shooterDef = experienceManager.getDefinition('shooter')!;
115
+ const systems = shooterDef.createSystems();
116
+
117
+ // Init all systems with the shooter session as the "session" object
118
+ for (const system of systems) {
119
+ system.init?.(shooterSession, { io, tick: 0, experienceId: 'shooter' });
120
+ }
121
+
122
+ // Start tick loop
123
+ let tick = 0;
124
+ const context = { io, tick: 0, experienceId: 'shooter' };
125
+ const stopLoop = createTickLoop(20, (dt: number) => {
126
+ tick++;
127
+ context.tick = tick;
128
+ for (const system of systems) {
129
+ system.update(shooterSession!, dt, context);
130
+ }
131
+ });
132
+ addStopCallback(stopLoop);
133
+ }
134
+
135
+ const player = addShooterPlayer(shooterSession, socket.id, socket.id);
136
+ playerShooterSession.set(socket.id, sessionId);
137
+ shooterJumpStates.set(socket.id, createJumpState());
138
+
139
+ socket.emit('message', {
140
+ type: 'EXPERIENCE_CHANGE',
141
+ experienceId: 'shooter',
142
+ experienceName: 'Shooter Arena',
143
+ cameraMode: 'third-person',
144
+ spawnX: player.body.x,
145
+ spawnY: player.body.z,
146
+ spawnZ: player.body.y,
147
+ localEntityId: player.entityId,
148
+ seed: shooterSession.arena.seed,
149
+ payload: {
150
+ arena: {
151
+ width: shooterSession.arena.width,
152
+ depth: shooterSession.arena.depth,
153
+ walls: shooterSession.arena.walls,
154
+ covers: shooterSession.arena.covers,
155
+ spawnPoints: shooterSession.arena.spawnPoints,
156
+ },
157
+ },
158
+ });
159
+
160
+ // Send initial weapon state so HUD has ammo info
161
+ socket.emit('message', {
162
+ type: 'WEAPON_STATE',
163
+ currentAmmo: player.weapon.currentAmmo,
164
+ maxAmmo: DEFAULT_WEAPON_CONFIG.maxAmmo,
165
+ isReloading: false,
166
+ });
167
+ }
168
+
169
+ /** Join the Runner experience. */
170
+ function joinRunnerExperience(socket: import('socket.io').Socket) {
171
+ const sessionId = 'runner-main';
172
+ let runnerSession = runnerSessions.get(sessionId);
173
+ if (!runnerSession) {
174
+ runnerSession = createRunnerSession(Date.now());
175
+ runnerSessions.set(sessionId, runnerSession);
176
+
177
+ // Start the experience game loop
178
+ const runnerDef = experienceManager.getDefinition('runner')!;
179
+ const systems = runnerDef.createSystems();
180
+
181
+ for (const system of systems) {
182
+ system.init?.(runnerSession, { io, tick: 0, experienceId: 'runner' });
183
+ }
184
+
185
+ let tick = 0;
186
+ const context = { io, tick: 0, experienceId: 'runner' };
187
+ const stopLoop = createTickLoop(20, (dt: number) => {
188
+ tick++;
189
+ context.tick = tick;
190
+ for (const system of systems) {
191
+ system.update(runnerSession!, dt, context);
192
+ }
193
+ });
194
+ addStopCallback(stopLoop);
195
+ }
196
+
197
+ const player = addRunnerPlayer(runnerSession, socket.id, socket.id);
198
+ playerRunnerSession.set(socket.id, sessionId);
199
+
200
+ // Send track data in payload for client rendering
201
+ socket.emit('message', {
202
+ type: 'EXPERIENCE_CHANGE',
203
+ experienceId: 'runner',
204
+ experienceName: 'Infinity Runner',
205
+ cameraMode: 'follow',
206
+ spawnX: 0,
207
+ spawnY: 0,
208
+ spawnZ: 0,
209
+ localEntityId: player.entityId,
210
+ seed: runnerSession.seed,
211
+ payload: {
212
+ laneWidth: 2,
213
+ trackWidth: 6, // 3 lanes * 2 units
214
+ },
215
+ });
216
+ }
217
+
218
+ /** Join the Diablo experience (legacy path — used by EXPERIENCE_JOIN and kept for now). */
219
+ function joinDiabloExperience(socket: import('socket.io').Socket) {
220
+ const player = areaManager.addPlayerToArea(socket.id, socket.id, 'overworld');
221
+ const session = areaManager.getSession('overworld')!;
222
+
223
+ socket.emit('message', {
224
+ type: 'EXPERIENCE_CHANGE',
225
+ experienceId: 'diablo',
226
+ experienceName: 'Diablo — Overworld & Dungeon',
227
+ cameraMode: 'orbit',
228
+ spawnX: session.state.dungeon.spawnPosition.x,
229
+ spawnY: session.state.dungeon.spawnPosition.y,
230
+ localEntityId: player.entityId,
231
+ seed: session.state.dungeon.seed,
232
+ payload: { voxelConfig: { terrainOptions: null, tileLayout: true }, targetArea: 'overworld' },
233
+ });
234
+
235
+ // Also send legacy AREA_CHANGE for backward compatibility
236
+ socket.emit('message', {
237
+ type: 'AREA_CHANGE',
238
+ targetArea: 'overworld',
239
+ spawnX: session.state.dungeon.spawnPosition.x,
240
+ spawnY: session.state.dungeon.spawnPosition.y,
241
+ localEntityId: player.entityId,
242
+ seed: session.state.dungeon.seed,
243
+ voxelConfig: { terrainOptions: null, tileLayout: true },
244
+ });
245
+ }
246
+
247
+ io.on('connection', (socket) => {
248
+ console.log(`[server] connect: ${socket.id}`);
249
+
250
+ // Send experience list — player starts in hub
251
+ socket.emit('message', {
252
+ type: 'EXPERIENCE_LIST',
253
+ experiences: experienceManager.getAvailableExperiences(),
254
+ });
255
+
256
+ let persistedPlayerId: string | null = null;
257
+ const getCurrentPlayer = () => getSession(socket.id)?.state.players.get(socket.id);
258
+
259
+ socket.on('message', (data: unknown) => {
260
+ // EXPERIENCE_JOIN — join an experience from hub
261
+ const joinResult = ExperienceJoinSchema.safeParse(data);
262
+ if (joinResult.success) {
263
+ const { experienceId } = joinResult.data;
264
+ if (experienceId === 'diablo') {
265
+ joinDiabloExperience(socket);
266
+ // Load persisted state if we have a playerId
267
+ if (persistedPlayerId) {
268
+ const p = getCurrentPlayer();
269
+ const sess = getSession(socket.id);
270
+ if (p && sess) {
271
+ loadPlayerState(db, sess, p, persistedPlayerId);
272
+ socket.emit('message', {
273
+ type: 'INVENTORY_UPDATE',
274
+ items: toInventoryItems(p.inventory),
275
+ });
276
+ socket.emit('message', {
277
+ type: 'EQUIPMENT_UPDATE',
278
+ equipped: Object.fromEntries(
279
+ Object.entries(p.equipped).map(([slotId, item]) => [slotId, {
280
+ id: item!.id, name: item!.name, rarity: item!.rarity, gridW: 1, gridH: 1, slot: 0, statBonus: item!.statBonus,
281
+ }])
282
+ ),
283
+ });
284
+ }
285
+ }
286
+ } else if (experienceId === 'shooter') {
287
+ joinShooterExperience(socket);
288
+ } else if (experienceId === 'runner') {
289
+ joinRunnerExperience(socket);
290
+ } else {
291
+ console.warn(`[server] Unknown experience: ${experienceId}`);
292
+ }
293
+ return;
294
+ }
295
+
296
+ // EXPERIENCE_LEAVE — return to hub
297
+ const leaveResult = ExperienceLeaveSchema.safeParse(data);
298
+ if (leaveResult.success) {
299
+ const p = getCurrentPlayer();
300
+ if (p) savePlayerState(db, p);
301
+ areaManager.removePlayerFromArea(socket.id);
302
+ // Also clean up shooter session if player was in shooter
303
+ const shooterSessId = playerShooterSession.get(socket.id);
304
+ if (shooterSessId) {
305
+ const ss = shooterSessions.get(shooterSessId);
306
+ if (ss) removeShooterPlayer(ss, socket.id);
307
+ playerShooterSession.delete(socket.id);
308
+ shooterJumpStates.delete(socket.id);
309
+ }
310
+ // Clean up runner session if player was in runner
311
+ const runnerSessId = playerRunnerSession.get(socket.id);
312
+ if (runnerSessId) {
313
+ const rs = runnerSessions.get(runnerSessId);
314
+ if (rs) removeRunnerPlayer(rs, socket.id);
315
+ playerRunnerSession.delete(socket.id);
316
+ }
317
+ // Re-send experience list so hub is populated
318
+ socket.emit('message', {
319
+ type: 'EXPERIENCE_LIST',
320
+ experiences: experienceManager.getAvailableExperiences(),
321
+ });
322
+ return;
323
+ }
324
+
325
+ // --- Shooter-specific messages ---
326
+ const shooterSessId = playerShooterSession.get(socket.id);
327
+ if (shooterSessId) {
328
+ const ss = shooterSessions.get(shooterSessId);
329
+ if (ss) {
330
+ const sp = ss.players.get(socket.id);
331
+ if (sp) {
332
+ const shootResult = ShootIntentSchema.safeParse(data);
333
+ if (shootResult.success) {
334
+ sp.shootQueue.push({
335
+ origin: { x: shootResult.data.originX, y: shootResult.data.originY, z: shootResult.data.originZ },
336
+ direction: { x: shootResult.data.dirX, y: shootResult.data.dirY, z: shootResult.data.dirZ },
337
+ });
338
+ return;
339
+ }
340
+
341
+ const reloadResult = ReloadSchema.safeParse(data);
342
+ if (reloadResult.success) {
343
+ sp.wantsReload = true;
344
+ return;
345
+ }
346
+
347
+ const moveResult = ShooterMoveSchema.safeParse(data);
348
+ if (moveResult.success) {
349
+ sp.input.x = moveResult.data.x;
350
+ sp.input.z = moveResult.data.z;
351
+ sp.input.sprint = moveResult.data.sprint;
352
+ sp.facingAngle = moveResult.data.facingAngle;
353
+ return;
354
+ }
355
+
356
+ const jumpResult = ShooterJumpSchema.safeParse(data);
357
+ if (jumpResult.success) {
358
+ const jumpState = shooterJumpStates.get(socket.id);
359
+ if (jumpState) {
360
+ tryJump(sp.body, jumpState, { jumpVelocity: 8, coyoteTime: 0.1, maxJumps: 2, jumpBufferTime: 0.1 });
361
+ }
362
+ return;
363
+ }
364
+
365
+ const restartResult = ShooterRestartSchema.safeParse(data);
366
+ if (restartResult.success) {
367
+ // Reset player state
368
+ const spawnIdx = 0;
369
+ const spawn = ss.arena.spawnPoints[spawnIdx];
370
+ sp.hp = sp.maxHp;
371
+ sp.body = createPhysicsBody(spawn.x, spawn.y, spawn.z);
372
+ sp.weapon = createWeaponState(DEFAULT_WEAPON_CONFIG);
373
+ sp.shootQueue = [];
374
+ sp.wantsReload = false;
375
+ sp.input = { x: 0, z: 0, sprint: false, glide: false };
376
+
377
+ // Reset wave state
378
+ ss.enemies = [];
379
+ ss.waveState = { currentWave: 0, waveActive: false, delayTimer: 2 };
380
+
381
+ // Reset jump state
382
+ shooterJumpStates.set(socket.id, createJumpState());
383
+
384
+ socket.emit('message', {
385
+ type: 'SHOOTER_RESTARTED',
386
+ hp: sp.hp,
387
+ maxHp: sp.maxHp,
388
+ maxAmmo: DEFAULT_WEAPON_CONFIG.maxAmmo,
389
+ });
390
+ return;
391
+ }
392
+ }
393
+ }
394
+ }
395
+
396
+ // --- Runner-specific messages ---
397
+ const runnerSessId = playerRunnerSession.get(socket.id);
398
+ if (runnerSessId) {
399
+ const rs = runnerSessions.get(runnerSessId);
400
+ if (rs) {
401
+ const rp = rs.players.get(socket.id);
402
+ if (rp) {
403
+ const inputResult = RunnerInputSchema.safeParse(data);
404
+ if (inputResult.success) {
405
+ if (inputResult.data.left) rp.input.left = true;
406
+ if (inputResult.data.right) rp.input.right = true;
407
+ if (inputResult.data.jump) rp.input.jump = true;
408
+ rp.input.glide = inputResult.data.glide;
409
+ return;
410
+ }
411
+
412
+ const restartResult = RunnerRestartSchema.safeParse(data);
413
+ if (restartResult.success) {
414
+ // Reset player state
415
+ rp.body = createPhysicsBody(0, 0, 0);
416
+ rp.jumpState = createJumpState();
417
+ rp.lane = { currentLane: 0, targetLane: 0, laneOffset: 0, switchTimer: 0 };
418
+ rp.track = { seed: rs.seed, nextZ: 0, nextIndex: 0, segments: [], nextItemId: 1 };
419
+ rp.distanceRan = 0;
420
+ rp.isGliding = false;
421
+ rp.dead = false;
422
+ rp.input = { left: false, right: false, jump: false, glide: false };
423
+ rp.score = 0;
424
+ rp.coins = 0;
425
+ rp.gems = 0;
426
+ rp.bestCombo = 0;
427
+ rp.combo = { count: 0, timer: 0 };
428
+ rp.modifiers = [];
429
+ rp.magnetActive = false;
430
+ rp.magnetTimer = 0;
431
+ rp.gravityModifier = null;
432
+ rp.nextItemId = 1;
433
+
434
+ socket.emit('message', { type: 'RUNNER_RESTARTED' });
435
+ return;
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ const sess = getSession(socket.id);
442
+
443
+ // PLAYER_CONNECT — store playerId for later use (state loaded on experience join)
444
+ const connectResult = PlayerConnectSchema.safeParse(data);
445
+ if (connectResult.success) {
446
+ persistedPlayerId = connectResult.data.playerId;
447
+ // If player is already in an experience, load state now
448
+ const p = getCurrentPlayer();
449
+ if (p && sess) {
450
+ loadPlayerState(db, sess, p, persistedPlayerId);
451
+ socket.emit('message', {
452
+ type: 'INVENTORY_UPDATE',
453
+ items: toInventoryItems(p.inventory),
454
+ });
455
+ socket.emit('message', {
456
+ type: 'EQUIPMENT_UPDATE',
457
+ equipped: Object.fromEntries(
458
+ Object.entries(p.equipped).map(([slotId, item]) => [slotId, {
459
+ id: item!.id, name: item!.name, rarity: item!.rarity, gridW: 1, gridH: 1, slot: 0, statBonus: item!.statBonus,
460
+ }])
461
+ ),
462
+ });
463
+ }
464
+ return;
465
+ }
466
+
467
+ if (!sess) return;
468
+
469
+ const regenResult = RequestNewDungeonSchema.safeParse(data);
470
+ if (regenResult.success) {
471
+ const dungeonSess = areaManager.getSession('dungeon');
472
+ if (dungeonSess) {
473
+ console.log(`[server] regenerating dungeon (seed: ${regenResult.data.seed ?? 'random'})`);
474
+ dungeonSess.regenerateDungeon(rooms, regenResult.data.seed);
475
+ areaManager.spawnInitialDungeonEnemies(dungeonSess);
476
+ io.emit('message', {
477
+ type: 'WORLD_STATE',
478
+ dungeon: dungeonSess.state.dungeon,
479
+ spawnPosition: dungeonSess.state.dungeon.spawnPosition,
480
+ });
481
+ }
482
+ return;
483
+ }
484
+
485
+ const moveResult = PlayerMoveSchema.safeParse(data);
486
+ if (moveResult.success) { sess.setPlayerTarget(socket.id, moveResult.data.targetX, moveResult.data.targetY); return; }
487
+
488
+ const moveDirectResult = PlayerMoveDirectSchema.safeParse(data);
489
+ if (moveDirectResult.success) { sess.setPlayerPositionDirect(socket.id, moveDirectResult.data.x, moveDirectResult.data.y, moveDirectResult.data.inputId); return; }
490
+
491
+ const actionResult = ActionEventSchema.safeParse(data);
492
+ if (actionResult.success && actionResult.data.action === 'melee') { handleMeleeAction(sess, socket.id); return; }
493
+
494
+ const raycastResult = ActionRaycastSchema.safeParse(data);
495
+ if (raycastResult.success) {
496
+ const playerArea = areaManager.getPlayerArea(socket.id);
497
+ const areaSess = areaManager.getAreaSession(playerArea);
498
+ const groundYFn = areaSess?.chunkCache
499
+ ? (x: number, z: number) => getTerrainHeight(areaSess.chunkCache!.getChunkFn, x, z)
500
+ : undefined;
501
+ handleRaycastAction(sess, io, raycastResult.data.entityId, raycastResult.data.origin, raycastResult.data.direction, raycastResult.data.range, undefined, groundYFn);
502
+ return;
503
+ }
504
+
505
+ const useSkillResult = UseSkillSchema.safeParse(data);
506
+ if (useSkillResult.success) {
507
+ const playerArea2 = areaManager.getPlayerArea(socket.id);
508
+ const areaSess2 = areaManager.getAreaSession(playerArea2);
509
+ const groundYFn2 = areaSess2?.chunkCache
510
+ ? (x: number, z: number) => getTerrainHeight(areaSess2.chunkCache!.getChunkFn, x, z)
511
+ : undefined;
512
+ handleUseSkill(sess, io, socket.id, useSkillResult.data.skillId, skillBook, useSkillResult.data.origin, useSkillResult.data.direction, groundYFn2);
513
+ return;
514
+ }
515
+
516
+ const craftResult = CraftRequestSchema.safeParse(data);
517
+ if (craftResult.success) { handleCraftRequest(sess, socket, craftResult.data.grid, recipeBook); return; }
518
+
519
+ const equipResult = EquipRequestSchema.safeParse(data);
520
+ if (equipResult.success) { handleEquipRequest(sess, socket, equipResult.data.slotId, equipResult.data.itemId); return; }
521
+
522
+ const unequipResult = UnequipRequestSchema.safeParse(data);
523
+ if (unequipResult.success) { handleUnequipRequest(sess, socket, unequipResult.data.slotId); return; }
524
+
525
+ const jumpResult = PlayerJumpSchema.safeParse(data);
526
+ if (jumpResult.success) {
527
+ const playerArea = areaManager.getPlayerArea(socket.id);
528
+ const physicsMap = voxelPhysicsRegistry.get(playerArea);
529
+ const physics = physicsMap?.get(socket.id);
530
+ if (physics) {
531
+ tryJump(physics.body, physics.jumpState, GAME_JUMP_CONFIG);
532
+ }
533
+ return;
534
+ }
535
+
536
+ if (process.env.NODE_ENV !== 'production') {
537
+ const debugTeleportResult = (data as any)?.type === 'DEBUG_TELEPORT' && typeof (data as any)?.targetArea === 'string';
538
+ if (debugTeleportResult) {
539
+ const { targetArea } = data as { type: string; targetArea: string };
540
+ // Overworld: spawn at basecamp, dungeon: use natural spawn
541
+ const spawnLookup: Record<string, { x: number; y: number }> = {
542
+ overworld: { x: -1, y: -1 },
543
+ dungeon: { x: -1, y: -1 },
544
+ };
545
+ areaManager.movePlayer(io, socket.id, targetArea, spawnLookup[targetArea]?.x ?? -1, spawnLookup[targetArea]?.y ?? -1);
546
+ console.log(`[server] DEBUG_TELEPORT ${socket.id} → ${targetArea}`);
547
+ return;
548
+ }
549
+
550
+ const debugSpawnResult = DebugSpawnSchema.safeParse(data);
551
+ if (debugSpawnResult.success) {
552
+ const { x, y } = debugSpawnResult.data;
553
+ const grunt = sess.spawnEnemy({ enemyDefId: 'grunt', x, y, hp: 50, maxHp: 50, alive: true, aiState: 'idle', waypoints: [{ x, y }, { x, y }], waypointIndex: 0, path: [], attackCooldown: 0 });
554
+ console.log(`[server] DEBUG_SPAWN grunt #${grunt.entityId} at (${x}, ${y})`);
555
+ return;
556
+ }
557
+ }
558
+
559
+ console.log(`[server] Unhandled message type: ${(data as any)?.type}, action: ${(data as any)?.action}`);
560
+ });
561
+
562
+ socket.on('disconnect', () => {
563
+ const p = getCurrentPlayer();
564
+ if (p) {
565
+ console.log(`[server] disconnect: ${socket.id} (entity ${p.entityId})`);
566
+ savePlayerState(db, p);
567
+ }
568
+ areaManager.removePlayerFromArea(socket.id);
569
+ // Clean up shooter session
570
+ const shooterSessId2 = playerShooterSession.get(socket.id);
571
+ if (shooterSessId2) {
572
+ const ss2 = shooterSessions.get(shooterSessId2);
573
+ if (ss2) removeShooterPlayer(ss2, socket.id);
574
+ playerShooterSession.delete(socket.id);
575
+ shooterJumpStates.delete(socket.id);
576
+ }
577
+ // Clean up runner session
578
+ const runnerSessId2 = playerRunnerSession.get(socket.id);
579
+ if (runnerSessId2) {
580
+ const rs2 = runnerSessions.get(runnerSessId2);
581
+ if (rs2) removeRunnerPlayer(rs2, socket.id);
582
+ playerRunnerSession.delete(socket.id);
583
+ }
584
+ });
585
+ });
586
+
587
+ return { close };
588
+ }
589
+
590
+ // Run as standalone if invoked directly
591
+ const isMain = process.argv[1]?.endsWith('index.ts') || process.argv[1]?.endsWith('index.js');
592
+ if (isMain) {
593
+ const PORT = Number(process.env.PORT ?? 3000);
594
+ createServer(PORT).then(() => {
595
+ console.log(`[server] running on port ${PORT}`);
596
+ });
597
+ }