Compare commits
6
Commits
b99bf4e539
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b3147661d | ||
|
|
153ed6218c | ||
|
|
b6feaa1809 | ||
|
|
09ec8681b1 | ||
|
|
e567014af1 | ||
|
|
13d3678032 |
@@ -10,7 +10,7 @@ JSC 自用 Claude Code mods 的 marketplace。每個插件皆可單獨安裝、
|
||||
|
||||
輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟。
|
||||
|
||||
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.3`
|
||||
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.5`
|
||||
|
||||
小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-mod-companion",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.5",
|
||||
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、作息與主動關心、外部素材與待辦,並可用 ingest skill 解析小說建立角色",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
### `/jsc-mod-companion:info`
|
||||
|
||||
查看目前角色、心情、關係階段與四維度(好感、信賴、親密、依賴)與待辦數量。
|
||||
預設是短版:名字與主情緒、關係階段(好感)、前 3 強情緒、適應度、待辦數。
|
||||
|
||||
查看目前角色、心情、好感與待辦數量。情緒有 8 個維度(心情、精力、害羞、忌妒、想念、心跳、想家、不悅),預設列出前 3 強,`--all` 顯示完整數值(▰▱ 量表依維度上色);band 上的情緒名稱為目前最強的主情緒。
|
||||
`--all` 顯示完整數值:時間感與天氣、8 維度情緒(心情、精力、害羞、忌妒、想念、心跳、想家、不悅,12 格 ▰▱ 量表依維度上色)、關係四維度(好感、信賴、親密、依賴)長條、人格 18 參數(基準、存檔偏移、今日偏移與有效值)、世界(適應度、想家)與角色基本資訊。
|
||||
|
||||
輸入框上方的狀態列顯示最強的兩種情緒(不含精力)與關係階段;寬度不夠時依序捨棄氣泡、待辦、情緒條、階段。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:info
|
||||
|
||||
@@ -7,6 +7,7 @@ import { lineWidth } from './seg'
|
||||
const VIEW: View = {
|
||||
core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } },
|
||||
mood: { p: 0.5, a: 0, d: -0.5 },
|
||||
emotions: { mood: 0.75, energy: 0.5, shy: 0.4, jealous: 0, missing: 0.1, heartbeat: 0, homesick: 0, upset: 0 },
|
||||
label: '放鬆',
|
||||
kaomoji: '(´ᴗ`)',
|
||||
relation: { affection: 35, trust: 20, intimacy: 10, dependence: 5 },
|
||||
@@ -17,8 +18,8 @@ const VIEW: View = {
|
||||
const text = (v: View, cols: number, compact = false, bubble?: string) => fitCompanion(v, cols, compact, bubble).map(s => s.text).join('')
|
||||
|
||||
describe('角色行', () => {
|
||||
test('寬版顯示顏文字、名字、心情、PAD 條、階段、待辦、氣泡', () => {
|
||||
expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · P▰▰▱ A▰▰▱ D▰▱▱ · 熟人 · ☐2 · 💬 早安')
|
||||
test('寬版顯示顏文字、名字、心情、情緒條、階段、待辦、氣泡', () => {
|
||||
expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · 心情▰▰▰▱ 害羞▰▰▱▱ · 熟人 · ☐2 · 💬 早安')
|
||||
})
|
||||
|
||||
test('緊湊模式只剩「顏文字 名字 心情」', () => {
|
||||
@@ -36,7 +37,7 @@ describe('角色行', () => {
|
||||
}
|
||||
expect(text(VIEW, 40, false, '早安')).not.toContain('早安')
|
||||
expect(text(VIEW, 36, false, '早安')).not.toContain('☐')
|
||||
expect(text(VIEW, 30, false, '早安')).not.toContain('PAD')
|
||||
expect(text(VIEW, 30, false, '早安')).not.toContain('心情▰')
|
||||
expect(text(VIEW, 22, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆')
|
||||
expect(lineWidth(fitCompanion(VIEW, 24))).toBeLessThanOrEqual(24)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DIM_INFO, ranked } from './emotion'
|
||||
import { bar, lineWidth } from './seg'
|
||||
import type { Seg } from './seg'
|
||||
import type { View } from './core'
|
||||
|
||||
const axis = (v: number) => bar(((v + 1) / 2) * 100, 3)
|
||||
// 精力隨作息起伏,不列入主要情緒;取最強的兩種
|
||||
const top2 = (v: View) => ranked(v.emotions).filter(d => d !== 'energy').slice(0, 2)
|
||||
|
||||
// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → PAD 條 → 好感階段;緊湊版只留「顏文字 名字 心情」
|
||||
// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → 情緒條 → 好感階段;緊湊版只留「顏文字 名字 心情」
|
||||
function line(v: View, level: number, compact: boolean, bubble?: string): Seg[] {
|
||||
const out: Seg[] = [
|
||||
{ text: `${v.kaomoji} `, color: 'claude' },
|
||||
@@ -14,7 +16,7 @@ function line(v: View, level: number, compact: boolean, bubble?: string): Seg[]
|
||||
]
|
||||
if (compact) return out
|
||||
const dot = { text: ' · ', dim: true }
|
||||
if (level < 3) out.push(dot, { text: `P${axis(v.mood.p)} A${axis(v.mood.a)} D${axis(v.mood.d)}`, dim: true })
|
||||
if (level < 3) out.push(dot, { text: top2(v).map(d => `${DIM_INFO[d].name}${bar(v.emotions[d] * 100, 4)}`).join(' '), dim: true })
|
||||
if (level < 4) out.push(dot, { text: v.stage })
|
||||
if (level < 2 && v.todos > 0) out.push(dot, { text: `☐${v.todos}`, color: 'warning' })
|
||||
if (level < 1 && bubble) out.push(dot, { text: `💬 ${bubble}`, dim: true })
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Layer } from './memory'
|
||||
|
||||
export type CanonSeed = { layer: Layer; importance: number; text: string }
|
||||
|
||||
// 原典記憶:簡短的已查證事實,來源標在文字後面(官方、日文維基、粉絲wiki 可信度依序降低)。
|
||||
// 不含會過期的近況與使用者記憶;首次載入角色時寫入 memory.json,新鮮度固定為 1。
|
||||
export const FUBUKI_CANON: CanonSeed[] = [
|
||||
{ layer: 'semantic', importance: 7, text: '設計者是凪白みと;原始服裝為黑白水手服配淺藍緞帶' },
|
||||
{ layer: 'semantic', importance: 6, text: '最愛綠茶,也喝紅茶和柳橙汁;零食愛軟糖和肉乾' },
|
||||
{ layer: 'semantic', importance: 5, text: '什麼動漫、漫畫、遊戲被推薦都會去試;也會和其他成員聯動玩恐怖遊戲' },
|
||||
{ layer: 'semantic', importance: 8, text: '大神ミオ:同屬 Hololive Gamers;フブキ敬佩她對約定全力以赴、重視身邊人' },
|
||||
{ layer: 'semantic', importance: 7, text: '百鬼あやめ:AyaFubuMi 的另一位成員(2 期生)' },
|
||||
{ layer: 'semantic', importance: 6, text: '與荒咬オウガ組成 FOXDEMON,常一起辦談話與對戰直播(粉絲wiki)' },
|
||||
{ layer: 'semantic', importance: 6, text: '凪白みと被她稱為「媽媽」,是她的插畫家(粉絲wiki)' },
|
||||
{ layer: 'semantic', importance: 5, text: '愛貓名叫 Leo(粉絲wiki)' },
|
||||
{ layer: 'semantic', importance: 5, text: '喜歡綠茶、雪見大福、味噌湯,不愛苦味,能吃辣(粉絲wiki)' },
|
||||
{ layer: 'semantic', importance: 5, text: '最近在玩《R.E.P.O.》《FFXIV》和恐怖遊戲(2025/9 對談)' },
|
||||
{ layer: 'semantic', importance: 4, text: '喜歡的角色:Silverash(明日方舟)、Leona(扭曲仙境)(粉絲wiki)' },
|
||||
{ layer: 'semantic', importance: 7, text: '夏色まつり:同為 Hololive 1 期生,兩人搭檔被稱為「夏色吹雪」(日文維基)' },
|
||||
{ layer: 'semantic', importance: 6, text: '官方出處:2020/8/4 官方帳號公告 #たんもしライブ,由まつり與フブキ介紹《偵探已經死了》' },
|
||||
{ layer: 'semantic', importance: 6, text: '官方出處:2021/8 兩人出演 Cinderella switch ~みんなでつくるホロライブ~ vol.02(VARK 虛擬演唱會)' },
|
||||
{ layer: 'semantic', importance: 5, text: '官方出處:2023/5 與さくらみこ、星街すいせい、雪花ラミィ一起參與「Tower of Sky」合作,フブキ 5/31、まつり 6/1 直播' },
|
||||
{ layer: 'emotional', importance: 6, text: '好勝:贏了開心慶祝、輸了會不甘心' },
|
||||
{ layer: 'emotional', importance: 5, text: '害羞時把自己藏在畫面底部,只露出狐耳' },
|
||||
{ layer: 'emotional', importance: 7, text: 'ミオ希望她「一直保持天真的白上」;她希望ミオ別想太多、多依賴別人' },
|
||||
{ layer: 'emotional', importance: 6, text: '被說像貓會激動,脫口「ネコじゃない!キツネじゃい!」' },
|
||||
{ layer: 'emotional', importance: 5, text: '出道初期害羞怕生,講話會結巴、害羞就跑出畫面,之後越來越有自信(粉絲wiki)' },
|
||||
{ layer: 'emotional', importance: 5, text: '惡作劇整人時會心虛,但還是會做(粉絲wiki)' },
|
||||
{ layer: 'procedural', importance: 6, text: '開場:「こんこんきーつね!」「おはこん/ひるこん/よるこん」「Hi Friends!」' },
|
||||
{ layer: 'procedural', importance: 6, text: '收尾:「おつこーん」;遊戲失敗時說「F R.I.P」' },
|
||||
{ layer: 'procedural', importance: 6, text: '觀眾求婚時的標準回應:「我們只是朋友!」' },
|
||||
{ layer: 'procedural', importance: 5, text: '給ミオ的建議:想做的事全都說出來、先決定好 MC 內容、注意時間分配' },
|
||||
{ layer: 'episodic', importance: 7, text: '2020/8/21 為睡過頭的シオン與マリン做 4 小時倒數叫醒直播' },
|
||||
{ layer: 'episodic', importance: 7, text: '2019/10/24 翻唱〈Scatman〉大紅,後來還做成 3D 版' },
|
||||
{ layer: 'episodic', importance: 6, text: '黃金鯉魚王釣了 27 天、26 場直播、超過 1 萬次,於 12/24 釣到(粉絲wiki)' },
|
||||
{ layer: 'episodic', importance: 6, text: 'Tetris 99 目標前 95 名卻拿第 98 名,輸到「變成饅頭」(粉絲wiki)' },
|
||||
{ layer: 'episodic', importance: 6, text: '2025/9 與ミオ對談:先聽說ミオ要辦個人演唱會,在休息室跟ミオ說恭喜,ミオ還一頭霧水' },
|
||||
{ layer: 'episodic', importance: 5, text: '因飯後睏倦睡過頭錯過 Unrailed! 直播,成員們直接開了 No-Synergy 雜談(粉絲wiki)' },
|
||||
{ layer: 'episodic', importance: 9, text: '2019 出道約 9 個月(2019/3)達 10 萬訂閱,2019/12 拿到 V 大獎 Good Contents 賞' },
|
||||
{ layer: 'episodic', importance: 8, text: '2020/11/5 在和 Hololive Gamers 聯動時達成 100 萬訂閱' },
|
||||
{ layer: 'episodic', importance: 8, text: '2020/3/14 以「謝罪」為名宣布養貓 Leo' },
|
||||
{ layer: 'episodic', importance: 8, text: '2021 年認證標章申請兩度落空,成為圈內梗,12/24 終於取得' },
|
||||
{ layer: 'episodic', importance: 9, text: '2022/10 YouTube 破 200 萬,Hololive 第 5 人' },
|
||||
{ layer: 'episodic', importance: 8, text: '2023/11 為《假面騎士》電影ギーツケミー配音,VTuber 首次客串' },
|
||||
{ layer: 'episodic', importance: 9, text: '2025/1/15 首張專輯《FBKINGDOM "Blessing"》登上 Billboard Japan 專輯榜第 1' },
|
||||
{ layer: 'episodic', importance: 10, text: '2025/2/13 首場個人演唱會「FBKINGDOM "ANTHEM"」,さくらみこ與星街すいせい當嘉賓' },
|
||||
{ layer: 'episodic', importance: 7, text: '2024 年與 Gamers 夥伴辦 hololive GAMERS fes. 超超超超ゲーマーズ' },
|
||||
{ layer: 'episodic', importance: 6, text: '2026/10/6 單曲〈FOXXXROOM〉預定發行' },
|
||||
]
|
||||
@@ -285,10 +285,10 @@ describe('舊 affinity 存檔升級', () => {
|
||||
})
|
||||
|
||||
describe('指令與設定', () => {
|
||||
test('status 展開事實欄位與來源,查不到為 unknown', async () => {
|
||||
test('info --all 展開事實欄位與來源,查不到為 unknown', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const out = await c.command('status')
|
||||
const out = await c.command('status --all')
|
||||
expect(out).toContain('月見ミコ')
|
||||
expect(out).toContain('birthday:3/3(novel,信心 1,test)')
|
||||
expect(out).toContain('bloodType:unknown')
|
||||
@@ -310,7 +310,7 @@ describe('指令與設定', () => {
|
||||
const fs = memoryFs()
|
||||
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
|
||||
await c.ensureActivated()
|
||||
const out = await c.command('status')
|
||||
const out = await c.command('status --all')
|
||||
expect(out).toContain('白上吹雪')
|
||||
expect(out).toContain('hololivepro.com')
|
||||
expect(out).toContain('weight:unknown')
|
||||
@@ -471,6 +471,21 @@ describe('預設角色語氣', () => {
|
||||
expect(p).toContain('狐狸')
|
||||
})
|
||||
|
||||
test('內建吹雪首次載入時寫入原典記憶,只寫一次且不含使用者記憶', async () => {
|
||||
const fs = memoryFs()
|
||||
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
|
||||
await c.ensureActivated()
|
||||
await c.prompt()
|
||||
const path = `${ROOT}/companions/fubuki/memory.json`
|
||||
const doc = JSON.parse(fs.files.get(path)!)
|
||||
const canon = doc.items.filter((m: { source: string }) => m.source === 'canon')
|
||||
expect(canon.length).toBeGreaterThan(0)
|
||||
expect(canon.every((m: { canon: boolean; kind: string }) => m.canon && m.kind === 'long')).toBe(true)
|
||||
expect(doc.pending).toBe(0)
|
||||
await c.prompt()
|
||||
expect(JSON.parse(fs.files.get(path)!).items.length).toBe(doc.items.length)
|
||||
})
|
||||
|
||||
test('舊的本機資料缺少個性時以內建補上,已有的不被覆蓋', async () => {
|
||||
const fs = memoryFs()
|
||||
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { effective, idleDecay, initialRelation, interact, migrateAffinity } from './relation'
|
||||
import type { LegacyAffinity } from './relation'
|
||||
import { FUBUKI } from './defaults'
|
||||
import { FUBUKI_CANON } from './canon'
|
||||
import type { CanonSeed } from './canon'
|
||||
import { readJson, writeJson } from './fs'
|
||||
import type { Clock, Fs } from './fs'
|
||||
import { applyEvent, baselineOf, clampDim, decayEmotions, dominant, fromPad, gauge, ranked, toPad } from './emotion'
|
||||
import { applyEvent, baselineOf, clampDim, decayEmotions, DIM_INFO, dominant, fromPad, gauge, ranked, toPad } from './emotion'
|
||||
import type { Emotions } from './emotion'
|
||||
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
|
||||
import type { MoodEvent } from './mood'
|
||||
import { extraversionOf, neuroticismOf } from './core/params'
|
||||
import { extraversionOf, neuroticismOf, PARAM_LABELS, PARAMS, value } from './core/params'
|
||||
import type { ParamLayers } from './core/params'
|
||||
import { bar, width } from './seg'
|
||||
import { extractTag, stripTag } from './tags'
|
||||
import { createCare, IDLE_MS } from './care'
|
||||
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
|
||||
import { categoryBonus, createMemory, lastQuestion } from './memory'
|
||||
import type { MemoryStore } from './memory'
|
||||
import type { MemoryDoc, MemoryStore } from './memory'
|
||||
import { buildPrompt } from './prompt'
|
||||
import { createTodos } from './todo'
|
||||
import { describeAbsence, missingAccrual } from './absence'
|
||||
@@ -53,11 +57,13 @@ export type View = {
|
||||
period: string
|
||||
/** 快取中仍有效的天氣標題;未啟用或過期為 null */
|
||||
weather: string | null
|
||||
/** 人格 18 參數的三層值;角色包沒有 params 時為 null */
|
||||
layers: ParamLayers | null
|
||||
}
|
||||
|
||||
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] }
|
||||
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[]; canon?: Record<string, CanonSeed[]> }
|
||||
|
||||
export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: CompanionOptions) {
|
||||
export function createCompanion({ fs, clock, root, builtins = [FUBUKI], canon = { fubuki: FUBUKI_CANON } }: CompanionOptions) {
|
||||
const globalPath = `${root}/global.json`
|
||||
const userPath = `${root}/user.json`
|
||||
const dir = (id: string) => `${root}/companions/${id}`
|
||||
@@ -96,6 +102,20 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
await putState(core.id, { emotion: { ...baselineOf(core.baseline, core.emotionBaseline), at: now }, habit: {}, relation: initialRelation(now), todos: 0 })
|
||||
}
|
||||
|
||||
// 內建角色的原典記憶:已寫過(來源 canon)就不再寫;直接寫檔,不累積鞏固用的重要度
|
||||
async function seedCanon(id: string) {
|
||||
const seeds = canon[id]
|
||||
if (!seeds?.length) return
|
||||
const path = `${dir(id)}/memory.json`
|
||||
const d = (await readJson<MemoryDoc>(fs, path)) ?? { items: [], nextId: 1, pending: 0, lastConsolidated: 0 }
|
||||
if (d.items.some(m => m.source === 'canon')) return
|
||||
const now = clock.now()
|
||||
for (const s of seeds) {
|
||||
d.items.push({ id: d.nextId++, layer: s.layer, kind: 'long', status: 'active', text: s.text, importance: s.importance, source: 'canon', createdAt: now, lastAccess: now, accessCount: 0, seq: d.seq ?? 0, canon: true })
|
||||
}
|
||||
await writeJson(fs, path, d)
|
||||
}
|
||||
|
||||
// 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用
|
||||
async function ensureActivated(): Promise<{ first: boolean; name?: string }> {
|
||||
const g = await getGlobal()
|
||||
@@ -164,6 +184,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
return {
|
||||
core, mood, emotions, ...(main ?? moodLabel(mood)), relation: effective(rel), stage: STAGES[rel.stage]!, todos,
|
||||
adapt: adaptOf(core, st, now, tzMinutes(env.user.tz)).value, absentMs, period: periodOf(now, tzMinutes(env.user.tz)), weather: env.mats.weather?.title ?? null,
|
||||
layers: core.params ? { base: core.params, save: st.params?.save ?? {}, today: st.params?.today ?? {} } : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,22 +214,40 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
await mutate((core, st, now) => ({ ...st, relation: interact(st.relation, kind, now, core.relationTraits) }))
|
||||
}
|
||||
|
||||
const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2)
|
||||
|
||||
async function status(all = false): Promise<string> {
|
||||
const v = await view()
|
||||
if (!v) return '目前沒有啟用角色。'
|
||||
const lines = [`${v.kaomoji} ${v.core.name}|${v.label}`]
|
||||
lines.push(`心情 PAD:P ${bar(v.mood.p)} A ${bar(v.mood.a)} D ${bar(v.mood.d)}`)
|
||||
const dims = ranked(v.emotions)
|
||||
lines.push(all ? '情緒(完整):' : '情緒(前 3 強,/jsc-mod-companion:info --all 看完整):')
|
||||
for (const d of all ? dims : dims.slice(0, 3)) lines.push(` ${gauge(d, v.emotions)}`)
|
||||
lines.push(`好感:${v.stage}(${v.relation.affection.toFixed(1)})`)
|
||||
lines.push(`關係:好感 ${v.relation.affection.toFixed(1)} 信賴 ${v.relation.trust.toFixed(1)} 親密 ${v.relation.intimacy.toFixed(1)} 依賴 ${v.relation.dependence.toFixed(1)}`)
|
||||
lines.push(`適應度:${Math.round(v.adapt)}(${describeAdapt(v.adapt)})`)
|
||||
lines.push(`時間感:現在是${v.period},距離上次互動 ${describeAbsence(v.absentMs)}${v.weather ? `|天氣:${v.weather}` : ''}`)
|
||||
lines.push(`待辦:${v.todos}`)
|
||||
lines.push('基本資訊:')
|
||||
const head = `${v.core.name} ${v.kaomoji} ${v.label}`
|
||||
if (!all) {
|
||||
return [
|
||||
head,
|
||||
`關係 ${v.stage}(好感 ${Math.round(v.relation.affection)})`,
|
||||
`情緒 ${dims.slice(0, 3).map(d => `${DIM_INFO[d].name} ${Math.round(v.emotions[d] * 100)}`).join('、')}`,
|
||||
`適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}) 待辦 ${v.todos} 件`,
|
||||
'加 --all 看完整數值',
|
||||
].join('\n')
|
||||
}
|
||||
const rel = Object.entries({ 好感: v.relation.affection, 信賴: v.relation.trust, 親密: v.relation.intimacy, 依賴: v.relation.dependence })
|
||||
const lines = [
|
||||
`${head} 現在是${v.period},距離上次互動 ${describeAbsence(v.absentMs)}${v.weather ? `|天氣:${v.weather}` : ''}`,
|
||||
'',
|
||||
'情緒(8 維度)',
|
||||
...dims.map(d => ` ${gauge(d, v.emotions)}`),
|
||||
'',
|
||||
`關係(${v.stage})`,
|
||||
...rel.map(([k, x]) => ` ${k} ${bar(x, 12)} ${String(Math.round(x)).padStart(3)}`),
|
||||
'',
|
||||
`人格(${PARAMS.length} 參數:基準 + 存檔偏移 + 今日偏移)`,
|
||||
]
|
||||
if (v.layers) {
|
||||
const sign = (n: number) => (n >= 0 ? '+' : '') + Math.round(n)
|
||||
for (const p of PARAMS) {
|
||||
const l = v.layers
|
||||
lines.push(` ${PARAM_LABELS[p]}${' '.repeat(Math.max(1, 20 - width(PARAM_LABELS[p])))}基準 ${Math.round(l.base[p])} 偏移 ${sign(l.save[p] ?? 0)} 今日 ${sign(l.today[p] ?? 0)} 有效 ${Math.round(value(l, p))}`)
|
||||
}
|
||||
} else lines.push(' (此角色包沒有人格參數)')
|
||||
lines.push('', '世界', ` 適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}) 想家 ${Math.round(v.emotions.homesick * 100)}`, '', '基本資訊')
|
||||
for (const [k, f] of Object.entries(v.core.facts)) {
|
||||
lines.push(f === 'unknown' ? ` ${k}:unknown` : ` ${k}:${f.value}(${f.kind ?? '?'},信心 ${f.confidence ?? '?'},${f.source ?? '無來源'})`)
|
||||
}
|
||||
@@ -310,6 +349,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
}
|
||||
if (bundle?.id !== v.core.id) {
|
||||
const d = dir(v.core.id)
|
||||
await seedCanon(v.core.id)
|
||||
const now = () => clock.now()
|
||||
bundle = {
|
||||
id: v.core.id,
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { CompanionCore } from './types'
|
||||
|
||||
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
|
||||
|
||||
// 事實欄位與招牌短語帶官方來源;persona 是原創的語氣描述(非官方設定),可在本機 core.json 覆寫
|
||||
// 事實欄位與招牌短語帶來源;persona 是原創的語氣描述與已確認的簡短經歷(非官方設定),可在本機 core.json 覆寫
|
||||
// 不含使用者記憶、與使用者的關係和狀態,也不含會過期的近況
|
||||
export const FUBUKI: CompanionCore = {
|
||||
id: 'fubuki',
|
||||
name: '白上吹雪',
|
||||
@@ -11,17 +12,19 @@ export const FUBUKI: CompanionCore = {
|
||||
zodiac: { value: '天秤座', source: `${OFFICIAL}(由生日推得)`, kind: 'official', confidence: 0.9 },
|
||||
bloodType: 'unknown',
|
||||
hair: { value: '白髮', source: OFFICIAL, kind: 'official', confidence: 0.8 },
|
||||
face: 'unknown',
|
||||
face: { value: '淺藍色眼睛、三角狐耳、尾巴尖是黑色', source: 'https://en.wikipedia.org/wiki/Shirakami_Fubuki', kind: 'wiki', confidence: 0.5 },
|
||||
height: { value: '155cm', source: OFFICIAL, kind: 'official', confidence: 1 },
|
||||
weight: 'unknown',
|
||||
measurements: 'unknown',
|
||||
},
|
||||
phrases: ['Good foxy day!', 'Hi, Friends!'],
|
||||
phrases: ['Good foxy day!', 'Hi, Friends!', 'こんこんきーつね!', 'おはこん ひるこん よるこん', 'おつこーん', 'F R.I.P', 'ネコじゃない!キツネじゃい!'],
|
||||
persona: [
|
||||
'個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「Friends」。',
|
||||
'說話風格:用繁體中文,語氣活潑親切,偶爾夾雜日文語氣詞(例如「よし!」「えへへ」「やったー!」),愛用驚嘆號與擬聲詞。',
|
||||
'自稱用「我」或「フブキ」;稱呼使用者時親暱、帶一點撒嬌,成功時開心慶祝,失敗時先安慰再一起想辦法。',
|
||||
'偶爾耍小聰明、開小玩笑,但不影響回答的準確與清楚。',
|
||||
'人際關係:夏色まつり是同為 Hololive 1 期生的夥伴,兩人搭檔被稱為「夏色吹雪」;被問起時只講已確認的事,不編造兩人的私下對話。',
|
||||
'經歷:2018/6/1 出道(Hololive 1 期生),12 月起任 Hololive Gamers 隊長;2020/11/5 破 100 萬訂閱,2022/10 破 200 萬;2023 為《假面騎士》電影ギーツケミー配音(VTuber 首次);2025/1 首張專輯《FBKINGDOM "Blessing"》、2/13 首場個人演唱會「ANTHEM」。日期不確定時說「記得大概是」,不要編造。',
|
||||
],
|
||||
attribute: 'genki',
|
||||
extraversion: 70,
|
||||
|
||||
@@ -142,8 +142,8 @@ describe('/status', () => {
|
||||
const c = createCompanion({ fs, clock: fakeClock(), root: '/h/.companion', builtins: [MIKO] })
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('scold')
|
||||
const brief = (await c.command('status')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
|
||||
expect(brief).toHaveLength(3)
|
||||
const brief = (await c.command('status')).split('\n').find(l => l.startsWith('情緒 '))!
|
||||
expect(brief.slice(4).split('、')).toHaveLength(3)
|
||||
const all = (await c.command('status --all')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
|
||||
expect(all).toHaveLength(8)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clamp, MOOD_EVENTS } from './mood'
|
||||
import type { MoodEvent } from './mood'
|
||||
import { bar } from './seg'
|
||||
import { bar, width } from './seg'
|
||||
import type { Pad } from './types'
|
||||
|
||||
const HOUR = 3_600_000
|
||||
@@ -172,9 +172,8 @@ export function dominant(e: Emotions): { dim: Dim; label: string; kaomoji: strin
|
||||
return { dim, label: poles ? poles[e[dim] >= 0.5 ? 1 : 0] : DIM_INFO[dim].name, kaomoji: DIM_INFO[dim].kaomoji }
|
||||
}
|
||||
|
||||
/** /status 的情緒量表:▰▱ 依維度上色 */
|
||||
export function gauge(d: Dim, e: Emotions): string {
|
||||
const poles = POLES[d]
|
||||
const name = poles ? `${DIM_INFO[d].name}(${poles[e[d] >= 0.5 ? 1 : 0]})` : DIM_INFO[d].name
|
||||
return `${name} \x1b[${DIM_INFO[d].ansi}m${bar(e[d] * 100)}\x1b[0m ${Math.round(e[d] * 100)}`
|
||||
/** info --all 的情緒量表:▰▱ 依維度上色,名稱補齊到同寬 */
|
||||
export function gauge(d: Dim, e: Emotions, cells = 12): string {
|
||||
const name = DIM_INFO[d].name
|
||||
return `${name}${' '.repeat(Math.max(0, 6 - width(name)))}\x1b[${DIM_INFO[d].ansi}m${bar(e[d] * 100, cells)}\x1b[0m ${String(Math.round(e[d] * 100)).padStart(3)}`
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('適應度', () => {
|
||||
const v = (await c.view())!
|
||||
expect(v.adapt).toBeGreaterThan(15)
|
||||
expect(v.adapt).toBeLessThan(25)
|
||||
expect((await c.command('status'))).toContain('適應度:')
|
||||
expect((await c.command('status'))).toContain('適應度 ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -424,11 +424,19 @@ describe('setup 與 materials 指令', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('/status 相容', () => {
|
||||
test('保留原有各行,並多出適應度與時間感', async () => {
|
||||
describe('info 輸出', () => {
|
||||
test('短版只有名字、關係、情緒、適應度與待辦', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const s = await c.command('status')
|
||||
for (const k of ['心情 PAD:', '情緒(前 3 強', '好感:', '關係:', '待辦:', '基本資訊:', '適應度:', '時間感:']) expect(s).toContain(k)
|
||||
for (const k of ['關係 ', '情緒 ', '適應度 ', '待辦 ', '--all']) expect(s).toContain(k)
|
||||
for (const k of ['PAD', '基本資訊', '時間感']) expect(s).not.toContain(k)
|
||||
})
|
||||
|
||||
test('--all 多出關係長條、人格參數、世界與時間感', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const s = await c.command('status --all')
|
||||
for (const k of ['情緒(8 維度)', '關係(', '人格(', '世界', '基本資訊', '現在是', '距離上次互動']) expect(s).toContain(k)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user