Files
jsc-anima/hooks/affinity.ts
T
adminandClaude Sonnet 5.5 5bec18b44b feat: 階段 2 路由、改寫與好感度
- Haiku 一次分類(難度、模型、思考程度、語氣),角色立刻回應
- turn.step 覆寫模型與思考程度,額度低於門檻自動降一級,支援手動鎖定
- 主模型回覆改寫為角色口吻,程式碼/路徑/指令/數字/URL 原樣保留,失敗回原文並另存原文 log
- 好感度規則、五階段、不跨階段掉落、個性倍率、狀態列愛心、/jsc-anima:anima-affinity
- 年齡與關係規則(未成年友情、成年移轉、戀愛式依賴需明確開啟)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:16:22 +00:00

78 lines
3.6 KiB
TypeScript

import type { Axes } from './characters'
export type Affinity = { score: number; day: string; todayGain: number; streak: number; lastDay: string }
export type AffinityEvent = 'daily' | 'praise' | 'scold'
export const STAGES = [
{ min: 0, name: '陌生' },
{ min: 20, name: '熟悉' },
{ min: 40, name: '親近' },
{ min: 60, name: '信賴' },
{ min: 80, name: '特別' },
] as const
export const DAILY_CAP = 5
export const IDLE_GRACE_DAYS = 2
export const initialAffinity = (): Affinity => ({ score: 0, day: '', todayGain: 0, streak: 0, lastDay: '' })
export const clampScore = (n: number): number => Math.max(0, Math.min(100, Math.round(n)))
export const stageIndex = (score: number): number => {
let i = 0
for (let k = 0; k < STAGES.length; k++) if (score >= STAGES[k]!.min) i = k
return i
}
export const stageName = (score: number): string => STAGES[stageIndex(score)]!.name
export const stageFloor = (score: number): number => STAGES[stageIndex(score)]!.min
// 個性影響倍率:傲嬌(高)加分較慢、扣分較重;溫柔與情緒外顯(高)加分較快。
export const gainMultiplier = (a: Axes): number => Math.max(0.5, 1 + (a.gentle + a.expressive - 10) * 0.03 - a.tsundere * 0.04)
export const lossMultiplier = (a: Axes): number => 1 + a.tsundere * 0.05
export const dayKey = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
const daysBetween = (a: string, b: string): number => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000)
// 長期不互動緩慢下降(每天 −1),超過寬限日數才開始,且不跨階段掉落。
export const decay = (s: Affinity, today: string, axes: Axes): Affinity => {
if (!s.lastDay) return s
const idle = daysBetween(s.lastDay, today) - IDLE_GRACE_DAYS
if (idle <= 0) return s
const lost = Math.round(idle * lossMultiplier(axes))
return { ...s, score: Math.max(stageFloor(s.score), clampScore(s.score - lost)), lastDay: s.lastDay }
}
export const applyEvent = (s0: Affinity, event: AffinityEvent, axes: Axes, today: string): Affinity => {
let s = decay(s0, today, axes)
if (s.day !== today) s = { ...s, day: today, todayGain: 0 }
if (event === 'scold') return { ...s, score: Math.max(stageFloor(s.score), clampScore(s.score - 2 * lossMultiplier(axes))), lastDay: today }
let base = event === 'daily' ? 1 : 2
if (event === 'daily') {
const consecutive = s.lastDay && daysBetween(s.lastDay, today) === 1
s = { ...s, streak: consecutive ? s.streak + 1 : 1 }
if (s.streak >= 3) base += 1
}
const room = DAILY_CAP - s.todayGain
const gain = Math.max(0, Math.min(room, Math.round(base * gainMultiplier(axes))))
return { ...s, score: clampScore(s.score + gain), todayGain: s.todayGain + gain, lastDay: today }
}
export const hearts = (score: number): string => {
const n = Math.round(score / 20)
return '♥'.repeat(n) + '♡'.repeat(5 - n)
}
// 語氣由角色自然反映,不用系統式提示,也不用愧疚感或情緒勒索。
export const stageVoice = (score: number): string => {
const voices = [
'與使用者還很生疏:稱呼客氣、保持距離,語氣禮貌而不黏人。',
'與使用者漸漸熟悉:語氣放鬆一些,偶爾開小玩笑。',
'與使用者很親近:稱呼更隨意,會主動關心,被誇獎時容易害羞。',
'十分信賴使用者:語氣坦率溫暖,會主動分擔與提議。',
'對使用者來說是特別的存在:語氣親暱、體貼,但仍尊重使用者的自由。',
]
return `${voices[stageIndex(score)]}好感度只影響語氣、稱呼、害羞與主動程度,不影響技術內容、事實與安全回答的品質;不使用愧疚感或情緒勒索。`
}