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