Files
JefferyandClaude Sonnet 5 cd948f0026 feat: 完成 L 群組 — 戀愛關係軸與內容尺度
戀愛軸雙軸模型(Relationship 新增心動值/戀愛階段欄位,與親密度獨立、事件
驅動、衰減快)、告白事件狀態機(可拒絕、尷尬期、連續失敗傷關係、立即再
推進觸發警戒)、六原型戀愛曲線參數化(重用 G-3 tsundere gap 處理傲嬌)、
親密表現分層與里程碑(跳級拒絕+退縮反應+帳本、第一次做高權重記憶)、
心動事件與階段門檻(含推太急回落、關係危機與修復)。

角色年齡判定(apps/api/src/romance/age-determination.service.ts):僅
決定性/強證據可判定成年,外觀證據不可單獨採信,證據不足從嚴按未成年
處理,且不存最終結論、每次重新算。內容尺度三層邊界(引擎層):使用者
opt-in、角色資格(僅正史成年)、硬邊界(後日談推演成年者永久排除成人
模式);任何嘗試不論放行與否都記錄,判定完全不看對話內容,沒有「說服
角色」繞過的管道。

刻意收斂範圍:僅實作資格判定與硬邊界攔截機制,不實作任何成人向內容的
實際生成。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:26:55 +08:00

247 lines
14 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 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 } });
}