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(); }