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)]}好感度只影響語氣、稱呼、害羞與主動程度,不影響技術內容、事實與安全回答的品質;不使用愧疚感或情緒勒索。` }