Files
Kokorone/scripts/smoke/S.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

232 lines
12 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-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 } } });
}