import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const USER_ID = "seed-user-primary"; const ADULT_ID = "smoke-l-adult"; const MINOR_ID = "smoke-l-minor"; const EPILOGUE_ID = "smoke-l-epilogue"; const INSUFFICIENT_ID = "smoke-l-insufficient"; const GENKI_ID = "smoke-l-genki"; const COOL_ID = "smoke-l-cool"; const OJOU_ID = "smoke-l-ojou"; const EAGER_ID = "smoke-l-eager"; const ACCEPT_ID = "smoke-l-accept"; const ALL_IDS = [ADULT_ID, MINOR_ID, EPILOGUE_ID, INSUFFICIENT_ID, GENKI_ID, COOL_ID, OJOU_ID, EAGER_ID, ACCEPT_ID]; 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, archetype) { await post("/personality/characters", { id, source: "ORIGINAL", buildStatus: "BUILT", formalName: id, basicInfo: "測試角色", backgroundStory: "測試", personalityArchetype: archetype, likesDislikes: "測試", goalsObsessions: "測試", speechStyle: "第一人稱「我」", initialRelationship: { userId: USER_ID, intimacy: 50, trust: 50 }, }); } async function addEvidence(id, evidenceType, impliesAdult, isEpilogue, description) { await post(`/romance/${id}/age-evidence`, { evidenceType, impliesAdult, isEpilogue, description }); } export default async function smokeL() { await prisma.character.deleteMany({ where: { id: { in: ALL_IDS } } }); // AdultModeConsent 是綁在共用種子使用者身上的設定,不隨角色一起被 cascade 清掉,測試開始前先重置成「未 opt-in」的乾淨基準。 await prisma.adultModeConsent.deleteMany({ where: { userId: USER_ID } }); for (const id of ALL_IDS) { await createCharacter(id, id === GENKI_ID ? "元氣" : id === COOL_ID ? "冷淡" : id === OJOU_ID ? "大小姐" : "元氣"); } // L-6 角色年齡判定:官方明示決定性;外觀證據不可單獨採信;無足夠證據一律從嚴按未成年處理。 await addEvidence(ADULT_ID, "OFFICIAL_STATED", true, false, "設定集載明 22 歲"); await addEvidence(MINOR_ID, "OFFICIAL_STATED", false, false, "設定集載明 16 歲"); await addEvidence(EPILOGUE_ID, "OFFICIAL_STATED", false, false, "正史設定 16 歲"); await addEvidence(EPILOGUE_ID, "ELAPSED_TIME", true, true, "後日談推演經過 3 年"); await addEvidence(INSUFFICIENT_ID, "APPEARANCE", true, false, "外表看起來像成年人,但無其他證據"); // 以下角色用於 L-1/L-3/L-4/L-5 的戀愛軸機制測試,皆需正史成年資格才能啟動戀愛軸。 for (const id of [GENKI_ID, COOL_ID, OJOU_ID, EAGER_ID, ACCEPT_ID]) { await addEvidence(id, "OFFICIAL_STATED", true, false, "設定集載明成年"); } const adultDetermination = await get(`/romance/${ADULT_ID}/age-determination`); if (!adultDetermination.canonAdult) { throw new Error("官方明示成年應判定為正史成年"); } const insufficientDetermination = await get(`/romance/${INSUFFICIENT_ID}/age-determination`); if (insufficientDetermination.canonAdult || insufficientDetermination.currentAdult) { throw new Error("僅有外觀證據不可單獨採信為成年——證據不足應從嚴按未成年處理"); } // L-7 內容尺度三層邊界:使用者分級 opt-in、角色資格(僅正史成年)、硬邊界(永不可解除)。 const adultEligibility = await get(`/romance/${ADULT_ID}/eligibility`); if (!adultEligibility.romanceAxisEligible || !adultEligibility.adultModeEligible) { throw new Error("正史成年角色應同時具備戀愛軸與成人模式資格"); } const minorEligibility = await get(`/romance/${MINOR_ID}/eligibility`); if (minorEligibility.romanceAxisEligible || minorEligibility.adultModeEligible) { throw new Error("未成年角色不應具備戀愛軸或成人模式資格"); } const epilogueEligibility = await get(`/romance/${EPILOGUE_ID}/eligibility`); if (!epilogueEligibility.romanceAxisEligible || epilogueEligibility.adultModeEligible || !epilogueEligibility.isEpilogueAdult) { throw new Error("正史未成年、後日談推演成年者:戀愛軸可用但成人模式應永久排除"); } // 未開啟 opt-in 時,即使角色具資格仍應攔截;任何嘗試都要留下記錄。 const beforeConsent = await post(`/romance/${ADULT_ID}/adult-content-check`, { userId: USER_ID }); if (beforeConsent.allowed) { throw new Error("使用者未完成成年驗證+opt-in 時,即使角色具資格也不應放行"); } await post("/romance/adult-mode/consent", { userId: USER_ID, verifiedAdult: true, optedIn: true }); const afterConsent = await post(`/romance/${ADULT_ID}/adult-content-check`, { userId: USER_ID }); if (!afterConsent.allowed) { throw new Error("正史成年角色+使用者已驗證並 opt-in 時,應該放行"); } // 硬邊界永不可解除:即使使用者已 opt-in,未成年/後日談推演成年角色仍必須被攔截。 const epilogueAttempt = await post(`/romance/${EPILOGUE_ID}/adult-content-check`, { userId: USER_ID }); const minorAttempt = await post(`/romance/${MINOR_ID}/adult-content-check`, { userId: USER_ID }); if (epilogueAttempt.allowed || minorAttempt.allowed) { throw new Error("硬邊界永不可解除:後日談推演成年與未成年角色即使使用者已 opt-in 仍必須被攔截"); } const attemptCount = await prisma.adultContentAttemptLog.count({ where: { userId: USER_ID, characterId: { in: [ADULT_ID, EPILOGUE_ID, MINOR_ID] } } }); if (attemptCount < 4) { throw new Error("每一次成人內容檢查(無論放行與否)都必須留下記錄"); } // L-1 雙軸模型:純聊天量只推升親密度,不推升心動值;未成年角色的戀愛軸不會被事件啟動。 await post(`/chat/${ADULT_ID}`, { userId: USER_ID, sessionId: "smoke-l-chat", text: "嗨嗨嗨,今天過得如何呀" }); const stateAfterChat = await get(`/romance/${ADULT_ID}/${USER_ID}/state`); if (stateAfterChat.romanceMeter !== 0) { throw new Error("純聊天量不應該推升心動值"); } if (!(stateAfterChat.intimacy > 50)) { throw new Error("純聊天量仍應該正常推升親密度"); } const minorEventResult = await post(`/romance/${MINOR_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE" }); if (minorEventResult.applied || minorEventResult.romanceMeter !== 0) { throw new Error("未成年角色的戀愛軸應完全關閉,心動事件不應該被套用"); } // L-3 性格進展曲線:相同事件序列,在不同原型下應該產生不同的心動值曲線。 const curveTimestamps = ["2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z", "2026-01-03T00:00:00.000Z"]; let genkiMeter = 0; let coolMeter = 0; let ojouMeter = 0; for (const now of curveTimestamps) { genkiMeter = (await post(`/romance/${GENKI_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now })).romanceMeter; coolMeter = (await post(`/romance/${COOL_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now })).romanceMeter; ojouMeter = (await post(`/romance/${OJOU_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now })).romanceMeter; } if (genkiMeter === coolMeter || genkiMeter === ojouMeter || coolMeter === ojouMeter) { throw new Error(`相同事件序列在不同原型下應該產生不同曲線,實際為 元氣=${genkiMeter} 冷淡=${coolMeter} 大小姐=${ojouMeter}`); } if (!(coolMeter < genkiMeter)) { throw new Error("冷淡原型的戀愛軸應該遠比元氣原型緩慢"); } // L-5 推得太急會回落:短時間內密集觸發正向心動事件,應該轉為回落而非持續上升。 const eagerTimestamps = ["2026-02-01T10:00:00.000Z", "2026-02-01T10:10:00.000Z", "2026-02-01T10:20:00.000Z", "2026-02-01T10:30:00.000Z"]; let lastEagerResult; for (const now of eagerTimestamps) { lastEagerResult = await post(`/romance/${EAGER_ID}/event`, { userId: USER_ID, eventType: "ADMIRED_TRAIT", now }); } if (!lastEagerResult.overEager || lastEagerResult.delta >= 0) { throw new Error("短時間內密集推進心動事件,應該被判定為推得太急並轉為回落"); } // L-2/L-4/L-5:建立一個乾淨時間軸,走完 曖昧→告白成功→戀人初期→戀人穩定期→關係危機→修復。 for (const now of ["2026-03-01T00:00:00.000Z", "2026-03-02T00:00:00.000Z", "2026-03-03T00:00:00.000Z", "2026-03-04T00:00:00.000Z"]) { await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now }); } const preConfessState = await get(`/romance/${ACCEPT_ID}/${USER_ID}/state?now=${encodeURIComponent("2026-03-04T00:30:00.000Z")}`); if (preConfessState.romanceStage !== "AMBIGUOUS") { throw new Error(`累積心動事件後應進入曖昧階段,實際為 ${preConfessState.romanceStage}`); } // L-2 告白:心動值不足或時機不對會被拒絕(進入尷尬期),拒絕後立即再推進觸發警戒與心動值下降。 const tooSoonId = "smoke-l-toosoon"; await prisma.character.deleteMany({ where: { id: tooSoonId } }); await createCharacter(tooSoonId, "元氣"); await addEvidence(tooSoonId, "OFFICIAL_STATED", true, false, "設定集載明成年"); await post(`/romance/${tooSoonId}/event`, { userId: USER_ID, eventType: "ADMIRED_TRAIT", now: "2026-03-01T00:00:00.000Z" }); const insufficientConfess = await post(`/romance/${tooSoonId}/confess`, { userId: USER_ID, now: "2026-03-01T01:00:00.000Z" }); if (insufficientConfess.outcome !== "REJECTED") { throw new Error("心動值不足時告白應該被拒絕"); } const stateAfterReject = await get(`/romance/${tooSoonId}/${USER_ID}/state?now=${encodeURIComponent("2026-03-01T01:01:00.000Z")}`); if (!stateAfterReject.awkwardUntil) { throw new Error("告白被拒後應該進入尷尬期"); } const meterBeforeRetry = stateAfterReject.romanceMeter; const tooSoonRetry = await post(`/romance/${tooSoonId}/confess`, { userId: USER_ID, now: "2026-03-01T01:05:00.000Z" }); if (tooSoonRetry.outcome !== "REJECTED_TOO_SOON") { throw new Error("尷尬期內立即再推進應該被判定為 REJECTED_TOO_SOON"); } if (!(tooSoonRetry.romanceMeter < meterBeforeRetry)) { throw new Error("尷尬期內立即再推進應該觸發心動值下降"); } // 成功告白 → 進入戀人階段。 const confessResult = await post(`/romance/${ACCEPT_ID}/confess`, { userId: USER_ID, now: "2026-03-04T01:00:00.000Z" }); if (confessResult.outcome !== "ACCEPTED") { throw new Error(`心動值充足且時機正常時告白應該成功,實際為 ${confessResult.outcome}`); } // L-4 親密表現分層:戀人初期不能跳級要求戀人穩定期才開放的表現;跳級要求會被拒且記入帳本。 const handHolding = await post(`/romance/${ACCEPT_ID}/milestone`, { userId: USER_ID, milestoneType: "HAND_HOLDING", now: "2026-03-04T02:00:00.000Z" }); if (!handHolding.allowed || !handHolding.isFirstTime) { throw new Error("戀人初期應該可以第一次牽手,且應標記為第一次"); } const prematureHug = await post(`/romance/${ACCEPT_ID}/milestone`, { userId: USER_ID, milestoneType: "HUG", now: "2026-03-04T02:05:00.000Z" }); if (prematureHug.allowed || !prematureHug.reactionLine) { throw new Error("戀人初期跳級要求戀人穩定期的親密表現應該被拒絕,且要有符合角色的退縮反應"); } const rejectedLogged = await prisma.romanceEventLog.findFirst({ where: { characterId: ACCEPT_ID, userId: USER_ID, eventType: "MILESTONE_REJECTED", detail: "HUG" }, }); if (!rejectedLogged) { throw new Error("跳級要求被拒絕時應該記入帳本"); } // 累積正向事件進入戀人穩定期後,該表現才會被允許。 for (const now of ["2026-03-05T00:00:00.000Z", "2026-03-06T00:00:00.000Z", "2026-03-07T00:00:00.000Z"]) { await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "ADMIRED_TRAIT", now }); } const stableHug = await post(`/romance/${ACCEPT_ID}/milestone`, { userId: USER_ID, milestoneType: "HUG", now: "2026-03-07T01:00:00.000Z" }); if (!stableHug.allowed) { throw new Error("進入戀人穩定期後,擁抱應該被允許"); } const milestoneMemory = await prisma.episodicMemory.findFirst({ where: { characterId: ACCEPT_ID, relatedUserId: USER_ID, content: { contains: "第一次" } }, }); if (!milestoneMemory || milestoneMemory.weight < 2) { throw new Error("第一次的親密表現里程碑應該做成高權重的事件級記憶"); } // L-5 關係危機與修復:戀人階段的重大失望應該直接跳危機狀態,累積正向事件修復後回到戀人。 const crisisResult = await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "DISAPPOINTMENT", severe: true, now: "2026-03-08T00:00:00.000Z" }); if (crisisResult.romanceStage !== "CRISIS") { throw new Error("戀人階段的重大失望應該直接進入關係危機狀態"); } let repaired = crisisResult; for (const now of ["2026-03-09T00:00:00.000Z", "2026-03-10T00:00:00.000Z", "2026-03-11T00:00:00.000Z"]) { repaired = await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now }); } if (repaired.romanceStage !== "PARTNER") { throw new Error("累積足夠的正向修復事件後,關係危機應該回到戀人階段"); } // 清理本次測試建立的角色(cascade 會一併清掉戀愛軸紀錄、年齡證據、記憶等)與共用使用者的 opt-in 設定。 await prisma.character.deleteMany({ where: { id: { in: [...ALL_IDS, tooSoonId] } } }); await prisma.adultModeConsent.deleteMany({ where: { userId: USER_ID } }); }