feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能

新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:10:26 +08:00
co-authored by Claude Sonnet 5
parent 127a59dafd
commit 1bea06e3cd
195 changed files with 17219 additions and 738 deletions
@@ -0,0 +1,142 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { afterEach, describe, expect, it } from "vitest";
import { createPrismaClient } from "@kokorone/db";
import type { PrismaService } from "../prisma/prisma.service.js";
import type { JobQueue } from "../memory/job-queue.js";
import { ControllableClock } from "../clock/controllable-clock.js";
import { EmotionService } from "../emotion/emotion.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { SleepDebtService } from "../schedule/sleep-debt.service.js";
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
import { LobbyService } from "./lobby.service.js";
import { RoomEventsService } from "./room-events.service.js";
import { RoomService } from "./room.service.js";
import { RoomIntervalService } from "./room-interval.service.js";
import type { SelfChatService } from "./self-chat.service.js";
import { RoomSchedulerService } from "./room-scheduler.service.js";
import { ROOM_ADVANCE_INTERVAL_UPPER_MS } from "./constants.js";
// T37/T8 驗收:重啟 api 後房間自動繼續推進、不出現爆量補齊;到期判斷改依 T22 動態間隔(本測試用的
// 空房間沒有任何在場角色/對話內容,熱度恆為 0,動態間隔會穩定落在上限值,藉此驗證排程判斷邏輯本身,
// 實際自走內容生成已由 smoke K 群組覆蓋)。
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
const prisma = { client: createPrismaClient() } as PrismaService;
const noopJobQueue = {} as JobQueue; // scanAndAdvance() 本身不用到 jobQueue,只有 onModuleInit 會用
const roomEvents = new RoomEventsService(new EventEmitter2());
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
const scheduleService = new ScheduleService(prisma);
const sleepDebtService = new SleepDebtService(prisma, scheduleService);
const retriever = new KeywordMemoryRetriever(prisma);
const userId = "test-scheduler-user";
afterEach(async () => {
await prisma.client.room.deleteMany({ where: { userId } });
await prisma.client.user.deleteMany({ where: { id: userId } });
});
function buildScheduler(clock: ControllableClock, selfChat: SelfChatService) {
const lobbyService = new LobbyService(prisma, clock, roomEvents);
const roomService = new RoomService(prisma, clock, lobbyService, roomEvents);
const intervalService = new RoomIntervalService(prisma, emotionService, scheduleService, retriever, sleepDebtService);
return new RoomSchedulerService(prisma, noopJobQueue, clock, roomService, selfChat, intervalService);
}
describe("RoomSchedulerService(T37/T8)", () => {
it("到期才推進一次;停機再久重新掃描也只補推進一次,不會依錯過的輪數重複補齊", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者" } });
const room = await prisma.client.room.create({ data: { userId, mode: "SELF_CHAT", status: "ACTIVE" } });
const clock = new ControllableClock("2026-08-18T12:00:00.000Z");
const advanceCalls: string[] = [];
const fakeSelfChat = {
advance: async (roomId: string, now?: Date) => {
advanceCalls.push(roomId);
await prisma.client.room.update({ where: { id: roomId }, data: { lastAdvancedAt: now ?? clock.now() } });
return { ended: false };
},
} as unknown as SelfChatService;
const scheduler = buildScheduler(clock, fakeSelfChat);
// 剛建立、lastAdvancedAt 為 null:立刻視為到期,推進一次。
await scheduler.scanAndAdvance();
expect(advanceCalls).toEqual([room.id]);
// 間隔內再掃一次:還沒到期,不推進(空房間熱度恆為 0,動態間隔落在上限值)。
advanceCalls.length = 0;
await scheduler.scanAndAdvance();
expect(advanceCalls).toEqual([]);
// 模擬停機遠超過間隔上限後才重新掃描:只推進「一次」讓它接上目前時間,不依錯過的輪數補齊。
clock.advance(ROOM_ADVANCE_INTERVAL_UPPER_MS * 10);
advanceCalls.length = 0;
await scheduler.scanAndAdvance();
expect(advanceCalls).toEqual([room.id]);
expect(advanceCalls).toHaveLength(1);
// 補推進後立刻再掃一次:又還沒到期,不會連續補第二次。
advanceCalls.length = 0;
await scheduler.scanAndAdvance();
expect(advanceCalls).toEqual([]);
// 間隔剛好過了:正常繼續推進,不因為前面的補推進而被跳過。
clock.advance(ROOM_ADVANCE_INTERVAL_UPPER_MS + 1);
advanceCalls.length = 0;
await scheduler.scanAndAdvance();
expect(advanceCalls).toEqual([room.id]);
}, 20000);
it("同一房間的上一次 advance() 還沒跑完時,下一次 tick 不會再併發推進同一房間(T18 迴歸)", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者3" } });
const room = await prisma.client.room.create({ data: { userId, mode: "SELF_CHAT", status: "ACTIVE" } });
const clock = new ControllableClock("2026-08-18T12:00:00.000Z");
let concurrentCalls = 0;
let maxConcurrentCalls = 0;
const fakeSelfChat = {
advance: async (roomId: string) => {
concurrentCalls += 1;
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
// 故意拖長:模擬 T18 實測中大房間單輪耗時可能超過 tick 週期(10 秒)的情況。
await new Promise((resolve) => setTimeout(resolve, 100));
concurrentCalls -= 1;
return { ended: false };
},
} as unknown as SelfChatService;
const scheduler = buildScheduler(clock, fakeSelfChat);
// 第一次 scanAndAdvance() 還在跑(advance() 尚未完成)時,立刻疊上第二次 tick。
const first = scheduler.scanAndAdvance();
const second = scheduler.scanAndAdvance();
await Promise.all([first, second]);
expect(maxConcurrentCalls).toBe(1); // 任何時刻最多只有一個 advance() 在跑同一房間
void room; // 僅供上方 create 呼叫,本測試不需要另外讀取房間內容
}, 20000);
it("不掃描 LOBBY 房與 autoAdvanceEnabled=false 的房間", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者2" } });
await prisma.client.room.create({ data: { userId, mode: "LOBBY", status: "ACTIVE", autoAdvanceEnabled: false } });
await prisma.client.room.create({
data: { userId, mode: "SELF_CHAT", status: "ACTIVE", autoAdvanceEnabled: false },
});
const clock = new ControllableClock("2026-08-18T12:00:00.000Z");
const advanceCalls: string[] = [];
const fakeSelfChat = {
advance: async (roomId: string) => {
advanceCalls.push(roomId);
return { ended: false };
},
} as unknown as SelfChatService;
const scheduler = buildScheduler(clock, fakeSelfChat);
await scheduler.scanAndAdvance();
expect(advanceCalls).toEqual([]);
}, 20000);
});