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
+2
View File
@@ -6,6 +6,7 @@ import { MemoryModule } from "./memory/memory.module.js";
import { EmotionModule } from "./emotion/emotion.module.js";
import { RelationshipModule } from "./relationship/relationship.module.js";
import { LlmModule } from "./llm/llm.module.js";
import { PersonalityModule } from "./personality/personality.module.js";
@Module({
imports: [
@@ -15,6 +16,7 @@ import { LlmModule } from "./llm/llm.module.js";
EmotionModule,
RelationshipModule,
LlmModule,
PersonalityModule,
],
controllers: [HealthController],
})
+11 -1
View File
@@ -4,11 +4,14 @@ import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
// 低於此值視為「沒有明顯情緒」,dominantState 回報平靜。
const DOMINANT_EPSILON = 5;
// 標記強度低於此值視為刺激太弱,不足以真正觸發情緒轉移(高情緒觸發閾值的原型因此「難觸發」)。
const MIN_TRIGGER_INTENSITY = 0.25;
interface Dimensions {
calm: number;
@@ -95,7 +98,14 @@ export class EmotionService {
const { dims: decayed } = await this.decayedDimensions(characterId, now, options.halfLifeMs);
const currentDominant = dominantState(decayed);
const signal = this.tagger.tag({ text, triggerThreshold: options.triggerThreshold });
// G-1:情緒觸發閾值預設由角色性格原型決定,呼叫端仍可明確覆寫。
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const triggerThreshold = options.triggerThreshold ?? archetypeParams.emotionTriggerThreshold;
const rawSignal = this.tagger.tag({ text, triggerThreshold });
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
const signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
const next = nextEmotionState(currentDominant, signal, options);
let updated: Dimensions;
+6 -1
View File
@@ -6,6 +6,7 @@ import { ContextAssemblerService } from "./context-assembler.service.js";
import { OutputFilterService } from "./output-filter.service.js";
import { FastChannelDetector, DEFAULT_FAST_REPLIES } from "./fast-channel.service.js";
import { extractActions } from "./action-markup.js";
import { getForbiddenWords, applySpeechQuirks } from "../personality/language-style.js";
import type { GenerationContext } from "./types.js";
export interface HandleMessageOptions {
@@ -71,7 +72,11 @@ export class DialogueService {
actions = generated.actions;
}
const filteredText = this.outputFilter.filter(text);
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
const filteredText = applySpeechQuirks(
this.outputFilter.filter(text, forbiddenWords),
context.character.personalityArchetype,
);
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
return { text: filteredText, actions, isFastChannel, context };
@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { WorkingMemoryService } from "./working-memory.service.js";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
import { isUserTrivia } from "../personality/cuteness-behavior.js";
function normalize(content: string): string {
return content.trim().toLowerCase();
@@ -30,6 +31,12 @@ export class MemoryConsolidationService {
const isRepeated = (contentCounts.get(normalize(entry.content)) ?? 0) > 1;
if (!isHighEmotion && !isRepeated) {
// G-6 記住小事:使用者隨口提過的瑣事即使情緒強度低也額外加權保留(寫入語意記憶,日後可主動提起)。
if (entry.role === "user" && isUserTrivia(entry.content)) {
await this.prisma.client.semanticMemory.create({
data: { characterId, fact: entry.content, about: "使用者" },
});
}
continue;
}
@@ -0,0 +1,67 @@
// G-1 性格原型參數表:同一套情緒引擎,調不同參數就是不同性格。
export type Archetype = "傲嬌" | "冷淡" | "天然呆" | "元氣" | "大小姐" | "三無";
export interface ArchetypeParams {
emotionTriggerThreshold: number; // 情緒觸發閾值:數值越低越敏感、越容易被觸發(見 D-1 EmotionTagger)
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6)參考
}
const DEFAULT_PARAMS: ArchetypeParams = {
emotionTriggerThreshold: 1,
expressiveness: 0.5,
trustGrowthRate: 1,
invertedIntimacyExpression: false,
traits: [],
};
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
傲嬌: {
emotionTriggerThreshold: 0.6,
expressiveness: 0.9,
trustGrowthRate: 0.5,
invertedIntimacyExpression: true,
traits: ["denial-then-honest"],
},
冷淡: {
emotionTriggerThreshold: 2.5,
expressiveness: 0.1,
trustGrowthRate: 0.3,
invertedIntimacyExpression: false,
traits: ["flat-affect", "short-replies"],
},
天然呆: {
emotionTriggerThreshold: 0.7,
expressiveness: 0.8,
trustGrowthRate: 1.5,
invertedIntimacyExpression: false,
traits: ["misreads-context"],
},
元氣: {
emotionTriggerThreshold: 0.7,
expressiveness: 1.0,
trustGrowthRate: 1.3,
invertedIntimacyExpression: false,
traits: ["talkative", "fast-emotion-decay"],
},
大小姐: {
emotionTriggerThreshold: 1.3,
expressiveness: 0.7,
trustGrowthRate: 0.4,
invertedIntimacyExpression: false,
traits: ["formal-address", "poor-loser"],
},
三無: {
emotionTriggerThreshold: 3.5,
expressiveness: 0.05,
trustGrowthRate: 0.2,
invertedIntimacyExpression: false,
traits: ["very-short-replies", "sudden-overflow"],
},
};
export function getArchetypeParams(archetype: string): ArchetypeParams {
return ARCHETYPE_PARAMS[archetype as Archetype] ?? DEFAULT_PARAMS;
}
@@ -0,0 +1,96 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { stageForIntimacy } from "../relationship/constants.js";
export interface InitialRelationshipInput {
userId: string;
intimacy?: number;
trust?: number;
}
export interface CreateCharacterInput {
id?: string;
source: "ORIGINAL" | "EXISTING_WORK";
buildStatus?: "CANDIDATE" | "BUILT";
workId?: string;
formalName: string;
basicInfo: string;
backgroundStory: string;
personalityArchetype: string;
likesDislikes: string;
goalsObsessions: string;
speechStyle: string;
initialRelationValue?: string;
initialRelationship?: InitialRelationshipInput;
}
// G-5 角色建立流程:由角色設定表產生初始語意記憶+初始情節記憶+情緒參數+關係初始值。
@Injectable()
export class CharacterCreationService {
constructor(
private readonly prisma: PrismaService,
private readonly tagger: RuleBasedEmotionTagger,
) {}
async createCharacter(input: CreateCharacterInput, now: Date = new Date()) {
const character = await this.prisma.client.character.create({
data: {
id: input.id,
source: input.source,
buildStatus: input.buildStatus ?? "CANDIDATE",
workId: input.workId,
basicInfo: input.basicInfo,
backgroundStory: input.backgroundStory,
personalityArchetype: input.personalityArchetype,
likesDislikes: input.likesDislikes,
goalsObsessions: input.goalsObsessions,
speechStyle: input.speechStyle,
initialRelationValue: input.initialRelationValue,
},
});
await this.prisma.client.characterAlias.create({
data: { characterId: character.id, formalName: input.formalName },
});
// 初始語意記憶:關於自己的事實。
await this.prisma.client.semanticMemory.create({
data: { characterId: character.id, fact: input.basicInfo, about: "自己" },
});
// 初始情節記憶:背景故事,重用 D-1 的規則標記器判斷情緒基調(帶情緒標記)。
const signal = this.tagger.tag({ text: input.backgroundStory });
await this.prisma.client.episodicMemory.create({
data: {
characterId: character.id,
content: input.backgroundStory,
occurredAt: now,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "OFFLINE_GENERATED",
weight: 2,
},
});
// 情緒參數:預設值(calm=100,其餘為 0)。
await this.prisma.client.emotionState.create({ data: { characterId: character.id } });
// 關係初始值(若指定了對象)。
if (input.initialRelationship) {
const intimacy = input.initialRelationship.intimacy ?? 0;
await this.prisma.client.relationship.create({
data: {
characterId: character.id,
userId: input.initialRelationship.userId,
intimacy,
trust: input.initialRelationship.trust ?? 0,
firstMet: now,
stage: stageForIntimacy(intimacy),
},
});
}
return character;
}
}
@@ -0,0 +1,63 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import type { Archetype } from "./archetype-params.js";
interface ContrastTriggerConfig {
intimacyThreshold: number;
requiredEmotion: EmotionTag;
probability: number;
cooldownDays: number;
}
// 反差觸發的一般條件(親密度>70 且情緒=愉悅 且機率門檻),機率/冷卻天數依原型而異,
// 數值越低代表該原型的反差越稀有(見 §反差萌的參數化)。
const CONTRAST_CONFIG: Record<Archetype, ContrastTriggerConfig> = {
傲嬌: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.3, cooldownDays: 3 },
冷淡: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.2, cooldownDays: 5 },
天然呆: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.25, cooldownDays: 4 },
元氣: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.2, cooldownDays: 5 },
大小姐: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.2, cooldownDays: 5 },
三無: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.15, cooldownDays: 7 },
};
// G-4 反差萌與稀有度控制:反差行為=性格原型的例外規則,觸發後進入冷卻期,永不常態化。
@Injectable()
export class ContrastBehaviorService {
constructor(private readonly prisma: PrismaService) {}
async tryTrigger(
characterId: string,
intimacy: number,
emotionTag: EmotionTag,
now: Date = new Date(),
randomFn: () => number = Math.random,
): Promise<boolean> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const config = CONTRAST_CONFIG[character.personalityArchetype as Archetype];
if (!config) {
return false;
}
if (intimacy < config.intimacyThreshold || emotionTag !== config.requiredEmotion) {
return false;
}
const lastLog = await this.prisma.client.contrastTriggerLog.findFirst({
where: { characterId },
orderBy: { triggeredAt: "desc" },
});
if (lastLog) {
const cooldownMs = config.cooldownDays * 24 * 60 * 60 * 1000;
if (now.getTime() - lastLog.triggeredAt.getTime() < cooldownMs) {
return false;
}
}
if (randomFn() > config.probability) {
return false;
}
await this.prisma.client.contrastTriggerLog.create({ data: { characterId, triggeredAt: now } });
return true;
}
}
@@ -0,0 +1,44 @@
import type { EmotionTag } from "@kokorone/shared";
// G-6 可愛度行為機制:八種行為的觸發條件與節奏控制。
// 1. 害羞:由 D-4 情緒狀態機的 CALM → SHY 邊處理(見 emotion-tagger.ts 的 PRAISE_KEYWORDS),本檔不重複實作。
// 7. 稱呼進化:由 language-style.ts 的 resolveAddressWithTransition 處理,本檔不重複實作。
const SPOILED_INTIMACY_THRESHOLD = 60;
const MAX_CONSECUTIVE_GRUMPY_TURNS = 3;
const EXCLUSIVE_TRUST_THRESHOLD = 80;
// 2. 撒嬌:親密度 ≥ 60 且情緒為愉悅/低落時,可解鎖撒嬌行為模式(要求陪伴、討獎勵、語尾軟化)。
export function canActSpoiled(intimacy: number, emotionTag: EmotionTag): boolean {
return intimacy >= SPOILED_INTIMACY_THRESHOLD && (emotionTag === "JOY" || emotionTag === "SAD");
}
// 3. 鬧彆扭的節奏控制:彆扭超過兩三輪就該被哄下台階,不宜放任累積成負擔。
export function shouldForceRecoveryFromGrumpy(consecutiveGrumpyTurns: number): boolean {
return consecutiveGrumpyTurns >= MAX_CONSECUTIVE_GRUMPY_TURNS;
}
const THIRD_PARTY_SIGNALS = ["他人很好", "她人很好", "比較喜歡別人", "別人對我更好", "另一個女生", "另一個男生"];
// 4. 吃醋:對話中稱讚其他角色 → 偵測「第三者好感訊號」,觸發短暫彆扭態。
export function detectThirdPartySignal(text: string): boolean {
return THIRD_PARTY_SIGNALS.some((signal) => text.includes(signal));
}
const TRIVIA_PATTERNS = ["我喜歡", "我不喜歡", "我討厭", "我最近", "我的興趣", "我常常"];
// 5. 記住小事:使用者隨口提過的瑣事,供 C-4 睡眠固化額外加權(寫入語意記憶而非因情緒強度判斷)。
export function isUserTrivia(content: string): boolean {
return TRIVIA_PATTERNS.some((pattern) => content.includes(pattern));
}
// 6. 笨拙的努力:依角色弱項設定,過程出小錯但不放棄——弱項判定屬任務內容(J 群組委託子系統的範疇),
// 本檔只提供觸發規則本身,供 J 群組的任務執行流程呼叫。
export function shouldShowClumsyEffort(isWeakArea: boolean): boolean {
return isWeakArea;
}
// 8. 專屬揭露:信任度 ≥ 80 解鎖背景故事中的深層記憶,且明示「這件事只跟你說過」。
export function canUnlockExclusiveMemory(trust: number): boolean {
return trust >= EXCLUSIVE_TRUST_THRESHOLD;
}
@@ -0,0 +1,29 @@
import { getArchetypeParams } from "./archetype-params.js";
// 傲嬌值的最大幅度係數:內外差值的峰值大小(0~1,相對於親密度量尺)。
const TSUNDERE_GAP_COEFFICIENT = 0.6;
// 傲嬌反向表達:內外差值=gap(internal)=k × internal × (100 - internal) / 100,
// 在 internal=0 與 internal=100 時歸零、在 internal=50 附近達峰值——
// 也就是「外顯敵意」隨內部親密度升高先升後降,直到高親密度時內外才趨於一致(偶爾誠實的瞬間)。
function tsundereGap(internalIntimacy: number): number {
return (TSUNDERE_GAP_COEFFICIENT * internalIntimacy * (100 - internalIntimacy)) / 100;
}
export interface ExpressedIntimacy {
internalIntimacy: number;
externalIntimacy: number;
hostilityGap: number; // 傲嬌值:內外差值,數值越大代表外顯越嘴硬
}
// G-3 外顯函數:把「內部親密度」轉換成「外顯表現」,非傲嬌原型內外一致。
export function computeExpressedIntimacy(archetype: string, internalIntimacy: number): ExpressedIntimacy {
const params = getArchetypeParams(archetype);
if (!params.invertedIntimacyExpression) {
return { internalIntimacy, externalIntimacy: internalIntimacy, hostilityGap: 0 };
}
const gap = tsundereGap(internalIntimacy);
const externalIntimacy = Math.max(0, Math.min(100, internalIntimacy - gap));
return { internalIntimacy, externalIntimacy, hostilityGap: gap };
}
@@ -0,0 +1,79 @@
import type { Archetype } from "./archetype-params.js";
export type AddressTier = "formal" | "name" | "nickname";
// 對齊 E-3 的親密度分層:認識(20)起改叫名字,摯友-曖昧(60)起改叫暱稱。
const NAME_TIER_THRESHOLD = 20;
const NICKNAME_TIER_THRESHOLD = 60;
export function addressTierForIntimacy(intimacy: number): AddressTier {
if (intimacy >= NICKNAME_TIER_THRESHOLD) return "nickname";
if (intimacy >= NAME_TIER_THRESHOLD) return "name";
return "formal";
}
function nicknameFor(userName: string): string {
const lastChar = userName.slice(-1) || userName;
return `小${lastChar}`;
}
// G-2 稱呼系統:陌生人→您、熟人→名字、摯友→暱稱。
export function resolveAddress(userName: string, intimacy: number): string {
const tier = addressTierForIntimacy(intimacy);
if (tier === "formal") return "您";
if (tier === "name") return userName;
return nicknameFor(userName);
}
export interface AddressTransition {
address: string;
tier: AddressTier;
justEvolved: boolean;
}
// G-6 稱呼進化:親密度跨越門檻的當下,回報 justEvolved 供上層觸發「害羞確認」演出。
export function resolveAddressWithTransition(
userName: string,
previousIntimacy: number,
currentIntimacy: number,
): AddressTransition {
const previousTier = addressTierForIntimacy(previousIntimacy);
const currentTier = addressTierForIntimacy(currentIntimacy);
return {
address: resolveAddress(userName, currentIntimacy),
tier: currentTier,
justEvolved: currentTier !== previousTier,
};
}
interface LanguageStyleRule {
sentenceSuffix?: string; // 語尾/口癖
forbiddenWords: string[]; // 禁則清單
}
// G-2 禁則:角色絕不使用的詞彙/語氣,作為程序記憶中的硬規則。
const LANGUAGE_STYLE_RULES: Record<Archetype, LanguageStyleRule> = {
傲嬌: { forbiddenWords: [] },
冷淡: { forbiddenWords: ["!", "!"] }, // 冷淡角色不用驚嘆號
天然呆: { sentenceSuffix: "的說", forbiddenWords: [] },
元氣: { forbiddenWords: [] },
大小姐: { forbiddenWords: ["幹", "靠", "屁啦"] }, // 大小姐不說粗話
三無: { forbiddenWords: ["!", "!"] },
};
export function getForbiddenWords(archetype: string): string[] {
return LANGUAGE_STYLE_RULES[archetype as Archetype]?.forbiddenWords ?? [];
}
// 口癖/語尾:插在句尾標點之前;沒有標點就直接接在後面。
export function applySpeechQuirks(text: string, archetype: string): string {
const suffix = LANGUAGE_STYLE_RULES[archetype as Archetype]?.sentenceSuffix;
if (!suffix || text.includes(suffix)) {
return text;
}
const match = text.match(/([!?。~]+)$/);
if (match) {
return text.slice(0, -match[0].length) + suffix + match[0];
}
return text + suffix;
}
@@ -0,0 +1,95 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { ARCHETYPE_PARAMS, getArchetypeParams } from "./archetype-params.js";
import { computeExpressedIntimacy } from "./expressiveness.js";
import { resolveAddress, resolveAddressWithTransition } from "./language-style.js";
import { ContrastBehaviorService } from "./contrast-behavior.service.js";
import { CharacterCreationService, type CreateCharacterInput } from "./character-creation.service.js";
import {
canActSpoiled,
shouldForceRecoveryFromGrumpy,
detectThirdPartySignal,
canUnlockExclusiveMemory,
} from "./cuteness-behavior.js";
interface ContrastTriggerBody {
intimacy: number;
emotionTag: EmotionTag;
now?: string;
roll?: number; // 測試用:指定機率骰值,避免依賴真實 Math.random()
}
@Controller("personality")
export class PersonalityController {
constructor(
private readonly contrastBehavior: ContrastBehaviorService,
private readonly characterCreation: CharacterCreationService,
) {}
@Get("archetypes")
getAllArchetypes() {
return ARCHETYPE_PARAMS;
}
@Get("archetypes/:archetype")
getArchetype(@Param("archetype") archetype: string) {
return getArchetypeParams(archetype);
}
@Get(":archetype/expressed-intimacy")
getExpressedIntimacy(@Param("archetype") archetype: string, @Query("intimacy") intimacy: string) {
return computeExpressedIntimacy(archetype, Number(intimacy));
}
@Get("address")
getAddress(@Query("userName") userName: string, @Query("intimacy") intimacy: string) {
return { address: resolveAddress(userName, Number(intimacy)) };
}
@Get("address-transition")
getAddressTransition(
@Query("userName") userName: string,
@Query("previous") previous: string,
@Query("current") current: string,
) {
return resolveAddressWithTransition(userName, Number(previous), Number(current));
}
@Post(":characterId/contrast-trigger")
async tryContrastTrigger(@Param("characterId") characterId: string, @Body() body: ContrastTriggerBody) {
const randomFn = body.roll !== undefined ? () => body.roll! : undefined;
const triggered = await this.contrastBehavior.tryTrigger(
characterId,
body.intimacy,
body.emotionTag,
body.now ? new Date(body.now) : undefined,
randomFn,
);
return { triggered };
}
@Post("characters")
async createCharacter(@Body() body: CreateCharacterInput) {
return this.characterCreation.createCharacter(body);
}
@Get("cuteness/spoiled")
getCanActSpoiled(@Query("intimacy") intimacy: string, @Query("emotionTag") emotionTag: EmotionTag) {
return { canActSpoiled: canActSpoiled(Number(intimacy), emotionTag) };
}
@Get("cuteness/grumpy-recovery")
getShouldForceRecovery(@Query("turns") turns: string) {
return { shouldForceRecovery: shouldForceRecoveryFromGrumpy(Number(turns)) };
}
@Get("cuteness/third-party")
getThirdPartySignal(@Query("text") text: string) {
return { detected: detectThirdPartySignal(text ?? "") };
}
@Get("cuteness/exclusive")
getCanUnlockExclusive(@Query("trust") trust: string) {
return { canUnlock: canUnlockExclusiveMemory(Number(trust)) };
}
}
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { ContrastBehaviorService } from "./contrast-behavior.service.js";
import { CharacterCreationService } from "./character-creation.service.js";
import { PersonalityController } from "./personality.controller.js";
@Module({
imports: [PrismaModule, EmotionModule],
controllers: [PersonalityController],
providers: [ContrastBehaviorService, CharacterCreationService],
exports: [ContrastBehaviorService, CharacterCreationService],
})
export class PersonalityModule {}
@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RelationshipService } from "./relationship.service.js";
import { DEFAULT_NEGATIVE_BIAS_MULTIPLIER } from "./constants.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
@@ -28,6 +29,8 @@ export class SentimentLedgerService {
options: RecordEventOptions = {},
): Promise<{ biasedWeight: number; trust: number }> {
const now = options.now ?? new Date();
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const { relationship } = await this.relationship.getState(characterId, userId, now);
const biasMultiplier = options.negativeBiasMultiplier ?? DEFAULT_NEGATIVE_BIAS_MULTIPLIER;
const biasedWeight = rawWeight < 0 ? rawWeight * biasMultiplier : rawWeight;
@@ -36,7 +39,9 @@ export class SentimentLedgerService {
data: { relationshipId: relationship.id, date: now, event, weight: biasedWeight },
});
const trust = clamp(relationship.trust + biasedWeight, 0, 100);
// G-1 信任成長速度:只調節正向事件的實際信任增幅,負向事件的衝擊幅度已由負向偏誤決定,不重複套用。
const trustDelta = biasedWeight < 0 ? biasedWeight : biasedWeight * archetypeParams.trustGrowthRate;
const trust = clamp(relationship.trust + trustDelta, 0, 100);
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: { trust },