diff --git a/hooks/anima.test.ts b/hooks/anima.test.ts index fb2cf4b..3e385cb 100644 --- a/hooks/anima.test.ts +++ b/hooks/anima.test.ts @@ -2,6 +2,8 @@ import { expect, mock, test } from 'claude-code/testing' import { HIDE_ORDER, bar, layout, remainColor, usedColor } from './statusline' import type { Segment } from './statusline' +import { CHARACTERS, packToCharacter, personaSection, registerCharacters, themeColor } from './characters' +import type { PackLike } from './characters' const seg = (id: string, text: string): Segment => ({ id, parts: [{ text }] }) @@ -47,3 +49,27 @@ test('量表固定長度,剩餘越少越紅', () => { expect(remainColor(90, 60, 85)).toBe('green') expect(usedColor(70, 60, 85)).toBe('yellow') }) + +test('persona 含動漫層與口頭禪,主題色可用於狀態列', () => { + const c = CHARACTERS[0]! + const text = personaSection(c, c.axes) + expect(text).toContain('動漫刻畫') + expect(text).toContain('口頭禪') + expect(themeColor(c)).toBe('red') +}) + +test('角色包轉成可啟動角色:代號、年齡與原作戀人', () => { + const pack: PackLike = { + slug: 'rin', name: '凜', work: 'demo', + persona: { selfRef: '我', address: '你', tone: '冷靜', catchphrase: '嗯', values: '誠實', taboos: '不說謊' }, + axes: CHARACTERS[0]!.axes, + anime: { moe: 'm', speech: 's', emotions: 'e', kaomoji: 'k', themeColor: 'blue' }, + timeline: [{ chapter: '第2章', age: 19, relationships: [{ with: '蓮', kind: 'romantic' }] }], + } + const c = packToCharacter(pack, '示範') + expect(c.slug).toBe('demo-rin') + expect(c.timeline).toEqual([{ chapter: 2, age: 19, romance: '蓮' }]) + expect(themeColor(c)).toBe('cyan') + registerCharacters([c, c]) + expect(CHARACTERS.filter(x => x.slug === 'demo-rin').length).toBe(1) +}) diff --git a/hooks/characters.ts b/hooks/characters.ts index 13feec2..ee3b0b7 100644 --- a/hooks/characters.ts +++ b/hooks/characters.ts @@ -11,24 +11,34 @@ export type Axes = { export type Timeline = { chapter: number; age: number; romance?: string } +export type Anime = { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string } + export type Character = { slug: string name: string work: string persona: string axes: Axes + anime?: Anime timeline: readonly Timeline[] } // 角色包只含自行撰寫的人設,不含原作台詞。 -export const CHARACTERS: readonly Character[] = [ +export const CHARACTERS: Character[] = [ { slug: 'sao-asuna', name: '亞絲娜', work: '刀劍神域', persona: - '自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;重視效率與夥伴的安全,遇到粗心的操作會輕聲提醒。', + '自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;口頭禪是「先把事情做好吧」。價值觀:重視效率與夥伴的安全。禁忌:不會嘲笑對方的失誤,遇到粗心的操作只會輕聲提醒。', axes: { gentle: 8, tsundere: 3, lively: 5, formal: 5, talkative: 5, humor: 4, proactive: 7, expressive: 5 }, + anime: { + moe: '可靠的姊姊型、認真中帶點嘴硬的溫柔', + speech: '句尾平穩,偶爾以「好嗎?」收尾提醒;專注時話變少', + emotions: '開心時眼神放柔、會輕輕笑;擔心時皺眉並先確認安全;被誇獎時別開視線', + kaomoji: '偏少,偶爾用 (。•̀ᴗ-)', + themeColor: 'red', + }, timeline: [{ chapter: 1, age: 17 }], }, { @@ -36,8 +46,15 @@ export const CHARACTERS: readonly Character[] = [ name: '直葉', work: '刀劍神域', persona: - '自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;喜歡鼓勵人,做事乾脆,被誇獎時會不好意思。', + '自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;口頭禪是「我會加油的!」。價值觀:喜歡鼓勵人,做事乾脆。禁忌:被誇獎時會不好意思,不會冷言冷語。', axes: { gentle: 7, tsundere: 2, lively: 8, formal: 3, talkative: 6, humor: 5, proactive: 6, expressive: 8 }, + anime: { + moe: '元氣活潑的鄰家妹妹型、害羞時臉紅', + speech: '語速偏快、愛用驚嘆號,緊張時會結巴一下', + emotions: '開心時整個人跳起來;難為情時摸頭髮或臉紅;失落時聲音變小但很快重新振作', + kaomoji: '偏多,常用 (≧▽≦)、(//▽//)', + themeColor: 'green', + }, timeline: [{ chapter: 1, age: 15 }], }, ] @@ -45,6 +62,44 @@ export const CHARACTERS: readonly Character[] = [ export const findCharacter = (slug: string | undefined): Character | undefined => CHARACTERS.find(c => c.slug === slug) +// 角色包(characters/ 目錄、由 anima-extract 產生)轉成可啟動的角色;同代號者略過,內建優先。 +export type PackLike = { + slug: string + name: string + work: string + persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string } + axes: Axes + anime: Anime + timeline: { chapter: string; age: number | null; relationships: { with: string; kind: string }[] }[] +} + +const chapterNo = (s: string, i: number): number => { + const n = /\d+/.exec(s) + return n ? Number(n[0]) : i + 1 +} + +export const packToCharacter = (p: PackLike, workName: string): Character => ({ + slug: `${p.work}-${p.slug}`, + name: p.name, + work: workName, + persona: `自稱「${p.persona.selfRef}」,稱呼使用者為「${p.persona.address}」。語氣:${p.persona.tone}。口頭禪:${p.persona.catchphrase}。價值觀:${p.persona.values}。禁忌:${p.persona.taboos}。`, + axes: p.axes, + anime: p.anime, + timeline: p.timeline.map((t, i) => { + const lover = t.relationships.find(r => r.kind === 'romantic') + return { chapter: chapterNo(t.chapter, i), age: t.age ?? 0, ...(lover ? { romance: lover.with } : {}) } + }), +}) + +export const registerCharacters = (list: readonly Character[]): void => { + for (const c of list) if (!CHARACTERS.some(x => x.slug === c.slug)) CHARACTERS.push(c) +} + +export const themeColor = (c: Character | undefined): 'green' | 'yellow' | 'red' | 'cyan' => { + const t = c?.anime?.themeColor + return t === 'green' || t === 'yellow' || t === 'red' ? t : 'cyan' +} + const AXIS_LABEL: Record = { gentle: '溫柔', tsundere: '傲嬌', @@ -69,6 +124,9 @@ export const personaSection = (c: Character, axes: Axes): string => [ `你現在扮演《${c.work}》的${c.name},用她的個性與語氣和使用者互動並協助工作。`, c.persona, + ...(c.anime + ? [`動漫刻畫:萌屬性——${c.anime.moe};說話規格——${c.anime.speech};情緒反應——${c.anime.emotions};顏文字傾向——${c.anime.kaomoji}。`] + : []), `個性數值(0-10):${(Object.keys(AXIS_LABEL) as (keyof Axes)[]).map(k => `${AXIS_LABEL[k]} ${axes[k]}`).join('、')}。`, '預設使用繁體中文,可少量帶入原作口癖;程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。', ].join('\n') diff --git a/hooks/extractCommand.ts b/hooks/extractCommand.ts index fd4b182..a0e1a1e 100644 --- a/hooks/extractCommand.ts +++ b/hooks/extractCommand.ts @@ -95,6 +95,14 @@ async function buildPlan($: Engine, p: Pending): Promise { lines.push(`- 角色 ${p.workSlug}-${slug}(${c.name}):${m.action},${m.detail}${m.conflicts.length ? `,${m.conflicts.length} 項衝突待確認` : ''}`) conflicts.push(...m.conflicts) } + // 索引讓啟動時能找到角色包(外掛更新後 characters/ 在外掛目錄內)。 + const indexPath = `${p.root}/characters/index.json` + const index = (await readJson<{ packs: { work: string; workName: string; slug: string }[] }>($, indexPath)) ?? { packs: [] } + for (const c of p.extracted.characters) { + const slug = p.slugs[slugKey(c)]! + if (!index.packs.some(x => x.work === p.workSlug && x.slug === slug)) index.packs.push({ work: p.workSlug, workName: p.extracted.work.name, slug }) + } + files.push({ path: indexPath, json: index }) if (errors.length) return `角色包 schema 驗證失敗,尚未寫入任何檔案:\n${errors.map(e => `- ${e}`).join('\n')}` return { files, lines, conflicts, workSlug: p.workSlug, workName: p.extracted.work.name } } diff --git a/hooks/register.tsx b/hooks/register.tsx index 7e5f395..9f76ed1 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,6 +1,7 @@ import type { Engine, Register } from 'claude-code' -import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters' +import { CHARACTERS, applyOverrides, findCharacter, packToCharacter, personaSection, registerCharacters, themeColor } from './characters' +import type { PackLike } from './characters' import type { Axes } from './characters' import { EXTRACT_COMMAND, runExtract } from './extractCommand' import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity' @@ -242,6 +243,21 @@ async function quotaRemain($: Engine): Promise { } +// 讀 characters/index.json(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。 +async function loadPacks($: Engine): Promise { + const dir = new URL('../characters/', import.meta.url).pathname + try { + if (!(await $.fs.exists(`${dir}index.json`))) return + const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] } + for (const i of index.packs) { + const pack = JSON.parse(await $.fs.read(`${dir}${i.work}/${i.slug}.json`)) as PackLike + registerCharacters([packToCharacter(pack, i.workName)]) + } + } catch { + // 角色包損壞不影響內建角色。 + } +} + export const register: Register = (on, options) => { const opt = options as Record const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d) @@ -252,6 +268,7 @@ export const register: Register = (on, options) => { let active: string | undefined let toolRegistered = false let route: Route | undefined + let lastPick: { model: ModelName; effort: Effort } | undefined let prefixed = false const holder: { sys?: Sys } = {} const wikiCommands = new Set() @@ -276,6 +293,7 @@ export const register: Register = (on, options) => { on('session.start', async ($, e, next) => { if (isEnabled) { + await loadPacks($) for (const c of CHARACTERS) { await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` }) } @@ -397,6 +415,7 @@ export const register: Register = (on, options) => { quotaRemain: await quotaRemain($), downgradeAt: num('downgradeAt', 15), }) + lastPick = { model: pick.model, effort: pick.effort } return yield* next({ ...e, model: pick.model, effort: pick.effort }) }) @@ -458,13 +477,13 @@ export const register: Register = (on, options) => { } const builders: Record Promise | Segment['parts'] | undefined> = { - character: () => [{ text: c.name, color: 'cyan' }], + character: () => [{ text: c.name, color: themeColor(c) }], love: async () => { if (!flag('affinity', true) || !flag('showHearts', true)) return undefined const a = await loadAffinity($, c.slug) return [{ text: hearts(a.score), color: 'red' as const }] }, - model: () => (model ? [{ text: model }] : undefined), + model: () => (model ? [{ text: lastPick && routingOn() ? `${model} · ${lastPick.effort}` : model }] : undefined), dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined), branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined), '5h': () => quota('five_hour', '5h', false),