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:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,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 },
|
||||
|
||||
Reference in New Issue
Block a user