Files
Jeffery d82b834e47 feat: 完成 I 群組 — 生活作息與離線生活
- ScheduleService:I-1 平日/週末/假期時段表(可為角色寫入高中生預設作息)、
  I-3 時間感知(Asia/Taipei 當地時間,不受伺服器時區影響)、剛醒/睡前偵測
- constants.ts:I-2 時段狀態 → 回應可用度對照(FREE/SEMI_BUSY/BUSY/SLEEP →
  NORMAL/BRIEF/DELAYED/NO_REPLY),沒有作息表的角色一律視為 NORMAL(相容既有角色)
- ScheduleExceptionService:I-4 三種破例(深夜還陪你聊/上課偷回訊息/為你調整行程),
  各自獨立冷卻期,觸發時記入關係帳本
- ScheduleEmotionBaselineService:I-5 作息驅動情緒基線(社團剛結束的疲憊、假日偏高心情),
  只在本輪沒有明確情緒訊號時介入,串接進 EmotionService.processInput 的新增選項
- OfflineEventService:I-6 離線事件生成,依「今天已發生的固定行程」挑選對應模板,
  一天只生成一次,寫入低權重情節記憶
- DialogueService 整合以上機制:離線事件生成 → 情緒基線標記 → 累積親密度 →
  作息可用度判定 → 不符合則嘗試破例 → 依可用度決定回覆內容
- 修正真實 bug:G-5 角色建立流程誤用 source=OFFLINE_GENERATED 標記背景故事,
  與 I-6「今天是否已生成離線事件」的判斷共用同一標記,已改為 SOURCE_EXTRACTION
- ChatView.tsx 補上 NO_REPLY/context 為 null 的防呆(作息可用度整合後的必要調整)
- scripts/smoke/I.mjs:以獨立測試角色(避免污染種子角色的排程狀態)驗證全部六項

npm run restart && npm run smoke -- I 皆通過(I-V),A~H 群組冒煙測試無回歸。
2026-08-13 14:08:24 +08:00

163 lines
7.4 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 CHARACTER_ID = "smoke-i-character";
const USER_ID = "seed-user-primary";
// 固定測試時間軸(皆為 UTC,對應 Asia/Taipei 當地時間,註解為當地時間):
const WEEKDAY_CLASS_TIME = "2026-08-13T02:00:00.000Z"; // 週四 10:00(上課,BUSY)
const WEEKDAY_FREE_TIME = "2026-08-13T14:00:00.000Z"; // 週四 22:00(自由時間,FREE)
const WEEKDAY_SLEEP_TIME = "2026-08-12T18:00:00.000Z"; // 週四 02:00(睡眠,SLEEP)
const WEEKDAY_WAKING_TIME = "2026-08-12T22:40:00.000Z"; // 週四 06:40(睡眠剛結束 10 分鐘)
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();
}
export default async function smokeI() {
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
await post("/personality/characters", {
id: CHARACTER_ID,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: "作息測試角色",
basicInfo: "作息測試角色,17 歲高中生",
backgroundStory: "普通的高中生活",
personalityArchetype: "元氣",
likesDislikes: "喜歡:運動;討厭:早起",
goalsObsessions: "測試用",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy: 25, trust: 10 },
});
// I-1 作息表資料結構:可為角色寫入一份高中生作息表
await post(`/schedule/${CHARACTER_ID}/seed-default`, {});
const slotCount = await prisma.scheduleSlot.count({ where: { characterId: CHARACTER_ID } });
if (slotCount === 0) {
throw new Error("seed-default 未寫入任何作息時段");
}
// I-2/I-3 時段狀態 → 回應可用度 + 時間感知(星期幾/幾點)
const busyInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_CLASS_TIME)}`);
if (busyInfo.availability !== "DELAYED") {
throw new Error(`上課時段應對應 DELAYED,實際為 ${busyInfo.availability}`);
}
const freeInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
if (freeInfo.availability !== "NORMAL") {
throw new Error(`自由時間應對應 NORMAL,實際為 ${freeInfo.availability}`);
}
const sleepInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_SLEEP_TIME)}`);
if (sleepInfo.availability !== "NO_REPLY") {
throw new Error(`睡眠時段應對應 NO_REPLY,實際為 ${sleepInfo.availability}`);
}
const wakingInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_WAKING_TIME)}`);
if (!wakingInfo.isWaking) {
throw new Error("剛醒時間點應標記 isWaking");
}
// I-4 破例規則:睡眠時段一般不回應,且不應無條件破例
const sleepSessionA = "smoke-i-sleep-a-" + Date.now();
const noExceptionResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionA,
text: "在嗎",
now: WEEKDAY_SLEEP_TIME,
});
if (noExceptionResult.text !== "" || noExceptionResult.availability !== "NO_REPLY") {
throw new Error("條件不足時睡眠時段不應破例回應");
}
// 條件滿足(親密度高+使用者情緒低落)時應觸發「深夜還陪你聊」破例
await prisma.relationship.update({
where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } },
data: { intimacy: 70 },
});
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date(WEEKDAY_SLEEP_TIME) },
});
const sleepSessionB = "smoke-i-sleep-b-" + Date.now();
const exceptionResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionB,
text: "我今天過得好難過,很失望",
now: WEEKDAY_SLEEP_TIME,
});
if (exceptionResult.exceptionType !== "LATE_NIGHT_CHAT" || !exceptionResult.text) {
throw new Error("條件滿足時應觸發「深夜還陪你聊」破例並正常生成回應");
}
// 冷卻期內同類破例不應再次觸發
const sleepSessionC = "smoke-i-sleep-c-" + Date.now();
const cooldownResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionC,
text: "我今天過得好難過,很失望",
now: WEEKDAY_SLEEP_TIME,
});
if (cooldownResult.exceptionType !== null || cooldownResult.availability !== "NO_REPLY") {
throw new Error("冷卻期內同類破例不應再次觸發");
}
// I-5 作息驅動情緒基線:同一輸入在不同時段得到不同情緒起點
const afterClub = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-13T10:15:00.000Z")}`);
if (afterClub.baseline?.tag !== "SAD") {
throw new Error("社團剛結束應有偏低落的情緒基線");
}
const weekendBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-15T06:00:00.000Z")}`);
if (weekendBaseline.baseline?.tag !== "JOY") {
throw new Error("放假日應有偏愉悅的情緒基線");
}
const normalBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
if (normalBaseline.baseline !== null) {
throw new Error("一般時段不應有特殊情緒基線");
}
// I-6 離線事件生成:依作息生成的事件必須符合角色作息表(不可生成沒有的活動)
// 先清掉前面 I-4 測試(在 02:00 觸發對話)順帶生成的「今天」離線事件,確保本節從乾淨狀態開始。
await prisma.episodicMemory.deleteMany({ where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" } });
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date() },
});
const offlineEventTime = "2026-08-13T10:15:00.000Z"; // 週四 18:15,社團活動(16:00-18:00)剛結束
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: offlineEventTime });
const offlineMemories = await prisma.episodicMemory.findMany({
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
});
if (offlineMemories.length !== 1) {
throw new Error(`應生成恰好一筆離線事件,實際為 ${offlineMemories.length} 筆`);
}
if (!offlineMemories[0].content.includes("社團")) {
throw new Error("離線事件內容應符合當天已發生的固定行程(社團活動)");
}
// 同一天再次觸發不應重複生成
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: "2026-08-13T11:00:00.000Z" });
const offlineMemoriesAgain = await prisma.episodicMemory.count({
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
});
if (offlineMemoriesAgain !== 1) {
throw new Error("同一天不應重複生成離線事件");
}
// 清理本次測試建立的角色(cascade 會一併清掉作息表、破例紀錄、記憶等)
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
}