feat: 階段 2 路由、改寫與好感度
- Haiku 一次分類(難度、模型、思考程度、語氣),角色立刻回應 - turn.step 覆寫模型與思考程度,額度低於門檻自動降一級,支援手動鎖定 - 主模型回覆改寫為角色口吻,程式碼/路徑/指令/數字/URL 原樣保留,失敗回原文並另存原文 log - 好感度規則、五階段、不跨階段掉落、個性倍率、狀態列愛心、/jsc-anima:anima-affinity - 年齡與關係規則(未成年友情、成年移轉、戀愛式依賴需明確開啟) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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)]}好感度只影響語氣、稱呼、害羞與主動程度,不影響技術內容、事實與安全回答的品質;不使用愧疚感或情緒勒索。`
|
||||
}
|
||||
Reference in New Issue
Block a user