新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
159 lines
8.7 KiB
JavaScript
159 lines
8.7 KiB
JavaScript
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();
|
||
}
|