@nexusgame/dev 0.2.0 → 0.4.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.
@@ -2670,6 +2670,15 @@ var sign2 = Jwt.sign;
2670
2670
 
2671
2671
  // src/runtime/game-worker-client.ts
2672
2672
  function resolveGameWorkerClient(gameId, baseUrl, env) {
2673
+ const normalizedBaseUrl = (baseUrl || "").replace(/\/$/, "");
2674
+ if (normalizedBaseUrl) {
2675
+ return {
2676
+ async fetch(path, init) {
2677
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
2678
+ return fetch(`${normalizedBaseUrl}${cleanPath}`, init);
2679
+ }
2680
+ };
2681
+ }
2673
2682
  const bindingName = `GAME_${gameId.toUpperCase()}`;
2674
2683
  const binding = env[bindingName];
2675
2684
  if (binding && typeof binding.fetch === "function") {
@@ -2681,14 +2690,7 @@ function resolveGameWorkerClient(gameId, baseUrl, env) {
2681
2690
  }
2682
2691
  };
2683
2692
  }
2684
- console.log("Using global fetch for game", gameId);
2685
- const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
2686
- return {
2687
- async fetch(path, init) {
2688
- const cleanPath = path.startsWith("/") ? path : `/${path}`;
2689
- return fetch(`${normalizedBaseUrl}${cleanPath}`, init);
2690
- }
2691
- };
2693
+ throw new Error(`No game worker URL or service binding for game ${gameId}`);
2692
2694
  }
2693
2695
 
2694
2696
  // src/managers/base.ts
@@ -2698,6 +2700,18 @@ var BaseManager = class {
2698
2700
  }
2699
2701
  };
2700
2702
 
2703
+ // src/identity.ts
2704
+ function generateLlmUserId(modelName) {
2705
+ const uuid = crypto.randomUUID().slice(0, 8);
2706
+ return `llm:${modelName}:${uuid}`;
2707
+ }
2708
+ function getRoleForUser(roleMapping, userId) {
2709
+ for (const [roleId, uid] of Object.entries(roleMapping)) {
2710
+ if (uid === userId) return roleId;
2711
+ }
2712
+ return null;
2713
+ }
2714
+
2701
2715
  // src/managers/presence.ts
2702
2716
  var PresenceManager = class extends BaseManager {
2703
2717
  async handleSession(ws, userId, displayName) {
@@ -2729,7 +2743,10 @@ var PresenceManager = class extends BaseManager {
2729
2743
  this.room.connections.set(ws, userId);
2730
2744
  this.room.sessions.set(userId, ws);
2731
2745
  await this.room.persist("players");
2732
- this.sendSyncState(ws, userId);
2746
+ await this.sendSyncState(ws, userId);
2747
+ if (isOwner) {
2748
+ this.room.sendPendingJoinRequests(ws);
2749
+ }
2733
2750
  if (isAlreadyMember || isOwner) {
2734
2751
  this.broadcastSyncStateExcept(userId);
2735
2752
  }
@@ -2814,7 +2831,7 @@ var PresenceManager = class extends BaseManager {
2814
2831
  ownerDisplayName: isAuthorized ? this.room.ownerDisplayName : "Room Owner",
2815
2832
  name: isAuthorized ? this.room.name : "",
2816
2833
  isPublic: isAuthorized ? this.room.isPublic : true,
2817
- phase: isAuthorized ? this.room.phase : "authorized_check",
2834
+ phase: isAuthorized ? this.room.phase : null,
2818
2835
  players: clientPlayers,
2819
2836
  gameConfig: isAuthorized && this.room.gameConfig ? {
2820
2837
  gameId: this.room.gameConfig.gameId,
@@ -2856,8 +2873,6 @@ var PresenceManager = class extends BaseManager {
2856
2873
  };
2857
2874
 
2858
2875
  // src/managers/room.ts
2859
- var WORKER_VERIFY_PATH = "/__nexus_worker_verify";
2860
- var WORKER_VERIFY_SIGNATURE = "NEXUS_GAME_WORKER_VERIFIED_V1";
2861
2876
  var RoomManager = class extends BaseManager {
2862
2877
  async handleSelectRole(userId, roleId) {
2863
2878
  if (this.room.phase !== "lobby") {
@@ -2887,6 +2902,7 @@ var RoomManager = class extends BaseManager {
2887
2902
  }
2888
2903
  }
2889
2904
  delete this.room.players[userId];
2905
+ await this.room.clearJoinRequest(userId);
2890
2906
  const ws = this.room.sessions.get(userId);
2891
2907
  if (ws) {
2892
2908
  try {
@@ -2899,116 +2915,20 @@ var RoomManager = class extends BaseManager {
2899
2915
  await this.room.persist("players", "roleMapping");
2900
2916
  this.room.broadcastSyncState();
2901
2917
  }
2902
- async handleAdminSetGame(userId, payload) {
2903
- if (!this.room.requireOwner(userId)) return;
2904
- if (this.room.phase !== "lobby") {
2905
- return this.room.sendErrorToUser(userId, "Can only set game in lobby phase");
2906
- }
2907
- try {
2908
- const baseUrl = payload.gameWorkerUrl.replace(/\/$/, "");
2909
- const client = resolveGameWorkerClient(payload.gameId, baseUrl, this.room.bindings);
2910
- const verifyRes = await client.fetch(WORKER_VERIFY_PATH);
2911
- if (!verifyRes.ok) {
2912
- return this.room.sendErrorToUser(userId, "Game worker verification failed");
2913
- }
2914
- const signature = (await verifyRes.text()).trim();
2915
- if (signature !== WORKER_VERIFY_SIGNATURE) {
2916
- return this.room.sendErrorToUser(userId, "Game worker verification signature mismatch");
2917
- }
2918
- const metaRes = await client.fetch("/metadata");
2919
- if (!metaRes.ok) {
2920
- return this.room.sendErrorToUser(userId, "Failed to fetch game metadata");
2921
- }
2922
- const metadata = await metaRes.json();
2923
- let roleIds;
2924
- let resolvedPlayerCount;
2925
- if (Array.isArray(metadata.roleIds)) {
2926
- roleIds = metadata.roleIds;
2927
- resolvedPlayerCount = roleIds.length;
2928
- } else {
2929
- const counts = Object.keys(metadata.roleIds).map(Number).sort((a, b) => a - b);
2930
- const selectedCount = payload.selectedPlayerCount;
2931
- const effectiveCount = selectedCount && metadata.roleIds[selectedCount] ? selectedCount : counts[0];
2932
- roleIds = metadata.roleIds[effectiveCount] || [];
2933
- resolvedPlayerCount = effectiveCount;
2934
- }
2935
- this.room.gameConfig = {
2936
- gameWorkerUrl: baseUrl,
2937
- gameId: payload.gameId || metadata.id,
2938
- maxPlayers: roleIds.length,
2939
- roleIds,
2940
- selectedPlayerCount: resolvedPlayerCount,
2941
- enable_llm_memory: metadata.enable_llm_memory,
2942
- auto_save_mode: metadata.auto_save_mode
2943
- };
2944
- this.room.roleMapping = {};
2945
- await this.room.persist("gameConfig", "roleMapping");
2946
- this.room.broadcastSyncState();
2947
- this.room.waitUntil(this.room.syncRoomMeta());
2948
- } catch (e) {
2949
- console.error("[RoomManager] Failed to set game:", e);
2950
- this.room.sendErrorToUser(userId, "Failed to set game");
2951
- }
2952
- }
2953
- async handleAdminRemovePlayer(userId, targetUserId) {
2954
- if (!this.room.requireOwner(userId)) return;
2955
- if (targetUserId === this.room.ownerId) {
2956
- return this.room.sendErrorToUser(userId, "Cannot remove the room owner");
2957
- }
2958
- if (!this.room.players[targetUserId]) {
2959
- return this.room.sendErrorToUser(userId, "Player not found");
2960
- }
2961
- for (const [rId, uId] of Object.entries(this.room.roleMapping)) {
2962
- if (uId === targetUserId) {
2963
- delete this.room.roleMapping[rId];
2964
- }
2965
- }
2966
- delete this.room.players[targetUserId];
2967
- const targetWs = this.room.sessions.get(targetUserId);
2968
- if (targetWs) {
2969
- this.room.sendMessage(targetWs, { type: "KICKED", payload: "Removed by room owner" });
2970
- try {
2971
- targetWs.close(1e3, "Kicked");
2972
- } catch (_) {
2973
- }
2974
- this.room.connections.delete(targetWs);
2975
- this.room.sessions.delete(targetUserId);
2976
- }
2977
- await this.room.persist("players", "roleMapping");
2978
- this.room.broadcastSyncState();
2979
- }
2980
- async handleAdminAssignRole(userId, payload) {
2981
- if (!this.room.requireOwner(userId)) return;
2982
- if (this.room.phase !== "lobby") {
2983
- return this.room.sendErrorToUser(userId, "Can only assign roles in lobby phase");
2984
- }
2985
- if (!this.room.players[payload.userId]) {
2986
- return this.room.sendErrorToUser(userId, `Player ${payload.userId} not found`);
2987
- }
2988
- if (this.room.gameConfig && !this.room.gameConfig.roleIds.includes(payload.roleId)) {
2989
- return this.room.sendErrorToUser(userId, `Invalid role: ${payload.roleId}`);
2990
- }
2991
- for (const [rId, uId] of Object.entries(this.room.roleMapping)) {
2992
- if (uId === payload.userId) {
2993
- delete this.room.roleMapping[rId];
2994
- }
2995
- }
2996
- delete this.room.roleMapping[payload.roleId];
2997
- this.room.roleMapping[payload.roleId] = payload.userId;
2998
- await this.room.persist("roleMapping");
2999
- this.room.broadcastSyncState();
3000
- }
3001
- async handleAdminApproveJoin(userId, payload) {
3002
- if (!this.room.requireOwner(userId)) return;
2918
+ async handleAdminApproveJoin(_userId, payload) {
3003
2919
  this.room.players[payload.userId] = {
3004
2920
  displayName: payload.displayName,
3005
2921
  connected: true,
3006
2922
  isOwner: false,
3007
2923
  type: "human"
3008
2924
  };
2925
+ await this.room.clearJoinRequest(payload.userId);
3009
2926
  await this.room.persist("players");
3010
2927
  this.room.broadcastSyncState();
3011
2928
  }
2929
+ async handleAdminIgnoreJoin(_userId, payload) {
2930
+ await this.room.clearJoinRequest(payload.userId);
2931
+ }
3012
2932
  };
3013
2933
 
3014
2934
  // src/monitor-store.ts
@@ -3206,6 +3126,78 @@ async function insertMonitorLog(db, input) {
3206
3126
  return getMonitorLogById(db, input.interactionId);
3207
3127
  }
3208
3128
 
3129
+ // src/managers/task-prompts.ts
3130
+ var TASK_PROMPT_NO_MEMORY = `
3131
+ # \u4EFB\u52A1\u8981\u6C42
3132
+ \u8BF7\u5206\u6790\u5F53\u524D\u5C40\u52BF\uFF0C\u9009\u62E9\u4E00\u4E2A\u6700\u4F18\u884C\u52A8\u3002\u4F60\u5FC5\u987B\u4E25\u683C\u6309\u7167\u4EE5\u4E0BJSON\u683C\u5F0F\u8FD4\u56DE\uFF1A
3133
+
3134
+ \`\`\`json
3135
+ {
3136
+ "reasoning": "\u4F60\u7684\u51B3\u7B56\u601D\u8DEF",
3137
+ "action_id": "\u884C\u52A8ID\u5B57\u7B26\u4E32\uFF08\u4ECE\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u9009\u62E9\uFF0C\u4F8B\u5982 'place'\uFF09",
3138
+ "params": {
3139
+ "\u53C2\u6570\u540D": "\u53C2\u6570\u503C"
3140
+ }
3141
+ }
3142
+ \`\`\`
3143
+
3144
+ **\u91CD\u8981\u63D0\u793A**\uFF1A
3145
+ 1. action_id \u5FC5\u987B\u662F\u5B8C\u5168\u5339\u914D\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u7684\u67D0\u4E2A action_id \u7684\u7EAF\u5B57\u7B26\u4E32\uFF0C\u4E0D\u8981\u5305\u542B\u53C2\u6570\uFF08\u9519\u8BEF\u793A\u4F8B\uFF1A\u274C "place(row: 7, col: 6)"\uFF09
3146
+ 2. \u5982\u679C\u884C\u52A8\u9700\u8981\u53C2\u6570\uFF0Cparams \u5B57\u6BB5\u5FC5\u987B\u5305\u542B\u6240\u6709\u5FC5\u9700\u7684\u53C2\u6570
3147
+ 3. \u53C2\u6570\u7684\u7C7B\u578B\u548C\u503C\u5FC5\u987B\u7B26\u5408\u884C\u52A8\u5B9A\u4E49\u7684\u7EA6\u675F
3148
+ 4. \u53EA\u8FD4\u56DEJSON\u5BF9\u8C61\uFF0C\u4E0D\u8981\u5305\u542B\u5176\u4ED6\u6587\u5B57\u8BF4\u660E`;
3149
+ var TASK_PROMPT_WITH_MEMORY = `
3150
+ # \u4EFB\u52A1\u8981\u6C42
3151
+ \u8BF7\u5206\u6790\u5F53\u524D\u5C40\u52BF\uFF0C\u901A\u8FC7 reasoning \u601D\u8003\u5E76\u9009\u62E9\u4E00\u4E2A\u6700\u4F18\u884C\u52A8\u3002\u5E76\u53EF\u9009\u66F4\u65B0\u8BB0\u5FC6\u3002\u4F60\u5FC5\u987B\u4E25\u683C\u6309\u7167\u4EE5\u4E0BJSON\u683C\u5F0F\u8FD4\u56DE\uFF1A
3152
+
3153
+ \`\`\`json
3154
+ {
3155
+ "reasoning": "\u4F60\u7684\u51B3\u7B56\u601D\u8DEF",
3156
+ "action_id": "\u884C\u52A8ID\u5B57\u7B26\u4E32\uFF08\u4ECE\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u9009\u62E9\uFF0C\u4F8B\u5982 'place'\uFF09",
3157
+ "params": {
3158
+ "\u53C2\u6570\u540D": "\u53C2\u6570\u503C"
3159
+ },
3160
+ "memory_update": {
3161
+ "mode": "append/replace",
3162
+ "content": "\u672C\u56DE\u5408\u7684\u65B0\u89C2\u5BDF\u548C\u63A8\u7406\uFF08\u53EF\u9009\uFF09"
3163
+ }
3164
+ }
3165
+ \`\`\`
3166
+
3167
+
3168
+ **\u8BB0\u5FC6\u66F4\u65B0\u8BF4\u660E**\uFF1A
3169
+ - \u4F60\u53EF\u4EE5\u5728 \`memory_update\` \u5B57\u6BB5\u4E2D\u66F4\u65B0\u4F60\u7684\u8BB0\u5FC6
3170
+ - \`mode: "append"\` - \u8FFD\u52A0\u5230\u73B0\u6709\u8BB0\u5FC6
3171
+ - \`mode: "replace"\` - \u66FF\u6362\u8BB0\u5FC6\uFF0C\u5982\u91CD\u65B0\u603B\u7ED3\u5F62\u6210\u65B0\u7684\u8BB0\u5FC6
3172
+ - \u5982\u679C\u672C\u56DE\u5408\u65E0\u9700\u66F4\u65B0\u8BB0\u5FC6\uFF0C\u53EF\u7701\u7565 \`memory_update\` \u5B57\u6BB5
3173
+
3174
+
3175
+ **\u91CD\u8981\u63D0\u793A**\uFF1A
3176
+ 1. action_id \u5FC5\u987B\u662F\u5B8C\u5168\u5339\u914D\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u7684\u67D0\u4E2A action_id \u7684\u7EAF\u5B57\u7B26\u4E32\uFF0C\u4E0D\u8981\u5305\u542B\u53C2\u6570\uFF08\u9519\u8BEF\u793A\u4F8B\uFF1A\u274C "place(row: 7, col: 6)"\uFF09
3177
+ 2. \u5982\u679C\u884C\u52A8\u9700\u8981\u53C2\u6570\uFF0Cparams \u5B57\u6BB5\u5FC5\u987B\u5305\u542B\u6240\u6709\u5FC5\u9700\u7684\u53C2\u6570
3178
+ 3. \u53C2\u6570\u7684\u7C7B\u578B\u548C\u503C\u5FC5\u987B\u7B26\u5408\u884C\u52A8\u5B9A\u4E49\u7684\u7EA6\u675F
3179
+ 4. \u53EA\u8FD4\u56DEJSON\u5BF9\u8C61\uFF0C\u4E0D\u8981\u5305\u542B\u5176\u4ED6\u6587\u5B57\u8BF4\u660E`;
3180
+ var NO_TASK_PROMPT_WITH_MEMORY = `
3181
+ # \u4EFB\u52A1\u8981\u6C42
3182
+ \u5F53\u524D\u65E0\u9700\u884C\u52A8\uFF0C\u4F46\u4F60\u53EF\u4EE5\u6839\u636E\u6700\u65B0\u4FE1\u606F\u66F4\u65B0\u4F60\u7684\u8BB0\u5FC6\u3002\u4F60\u5FC5\u987B\u4E25\u683C\u6309\u7167\u4EE5\u4E0BJSON\u683C\u5F0F\u8FD4\u56DE\uFF0C\u4E0D\u8981\u5305\u542B\u5176\u4ED6\u6587\u5B57\u8BF4\u660E\uFF1A
3183
+
3184
+ \`\`\`json
3185
+ {
3186
+ "reasoning": "\u4F60\u7684\u89C2\u5BDF\u548C\u601D\u8003\uFF08\u53EF\u9009\uFF09",
3187
+ "memory_update": {
3188
+ "mode": "append",
3189
+ "content": "\u672C\u56DE\u5408\u7684\u65B0\u89C2\u5BDF\u3001\u63A8\u7406\u548C\u91CD\u8981\u4FE1\u606F"
3190
+ }
3191
+ }
3192
+ \`\`\`
3193
+
3194
+
3195
+ **\u8BB0\u5FC6\u66F4\u65B0\u8BF4\u660E**\uFF1A
3196
+ - \`mode: "append"\` - \u8FFD\u52A0\u5230\u73B0\u6709\u8BB0\u5FC6\uFF08\u63A8\u8350\uFF09
3197
+ - \`mode: "replace"\` - \u5B8C\u5168\u66FF\u6362\u8BB0\u5FC6\uFF0C\u9002\u5408\u91CD\u65B0\u603B\u7ED3\u65F6\u4F7F\u7528
3198
+ - \u5EFA\u8BAE\u8BB0\u5F55\uFF1A\u5176\u4ED6\u73A9\u5BB6\u7684\u884C\u52A8\u3001\u53D1\u8A00\u3001\u6295\u7968\u3001\u6B7B\u4EA1\u4FE1\u606F\u3001\u8EAB\u4EFD\u7EBF\u7D22\u7B49\u5173\u952E\u4FE1\u606F
3199
+ `;
3200
+
3209
3201
  // src/managers/action-processor.ts
3210
3202
  async function applySuccessfulAction(opts) {
3211
3203
  const {
@@ -3273,357 +3265,74 @@ async function applySuccessfulAction(opts) {
3273
3265
  })());
3274
3266
  }
3275
3267
 
3276
- // src/managers/executor.ts
3277
- var GameExecutor = class extends BaseManager {
3278
- async handleAdminStartGame(userId) {
3279
- if (!this.room.requireOwner(userId)) return;
3280
- if (this.room.phase !== "lobby" && this.room.phase !== "finished") {
3281
- return this.room.sendErrorToUser(userId, "Game cannot be started in current phase");
3282
- }
3283
- if (!this.room.gameConfig) {
3284
- return this.room.sendErrorToUser(userId, "No game selected");
3268
+ // src/managers/llm.ts
3269
+ var LlmManager = class extends BaseManager {
3270
+ async callLlmWebhook(roleId, retryCount = 0) {
3271
+ if (!this.room.llmWebhookUrl) {
3272
+ console.warn(`[LlmManager] Skipping LLM call: LLM_WEBHOOK_URL is not configured (roomId: ${this.room.roomId})`);
3273
+ return;
3285
3274
  }
3286
- const missingRoles = this.room.gameConfig.roleIds.filter(
3287
- (rId) => !this.room.roleMapping[rId]
3288
- );
3289
- if (missingRoles.length > 0) {
3290
- return this.room.sendErrorToUser(
3291
- userId,
3292
- `Missing role assignments: ${missingRoles.join(", ")}`
3293
- );
3275
+ const userId = this.room.roleMapping[roleId];
3276
+ const player = this.room.players[userId];
3277
+ if (!player || player.type !== "llm" || !player.llmConfig) {
3278
+ console.warn(`[LlmManager] Skipping LLM call: role ${roleId} is not mapped to an LLM player`);
3279
+ return;
3294
3280
  }
3281
+ console.log(`[LlmManager] Calling LLM webhook for role ${roleId} (attempt: ${retryCount + 1})`);
3282
+ const interactionGroupId = crypto.randomUUID();
3283
+ const interactionId = crypto.randomUUID();
3284
+ const startTs = Date.now();
3295
3285
  try {
3296
- for (const player of Object.values(this.room.players)) {
3297
- if (player.type === "llm" && player.llmConfig) {
3298
- player.llmConfig.memory = "";
3286
+ const perspective = await this.room.fetchPerspective(roleId);
3287
+ if (!perspective) {
3288
+ throw new Error("Failed to fetch perspective from game worker");
3289
+ }
3290
+ const statePrompt = await this.room.fetchStatePrompt(perspective);
3291
+ const hasActions = perspective.action_space_definition?.actions?.length > 0;
3292
+ const memoryEnabled = !!player.llmConfig.memory || player.llmConfig.memory === "";
3293
+ if (!hasActions) {
3294
+ if (!memoryEnabled) {
3295
+ console.log(`[LlmManager] Skipping turn for ${roleId}: No actions and memory disabled`);
3296
+ return;
3299
3297
  }
3298
+ console.log(`[LlmManager] No actions available, but memory enabled. Calling LLM for memory-only update.`);
3300
3299
  }
3301
- const client = resolveGameWorkerClient(
3302
- this.room.gameConfig.gameId,
3303
- this.room.gameConfig.gameWorkerUrl,
3304
- this.room.bindings
3305
- );
3306
- const initRes = await client.fetch("/init", {
3300
+ const userPrompt = this.buildUserPrompt(roleId, perspective, player.llmConfig, statePrompt || void 0);
3301
+ const reqBody = {
3302
+ roomId: this.room.roomId,
3303
+ roleId,
3304
+ gameId: this.room.gameConfig?.gameId || "",
3305
+ perspective,
3306
+ statePrompt: userPrompt,
3307
+ llmConfig: player.llmConfig,
3308
+ attempt: retryCount + 1,
3309
+ maxAttempts: 3
3310
+ };
3311
+ const response = await fetch(this.room.llmWebhookUrl, {
3307
3312
  method: "POST",
3308
- headers: { "Content-Type": "application/json" },
3309
- body: JSON.stringify({
3310
- players: this.room.gameConfig.roleIds
3311
- })
3313
+ headers: {
3314
+ "Content-Type": "application/json",
3315
+ "x-engine-secret": this.room.bindings.LLM_WEBHOOK_SECRET || ""
3316
+ },
3317
+ body: JSON.stringify(reqBody)
3312
3318
  });
3313
- if (!initRes.ok) {
3314
- const errText = await initRes.text();
3315
- return this.room.sendErrorToUser(
3319
+ if (!response.ok) {
3320
+ const errorText = await response.text();
3321
+ await this.logLlmInteraction(
3322
+ interactionId,
3323
+ interactionGroupId,
3324
+ roleId,
3316
3325
  userId,
3317
- `Game Worker init failed: ${errText}`
3326
+ player,
3327
+ userPrompt,
3328
+ null,
3329
+ null,
3330
+ "failed",
3331
+ errorText,
3332
+ Date.now() - startTs,
3333
+ retryCount + 1
3318
3334
  );
3319
- }
3320
- this.room.gameState = await initRes.json();
3321
- this.room.history = [];
3322
- this.room.runtimeId = crypto.randomUUID();
3323
- this.room.stateIndex = 0;
3324
- this.room.stateHistory = [{
3325
- index: 0,
3326
- name: "Initial State",
3327
- state: this.room.gameState,
3328
- timestamp: Date.now()
3329
- }];
3330
- this.room.phase = "playing";
3331
- await this.room.persistAll();
3332
- this.room.broadcastSyncState();
3333
- await this.room.checkAndTriggerNextTurn();
3334
- } catch (e) {
3335
- console.error("[GameExecutor] Failed to start game:", e);
3336
- this.room.sendErrorToUser(userId, "Failed to start game");
3337
- }
3338
- }
3339
- async handleAdminStopGame(userId) {
3340
- if (!this.room.requireOwner(userId)) return;
3341
- if (this.room.phase === "lobby") {
3342
- return this.room.sendErrorToUser(userId, "Game has not started");
3343
- }
3344
- this.room.phase = "lobby";
3345
- this.room.gameState = null;
3346
- this.room.history = [];
3347
- this.room.stateHistory = [];
3348
- await this.room.persist("phase", "gameState", "history", "stateHistory");
3349
- this.room.broadcastSyncState();
3350
- }
3351
- async handleAdminRestartGame(userId) {
3352
- if (!this.room.requireOwner(userId)) return;
3353
- if (this.room.phase !== "playing" && this.room.phase !== "paused" && this.room.phase !== "finished") {
3354
- return this.room.sendErrorToUser(userId, "Nothing to restart");
3355
- }
3356
- if (!this.room.gameConfig) {
3357
- return this.room.sendErrorToUser(userId, "No game selected");
3358
- }
3359
- try {
3360
- for (const player of Object.values(this.room.players)) {
3361
- if (player.type === "llm" && player.llmConfig) {
3362
- player.llmConfig.memory = "";
3363
- }
3364
- }
3365
- const client = resolveGameWorkerClient(
3366
- this.room.gameConfig.gameId,
3367
- this.room.gameConfig.gameWorkerUrl,
3368
- this.room.bindings
3369
- );
3370
- const initRes = await client.fetch("/init", {
3371
- method: "POST",
3372
- headers: { "Content-Type": "application/json" },
3373
- body: JSON.stringify({
3374
- players: Object.keys(this.room.roleMapping)
3375
- })
3376
- });
3377
- if (!initRes.ok) {
3378
- return this.room.sendErrorToUser(userId, "Game Worker init failed on restart");
3379
- }
3380
- this.room.gameState = await initRes.json();
3381
- this.room.history = [];
3382
- this.room.runtimeId = crypto.randomUUID();
3383
- this.room.stateIndex = 0;
3384
- this.room.stateHistory = [{
3385
- index: 0,
3386
- name: "Initial State",
3387
- state: this.room.gameState,
3388
- timestamp: Date.now()
3389
- }];
3390
- this.room.phase = "playing";
3391
- await this.room.persistAll();
3392
- this.room.broadcastSyncState();
3393
- await this.room.checkAndTriggerNextTurn();
3394
- } catch (e) {
3395
- console.error("[GameExecutor] Failed to restart game:", e);
3396
- this.room.sendErrorToUser(userId, "Failed to restart game");
3397
- }
3398
- }
3399
- async handleAction(userId, payload) {
3400
- const startedAt = Date.now();
3401
- if (this.room.phase !== "playing") {
3402
- return this.room.sendErrorToUser(userId, "Game is not in playing phase");
3403
- }
3404
- if (!this.room.gameConfig || !this.room.gameState) {
3405
- return this.room.sendErrorToUser(userId, "Game not initialized");
3406
- }
3407
- const roleId = getRoleForUser(this.room.roleMapping, userId);
3408
- if (!roleId) {
3409
- return this.room.sendErrorToUser(userId, "You don't have a role assigned");
3410
- }
3411
- const currentRoleId = await this.room.getCurrentRole();
3412
- if (currentRoleId !== roleId) {
3413
- return this.room.sendErrorToUser(userId, "Not your turn");
3414
- }
3415
- const result = await this.room.submitActionToGameWorker(roleId, {
3416
- action_id: payload.action_id,
3417
- params: payload.params || {}
3418
- });
3419
- const interactionId = crypto.randomUUID();
3420
- const baseHumanLog = {
3421
- interactionId,
3422
- interactionGroupId: interactionId,
3423
- roomId: this.room.roomId,
3424
- gameId: this.room.gameConfig.gameId || null,
3425
- gameName: null,
3426
- roleId,
3427
- userId,
3428
- playerType: "human",
3429
- modelName: null,
3430
- systemPrompt: null,
3431
- userPrompt: null,
3432
- response: JSON.stringify({ action_id: payload.action_id, params: payload.params || {} }),
3433
- action_id: payload.action_id,
3434
- actionParams: payload.params || {},
3435
- attempt: 1,
3436
- outerAttempt: 1,
3437
- maxAttempts: 1,
3438
- previousError: null,
3439
- responseTimeMs: Date.now() - startedAt,
3440
- eventTs: Date.now()
3441
- };
3442
- if (!result.success) {
3443
- this.room.waitUntil(insertMonitorLog(this.room.bindings.DB, {
3444
- ...baseHumanLog,
3445
- status: "rejected",
3446
- errorMessage: result.error || "Action rejected"
3447
- }));
3448
- return this.room.sendErrorToUser(userId, result.error || "Action rejected");
3449
- }
3450
- this.room.waitUntil(insertMonitorLog(this.room.bindings.DB, {
3451
- ...baseHumanLog,
3452
- status: "success"
3453
- }));
3454
- await applySuccessfulAction({
3455
- room: this.room,
3456
- roleId,
3457
- action: { action_id: payload.action_id, params: payload.params || {} },
3458
- nextState: result.nextState,
3459
- commands: result.commands,
3460
- triggerMode: "await"
3461
- });
3462
- console.log(`[GameExecutor] Action processed for ${userId} (${roleId}), checking next turn...`);
3463
- }
3464
- };
3465
-
3466
- // src/managers/task-prompts.ts
3467
- var TASK_PROMPT_NO_MEMORY = `
3468
- # \u4EFB\u52A1\u8981\u6C42
3469
- \u8BF7\u5206\u6790\u5F53\u524D\u5C40\u52BF\uFF0C\u9009\u62E9\u4E00\u4E2A\u6700\u4F18\u884C\u52A8\u3002\u4F60\u5FC5\u987B\u4E25\u683C\u6309\u7167\u4EE5\u4E0BJSON\u683C\u5F0F\u8FD4\u56DE\uFF1A
3470
-
3471
- \`\`\`json
3472
- {
3473
- "reasoning": "\u4F60\u7684\u51B3\u7B56\u601D\u8DEF",
3474
- "action_id": "\u884C\u52A8ID\u5B57\u7B26\u4E32\uFF08\u4ECE\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u9009\u62E9\uFF0C\u4F8B\u5982 'place'\uFF09",
3475
- "params": {
3476
- "\u53C2\u6570\u540D": "\u53C2\u6570\u503C"
3477
- }
3478
- }
3479
- \`\`\`
3480
-
3481
- **\u91CD\u8981\u63D0\u793A**\uFF1A
3482
- 1. action_id \u5FC5\u987B\u662F\u5B8C\u5168\u5339\u914D\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u7684\u67D0\u4E2A action_id \u7684\u7EAF\u5B57\u7B26\u4E32\uFF0C\u4E0D\u8981\u5305\u542B\u53C2\u6570\uFF08\u9519\u8BEF\u793A\u4F8B\uFF1A\u274C "place(row: 7, col: 6)"\uFF09
3483
- 2. \u5982\u679C\u884C\u52A8\u9700\u8981\u53C2\u6570\uFF0Cparams \u5B57\u6BB5\u5FC5\u987B\u5305\u542B\u6240\u6709\u5FC5\u9700\u7684\u53C2\u6570
3484
- 3. \u53C2\u6570\u7684\u7C7B\u578B\u548C\u503C\u5FC5\u987B\u7B26\u5408\u884C\u52A8\u5B9A\u4E49\u7684\u7EA6\u675F
3485
- 4. \u53EA\u8FD4\u56DEJSON\u5BF9\u8C61\uFF0C\u4E0D\u8981\u5305\u542B\u5176\u4ED6\u6587\u5B57\u8BF4\u660E`;
3486
- var TASK_PROMPT_WITH_MEMORY = `
3487
- # \u4EFB\u52A1\u8981\u6C42
3488
- \u8BF7\u5206\u6790\u5F53\u524D\u5C40\u52BF\uFF0C\u901A\u8FC7 reasoning \u601D\u8003\u5E76\u9009\u62E9\u4E00\u4E2A\u6700\u4F18\u884C\u52A8\u3002\u5E76\u53EF\u9009\u66F4\u65B0\u8BB0\u5FC6\u3002\u4F60\u5FC5\u987B\u4E25\u683C\u6309\u7167\u4EE5\u4E0BJSON\u683C\u5F0F\u8FD4\u56DE\uFF1A
3489
-
3490
- \`\`\`json
3491
- {
3492
- "reasoning": "\u4F60\u7684\u51B3\u7B56\u601D\u8DEF",
3493
- "action_id": "\u884C\u52A8ID\u5B57\u7B26\u4E32\uFF08\u4ECE\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u9009\u62E9\uFF0C\u4F8B\u5982 'place'\uFF09",
3494
- "params": {
3495
- "\u53C2\u6570\u540D": "\u53C2\u6570\u503C"
3496
- },
3497
- "memory_update": {
3498
- "mode": "append/replace",
3499
- "content": "\u672C\u56DE\u5408\u7684\u65B0\u89C2\u5BDF\u548C\u63A8\u7406\uFF08\u53EF\u9009\uFF09"
3500
- }
3501
- }
3502
- \`\`\`
3503
-
3504
-
3505
- **\u8BB0\u5FC6\u66F4\u65B0\u8BF4\u660E**\uFF1A
3506
- - \u4F60\u53EF\u4EE5\u5728 \`memory_update\` \u5B57\u6BB5\u4E2D\u66F4\u65B0\u4F60\u7684\u8BB0\u5FC6
3507
- - \`mode: "append"\` - \u8FFD\u52A0\u5230\u73B0\u6709\u8BB0\u5FC6
3508
- - \`mode: "replace"\` - \u66FF\u6362\u8BB0\u5FC6\uFF0C\u5982\u91CD\u65B0\u603B\u7ED3\u5F62\u6210\u65B0\u7684\u8BB0\u5FC6
3509
- - \u5982\u679C\u672C\u56DE\u5408\u65E0\u9700\u66F4\u65B0\u8BB0\u5FC6\uFF0C\u53EF\u7701\u7565 \`memory_update\` \u5B57\u6BB5
3510
-
3511
-
3512
- **\u91CD\u8981\u63D0\u793A**\uFF1A
3513
- 1. action_id \u5FC5\u987B\u662F\u5B8C\u5168\u5339\u914D\u4E0A\u8FF0\u53EF\u7528\u884C\u52A8\u5217\u8868\u4E2D\u7684\u67D0\u4E2A action_id \u7684\u7EAF\u5B57\u7B26\u4E32\uFF0C\u4E0D\u8981\u5305\u542B\u53C2\u6570\uFF08\u9519\u8BEF\u793A\u4F8B\uFF1A\u274C "place(row: 7, col: 6)"\uFF09
3514
- 2. \u5982\u679C\u884C\u52A8\u9700\u8981\u53C2\u6570\uFF0Cparams \u5B57\u6BB5\u5FC5\u987B\u5305\u542B\u6240\u6709\u5FC5\u9700\u7684\u53C2\u6570
3515
- 3. \u53C2\u6570\u7684\u7C7B\u578B\u548C\u503C\u5FC5\u987B\u7B26\u5408\u884C\u52A8\u5B9A\u4E49\u7684\u7EA6\u675F
3516
- 4. \u53EA\u8FD4\u56DEJSON\u5BF9\u8C61\uFF0C\u4E0D\u8981\u5305\u542B\u5176\u4ED6\u6587\u5B57\u8BF4\u660E`;
3517
- var NO_TASK_PROMPT_WITH_MEMORY = `
3518
- # \u4EFB\u52A1\u8981\u6C42
3519
- \u5F53\u524D\u65E0\u9700\u884C\u52A8\uFF0C\u4F46\u4F60\u53EF\u4EE5\u6839\u636E\u6700\u65B0\u4FE1\u606F\u66F4\u65B0\u4F60\u7684\u8BB0\u5FC6\u3002\u4F60\u5FC5\u987B\u4E25\u683C\u6309\u7167\u4EE5\u4E0BJSON\u683C\u5F0F\u8FD4\u56DE\uFF0C\u4E0D\u8981\u5305\u542B\u5176\u4ED6\u6587\u5B57\u8BF4\u660E\uFF1A
3520
-
3521
- \`\`\`json
3522
- {
3523
- "reasoning": "\u4F60\u7684\u89C2\u5BDF\u548C\u601D\u8003\uFF08\u53EF\u9009\uFF09",
3524
- "memory_update": {
3525
- "mode": "append",
3526
- "content": "\u672C\u56DE\u5408\u7684\u65B0\u89C2\u5BDF\u3001\u63A8\u7406\u548C\u91CD\u8981\u4FE1\u606F"
3527
- }
3528
- }
3529
- \`\`\`
3530
-
3531
-
3532
- **\u8BB0\u5FC6\u66F4\u65B0\u8BF4\u660E**\uFF1A
3533
- - \`mode: "append"\` - \u8FFD\u52A0\u5230\u73B0\u6709\u8BB0\u5FC6\uFF08\u63A8\u8350\uFF09
3534
- - \`mode: "replace"\` - \u5B8C\u5168\u66FF\u6362\u8BB0\u5FC6\uFF0C\u9002\u5408\u91CD\u65B0\u603B\u7ED3\u65F6\u4F7F\u7528
3535
- - \u5EFA\u8BAE\u8BB0\u5F55\uFF1A\u5176\u4ED6\u73A9\u5BB6\u7684\u884C\u52A8\u3001\u53D1\u8A00\u3001\u6295\u7968\u3001\u6B7B\u4EA1\u4FE1\u606F\u3001\u8EAB\u4EFD\u7EBF\u7D22\u7B49\u5173\u952E\u4FE1\u606F
3536
- `;
3537
-
3538
- // src/managers/llm.ts
3539
- var LlmManager = class extends BaseManager {
3540
- async handleAdminAddBot(userId, payload) {
3541
- if (!this.room.requireOwner(userId)) return;
3542
- if (this.room.phase !== "lobby") {
3543
- return this.room.sendErrorToUser(userId, "Can only add bots in lobby phase");
3544
- }
3545
- const llmUserId = generateLlmUserId(payload.modelName);
3546
- this.room.players[llmUserId] = {
3547
- displayName: payload.displayName,
3548
- connected: false,
3549
- isOwner: false,
3550
- type: "llm",
3551
- llmConfig: {
3552
- modelName: payload.modelName,
3553
- systemPrompt: payload.systemPrompt || "\u4F60\u662F\u4E00\u4E2A\u806A\u660E\u7684\u6E38\u620F\u73A9\u5BB6",
3554
- temperature: payload.temperature ?? 0.7,
3555
- memory: ""
3556
- }
3557
- };
3558
- await this.room.persist("players");
3559
- this.room.broadcastSyncState();
3560
- }
3561
- async callLlmWebhook(roleId, retryCount = 0) {
3562
- if (!this.room.llmWebhookUrl) {
3563
- console.warn(`[LlmManager] Skipping LLM call: LLM_WEBHOOK_URL is not configured (roomId: ${this.room.roomId})`);
3564
- return;
3565
- }
3566
- const userId = this.room.roleMapping[roleId];
3567
- const player = this.room.players[userId];
3568
- if (!player || player.type !== "llm" || !player.llmConfig) {
3569
- console.warn(`[LlmManager] Skipping LLM call: role ${roleId} is not mapped to an LLM player`);
3570
- return;
3571
- }
3572
- console.log(`[LlmManager] Calling LLM webhook for role ${roleId} (attempt: ${retryCount + 1})`);
3573
- const interactionGroupId = crypto.randomUUID();
3574
- const interactionId = crypto.randomUUID();
3575
- const startTs = Date.now();
3576
- try {
3577
- const perspective = await this.room.fetchPerspective(roleId);
3578
- if (!perspective) {
3579
- throw new Error("Failed to fetch perspective from game worker");
3580
- }
3581
- const statePrompt = await this.room.fetchStatePrompt(perspective);
3582
- const hasActions = perspective.action_space_definition?.actions?.length > 0;
3583
- const memoryEnabled = !!player.llmConfig.memory || player.llmConfig.memory === "";
3584
- if (!hasActions) {
3585
- if (!memoryEnabled) {
3586
- console.log(`[LlmManager] Skipping turn for ${roleId}: No actions and memory disabled`);
3587
- return;
3588
- }
3589
- console.log(`[LlmManager] No actions available, but memory enabled. Calling LLM for memory-only update.`);
3590
- }
3591
- const userPrompt = this.buildUserPrompt(roleId, perspective, player.llmConfig, statePrompt || void 0);
3592
- const reqBody = {
3593
- roomId: this.room.roomId,
3594
- roleId,
3595
- gameId: this.room.gameConfig?.gameId || "",
3596
- perspective,
3597
- statePrompt: userPrompt,
3598
- llmConfig: player.llmConfig,
3599
- attempt: retryCount + 1,
3600
- maxAttempts: 3
3601
- };
3602
- const response = await fetch(this.room.llmWebhookUrl, {
3603
- method: "POST",
3604
- headers: {
3605
- "Content-Type": "application/json",
3606
- "x-engine-secret": this.room.bindings.LLM_WEBHOOK_SECRET || ""
3607
- },
3608
- body: JSON.stringify(reqBody)
3609
- });
3610
- if (!response.ok) {
3611
- const errorText = await response.text();
3612
- await this.logLlmInteraction(
3613
- interactionId,
3614
- interactionGroupId,
3615
- roleId,
3616
- userId,
3617
- player,
3618
- userPrompt,
3619
- null,
3620
- null,
3621
- "failed",
3622
- errorText,
3623
- Date.now() - startTs,
3624
- retryCount + 1
3625
- );
3626
- throw new Error(`Webhook failed: ${response.statusText} - ${errorText}`);
3335
+ throw new Error(`Webhook failed: ${response.statusText} - ${errorText}`);
3627
3336
  }
3628
3337
  const resultJson = await response.json();
3629
3338
  const rawContent = resultJson.content;
@@ -3767,11 +3476,7 @@ var LlmManager = class extends BaseManager {
3767
3476
  if (workerStatePrompt) {
3768
3477
  statePrompt = workerStatePrompt;
3769
3478
  } else {
3770
- statePrompt = perspective.message ? `# \u73AF\u5883\u4FE1\u606F
3771
- ${perspective.message}
3772
-
3773
- ` : "";
3774
- statePrompt += `# \u5F53\u524D\u6E38\u620F\u72B6\u6001
3479
+ statePrompt = `# \u5F53\u524D\u6E38\u620F\u72B6\u6001
3775
3480
  ${JSON.stringify(perspective.current_state, null, 2)}`;
3776
3481
  }
3777
3482
  const memoryEnabled = llmConfig.memory !== void 0 && llmConfig.memory !== null && this.room.gameConfig?.enable_llm_memory !== false;
@@ -3882,66 +3587,452 @@ ${NO_TASK_PROMPT_WITH_MEMORY}`;
3882
3587
  if (schema.enum && !schema.enum.includes(value)) return { valid: false, error: `\u53C2\u6570 "${key}" \u7684\u503C\u65E0\u6548\u3002\u53EF\u9009\uFF1A${schema.enum.join(", ")}` };
3883
3588
  }
3884
3589
  }
3885
- }
3886
- return { valid: true };
3590
+ }
3591
+ return { valid: true };
3592
+ }
3593
+ extractJsonFromText(raw2) {
3594
+ const trimmed = raw2.trim();
3595
+ const match2 = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
3596
+ if (match2?.[1]) {
3597
+ return match2[1].trim();
3598
+ }
3599
+ const firstBrace = trimmed.indexOf("{");
3600
+ const lastBrace = trimmed.lastIndexOf("}");
3601
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
3602
+ return trimmed.slice(firstBrace, lastBrace + 1);
3603
+ }
3604
+ return trimmed;
3605
+ }
3606
+ isRecord(value) {
3607
+ return typeof value === "object" && value !== null;
3608
+ }
3609
+ async logLlmInteraction(id, groupId, roleId, userId, player, userPrompt, rawResponse, res, status, error, duration, attempt) {
3610
+ try {
3611
+ await insertMonitorLog(this.room.bindings.DB, {
3612
+ interactionId: id,
3613
+ interactionGroupId: groupId,
3614
+ roomId: this.room.roomId,
3615
+ gameId: this.room.gameConfig?.gameId,
3616
+ roleId,
3617
+ userId,
3618
+ playerType: "llm",
3619
+ modelName: player.llmConfig?.modelName,
3620
+ systemPrompt: player.llmConfig?.systemPrompt,
3621
+ userPrompt,
3622
+ response: rawResponse,
3623
+ action_id: res?.action?.action_id,
3624
+ actionParams: res?.action?.params,
3625
+ status,
3626
+ attempt,
3627
+ outerAttempt: 1,
3628
+ maxAttempts: 3,
3629
+ errorMessage: error,
3630
+ responseTimeMs: duration,
3631
+ eventTs: Date.now()
3632
+ });
3633
+ } catch (e) {
3634
+ console.error("[LlmManager] Failed to record monitor log:", e);
3635
+ }
3636
+ }
3637
+ };
3638
+
3639
+ // src/commands/room-commands.ts
3640
+ var WORKER_VERIFY_PATH = "/__nexus_worker_verify";
3641
+ var WORKER_VERIFY_SIGNATURE = "NEXUS_GAME_WORKER_VERIFIED_V1";
3642
+ function fail(error, status = 400) {
3643
+ return { ok: false, error, status };
3644
+ }
3645
+ var RoomCommandService = class {
3646
+ constructor(room) {
3647
+ this.room = room;
3648
+ }
3649
+ // ─── Game configuration ────────────────────────────────
3650
+ async setGame(input) {
3651
+ if (!input.gameWorkerUrl) return fail("gameWorkerUrl is required");
3652
+ if (this.room.phase !== "lobby") {
3653
+ return fail("Can only set game in lobby phase");
3654
+ }
3655
+ try {
3656
+ const baseUrl = input.gameWorkerUrl.replace(/\/$/, "");
3657
+ const client = resolveGameWorkerClient(
3658
+ input.gameId || "",
3659
+ baseUrl,
3660
+ this.room.bindings
3661
+ );
3662
+ const verifyRes = await client.fetch(WORKER_VERIFY_PATH);
3663
+ if (!verifyRes.ok) {
3664
+ return fail("Game worker verification failed", 502);
3665
+ }
3666
+ const signature = (await verifyRes.text()).trim();
3667
+ if (signature !== WORKER_VERIFY_SIGNATURE) {
3668
+ return fail("Game worker verification signature mismatch", 502);
3669
+ }
3670
+ const metaRes = await client.fetch("/metadata");
3671
+ if (!metaRes.ok) {
3672
+ const errText = await metaRes.text();
3673
+ console.error("[RoomCommands] Metadata fetch failed:", metaRes.status, errText);
3674
+ return fail("Failed to fetch game metadata", 502);
3675
+ }
3676
+ const metadata = await metaRes.json();
3677
+ const resolved = resolveRoleIds(metadata, input.selectedPlayerCount);
3678
+ if (!resolved) {
3679
+ return fail("Game metadata has no roleIds", 502);
3680
+ }
3681
+ this.room.gameConfig = {
3682
+ gameWorkerUrl: baseUrl,
3683
+ gameId: input.gameId || metadata.id,
3684
+ maxPlayers: resolved.roleIds.length,
3685
+ roleIds: resolved.roleIds,
3686
+ selectedPlayerCount: resolved.selectedPlayerCount,
3687
+ enable_llm_memory: metadata.enable_llm_memory,
3688
+ auto_save_mode: metadata.auto_save_mode
3689
+ };
3690
+ this.room.roleMapping = {};
3691
+ await this.room.persist("gameConfig", "roleMapping");
3692
+ this.room.broadcastSyncState();
3693
+ this.room.waitUntil(this.room.syncRoomMeta());
3694
+ return {
3695
+ ok: true,
3696
+ data: { gameId: this.room.gameConfig.gameId, roleIds: resolved.roleIds }
3697
+ };
3698
+ } catch (e) {
3699
+ console.error("[RoomCommands] setGame failed:", e);
3700
+ return fail(e.message || "Failed to set game", 500);
3701
+ }
3702
+ }
3703
+ // ─── Lifecycle ─────────────────────────────────────────
3704
+ async startGame() {
3705
+ if (this.room.phase !== "lobby" && this.room.phase !== "finished") {
3706
+ return fail("Game cannot be started in current phase");
3707
+ }
3708
+ const config = this.room.gameConfig;
3709
+ if (!config) return fail("No game selected");
3710
+ const missingRoles = config.roleIds.filter((roleId) => !this.room.roleMapping[roleId]);
3711
+ if (missingRoles.length > 0) {
3712
+ return fail(`Missing role assignments: ${missingRoles.join(", ")}`);
3713
+ }
3714
+ return this.initializeGameState("Game Worker init failed");
3715
+ }
3716
+ async restartGame() {
3717
+ if (this.room.phase !== "playing" && this.room.phase !== "paused" && this.room.phase !== "finished") {
3718
+ return fail("Nothing to restart");
3719
+ }
3720
+ if (!this.room.gameConfig) return fail("No game selected");
3721
+ return this.initializeGameState("Game Worker init failed on restart");
3722
+ }
3723
+ async stopGame() {
3724
+ if (this.room.phase === "lobby") {
3725
+ return fail("Game has not started");
3726
+ }
3727
+ this.room.phase = "lobby";
3728
+ this.room.gameState = null;
3729
+ this.room.history = [];
3730
+ this.room.stateHistory = [];
3731
+ await this.room.persist("phase", "gameState", "history", "stateHistory");
3732
+ this.room.broadcastSyncState();
3733
+ this.room.waitUntil(this.room.syncRoomMeta());
3734
+ return { ok: true, data: { phase: this.room.phase } };
3735
+ }
3736
+ async pauseGame() {
3737
+ if (this.room.phase !== "playing") {
3738
+ return fail("Can only pause a playing game");
3739
+ }
3740
+ this.room.phase = "paused";
3741
+ await this.room.persist("phase");
3742
+ this.room.broadcastSyncState();
3743
+ this.room.waitUntil(this.room.syncRoomMeta());
3744
+ return { ok: true, data: { phase: this.room.phase } };
3745
+ }
3746
+ async resumeGame() {
3747
+ if (this.room.phase !== "paused") {
3748
+ return fail("Game is not paused");
3749
+ }
3750
+ this.room.phase = "playing";
3751
+ await this.room.persist("phase");
3752
+ this.room.broadcastSyncState();
3753
+ this.room.waitUntil(this.room.syncRoomMeta());
3754
+ this.room.waitUntil(this.room.checkAndTriggerNextTurn());
3755
+ return { ok: true, data: { phase: this.room.phase } };
3756
+ }
3757
+ async backtrack(index) {
3758
+ if (this.room.phase !== "paused") {
3759
+ return fail("Game must be paused to backtrack");
3760
+ }
3761
+ if (!Number.isInteger(index)) {
3762
+ return fail("Invalid state index");
3763
+ }
3764
+ const targetState = this.room.stateHistory.find((entry) => entry.index === index);
3765
+ if (!targetState) {
3766
+ return fail("Invalid state index");
3767
+ }
3768
+ this.room.gameState = targetState.state;
3769
+ this.room.stateIndex = targetState.index;
3770
+ this.room.history = this.room.history.filter((event) => event.stateIndex <= targetState.index);
3771
+ await this.room.persist("gameState", "history", "stateIndex");
3772
+ this.room.broadcastSyncState();
3773
+ return { ok: true };
3774
+ }
3775
+ // ─── Room metadata ─────────────────────────────────────
3776
+ async updateRoomMeta(input) {
3777
+ const name = input.name?.trim();
3778
+ if (!name) return fail("Room name is required");
3779
+ this.room.name = name;
3780
+ this.room.isPublic = input.isPublic;
3781
+ await this.room.persist("name", "isPublic");
3782
+ this.room.broadcastSyncState();
3783
+ this.room.waitUntil(this.room.syncRoomMeta());
3784
+ return { ok: true };
3785
+ }
3786
+ // ─── Players & roles ───────────────────────────────────
3787
+ /**
3788
+ * Create a human player without a WebSocket (used by the local console and
3789
+ * CLI to represent `dev:<roleId>`). Idempotent so repeated setups work.
3790
+ */
3791
+ async createPlayer(input) {
3792
+ if (this.room.phase !== "lobby") {
3793
+ return fail("Can only manage players in lobby phase");
3794
+ }
3795
+ const userId = input.userId || (input.roleId ? `dev:${input.roleId}` : "");
3796
+ if (!userId) return fail("userId or roleId is required");
3797
+ if (this.room.players[userId]) {
3798
+ return { ok: true, data: { userId, created: false } };
3799
+ }
3800
+ this.room.players[userId] = {
3801
+ displayName: input.displayName || input.roleId || userId,
3802
+ connected: false,
3803
+ isOwner: false,
3804
+ type: "human"
3805
+ };
3806
+ await this.room.persist("players");
3807
+ this.room.broadcastSyncState();
3808
+ return { ok: true, data: { userId, created: true } };
3809
+ }
3810
+ async removePlayer(targetUserId) {
3811
+ if (this.room.phase !== "lobby") {
3812
+ return fail("Can only remove players in lobby phase");
3813
+ }
3814
+ if (targetUserId === this.room.ownerId) {
3815
+ return fail("Cannot remove the room owner");
3816
+ }
3817
+ if (!this.room.players[targetUserId]) {
3818
+ return fail("Player not found");
3819
+ }
3820
+ for (const [roleId, userId] of Object.entries(this.room.roleMapping)) {
3821
+ if (userId === targetUserId) {
3822
+ delete this.room.roleMapping[roleId];
3823
+ }
3824
+ }
3825
+ delete this.room.players[targetUserId];
3826
+ const targetWs = this.room.sessions.get(targetUserId);
3827
+ if (targetWs) {
3828
+ this.room.sendMessage(targetWs, { type: "KICKED", payload: "Removed by room owner" });
3829
+ try {
3830
+ targetWs.close(1e3, "Kicked");
3831
+ } catch (_) {
3832
+ }
3833
+ this.room.connections.delete(targetWs);
3834
+ this.room.sessions.delete(targetUserId);
3835
+ }
3836
+ await this.room.persist("players", "roleMapping");
3837
+ this.room.broadcastSyncState();
3838
+ return { ok: true };
3839
+ }
3840
+ async assignRole(input) {
3841
+ if (this.room.phase !== "lobby") {
3842
+ return fail("Can only assign roles in lobby phase");
3843
+ }
3844
+ if (!this.room.players[input.userId]) {
3845
+ return fail(`Player ${input.userId} not found`);
3846
+ }
3847
+ if (this.room.gameConfig && !this.room.gameConfig.roleIds.includes(input.roleId)) {
3848
+ return fail(`Invalid role: ${input.roleId}`);
3849
+ }
3850
+ for (const [roleId, userId] of Object.entries(this.room.roleMapping)) {
3851
+ if (userId === input.userId) {
3852
+ delete this.room.roleMapping[roleId];
3853
+ }
3854
+ }
3855
+ delete this.room.roleMapping[input.roleId];
3856
+ this.room.roleMapping[input.roleId] = input.userId;
3857
+ await this.room.persist("roleMapping");
3858
+ this.room.broadcastSyncState();
3859
+ return { ok: true };
3860
+ }
3861
+ async addBot(input) {
3862
+ if (this.room.phase !== "lobby") {
3863
+ return fail("Can only add bots in lobby phase");
3864
+ }
3865
+ if (!input.displayName || !input.modelName) {
3866
+ return fail("displayName and modelName are required");
3867
+ }
3868
+ const llmUserId = generateLlmUserId(input.modelName);
3869
+ this.room.players[llmUserId] = {
3870
+ displayName: input.displayName,
3871
+ connected: false,
3872
+ isOwner: false,
3873
+ type: "llm",
3874
+ llmConfig: {
3875
+ modelName: input.modelName,
3876
+ systemPrompt: input.systemPrompt || "\u4F60\u662F\u4E00\u4E2A\u806A\u660E\u7684\u6E38\u620F\u73A9\u5BB6",
3877
+ temperature: input.temperature ?? 0.7,
3878
+ memory: ""
3879
+ }
3880
+ };
3881
+ await this.room.persist("players");
3882
+ this.room.broadcastSyncState();
3883
+ return { ok: true, data: { userId: llmUserId } };
3887
3884
  }
3888
- extractJsonFromText(raw2) {
3889
- const trimmed = raw2.trim();
3890
- const match2 = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
3891
- if (match2?.[1]) {
3892
- return match2[1].trim();
3885
+ // ─── Actions ───────────────────────────────────────────
3886
+ async submitAction(input) {
3887
+ if (this.room.phase !== "playing") {
3888
+ return fail("Game is not in playing phase");
3893
3889
  }
3894
- const firstBrace = trimmed.indexOf("{");
3895
- const lastBrace = trimmed.lastIndexOf("}");
3896
- if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
3897
- return trimmed.slice(firstBrace, lastBrace + 1);
3890
+ if (!this.room.gameConfig || !this.room.gameState) {
3891
+ return fail("Game not initialized");
3898
3892
  }
3899
- return trimmed;
3900
- }
3901
- isRecord(value) {
3902
- return typeof value === "object" && value !== null;
3893
+ if (!input.action?.action_id) {
3894
+ return fail("action_id is required");
3895
+ }
3896
+ const config = this.room.gameConfig;
3897
+ let roleId;
3898
+ if (input.mode === "operator") {
3899
+ if (!input.roleId) return fail("roleId is required for operator actions");
3900
+ if (!config.roleIds.includes(input.roleId)) {
3901
+ return fail(`Invalid role: ${input.roleId}`);
3902
+ }
3903
+ roleId = input.roleId;
3904
+ } else {
3905
+ const assignedRole = getRoleForUser(this.room.roleMapping, input.userId);
3906
+ if (!assignedRole) {
3907
+ return fail("You don't have a role assigned");
3908
+ }
3909
+ const currentRoleId = await this.room.getCurrentRole();
3910
+ if (currentRoleId !== assignedRole) {
3911
+ return fail("Not your turn");
3912
+ }
3913
+ roleId = assignedRole;
3914
+ }
3915
+ const startedAt = Date.now();
3916
+ const action = {
3917
+ action_id: input.action.action_id,
3918
+ params: input.action.params || {}
3919
+ };
3920
+ const result = await this.room.submitActionToGameWorker(roleId, action);
3921
+ const interactionId = crypto.randomUUID();
3922
+ const baseHumanLog = {
3923
+ interactionId,
3924
+ interactionGroupId: interactionId,
3925
+ roomId: this.room.roomId,
3926
+ gameId: config.gameId || null,
3927
+ gameName: null,
3928
+ roleId,
3929
+ userId: input.mode === "operator" ? "operator" : input.userId,
3930
+ playerType: "human",
3931
+ modelName: null,
3932
+ systemPrompt: null,
3933
+ userPrompt: null,
3934
+ response: JSON.stringify(action),
3935
+ action_id: action.action_id,
3936
+ actionParams: action.params,
3937
+ attempt: 1,
3938
+ outerAttempt: 1,
3939
+ maxAttempts: 1,
3940
+ previousError: null,
3941
+ responseTimeMs: Date.now() - startedAt,
3942
+ eventTs: Date.now()
3943
+ };
3944
+ if (!result.success) {
3945
+ this.room.waitUntil(insertMonitorLog(this.room.bindings.DB, {
3946
+ ...baseHumanLog,
3947
+ status: "rejected",
3948
+ errorMessage: result.error || "Action rejected"
3949
+ }));
3950
+ return fail(result.error || "Action rejected");
3951
+ }
3952
+ this.room.waitUntil(insertMonitorLog(this.room.bindings.DB, {
3953
+ ...baseHumanLog,
3954
+ status: "success"
3955
+ }));
3956
+ await applySuccessfulAction({
3957
+ room: this.room,
3958
+ roleId,
3959
+ action,
3960
+ nextState: result.nextState,
3961
+ commands: result.commands,
3962
+ triggerMode: "await"
3963
+ });
3964
+ return {
3965
+ ok: true,
3966
+ data: { nextState: result.nextState, commands: result.commands }
3967
+ };
3903
3968
  }
3904
- async logLlmInteraction(id, groupId, roleId, userId, player, userPrompt, rawResponse, res, status, error, duration, attempt) {
3969
+ // ─── Internals ─────────────────────────────────────────
3970
+ async initializeGameState(errorPrefix) {
3971
+ const config = this.room.gameConfig;
3972
+ if (!config) return fail("No game selected");
3905
3973
  try {
3906
- await insertMonitorLog(this.room.bindings.DB, {
3907
- interactionId: id,
3908
- interactionGroupId: groupId,
3909
- roomId: this.room.roomId,
3910
- gameId: this.room.gameConfig?.gameId,
3911
- roleId,
3912
- userId,
3913
- playerType: "llm",
3914
- modelName: player.llmConfig?.modelName,
3915
- systemPrompt: player.llmConfig?.systemPrompt,
3916
- userPrompt,
3917
- response: rawResponse,
3918
- action_id: res?.action?.action_id,
3919
- actionParams: res?.action?.params,
3920
- status,
3921
- attempt,
3922
- outerAttempt: 1,
3923
- maxAttempts: 3,
3924
- errorMessage: error,
3925
- responseTimeMs: duration,
3926
- eventTs: Date.now()
3974
+ for (const player of Object.values(this.room.players)) {
3975
+ if (player.type === "llm" && player.llmConfig) {
3976
+ player.llmConfig.memory = "";
3977
+ }
3978
+ }
3979
+ const client = resolveGameWorkerClient(
3980
+ config.gameId,
3981
+ config.gameWorkerUrl,
3982
+ this.room.bindings
3983
+ );
3984
+ const initRes = await client.fetch("/init", {
3985
+ method: "POST",
3986
+ headers: { "Content-Type": "application/json" },
3987
+ body: JSON.stringify({ players: config.roleIds })
3927
3988
  });
3989
+ if (!initRes.ok) {
3990
+ const errText = await initRes.text();
3991
+ return fail(`${errorPrefix}: ${errText}`, 502);
3992
+ }
3993
+ this.room.gameState = await initRes.json();
3994
+ this.room.history = [];
3995
+ this.room.runtimeId = crypto.randomUUID();
3996
+ this.room.stateIndex = 0;
3997
+ this.room.stateHistory = [{
3998
+ index: 0,
3999
+ name: "Initial State",
4000
+ state: this.room.gameState,
4001
+ timestamp: Date.now()
4002
+ }];
4003
+ this.room.phase = "playing";
4004
+ await this.room.persistAll();
4005
+ this.room.broadcastSyncState();
4006
+ await this.room.checkAndTriggerNextTurn();
4007
+ this.room.waitUntil(this.room.syncRoomMeta());
4008
+ return { ok: true, data: { phase: this.room.phase } };
3928
4009
  } catch (e) {
3929
- console.error("[LlmManager] Failed to record monitor log:", e);
4010
+ console.error("[RoomCommands] Failed to initialize game:", e);
4011
+ return fail(errorPrefix, 500);
3930
4012
  }
3931
4013
  }
3932
4014
  };
3933
-
3934
- // src/game-do.ts
3935
- function generateLlmUserId(modelName) {
3936
- const uuid = crypto.randomUUID().slice(0, 8);
3937
- return `llm:${modelName}:${uuid}`;
3938
- }
3939
- function getRoleForUser(roleMapping, userId) {
3940
- for (const [roleId, uid] of Object.entries(roleMapping)) {
3941
- if (uid === userId) return roleId;
4015
+ function resolveRoleIds(metadata, selectedPlayerCount) {
4016
+ if (Array.isArray(metadata.roleIds)) {
4017
+ return {
4018
+ roleIds: metadata.roleIds,
4019
+ selectedPlayerCount: metadata.roleIds.length
4020
+ };
4021
+ }
4022
+ if (metadata.roleIds && typeof metadata.roleIds === "object") {
4023
+ const counts = Object.keys(metadata.roleIds).map(Number).sort((a, b) => a - b);
4024
+ const effectiveCount = selectedPlayerCount && metadata.roleIds[selectedPlayerCount] ? selectedPlayerCount : counts[0];
4025
+ if (effectiveCount === void 0) return { roleIds: [] };
4026
+ return {
4027
+ roleIds: metadata.roleIds[effectiveCount] || [],
4028
+ selectedPlayerCount: effectiveCount
4029
+ };
3942
4030
  }
3943
4031
  return null;
3944
4032
  }
4033
+
4034
+ // src/game-do.ts
4035
+ var JOIN_REQUEST_TTL_MS = 10 * 60 * 1e3;
3945
4036
  var GameDO = class extends DurableObject {
3946
4037
  // userId → ws
3947
4038
  constructor(state, env) {
@@ -3964,6 +4055,8 @@ var GameDO = class extends DurableObject {
3964
4055
  this.stateIndex = 0;
3965
4056
  this.llmWebhookUrl = null;
3966
4057
  this.roomMetaHookUrl = null;
4058
+ this.joinRequests = {};
4059
+ // pending lobby join requests, key = userId
3967
4060
  // ─── In-memory connection tracking ──────────────────────
3968
4061
  this.connections = /* @__PURE__ */ new Map();
3969
4062
  // ws → userId
@@ -3971,7 +4064,7 @@ var GameDO = class extends DurableObject {
3971
4064
  this.bindings = env;
3972
4065
  this.presence = new PresenceManager(this);
3973
4066
  this.roomMgr = new RoomManager(this);
3974
- this.executor = new GameExecutor(this);
4067
+ this.commands = new RoomCommandService(this);
3975
4068
  this.llm = new LlmManager(this);
3976
4069
  this.ctx.blockConcurrencyWhile(async () => {
3977
4070
  await this.loadState();
@@ -3999,6 +4092,7 @@ var GameDO = class extends DurableObject {
3999
4092
  this.stateIndex = await s.get("stateIndex") || 0;
4000
4093
  this.llmWebhookUrl = this.bindings.LLM_WEBHOOK_URL || await s.get("llmWebhookUrl") || null;
4001
4094
  this.roomMetaHookUrl = await s.get("roomMetaHookUrl") || null;
4095
+ this.joinRequests = await s.get("joinRequests") || {};
4002
4096
  }
4003
4097
  async persistAll() {
4004
4098
  await this.ctx.storage.put({
@@ -4017,7 +4111,8 @@ var GameDO = class extends DurableObject {
4017
4111
  runtimeId: this.runtimeId,
4018
4112
  stateIndex: this.stateIndex,
4019
4113
  llmWebhookUrl: this.llmWebhookUrl,
4020
- roomMetaHookUrl: this.roomMetaHookUrl
4114
+ roomMetaHookUrl: this.roomMetaHookUrl,
4115
+ joinRequests: this.joinRequests
4021
4116
  });
4022
4117
  }
4023
4118
  async persist(...keys) {
@@ -4060,6 +4155,7 @@ var GameDO = class extends DurableObject {
4060
4155
  this.llmWebhookUrl = this.bindings.LLM_WEBHOOK_URL || null;
4061
4156
  this.gameConfig = null;
4062
4157
  this.roomMetaHookUrl = body.roomMetaHookUrl || null;
4158
+ this.joinRequests = {};
4063
4159
  await this.persistAll();
4064
4160
  this.ctx.waitUntil(this.syncRoomMeta());
4065
4161
  return c.json({ success: true });
@@ -4122,192 +4218,98 @@ var GameDO = class extends DurableObject {
4122
4218
  });
4123
4219
  this.app.post("/role-action", async (c) => {
4124
4220
  const body = await c.req.json();
4125
- if (!body.roleId || !body.action) return c.json({ error: "roleId and action required" }, 400);
4126
- const res = await this.submitActionToGameWorker(body.roleId, body.action);
4127
- if (res.success && res.nextState) {
4128
- this.history.push({
4129
- turn: (this.history[this.history.length - 1]?.turn || 0) + 1,
4130
- roleId: body.roleId,
4131
- action: body.action,
4132
- timestamp: Date.now(),
4133
- stateIndex: this.stateIndex
4221
+ if (!body.roleId || !body.action) {
4222
+ return this.commandResponse(c, {
4223
+ ok: false,
4224
+ error: "roleId and action required"
4134
4225
  });
4135
- this.gameState = res.nextState;
4136
- this.stateIndex++;
4137
- await this.persist("gameState", "history", "stateIndex");
4138
- this.presence.broadcastSyncState();
4139
4226
  }
4140
- return c.json(res);
4227
+ const result = await this.commands.submitAction({
4228
+ userId: "operator",
4229
+ roleId: body.roleId,
4230
+ action: body.action,
4231
+ mode: "operator"
4232
+ });
4233
+ return this.commandResponse(c, result);
4141
4234
  });
4142
4235
  this.app.post("/set-game", async (c) => {
4143
4236
  const body = await c.req.json();
4144
- if (!body.gameWorkerUrl) return c.json({ error: "gameWorkerUrl is required" }, 400);
4145
- if (this.phase !== "lobby" && this.phase !== "finished") {
4146
- return c.json({ error: "Can only set game in lobby/finished phase" }, 400);
4147
- }
4148
- try {
4149
- const baseUrl = body.gameWorkerUrl.replace(/\/$/, "");
4150
- const client = resolveGameWorkerClient(body.gameId || "", baseUrl, this.bindings);
4151
- const metaRes = await client.fetch("/metadata");
4152
- if (!metaRes.ok) {
4153
- const errText = await metaRes.text();
4154
- console.error("[GameDO] Metadata fetch failed. Status:", metaRes.status, "Body:", errText);
4155
- return c.json({ error: "Failed to fetch game metadata" }, 502);
4156
- }
4157
- const metadata = await metaRes.json();
4158
- let roleIds;
4159
- let resolvedPlayerCount;
4160
- if (Array.isArray(metadata.roleIds)) {
4161
- roleIds = metadata.roleIds;
4162
- resolvedPlayerCount = roleIds.length;
4163
- } else if (metadata.roleIds) {
4164
- const counts = Object.keys(metadata.roleIds).map(Number).sort((a, b) => a - b);
4165
- const selectedCount = body.selectedPlayerCount;
4166
- const effectiveCount = selectedCount && metadata.roleIds[selectedCount] ? selectedCount : counts[0];
4167
- roleIds = metadata.roleIds[effectiveCount] || [];
4168
- resolvedPlayerCount = effectiveCount;
4169
- } else {
4170
- roleIds = [];
4171
- }
4172
- this.gameConfig = {
4173
- gameWorkerUrl: baseUrl,
4174
- gameId: body.gameId || metadata.id,
4175
- maxPlayers: roleIds.length,
4176
- roleIds,
4177
- selectedPlayerCount: resolvedPlayerCount,
4178
- enable_llm_memory: metadata.enable_llm_memory,
4179
- auto_save_mode: metadata.auto_save_mode
4180
- };
4181
- this.roleMapping = {};
4182
- await this.persist("gameConfig", "roleMapping");
4183
- this.presence.broadcastSyncState();
4184
- this.ctx.waitUntil(this.syncRoomMeta());
4185
- return c.json({ success: true, gameId: this.gameConfig.gameId, roleIds });
4186
- } catch (e) {
4187
- console.error("[GameDO] /set-game failed:", e);
4188
- return c.json({ error: e.message || "Failed to set game" }, 500);
4189
- }
4237
+ const result = await this.commands.setGame({
4238
+ gameWorkerUrl: body.gameWorkerUrl || "",
4239
+ gameId: body.gameId,
4240
+ selectedPlayerCount: body.selectedPlayerCount
4241
+ });
4242
+ return this.commandResponse(c, result);
4190
4243
  });
4191
4244
  this.app.post("/start-game", async (c) => {
4192
- if (this.phase !== "lobby" && this.phase !== "finished") {
4193
- return c.json({ error: "Game cannot be started in current phase" }, 400);
4194
- }
4195
- if (!this.gameConfig) {
4196
- return c.json({ error: "No game selected" }, 400);
4197
- }
4198
- for (const roleId of this.gameConfig.roleIds) {
4199
- if (!this.roleMapping[roleId]) {
4200
- const devUserId = `dev:${roleId}`;
4201
- this.players[devUserId] = {
4202
- displayName: roleId,
4203
- connected: false,
4204
- isOwner: false,
4205
- type: "human"
4206
- };
4207
- this.roleMapping[roleId] = devUserId;
4208
- }
4209
- }
4210
- try {
4211
- for (const player of Object.values(this.players)) {
4212
- if (player.type === "llm" && player.llmConfig) {
4213
- player.llmConfig.memory = "";
4214
- }
4215
- }
4216
- const client = resolveGameWorkerClient(
4217
- this.gameConfig.gameId,
4218
- this.gameConfig.gameWorkerUrl,
4219
- this.bindings
4220
- );
4221
- const initRes = await client.fetch("/init", {
4222
- method: "POST",
4223
- headers: { "Content-Type": "application/json" },
4224
- body: JSON.stringify({ players: this.gameConfig.roleIds })
4225
- });
4226
- if (!initRes.ok) {
4227
- const errText = await initRes.text();
4228
- return c.json({ error: `Game Worker init failed: ${errText}` }, 502);
4229
- }
4230
- this.gameState = await initRes.json();
4231
- this.history = [];
4232
- this.runtimeId = crypto.randomUUID();
4233
- this.stateIndex = 0;
4234
- this.stateHistory = [{
4235
- index: 0,
4236
- name: "Initial State",
4237
- state: this.gameState,
4238
- timestamp: Date.now()
4239
- }];
4240
- this.phase = "playing";
4241
- await this.persistAll();
4242
- this.presence.broadcastSyncState();
4243
- await this.checkAndTriggerNextTurn();
4244
- this.ctx.waitUntil(this.syncRoomMeta());
4245
- return c.json({ success: true, phase: this.phase });
4246
- } catch (e) {
4247
- console.error("[GameDO] /start-game failed:", e);
4248
- return c.json({ error: e.message || "Failed to start game" }, 500);
4249
- }
4245
+ return this.commandResponse(c, await this.commands.startGame());
4250
4246
  });
4251
4247
  this.app.post("/stop-game", async (c) => {
4252
- if (this.phase === "lobby") {
4253
- return c.json({ error: "Game has not started" }, 400);
4254
- }
4255
- this.phase = "lobby";
4256
- this.gameState = null;
4257
- this.history = [];
4258
- this.stateHistory = [];
4259
- await this.persist("phase", "gameState", "history", "stateHistory");
4260
- this.presence.broadcastSyncState();
4261
- this.ctx.waitUntil(this.syncRoomMeta());
4262
- return c.json({ success: true, phase: this.phase });
4248
+ return this.commandResponse(c, await this.commands.stopGame());
4263
4249
  });
4264
4250
  this.app.post("/restart-game", async (c) => {
4265
- if (this.phase !== "playing" && this.phase !== "paused" && this.phase !== "finished") {
4266
- return c.json({ error: "Nothing to restart" }, 400);
4267
- }
4268
- if (!this.gameConfig) {
4269
- return c.json({ error: "No game selected" }, 400);
4270
- }
4271
- try {
4272
- for (const player of Object.values(this.players)) {
4273
- if (player.type === "llm" && player.llmConfig) {
4274
- player.llmConfig.memory = "";
4275
- }
4276
- }
4277
- const client = resolveGameWorkerClient(
4278
- this.gameConfig.gameId,
4279
- this.gameConfig.gameWorkerUrl,
4280
- this.bindings
4281
- );
4282
- const initRes = await client.fetch("/init", {
4283
- method: "POST",
4284
- headers: { "Content-Type": "application/json" },
4285
- body: JSON.stringify({ players: Object.keys(this.roleMapping) })
4251
+ return this.commandResponse(c, await this.commands.restartGame());
4252
+ });
4253
+ this.app.post("/pause-game", async (c) => {
4254
+ return this.commandResponse(c, await this.commands.pauseGame());
4255
+ });
4256
+ this.app.post("/resume-game", async (c) => {
4257
+ return this.commandResponse(c, await this.commands.resumeGame());
4258
+ });
4259
+ this.app.post("/backtrack", async (c) => {
4260
+ const body = await c.req.json();
4261
+ return this.commandResponse(
4262
+ c,
4263
+ await this.commands.backtrack(body.index)
4264
+ );
4265
+ });
4266
+ this.app.post("/players", async (c) => {
4267
+ const body = await c.req.json();
4268
+ return this.commandResponse(c, await this.commands.createPlayer(body));
4269
+ });
4270
+ this.app.delete("/players/:userId", async (c) => {
4271
+ return this.commandResponse(
4272
+ c,
4273
+ await this.commands.removePlayer(c.req.param("userId"))
4274
+ );
4275
+ });
4276
+ this.app.post("/assign-role", async (c) => {
4277
+ const body = await c.req.json();
4278
+ if (!body.roleId || !body.userId) {
4279
+ return this.commandResponse(c, {
4280
+ ok: false,
4281
+ error: "roleId and userId are required"
4286
4282
  });
4287
- if (!initRes.ok) {
4288
- return c.json({ error: "Game Worker init failed on restart" }, 502);
4289
- }
4290
- this.gameState = await initRes.json();
4291
- this.history = [];
4292
- this.runtimeId = crypto.randomUUID();
4293
- this.stateIndex = 0;
4294
- this.stateHistory = [{
4295
- index: 0,
4296
- name: "Initial State",
4297
- state: this.gameState,
4298
- timestamp: Date.now()
4299
- }];
4300
- this.phase = "playing";
4301
- await this.persistAll();
4302
- this.presence.broadcastSyncState();
4303
- await this.checkAndTriggerNextTurn();
4304
- this.ctx.waitUntil(this.syncRoomMeta());
4305
- return c.json({ success: true, phase: this.phase });
4306
- } catch (e) {
4307
- console.error("[GameDO] /restart-game failed:", e);
4308
- return c.json({ error: e.message || "Failed to restart game" }, 500);
4309
4283
  }
4284
+ return this.commandResponse(c, await this.commands.assignRole({
4285
+ roleId: body.roleId,
4286
+ userId: body.userId
4287
+ }));
4310
4288
  });
4289
+ this.app.post("/add-bot", async (c) => {
4290
+ const body = await c.req.json();
4291
+ return this.commandResponse(c, await this.commands.addBot({
4292
+ displayName: body.displayName || "",
4293
+ modelName: body.modelName || "",
4294
+ systemPrompt: body.systemPrompt,
4295
+ temperature: body.temperature
4296
+ }));
4297
+ });
4298
+ }
4299
+ /**
4300
+ * Turn a command result into an HTTP response. The same result object is
4301
+ * also what the WebSocket transport inspects (only errors are sent).
4302
+ */
4303
+ commandResponse(c, result) {
4304
+ if (result.ok) {
4305
+ return c.json(result.data === void 0 ? { ok: true } : { ok: true, data: result.data });
4306
+ }
4307
+ return c.json({ ok: false, error: result.error }, result.status ?? 400);
4308
+ }
4309
+ reportCommandFailure(userId, result) {
4310
+ if (!result.ok) {
4311
+ this.sendErrorToUser(userId, result.error);
4312
+ }
4311
4313
  }
4312
4314
  async fetch(request) {
4313
4315
  return this.app.fetch(request);
@@ -4325,79 +4327,71 @@ var GameDO = class extends DurableObject {
4325
4327
  await this.roomMgr.handleLeave(userId);
4326
4328
  break;
4327
4329
  case "LOBBY_JOIN_REQUEST":
4328
- const ownerWs = this.sessions.get(this.ownerId);
4329
- if (ownerWs) {
4330
- this.sendMessage(ownerWs, {
4331
- type: "JOIN_REQUEST_INTERNAL",
4332
- payload: { userId, displayName: msg.payload.displayName }
4333
- });
4334
- }
4330
+ this.joinRequests[userId] = {
4331
+ displayName: msg.payload.displayName,
4332
+ requestedAt: Date.now()
4333
+ };
4334
+ await this.persist("joinRequests");
4335
+ this.notifyOwnerOfJoinRequest(userId);
4335
4336
  break;
4336
4337
  case "ADMIN_SET_GAME":
4337
- await this.roomMgr.handleAdminSetGame(userId, msg.payload);
4338
+ if (!this.requireOwner(userId)) return;
4339
+ this.reportCommandFailure(userId, await this.commands.setGame(msg.payload));
4338
4340
  break;
4339
4341
  case "ADMIN_ADD_BOT":
4340
- await this.llm.handleAdminAddBot(userId, msg.payload);
4342
+ if (!this.requireOwner(userId)) return;
4343
+ this.reportCommandFailure(userId, await this.commands.addBot(msg.payload));
4341
4344
  break;
4342
4345
  case "ADMIN_REMOVE_PLAYER":
4343
- await this.roomMgr.handleAdminRemovePlayer(userId, msg.payload.userId);
4346
+ if (!this.requireOwner(userId)) return;
4347
+ this.reportCommandFailure(userId, await this.commands.removePlayer(msg.payload.userId));
4344
4348
  break;
4345
4349
  case "ADMIN_APPROVE_JOIN":
4350
+ if (!this.requireOwner(userId)) return;
4346
4351
  await this.roomMgr.handleAdminApproveJoin(userId, msg.payload);
4347
4352
  break;
4353
+ case "ADMIN_IGNORE_JOIN":
4354
+ if (!this.requireOwner(userId)) return;
4355
+ await this.roomMgr.handleAdminIgnoreJoin(userId, msg.payload);
4356
+ break;
4348
4357
  case "ADMIN_ASSIGN_ROLE":
4349
- await this.roomMgr.handleAdminAssignRole(userId, msg.payload);
4358
+ if (!this.requireOwner(userId)) return;
4359
+ this.reportCommandFailure(userId, await this.commands.assignRole(msg.payload));
4350
4360
  break;
4351
4361
  case "ADMIN_START_GAME":
4352
- await this.executor.handleAdminStartGame(userId);
4362
+ if (!this.requireOwner(userId)) return;
4363
+ this.reportCommandFailure(userId, await this.commands.startGame());
4353
4364
  break;
4354
4365
  case "ADMIN_STOP_GAME":
4355
- await this.executor.handleAdminStopGame(userId);
4366
+ if (!this.requireOwner(userId)) return;
4367
+ this.reportCommandFailure(userId, await this.commands.stopGame());
4356
4368
  break;
4357
4369
  case "ADMIN_RESTART_GAME":
4358
- await this.executor.handleAdminRestartGame(userId);
4370
+ if (!this.requireOwner(userId)) return;
4371
+ this.reportCommandFailure(userId, await this.commands.restartGame());
4359
4372
  break;
4360
4373
  case "ADMIN_PAUSE_GAME":
4361
4374
  if (!this.requireOwner(userId)) return;
4362
- this.phase = "paused";
4363
- await this.persist("phase");
4364
- this.presence.broadcastSyncState();
4365
- this.ctx.waitUntil(this.syncRoomMeta());
4375
+ this.reportCommandFailure(userId, await this.commands.pauseGame());
4366
4376
  break;
4367
4377
  case "ADMIN_RESUME_GAME":
4368
4378
  if (!this.requireOwner(userId)) return;
4369
- this.phase = "playing";
4370
- await this.persist("phase");
4371
- this.presence.broadcastSyncState();
4372
- this.ctx.waitUntil(this.syncRoomMeta());
4373
- this.ctx.waitUntil(this.checkAndTriggerNextTurn());
4379
+ this.reportCommandFailure(userId, await this.commands.resumeGame());
4374
4380
  break;
4375
4381
  case "ADMIN_BACKTRACK_STATE":
4376
4382
  if (!this.requireOwner(userId)) return;
4377
- if (this.phase !== "paused") {
4378
- return this.sendErrorToUser(userId, "Game must be paused to backtrack");
4379
- }
4380
- const historyIdx = msg.payload.index;
4381
- const targetState = this.stateHistory.find((h) => h.index === historyIdx);
4382
- if (!targetState) {
4383
- return this.sendErrorToUser(userId, "Invalid state index");
4384
- }
4385
- this.gameState = targetState.state;
4386
- this.stateIndex = targetState.index;
4387
- this.history = this.history.filter((h) => h.stateIndex <= targetState.index);
4388
- await this.persist("gameState", "history", "stateIndex");
4389
- this.presence.broadcastSyncState();
4383
+ this.reportCommandFailure(userId, await this.commands.backtrack(msg.payload.index));
4390
4384
  break;
4391
4385
  case "ADMIN_UPDATE_ROOM_META":
4392
4386
  if (!this.requireOwner(userId)) return;
4393
- this.name = msg.payload.name;
4394
- this.isPublic = msg.payload.isPublic;
4395
- await this.persist("name", "isPublic");
4396
- this.presence.broadcastSyncState();
4397
- this.ctx.waitUntil(this.syncRoomMeta());
4387
+ this.reportCommandFailure(userId, await this.commands.updateRoomMeta(msg.payload));
4398
4388
  break;
4399
4389
  case "ACT":
4400
- await this.executor.handleAction(userId, msg.payload);
4390
+ this.reportCommandFailure(userId, await this.commands.submitAction({
4391
+ userId,
4392
+ action: msg.payload,
4393
+ mode: "player"
4394
+ }));
4401
4395
  break;
4402
4396
  default:
4403
4397
  this.presence.sendErrorToUser(userId, `Unknown message type: ${msg.type}`);
@@ -4455,6 +4449,43 @@ var GameDO = class extends DurableObject {
4455
4449
  sendErrorToUser(userId, msg) {
4456
4450
  this.presence.sendErrorToUser(userId, msg);
4457
4451
  }
4452
+ // ─── Lobby join requests ────────────────────────────────
4453
+ /** Push one pending join request to the owner's live session, if any. */
4454
+ notifyOwnerOfJoinRequest(userId) {
4455
+ const request = this.joinRequests[userId];
4456
+ const ownerWs = this.sessions.get(this.ownerId);
4457
+ if (!request || !ownerWs) return;
4458
+ this.sendMessage(ownerWs, {
4459
+ type: "JOIN_REQUEST_INTERNAL",
4460
+ payload: { userId, displayName: request.displayName }
4461
+ });
4462
+ }
4463
+ /**
4464
+ * Replay every unexpired pending join request to a newly connected owner.
4465
+ * Called when the owner (re)connects so requests sent during a disconnect
4466
+ * are not lost.
4467
+ */
4468
+ sendPendingJoinRequests(ws) {
4469
+ const cutoff = Date.now() - JOIN_REQUEST_TTL_MS;
4470
+ let changed = false;
4471
+ for (const [userId, request] of Object.entries(this.joinRequests)) {
4472
+ if (request.requestedAt < cutoff || this.players[userId]) {
4473
+ delete this.joinRequests[userId];
4474
+ changed = true;
4475
+ continue;
4476
+ }
4477
+ this.sendMessage(ws, {
4478
+ type: "JOIN_REQUEST_INTERNAL",
4479
+ payload: { userId, displayName: request.displayName }
4480
+ });
4481
+ }
4482
+ if (changed) this.ctx.waitUntil(this.persist("joinRequests"));
4483
+ }
4484
+ async clearJoinRequest(userId) {
4485
+ if (!this.joinRequests[userId]) return;
4486
+ delete this.joinRequests[userId];
4487
+ await this.persist("joinRequests");
4488
+ }
4458
4489
  requireOwner(userId) {
4459
4490
  if (userId !== this.ownerId) {
4460
4491
  this.sendErrorToUser(userId, "Only the room owner can perform this action");
@@ -4577,7 +4608,8 @@ var GameDO = class extends DurableObject {
4577
4608
  return {
4578
4609
  success: data.success,
4579
4610
  nextState: data.nextState,
4580
- error: data.error
4611
+ error: data.error,
4612
+ commands: data.commands
4581
4613
  };
4582
4614
  } catch (e) {
4583
4615
  console.error("[GameDO] Worker action failed:", e);
@@ -4639,6 +4671,8 @@ var MonitorDO = class extends DurableObject2 {
4639
4671
  this.subscribers = /* @__PURE__ */ new Map();
4640
4672
  this.lastProcessedTs = Date.now();
4641
4673
  this.isPolling = false;
4674
+ // Workers' setInterval returns a number; keep the type independent of the
4675
+ // ambient lib (vitest pulls in Node's `Timeout`, which the test program sees).
4642
4676
  this.heartbeatTimer = null;
4643
4677
  this.setupRoutes();
4644
4678
  this.startHeartbeat();
@@ -4903,6 +4937,19 @@ function verifyAdmin(c) {
4903
4937
  const authHeader = c.req.header("Authorization");
4904
4938
  return authHeader === `Bearer ${c.env.ADMIN_SECRET}`;
4905
4939
  }
4940
+ function getRoomStub(c, roomId) {
4941
+ const id = roomId.length === 64 ? c.env.GAME_DO.idFromString(roomId) : c.env.GAME_DO.idFromName(roomId);
4942
+ return c.env.GAME_DO.get(id);
4943
+ }
4944
+ async function proxyRoom(c, roomId, doPath, init) {
4945
+ let stub;
4946
+ try {
4947
+ stub = getRoomStub(c, roomId);
4948
+ } catch {
4949
+ return c.json({ ok: false, error: "Invalid ID format" }, 400);
4950
+ }
4951
+ return stub.fetch(`http://do${doPath}`, init);
4952
+ }
4906
4953
  app.get("/api/monitor/logs", async (c) => {
4907
4954
  if (!verifyAdmin(c)) return c.json({ error: "Unauthorized" }, 401);
4908
4955
  if (!c.env.DB) return c.json({ error: "Monitor logging is disabled" }, 404);
@@ -4950,75 +4997,48 @@ app.get("/api/monitor/logs/:interactionId", async (c) => {
4950
4997
  if (!data) return c.json({ error: "Not found" }, 404);
4951
4998
  return c.json({ data });
4952
4999
  });
4953
- app.get("/api/monitor/room/:id", async (c) => {
5000
+ app.get("/api/engine/rooms/:id", async (c) => {
4954
5001
  if (!verifyAdmin(c)) return c.json({ error: "Unauthorized" }, 401);
4955
- const idParam = c.req.param("id");
4956
- let id;
4957
- try {
4958
- if (idParam.length === 64) {
4959
- id = c.env.GAME_DO.idFromString(idParam);
4960
- } else {
4961
- id = c.env.GAME_DO.idFromName(idParam);
4962
- }
4963
- } catch (e) {
4964
- return c.json({ error: "Invalid ID format" }, 400);
4965
- }
4966
- const stub = c.env.GAME_DO.get(id);
4967
- const res = await stub.fetch("http://do/state");
4968
- if (!res.ok) {
4969
- return c.json({ error: "Failed to fetch room state" }, res.status);
4970
- }
4971
- const state = await res.json();
4972
- return c.json({ data: state });
5002
+ return proxyRoom(c, c.req.param("id"), "/state");
4973
5003
  });
4974
- app.get("/api/monitor/room/:id/perspective", async (c) => {
5004
+ app.get("/api/engine/rooms/:id/debug", async (c) => {
4975
5005
  if (!verifyAdmin(c)) return c.json({ error: "Unauthorized" }, 401);
4976
- const roomId = c.req.param("id");
4977
- const roleId = c.req.query("roleId");
4978
- if (!roleId) return c.json({ error: "roleId is required" }, 400);
4979
- const id = c.env.GAME_DO.idFromName(roomId);
4980
- const stub = c.env.GAME_DO.get(id);
4981
- const res = await stub.fetch(`http://do/perspective?roleId=${encodeURIComponent(roleId)}`);
4982
- if (!res.ok) {
4983
- return c.json({ error: "Failed to fetch perspective" }, res.status);
4984
- }
4985
- return c.json(await res.json());
5006
+ return proxyRoom(c, c.req.param("id"), "/debug");
4986
5007
  });
4987
- app.post("/api/monitor/room/:id/action", async (c) => {
5008
+ app.get("/api/engine/rooms/:id/perspective", async (c) => {
4988
5009
  if (!verifyAdmin(c)) return c.json({ error: "Unauthorized" }, 401);
4989
- const roomId = c.req.param("id");
4990
- const body = await c.req.json();
4991
- const id = c.env.GAME_DO.idFromName(roomId);
4992
- const stub = c.env.GAME_DO.get(id);
4993
- const res = await stub.fetch("http://do/role-action", {
4994
- method: "POST",
4995
- headers: { "Content-Type": "application/json" },
4996
- body: JSON.stringify(body)
4997
- });
4998
- if (!res.ok) {
4999
- return c.json({ error: "Failed to submit action" }, res.status);
5000
- }
5001
- return c.json(await res.json());
5010
+ const query = new URL(c.req.url).search;
5011
+ return proxyRoom(c, c.req.param("id"), `/perspective${query}`);
5002
5012
  });
5003
- var gameLifecycleEndpoints = ["set-game", "start-game", "stop-game", "restart-game"];
5004
- for (const endpoint of gameLifecycleEndpoints) {
5005
- app.post(`/api/monitor/room/:id/${endpoint}`, async (c) => {
5013
+ var roomPostEndpoints = [
5014
+ "role-action",
5015
+ "set-game",
5016
+ "start-game",
5017
+ "stop-game",
5018
+ "restart-game",
5019
+ "pause-game",
5020
+ "resume-game",
5021
+ "backtrack",
5022
+ "players",
5023
+ "assign-role",
5024
+ "add-bot"
5025
+ ];
5026
+ for (const endpoint of roomPostEndpoints) {
5027
+ app.post(`/api/engine/rooms/:id/${endpoint}`, async (c) => {
5006
5028
  if (!verifyAdmin(c)) return c.json({ error: "Unauthorized" }, 401);
5007
- const roomId = c.req.param("id");
5008
- const id = c.env.GAME_DO.idFromName(roomId);
5009
- const stub = c.env.GAME_DO.get(id);
5010
- const body = endpoint === "set-game" ? await c.req.json() : {};
5011
- const res = await stub.fetch(`http://do/${endpoint}`, {
5029
+ const body = await c.req.text();
5030
+ return proxyRoom(c, c.req.param("id"), `/${endpoint}`, {
5012
5031
  method: "POST",
5013
5032
  headers: { "Content-Type": "application/json" },
5014
- body: JSON.stringify(body)
5033
+ body: body || "{}"
5015
5034
  });
5016
- if (!res.ok) {
5017
- return c.json(await res.json(), res.status);
5018
- }
5019
- return c.json(await res.json());
5020
5035
  });
5021
5036
  }
5037
+ app.delete("/api/engine/rooms/:id/players/:userId", async (c) => {
5038
+ if (!verifyAdmin(c)) return c.json({ error: "Unauthorized" }, 401);
5039
+ const userId = encodeURIComponent(c.req.param("userId"));
5040
+ return proxyRoom(c, c.req.param("id"), `/players/${userId}`, { method: "DELETE" });
5041
+ });
5022
5042
  app.get("/debug/:roomId", async (c) => {
5023
5043
  const roomId = c.req.param("roomId");
5024
5044
  const id = c.env.GAME_DO.idFromName(roomId);