feat: companion 作息、主動發話、適應度與外部素材

- 作息與疲勞、想念累積與時間感,主動發話沿用 care.ts 的冷卻、日限、靜音
- 想家與適應度(9.13)
- 獨立腳本 fetch-materials.mjs 由系統排程寫入快取,hook 只讀、不連網
- 新增 /jsc-mod-companion:materials 與 setup location=、feeds=
- 天氣微調心情基準,新聞依適應度每天最多 2 則並寫入情節記憶
- 版本 0.0.5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 09:29:46 +00:00
co-authored by Claude Sonnet 5.5
parent eeed12741e
commit 7e6e323473
15 changed files with 1237 additions and 20 deletions
@@ -0,0 +1,60 @@
import { SCHEDULE as S } from './rules'
const HOUR = 3_600_000
/** "HH:MM" → 當天的分鐘數;格式錯誤回 null */
export function minutesOf(hhmm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm)
if (!m) return null
const h = Number(m[1])
const mm = Number(m[2])
return h < 24 && mm < 60 ? h * 60 + mm : null
}
const localMinutes = (now: number, tzOffsetMin: number) => (((Math.floor(now / 60_000) + tzOffsetMin) % 1440) + 1440) % 1440
/** 離上次起床過了幾小時;起床時間格式錯誤時回 0 */
export function awakeHours(now: number, tzOffsetMin: number, wake: string): number {
const w = minutesOf(wake)
if (w === null) return 0
return (((localMinutes(now, tzOffsetMin) - w) % 1440) + 1440) % 1440 / 60
}
/** 現在是否落在使用者的睡眠時段(就寢 → 起床,可跨午夜) */
export function inSleepWindow(now: number, tzOffsetMin: number, wake: string, sleep: string): boolean {
const w = minutesOf(wake)
const s = minutesOf(sleep)
if (w === null || s === null || w === s) return false
const t = localMinutes(now, tzOffsetMin)
return s < w ? t >= s && t < w : t >= s || t < w
}
export type Rhythm = { wake: string; sleep: string; tzOffsetMin: number }
/**
* 精力基準的作息位移:醒太久、連續工作太久、在使用者睡眠時段仍在線都會更累;剛起床稍有回復。
* workMs 為連續工作時間(0 表示沒在工作)。
*/
export function energyShift(now: number, workMs: number, r: Rhythm): number {
const hours = awakeHours(now, r.tzOffsetMin, r.wake)
const sleeping = inSleepWindow(now, r.tzOffsetMin, r.wake, r.sleep)
let shift = 0
if (sleeping) shift -= S.lateShift
else shift -= Math.min(S.awakeMax, Math.max(0, hours - S.awakeFreeH) * S.perHour)
shift -= Math.min(S.workMax, Math.max(0, workMs / HOUR - S.workFreeH) * S.workPerHour)
if (!sleeping && hours < S.wakeBoostH) shift += S.wakeBoost
return shift
}
export type Period = '清晨' | '上午' | '下午' | '傍晚' | '夜晚' | '深夜'
/** 時間感:本地時段名稱 */
export function periodOf(now: number, tzOffsetMin: number): Period {
const h = Math.floor(localMinutes(now, tzOffsetMin) / 60)
if (h < 5) return '深夜'
if (h < 9) return '清晨'
if (h < 12) return '上午'
if (h < 17) return '下午'
if (h < 19) return '傍晚'
return h < 23 ? '夜晚' : '深夜'
}