合併 origin/main,解決 routing.test.ts 衝突
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+70
-14
@@ -17,25 +17,29 @@ const reply = (c: Extracted['characters'][number]) =>
|
||||
|
||||
type Setup = { files: Map<string, string>; cmds: string[][]; prompts: string[] }
|
||||
|
||||
let cloneMissing = false
|
||||
let registered: string[] = []
|
||||
const probed = new Set<string>()
|
||||
let sessionCwd = '/repo'
|
||||
const setup = (on: never, answers: string[], files = new Map<string, string>()): Setup => {
|
||||
const s: Setup = { files, cmds: [], prompts: [] }
|
||||
const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void
|
||||
mock.store(on)
|
||||
o('command.register', () => ({ value: { name: 'x' } }))
|
||||
o('command.register', ((_$: unknown, e: { name: string }) => { registered.push(e.name); return { value: { name: e.name } } }) as never)
|
||||
o('session.start', () => ({ cwd: '/repo' }))
|
||||
o('session.cwd', (() => ({ value: '/repo' })) as never)
|
||||
o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never)
|
||||
o('session.cwd', (() => ({ value: sessionCwd })) as never)
|
||||
mock.env(on, { HOME: '/home/u' })
|
||||
o('fs.exists', ((_$: unknown, e: { path: string }) => (e.path.endsWith('/characters/index.json') && probed.add(e.path), { value: s.files.has(e.path) || (e.path === '/home/u/.cache/jsc-anima/repo/.git' && !cloneMissing) })) as never)
|
||||
o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(e.path) ?? '' })) as never)
|
||||
o('fs.write', ((_$: unknown, e: { path: string; text: string }) => { s.files.set(e.path, e.text); return { value: undefined } }) as never)
|
||||
o('model.complete', ((_$: unknown, e: { prompt: string }) => {
|
||||
s.prompts.push(e.prompt)
|
||||
return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } }
|
||||
}) as never)
|
||||
o('env.get', (() => ({ value: undefined })) as never)
|
||||
o('process.run', ((_$: unknown, e: { argv: string[] }) => {
|
||||
o('process.run', ((_$: unknown, e: { argv: string[]; }) => {
|
||||
s.cmds.push(e.argv)
|
||||
const j = e.argv.join(' ')
|
||||
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.example/o/r.git' : j.startsWith('tea logins') ? 'admin https://gitea.example u' : j.startsWith('tea pulls') ? 'PR#1' : ''
|
||||
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' : j.startsWith('tea logins') ? 'admin https://gitea.jsc.idv.tw u' : j.startsWith('tea pulls') ? 'PR#1' : ''
|
||||
return { value: { exitCode: 0, stdout: out, stderr: '' } }
|
||||
}) as never)
|
||||
return s
|
||||
@@ -50,18 +54,18 @@ test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR',
|
||||
expect(t1).toContain('ts')
|
||||
expect(t1).toContain('aoi')
|
||||
expect(t1).toContain('尚未建立')
|
||||
expect(s.files.has('/repo/characters/ts/aoi.json')).toBe(false)
|
||||
expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')).toBe(false)
|
||||
|
||||
const t2 = await run($ as never, 'confirm work=testy aoi=ao')
|
||||
expect(t2).toContain('變更摘要')
|
||||
expect(s.files.has('/repo/characters/testy/ao.json')).toBe(false)
|
||||
expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')).toBe(false)
|
||||
|
||||
const t3 = await run($ as never, 'confirm')
|
||||
expect(t3).toContain('已建立 PR')
|
||||
const pack = JSON.parse(s.files.get('/repo/characters/testy/ao.json')!)
|
||||
const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')!)
|
||||
expect(pack.timeline[0].chapter).toBe('ch1')
|
||||
expect(pack.timeline[0].age).toBe(16)
|
||||
expect(JSON.parse(s.files.get('/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核')
|
||||
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核')
|
||||
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
|
||||
expect(pr).toContain('main')
|
||||
expect(pr).toContain('feat/extract-testy-ch1')
|
||||
@@ -79,14 +83,14 @@ test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確
|
||||
}
|
||||
await write('ch1.txt')
|
||||
await write('ch1.txt')
|
||||
expect(JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!).timeline.length).toBe(1)
|
||||
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!).timeline.length).toBe(1)
|
||||
|
||||
await run($ as never, 'ch2.txt')
|
||||
const summary = await run($ as never, 'confirm')
|
||||
expect(summary).toContain('追加')
|
||||
expect(summary).toContain('待確認')
|
||||
await run($ as never, 'confirm')
|
||||
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!)
|
||||
const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!)
|
||||
expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2'])
|
||||
expect(pack.persona.tone).toBe('溫柔')
|
||||
expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' })
|
||||
@@ -112,10 +116,10 @@ test('貼上章節內容:paste <章節名稱> 加下一行起的內容,並
|
||||
expect(s.prompts[0]).toContain('這是貼上的章節內容。')
|
||||
await run($ as never, 'confirm')
|
||||
await run($ as never, 'confirm')
|
||||
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!)
|
||||
const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!)
|
||||
expect(pack.timeline[0].chapter).toBe('第一章')
|
||||
expect(pack.language).toBe('繁體中文,少量原作口癖')
|
||||
expect(JSON.parse(s.files.get('/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' })
|
||||
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' })
|
||||
})
|
||||
|
||||
test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($, on) => {
|
||||
@@ -126,3 +130,55 @@ test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($
|
||||
expect(await run($ as never, 'confirm')).toContain('疑似抄錄原作台詞')
|
||||
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
|
||||
})
|
||||
|
||||
test('在非 jsc-anima 目錄執行:git 與 PR 都走 jsc-anima 的快取 clone,不碰 session 目錄', async ($, on) => {
|
||||
sessionCwd = '/other/proj'
|
||||
try {
|
||||
const s = setup(on as never, [reply(char())], new Map([['/other/proj/ch1.txt', '第一段']]))
|
||||
await $.session.start({ source: 'startup', cwd: '/other/proj' } as never)
|
||||
await run($ as never, '/other/proj/ch1.txt')
|
||||
await run($ as never, 'confirm')
|
||||
expect(await run($ as never, 'confirm')).toContain('已建立 PR')
|
||||
expect(s.cmds.some(c => c.includes('fetch'))).toBe(true)
|
||||
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
|
||||
expect(pr).toContain('jiantw83/jsc-anima')
|
||||
expect(s.files.has('/other/proj/characters/index.json')).toBe(false)
|
||||
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0].slug).toBe('aoi')
|
||||
} finally {
|
||||
sessionCwd = '/repo'
|
||||
}
|
||||
})
|
||||
|
||||
test('快取 clone 不存在時先 clone jsc-anima', async ($, on) => {
|
||||
cloneMissing = true
|
||||
try {
|
||||
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
await run($ as never, 'ch1.txt')
|
||||
const clone = s.cmds.find(c => c.includes('clone'))!
|
||||
expect(clone).toContain('https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git')
|
||||
} finally {
|
||||
cloneMissing = false
|
||||
}
|
||||
})
|
||||
|
||||
test('paste:CRLF 換行也能分出章節名稱與內容', async ($, on) => {
|
||||
const s = setup(on as never, [reply(char())])
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
await run($ as never, 'paste 第二章\r\n第一行\r\n第二行')
|
||||
expect(s.prompts[0]).toContain('第一行')
|
||||
})
|
||||
|
||||
test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 {作品代號}-{角色代號} 並可啟動', async ($, on) => {
|
||||
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
|
||||
registered = []
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
await run($ as never, 'ch1.txt')
|
||||
await run($ as never, 'confirm')
|
||||
await run($ as never, 'confirm')
|
||||
// 模擬 PR 合併、外掛更新:clone 內的 characters/ 內容出現在外掛目錄。
|
||||
const root = ([...probed].find(q => !q.startsWith('/home/u/')) ?? '').replace('/characters/index.json', '')
|
||||
for (const [k, v] of [...s.files]) if (k.startsWith('/home/u/.cache/jsc-anima/repo/characters/')) s.files.set(k.replace('/home/u/.cache/jsc-anima/repo', root), v)
|
||||
await $.session.start({ source: 'startup', cwd: '/anywhere' } as never)
|
||||
expect(registered).toContain('ts-aoi')
|
||||
})
|
||||
|
||||
+49
-14
@@ -1,9 +1,12 @@
|
||||
import type { Engine } from 'claude-code'
|
||||
|
||||
import { teaToken } from './wiki'
|
||||
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract'
|
||||
import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract'
|
||||
|
||||
export const EXTRACT_COMMAND = 'anima-extract'
|
||||
// PR 永遠開在 jsc-anima repo,不論 session 在哪個專案:改用專屬的快取 clone 讀寫角色包。
|
||||
export const ANIMA_REPO_URL = 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git'
|
||||
const PENDING_KEY = 'extract:pending'
|
||||
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>(或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。`
|
||||
|
||||
@@ -38,6 +41,40 @@ async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
|
||||
|
||||
const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name
|
||||
|
||||
type Repo = { dir: string; base: string; auth: string[] }
|
||||
|
||||
async function gitIn($: Engine, auth: string[], dir: string, a: string[]) {
|
||||
const r = await $.process.run(['git', ...auth, ...a], { cwd: dir, timeoutMs: 120000 })
|
||||
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
|
||||
return String(r.stdout).trim()
|
||||
}
|
||||
|
||||
// 取得 jsc-anima 的快取 clone 並對齊 origin 預設分支(認證優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數)。
|
||||
async function syncRepo($: Engine): Promise<Repo | string> {
|
||||
const home = (await $.env.get('HOME')) ?? ''
|
||||
const dir = `${home}/.cache/jsc-anima/repo`
|
||||
let token: string | undefined
|
||||
try {
|
||||
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(ANIMA_REPO_URL).host)
|
||||
} catch {}
|
||||
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
|
||||
const auth = token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : []
|
||||
try {
|
||||
if (!(await $.fs.exists(`${dir}/.git`).catch(() => false))) {
|
||||
await $.process.run(['mkdir', '-p', dir.replace(/\/[^/]+$/, '')], { timeoutMs: 5000 })
|
||||
await gitIn($, auth, home || '/', ['clone', ANIMA_REPO_URL, dir])
|
||||
}
|
||||
await gitIn($, auth, dir, ['fetch', 'origin', '--prune'])
|
||||
await gitIn($, auth, dir, ['remote', 'set-head', 'origin', '--auto'])
|
||||
const base = (await gitIn($, auth, dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).replace(/^origin\//, '')
|
||||
await gitIn($, auth, dir, ['checkout', '--detach', '--force', `origin/${base}`])
|
||||
await gitIn($, auth, dir, ['clean', '-fd', 'characters'])
|
||||
return { dir, base, auth }
|
||||
} catch (err) {
|
||||
return `無法取得 jsc-anima repo(${ANIMA_REPO_URL}):${(err as Error).message}`
|
||||
}
|
||||
}
|
||||
|
||||
async function stageFile($: Engine, path: string): Promise<string> {
|
||||
const cwd = await $.session.cwd().catch(() => '')
|
||||
const full = path.startsWith('/') ? path : `${cwd}/${path}`
|
||||
@@ -48,7 +85,6 @@ async function stageFile($: Engine, path: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function stage($: Engine, chapter: string, text: string): Promise<string> {
|
||||
const cwd = await $.session.cwd().catch(() => '')
|
||||
const chunks = chunkText(text)
|
||||
const parts: Extracted[] = []
|
||||
for (const [i, chunk] of chunks.entries()) {
|
||||
@@ -58,6 +94,9 @@ async function stage($: Engine, chapter: string, text: string): Promise<string>
|
||||
if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。`
|
||||
parts.push(parsed)
|
||||
}
|
||||
const repo = await syncRepo($)
|
||||
if (typeof repo === 'string') return repo
|
||||
const cwd = repo.dir
|
||||
const extracted = mergeChunks(parts)
|
||||
const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
|
||||
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
|
||||
@@ -115,23 +154,19 @@ function run($: Engine, argv: string[], cwd: string) {
|
||||
}
|
||||
|
||||
async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> {
|
||||
const root = p.root
|
||||
const git = async (...a: string[]) => {
|
||||
const r = await run($, ['git', ...a], root)
|
||||
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
|
||||
return String(r.stdout).trim()
|
||||
}
|
||||
const repo = await syncRepo($)
|
||||
if (typeof repo === 'string') return repo
|
||||
const root = repo.dir
|
||||
const git = (...a: string[]) => gitIn($, repo.auth, root, a)
|
||||
try {
|
||||
const prev = await git('rev-parse', '--abbrev-ref', 'HEAD')
|
||||
const base = (await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).replace(/^origin\//, '')
|
||||
const base = repo.base
|
||||
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
|
||||
await git('fetch', 'origin', base)
|
||||
await git('switch', '-c', branch, '--no-track', `origin/${base}`)
|
||||
await git('checkout', '-B', branch, '--no-track', `origin/${base}`)
|
||||
for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`)
|
||||
await git('add', 'characters')
|
||||
await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`)
|
||||
await git('push', '-u', 'origin', branch)
|
||||
await git('switch', prev)
|
||||
await git('push', '-u', '--force-with-lease', 'origin', branch)
|
||||
await git('checkout', '--detach', `origin/${base}`)
|
||||
|
||||
const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包`
|
||||
const body = [
|
||||
@@ -177,7 +212,7 @@ export async function runExtract($: Engine, args: string): Promise<string> {
|
||||
}
|
||||
if (head === 'paste') {
|
||||
// 貼上:第一行 paste <章節名稱>,下一行起為章節內容。
|
||||
const [first = '', ...body] = args.trim().split('\n')
|
||||
const [first = '', ...body] = args.trim().replace(/\r\n?/g, '\n').split('\n')
|
||||
const name = first.replace(/^paste\s*/, '').trim()
|
||||
const text = body.join('\n')
|
||||
if (!name || !text.trim()) return `貼上章節的用法:/jsc-anima:${EXTRACT_COMMAND} paste <章節名稱>,下一行起貼上章節內容。`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import { CHARACTERS } from './characters'
|
||||
|
||||
const setup = (on: any, active?: string) => {
|
||||
mock.store(on, active ? { active } : undefined)
|
||||
on('command.register', () => ({ value: { name: 'x' } }) as never)
|
||||
on('session.start', () => ({ cwd: '/tmp' }) as never)
|
||||
on('command.run', () => ({ text: '' }))
|
||||
on('prompt.compose', () => ({ sections: [] }))
|
||||
}
|
||||
const compose = ($: any) => $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
|
||||
const persona = async ($: any): Promise<string | undefined> => (await compose($)).sections.find((s: any) => s.id === 'jsc-anima:persona')?.text
|
||||
const start = ($: any) => $.session.start({ source: 'startup', cwd: '/tmp' } as never)
|
||||
|
||||
test('啟動狀態跨 session 保留:下次開啟自動恢復', async ($, on) => {
|
||||
setup(on, 'sao-suguha')
|
||||
await start($)
|
||||
expect(await persona($)).toContain('直葉')
|
||||
})
|
||||
|
||||
test('啟動角色後寫入儲存,新 session 恢復;離開後不再恢復', async ($, on) => {
|
||||
setup(on)
|
||||
await start($)
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
await $.command.run({ command: 'anima-off', args: '' } as never)
|
||||
await $.command.run({ command: 'sao-suguha', args: '' } as never)
|
||||
await start($)
|
||||
expect(await persona($)).toContain('直葉')
|
||||
await $.command.run({ command: 'anima-off', args: '' } as never)
|
||||
await start($)
|
||||
expect(await persona($)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('在 /config 關閉 persist 後不恢復', { options: { persist: false } }, async ($, on) => {
|
||||
setup(on, 'sao-asuna')
|
||||
await start($)
|
||||
expect(await persona($)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('儲存的角色不存在時不恢復', async ($, on) => {
|
||||
setup(on, 'no-such-character')
|
||||
await start($)
|
||||
expect(await persona($)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('微調數值軸:反映在 persona,其餘維持角色包預設,且不改動角色包', { options: { axisGentle: 2, axisHumor: 9 } }, async ($, on) => {
|
||||
setup(on, 'sao-asuna')
|
||||
const before = { ...CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes }
|
||||
await start($)
|
||||
const text = (await persona($))!
|
||||
expect(text).toContain('溫柔 2')
|
||||
expect(text).toContain('幽默 9')
|
||||
expect(text).toContain(`傲嬌 ${before.tsundere}`)
|
||||
expect(CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes).toEqual(before)
|
||||
})
|
||||
|
||||
test('未微調(-1)沿用角色包預設', { options: { axisGentle: -1 } }, async ($, on) => {
|
||||
setup(on, 'sao-asuna')
|
||||
await start($)
|
||||
expect(await persona($)).toContain(`溫柔 ${CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes.gentle}`)
|
||||
})
|
||||
|
||||
test('8 個數值軸都可微調', {
|
||||
options: { axisGentle: 1, axisTsundere: 2, axisLively: 3, axisFormal: 4, axisTalkative: 5, axisHumor: 6, axisProactive: 7, axisExpressive: 8 },
|
||||
}, async ($, on) => {
|
||||
setup(on, 'sao-asuna')
|
||||
await start($)
|
||||
expect(await persona($)).toContain('溫柔 1、傲嬌 2、活潑 3、正式 4、話量 5、幽默 6、主動 7、情緒外顯 8')
|
||||
})
|
||||
|
||||
test('超出 0-10 的覆蓋值被忽略', { options: { axisGentle: 11 } }, async ($, on) => {
|
||||
setup(on, 'sao-asuna')
|
||||
await start($)
|
||||
expect(await persona($)).toContain(`溫柔 ${CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes.gentle}`)
|
||||
})
|
||||
+3
-3
@@ -6,7 +6,7 @@ import type { Axes } from './characters'
|
||||
import { EXTRACT_COMMAND, runExtract } from './extractCommand'
|
||||
import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity'
|
||||
import type { Affinity } from './affinity'
|
||||
import { relationOf, relationText } from './relation'
|
||||
import { latestEntry, relationOf, relationText } from './relation'
|
||||
import { CLASSIFY_PROMPT, choose, parseRoute } from './router'
|
||||
import type { Effort, ModelName, Route } from './router'
|
||||
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
|
||||
@@ -383,9 +383,9 @@ export const register: Register = (on, options) => {
|
||||
let text = personaSection(c, applyOverrides(c.axes, overrides()))
|
||||
if (flag('affinity', true)) {
|
||||
const a = await loadAffinity($, c.slug)
|
||||
text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)))}`
|
||||
text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)), latestEntry(c.timeline)?.romance)}`
|
||||
} else {
|
||||
text += `\n${relationText(relationOf(c.timeline, 0, false))}`
|
||||
text += `\n${relationText(relationOf(c.timeline, 0, false), latestEntry(c.timeline)?.romance)}`
|
||||
}
|
||||
if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。'
|
||||
return {
|
||||
|
||||
+100
-2
@@ -1,6 +1,6 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[] }
|
||||
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[]; clock?: any }
|
||||
|
||||
const setup = (on: any, kv?: Map<string, unknown>): Ctl => {
|
||||
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] }
|
||||
@@ -9,7 +9,7 @@ const setup = (on: any, kv?: Map<string, unknown>): Ctl => {
|
||||
on('store.get', (_$: any, e: any) => ({ value: kv.get(e.key) }) as never)
|
||||
on('store.set', (_$: any, e: any) => (kv.set(e.key, e.value), { value: undefined }) as never)
|
||||
} else mock.store(on)
|
||||
mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
|
||||
ctl.clock = mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
|
||||
on('ui.log', () => ({ value: undefined }) as never)
|
||||
on('command.register', () => ({ value: { name: 'x' } }) as never)
|
||||
on('session.start', () => ({ cwd: '/tmp' }) as never)
|
||||
@@ -237,6 +237,104 @@ test('未成年角色維持友情,即使好感度最高且開啟戀愛式依
|
||||
expect((await personaText($)).includes('關係定位:友情')).toBe(true)
|
||||
})
|
||||
|
||||
// 以 fs 事件邊界提供角色包(characters/index.json),讓外掛自行載入成年角色。
|
||||
const packs = (on: any, list: { slug: string; romance?: string }[]) => {
|
||||
const files = new Map<string, string>()
|
||||
files.set('index.json', JSON.stringify({ packs: list.map(p => ({ work: 'tw', workName: '測試作品', slug: p.slug })) }))
|
||||
for (const p of list) {
|
||||
files.set(
|
||||
`tw/${p.slug}.json`,
|
||||
JSON.stringify({
|
||||
work: 'tw',
|
||||
slug: p.slug,
|
||||
name: '成年測試',
|
||||
persona: { selfRef: '我', address: '你', tone: '平穩', catchphrase: '好', values: '可靠', taboos: '無' },
|
||||
axes: { gentle: 5, tsundere: 5, lively: 5, formal: 5, talkative: 5, humor: 5, proactive: 5, expressive: 5 },
|
||||
anime: { moe: '', speech: '', emotions: '', kaomoji: '', themeColor: 'cyan' },
|
||||
timeline: [
|
||||
{ chapter: '第1章', age: 17, relationships: [] },
|
||||
{ chapter: '第2章', age: 20, relationships: p.romance ? [{ with: p.romance, kind: 'romantic' }] : [] },
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
const key = (path: string) => path.replace(/^.*\/characters\//, '')
|
||||
on('fs.exists', (_$: any, e: any) => ({ value: files.has(key(e.path)) }) as never)
|
||||
on('fs.read', (_$: any, e: any) => ({ value: files.get(key(e.path)) ?? '' }) as never)
|
||||
}
|
||||
|
||||
test('成年且原作有戀愛關係:關係移轉給使用者,原戀人退為過去的重要人物', async ($, on) => {
|
||||
setup(on)
|
||||
packs(on, [{ slug: 'lover', romance: '桐人' }])
|
||||
await start($, 'tw-lover')
|
||||
const t = await personaText($)
|
||||
expect(t.includes('已移轉給使用者')).toBe(true)
|
||||
expect(t.includes('桐人')).toBe(true)
|
||||
expect(t.includes('過去的重要人物')).toBe(true)
|
||||
})
|
||||
|
||||
test('成年但原作無戀愛關係:需最高階段且明確開啟才進入戀愛式依賴', async ($, on) => {
|
||||
setup(on)
|
||||
packs(on, [{ slug: 'plain' }])
|
||||
await start($, 'tw-plain')
|
||||
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
|
||||
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
|
||||
})
|
||||
|
||||
test('成年無戀愛、最高階段但未在 /config 開啟:維持夥伴關係', { options: { romance: false } }, async ($, on) => {
|
||||
setup(on)
|
||||
packs(on, [{ slug: 'plain' }])
|
||||
await start($, 'tw-plain')
|
||||
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
|
||||
expect((await personaText($)).includes('夥伴與朋友')).toBe(true)
|
||||
})
|
||||
|
||||
test('成年無戀愛、已開啟但好感度未達最高階段:不進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
|
||||
setup(on)
|
||||
packs(on, [{ slug: 'plain' }])
|
||||
await start($, 'tw-plain')
|
||||
await $.command.run({ command: 'anima-affinity', args: '79' } as never)
|
||||
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
|
||||
})
|
||||
|
||||
test('成年無戀愛、最高階段且已開啟:進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
|
||||
setup(on)
|
||||
packs(on, [{ slug: 'plain' }])
|
||||
await start($, 'tw-plain')
|
||||
await $.command.run({ command: 'anima-affinity', args: '80' } as never)
|
||||
expect((await personaText($)).includes('戀愛式依賴')).toBe(true)
|
||||
})
|
||||
|
||||
test('長期不互動好感度緩慢下降,但不跨階段掉落', async ($, on) => {
|
||||
const ctl = setup(on)
|
||||
ctl.complete = () => route('haiku')
|
||||
await start($)
|
||||
await submit($, '你好')
|
||||
await $.command.run({ command: 'anima-affinity', args: '45' } as never)
|
||||
await ctl.clock.advance(30 * 86_400_000)
|
||||
await submit($, '我回來了')
|
||||
const n = await affinity($)
|
||||
expect(n < 45).toBe(true)
|
||||
expect(n >= 40).toBe(true)
|
||||
})
|
||||
|
||||
test('個性軸影響扣分倍率:傲嬌角色掉得比溫柔角色多', async ($, on) => {
|
||||
const ctl = setup(on)
|
||||
ctl.complete = () => route('haiku')
|
||||
packs(on, [{ slug: 'plain' }])
|
||||
const run = async (slug: string) => {
|
||||
await start($, slug)
|
||||
await submit($, '你好')
|
||||
await $.command.run({ command: 'anima-affinity', args: '70' } as never)
|
||||
await ctl.clock.advance(8 * 86_400_000)
|
||||
await submit($, '我回來了')
|
||||
return affinity($)
|
||||
}
|
||||
const proud = await run('tw-plain')
|
||||
const calm = await run('sao-suguha')
|
||||
expect(proud < calm).toBe(true)
|
||||
})
|
||||
|
||||
const workRoute = (model: string, work: boolean) => ({ isAnswered: true, usage, text: `{"difficulty":8,"model":"${model}","effort":"high","tone":"neutral","ack":"好,我來處理。","work":${work}}` })
|
||||
|
||||
test('需要動手的要求交給背景工作線,對話線改用 Haiku', async ($, on) => {
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] =>
|
||||
const USAGE_SECTIONS = [
|
||||
'## 角色與萃取',
|
||||
'',
|
||||
'- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。',
|
||||
'- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。',
|
||||
'- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。',
|
||||
'',
|
||||
'## 待辦事項',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## 角色與萃取
|
||||
|
||||
- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。
|
||||
- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。
|
||||
- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。
|
||||
|
||||
## 待辦事項
|
||||
|
||||
Reference in New Issue
Block a user