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
+109
View File
@@ -0,0 +1,109 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const USER_ID = "smoke-r-user";
const CHAR_A = "smoke-r-char-a";
const CHAR_B = "smoke-r-char-b";
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}:${await res.text()}`);
}
return res.json();
}
async function createCharacter(id, formalName) {
await post("/personality/characters", {
id,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName,
basicInfo: `${formalName},測試角色`,
backgroundStory: "測試用背景",
personalityArchetype: "元氣",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy: 50, trust: 50 },
});
}
// P1 端到端驗證:建房與取回、房間持久化(供重啟後接續推進的依據)、大廳常駐房在場流轉、
// 離開後重進、1:1 併入 SOLO 房後關房仍觸發睡眠固化。
export default async function smokeR() {
await prisma.room.deleteMany({ where: { userId: USER_ID } });
await prisma.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
await prisma.user.upsert({ where: { id: USER_ID }, update: {}, create: { id: USER_ID, name: "R 群組測試使用者" } });
await createCharacter(CHAR_A, "小R甲");
await createCharacter(CHAR_B, "小R乙");
// T3/T12 驗收:建房與取回。
const room = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "GROUP" });
const fetched = await get(`/room/${room.id}`);
if (fetched.id !== room.id || fetched.participants.length !== 2) {
throw new Error("建房後應能立即取回相同房間與完整在場名單");
}
// T37 驗收:對話會更新 lastAdvancedAt——這是重啟後排程據以判斷「是否該立刻接續推進」的依據;
// 資料直接落在 DB(非行程記憶體),故任何重新連線(含 api 重啟後的新行程)都能讀到同一筆值。
// 「api 行程真的重啟後房間仍能取回」已於 T37 由人工實際重啟驗證覆蓋(見該項備註的 6 小時停機模擬)。
await post(`/room/${room.id}/message`, { userId: USER_ID, userLabel: "測試", text: "哈囉大家" });
const roomRow = await prisma.room.findUnique({ where: { id: room.id } });
if (!roomRow?.lastAdvancedAt) {
throw new Error("推進後 lastAdvancedAt 應被更新並持久化,供重啟恢復判斷使用");
}
// T24 驗收:大廳常駐房在場流轉——進房後大廳在場名單少一人。
const lobbyBefore = await get(`/room/lobby?userId=${USER_ID}`);
if (!lobbyBefore.roster.includes(CHAR_A)) {
throw new Error("尚未進房前,角色應在大廳在場名單中");
}
const soloRoom = await post("/room/solo", { userId: USER_ID, characterId: CHAR_A });
const lobbyAfterEnter = await get(`/room/lobby?userId=${USER_ID}`);
if (lobbyAfterEnter.roster.includes(CHAR_A)) {
throw new Error("進房後大廳在場名單應該少一人");
}
// T12 驗收:離開後重進,對話歷程完整且角色仍在場。
await post(`/room/${soloRoom.id}/message`, { userId: USER_ID, text: "你好呀" });
const reentered = await get(`/room/${soloRoom.id}`);
if (reentered.turns.length === 0) {
throw new Error("重新進入房間應能看到先前對話歷程");
}
if (!reentered.participants.includes(CHAR_A)) {
throw new Error("重新進入房間時角色應仍在場");
}
// T38 驗收:1:1 併入 SOLO 房後,關房仍觸發睡眠固化(工作記憶清空)並自動回到大廳。
const workingMemBefore = await get(`/memory/${CHAR_A}/sessions/${soloRoom.id}/messages`);
if (workingMemBefore.context.length === 0) {
throw new Error("關房前工作記憶應保留本輪對話內容");
}
await post(`/room/${soloRoom.id}/end`, {});
const workingMemAfter = await get(`/memory/${CHAR_A}/sessions/${soloRoom.id}/messages`);
if (workingMemAfter.context.length !== 0) {
throw new Error("關房應觸發睡眠固化並清空工作記憶");
}
const lobbyAfterLeave = await get(`/room/lobby?userId=${USER_ID}`);
if (!lobbyAfterLeave.roster.includes(CHAR_A)) {
throw new Error("關房後角色應自動回到大廳");
}
// 清理本次測試建立的角色、房間與使用者。
await prisma.room.deleteMany({ where: { userId: USER_ID } });
await prisma.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
await prisma.user.deleteMany({ where: { id: USER_ID } });
}