新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
232 lines
12 KiB
JavaScript
232 lines
12 KiB
JavaScript
import { prisma } from "@kokorone/db";
|
||
|
||
const API_PORT = process.env.PORT_API ?? "3001";
|
||
const USER_ID = "smoke-s-user";
|
||
const NOW = "2026-08-19T05:00:00.000Z"; // 週三 13:00 台北,FREE 時段
|
||
|
||
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, archetype, likesDislikes, intimacy = 50) {
|
||
await post("/personality/characters", {
|
||
id,
|
||
source: "ORIGINAL",
|
||
buildStatus: "BUILT",
|
||
formalName,
|
||
basicInfo: `${formalName},測試角色`,
|
||
backgroundStory: "測試用背景",
|
||
personalityArchetype: archetype,
|
||
likesDislikes,
|
||
goalsObsessions: "測試",
|
||
speechStyle: "第一人稱「我」",
|
||
initialRelationship: { userId: USER_ID, intimacy, trust: 50 },
|
||
});
|
||
}
|
||
|
||
// 讀取一段 SSE 串流直到收滿 expectedTurnCount 個 turn 事件或逾時,回傳收到的 turn 事件陣列(依到達順序)。
|
||
async function collectStreamTurns(roomId, afterSeq, expectedTurnCount, timeoutMs = 8000) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
const collected = [];
|
||
try {
|
||
const resp = await fetch(`http://localhost:${API_PORT}/room/${roomId}/stream?afterSeq=${afterSeq}`, {
|
||
headers: { Accept: "text/event-stream" },
|
||
signal: controller.signal,
|
||
});
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
while (collected.length < expectedTurnCount) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let frameBreak = buffer.indexOf("\n\n");
|
||
while (frameBreak !== -1) {
|
||
const frame = buffer.slice(0, frameBreak);
|
||
buffer = buffer.slice(frameBreak + 2);
|
||
let eventType = "message";
|
||
let id = null;
|
||
const dataLines = [];
|
||
for (const line of frame.split("\n")) {
|
||
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||
else if (line.startsWith("id:")) id = line.slice(3).trim();
|
||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
||
}
|
||
if (eventType === "turn" && dataLines.length > 0) {
|
||
collected.push({ seq: Number(id), data: JSON.parse(dataLines.join("\n")) });
|
||
}
|
||
frameBreak = buffer.indexOf("\n\n");
|
||
}
|
||
}
|
||
reader.cancel().catch(() => undefined);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
return collected;
|
||
}
|
||
|
||
const GENKI_LIKE = "運動、甜食";
|
||
const COOL_LIKE = "安靜、看書";
|
||
|
||
const CHAR_IDS = [
|
||
"smoke-s-genki",
|
||
"smoke-s-cool",
|
||
"smoke-s-sleeper",
|
||
"smoke-s-delegate",
|
||
"smoke-s-genki2",
|
||
"smoke-s-invite-a",
|
||
"smoke-s-invite-b",
|
||
"smoke-s-invite-target",
|
||
"smoke-s-invite-target2",
|
||
];
|
||
|
||
// T21-P2:P2(大廳呼叫、多方邀請裁決、SSE 續傳、未讀跳轉、SOLO→GROUP 房型切換)端到端驗證。
|
||
export default async function smokeS() {
|
||
await prisma.room.deleteMany({ where: { userId: USER_ID } });
|
||
await prisma.callAttemptLog.deleteMany({ where: { userId: USER_ID } });
|
||
await prisma.characterRelationship.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||
await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||
await prisma.character.deleteMany({ where: { id: { in: CHAR_IDS } } });
|
||
await prisma.user.upsert({ where: { id: USER_ID }, update: {}, create: { id: USER_ID, name: "S 群組測試使用者" } });
|
||
|
||
// 1. 答應:大廳喊一位元氣角色,應答應並開 SOLO 房。
|
||
await createCharacter("smoke-s-genki", "測試S元氣", "元氣", GENKI_LIKE);
|
||
const acceptResult = await post("/lobby/say", { userId: USER_ID, text: "測試S元氣,你在嗎?", now: NOW });
|
||
if (!acceptResult.accepted.includes("smoke-s-genki") || acceptResult.mode !== "SOLO" || !acceptResult.roomId) {
|
||
throw new Error("答應情境:應答應並降級開 SOLO 房");
|
||
}
|
||
|
||
// 2. 降級為單人房:同時喊一位會答應、一位會婉拒的角色,應只有答應者入房、開 SOLO。
|
||
await createCharacter("smoke-s-genki2", "測試S元氣二", "元氣", GENKI_LIKE);
|
||
await createCharacter("smoke-s-cool", "測試S三無", "三無", COOL_LIKE);
|
||
const downgradeResult = await post("/lobby/say", {
|
||
userId: USER_ID,
|
||
text: "測試S元氣二、測試S三無,你們在嗎?",
|
||
now: NOW,
|
||
});
|
||
if (
|
||
!downgradeResult.accepted.includes("smoke-s-genki2") ||
|
||
!downgradeResult.declined.includes("smoke-s-cool") ||
|
||
downgradeResult.mode !== "SOLO"
|
||
) {
|
||
throw new Error("降級情境:僅一位答應時應降級開 SOLO 房");
|
||
}
|
||
|
||
// 3. 全部不答應:只喊會婉拒的角色,不開房,但要有角色台詞(非系統錯誤)。
|
||
const declineAllResult = await post("/lobby/say", { userId: USER_ID, text: "測試S三無,你在嗎?", now: NOW });
|
||
if (declineAllResult.roomId !== null || !declineAllResult.message || /error|Error|錯誤/.test(declineAllResult.message)) {
|
||
throw new Error("全部不答應情境:不應開房,且應回傳角色台詞而非系統錯誤");
|
||
}
|
||
|
||
// 4. 關係最近者代答:喊睡著的角色,由關係最近且清醒未入房者代答,訊息附上下次可找的時間。
|
||
await createCharacter("smoke-s-sleeper", "測試S睡神", "三無", COOL_LIKE);
|
||
await createCharacter("smoke-s-delegate", "測試S代答", "元氣", GENKI_LIKE);
|
||
await prisma.scheduleSlot.create({
|
||
data: { characterId: "smoke-s-sleeper", dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "SLEEP" },
|
||
});
|
||
await post("/room/relationship/smoke-s-sleeper", { otherCharacterId: "smoke-s-delegate", affinity: 90 });
|
||
const delegateResult = await post("/lobby/say", { userId: USER_ID, text: "測試S睡神,你在嗎?", now: NOW });
|
||
if (delegateResult.delegatedBy !== "smoke-s-delegate" || !delegateResult.message?.includes("15:00")) {
|
||
throw new Error("代答情境:應由關係最近且清醒未入房者代答,並附上次可找時間");
|
||
}
|
||
|
||
// 5. 多方邀請兩處都收到回覆且只入一間:房內角色各自邀請同一目標,裁決後只入關係較好的那間。
|
||
await createCharacter("smoke-s-invite-a", "測試S邀請甲", "元氣", GENKI_LIKE);
|
||
await createCharacter("smoke-s-invite-b", "測試S邀請乙", "元氣", GENKI_LIKE);
|
||
await createCharacter("smoke-s-invite-target", "測試S被邀角", "元氣", GENKI_LIKE);
|
||
const roomA = await post("/room", { userId: USER_ID, characterIds: ["smoke-s-invite-a"], mode: "GROUP" });
|
||
const roomB = await post("/room", { userId: USER_ID, characterIds: ["smoke-s-invite-b"], mode: "GROUP" });
|
||
await post("/room/relationship/smoke-s-invite-target", { otherCharacterId: "smoke-s-invite-a", affinity: 50 });
|
||
await post("/room/relationship/smoke-s-invite-target", { otherCharacterId: "smoke-s-invite-b", affinity: 95 });
|
||
await post(`/room/${roomA.id}/invite`, { characterId: "smoke-s-invite-target", invitedByCharacterId: "smoke-s-invite-a", now: NOW });
|
||
await post(`/room/${roomB.id}/invite`, { characterId: "smoke-s-invite-target", invitedByCharacterId: "smoke-s-invite-b", now: NOW });
|
||
await post(`/room/invitations/smoke-s-invite-target/resolve`, { now: NOW });
|
||
const roomAAfterInvite = await get(`/room/${roomA.id}`);
|
||
const roomBAfterInvite = await get(`/room/${roomB.id}`);
|
||
if (!roomAAfterInvite.turns.some((t) => t.speakerId === "smoke-s-invite-target")) {
|
||
throw new Error("多方邀請情境:落選的房間也應收到該角色的回覆(婉拒台詞)");
|
||
}
|
||
if (!roomBAfterInvite.participants.includes("smoke-s-invite-target") || roomAAfterInvite.participants.includes("smoke-s-invite-target")) {
|
||
throw new Error("多方邀請情境:應只入關係較好的那間,另一間不應在場");
|
||
}
|
||
|
||
// 6. 1:1 升級為多人房後規則改變:邀請角色加入 SOLO 房,應轉為 GROUP,且不會因為對方離開而降回 SOLO。
|
||
// 用全新的元氣角色當被邀角色(conversationBaseline 高,中性關係下應答應),避免與第 5 項已入房的
|
||
// smoke-s-invite-target 衝突(R5:已在其他非大廳房間在場者邀請一律直接婉拒,不會進入待裁決佇列)。
|
||
await createCharacter("smoke-s-invite-target2", "測試S被邀角二", "元氣", GENKI_LIKE);
|
||
const soloForUpgrade = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-genki" });
|
||
await post(`/room/${soloForUpgrade.id}/invite`, {
|
||
characterId: "smoke-s-invite-target2",
|
||
invitedByCharacterId: "smoke-s-genki",
|
||
now: NOW,
|
||
});
|
||
await post(`/room/invitations/smoke-s-invite-target2/resolve`, { now: NOW });
|
||
const upgradedRoom = await get(`/room/${soloForUpgrade.id}`);
|
||
if (upgradedRoom.mode !== "GROUP") {
|
||
throw new Error("房型切換情境:SOLO 房被邀請角色答應後應轉為 GROUP");
|
||
}
|
||
await prisma.roomMember.updateMany({
|
||
where: { roomId: soloForUpgrade.id, characterId: "smoke-s-invite-target2" },
|
||
data: { leftAt: new Date(), activeCharacterId: null, leaveReason: "SMOKE_TEST" },
|
||
});
|
||
const afterLeaveRoom = await prisma.room.findUnique({ where: { id: soloForUpgrade.id } });
|
||
if (afterLeaveRoom.mode !== "GROUP") {
|
||
throw new Error("房型切換情境:對方離開後不應自動降回 SOLO");
|
||
}
|
||
|
||
// 7. 未讀跳轉:送兩則訊息後不回報已讀,unreadCount 應等於總則數;回報已讀後歸零。
|
||
const unreadRoom = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-genki2" });
|
||
await post(`/room/${unreadRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "第一句", now: NOW });
|
||
await post(`/room/${unreadRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "第二句", now: NOW });
|
||
const beforeRead = await get(`/room/${unreadRoom.id}`);
|
||
if (beforeRead.unread.unreadCount !== beforeRead.turnCount || beforeRead.unread.firstUnreadSeq !== 0) {
|
||
throw new Error("未讀跳轉情境:未回報已讀前,未讀數應等於總則數、第一則未讀應是第 0 則");
|
||
}
|
||
await post(`/room/${unreadRoom.id}/read`, { userId: USER_ID, lastReadSeq: beforeRead.turnCount - 1 });
|
||
const afterRead = await get(`/room/${unreadRoom.id}`);
|
||
if (afterRead.unread.unreadCount !== 0 || afterRead.unread.firstUnreadSeq !== null) {
|
||
throw new Error("未讀跳轉情境:回報讀到底後,未讀數應歸零");
|
||
}
|
||
|
||
// 8. SSE 續傳不漏訊息:斷線期間(未連線)發生的 turn,重新連線帶 afterSeq 應完整補齊、無重複無遺漏。
|
||
const sseRoom = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-delegate" });
|
||
const beforeStream = await get(`/room/${sseRoom.id}`);
|
||
const lastSeqBefore = beforeStream.turns.length > 0 ? beforeStream.turns[beforeStream.turns.length - 1].seq : -1;
|
||
await post(`/room/${sseRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "斷線期間的話", now: NOW });
|
||
const afterMissed = await get(`/room/${sseRoom.id}`);
|
||
const missedTurns = afterMissed.turns.filter((t) => t.seq > lastSeqBefore);
|
||
const replayed = await collectStreamTurns(sseRoom.id, lastSeqBefore, missedTurns.length);
|
||
if (replayed.length !== missedTurns.length) {
|
||
throw new Error(`SSE 續傳情境:應補齊 ${missedTurns.length} 則錯過的訊息,實際收到 ${replayed.length} 則`);
|
||
}
|
||
const replayedSeqs = replayed.map((r) => r.seq).sort((a, b) => a - b);
|
||
const missedSeqs = missedTurns.map((t) => t.seq).sort((a, b) => a - b);
|
||
if (JSON.stringify(replayedSeqs) !== JSON.stringify(missedSeqs)) {
|
||
throw new Error("SSE 續傳情境:補齊的 seq 應與實際錯過的 seq 完全一致,不可重複也不可遺漏");
|
||
}
|
||
|
||
// 清理本次測試建立的資料。
|
||
await prisma.room.deleteMany({ where: { userId: USER_ID } });
|
||
await prisma.callAttemptLog.deleteMany({ where: { userId: USER_ID } });
|
||
await prisma.characterRelationship.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||
await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||
await prisma.character.deleteMany({ where: { id: { in: CHAR_IDS } } });
|
||
}
|