feat: 完成 G 群組 — 角色人格層

- archetype-params.ts:六原型(傲嬌/冷淡/天然呆/元氣/大小姐/三無)參數表,
  串接進 D 群組 EmotionService(情緒觸發閾值)與 E 群組 SentimentLedgerService(信任成長速度)
- 修正一個真實落差:觸發閾值原本只縮放強度、不影響是否觸發,加入 MIN_TRIGGER_INTENSITY
  門檻後「難觸發」原型才會對弱刺激真的沒反應
- expressiveness.ts:G-3 外顯函數,傲嬌反向表達(傲嬌值=內外差值,隨內部親密度先升後降)
- language-style.ts:G-2 稱呼系統(陌生→您、認識→名字、摯友→暱稱,含跨階事件)與角色禁則清單,
  已串接進 F-3 輸出過濾層與口癖後處理
- contrast-behavior.service.ts:G-4 反差萌,親密度+情緒+機率門檻皆滿足才觸發,
  新增 ContrastTriggerLog 資料表記錄冷卻期
- character-creation.service.ts:G-5 由角色設定表一次產生語意記憶/情節記憶/情緒參數/關係初始值
- cuteness-behavior.ts:G-6 撒嬌/鬧彆扭節奏控制/吃醋/笨拙的努力/專屬揭露觸發條件;
  記住小事擴充了 C-4 睡眠固化,使用者瑣事即使情緒強度低也會寫入語意記憶
- scripts/smoke/G.mjs:涵蓋六原型差異化反應、稱呼進化、傲嬌外顯曲線、反差冷卻、
  可愛行為觸發條件、瑣事固化

npm run restart && npm run smoke -- G 皆通過(G-V),A/B/C/D/E/F 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 11:26:54 +08:00
parent caf45f69e1
commit 503024c92f
17 changed files with 757 additions and 16 deletions
+198
View File
@@ -0,0 +1,198 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const GENKI_ID = "seed-character-genki";
const COLD_ID = "smoke-g-coldtype";
const USER_ID = "seed-user-primary";
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 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 resetEmotion(characterId, overrides) {
await prisma.emotionState.upsert({
where: { characterId },
update: { ...overrides, updatedAt: new Date() },
create: { characterId, ...overrides },
});
}
export default async function smokeG() {
await prisma.character.deleteMany({ where: { id: COLD_ID } });
// G-5 角色建立流程:由角色設定表產生四類初始資料
const coldCharacter = await post("/personality/characters", {
id: COLD_ID,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: "冰璃",
basicInfo: "冰璃,16歲,轉學生,總是面無表情",
backgroundStory: "小時候因為一次意外失去了摯友,從此學會了不輕易表露情緒,把自己封閉起來保護自己",
personalityArchetype: "冷淡",
likesDislikes: "喜歡:安靜、看書;討厭:吵鬧",
goalsObsessions: "想找到一個可以真心相待的人",
speechStyle: "第一人稱「我」,語氣平淡簡短",
initialRelationValue: "對誰都保持距離",
initialRelationship: { userId: USER_ID, intimacy: 25, trust: 10 },
});
if (coldCharacter.personalityArchetype !== "冷淡") {
throw new Error("角色建立未正確寫入性格原型");
}
const createdCharacterData = await prisma.character.findUnique({
where: { id: COLD_ID },
include: { semanticMemories: true, episodicMemories: true, emotionState: true, relationships: true },
});
if (createdCharacterData.semanticMemories.length === 0) {
throw new Error("G-5 未產生初始語意記憶");
}
if (createdCharacterData.episodicMemories.length === 0) {
throw new Error("G-5 未產生初始情節記憶");
}
if (!createdCharacterData.emotionState) {
throw new Error("G-5 未產生初始情緒參數");
}
if (createdCharacterData.relationships.length === 0) {
throw new Error("G-5 未產生初始關係值");
}
// G-1 性格原型參數表:切換原型後同一輸入產生不同情緒(易觸發 vs 難觸發)
await resetEmotion(GENKI_ID, { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 });
await resetEmotion(COLD_ID, { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 });
const genkiResult = await post(`/emotion/${GENKI_ID}/input`, { text: "有點難過" });
const coldResult = await post(`/emotion/${COLD_ID}/input`, { text: "有點難過" });
if (genkiResult.state.sad <= 0) {
throw new Error("元氣(易觸發)對弱負向輸入應觸發低落情緒");
}
if (coldResult.state.sad !== 0) {
throw new Error("冷淡(難觸發)對同樣的弱負向輸入不應觸發情緒轉移");
}
// G-2 語言風格層:稱呼系統依親密度分層,跨階時 justEvolved 為真
const formalAddress = await get(`/personality/address?userName=${encodeURIComponent("陳大文")}&intimacy=5`);
const nameAddress = await get(`/personality/address?userName=${encodeURIComponent("陳大文")}&intimacy=30`);
const nicknameAddress = await get(`/personality/address?userName=${encodeURIComponent("陳大文")}&intimacy=70`);
if (formalAddress.address !== "您" || nameAddress.address !== "陳大文" || nicknameAddress.address === "陳大文") {
throw new Error("稱呼系統未依親密度分層正確切換");
}
const transition = await get(
`/personality/address-transition?userName=${encodeURIComponent("陳大文")}&previous=15&current=25`,
);
if (!transition.justEvolved) {
throw new Error("跨越稱呼分層門檻時應標記 justEvolved");
}
// G-3 外顯函數:傲嬌角色內部親密度上升時,外顯敵意(傲嬌值)應先升後降
const gapAt30 = (await get(`/personality/${encodeURIComponent("傲嬌")}/expressed-intimacy?intimacy=30`)).hostilityGap;
const gapAt50 = (await get(`/personality/${encodeURIComponent("傲嬌")}/expressed-intimacy?intimacy=50`)).hostilityGap;
const gapAt90 = (await get(`/personality/${encodeURIComponent("傲嬌")}/expressed-intimacy?intimacy=90`)).hostilityGap;
if (!(gapAt30 < gapAt50 && gapAt50 > gapAt90)) {
throw new Error(`傲嬌值應先升後降,實際:30→${gapAt30}, 50→${gapAt50}, 90→${gapAt90}`);
}
const nonTsundereGap = (await get(`/personality/${encodeURIComponent("元氣")}/expressed-intimacy?intimacy=50`))
.hostilityGap;
if (nonTsundereGap !== 0) {
throw new Error("非傲嬌原型不應有外顯落差");
}
// G-4 反差萌與稀有度控制:門檻+機率命中才觸發,觸發後進入冷卻期
await prisma.contrastTriggerLog.deleteMany({ where: { characterId: GENKI_ID } });
const belowThreshold = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 50,
emotionTag: "JOY",
roll: 0.01,
});
if (belowThreshold.triggered) {
throw new Error("親密度未達門檻時不應觸發反差行為");
}
const firstTrigger = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 80,
emotionTag: "JOY",
roll: 0.01,
});
if (!firstTrigger.triggered) {
throw new Error("條件皆滿足時應觸發反差行為");
}
const duringCooldown = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 80,
emotionTag: "JOY",
roll: 0.01,
});
if (duringCooldown.triggered) {
throw new Error("冷卻期內同類反差不應再次觸發");
}
const tenDaysLater = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
const afterCooldown = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 80,
emotionTag: "JOY",
roll: 0.01,
now: tenDaysLater,
});
if (!afterCooldown.triggered) {
throw new Error("冷卻期過後應可再次觸發反差行為");
}
// G-6 可愛度行為機制:撒嬌/鬧彆扭節奏/吃醋/專屬揭露的觸發條件
const spoiledOk = await get("/personality/cuteness/spoiled?intimacy=70&emotionTag=JOY");
const spoiledBlocked = await get("/personality/cuteness/spoiled?intimacy=30&emotionTag=JOY");
if (!spoiledOk.canActSpoiled || spoiledBlocked.canActSpoiled) {
throw new Error("撒嬌觸發條件(親密度 ≥ 60 且情緒為愉悅/低落)不符預期");
}
const grumpyOk = await get("/personality/cuteness/grumpy-recovery?turns=2");
const grumpyForce = await get("/personality/cuteness/grumpy-recovery?turns=3");
if (grumpyOk.shouldForceRecovery || !grumpyForce.shouldForceRecovery) {
throw new Error("鬧彆扭節奏控制(超過兩三輪應強制恢復)不符預期");
}
const jealousy = await get(`/personality/cuteness/third-party?text=${encodeURIComponent("他人很好,我比較喜歡他")}`);
const noJealousy = await get(`/personality/cuteness/third-party?text=${encodeURIComponent("今天天氣好好")}`);
if (!jealousy.detected || noJealousy.detected) {
throw new Error("第三者好感訊號偵測不符預期");
}
const exclusiveOk = await get("/personality/cuteness/exclusive?trust=85");
const exclusiveBlocked = await get("/personality/cuteness/exclusive?trust=50");
if (!exclusiveOk.canUnlock || exclusiveBlocked.canUnlock) {
throw new Error("專屬揭露門檻(信任度 ≥ 80)不符預期");
}
// G-6 記住小事:使用者瑣事即使情緒強度低也應被固化保留
const triviaSessionId = "smoke-g-trivia-" + Date.now();
await post(`/memory/${GENKI_ID}/sessions/${triviaSessionId}/messages`, {
role: "user",
content: "我最近迷上了做甜點",
emotionTag: "CALM",
emotionIntensity: 0.05,
});
await post(`/memory/${GENKI_ID}/sessions/${triviaSessionId}/consolidate`, {});
const triviaSaved = await prisma.semanticMemory.findFirst({
where: { characterId: GENKI_ID, fact: "我最近迷上了做甜點" },
});
if (!triviaSaved) {
throw new Error("使用者瑣事未被固化保留為語意記憶");
}
// 清理本次測試建立的資料,避免重複執行時累積髒資料
await prisma.character.deleteMany({ where: { id: COLD_ID } });
await prisma.semanticMemory.deleteMany({ where: { id: triviaSaved.id } });
await prisma.contrastTriggerLog.deleteMany({ where: { characterId: GENKI_ID } });
await resetEmotion(GENKI_ID, { calm: 70, joy: 20, sad: 0, alert: 0, shy: 0, grumpy: 0 });
}