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

216 lines
10 KiB
JavaScript
Raw Permalink 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 = "seed-user-primary";
const GENKI_ID = "smoke-k-genki";
const TSUNDERE_ID = "smoke-k-tsundere";
const COOL_ID = "smoke-k-cool";
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}`);
}
return res.json();
}
async function createCharacter(id, formalName, archetype, likesDislikes, intimacy) {
await post("/personality/characters", {
id,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName,
basicInfo: `${formalName},測試角色`,
backgroundStory: "測試用背景",
personalityArchetype: archetype,
likesDislikes,
goalsObsessions: "測試",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy, trust: intimacy },
});
}
export default async function smokeK() {
await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, TSUNDERE_ID, COOL_ID] } } });
await createCharacter(GENKI_ID, "小元", "元氣", "喜歡:運動、音樂;討厭:早起", 70);
await createCharacter(TSUNDERE_ID, "小傲", "傲嬌", "喜歡:貓;討厭:被嘲笑", 50);
await createCharacter(COOL_ID, "小冷", "三無", "喜歡:安靜;討厭:吵鬧", 50);
// K-1 在場者模型:群聊 session 內每個角色都能取得完整在場名單。
const room = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" });
const roomDetail = await get(`/room/${room.id}`);
const roster = new Set(roomDetail.participants);
if (!roster.has(GENKI_ID) || !roster.has(TSUNDERE_ID) || !roster.has(COOL_ID) || roster.size !== 3) {
throw new Error(`在場名單應包含所有三個角色,實際為 ${JSON.stringify(roomDetail.participants)}`);
}
// K-4 發言權分配:不點名的閒聊,只有基線最高的元氣角色會主動開口,其他角色保持沉默。
const genericRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" });
const genericReply = await post(`/room/${genericRoom.id}/message`, {
userId: USER_ID,
userLabel: "小明",
text: "今天大家過得怎麼樣?",
});
const genericSpeakerIds = genericReply.speakers.map((s) => s.characterId);
if (!genericSpeakerIds.includes(GENKI_ID)) {
throw new Error("元氣角色基線高,不點名的閒聊也應該主動開口");
}
if (genericSpeakerIds.includes(COOL_ID)) {
throw new Error("三無角色基線極低,不點名不該主動開口");
}
// 被點名(提及正式名)幾乎必回,且衝動值應該是本輪最高。
const namedRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" });
const namedReply = await post(`/room/${namedRoom.id}/message`, {
userId: USER_ID,
userLabel: "小明",
text: "小冷,你今天有出去玩嗎?",
});
const coolSpeaker = namedReply.speakers.find((s) => s.characterId === COOL_ID);
if (!coolSpeaker) {
throw new Error("被明確點名時,該角色應該回應");
}
const maxScore = Math.max(...namedReply.speakers.map((s) => s.score));
if (coolSpeaker.score !== maxScore) {
throw new Error("被點名的角色本輪衝動值應該是最高的");
}
// 話題相關度:提到「貓」應該提升喜歡貓的傲嬌角色的發言衝動。
const topicRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID], mode: "GROUP" });
const topicReply = await post(`/room/${topicRoom.id}/message`, {
userId: USER_ID,
userLabel: "小明",
text: "我今天在路上看到一隻好可愛的貓",
});
if (!topicReply.speakers.some((s) => s.characterId === TSUNDERE_ID)) {
throw new Error("話題與角色喜好相關時,應該提升其發言衝動並促使發言");
}
// K-2 群聊行為變化:同角色、同輸入,在一對一與群聊應輸出不同(群聊更矜持,命中不同親密度分層的模板)。
// T38:一對一改走 SOLO 房(DialogueService 管線),群聊仍走 GROUP 房(RoomGenerationService 管線)。
const soloRoom = await post("/room/solo", { userId: USER_ID, characterId: GENKI_ID });
const oneOnOneReply = await post(`/room/${soloRoom.id}/message`, {
userId: USER_ID,
text: "今天過得如何呀",
});
const groupIntimacyRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID], mode: "GROUP" });
const groupReply = await post(`/room/${groupIntimacyRoom.id}/message`, {
userId: USER_ID,
userLabel: "小明",
text: "今天過得如何呀",
});
if (oneOnOneReply.text === groupReply.speakers[0].text) {
throw new Error("同角色同情境在一對一與群聊應該輸出不同(群聊應更矜持)");
}
// K-3 隱私邊界:標記為私密的記憶,群聊情境(excludePrivate)不應被檢索到;一般情境仍可檢索。
await prisma.episodicMemory.create({
data: {
characterId: GENKI_ID,
content: "使用者偷偷告訴我他其實很怕黑",
occurredAt: new Date(),
emotionTag: "CALM",
emotionIntensity: 0.5,
source: "INTERACTION",
isPrivate: true,
relatedUserId: USER_ID,
},
});
const privateIncluded = await get(`/memory/${GENKI_ID}/retrieve?query=${encodeURIComponent("怕黑")}`);
if (!privateIncluded.results.some((m) => m.content.includes("怕黑"))) {
throw new Error("一般情境(未排除私密)應該能檢索到私密記憶");
}
const privateExcluded = await get(`/memory/${GENKI_ID}/retrieve?query=${encodeURIComponent("怕黑")}&excludePrivate=true`);
if (privateExcluded.results.some((m) => m.content.includes("怕黑"))) {
throw new Error("群聊情境(excludePrivate=true)不應該檢索到私密記憶");
}
// 口風不緊(天然呆)是性格設定上的例外,資料層需標記這個豁免旗標。
const naturalArchetype = await get("/personality/archetypes/天然呆");
if (!naturalArchetype.traits.includes("loose-lipped")) {
throw new Error("天然呆原型應標記 loose-lipped,作為隱私邊界的性格設定例外");
}
// K-5 群聊記憶投影:同一場對話,被點名/被虧的那位應該記得比較牢(權重較高),旁觀者權重較低但仍留下記錄。
const projectionRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID, COOL_ID], mode: "GROUP" });
await post(`/room/${projectionRoom.id}/message`, {
userId: USER_ID,
userLabel: "小明",
text: "哈哈笨蛋小元你今天怎麼那麼笨啊",
});
await post(`/room/${projectionRoom.id}/end`, {});
const genkiMemory = await prisma.episodicMemory.findFirst({
where: { characterId: GENKI_ID, content: { contains: "笨蛋" } },
});
const coolMemory = await prisma.episodicMemory.findFirst({
where: { characterId: COOL_ID, content: { contains: "笨蛋" } },
});
if (!genkiMemory || !coolMemory) {
throw new Error("群聊結束固化後,雙方都應該留下這場對話的記憶");
}
if (!(genkiMemory.weight > coolMemory.weight)) {
throw new Error("被虧的那位(小元)記憶權重應該高於旁觀者(小冷)");
}
// K-6/T6 角色自聊:話題種子驅動、發言權機制沿用;行為種類(TALK/ACTION/DELEGATE/LEAVE)由
// BehaviorSelectionService 依人格參數+情緒/行程/話題狀態算權重抽樣決定,不再受舊版 12 輪硬上限
// 節制——測試角色沒有設定行程/極端情緒,三項離開動因皆不成立,驗證的是「可以持續推進超過舊上限」。
const selfChatRoom = await post("/room", {
userId: USER_ID,
characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID],
mode: "SELF_CHAT",
topicSeed: "昨天放學路上的事",
});
let ended = false;
let turns = 0;
const PAST_OLD_CAP = 20; // 大於舊版 SELF_CHAT_MAX_TURNS(12),驗證不再被舊上限強制結束
const OLD_CAP = 12;
for (let i = 0; i < PAST_OLD_CAP && !ended; i++) {
const result = await post(`/room/${selfChatRoom.id}/self-chat/advance`, {});
if (result.ended) {
ended = true;
} else {
turns += 1;
}
}
// 測試角色沒有設定行程/極端情緒,LEAVE 只可能因話題耗盡觸發;三人須「各自都」耗盡話題才會整室結束,
// 這在短短 20 輪內發生的機率極低,因此正常情況下不應該提前結束——若真的提前結束也至少要確認曾經推進
// 超過舊版 12 輪上限,而不是被舊上限機制卡住(該機制已整個移除,理論上不會再發生)。
if (turns <= OLD_CAP) {
throw new Error(`角色自聊應可連續推進超過舊版 ${OLD_CAP} 輪上限,實際只推進了 ${turns} 輪就停止`);
}
// 使用者插話即切換為群聊模式。
const interjectRoom = await post("/room", {
userId: USER_ID,
characterIds: [GENKI_ID, TSUNDERE_ID],
mode: "SELF_CHAT",
topicSeed: "測試話題",
});
await post(`/room/${interjectRoom.id}/self-chat/advance`, {});
const beforeInterject = await get(`/room/${interjectRoom.id}`);
if (beforeInterject.mode !== "SELF_CHAT") {
throw new Error("插話前應仍為角色自聊模式");
}
await post(`/room/${interjectRoom.id}/interject`, { userId: USER_ID, userLabel: "小明", text: "我插句話!" });
const afterInterject = await get(`/room/${interjectRoom.id}`);
if (afterInterject.mode !== "GROUP") {
throw new Error("使用者插話後應該切換為群聊模式");
}
// 清理本次測試建立的角色(cascade 會一併清掉關係、記憶等)與本次建立的 SOLO 房。
await prisma.room.deleteMany({ where: { id: soloRoom.id } });
await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, TSUNDERE_ID, COOL_ID] } } });
}