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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6125d6e27f
commit
cd948f0026
@@ -0,0 +1,246 @@
|
||||
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 } });
|
||||
}
|
||||
Reference in New Issue
Block a user