Files
Kokorone/scripts/smoke/V.mjs
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

159 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const USER_ID = "smoke-v-user";
const OTHER_USER_ID = "smoke-v-other-user";
const CHAR_A = "smoke-v-char-a"; // 被套用的角色
const CHAR_B = "smoke-v-char-b";
const ALL_CHAR_IDS = [CHAR_A, 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 },
});
}
async function cleanup() {
await prisma.episodicMemory.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
await prisma.characterRelationship.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
await prisma.roomInvitation.deleteMany({ where: { characterId: CHAR_A } });
await prisma.pendingLobbyCall.deleteMany({ where: { userId: { in: [USER_ID, OTHER_USER_ID] } } });
await prisma.callAttemptLog.deleteMany({ where: { userId: { in: [USER_ID, OTHER_USER_ID] } } });
await prisma.roomTurn.deleteMany({ where: { room: { userId: { in: [USER_ID, OTHER_USER_ID] } } } });
await prisma.roomMember.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
await prisma.room.deleteMany({ where: { userId: { in: [USER_ID, OTHER_USER_ID] } } });
await prisma.character.deleteMany({ where: { id: { in: ALL_CHAR_IDS } } });
await prisma.user.deleteMany({ where: { id: { in: [USER_ID, OTHER_USER_ID] } } });
}
// T21-P5:P5(人格套用,R8)端到端驗證——套用期間該角色不自走、他人當本人對待、記憶與關係記在
// 她名下、被邀請/被呼叫時由使用者代決(不出現系統代答)、退出後恢復自走。人工實際跑過一次套用
// 主流程(套用→以她的身分在房內發話→被邀請並代她決定→退出套用)於本項 wiki 完成記錄中一併記載。
export default async function smokeV() {
await cleanup();
await prisma.user.create({ data: { id: USER_ID, name: "V 群組測試使用者" } });
await prisma.user.create({ data: { id: OTHER_USER_ID, name: "V 群組另一位測試使用者" } });
await createCharacter(CHAR_A, "小V甲");
await createCharacter(CHAR_B, "小V乙");
// ── 1/5:套用期間該角色不自走;退出後恢復自走 ──────────────────────────────────────
const room = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "SELF_CHAT", topicSeed: "今天過得怎麼樣?" });
await post("/possession/enter", { userId: USER_ID, characterId: CHAR_A });
const statusAfterEnter = await get(`/possession/status?userId=${USER_ID}`);
if (statusAfterEnter.possessingCharacterId !== CHAR_A) {
throw new Error("套用後狀態應反映目前套用中的角色");
}
let sawAutonomousSpeechByA = false;
for (let i = 0; i < 20; i++) {
const result = await post(`/room/${room.id}/self-chat/advance`, { now: new Date(Date.now() + i * 1000).toISOString() });
if (result.turn?.characterId === CHAR_A) sawAutonomousSpeechByA = true;
if (result.ended) break;
}
if (sawAutonomousSpeechByA) {
throw new Error("套用期間該角色不應被自走機制選為發言者");
}
// ── 2/3:以她的身分發話,其他角色反應與她本人在場一致;固化後記憶與關係記在她名下 ─────
const speakResult = await post(`/possession/${room.id}/message`, { userId: USER_ID, text: "太開心了!好棒!太好了!" });
for (const speaker of speakResult.speakers) {
if (speaker.characterId === CHAR_A) {
throw new Error("套用中的角色不應出現在自己這輪發話的回應者清單裡");
}
}
const roomAfterSpeak = await get(`/room/${room.id}`);
if (!roomAfterSpeak.turns.some((t) => t.speakerId === CHAR_A && t.content === "太開心了!好棒!太好了!")) {
throw new Error("以套用角色的身分發話應逐字寫入她自己的 RoomTurn");
}
await post(`/room/${room.id}/end`, {});
const memoriesAfterEnd = await prisma.episodicMemory.count({ where: { characterId: CHAR_A } });
if (memoriesAfterEnd === 0) {
throw new Error("套用期間產生的對話固化後,記憶應照常記在她名下");
}
// ── 4:被邀請時由使用者代決,未出現系統代答 ─────────────────────────────────────────
await post("/possession/enter", { userId: USER_ID, characterId: CHAR_A }); // 上個房間已結束,重新套用
const inviterRoom = await post("/room", { userId: OTHER_USER_ID, characterIds: [CHAR_B], mode: "GROUP" });
await post(`/room/${inviterRoom.id}/invite`, { characterId: CHAR_A, invitedByCharacterId: CHAR_B });
await post(`/room/invitations/${CHAR_A}/resolve`, {});
const pendingAfterResolve = await get(`/possession/pending-invitations?userId=${USER_ID}`);
if (pendingAfterResolve.length !== 1 || pendingAfterResolve[0].status !== "PENDING") {
throw new Error("套用中角色被邀請時,人格系統不應自動裁決,應維持待決交由使用者決定");
}
const roomBeforeDecision = await get(`/room/${inviterRoom.id}`);
if (roomBeforeDecision.turns.length !== 0) {
throw new Error("套用中角色被邀請時,裁決前不應出現任何系統代答的台詞");
}
await post(`/possession/invitations/${pendingAfterResolve[0].id}/decide`, { accept: true });
const roomAfterDecision = await get(`/room/${inviterRoom.id}`);
if (!roomAfterDecision.participants.includes(CHAR_A)) {
throw new Error("使用者代她接受邀請後,她應實際入房");
}
// 被呼叫(大廳)時同樣由使用者代決:另一位使用者喊她,應記一筆待決而非人格系統自動判定。
await post(`/room/${inviterRoom.id}/end`, {}); // 先讓她離場,避免 R5 擋下後續的呼叫判定
const callResult = await post("/lobby/say", { userId: OTHER_USER_ID, text: "小V甲,你在嗎?" });
if (callResult.accepted.length !== 0 || callResult.declined.length !== 0 || callResult.message !== null) {
throw new Error("套用中角色被呼叫時,不應出現任何人格系統的接受/婉拒判定或代答台詞");
}
if (!callResult.pendingPossessedCharacterIds.includes(CHAR_A)) {
throw new Error("套用中角色被呼叫時,應標記為待套用者決定");
}
const pendingCalls = await get(`/possession/pending-calls?userId=${USER_ID}`);
if (pendingCalls.length !== 1 || pendingCalls[0].characterId !== CHAR_A) {
throw new Error("待決呼叫應歸戶給正在套用她的使用者,而不是打這通呼叫的使用者");
}
await post(`/possession/lobby-calls/${pendingCalls[0].id}/decide`, { accept: false });
if ((await get(`/possession/pending-calls?userId=${USER_ID}`)).length !== 0) {
throw new Error("婉拒後待決呼叫應消除");
}
// ── 5:退出套用後恢復自走 ─────────────────────────────────────────────────────────
await post("/possession/exit", { userId: USER_ID });
const statusAfterExit = await get(`/possession/status?userId=${USER_ID}`);
if (statusAfterExit.possessingCharacterId !== null) {
throw new Error("退出套用後狀態應變回未套用");
}
const resumedRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "SELF_CHAT", topicSeed: "退出套用後的話題" });
let sawAutonomousSpeechByAAfterExit = false;
for (let i = 0; i < 30 && !sawAutonomousSpeechByAAfterExit; i++) {
const result = await post(`/room/${resumedRoom.id}/self-chat/advance`, { now: new Date(Date.now() + i * 1000).toISOString() });
if (result.turn?.characterId === CHAR_A) sawAutonomousSpeechByAAfterExit = true;
if (result.ended) break;
}
if (!sawAutonomousSpeechByAAfterExit) {
throw new Error("退出套用後,該角色應恢復自走並終究輪到她自己開口");
}
await cleanup();
}