import { Injectable } from "@nestjs/common"; import type { EmotionTag, EmotionState } from "@kokorone/shared"; import { PrismaService } from "../prisma/prisma.service.js"; import { EmotionService, dominantState } from "../emotion/emotion.service.js"; import { RelationshipService } from "../relationship/relationship.service.js"; import { getArchetypeParams } from "../personality/archetype-params.js"; import { EMOTION_EXPRESSION_TABLE, BASIC_TIER_EMOTIONS, FRIEND_TIER_EMOTIONS, CLOSE_TIER_EMOTIONS, FRIEND_TIER_COSTUMES, CLOSE_TIER_COSTUMES, CRYING_EFFECT, CRYING_INTENSITY_THRESHOLD, type GazeDirection, } from "./constants.js"; export interface ResolveExpressionOptions { now?: Date; isThinking?: boolean; // O-2 視線:思考時視線飄上 isDeflecting?: boolean; // O-2 視線:說謊/迴避時視線撇開(跟 SHY 的視線飄開共用同一個方向) } export interface ExpressionState { emotionTag: EmotionTag; // 實際套用的表情(可能因親密度未解鎖而降級) rawEmotionTag: EmotionTag; // 情緒引擎真實判定的狀態,不受親密度解鎖影響——立繪與文字可以故意不一致 eyebrow: string; eye: string; mouth: string; effect: string | null; pose: string; magnitude: number; // O-4:0~1,外顯度 × 情緒強度——同樣是愉悅,元氣角色滿臉笑容、三無角色只有嘴角微動 gaze: GazeDirection; availableCostumes: string[]; lockedFallbackApplied: boolean; // O-3:真實情緒不在目前親密度階段的解鎖範圍內,被降級成基本表情 usedCryingVariant: boolean; // O-3:哭臉——只有 BONDED 階段的深度低落才會出現 } function fieldFor(tag: EmotionTag): keyof Omit { return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy"); } function clamp01(value: number): number { return Math.max(0, Math.min(1, value)); } function resolveGaze(emotionTag: EmotionTag, options: ResolveExpressionOptions): GazeDirection { if (options.isThinking) { return "UP"; } if (options.isDeflecting || emotionTag === "SHY") { return "AWAY"; } if (emotionTag === "ALERT") { return "DIRECT"; } return "NEUTRAL"; } // O-2/O-3/O-4 表情解析:情緒引擎直接驅動表情差分,套用親密度解鎖與性格外顯度縮放。 // 這裡回傳的「表情」永遠反映真實情緒狀態(rawEmotionTag/magnitude 都不看對話文字內容), // 文字生成走另一條完全獨立的路徑(MockProvider)——兩者不一致本身就是「微反差演出」, // 不需要額外寫「故意不一致」的特殊邏輯,是兩條獨立資料流的自然結果。 @Injectable() export class ExpressionResolverService { constructor( private readonly prisma: PrismaService, private readonly emotion: EmotionService, private readonly relationship: RelationshipService, ) {} async resolveExpression( characterId: string, userId: string, options: ResolveExpressionOptions = {}, ): Promise { const now = options.now ?? new Date(); const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); const archetypeParams = getArchetypeParams(character.personalityArchetype); const emotionState = await this.emotion.getState(characterId, now); const rawEmotionTag = dominantState(emotionState); const intensityNormalized = clamp01((emotionState[fieldFor(rawEmotionTag)] as number) / 100); const { relationship } = await this.relationship.getState(characterId, userId, now); const stage = relationship.stage; const allowedEmotions = stage === "BONDED" || stage === "CLOSE_AMBIGUOUS" ? CLOSE_TIER_EMOTIONS : stage === "FRIEND" ? FRIEND_TIER_EMOTIONS : BASIC_TIER_EMOTIONS; const lockedFallbackApplied = !allowedEmotions.includes(rawEmotionTag); const emotionTag = lockedFallbackApplied ? "CALM" : rawEmotionTag; const template = EMOTION_EXPRESSION_TABLE[emotionTag]; let effect = template.effect; let usedCryingVariant = false; if (emotionTag === "SAD" && intensityNormalized >= CRYING_INTENSITY_THRESHOLD && stage === "BONDED") { effect = CRYING_EFFECT; usedCryingVariant = true; } const magnitude = clamp01(archetypeParams.expressiveness * intensityNormalized); const gaze = resolveGaze(emotionTag, options); const availableCostumes = stage === "BONDED" || stage === "CLOSE_AMBIGUOUS" ? CLOSE_TIER_COSTUMES : stage === "FRIEND" ? FRIEND_TIER_COSTUMES : ["制服"]; return { emotionTag, rawEmotionTag, eyebrow: template.eyebrow, eye: template.eye, mouth: template.mouth, effect, pose: template.pose, magnitude, gaze, availableCostumes, lockedFallbackApplied, usedCryingVariant, }; } }