/rpg で作業が経験値になる RPG のステータス窓をペインに SVG で表示する (レベル・称号・実績はセッションをまたいで育つ。ターンでたまる遭遇で魔物やボスと戦い、ボスは時々湧く。ターンで潜るダンジョンは一番深い階を記録し、帰ると袋の経験値を持ち帰る。SP で能力とスキルを育てる。turn-gacha…

Claude Code で作業すると育つ RPG「冒険の書」(dev-rpg)と、ターンごとに引けるガチャ(turn-gacha)の 2 本です。 ガチャで出たカードは、冒険の書で武器・防具・お守りとして装備できます。
どちらも Claude Code の関数フック mod で、VS Code 拡張ではペインに SVG の絵で、ターミナルでは文字で表示します。
Anthropic とは関係のない、個人が作った非公式の mod です。
<table> <tr> <td align="center" valign="top"><b>dev-rpg(冒険の書)</b><br><img src="docs/images/dev-rpg.png" width="300" alt="dev-rpg"></td> <td align="center" valign="top"><b>turn-gacha(ガチャ)</b><br><img src="docs/images/turn-gacha.png" width="300" alt="turn-gacha"></td> </tr> </table>
| mod | 呼び方 | 何をするか |
|---|---|---|
| dev-rpg | /rpg | 作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる |
| turn-gacha | /gacha | ターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。記録は毎日控えを取り、「控えから戻す」で戻せる |
片方だけでも動きます。dev-rpg だけの時は、装備の画面に「turn-gacha を入れるとカードを装備できます」と出ます。claude-code-mods の daily-quest(日替わりのお題)も入れていると、こなしたお題のごほうびとして dev-rpg は経験値、turn-gacha はおまけの抽選を受け取ります(無くても動きます)。
ターミナルの Claude Code の入力欄で:
/plugin install dev-rpg --marketplace takosasi-dev/claude-code-dev-rpg
/plugin install turn-gacha --marketplace takosasi-dev/claude-code-dev-rpg
y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-dev-rpg
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-dev-rpg
デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-dev-rpg を入れ、Add plugin で選びます。
コマンドの名前がほかのプラグインや組み込みと被った時は、/dev-rpg・/turn-gacha のように mod の名前のコマンドになります。
/rpg や /gacha が 2 つずつ動きますclaude plugin validate mods/dev-rpg
claude plugin test mods/dev-rpg
claude plugin test mods/turn-gacha
このリポジトリは claude-code-mods から 2 本だけを抜き出した写しです。直すのはいつも claude-code-mods の側で、リリースの時にこちらへ写します。不具合や要望は、どちらのリポジトリの Issue でも受け付けます。
backupDir(2 本とも、既定は空)に絶対パスを入れると、毎日 1 回、記録を <backupDir>/<mod>/ に控えます(7 日分)。ペインの「控えから戻す」で選んだ日の記録に戻せます(今の記録は上書きされます)claude plugin test)MIT
hooks/register.tsx 732 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RpgBattle, RpgGachaCard, RpgHero, RpgRestore, RpgSaved, RpgSlot, RpgStat, RpgTab } from '../types'
5import { act, battleLines, battleSvg, canUse, endBattle, growthLines, growthSvg, nextBoss, spawnBoss, startBattle } from './battle'
6import type { Command } from './battle'
7import { checkpoint, dungeonLines, dungeonSvg, enterDelve, eventText, leaveDelve, stepDelve } from './dungeon'
8import type { DelveEvent } from './dungeon'
9import {
10 INITIAL,
11 INITIAL_SAVED,
12 SKILLS,
13 SLOTS,
14 SLOT_LABEL,
15 STATS,
16 STAT_LABEL,
17 STAT_NOTE,
18 SP_RULE,
19 achievementLines,
20 achievementsSvg,
21 claimedFrom,
22 fromSaved,
23 gain,
24 KEEP_CLAIMED,
25 questRewards,
26 gearLines,
27 gearSvg,
28 grant,
29 hpPctAt,
30 learnSkill,
31 raiseStat,
32 respec,
33 skillOf,
34 spOf,
35 levelInfo,
36 levelUpText,
37 mergeSaved,
38 onTool,
39 onTurnEnd,
40 onTurnStart,
41 ownedCards,
42 setGear,
43 startSession,
44 statusLines,
45 statusSvg,
46 toSaved,
47 upgradeHero,
48 wear,
49 withUnknown,
50 wornOf,
51} from './rpg'
52import type { Achievement } from './rpg'
53import { backupDaily, followTheme, jstDay, listBackups, mayWrite, readTheme, rememberVersion } from './style'
54
55const PANE = 'dev-rpg'
56const TITLE = '冒険の書'
57const DESCRIPTION = '作業が経験値になる RPG。魔物と戦い、SP で能力とスキルを育てる'
58const STORE_KEY = 'hero'
59
60const HERO = atom({ plugin: 'dev-rpg', key: 'hero' } as const, INITIAL)
61const TAB = atom({ plugin: 'dev-rpg', key: 'tab' } as const, 'status' as RpgTab)
62const CONFIRM = atom({ plugin: 'dev-rpg', key: 'isConfirmingReset' } as const, false)
63const NEWS = atom({ plugin: 'dev-rpg', key: 'news' } as const, '')
64const BATTLE = atom({ plugin: 'dev-rpg', key: 'battle' } as const, null as RpgBattle | null)
65const RESTORE = atom({ plugin: 'dev-rpg', key: 'restore' } as const, { list: null, pick: null } as RpgRestore)
66// turn-gacha の state (読むだけ。turn-gacha が無ければ undefined のまま)
67const GACHA_SAVE = { plugin: 'turn-gacha', key: 'save' } as const
68const GACHA_CARDS = { plugin: 'turn-gacha', key: 'cards' } as const
69// daily-quest の今日のお題 (読むだけ。daily-quest が無ければ undefined のまま)
70const QUEST_TODAY = { plugin: 'daily-quest', key: 'today' } as const
71const CLAIM_KEY = 'questClaimed'
72
73type $ = EngineInterface
74
75const BROKEN = '記録を読めなかったので、今の冒険は保存していません'
76
77let ownsMain = false
78
79/** 設定の backupDir (絶対パスでなければ空 = 控えない)。register で入れる */
80let backupDir = ''
81/** 控えを書いた (書かなくてよいと分かった) JST の日。同じ日は fs を読みに行かない */
82let backedDay = ''
83
84// 絶対パス (/x、\x、C:\x、C:/x) だけを受け付ける。相対パスや「C:」は今のフォルダから読まれてしまう
85const folderOption = (value: unknown) => {
86 const raw = String(value ?? '').trim()
87 return /^(?:[\\/]|[A-Za-z]:[\\/])/.test(raw) ? raw : ''
88}
89
90/** その日はじめての保存の前に、store の生の値 (知らない欄ごと) を控える */
91async function backup($: $, raw: unknown, now: number) {
92 if (backupDir === '' || raw === undefined || backedDay === jstDay(now)) return
93 const isDone = await backupDaily($, backupDir, STORE_KEY, raw, now).then(
94 () => true,
95 () => false,
96 )
97 if (isDone) backedDay = jstDay(now)
98}
99
100const OLD_VERSION = '新しい版が入りました。VS Code を開き直すまで記録は書きません'
101
102// store に書く物 (保存・やり直し) は 1 本ずつ流す。重なると同じ差分を 2 回足してしまう
103let chain: Promise<unknown> = Promise.resolve()
104const serial = (f: () => Promise<unknown>) => (chain = chain.then(f, f))
105
106/** store の値 (と、知らない欄を残して書くための生の値)。キーが無ければ最初から、読めない時 (例外・形が違う) は undefined */
107async function readStore($: $): Promise<{ saved: RpgSaved; raw: unknown } | undefined> {
108 try {
109 const raw = await $.store.get(STORE_KEY)
110 if (raw === undefined) return { saved: INITIAL_SAVED, raw }
111 return typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? { saved: fromSaved(raw), raw } : undefined
112 } catch {
113 return undefined
114 }
115}
116
117/** state が空なら (新しいセッション・/clear の後) store から読み戻して冒険の回数を足す。リロードなら state の方が新しいのでそのまま */
118async function ensureLoaded($: $) {
119 const cur = await read($, HERO)
120 if (cur.s.startedAt !== 0) {
121 // 0.2 の勇者が state に残っていたら (開いたまま /reload-plugins した時) 新しい値を足す
122 if (upgradeHero(cur) !== cur) await update($, HERO, upgradeHero)
123 return
124 }
125 const saved = (await readStore($))?.saved
126 if (!saved) await update($, NEWS, () => BROKEN)
127 const now = await $.clock.now()
128 await update($, HERO, x => (x.s.startedAt === 0 ? startSession(saved ?? INITIAL_SAVED, now) : x))
129}
130
131/** 描く時と戦いの手で読む勇者 (0.2 の形のままでも 0.3.0 の値を足して読む。書くのは ensureLoaded) */
132const readHero = async ($: $) => upgradeHero(await read($, HERO))
133
134/** 今の store を読み直し、前に合わせた時から増えた分だけを足して書く。画面も store に合わせる */
135const save = ($: $) =>
136 serial(async () => {
137 const now = await $.clock.now()
138 if (!(await mayWrite($, now))) return
139 const got = await readStore($)
140 if (!got) {
141 await update($, NEWS, () => BROKEN)
142 return
143 }
144 await backup($, got.raw, now)
145 const store = got.saved
146 // $.state.get は 1 つの dispatch の中では同じ時点を読むので、差分は update の中 (最新の値) で取る
147 let written: RpgSaved | undefined
148 await update($, HERO, x0 => {
149 if (x0.s.startedAt === 0) return x0
150 const x = upgradeHero(x0)
151 written = mergeSaved(store, x.synced, toSaved(x))
152 return { ...x, ...written, synced: written }
153 })
154 if (!written) return
155 // この版の知らない欄 (新しい版が足した物) は読んだまま残す
156 await $.store.set(STORE_KEY, withUnknown(got.raw, written)).catch(async () => {
157 // 書けなかった分は、読んだ store からの差分として次に足す
158 await update($, HERO, x => ({ ...x, synced: store }))
159 await update($, NEWS, () => '記録を保存できませんでした。次のターンの終わりにもう一度書きます')
160 })
161 })
162
163/** 装備の候補の並び (レア度の高い順) */
164const RANK: Record<RpgGachaCard['rarity'], number> = { SSR: 0, SR: 1, R: 2, N: 3 }
165
166/** ガチャで持っているカード。turn-gacha が無い・読めない時は undefined。描画の中で呼ぶと、ガチャが変わった時に描き直る */
167async function readOwned($: $): Promise<RpgGachaCard[] | undefined> {
168 try {
169 return ownedCards((await $.state.get(GACHA_SAVE)).value, (await $.state.get(GACHA_CARDS)).value)
170 } catch {
171 return undefined
172 }
173}
174
175/** 変える → 実績を見る。レベルが上がった時だけトーストを出す (store に書くのは save) */
176async function change($: $, fn: (h: RpgHero, now: number) => RpgHero, isTurnEnd = false, lead: string[] = []) {
177 await ensureLoaded($)
178 const now = await $.clock.now()
179 const owned = await readOwned($)
180 let before = 1
181 let gained: Achievement[] = []
182 const hero = await update($, HERO, x => {
183 before = levelInfo(x.xp).level
184 const r = grant(fn(x, now), now, isTurnEnd, owned)
185 gained = r.gained
186 return r.hero
187 })
188 const after = levelInfo(hero.xp).level
189 const news: string[] = [...lead]
190 if (after > before) {
191 $.ui.toast(levelUpText(after))
192 news.push(levelUpText(after))
193 }
194 if (gained.length > 0) news.push(`実績「${gained.map(a => a.name).join('」「')}」を手に入れた`)
195 if (news.length > 0) await update($, NEWS, () => news.join(' / '))
196}
197
198/** HP は時間で減る。整数が変わった時だけ書く (経験値のアニメは動かさない) */
199async function refresh($: $) {
200 const now = await $.clock.now()
201 const hero = await read($, HERO)
202 if (hpPctAt(hero, now) === hero.hpPct) return
203 await update($, HERO, x => ({ ...x, hpPct: hpPctAt(x, now), xpFrom: levelInfo(x.xp).frac }))
204}
205
206/** 枠にカードを着ける (id が空なら外す)。持っていないカードは着けない。装備は最後に書いた物が勝つ */
207async function equip($: $, slot: RpgSlot, id: string) {
208 const card = (await readOwned($))?.find(c => c.id === id && c.slot === slot)
209 if (id !== '' && !card) {
210 await update($, NEWS, () => 'そのカードは持っていません')
211 return
212 }
213 await update($, NEWS, () => (card ? `${SLOT_LABEL[slot]}に「${card.name}」を着けた` : `${SLOT_LABEL[slot]}を外した`))
214 await change($, h => setGear(h, slot, card))
215 await save($)
216}
217
218/**
219 * daily-quest でこなしたお題のごほうび (経験値) を 1 回だけ受け取る。
220 * 受け取った印は store の questClaimed (書く直前に読み直す)。daily-quest が無ければ何もしない
221 */
222async function claim($: $) {
223 let today: unknown
224 try {
225 today = (await $.state.get(QUEST_TODAY)).value
226 } catch {
227 return
228 }
229 if (questRewards(today, []).length === 0) return
230 let got: ReturnType<typeof questRewards> = []
231 await serial(async () => {
232 const claimed = await $.store.get(CLAIM_KEY).then(claimedFrom, () => undefined)
233 if (!claimed) return
234 const fresh = questRewards(today, claimed)
235 if (fresh.length === 0) return
236 if (!(await mayWrite($, await $.clock.now()))) return
237 // ponytail: 読んでから書くまでの間に別のセッションが受け取ると 2 度入る。間は数ミリ秒なので見送り
238 const isSaved = await $.store.set(CLAIM_KEY, [...claimed, ...fresh.map(g => g.key)].slice(-KEEP_CLAIMED)).then(
239 () => true,
240 () => false,
241 )
242 if (isSaved) got = fresh
243 })
244 if (got.length === 0) return
245 const xp = got.reduce((n, g) => n + g.exp, 0)
246 await change($, h => gain(h, xp), false, [`お題「${got.map(g => g.title).join('」「')}」のごほうび 経験値 +${xp}`])
247 await save($)
248}
249
250// ---------------------------------------------------------------- 戦い
251
252/** 魔物を探す (isBoss ならボスに挑む)。遭遇を 1 つ使う。ボスが湧いていれば、遭遇の代わりにその気配を使う。戦っている間は何もしない */
253async function fight($: $, isBoss: boolean) {
254 if ((await read($, BATTLE))?.result === 'fight') return
255 await ensureLoaded($)
256 const now = await $.clock.now()
257 const hero = wear(await readHero($), await readOwned($))
258 const isSpawned = isBoss && hero.bossSign > 0
259 if (!isSpawned && hero.encounters <= 0) {
260 await update($, NEWS, () => 'まだ魔物の気配がない。ターンを終えると現れる')
261 return
262 }
263 // 種は時刻と戦った回数から (同じ時刻に 2 回押しても別の敵)
264 const battle = startBattle(hero, (now ^ Math.imul(hero.battles + 1, 2654435761)) >>> 0, isBoss, isSpawned)
265 // 続けて押された時は、先に置いた戦いを残す (気配も 1 つだけ使う)。update は先を越されるとやり直すので、印も毎回付け直す
266 let isPlaced = false
267 await update($, BATTLE, b => {
268 isPlaced = b?.result !== 'fight'
269 return isPlaced ? battle : b
270 })
271 if (!isPlaced) return
272 await update($, NEWS, () => '')
273 await change($, h =>
274 isSpawned ? { ...h, bossSign: 0, battles: h.battles + 1 } : { ...h, encounters: Math.max(0, h.encounters - 1), battles: h.battles + 1 },
275 )
276 await save($)
277}
278
279/** 1 手。終わったら戦いの後の HP を残し、勝っていれば経験値と勝ち数も入れて残す */
280async function command($: $, cmd: Command) {
281 const { learned } = await readHero($)
282 let ended: RpgBattle | undefined
283 await update($, BATTLE, b => {
284 ended = undefined // update はやり直すことがある。前の試みの印を残さない
285 if (!b || b.result !== 'fight') return b
286 const next = act(b, cmd, learned)
287 if (next.result !== 'fight') ended = next
288 return next
289 })
290 if (!ended) return
291 const done = ended
292 const lead: string[] = []
293 if (done.result === 'won') {
294 lead.push(`${done.enemy.name}に勝った! 経験値 ${done.gainedXp}`)
295 if (done.enemy.isBoss && (await readHero($)).defeated[done.enemy.id] === undefined) lead.push(`ボスを初めて倒した! SP +${SP_RULE.boss}`)
296 } else if (done.result === 'lost') lead.push('力尽きた… しばらく休むと HP が戻る')
297 await change($, (h, now) => endBattle(h, done, now), false, lead)
298 await save($)
299}
300
301/** 終わった戦いを片付けて、魔物の気配の画面に戻る */
302const closeBattle = ($: $) => update($, BATTLE, b => (b?.result === 'fight' ? b : null))
303
304// ---------------------------------------------------------------- 成長
305
306async function raise($: $, stat: RpgStat) {
307 const lead: string[] = []
308 const fn = (h: RpgHero) => {
309 const next = raiseStat(h, stat)
310 lead.length = 0
311 lead.push(next ? `${STAT_LABEL[stat]}が 1 上がった (${STAT_NOTE[stat]})` : 'SP が足りません')
312 return next ?? h
313 }
314 await change($, fn, false, lead)
315 await save($)
316}
317
318async function learn($: $, id: string) {
319 const lead: string[] = []
320 const fn = (h: RpgHero, now: number) => {
321 const next = learnSkill(h, id, now)
322 lead.length = 0
323 lead.push(typeof next === 'string' ? next : `「${skillOf(id)?.name ?? id}」を覚えた!`)
324 return typeof next === 'string' ? h : next
325 }
326 await change($, fn, false, lead)
327 await save($)
328}
329
330async function unspend($: $) {
331 await change($, respec, false, ['振った SP を全部戻した'])
332 await save($)
333}
334
335/** 次の世代の番号。store と、このセッションが知っている世代の大きい方の 1 つ上 (store が読めなくても上がる) */
336async function nextEpoch($: $) {
337 const stored = (await readStore($))?.saved.epoch ?? 0
338 return Math.max(stored, (await readHero($)).synced.epoch) + 1
339}
340
341/** やり直しは消す操作なので、store を丸ごと書き替える (知らない欄も「すべて消えます」のうち) */
342const reset = ($: $) =>
343 serial(async () => {
344 const now = await $.clock.now()
345 if (!(await mayWrite($, now))) {
346 await update($, CONFIRM, () => false)
347 await update($, NEWS, () => OLD_VERSION)
348 return
349 }
350 // 世代を上げる。別のセッションは次の保存で差分を足さずに、このやり直しを取る
351 const fresh = startSession({ ...INITIAL_SAVED, epoch: await nextEpoch($) }, now)
352 const hero = { ...fresh, synced: toSaved(fresh) }
353 const isSaved = await $.store.set(STORE_KEY, hero.synced).then(
354 () => true,
355 () => false,
356 )
357 if (!isSaved) {
358 await update($, CONFIRM, () => false)
359 await update($, NEWS, () => '記録を書けなかったので、やり直していません')
360 return
361 }
362 await update($, HERO, () => hero)
363 await update($, BATTLE, () => null)
364 await update($, CONFIRM, () => false)
365 await update($, TAB, () => 'status' as const)
366 await update($, NEWS, () => '冒険をやり直しました。Lv 1 から')
367 })
368
369// ---------------------------------------------------------------- ダンジョン
370
371/** 潜り始める (from の階から。休み場より深くは選べない)。もう潜っていれば何もしない */
372async function enterDungeon($: $, from: number) {
373 const lead: string[] = []
374 await change(
375 $,
376 (h, now) => {
377 lead.length = 0
378 if (h.delve) return h
379 const next = enterDelve(h, now, from)
380 lead.push(`ダンジョンの ${next.delve?.floor ?? 1} 階に潜った。ターンを終えるたびに 1 歩進む`)
381 return next
382 },
383 false,
384 lead,
385 )
386 await save($)
387}
388
389/** 帰る: 袋の経験値を持ち帰る */
390async function leaveDungeon($: $) {
391 const lead: string[] = []
392 await change(
393 $,
394 h => {
395 lead.length = 0
396 if (h.delve) lead.push(`ダンジョンから帰った。袋の経験値 ${h.delve.loot} を持ち帰った`)
397 return leaveDelve(h)
398 },
399 false,
400 lead,
401 )
402 await save($)
403}
404
405// ---------------------------------------------------------------- 控えから戻す
406
407const CLOSED: RpgRestore = { list: null, pick: null }
408
409async function openRestore($: $) {
410 // 日付はファイルから来るので、形の合う物だけ (画面の文字に制御文字を入れない)
411 const list = (await listBackups($, backupDir, STORE_KEY).catch(() => [])).filter(b => /^\d{4}-\d{2}-\d{2}$/.test(b.day))
412 await update($, RESTORE, () => ({ list, pick: null }))
413}
414
415/** 控えの日の記録に戻す。store を丸ごと控えの値にして、画面の勇者も読み直す */
416const restore = ($: $, day: string) =>
417 serial(async () => {
418 const now = await $.clock.now()
419 const b = (await read($, RESTORE)).list?.find(x => x.day === day)
420 if (!b) return
421 if (!(await mayWrite($, now))) {
422 await update($, NEWS, () => OLD_VERSION)
423 return
424 }
425 if (typeof b.value !== 'object' || b.value === null || Array.isArray(b.value)) {
426 await update($, NEWS, () => 'この控えは読めない形でした')
427 return
428 }
429 // 控えの値は知らない欄ごと戻し、世代だけ上げる (別のセッションが戻す前の差分を足さないように)
430 const value = { ...b.value, epoch: Math.max(await nextEpoch($), fromSaved(b.value).epoch + 1) }
431 const isSaved = await $.store.set(STORE_KEY, value).then(
432 () => true,
433 () => false,
434 )
435 if (!isSaved) {
436 await update($, NEWS, () => '記録を書けませんでした')
437 return
438 }
439 const saved = fromSaved(value)
440 await update($, HERO, x => ({ ...(x.s.startedAt === 0 ? startSession(saved, now) : x), ...saved, synced: saved, xpFrom: levelInfo(saved.xp).frac }))
441 await update($, BATTLE, () => null)
442 await update($, RESTORE, () => CLOSED)
443 await update($, NEWS, () => `${day} の記録に戻しました`)
444 })
445
446const openPane = async ($: $) => {
447 await $.ui.open({ id: PANE, title: TITLE })
448 return { text: '冒険の書を開きました。' }
449}
450
451export const register: Register = (on, options) => {
452 backupDir = folderOption(options.backupDir)
453 followTheme(on)
454
455 on('session.start', async ($, e, next) => {
456 const started = await next(e)
457 await rememberVersion($)
458 await readTheme($)
459 // /rpg が拒まれたら (組み込みと被ったら) /dev-rpg にする
460 const got = await $.command.register({ name: 'rpg', description: DESCRIPTION }).catch(() => undefined)
461 ownsMain = got?.command === 'rpg'
462 if (!ownsMain) await $.command.register({ name: 'dev-rpg', description: DESCRIPTION }).catch(() => {})
463 // 読み戻して (冒険の回数を足して) 実績を見て、残す
464 await change($, h => h)
465 await save($)
466 // 別のセッションでこなしたお題の分もここで拾う
467 await claim($).catch(() => {})
468 $.clock.every(60_000, () => void refresh($).catch(() => {}))
469 return started
470 })
471
472 on('session.end', async ($, e, next) => {
473 await save($).catch(() => {})
474 return next(e)
475 })
476
477 on('command.run', { command: 'rpg' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
478 // /rpg を持てた時は /dev-rpg を登録していないので、同じ名前の別のコマンドに回す
479 on('command.run', { command: 'dev-rpg' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
480
481 // 主ループのターンだけ数える (サブエージェントの分は agentId 付き)
482 on('turn.start', async ($, e, next) => {
483 if (!e.agentId) await change($, onTurnStart)
484 return next(e)
485 })
486
487 // 答えて終わったターンだけ数える (中断・エラー・拒否は数えない)。ボスが湧くのもここ。保存はどのターンの終わりでも
488 on('turn.complete', async ($, e, next) => {
489 if (!e.agentId) {
490 if (e.reason === 'answer') {
491 const lead: string[] = []
492 const owned = await readOwned($)
493 let isBoss = false
494 let event: DelveEvent | undefined
495 const fn = (h: RpgHero, now: number) => {
496 const after = spawnBoss(onTurnEnd(h, now), now)
497 lead.length = 0
498 isBoss = after.bossSign > h.bossSign
499 if (isBoss) lead.push(`ボスの気配! ${nextBoss(after).name}が待っている (気配を使わずに挑めて、経験値 1.5 倍)`)
500 // ダンジョンで 1 歩。戦いの強さは持っている装備だけで
501 const step = stepDelve(after, wear(after, owned))
502 event = step.event
503 const said = eventText(event)
504 if (said) lead.push(said)
505 return step.hero
506 }
507 await change($, fn, true, lead)
508 if (isBoss) $.ui.toast(`ボスの気配! ${nextBoss(await readHero($)).name}が湧いた`)
509 const ev = event as DelveEvent | undefined
510 if (ev?.kind === 'lost') $.ui.toast(`ダンジョン ${ev.fight.floor} 階で力尽きた。入口に戻った`)
511 else if (ev?.kind === 'won' && ev.fight.isBoss) $.ui.toast(`ダンジョン ${ev.fight.floor} 階の番人を倒した!`)
512 }
513 await save($)
514 }
515 // お題のごほうびは next の後に見る (daily-quest は next の前に進みを書くので、読み込みの順によらず同じターンで届く)
516 const done = await next(e)
517 if (!e.agentId) await claim($).catch(() => {})
518 return done
519 })
520
521 // 見るだけ。断られた呼び出し (deny) は数えない
522 on('tool.call', async ($, e, next) => {
523 const ran = await next(e)
524 if (!e.agentId && !('deny' in ran && ran.deny !== undefined)) {
525 const tool = String(e.tool)
526 const isError = 'isError' in ran && ran.isError === true
527 await change($, (h, now) => onTool(h, tool, isError, now))
528 }
529 return ran
530 }).catch(($, e, next) => next(e))
531
532 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
533 const t = $.ui.resolve(e)
534 const { Box, Text, Button } = t
535 const Select = 'Select' in t ? t.Select : undefined
536 // terminal の表にも Svg の名前があるので、画面の種類でも分ける
537 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
538 const owned = await readOwned($)
539 // 持っていない装備は外した姿で見せる (store の装備はそのまま)
540 const hero = wear(await readHero($), owned)
541 const tab = await read($, TAB)
542 const isConfirming = await read($, CONFIRM)
543 const news = await read($, NEWS)
544 const battle = await read($, BATTLE)
545 const isFighting = battle?.result === 'fight'
546 const sp = spOf(hero)
547 const level = levelInfo(hero.xp).level
548 const boss = nextBoss(hero)
549 const restoring = await read($, RESTORE)
550 const from = checkpoint(hero.deepest)
551 const lines =
552 tab === 'status'
553 ? statusLines(hero)
554 : tab === 'gear'
555 ? gearLines(hero, owned)
556 : tab === 'battle'
557 ? battleLines(hero, battle)
558 : tab === 'growth'
559 ? growthLines(hero)
560 : tab === 'dungeon'
561 ? dungeonLines(hero)
562 : achievementLines(hero)
563 const source =
564 tab === 'status'
565 ? statusSvg(hero)
566 : tab === 'gear'
567 ? gearSvg(hero, owned)
568 : tab === 'battle'
569 ? battleSvg(hero, battle)
570 : tab === 'growth'
571 ? growthSvg(hero)
572 : tab === 'dungeon'
573 ? dungeonSvg(hero)
574 : achievementsSvg(hero)
575 const worn = wornOf(hero.equip, owned)
576 const go = (to: RpgTab) =>
577 void (async () => {
578 await update($, CONFIRM, () => false)
579 await update($, TAB, () => to)
580 })()
581 /** そのタブなら「ステータスに戻る」、違えば開く */
582 const toggle = (to: RpgTab) => go(tab === to ? 'status' : to)
583
584 return (
585 <Box flexDirection="column" gap={1}>
586 {Svg ? (
587 <Svg source={source} alt={lines.map(l => l.text).join('\n')} />
588 ) : (
589 <Box key="lines" flexDirection="column">
590 {lines.map(l => (
591 <Text color={l.color} dimColor={l.isDim} bold={l.isBold}>
592 {l.text}
593 </Text>
594 ))}
595 </Box>
596 )}
597 {tab === 'gear' && owned !== undefined && Select && (
598 <Box key="equip" flexDirection="column">
599 {SLOTS.map(k => (
600 <Select
601 key={`equip:${k}`}
602 label={`${SLOT_LABEL[k]}: `}
603 options={[
604 { value: '', label: 'なし' },
605 ...owned
606 .filter(c => c.slot === k)
607 .sort((a, b) => RANK[a.rarity] - RANK[b.rarity] || a.id.localeCompare(b.id))
608 .map(c => ({ value: c.id, label: `${c.rarity} ${c.name}` })),
609 ]}
610 value={worn[k]?.id ?? ''}
611 onSelect={(v: string) => void equip($, k, v)}
612 />
613 ))}
614 </Box>
615 )}
616 {tab === 'battle' && battle && isFighting && (
617 <Box key="commands" flexDirection="column">
618 <Button key="act:attack" label="攻撃" variant="primary" onPress={() => void command($, { kind: 'attack' })} />
619 {SKILLS.filter(sk => !sk.isPassive && hero.learned[sk.id] !== undefined).map(sk => (
620 <Button
621 key={`skill:${sk.id}`}
622 label={`${sk.name} (MP ${sk.mp})`}
623 dimColor={!canUse(battle, sk.id, hero.learned)}
624 onPress={() => void command($, { kind: 'skill', id: sk.id })}
625 />
626 ))}
627 <Button key="act:defend" label="防御 (MP 少し回復)" onPress={() => void command($, { kind: 'defend' })} />
628 <Button key="act:flee" label="逃げる" dimColor={battle.enemy.isBoss} onPress={() => void command($, { kind: 'flee' })} />
629 </Box>
630 )}
631 {tab === 'battle' && !isFighting && (
632 <Box key="lobby" flexDirection="column">
633 <Button
634 key="find"
635 label={`魔物を探す (気配 ${hero.encounters})`}
636 variant="primary"
637 dimColor={hero.encounters <= 0}
638 onPress={() => void fight($, false)}
639 />
640 {level >= boss.level && (
641 <Button
642 key="boss"
643 label={hero.bossSign > 0 ? `湧いたボスに挑む: ${boss.name} (経験値 1.5 倍)` : `ボスに挑む: ${boss.name}`}
644 variant={hero.bossSign > 0 ? 'primary' : undefined}
645 dimColor={hero.bossSign <= 0 && hero.encounters <= 0}
646 onPress={() => void fight($, true)}
647 />
648 )}
649 {battle && <Button key="close" label="戦いを終える" onPress={() => void closeBattle($)} />}
650 </Box>
651 )}
652 {tab === 'growth' && (
653 <Box key="growth" flexDirection="column">
654 {STATS.map(k => (
655 <Button key={`up:${k}`} label={`${STAT_LABEL[k]} +1 (${STAT_NOTE[k]})`} dimColor={sp.free < 1} onPress={() => void raise($, k)} />
656 ))}
657 {SKILLS.filter(sk => hero.learned[sk.id] === undefined && level >= sk.level).map(sk => (
658 <Button key={`learn:${sk.id}`} label={`覚える: ${sk.name} (${sk.sp} SP)`} dimColor={sp.free < sk.sp} onPress={() => void learn($, sk.id)} />
659 ))}
660 {STATS.some(k => hero.alloc[k] > 0) && <Button key="respec" label="能力の振り分けを戻す" onPress={() => void unspend($)} />}
661 </Box>
662 )}
663 {tab === 'dungeon' && (
664 <Box key="dungeon" flexDirection="column">
665 {hero.delve ? (
666 <Button key="delve:leave" label={`帰る (袋の経験値 ${hero.delve.loot} を持ち帰る)`} variant="primary" onPress={() => void leaveDungeon($)} />
667 ) : (
668 <Button key="delve:enter" label={`${from} 階から潜る`} variant="primary" onPress={() => void enterDungeon($, from)} />
669 )}
670 {!hero.delve && from > 1 && <Button key="delve:enter1" label="1 階から潜る" dimColor onPress={() => void enterDungeon($, 1)} />}
671 </Box>
672 )}
673 {restoring.list !== null && (
674 <Box key="restore" flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} paddingY={1}>
675 <Text color="yellow" bold>
676 控えから戻す
677 </Text>
678 {backupDir === '' ? (
679 <Text dimColor>設定の backupDir を入れると控えが取れます</Text>
680 ) : restoring.list.length === 0 ? (
681 <Text dimColor>まだ控えがありません (毎日はじめての保存の時に取ります)</Text>
682 ) : restoring.pick === null ? (
683 restoring.list.map(b => (
684 <Button
685 key={`restore:${b.day}`}
686 label={`${b.day} の記録 (Lv ${levelInfo(fromSaved(b.value).xp).level})`}
687 dimColor
688 onPress={() => void update($, RESTORE, r => ({ ...r, pick: b.day }))}
689 />
690 ))
691 ) : (
692 <Box key="restore-ask" flexDirection="column">
693 <Text>{`${restoring.pick} の記録に戻しますか。今の記録は上書きされます`}</Text>
694 <Button key="restore:yes" label="戻す" variant="primary" onPress={() => void restore($, restoring.pick ?? '')} />
695 <Button key="restore:no" label="やめる" dimColor onPress={() => void update($, RESTORE, r => ({ ...r, pick: null }))} />
696 </Box>
697 )}
698 <Button key="restore:close" label="閉じる" dimColor onPress={() => void update($, RESTORE, () => CLOSED)} />
699 </Box>
700 )}
701 {news !== '' && <Text color="yellow">{news}</Text>}
702 <Box flexDirection="column">
703 <Button
704 key="battle"
705 label={tab === 'battle' ? 'ステータスに戻る' : isFighting ? '戦う (戦闘中)' : `戦う (気配 ${hero.encounters}${hero.bossSign > 0 ? '・ボス出現' : ''})`}
706 variant={tab === 'status' ? 'primary' : undefined}
707 dimColor={tab !== 'status' && tab !== 'battle'}
708 onPress={() => toggle('battle')}
709 />
710 <Button
711 key="dungeon"
712 label={tab === 'dungeon' ? 'ステータスに戻る' : hero.delve ? `ダンジョン (地下 ${hero.delve.floor} 階)` : `ダンジョン (記録 ${hero.deepest} 階)`}
713 dimColor={tab !== 'dungeon'}
714 onPress={() => toggle('dungeon')}
715 />
716 <Button key="growth" label={tab === 'growth' ? 'ステータスに戻る' : `成長 (SP ${sp.free})`} dimColor={tab !== 'growth'} onPress={() => toggle('growth')} />
717 <Button key="tab" label={tab === 'achievements' ? 'ステータスに戻る' : '実績を見る'} dimColor={tab !== 'achievements'} onPress={() => toggle('achievements')} />
718 <Button key="gear" label={tab === 'gear' ? 'ステータスに戻る' : '装備'} dimColor={tab !== 'gear'} onPress={() => toggle('gear')} />
719 <Button key="restore" label="控えから戻す" dimColor onPress={() => void (restoring.list === null ? openRestore($) : update($, RESTORE, () => CLOSED))} />
720 <Button
721 key="reset"
722 label={isConfirming ? '本当にやり直す(すべて消えます)' : '冒険をやり直す'}
723 dimColor={!isConfirming}
724 onPress={() => void (isConfirming ? reset($) : update($, CONFIRM, () => true))}
725 />
726 {isConfirming && <Button key="reset-cancel" label="やめる" dimColor onPress={() => void update($, CONFIRM, () => false)} />}
727 </Box>
728 </Box>
729 )
730 })
731}
732hooks/battle.ts 700 lines1// 戦い (敵・1 手ずつの計算) と、戦いと成長の画面の SVG / terminal 用の行。どれも純関数
2import type { RpgBattle, RpgEnemy, RpgFighter, RpgHero, RpgTrait } from '../types'
3import {
4 BOSS_IDS,
5 K,
6 MAX_ENCOUNTERS,
7 MONO,
8 SKILLS,
9 SP_RULE,
10 STATS,
11 STAT_LABEL,
12 STAT_NOTE,
13 bar,
14 fatigueAt,
15 gain,
16 hpPctAt,
17 levelInfo,
18 paint,
19 skillOf,
20 sprite,
21 statsOf,
22 svgDoc,
23 win,
24} from './rpg'
25import type { Line } from './rpg'
26import { esc, short } from './style'
27
28// ---------------------------------------------------------------- 敵
29
30type Kind = {
31 id: string
32 name: string
33 /** 普通の敵: このレベルから出る。ボス: このレベルで戦う */
34 level: number
35 shape: Shape
36 color: string
37 /** 基本の値に掛ける倍率 */
38 hp: number
39 atk: number
40 def: number
41 spd: number
42 trait: RpgTrait
43}
44
45/** 普通の敵。勇者のレベルの前後で出る */
46export const ENEMIES: readonly Kind[] = [
47 { id: 'nullpo', name: 'ヌルポスライム', level: 1, shape: 'slime', color: '#3fb950', hp: 0.8, atk: 0.9, def: 0.8, spd: 0.8, trait: 'none' },
48 { id: 'typo', name: 'タイポバット', level: 1, shape: 'bat', color: '#bc8cff', hp: 0.7, atk: 0.9, def: 0.7, spd: 1.5, trait: 'none' },
49 { id: 'offby1', name: 'オフバイワン・ゴブリン', level: 3, shape: 'goblin', color: '#d29922', hp: 1, atk: 1.05, def: 1, spd: 1, trait: 'none' },
50 { id: 'leak', name: 'メモリリーク・ゴースト', level: 5, shape: 'ghost', color: '#9fb3e6', hp: 1.1, atk: 1, def: 0.6, spd: 1, trait: 'drain' },
51 { id: 'race', name: 'レースコンディション', level: 8, shape: 'bat', color: '#f85149', hp: 0.8, atk: 0.7, def: 0.8, spd: 1.6, trait: 'double' },
52 { id: 'heisen', name: 'ハイゼンバグ', level: 11, shape: 'ghost', color: '#39c5cf', hp: 0.9, atk: 1, def: 0.8, spd: 1.3, trait: 'evade' },
53 { id: 'deadlock', name: 'デッドロック・ゴーレム', level: 14, shape: 'golem', color: '#8b949e', hp: 1.4, atk: 1.05, def: 1.8, spd: 0.5, trait: 'heavy' },
54 { id: 'spaghetti', name: 'スパゲッティ・ヒドラ', level: 20, shape: 'dragon', color: '#f78166', hp: 1.5, atk: 1.1, def: 1.1, spd: 0.9, trait: 'none' },
55]
56
57/** ボス。並びは rpg.ts の BOSS_IDS と同じ。倒すたびに次が出る */
58export const BOSSES: readonly Kind[] = [
59 { id: 'loop', name: '無限ループ・ドラゴン', level: 10, shape: 'dragon', color: '#58a6ff', hp: 4, atk: 1.35, def: 1.2, spd: 1, trait: 'heavy' },
60 { id: 'outage', name: '本番障害の魔王', level: 25, shape: 'demon', color: '#bc8cff', hp: 4.5, atk: 1.4, def: 1.3, spd: 1.1, trait: 'drain' },
61 { id: 'legacy', name: 'レガシーコードの古竜', level: 45, shape: 'dragon', color: '#d29922', hp: 5, atk: 1.45, def: 1.4, spd: 1, trait: 'heavy' },
62]
63
64export const TRAIT_NOTE: Record<RpgTrait, string> = {
65 none: '',
66 drain: '攻撃で MP を吸う',
67 double: '1 ラウンドに 2 回動く',
68 evade: '物理攻撃をかわすことがある',
69 heavy: '力をためて大技を出す',
70}
71
72/** レベル L の敵の値 (倍率を掛ける前) */
73const baseOf = (L: number) => ({ hp: 18 + 8 * L, atk: 5 + 1.8 * L, def: 1 + L, spd: 2 + L, xp: 6 + 3 * L })
74
75export function enemyOf(kind: Kind, level: number, isBoss: boolean): RpgEnemy {
76 const b = baseOf(level)
77 const maxHp = Math.round(b.hp * kind.hp)
78 return {
79 id: kind.id,
80 name: kind.name,
81 level,
82 isBoss,
83 maxHp,
84 hp: maxHp,
85 atk: Math.round(b.atk * kind.atk),
86 def: Math.round(b.def * kind.def),
87 spd: Math.round(b.spd * kind.spd),
88 xp: Math.round(b.xp * (isBoss ? 4 : 1)),
89 trait: kind.trait,
90 shape: kind.shape,
91 color: kind.color,
92 }
93}
94
95/** 次に挑めるボス (まだ倒していない最初の 1 体)。全部倒したら最後のボスにもう一度 */
96export const nextBoss = (h: RpgHero) => BOSSES.find(b => h.defeated[b.id] === undefined) ?? BOSSES[BOSSES.length - 1]!
97export const isAllBossesDown = (h: RpgHero) => BOSS_IDS.every(id => h.defeated[id] !== undefined)
98
99/** ボスが湧く: 次のボスのレベルに届いていれば、ターンを終えるたびに 5%、それと 20 ターンごとに必ず。湧いたボスは経験値 1.5 倍 */
100export const BOSS_SPAWN = { chance: 0.05, every: 20, xp: 1.5 } as const
101
102// ---------------------------------------------------------------- 乱数 (種から決まる)
103
104/** mulberry32。同じ種なら同じ並び。戦いの種は 1 手ごとに進めて state に置く */
105export function rngOf(seed: number) {
106 let t = seed >>> 0
107 const next = () => {
108 t = (t + 0x6d2b79f5) >>> 0
109 let x = t
110 x = Math.imul(x ^ (x >>> 15), x | 1)
111 x ^= x + Math.imul(x ^ (x >>> 7), x | 61)
112 return ((x ^ (x >>> 14)) >>> 0) / 4294967296
113 }
114 return { next, seed: () => t }
115}
116
117// ---------------------------------------------------------------- ボスが湧く
118
119/**
120 * ターンを終えた後に呼ぶ (turns は数え終わった値)。気配が無く、次のボスのレベルに届いていれば、
121 * 20 ターンごとか 5% でボスの気配を出す。5% の乱数は時刻とターン数から決まる
122 */
123export function spawnBoss(h: RpgHero, now: number): RpgHero {
124 if (h.bossSign > 0 || levelInfo(h.xp).level < nextBoss(h).level) return h
125 const isDue = h.turns > 0 && h.turns % BOSS_SPAWN.every === 0
126 const isLucky = rngOf((now ^ Math.imul(h.turns, 2654435761)) >>> 0).next() < BOSS_SPAWN.chance
127 return isDue || isLucky ? { ...h, bossSign: 1 } : h
128}
129
130// ---------------------------------------------------------------- 戦いを始める
131
132/** 戦いの始めの勇者の強さ。HP と MP は今の値 (疲れていれば減ったまま) から */
133export function fighterOf(h: RpgHero): RpgFighter {
134 const st = statsOf(h)
135 return {
136 maxHp: st.maxHp,
137 hp: st.hp,
138 maxMp: st.maxMp,
139 mp: st.mp,
140 atk: st.attack,
141 def: st.defense,
142 mag: st.magic,
143 spd: st.speed,
144 crit: st.crit,
145 regen: h.learned.ci !== undefined ? 5 : 0,
146 }
147}
148
149/** 魔物を探す (boss なら次のボスに挑む。isSpawned なら湧いたボスで経験値 1.5 倍)。seed で敵の種類とレベルが決まる */
150export function startBattle(h: RpgHero, seed: number, isBoss: boolean, isSpawned = false): RpgBattle {
151 const r = rngOf(seed)
152 const L = levelInfo(h.xp).level
153 let enemy: RpgEnemy
154 if (isBoss) {
155 const kind = nextBoss(h)
156 enemy = enemyOf(kind, kind.level, true)
157 if (isSpawned) enemy = { ...enemy, xp: Math.round(enemy.xp * BOSS_SPAWN.xp) }
158 } else {
159 const pool = ENEMIES.filter(k => k.level <= L)
160 const kind = pool[Math.floor(r.next() * pool.length)] ?? ENEMIES[0]!
161 // 勇者のレベルの -2 〜 +1
162 const level = Math.max(1, L + [-2, -1, 0, 0, 1][Math.floor(r.next() * 5)]!)
163 enemy = enemyOf(kind, level, false)
164 }
165 return openBattle(fighterOf(h), enemy, r.seed(), isBoss ? `${enemy.name}が立ちはだかった!${isSpawned ? ' (経験値 1.5 倍)' : ''}` : `${enemy.name}があらわれた!`)
166}
167
168/** 戦いの始まりの形 (ダンジョンも使う) */
169export function openBattle(me: RpgFighter, enemy: RpgEnemy, seed: number, first: string): RpgBattle {
170 return {
171 enemy,
172 me,
173 round: 1,
174 guard: 0,
175 isExposed: false,
176 isCharging: false,
177 usedRollback: false,
178 seed,
179 log: [first],
180 result: 'fight',
181 gainedXp: 0,
182 hitEnemy: 0,
183 hitMe: 0,
184 }
185}
186
187// ---------------------------------------------------------------- 1 手
188
189export type Command = { kind: 'attack' } | { kind: 'skill'; id: string } | { kind: 'defend' } | { kind: 'flee' }
190
191const LOG_MAX = 6
192
193/** HP を戻すスキル (HP が満タンなら使えない) */
194const HEALS: readonly string[] = ['heal', 'rollback']
195
196/** 使えるスキルか (覚えていて、常に効く物でなく、ロールバックは 1 度だけ、回復は HP が減っている時、MP が足りる) */
197export function canUse(b: RpgBattle, id: string, learned: Record<string, number>) {
198 const sk = skillOf(id)
199 if (!sk || sk.isPassive || learned[id] === undefined) return false
200 if (id === 'rollback' && b.usedRollback) return false
201 if (HEALS.includes(id) && b.me.hp >= b.me.maxHp) return false
202 return b.me.mp >= sk.mp
203}
204
205/**
206 * 1 ラウンド進める: 素早い方から動く (同じなら勇者)。
207 * 勝った・負けた・逃げたら result が変わる。
208 * 終わった戦い・使えないスキル・ボスから逃げる手は、ラウンドを進めない (できない時はログに一言)
209 */
210export function act(b0: RpgBattle, cmd: Command, learned: Record<string, number>): RpgBattle {
211 if (b0.result !== 'fight') return b0
212 const refuse = (why: string): RpgBattle => ({ ...b0, log: [...b0.log, why].slice(-LOG_MAX), hitEnemy: 0, hitMe: 0 })
213 if (cmd.kind === 'flee' && b0.enemy.isBoss) return refuse('ボスからは逃げられない!')
214 if (cmd.kind === 'skill' && !canUse(b0, cmd.id, learned)) {
215 const sk = skillOf(cmd.id)
216 return refuse(
217 !sk || learned[cmd.id] === undefined
218 ? 'そのスキルは覚えていない'
219 : sk.isPassive
220 ? `${sk.name}は常に効いている`
221 : cmd.id === 'rollback' && b0.usedRollback
222 ? 'ロールバックはもう使った'
223 : HEALS.includes(cmd.id) && b0.me.hp >= b0.me.maxHp
224 ? 'HP は減っていない'
225 : 'MP が足りない',
226 )
227 }
228 const r = rngOf(b0.seed)
229 const b: RpgBattle = { ...b0, me: { ...b0.me }, enemy: { ...b0.enemy }, log: [...b0.log], hitEnemy: 0, hitMe: 0 }
230 const me = b.me
231 const en = b.enemy
232 const say = (t: string) => b.log.push(t)
233 const spread = () => 0.85 + r.next() * 0.3
234 const isDefending = cmd.kind === 'defend'
235 const isHeroFirst = me.spd >= en.spd
236
237 /** 物理で 1 回。守りは半分だけ効く。ログ解読の後は守りを 4 割下げる */
238 const physical = (mult: number) => {
239 if (en.trait === 'evade' && r.next() < 0.25) {
240 say(`${en.name}はすり抜けた!`)
241 return
242 }
243 const isCrit = r.next() * 100 < me.crit
244 const def = en.def * (b.isExposed ? 0.6 : 1)
245 const dmg = Math.max(1, Math.round((me.atk * mult * spread() - def * 0.5) * (isCrit ? 1.5 : 1)))
246 en.hp = Math.max(0, en.hp - dmg)
247 b.hitEnemy += dmg
248 say(isCrit ? `会心の一撃! ${en.name}に ${dmg} のダメージ` : `${en.name}に ${dmg} のダメージ`)
249 }
250 /** 魔法。守りも回避も効かない */
251 const magical = (mult: number) => {
252 const dmg = Math.max(1, Math.round(me.mag * mult * spread()))
253 en.hp = Math.max(0, en.hp - dmg)
254 b.hitEnemy += dmg
255 say(`${en.name}に ${dmg} のダメージ`)
256 }
257
258 const heroTurn = () => {
259 switch (cmd.kind) {
260 case 'attack':
261 say('勇者の攻撃!')
262 physical(1)
263 return
264 case 'defend': {
265 const mp = Math.min(me.maxMp - me.mp, Math.round(me.maxMp * 0.1))
266 me.mp += mp
267 say(mp > 0 ? `勇者は身を守っている (MP +${mp})` : '勇者は身を守っている')
268 return
269 }
270 case 'flee': {
271 const chance = Math.min(0.95, Math.max(0.2, 0.5 + (me.spd - en.spd) * 0.05))
272 if (r.next() < chance) {
273 say('勇者は逃げ出した')
274 b.result = 'fled'
275 } else say('逃げられなかった!')
276 return
277 }
278 case 'skill': {
279 const sk = skillOf(cmd.id)!
280 me.mp -= sk.mp
281 say(`勇者の${sk.name}!`)
282 if (sk.id === 'slash') physical(1.7)
283 else if (sk.id === 'multi') for (let i = 0; i < 3 && en.hp > 0; i++) physical(0.7)
284 else if (sk.id === 'lint') magical(1.6)
285 else if (sk.id === 'deploy') magical(3)
286 else if (sk.id === 'heal') {
287 const n = Math.min(me.maxHp - me.hp, Math.round(me.maxHp * 0.35 + me.mag))
288 me.hp += n
289 say(`HP が ${n} 回復した`)
290 } else if (sk.id === 'rollback') {
291 const n = me.maxHp - me.hp
292 me.hp = me.maxHp
293 b.usedRollback = true
294 say(`HP が全部戻った (+${n})`)
295 } else if (sk.id === 'guard') {
296 // 盾はラウンドの終わりに 1 減る。敵が先に動いた回は守れていないので 1 つ多く持たせる
297 b.guard = isHeroFirst ? 3 : 4
298 say('3 ラウンドの間、受ける傷が半分になる')
299 } else if (sk.id === 'scan') {
300 b.isExposed = true
301 say(`${en.name}の弱点を見抜いた! 守りが下がった`)
302 }
303 }
304 }
305 }
306
307 const enemyHit = () => {
308 if (en.trait === 'heavy' && !b.isCharging && r.next() < 0.3) {
309 b.isCharging = true
310 say(`${en.name}は力をためている…`)
311 return
312 }
313 const mult = b.isCharging ? 2 : 1
314 if (b.isCharging) say(`${en.name}の大技!`)
315 b.isCharging = false
316 let dmg = Math.max(1, Math.round((en.atk * mult * spread() - me.def * 0.5) * (isDefending ? 0.5 : 1) * (b.guard > 0 ? 0.5 : 1)))
317 dmg = Math.min(dmg, me.hp)
318 me.hp -= dmg
319 b.hitMe += dmg
320 say(`勇者は ${dmg} のダメージを受けた`)
321 if (en.trait === 'drain' && me.mp > 0) {
322 const mp = Math.min(me.mp, 3)
323 me.mp -= mp
324 say(`MP を ${mp} 吸い取られた`)
325 }
326 }
327 const enemyTurn = () => {
328 for (let i = 0; i < (en.trait === 'double' ? 2 : 1) && me.hp > 0; i++) enemyHit()
329 }
330
331 const isOver = () => b.result !== 'fight' || en.hp <= 0 || me.hp <= 0
332 const order = isHeroFirst ? [heroTurn, enemyTurn] : [enemyTurn, heroTurn]
333 for (const step of order) {
334 if (isOver()) break
335 step()
336 }
337
338 if (en.hp <= 0) {
339 b.result = 'won'
340 b.gainedXp = en.xp
341 say(`${en.name}をやっつけた! 経験値 ${en.xp}`)
342 } else if (me.hp <= 0) {
343 b.result = 'lost'
344 say('勇者は力尽きた… (経験値は減らない)')
345 } else if (b.result === 'fight') {
346 if (me.regen > 0 && me.hp < me.maxHp) {
347 const n = Math.min(me.maxHp - me.hp, Math.max(1, Math.round((me.maxHp * me.regen) / 100)))
348 me.hp += n
349 say(`CI が回って HP +${n}`)
350 }
351 if (b.guard > 0) b.guard -= 1
352 b.round += 1
353 }
354 b.log = b.log.slice(-LOG_MAX)
355 b.seed = r.seed()
356 return b
357}
358
359/** 勝った戦いを勇者に入れる: 経験値・勝ち数・ボスを倒した時刻 */
360export function winBattle(h: RpgHero, b: RpgBattle, now: number): RpgHero {
361 if (b.result !== 'won') return h
362 const next = gain(h, b.gainedXp)
363 const defeated = b.enemy.isBoss && h.defeated[b.enemy.id] === undefined ? { ...h.defeated, [b.enemy.id]: now } : h.defeated
364 return { ...next, wins: h.wins + 1, defeated }
365}
366
367/**
368 * 終わった戦い (勝ち・負け・逃げ) を勇者に入れる。戦いの後の HP を傷として残し (休むと治る)、勝っていれば winBattle。
369 * 傷は「今の疲れ」を引いた残りなので、戦っている間に増えた疲れは二重に数えない
370 */
371export function endBattle(h: RpgHero, b: RpgBattle, now: number): RpgHero {
372 if (b.result === 'fight') return h
373 const endPct = Math.round((b.me.hp / Math.max(1, b.me.maxHp)) * 100)
374 const s = { ...h.s, wound: Math.max(0, 100 - fatigueAt(h, now) - endPct) }
375 return winBattle({ ...h, s, hpPct: hpPctAt({ ...h, s }, now) }, b, now)
376}
377
378// ---------------------------------------------------------------- ドット絵
379
380type Shape = 'slime' | 'bat' | 'goblin' | 'ghost' | 'golem' | 'dragon' | 'demon'
381
382// 12x12。x 体 / d 影 / w 白目 / e 黒目 / r 赤
383const SHAPES: Record<Shape, string[]> = {
384 slime: [
385 '............',
386 '............',
387 '.....xx.....',
388 '....xxxx....',
389 '...xxxxxx...',
390 '..xxwxxwxx..',
391 '..xxexxexx..',
392 '.xxxxxxxxxx.',
393 '.xxxxddxxxx.',
394 'xxxxxxxxxxxx',
395 'dxxxxxxxxxxd',
396 '.dddddddddd.',
397 ],
398 bat: [
399 '............',
400 'x..........x',
401 'xx...xx...xx',
402 'xxx.xxxx.xxx',
403 'xxxxxxxxxxxx',
404 'xxxxwexxewxx',
405 'dxxxxxxxxxxd',
406 'd.xxxrrxxx.d',
407 '...xxxxxx...',
408 '....d..d....',
409 '............',
410 '............',
411 ],
412 goblin: [
413 '....xxxx....',
414 'x..xxxxxx..x',
415 'xxxxwexxewxx',
416 '.xxxxxxxxxx.',
417 '...xrrrrx...',
418 '....xxxx....',
419 '..dddddddd..',
420 '.xddddddddx.',
421 '.x.dddddd.x.',
422 '...dd..dd...',
423 '...xx..xx...',
424 '..xxx..xxx..',
425 ],
426 ghost: [
427 '....xxxx....',
428 '..xxxxxxxx..',
429 '.xxxxxxxxxx.',
430 '.xxeexxeexx.',
431 '.xxeexxeexx.',
432 'xxxxxxxxxxxx',
433 'xxxxxrrxxxxx',
434 'xxxxxxxxxxxx',
435 'xxxxxxxxxxxx',
436 'xxdxxxxxxdxx',
437 'x.dx.xx.xd.x',
438 '...x....x...',
439 ],
440 golem: [
441 '...dddddd...',
442 '..dxxxxxxd..',
443 '..dxrxxrxd..',
444 '..dxxxxxxd..',
445 'ddddxxxxdddd',
446 'dxxxxxxxxxxd',
447 'dxxdxxxxdxxd',
448 'dxxdxxxxdxxd',
449 'ddddxxxxdddd',
450 '...dxxxxd...',
451 '..ddxddxdd..',
452 '..ddd..ddd..',
453 ],
454 dragon: [
455 '.x........x.',
456 '.xx......xx.',
457 '..xxxxxxxx..',
458 '.xxwexxewxx.',
459 '.xxxxxxxxxx.',
460 'xx.xrrrrx.xx',
461 'x..xxxxxx..x',
462 '..dxxxxxxd..',
463 '.ddxxddxxdd.',
464 'dd.xxxxxx.dd',
465 '...xx..xx...',
466 '..xxx..xxx..',
467 ],
468 demon: [
469 'x..........x',
470 'xx..xxxx..xx',
471 '.xxxxxxxxxx.',
472 '..xrrxxrrx..',
473 '..xxxxxxxx..',
474 '...xwwwwx...',
475 'dddxxxxxxddd',
476 'ddxxxxxxxxdd',
477 'd.xxxddxxx.d',
478 '..xxxxxxxx..',
479 '..xx....xx..',
480 '.xxx....xxx.',
481 ],
482}
483
484export function monster(shape: string, color: string, x: number, y: number, px: number) {
485 const rows = SHAPES[shape as Shape] ?? SHAPES.slime
486 const colors: Record<string, string> = { x: color, d: color, w: '#ffffff', e: '#0d1117', r: '#f85149' }
487 let s = ''
488 rows.forEach((row, r) => {
489 ;[...row].forEach((ch, c) => {
490 const col = colors[ch]
491 if (col) s += `<rect x="${x + c * px}" y="${y + r * px}" width="${px}" height="${px}" fill="${col}"${ch === 'd' ? ' opacity="0.6"' : ''}/>`
492 })
493 })
494 return s
495}
496
497// ---------------------------------------------------------------- 戦いの画面
498
499/** 当たった時: 揺れて、数字が上に抜けて消える (1 回きり) */
500const shake = (body: string, dx: number) =>
501 `<g>${body}<animateTransform attributeName="transform" type="translate" values="0 0;${dx} 0;${-dx} 0;${dx / 2} 0;0 0" dur="0.35s" fill="freeze"/></g>`
502const popup = (x: number, y: number, text: string, color: string) =>
503 `<text x="${x}" y="${y}" font-size="20" font-weight="bold" font-family="${MONO}" fill="${color}" stroke="${K.bg}" stroke-width="3" paint-order="stroke" text-anchor="middle" opacity="0">${text}` +
504 `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.7;1" dur="1.2s" fill="freeze"/>` +
505 `<animate attributeName="y" from="${y}" to="${y - 26}" dur="1.2s" fill="freeze"/></text>`
506
507const RESULT_TEXT: Record<RpgBattle['result'], string> = { fight: '', won: '勝利!', lost: '力尽きた…', fled: 'うまく逃げた' }
508
509/** 戦いのタブ。戦っていなければ、魔物の気配 (遭遇) とボスの案内 */
510export function battleSvg(h: RpgHero, b: RpgBattle | null) {
511 paint()
512 if (!b) return lobbySvg(h)
513 const en = b.enemy
514 const me = b.me
515 let s = ''
516 // ── 敵
517 s += win(8, 8, 364, 176)
518 s += `<text x="24" y="34" font-size="15" font-weight="bold" fill="${en.isBoss ? K.purple : K.text}">${esc(short(en.name, 16))}</text>`
519 s += `<text x="358" y="34" font-size="14" font-weight="bold" font-family="${MONO}" fill="${K.text}" text-anchor="end">${en.isBoss ? 'BOSS ' : ''}Lv ${en.level}</text>`
520 const body = monster(en.shape, en.color, 142, 44, 8)
521 const alive = en.hp > 0
522 const mon = alive ? body : `<g opacity="1">${body}<animate attributeName="opacity" from="1" to="0.1" dur="0.8s" fill="freeze"/></g>`
523 s += b.hitEnemy > 0 ? shake(mon, 6) : `<g>${mon}<animateTransform attributeName="transform" type="translate" values="0 0;0 -2;0 0" dur="1.8s" repeatCount="indefinite"/></g>`
524 if (b.hitEnemy > 0) s += popup(190, 92, `-${b.hitEnemy}`, K.yellow)
525 if (b.isCharging) s += `<text x="358" y="60" font-size="12" font-weight="bold" fill="${K.red}" text-anchor="end">力をためている</text>`
526 if (b.isExposed) s += `<text x="24" y="60" font-size="12" font-weight="bold" fill="${K.gear}">守り -40%</text>`
527 s += `<text x="24" y="153" font-size="13" font-weight="bold" font-family="${MONO}" fill="${K.text}">HP</text>`
528 s += bar(56, 142, 236, en.hp / en.maxHp, en.hp / en.maxHp > 0.3 ? K.red : K.yellow)
529 s += `<text x="358" y="153" font-size="12" font-family="${MONO}" fill="${K.text}" text-anchor="end">${en.hp}/${en.maxHp}</text>`
530 const trait = TRAIT_NOTE[en.trait]
531 s += `<text x="24" y="172" font-size="12" fill="${K.weak}">${trait === '' ? `攻 ${en.atk} 守 ${en.def} 速 ${en.spd}` : `攻 ${en.atk} 守 ${en.def} 速 ${en.spd} / ${trait}`}</text>`
532
533 // ── 勇者
534 s += win(8, 192, 364, 92)
535 const hero = sprite(22, 210, 4)
536 s += b.hitMe > 0 ? shake(hero, 4) : hero
537 if (b.hitMe > 0) s += popup(46, 236, `-${b.hitMe}`, K.red)
538 const hpFrac = me.hp / me.maxHp
539 s += `<text x="86" y="219" font-size="13" font-weight="bold" font-family="${MONO}" fill="${K.text}">HP</text>`
540 s += bar(118, 208, 160, hpFrac, hpFrac > 0.5 ? K.green : hpFrac > 0.25 ? K.yellow : K.red)
541 s += `<text x="358" y="219" font-size="12" font-family="${MONO}" fill="${K.text}" text-anchor="end">${me.hp}/${me.maxHp}</text>`
542 s += `<text x="86" y="243" font-size="13" font-weight="bold" font-family="${MONO}" fill="${K.text}">MP</text>`
543 s += bar(118, 232, 160, me.mp / Math.max(1, me.maxMp), K.blue)
544 s += `<text x="358" y="243" font-size="12" font-family="${MONO}" fill="${K.text}" text-anchor="end">${me.mp}/${me.maxMp}</text>`
545 const marks = [b.guard > 0 ? `盾 残り ${b.guard}` : '', me.regen > 0 ? 'CI' : ''].filter(x => x !== '').join(' / ')
546 s += `<text x="86" y="270" font-size="12" fill="${K.weak}">ラウンド ${b.round} 攻 ${me.atk} 守 ${me.def} 魔 ${me.mag} 速 ${me.spd}</text>`
547 if (marks !== '') s += `<text x="358" y="270" font-size="12" font-weight="bold" fill="${K.gear}" text-anchor="end">${marks}</text>`
548
549 // ── ログ
550 const logTop = 292
551 const logH = 22 + LOG_MAX * 20
552 s += win(8, logTop, 364, logH)
553 b.log.forEach((t, i) => {
554 const isLast = i === b.log.length - 1
555 s += `<text x="24" y="${logTop + 26 + i * 20}" font-size="13" fill="${isLast ? K.text : K.weak}">${esc(short(t, 26))}</text>`
556 })
557 let y = logTop + logH + 8
558 if (b.result !== 'fight') {
559 const col = b.result === 'won' ? K.yellow : b.result === 'lost' ? K.red : K.weak
560 s += win(8, y, 364, 56)
561 s +=
562 `<text x="190" y="${y + 36}" font-size="20" font-weight="bold" fill="${col}" text-anchor="middle">${RESULT_TEXT[b.result]}` +
563 (b.result === 'won' ? `<animate attributeName="font-size" values="12;24;20" dur="0.5s" fill="freeze"/>` : '') +
564 `</text>`
565 y += 64
566 }
567 return svgDoc(y, s)
568}
569
570/** 戦っていない時: 遭遇の数・次のボス・戦績 */
571function lobbySvg(h: RpgHero) {
572 const L = levelInfo(h.xp).level
573 const boss = nextBoss(h)
574 let s = win(8, 8, 364, 112)
575 s += `<text x="24" y="34" font-size="15" font-weight="bold" fill="${K.text}">▶ 魔物の気配</text>`
576 s += `<text x="358" y="34" font-size="12" fill="${K.weak}" text-anchor="end">${h.wins} 勝 / ${h.battles} 戦</text>`
577 for (let i = 0; i < MAX_ENCOUNTERS; i++) {
578 const has = i < h.encounters
579 s += `<circle cx="${40 + i * 34}" cy="62" r="12" fill="${has ? K.red : K.off}" stroke="${has ? K.frame : K.offText}"${has ? '' : ' stroke-dasharray="3 2"'}/>`
580 if (has) s += `<text x="${40 + i * 34}" y="67" font-size="13" font-weight="bold" fill="#ffffff" text-anchor="middle">!</text>`
581 }
582 s += `<text x="358" y="67" font-size="13" font-weight="bold" font-family="${MONO}" fill="${K.text}" text-anchor="end">${h.encounters}/${MAX_ENCOUNTERS}</text>`
583 s += `<text x="24" y="96" font-size="12" fill="${K.weak}">ターンを終えるたびに気配が 1 つたまる (${MAX_ENCOUNTERS} まで)</text>`
584 s += `<text x="24" y="112" font-size="12" fill="${K.weak}">戦うと 1 つ使う。勝つと経験値、${SP_RULE.winsPer} 勝ごとに SP +1</text>`
585
586 // ── ボス
587 s += win(8, 128, 364, 172)
588 s += `<text x="24" y="154" font-size="14" font-weight="bold" fill="${K.text}">ボス</text>`
589 s += `<text x="358" y="154" font-size="12" fill="${K.weak}" text-anchor="end">初めて倒すと SP +${SP_RULE.boss}</text>`
590 BOSSES.forEach((b, i) => {
591 const y = 178 + i * 28
592 const isDown = h.defeated[b.id] !== undefined
593 const isNext = !isDown && b.id === boss.id
594 const col = isDown ? K.yellow : isNext ? K.text : K.offText
595 s += `<text x="24" y="${y}" font-size="13" font-family="${MONO}" fill="${col}">Lv${String(b.level).padStart(3, ' ')}</text>`
596 s += `<text x="80" y="${y}" font-size="13"${isNext ? ' font-weight="bold"' : ''} fill="${col}">${esc(b.name)}</text>`
597 s += `<text x="358" y="${y}" font-size="12" fill="${col}" text-anchor="end">${isDown ? '撃破' : isNext ? (h.bossSign > 0 ? '湧いた!' : L >= b.level ? '挑める' : `Lv ${b.level} 推奨`) : 'まだ'}</text>`
598 })
599 const signY = 178 + BOSSES.length * 28
600 s +=
601 h.bossSign > 0
602 ? `<text x="24" y="${signY}" font-size="13" font-weight="bold" fill="${K.purple}">ボスの気配! 気配を使わずに挑めて、経験値 1.5 倍</text>`
603 : `<text x="24" y="${signY}" font-size="12" fill="${K.weak}">${L >= boss.level ? 'ボスは時々湧く' : `Lv ${boss.level} から湧く`} (毎ターン 5%・${BOSS_SPAWN.every} ターンごとに必ず)</text>`
604 s += `<text x="24" y="${signY + 20}" font-size="12" fill="${K.weak}">ボスからは逃げられない。スキルと SP で備えよう</text>`
605 return svgDoc(308, s)
606}
607
608/** 戦いのタブの行 (terminal と Svg の alt) */
609export function battleLines(h: RpgHero, b: RpgBattle | null): Line[] {
610 if (!b) {
611 const boss = nextBoss(h)
612 return [
613 { text: `魔物の気配 ${h.encounters}/${MAX_ENCOUNTERS} (${h.wins} 勝 / ${h.battles} 戦)`, isBold: true },
614 { text: 'ターンを終えるたびに気配が 1 つたまる', isDim: true },
615 {
616 text: isAllBossesDown(h) ? `ボスは全部倒した (もう一度: ${boss.name})` : `次のボス: Lv ${boss.level} ${boss.name}`,
617 color: 'magenta',
618 },
619 h.bossSign > 0
620 ? { text: 'ボスの気配! 気配を使わずに挑めて、経験値 1.5 倍', isBold: true, color: 'magenta' }
621 : { text: `ボスは時々湧く (毎ターン 5%・${BOSS_SPAWN.every} ターンごとに必ず)`, isDim: true },
622 ]
623 }
624 const en = b.enemy
625 const me = b.me
626 return [
627 { text: `${en.isBoss ? 'BOSS ' : ''}${en.name} Lv ${en.level} HP ${en.hp}/${en.maxHp}`, isBold: true, color: 'red' },
628 { text: `勇者 HP ${me.hp}/${me.maxHp} MP ${me.mp}/${me.maxMp} ラウンド ${b.round}`, color: me.hp / me.maxHp > 0.25 ? undefined : 'red' },
629 ...b.log.map((t, i): Line => ({ text: t, isDim: i < b.log.length - 1 })),
630 ...(b.result !== 'fight' ? [{ text: RESULT_TEXT[b.result], isBold: true, color: b.result === 'won' ? 'yellow' : undefined }] : []),
631 ]
632}
633
634// ---------------------------------------------------------------- 成長の画面
635
636/** 成長のタブ: 残りの SP と内訳、能力の振り分け、スキルの一覧 */
637export function growthSvg(h: RpgHero) {
638 paint()
639 const st = statsOf(h)
640 const sp = st.sp
641 const L = st.level
642 let s = win(8, 8, 364, 84)
643 s += `<text x="24" y="34" font-size="15" font-weight="bold" fill="${K.text}">▶ 成長</text>`
644 s += `<text x="358" y="40" font-size="26" font-weight="bold" font-family="${MONO}" fill="${sp.free > 0 ? K.yellow : K.offText}" text-anchor="end">${sp.free} SP</text>`
645 s += `<text x="24" y="60" font-size="12" fill="${K.weak}">レベル ${sp.fromLevel} + 実績 ${sp.fromAchievements} + 勝利 ${sp.fromWins} + ボス ${sp.fromBosses} + 深さ ${sp.fromDeep}</text>`
646 s += `<text x="24" y="80" font-size="12" fill="${K.weak}">もらった ${sp.earned} / 使った ${sp.used}</text>`
647
648 // ── 能力
649 s += win(8, 100, 364, 36 + STATS.length * 26)
650 s += `<text x="24" y="124" font-size="13" font-weight="bold" fill="${K.text}">能力 (1 SP で 1 上がる)</text>`
651 STATS.forEach((k, i) => {
652 const y = 150 + i * 26
653 const n = h.alloc[k]
654 s += `<text x="24" y="${y}" font-size="13" font-weight="bold" fill="${K.text}">${STAT_LABEL[k]}</text>`
655 s += `<text x="100" y="${y}" font-size="14" font-weight="bold" font-family="${MONO}" fill="${n > 0 ? K.gear : K.offText}" text-anchor="end">${n}</text>`
656 s += `<text x="114" y="${y}" font-size="12" fill="${K.weak}">1 につき ${STAT_NOTE[k]}</text>`
657 })
658
659 // ── スキル
660 let y = 100 + 36 + STATS.length * 26 + 8
661 const top = y
662 let body = `<text x="24" y="${y + 24}" font-size="13" font-weight="bold" fill="${K.text}">スキル</text>`
663 body += `<text x="358" y="${y + 24}" font-size="12" fill="${K.weak}" text-anchor="end">${Object.keys(h.learned).length} / ${SKILLS.length}</text>`
664 y += 34
665 for (const sk of SKILLS) {
666 const has = h.learned[sk.id] !== undefined
667 const canLv = L >= sk.level
668 const col = has ? K.yellow : canLv ? K.text : K.offText
669 const state = has ? '習得済み' : canLv ? (sp.free >= sk.sp ? '覚えられる' : `あと ${sk.sp - sp.free} SP`) : `Lv ${sk.level} から`
670 body +=
671 `<line x1="20" y1="${y}" x2="360" y2="${y}" stroke="${K.frame}" stroke-opacity="0.15"/>` +
672 `<text x="24" y="${y + 17}" font-size="13" font-weight="bold" fill="${col}">${esc(sk.name)}</text>` +
673 `<text x="358" y="${y + 17}" font-size="12"${!has && canLv && sp.free >= sk.sp ? ` font-weight="bold" fill="${K.green}"` : ` fill="${col}"`} text-anchor="end">${state}</text>` +
674 `<text x="24" y="${y + 33}" font-size="12" fill="${has || canLv ? K.weak : K.offText}">${esc(sk.note)}</text>` +
675 `<text x="358" y="${y + 33}" font-size="12" font-family="${MONO}" fill="${has || canLv ? K.weak : K.offText}" text-anchor="end">${sk.isPassive ? '常時' : `MP ${sk.mp}`} ${sk.sp}SP</text>`
676 y += 40
677 }
678 s += win(8, top, 364, y - top + 8) + body
679 return svgDoc(y + 16, s)
680}
681
682export function growthLines(h: RpgHero): Line[] {
683 const st = statsOf(h)
684 const sp = st.sp
685 return [
686 { text: `SP ${sp.free} (もらった ${sp.earned} / 使った ${sp.used})`, isBold: true, color: sp.free > 0 ? 'yellow' : undefined },
687 { text: `内訳 レベル ${sp.fromLevel} + 実績 ${sp.fromAchievements} + 勝利 ${sp.fromWins} + ボス ${sp.fromBosses} + 深さ ${sp.fromDeep}`, isDim: true },
688 { text: STATS.map(k => `${STAT_LABEL[k]} ${h.alloc[k]}`).join(' ') },
689 ...SKILLS.map((sk): Line => {
690 const has = h.learned[sk.id] !== undefined
691 const canLv = st.level >= sk.level
692 return {
693 text: `${has ? '●' : '○'} ${sk.name} (${sk.isPassive ? '常時' : `MP ${sk.mp}`} / ${sk.sp}SP) ${has ? '' : canLv ? '' : `Lv ${sk.level} から `}${sk.note}`,
694 color: has ? 'yellow' : undefined,
695 isDim: !has && !canLv,
696 }
697 }),
698 ]
699}
700hooks/dungeon.ts 321 lines1// ダンジョン: ターンで潜る階・階ごとの敵 (戦いは battle.ts の act をそのまま使って自動で進める)・袋と持ち帰り・絵。どれも純関数
2import type { RpgBattle, RpgDelve, RpgDelveFight, RpgEnemy, RpgHero } from '../types'
3import type { Command } from './battle'
4import { BOSSES, ENEMIES, act, canUse, enemyOf, fighterOf, monster, openBattle, rngOf } from './battle'
5import type { Line } from './rpg'
6import { K, MONO, SP_RULE, addDone, bar, gain, paint, sprite, svgDoc, win } from './rpg'
7import { esc, isLight, short } from './style'
8
9// ---------------------------------------------------------------- 釣り合いの表 (数はここだけ)
10
11/**
12 * ふつうの 1 日 (60〜120 ターン) で 7〜15 階ほど進む (steps)。
13 * 敵のレベルは階の 2 倍、10 階ごとの番人はボスの姿。勇者のレベル・振った SP・装備・スキルで、どこまで勝ち抜けるかが決まる。
14 * 試しの数 (1 ターン 22 経験値・90 ターンの日・毎日帰る): 1 日目 6 階、SP を全部使うと 7 日で 41 階・28 日で 126 階、
15 * スキルだけだと 29 階・79 階、SP を使わないと 19 階・29 階で番人に止まる。Lv 99 でも 150 階ほどで止まる
16 */
17export const DELVE = {
18 /** 次の階の敵まで、これだけターンを終える */
19 steps: 8,
20 /** 1 歩ごとに戻る HP と MP (%) */
21 regenHp: 5,
22 regenMp: 8,
23 /** 敵のレベル = 階 × これ (端数は切り上げ) */
24 levelPerFloor: 2,
25 /** この階ごとに休み場。勝ち抜いた一番深い休み場の次の階から潜り直せる */
26 restEvery: 5,
27 /** この階ごとに番人 (ボスの姿) */
28 guardEvery: 10,
29 /** 番人の HP はボスの何倍か (ボスは普通の敵の 4〜5 倍) */
30 guardHp: 0.6,
31 /** 袋に入る経験値 = 敵の経験値 × これ (ターンの経験値の 2〜3 割になるように) */
32 lootRate: 0.3,
33 /** 力尽きた時に持ち帰れる袋の割合 */
34 lostKeep: 0.5,
35 /** 1 つの戦いのラウンドの上限 (超えたら力尽きた扱い) */
36 maxRounds: 300,
37} as const
38
39// ---------------------------------------------------------------- 階と敵
40
41export const isGuardFloor = (floor: number) => floor % DELVE.guardEvery === 0
42
43/** 勝ち抜いた一番深い休み場の次の階 (潜り直せる階)。まだなら 1 */
44export const checkpoint = (deepest: number) => Math.floor(deepest / DELVE.restEvery) * DELVE.restEvery + 1
45
46/** その階の敵。種と階で決まる (同じ潜りの同じ階なら同じ敵) */
47export function floorEnemy(floor: number, seed: number): RpgEnemy {
48 const level = Math.max(1, Math.ceil(floor * DELVE.levelPerFloor))
49 const r = rngOf((seed ^ Math.imul(floor, 2654435761)) >>> 0)
50 if (isGuardFloor(floor)) {
51 const kind = BOSSES[(floor / DELVE.guardEvery - 1) % BOSSES.length]!
52 const e = enemyOf(kind, level, true)
53 const maxHp = Math.max(1, Math.round(e.maxHp * DELVE.guardHp))
54 return { ...e, name: `番人 ${kind.name}`, maxHp, hp: maxHp }
55 }
56 const pool = ENEMIES.filter(k => k.level <= level)
57 const kind = pool[Math.floor(r.next() * pool.length)] ?? ENEMIES[0]!
58 return enemyOf(kind, level, false)
59}
60
61export const lootOf = (enemy: RpgEnemy) => Math.max(1, Math.round(enemy.xp * DELVE.lootRate))
62
63// ---------------------------------------------------------------- 自動の戦い
64
65/** 戦いの 1 手を選ぶ: 危なければ回復、番人には盾、硬い敵には弱点、あとは一番効く技 (回復の MP は残す) */
66export function pickCommand(b: RpgBattle, learned: Record<string, number>): Command {
67 const can = (id: string) => canUse(b, id, learned)
68 const skill = (id: string): Command => ({ kind: 'skill', id })
69 const me = b.me
70 const en = b.enemy
71 const hp = me.hp / me.maxHp
72 if (hp < 0.3 && can('rollback')) return skill('rollback')
73 if (hp < 0.45 && can('heal')) return skill('heal')
74 if (en.isBoss && b.guard === 0 && can('guard')) return skill('guard')
75 if (!b.isExposed && en.hp > en.maxHp * 0.5 && en.def * 0.5 > me.atk * 0.15 && can('scan')) return skill('scan')
76 // 回復を覚えていれば、その分の MP は残す (とどめなら使い切る)
77 const reserve = learned.heal !== undefined ? 6 : 0
78 const def = en.def * (b.isExposed ? 0.6 : 1) * 0.5
79 const hit = (m: number) => Math.max(1, me.atk * m - def) * (en.trait === 'evade' ? 0.75 : 1)
80 const options: [Command, number, number][] = [
81 [skill('deploy'), me.mag * 3, 24],
82 [skill('multi'), hit(0.7) * 3, 10],
83 [skill('lint'), me.mag * 1.6, 7],
84 [skill('slash'), hit(1.7), 4],
85 ]
86 let best: Command = { kind: 'attack' }
87 let bestDmg = hit(1)
88 for (const [cmd, dmg, mp] of options) {
89 if (cmd.kind !== 'skill' || !can(cmd.id) || dmg <= bestDmg) continue
90 if (me.mp - mp < reserve && dmg < en.hp) continue
91 best = cmd
92 bestDmg = dmg
93 }
94 return best
95}
96
97/** 勝つか力尽きるまで自動で戦う */
98export function autoFight(b0: RpgBattle, learned: Record<string, number>): RpgBattle {
99 let b = b0
100 for (let i = 0; i < DELVE.maxRounds && b.result === 'fight'; i++) b = act(b, pickCommand(b, learned), learned)
101 return b.result === 'fight' ? { ...b, result: 'lost' } : b
102}
103
104// ---------------------------------------------------------------- 潜る・進む・帰る
105
106/** 潜り始める (from の階から)。HP と MP は満タン、袋は空 */
107export const enterDelve = (h: RpgHero, now: number, from: number): RpgHero => ({
108 ...h,
109 delve: {
110 startedAt: now,
111 floor: Math.max(1, Math.min(from, checkpoint(h.deepest))),
112 step: 0,
113 hp: 100,
114 mp: 100,
115 loot: 0,
116 cleared: 0,
117 seed: (now ^ Math.imul(h.turns + 1, 2654435761)) >>> 0,
118 last: null,
119 },
120})
121
122/** 潜りを終える: 経験値を持ち帰り、入口に戻り、終えた印を残す (別のセッションがこの潜りを書き戻さない・もう一度持ち帰らない) */
123const finish = (h: RpgHero, d: RpgDelve, xp: number): RpgHero => ({ ...gain(h, xp), delve: null, doneDelves: addDone(h.doneDelves, { id: d.startedAt, xp }) })
124
125/** 帰る: 袋の経験値を持ち帰る */
126export const leaveDelve = (h: RpgHero): RpgHero => (h.delve ? finish(h, h.delve, h.delve.loot) : h)
127
128/** 1 歩の出来事。won / lost は戦った時だけ */
129export type DelveEvent = { kind: 'step' } | { kind: 'won'; fight: RpgDelveFight } | { kind: 'lost'; fight: RpgDelveFight; kept: number }
130
131const pct = (n: number, max: number) => Math.max(0, Math.min(100, Math.round((n / Math.max(1, max)) * 100)))
132
133/**
134 * ターンを 1 つ終えた時の 1 歩。潜っていなければ何もしない。
135 * 歩みが DELVE.steps に届いたら、その階の敵と自動で戦う (worn: 持っている装備だけにした勇者。強さはこちらから取る)。
136 * 勝てば次の階へ、袋に経験値。力尽きたら入口へ戻り、袋の半分を持ち帰る。一番深い階は勝った時に残す
137 */
138export function stepDelve(h: RpgHero, worn: RpgHero): { hero: RpgHero; event?: DelveEvent } {
139 const d0 = h.delve
140 if (!d0) return { hero: h }
141 const d: RpgDelve = { ...d0, step: d0.step + 1, hp: Math.min(100, d0.hp + DELVE.regenHp), mp: Math.min(100, d0.mp + DELVE.regenMp) }
142 if (d.step < DELVE.steps) return { hero: { ...h, delve: d }, event: { kind: 'step' } }
143 const enemy = floorEnemy(d.floor, d.seed)
144 const f = fighterOf(worn)
145 const me = { ...f, hp: Math.max(1, Math.round((f.maxHp * d.hp) / 100)), mp: Math.round((f.maxMp * d.mp) / 100) }
146 const start = openBattle(me, enemy, (d.seed ^ Math.imul(d.floor, 40503)) >>> 0, `${enemy.name}があらわれた!`)
147 const b = autoFight(start, worn.learned)
148 const won = b.result === 'won'
149 const loot = won ? lootOf(enemy) : 0
150 const fight: RpgDelveFight = {
151 floor: d.floor,
152 name: enemy.name,
153 level: enemy.level,
154 isBoss: enemy.isBoss,
155 shape: enemy.shape,
156 color: enemy.color,
157 result: won ? 'won' : 'lost',
158 hitMe: Math.max(0, me.hp - b.me.hp),
159 loot,
160 }
161 if (!won) {
162 const kept = Math.floor(d.loot * DELVE.lostKeep)
163 return { hero: finish(h, d, kept), event: { kind: 'lost', fight, kept } }
164 }
165 const next: RpgDelve = {
166 ...d,
167 floor: d.floor + 1,
168 step: 0,
169 hp: pct(b.me.hp, b.me.maxHp),
170 mp: pct(b.me.mp, b.me.maxMp),
171 loot: d.loot + loot,
172 cleared: d.cleared + 1,
173 last: fight,
174 }
175 return { hero: { ...h, delve: next, deepest: Math.max(h.deepest, d.floor) }, event: { kind: 'won', fight } }
176}
177
178/** 出来事の一言 */
179export function eventText(e: DelveEvent | undefined): string | undefined {
180 if (!e || e.kind === 'step') return undefined
181 const f = e.fight
182 if (e.kind === 'won') return `ダンジョン ${f.floor} 階: ${f.name}を倒した (袋に経験値 +${f.loot})`
183 return `ダンジョン ${f.floor} 階で${f.name}に敗れた… 入口に戻り、袋の経験値 ${e.kept} を持ち帰った`
184}
185
186// ---------------------------------------------------------------- 絵
187
188/** たいまつ (炎が揺れる) */
189const torch = (x: number, y: number) =>
190 `<rect x="${x - 2}" y="${y}" width="4" height="14" fill="#8b5a2b"/>` +
191 `<ellipse cx="${x}" cy="${y - 4}" rx="5" ry="8" fill="#f78166" opacity="0.9">` +
192 `<animate attributeName="ry" values="8;6;9;7;8" dur="0.9s" repeatCount="indefinite"/></ellipse>` +
193 `<ellipse cx="${x}" cy="${y - 2}" rx="2.5" ry="4" fill="#f2cc60"/>` +
194 `<circle cx="${x}" cy="${y - 4}" r="22" fill="#f78166" opacity="0.08"/>`
195
196/** 石の通路 (奥へすぼまる)。y から h の高さ */
197function corridor(y: number, h: number) {
198 const x0 = 20
199 const x1 = 360
200 const cx = 190
201 const vy = y + h * 0.45
202 let s = `<rect x="${x0}" y="${y}" width="${x1 - x0}" height="${h}" fill="${K.well}"/>`
203 // 奥の暗がり
204 s += `<rect x="${cx - 46}" y="${vy - 34}" width="92" height="64" fill="${isLight() ? K.frame : '#000000'}" opacity="${isLight() ? 0.18 : 0.35}"/>`
205 // 床と天井の線
206 s += `<polygon points="${x0},${y + h} ${x1},${y + h} ${cx + 46},${vy + 30} ${cx - 46},${vy + 30}" fill="${K.win}" opacity="0.7"/>`
207 for (const [a, b] of [
208 [x0, cx - 46],
209 [x1, cx + 46],
210 ] as const) {
211 s += `<line x1="${a}" y1="${y}" x2="${b}" y2="${vy - 34}" stroke="${K.frame}" stroke-opacity="0.3"/>`
212 s += `<line x1="${a}" y1="${y + h}" x2="${b}" y2="${vy + 30}" stroke="${K.frame}" stroke-opacity="0.3"/>`
213 }
214 // 壁の石 (左右に 3 段)
215 for (let i = 0; i < 3; i++) {
216 const yy = y + 14 + i * 30
217 s += `<rect x="${x0 + 8 + (i % 2) * 14}" y="${yy}" width="34" height="14" fill="none" stroke="${K.frame}" stroke-opacity="0.18"/>`
218 s += `<rect x="${x1 - 42 - (i % 2) * 14}" y="${yy}" width="34" height="14" fill="none" stroke="${K.frame}" stroke-opacity="0.18"/>`
219 }
220 return s + torch(70, y + 26) + torch(310, y + 26)
221}
222
223/** 歩みの点 (DELVE.steps 個) */
224function stepPips(x: number, y: number, step: number) {
225 let s = ''
226 for (let i = 0; i < DELVE.steps; i++) {
227 const has = i < step
228 s += `<rect x="${x + i * 18}" y="${y}" width="12" height="12" rx="2" fill="${has ? K.yellow : K.off}" stroke="${has ? K.frame : K.offText}" stroke-opacity="0.6"/>`
229 }
230 return s
231}
232
233/** ダンジョンのタブ。入口 (潜っていない) と、潜っている時 */
234export function dungeonSvg(h: RpgHero) {
235 paint()
236 const d = h.delve
237 const cp = checkpoint(h.deepest)
238 let s = win(8, 8, 364, 56)
239 s += `<text x="24" y="34" font-size="15" font-weight="bold" fill="${K.text}">▶ ${d ? `ダンジョン 地下 ${d.floor} 階` : 'ダンジョンの入口'}</text>`
240 s += `<text x="358" y="34" font-size="12" fill="${K.weak}" text-anchor="end">記録 ${h.deepest} 階</text>`
241 s += `<text x="24" y="54" font-size="12" fill="${K.weak}">${d ? `この潜りで ${d.cleared} 階を勝ち抜いた` : `ターンを終えるたびに 1 歩、${DELVE.steps} 歩で次の階の敵と戦う`}</text>`
242
243 // ── 絵: 石の通路。潜っていれば勇者と、近づいてくるこの階の敵
244 s += win(8, 72, 364, 150)
245 s += corridor(84, 126)
246 if (d) {
247 const en = floorEnemy(d.floor, d.seed)
248 // 歩むほど敵が手前に来て大きくなる
249 const t = d.step / DELVE.steps
250 const px = 3 + Math.round(t * 3)
251 const ex = 230 - Math.round(t * 30)
252 const ey = 168 - px * 12 + Math.round(t * 8)
253 s += `<g opacity="${(0.45 + t * 0.55).toFixed(2)}">${monster(en.shape, en.color, ex, ey, px)}</g>`
254 if (isGuardFloor(d.floor)) s += `<text x="190" y="102" font-size="13" font-weight="bold" fill="${K.purple}" text-anchor="middle">番人の階</text>`
255 s += sprite(60, 150, 4)
256 } else {
257 // 下りの階段
258 for (let i = 0; i < 5; i++) {
259 const w = 120 - i * 16
260 s += `<rect x="${190 - w / 2}" y="${150 + i * 12}" width="${w}" height="10" fill="${K.win}" stroke="${K.frame}" stroke-opacity="0.35"/>`
261 }
262 s += sprite(60, 150, 4)
263 }
264
265 // ── 潜っている間の HP・MP・歩み・袋
266 let y = 230
267 if (d) {
268 s += win(8, y, 364, 120)
269 const row = (yy: number, label: string, frac: number, color: string, value: string) =>
270 `<text x="24" y="${yy + 11}" font-size="14" font-weight="bold" font-family="${MONO}" fill="${K.text}">${label}</text>` +
271 bar(64, yy, 210, frac, color) +
272 `<text x="358" y="${yy + 11}" font-size="13" font-family="${MONO}" fill="${K.text}" text-anchor="end">${value}</text>`
273 s += row(y + 16, 'HP', d.hp / 100, d.hp > 50 ? K.green : d.hp > 25 ? K.yellow : K.red, `${d.hp}%`)
274 s += row(y + 40, 'MP', d.mp / 100, K.blue, `${d.mp}%`)
275 s += `<text x="24" y="${y + 80}" font-size="13" font-weight="bold" fill="${K.text}">歩み</text>`
276 s += stepPips(64, y + 69, d.step)
277 s += `<text x="358" y="${y + 80}" font-size="12" font-family="${MONO}" fill="${K.weak}" text-anchor="end">${d.step}/${DELVE.steps}</text>`
278 s += `<text x="24" y="${y + 106}" font-size="13" fill="${K.text}">袋 <tspan font-weight="bold" fill="${K.yellow}">経験値 ${d.loot}</tspan></text>`
279 s += `<text x="358" y="${y + 106}" font-size="12" fill="${K.weak}" text-anchor="end">帰ると持ち帰る</text>`
280 y += 128
281 // 最後の戦い
282 const f = d.last
283 s += win(8, y, 364, 56)
284 s += f
285 ? `<text x="24" y="${y + 24}" font-size="13" fill="${K.text}">${f.floor} 階 ${esc(short(f.name, 14))} Lv ${f.level}</text>` +
286 `<text x="358" y="${y + 24}" font-size="13" font-weight="bold" fill="${K.yellow}" text-anchor="end">勝利</text>` +
287 `<text x="24" y="${y + 44}" font-size="12" fill="${K.weak}">受けた傷 ${f.hitMe} / 袋に経験値 +${f.loot}</text>`
288 : `<text x="24" y="${y + 34}" font-size="12" fill="${K.weak}">まだ戦っていない。作業を進めると敵に近づく</text>`
289 y += 64
290 } else {
291 s += win(8, y, 364, 120)
292 s += `<text x="24" y="${y + 24}" font-size="13" font-weight="bold" fill="${K.text}">潜り方</text>`
293 s += `<text x="24" y="${y + 46}" font-size="12" fill="${K.weak}">階ごとの敵とは自動で戦う。装備・SP・スキルが効く</text>`
294 s += `<text x="24" y="${y + 66}" font-size="12" fill="${K.weak}">${DELVE.guardEvery} 階ごとに番人。勝ち抜くと ${SP_RULE.deepPer} 階ごとに SP +${SP_RULE.deep}</text>`
295 s += `<text x="24" y="${y + 86}" font-size="12" fill="${K.weak}">「帰る」で袋の経験値を持ち帰る。力尽きると半分</text>`
296 s += `<text x="24" y="${y + 104}" font-size="12" fill="${cp > 1 ? K.gear : K.weak}">${cp > 1 ? `${cp - 1} 階の休み場を越えたので ${cp} 階から潜れる` : `${DELVE.restEvery} 階ごとの休み場から潜り直せる`}</text>`
297 y += 128
298 }
299 return svgDoc(y, s)
300}
301
302export function dungeonLines(h: RpgHero): Line[] {
303 const d = h.delve
304 if (!d) {
305 const cp = checkpoint(h.deepest)
306 return [
307 { text: `ダンジョンの入口 (記録 ${h.deepest} 階)`, isBold: true },
308 { text: `ターンを終えるたびに 1 歩、${DELVE.steps} 歩で次の階の敵と自動で戦う`, isDim: true },
309 { text: `${DELVE.guardEvery} 階ごとに番人。「帰る」で袋の経験値を持ち帰る`, isDim: true },
310 ...(cp > 1 ? [{ text: `${cp} 階から潜れる`, color: 'cyan' }] : []),
311 ]
312 }
313 const f = d.last
314 return [
315 { text: `ダンジョン 地下 ${d.floor} 階 (記録 ${h.deepest} 階)${isGuardFloor(d.floor) ? ' 番人の階' : ''}`, isBold: true },
316 { text: `HP ${d.hp}% MP ${d.mp}% 歩み ${d.step}/${DELVE.steps}`, color: d.hp > 25 ? undefined : 'red' },
317 { text: `袋 経験値 ${d.loot} (帰ると持ち帰る)`, color: 'yellow' },
318 f ? { text: `${f.floor} 階 ${f.name} Lv ${f.level} に勝利 (受けた傷 ${f.hitMe})`, isDim: true } : { text: 'まだ戦っていない', isDim: true },
319 ]
320}
321hooks/rpg.ts 1085 lines1// 経験値・レベル・実績・成長 (SP) の計算と、ステータス窓の SVG / terminal 用の行。どれも純関数
2import type { RpgDelve, RpgDelveFight, RpgDoneDelve, RpgEquip, RpgGachaCard, RpgHero, RpgSaved, RpgSession, RpgSlot, RpgStat } from '../types'
3import { clean, esc, isLight, jstDay, short } from './style'
4
5// ---------------------------------------------------------------- 決まりごと
6
7export const XP = { turn: 10, recovery: 15, edit: 5, shell: 4, read: 2, agent: 6, other: 2 } as const
8/** これだけ間が空いたら「休んだ」として HP が戻る */
9export const REST_MS = 15 * 60_000
10/** HP は休んでから 2 分ごとに 1% 減る (戦いの傷はその上に減る)。10% より下には減らない */
11const HP_STEP_MS = 2 * 60_000
12const HP_MIN = 10
13export const MAX_LEVEL = 99
14
15/** レベル L から L+1 に上がるのに要る経験値。少しずつ増える */
16export const needFor = (level: number) => 40 + 20 * level
17/** レベル L になるまでの累計 */
18export const xpToReach = (level: number) => 40 * (level - 1) + 10 * level * (level - 1)
19
20export function levelInfo(xp: number) {
21 let level = 1
22 while (level < MAX_LEVEL && xp >= xpToReach(level + 1)) level++
23 const into = xp - xpToReach(level)
24 const need = needFor(level)
25 return { level, into, need, frac: level >= MAX_LEVEL ? 1 : Math.min(1, into / need) }
26}
27
28export const TITLES: [number, string][] = [
29 [1, '見習いコーダー'],
30 [3, '駆け出しデバッガー'],
31 [5, '一人前のプログラマー'],
32 [8, 'バグハンター'],
33 [12, 'テストの守り人'],
34 [16, 'リファクタ剣士'],
35 [20, 'コードの魔術師'],
36 [25, '設計の賢者'],
37 [30, 'ビルドの錬金術師'],
38 [38, 'マージの聖騎士'],
39 [47, '千のコミットの勇者'],
40 [60, '伝説のリファクタラー'],
41]
42export const titleOf = (level: number) => TITLES.filter(([lv]) => level >= lv).pop()?.[1] ?? TITLES[0]![1]
43
44export type ToolKind = 'edit' | 'shell' | 'read' | 'agent' | 'other'
45const KINDS: Record<string, ToolKind> = {
46 Edit: 'edit',
47 Write: 'edit',
48 MultiEdit: 'edit',
49 NotebookEdit: 'edit',
50 Bash: 'shell',
51 PowerShell: 'shell',
52 Read: 'read',
53 Grep: 'read',
54 Glob: 'read',
55 WebFetch: 'read',
56 WebSearch: 'read',
57 LSP: 'read',
58 Agent: 'agent',
59 Task: 'agent',
60}
61export const toolKind = (tool: string): ToolKind => KINDS[tool] ?? 'other'
62
63// ---------------------------------------------------------------- 装備 (turn-gacha のカード)
64
65export const SLOTS: readonly RpgSlot[] = ['weapon', 'armor', 'charm']
66export const SLOT_LABEL: Record<RpgSlot, string> = { weapon: '武器', armor: '防具', charm: 'お守り' }
67const RARITIES = ['SSR', 'SR', 'R', 'N'] as const
68type Rarity = (typeof RARITIES)[number]
69/** レア度ごとの上がり幅。武器は攻撃力に、防具は最大 HP に 5 倍、お守りは魔力と最大 MP に 3 倍で足す */
70export const GEAR_POINTS: Record<Rarity, number> = { N: 1, R: 2, SR: 4, SSR: 7 }
71const isRarity = (v: unknown): v is Rarity => typeof v === 'string' && (RARITIES as readonly string[]).includes(v)
72const isSlot = (v: unknown): v is RpgSlot => typeof v === 'string' && (SLOTS as readonly string[]).includes(v)
73
74/** 外から来たカード 1 枚を確かめる (名前は制御文字を落として 40 字まで) */
75function cardFrom(v: unknown): RpgGachaCard | undefined {
76 if (typeof v !== 'object' || v === null) return undefined
77 const o = v as Record<string, unknown>
78 if (typeof o.id !== 'string' || o.id === '' || o.id.length > 32 || !isRarity(o.rarity) || !isSlot(o.slot) || typeof o.name !== 'string') return undefined
79 return { id: clean(o.id), rarity: o.rarity, slot: o.slot, name: short(clean(o.name), 40) }
80}
81
82/**
83 * turn-gacha の state (save と cards) から、持っているカードの一覧を作る。
84 * turn-gacha が無い・まだ読み込む前・形が違う時は undefined
85 */
86export function ownedCards(save: unknown, cards: unknown): RpgGachaCard[] | undefined {
87 if (typeof save !== 'object' || save === null || !Array.isArray(cards)) return undefined
88 const book = (save as Record<string, unknown>).book
89 if (typeof book !== 'object' || book === null) return undefined
90 const has = (id: string) => {
91 const n = ((book as Record<string, unknown>)[id] as Record<string, unknown> | undefined)?.count
92 return typeof n === 'number' && n > 0
93 }
94 return cards.map(cardFrom).filter((c): c is RpgGachaCard => c !== undefined && has(c.id))
95}
96
97/** 装備のうち、今も持っている物だけ。ガチャが無ければ何も着けていない扱い */
98export const wornOf = (equip: RpgEquip, owned: RpgGachaCard[] | undefined): RpgEquip => {
99 const out: RpgEquip = {}
100 for (const k of SLOTS) {
101 const c = equip[k]
102 if (c && owned?.some(o => o.id === c.id)) out[k] = c
103 }
104 return out
105}
106
107/** 画面に出す前に、持っていない装備を外した姿にする (store の equip は残す) */
108export const wear = (h: RpgHero, owned: RpgGachaCard[] | undefined): RpgHero => ({ ...h, worn: wornOf(h.equip, owned) })
109
110/** 枠に着ける (card が無ければ外す) */
111export const setGear = (h: RpgHero, slot: RpgSlot, card: RpgGachaCard | undefined): RpgHero => {
112 const equip = { ...h.equip }
113 if (card) equip[slot] = { id: card.id, rarity: card.rarity, slot, name: card.name }
114 else delete equip[slot]
115 return { ...h, equip }
116}
117
118/** 装備で上がる分 */
119export function gearBonus(equip: RpgEquip) {
120 const p = (k: RpgSlot) => {
121 const c = equip[k]
122 return c ? GEAR_POINTS[c.rarity] : 0
123 }
124 return { attack: p('weapon'), hp: p('armor') * 5, magic: p('charm'), mp: p('charm') * 3 }
125}
126
127/** その枠の効き目の言い方 */
128export const gearEffect = (slot: RpgSlot, rarity: Rarity) => {
129 const n = GEAR_POINTS[rarity]
130 return slot === 'weapon' ? `攻撃力 +${n}` : slot === 'armor' ? `最大HP +${n * 5}` : `魔力 +${n} 最大MP +${n * 3}`
131}
132
133const sameEquip = (a: RpgEquip, b: RpgEquip) => SLOTS.every(k => a[k]?.id === b[k]?.id)
134
135// ---------------------------------------------------------------- daily-quest のごほうび
136
137/** 受け取ったお題 (`${date}:${id}`) を覚えておく数 */
138export const KEEP_CLAIMED = 60
139/** 1 つのお題でもらえる経験値の上限 (外から来る数は信用しない) */
140const MAX_QUEST_XP = 500
141
142/**
143 * daily-quest の state の today から、こなしたお題のうちまだ受け取っていない物。
144 * daily-quest が無い・形が違う時は空。題名は制御文字を落として短くする
145 */
146export function questRewards(today: unknown, claimed: readonly string[]): { key: string; title: string; exp: number }[] {
147 if (typeof today !== 'object' || today === null) return []
148 const { date, quests } = today as { date?: unknown; quests?: unknown }
149 if (typeof date !== 'string' || !Array.isArray(quests)) return []
150 return quests.flatMap(q => {
151 if (typeof q !== 'object' || q === null) return []
152 const { id, title, done, reward } = q as Record<string, unknown>
153 const exp = (reward as { exp?: unknown } | undefined)?.exp
154 if (done !== true || typeof id !== 'string' || typeof exp !== 'number' || !Number.isFinite(exp)) return []
155 const key = clean(`${date}:${id}`).slice(0, 80)
156 if (claimed.includes(key)) return []
157 return [{ key, title: short(clean(String(title ?? '')), 30), exp: Math.max(0, Math.min(MAX_QUEST_XP, Math.floor(exp))) }]
158 })
159}
160
161/** store の questClaimed を読む (壊れていれば空) */
162export const claimedFrom = (raw: unknown): string[] => (Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string').slice(-KEEP_CLAIMED) : [])
163
164// ---------------------------------------------------------------- 成長 (SP で能力とスキル)
165
166export const STATS: readonly RpgStat[] = ['str', 'vit', 'int', 'agi']
167export const STAT_LABEL: Record<RpgStat, string> = { str: '力', vit: '体力', int: '知恵', agi: '敏捷' }
168/** 1 振りで上がる分 */
169export const STAT_NOTE: Record<RpgStat, string> = {
170 str: '攻撃力 +2',
171 vit: '最大HP +10 防御力 +2',
172 int: '魔力 +2 最大MP +4',
173 agi: '素早さ +2 会心 +1%',
174}
175
176export type Skill = {
177 id: string
178 name: string
179 /** 覚えられるレベル */
180 level: number
181 /** 覚えるのに要る SP */
182 sp: number
183 /** 戦いで使う時の MP (常に効くスキルは 0) */
184 mp: number
185 isPassive?: true
186 note: string
187}
188
189export const SKILLS: readonly Skill[] = [
190 { id: 'slash', name: 'リファクタ斬り', level: 2, sp: 2, mp: 4, note: '攻撃力の 1.7 倍で斬る' },
191 { id: 'heal', name: 'デバッグの光', level: 3, sp: 2, mp: 6, note: '最大 HP の 35% と魔力の分だけ回復' },
192 { id: 'scan', name: 'ログ解読', level: 4, sp: 1, mp: 3, note: '弱点を見抜き、敵の守りを 4 割下げる' },
193 { id: 'lint', name: '静的解析', level: 5, sp: 3, mp: 7, note: '魔力の 1.6 倍。守りも回避も効かない' },
194 { id: 'review', name: 'コードレビュー', level: 6, sp: 3, mp: 0, isPassive: true, note: '常に会心の確率 +10%' },
195 { id: 'guard', name: 'テストの盾', level: 7, sp: 3, mp: 5, note: '3 ラウンドの間、受ける傷が半分' },
196 { id: 'ci', name: '継続的インテグレーション', level: 10, sp: 4, mp: 0, isPassive: true, note: '常に毎ラウンド最大 HP の 5% 回復' },
197 { id: 'multi', name: '並列実行', level: 12, sp: 4, mp: 10, note: '攻撃力の 0.7 倍で 3 回斬る' },
198 { id: 'rollback', name: 'ロールバック', level: 18, sp: 5, mp: 15, note: 'HP を全部戻す (1 回の戦いで 1 度)' },
199 { id: 'deploy', name: '本番デプロイ', level: 25, sp: 6, mp: 24, note: '魔力の 3 倍。守りも回避も効かない' },
200]
201export const skillOf = (id: string) => SKILLS.find(s => s.id === id)
202
203/** ボスの id (並びは battle.ts の BOSSES と同じ)。倒すと SP がもらえる */
204export const BOSS_IDS = ['loop', 'outage', 'legacy'] as const
205/** SP の入り方: レベル 1 つで 2、実績 1 つで 1、3 勝ごとに 1、ボスを初めて倒すと 3、ダンジョンを 10 階勝ち抜くごとに 2 */
206export const SP_RULE = { level: 2, achievement: 1, winsPer: 3, boss: 3, deepPer: 10, deep: 2 } as const
207
208const allocSum = (h: Pick<RpgSaved, 'alloc'>) => STATS.reduce((n, k) => n + h.alloc[k], 0)
209
210// ---------------------------------------------------------------- 実績
211
212/** owned: ガチャで持っているカード (turn-gacha が無ければ undefined) */
213type Check = { h: RpgHero; level: number; isTurnEnd: boolean; owned: RpgGachaCard[] | undefined }
214export type Achievement = { id: string; name: string; glyph: string; hint: string; isGacha?: true; test: (c: Check) => boolean }
215const worn = (c: Check) => Object.values(wornOf(c.h.equip, c.owned))
216
217export const ACHIEVEMENTS: Achievement[] = [
218 { id: 'first-bash', name: '初めての Bash', glyph: '初', hint: '初めてコマンドを実行する', test: c => c.h.shells >= 1 },
219 { id: 'first-edit', name: '初めての一太刀', glyph: '刃', hint: '初めてファイルを編集する', test: c => c.h.edits >= 1 },
220 { id: 'recover-3', name: '七転び八起き', glyph: '起', hint: 'エラーから 3 回立ち直る', test: c => c.h.recoveries >= 3 },
221 { id: 'day-50', name: '一日 50 ターン', glyph: '日', hint: '同じ日に 50 ターン', test: c => c.h.dayTurns >= 50 },
222 { id: 'turns-100', name: '百戦錬磨', glyph: '百', hint: '累計 100 ターン', test: c => c.h.turns >= 100 },
223 { id: 'edits-100', name: '鍛冶屋の常連', glyph: '鍛', hint: '累計 100 回編集する', test: c => c.h.edits >= 100 },
224 { id: 'reads-300', name: '図書館の主', glyph: '書', hint: '累計 300 回調べる', test: c => c.h.reads >= 300 },
225 { id: 'combo-20', name: '怒涛の連撃', glyph: '撃', hint: '1 ターンでツールを 20 回使う', test: c => c.h.s.turnTools >= 20 },
226 {
227 id: 'flawless',
228 name: '無傷の勝利',
229 glyph: '盾',
230 hint: '1 ターンでツール 10 回以上、エラー 0',
231 test: c => c.isTurnEnd && c.h.s.turnTools >= 10 && c.h.s.turnErrors === 0,
232 },
233 { id: 'level-10', name: '二桁の壁', glyph: '十', hint: 'Lv 10 になる', test: c => c.level >= 10 },
234 { id: 'level-30', name: '歴戦の勇者', glyph: '勇', hint: 'Lv 30 になる', test: c => c.level >= 30 },
235 { id: 'sessions-10', name: '旅の常連', glyph: '旅', hint: '10 回目の冒険に出る', test: c => c.h.sessions >= 10 },
236 // ここから turn-gacha と連動
237 { id: 'gear-first', name: '初めての装備', glyph: '装', hint: 'ガチャのカードを 1 枚装備する', isGacha: true, test: c => worn(c).length >= 1 },
238 { id: 'gear-full', name: '完全武装', glyph: '鎧', hint: '武器・防具・お守りを全部着ける', isGacha: true, test: c => worn(c).length >= SLOTS.length },
239 { id: 'gear-ssr', name: '虹の装備', glyph: '虹', hint: 'SSR のカードを装備する', isGacha: true, test: c => worn(c).some(x => x.rarity === 'SSR') },
240 { id: 'book-20', name: '収集家', glyph: '集', hint: 'ガチャの図鑑を 20 種集める', isGacha: true, test: c => (c.owned?.length ?? 0) >= 20 },
241 // ここから戦いと成長 (0.3.0)
242 { id: 'battle-first', name: '初陣', glyph: '陣', hint: '魔物に初めて勝つ', test: c => c.h.wins >= 1 },
243 { id: 'battle-30', name: '歴戦の剣', glyph: '剣', hint: '魔物に 30 回勝つ', test: c => c.h.wins >= 30 },
244 { id: 'boss-first', name: '竜殺し', glyph: '竜', hint: 'ボスを初めて倒す', test: c => Object.keys(c.h.defeated).length >= 1 },
245 { id: 'boss-all', name: '世界を救う', glyph: '救', hint: 'ボスを全部倒す', test: c => BOSS_IDS.every(id => c.h.defeated[id] !== undefined) },
246 { id: 'skill-first', name: '新しい技', glyph: '技', hint: 'スキルを初めて覚える', test: c => Object.keys(c.h.learned).length >= 1 },
247 { id: 'train-10', name: '鍛錬の成果', glyph: '錬', hint: '能力に SP を 10 振る', test: c => allocSum(c.h) >= 10 },
248 // ここからダンジョン (0.6.0)
249 { id: 'deep-5', name: '地下へ', glyph: '階', hint: 'ダンジョンの 5 階を勝ち抜く', test: c => c.h.deepest >= 5 },
250 { id: 'deep-10', name: '番人越え', glyph: '番', hint: 'ダンジョン 10 階の番人を倒す', test: c => c.h.deepest >= 10 },
251]
252
253/** SP: もらった分 (内訳つき)・使った分・残り。使った分がもらった分を超えても (別のセッションと同時に使った時) 残りは 0 */
254export function spOf(h: RpgSaved) {
255 const level = levelInfo(h.xp).level
256 const fromLevel = (level - 1) * SP_RULE.level
257 const fromAchievements = ACHIEVEMENTS.filter(a => h.achievements[a.id] !== undefined).length * SP_RULE.achievement
258 const fromWins = Math.floor(h.wins / SP_RULE.winsPer)
259 const fromBosses = BOSS_IDS.filter(id => h.defeated[id] !== undefined).length * SP_RULE.boss
260 const fromDeep = Math.floor((h.deepest ?? 0) / SP_RULE.deepPer) * SP_RULE.deep
261 const earned = fromLevel + fromAchievements + fromWins + fromBosses + fromDeep
262 const used = allocSum(h) + SKILLS.reduce((n, s) => n + (h.learned[s.id] !== undefined ? s.sp : 0), 0)
263 return { earned, used, free: Math.max(0, earned - used), fromLevel, fromAchievements, fromWins, fromBosses, fromDeep }
264}
265
266/** 能力に 1 振る。SP が無ければそのまま */
267export function raiseStat(h: RpgHero, stat: RpgStat): RpgHero | undefined {
268 if (spOf(h).free < 1) return undefined
269 return { ...h, alloc: { ...h.alloc, [stat]: h.alloc[stat] + 1 } }
270}
271
272/** スキルを覚える。覚えられない理由があれば文字で返す */
273export function learnSkill(h: RpgHero, id: string, now: number): RpgHero | string {
274 const sk = skillOf(id)
275 if (!sk) return 'そのスキルはありません'
276 if (h.learned[id] !== undefined) return `「${sk.name}」はもう覚えています`
277 if (levelInfo(h.xp).level < sk.level) return `「${sk.name}」は Lv ${sk.level} で覚えられます`
278 if (spOf(h).free < sk.sp) return `SP が足りません (「${sk.name}」は ${sk.sp} SP)`
279 return { ...h, learned: { ...h.learned, [id]: now } }
280}
281
282/** 振った SP を全部戻す (覚えたスキルはそのまま) */
283export const respec = (h: RpgHero): RpgHero => ({ ...h, alloc: { str: 0, vit: 0, int: 0, agi: 0 } })
284
285// ---------------------------------------------------------------- 値
286
287export const INITIAL_SAVED: RpgSaved = {
288 xp: 0,
289 turns: 0,
290 tools: 0,
291 edits: 0,
292 reads: 0,
293 shells: 0,
294 recoveries: 0,
295 errors: 0,
296 sessions: 0,
297 achievements: {},
298 day: '',
299 dayTurns: 0,
300 equip: {},
301 battles: 0,
302 wins: 0,
303 encounters: 0,
304 alloc: { str: 0, vit: 0, int: 0, agi: 0 },
305 learned: {},
306 defeated: {},
307 bossSign: 0,
308 deepest: 0,
309 delve: null,
310 doneDelves: [],
311 epoch: 0,
312}
313
314/** 終わった潜りを覚えておく数 */
315export const KEEP_DONE = 20
316
317/** 潜りを終えた印を足す (同じ id は 1 つだけ。古い物から落とす) */
318export const addDone = (done: readonly RpgDoneDelve[], d: RpgDoneDelve): RpgDoneDelve[] =>
319 [...done.filter(x => x.id !== d.id), d].slice(-KEEP_DONE)
320
321/** 遭遇はここまでためられる */
322export const MAX_ENCOUNTERS = 5
323/** 0.3.0 より前の store から来た時の遭遇 (すぐ戦えるように) */
324const FIRST_ENCOUNTERS = 3
325
326export const freshSession = (now: number): RpgSession => ({
327 startedAt: now,
328 restedAt: now,
329 lastActiveAt: now,
330 toolOk: 0,
331 toolErr: 0,
332 edits: 0,
333 reads: 0,
334 shells: 0,
335 recoveries: 0,
336 turnTools: 0,
337 turnErrors: 0,
338 isErrorPending: false,
339 wound: 0,
340})
341
342export const INITIAL: RpgHero = { ...INITIAL_SAVED, s: freshSession(0), hpPct: 100, xpFrom: 0, synced: INITIAL_SAVED }
343
344const SAVED_KEYS = ['xp', 'turns', 'tools', 'edits', 'reads', 'shells', 'recoveries', 'errors', 'sessions', 'dayTurns', 'battles', 'wins', 'encounters', 'bossSign'] as const
345
346export const toSaved = (h: RpgHero): RpgSaved => {
347 const out = {
348 ...INITIAL_SAVED,
349 achievements: { ...h.achievements },
350 day: h.day,
351 equip: { ...h.equip },
352 alloc: { ...h.alloc },
353 learned: { ...h.learned },
354 defeated: { ...h.defeated },
355 deepest: h.deepest,
356 delve: h.delve && { ...h.delve },
357 doneDelves: h.doneDelves.map(d => ({ ...d })),
358 epoch: h.epoch,
359 }
360 for (const k of SAVED_KEYS) out[k] = h[k]
361 return out
362}
363
364/** store の終わった潜りの印。形の違う物は捨てる */
365function doneFrom(v: unknown): RpgDoneDelve[] {
366 if (!Array.isArray(v)) return []
367 const out: RpgDoneDelve[] = []
368 for (const x of v) {
369 if (typeof x !== 'object' || x === null) continue
370 const { id, xp } = x as Record<string, unknown>
371 if (finite(id) && finite(xp)) out.push({ id: Math.max(0, Math.floor(id as number)), xp: Math.max(0, Math.floor(xp as number)) })
372 }
373 return out.slice(-KEEP_DONE)
374}
375
376const DELVE_NUMS = ['startedAt', 'floor', 'step', 'hp', 'mp', 'loot', 'cleared', 'seed'] as const
377export const DELVE_KEYS: readonly string[] = [...DELVE_NUMS, 'last']
378
379const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v)
380
381function fightFrom(v: unknown): RpgDelveFight | null {
382 if (typeof v !== 'object' || v === null) return null
383 const o = v as Record<string, unknown>
384 if (!finite(o.floor) || !finite(o.level) || (o.result !== 'won' && o.result !== 'lost')) return null
385 return {
386 floor: Math.max(1, Math.floor(o.floor as number)),
387 name: short(clean(String(o.name ?? '')), 20),
388 level: Math.max(1, Math.floor(o.level as number)),
389 isBoss: o.isBoss === true,
390 shape: typeof o.shape === 'string' ? o.shape.slice(0, 12) : 'slime',
391 color: typeof o.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(o.color) ? o.color : '#3fb950',
392 result: o.result,
393 hitMe: finite(o.hitMe) ? Math.max(0, Math.floor(o.hitMe as number)) : 0,
394 loot: finite(o.loot) ? Math.max(0, Math.floor(o.loot as number)) : 0,
395 }
396}
397
398/** store のダンジョンを確かめる。形が違えば入口にいる (null) */
399export function delveFrom(v: unknown): RpgDelve | null {
400 if (typeof v !== 'object' || v === null || Array.isArray(v)) return null
401 const o = v as Record<string, unknown>
402 if (!DELVE_NUMS.every(k => finite(o[k]))) return null
403 const n = (k: (typeof DELVE_NUMS)[number]) => Math.max(0, Math.floor(o[k] as number))
404 return {
405 startedAt: n('startedAt'),
406 floor: Math.max(1, n('floor')),
407 step: n('step'),
408 hp: Math.min(100, n('hp')),
409 mp: Math.min(100, n('mp')),
410 loot: n('loot'),
411 cleared: n('cleared'),
412 seed: n('seed') >>> 0,
413 last: fightFrom(o.last),
414 }
415}
416
417const sameDelve = (a: RpgDelve | null, b: RpgDelve | null) => JSON.stringify(a) === JSON.stringify(b)
418
419/** id → 時刻 の表を、知っている id の数だけ拾う */
420function stamps(v: unknown, ids: readonly string[]): Record<string, number> {
421 const out: Record<string, number> = {}
422 if (typeof v !== 'object' || v === null) return out
423 for (const id of ids) {
424 const at = (v as Record<string, unknown>)[id]
425 if (typeof at === 'number' && Number.isFinite(at)) out[id] = at
426 }
427 return out
428}
429
430/** 2 つの id → 時刻 の表を合わせる (早い方の時刻) */
431const unionStamps = (a: Record<string, number>, b: Record<string, number>) => {
432 const out = { ...a }
433 for (const [id, at] of Object.entries(b)) out[id] = Math.min(at, a[id] ?? at)
434 return out
435}
436
437/** store の中身は信用しない: 数と文字だけ拾う */
438export function fromSaved(raw: unknown): RpgSaved {
439 const o = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
440 const out = { ...INITIAL_SAVED, achievements: {} as Record<string, number>, equip: {} as RpgEquip }
441 for (const k of SAVED_KEYS) {
442 const v = o[k]
443 out[k] = typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0
444 }
445 out.day = typeof o.day === 'string' ? o.day.slice(0, 10) : ''
446 out.achievements = stamps(o.achievements, ACHIEVEMENTS.map(a => a.id))
447 // 戦いと成長は 0.3.0 から。無い (古い store) なら遭遇を少し持たせて始める
448 if (o.encounters === undefined) out.encounters = FIRST_ENCOUNTERS
449 out.encounters = Math.min(MAX_ENCOUNTERS, out.encounters)
450 out.bossSign = Math.min(1, out.bossSign)
451 const al = typeof o.alloc === 'object' && o.alloc !== null ? (o.alloc as Record<string, unknown>) : {}
452 out.alloc = { str: 0, vit: 0, int: 0, agi: 0 }
453 for (const k of STATS) {
454 const v = al[k]
455 out.alloc[k] = typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0
456 }
457 out.learned = stamps(o.learned, SKILLS.map(s => s.id))
458 out.defeated = stamps(o.defeated, BOSS_IDS)
459 // ダンジョンは 0.6.0 から。無い (古い store) なら入口にいる
460 out.deepest = finite(o.deepest) ? Math.max(0, Math.floor(o.deepest as number)) : 0
461 out.delve = delveFrom(o.delve)
462 // 終わった潜りと世代は 0.6.1 から。無ければ空と 0
463 out.doneDelves = doneFrom(o.doneDelves)
464 out.epoch = finite(o.epoch) ? Math.max(0, Math.floor(o.epoch as number)) : 0
465 // 装備は 0.2.0 から。無い (古い store) なら何も着けていない
466 const eq = o.equip
467 if (typeof eq === 'object' && eq !== null) {
468 for (const k of SLOTS) {
469 const c = cardFrom({ ...((eq as Record<string, unknown>)[k] as object | undefined), slot: k })
470 if (c) out.equip[k] = c
471 }
472 }
473 return out
474}
475
476/**
477 * 今の store に、このセッションで増えた分 (mine − base) だけを足す。
478 * 数は足し算、実績は和集合 (早い方の時刻)、日付は新しい方。同じ store を使う別のセッションの分を消さない。
479 * 装備とダンジョンの潜りはこのセッションで変えた時だけ mine を書く (最後に書いた方が勝つ)。一番深い階は大きい方。
480 * 終わった潜り (doneDelves にある物) は書き戻さず、取り込まない。別のセッションが先に終えた潜りの持ち帰りは足さない。
481 * store の世代が base と違えば (別のセッションがやり直した・控えから戻した)、差分は足さずに store を取る
482 */
483export function mergeSaved(store: RpgSaved, base: RpgSaved, mine: RpgSaved): RpgSaved {
484 if (store.epoch !== base.epoch) return store
485 // ponytail: 2 つのセッションが同じ潜りを同時に進めると、後に書いた方の歩みだけが残る。歩みを差分で足すなら潜りを id ごとに持つ
486 const storeDone = new Set(store.doneDelves.map(d => d.id))
487 const baseDone = new Set(base.doneDelves.map(d => d.id))
488 const mineNew = mine.doneDelves.filter(d => !baseDone.has(d.id))
489 // このセッションで終えた潜りを、別のセッションがもう終えていたら、その持ち帰りは 2 度目なので引く
490 const twice = mineNew.filter(d => storeDone.has(d.id)).reduce((n, d) => n + d.xp, 0)
491 let doneDelves = store.doneDelves.map(d => ({ ...d }))
492 for (const d of mineNew) if (!storeDone.has(d.id)) doneDelves = addDone(doneDelves, d)
493 const doneIds = new Set(doneDelves.map(d => d.id))
494 const isLive = (d: RpgDelve | null): d is RpgDelve => d !== null && !doneIds.has(d.startedAt)
495 // こちらが帰った時に、store に別の潜り (別のセッションが新しく入った物) があれば、それは消さない
496 const isOther = store.delve !== null && store.delve.startedAt !== base.delve?.startedAt
497 const picked = sameDelve(mine.delve, base.delve) || (mine.delve === null && isOther) ? store.delve : mine.delve
498 const out = {
499 ...store,
500 achievements: unionStamps(store.achievements, mine.achievements),
501 learned: unionStamps(store.learned, mine.learned),
502 defeated: unionStamps(store.defeated, mine.defeated),
503 equip: { ...(sameEquip(mine.equip, base.equip) ? store.equip : mine.equip) },
504 alloc: { ...store.alloc },
505 deepest: Math.max(store.deepest, mine.deepest),
506 // 選んだ潜りが終わった物なら書き戻さず、store に生きている潜りがあればそれを取る
507 delve: picked === null || isLive(picked) ? picked : isLive(store.delve) ? store.delve : null,
508 doneDelves,
509 }
510 for (const k of SAVED_KEYS) out[k] = Math.max(0, store[k] + mine[k] - base[k])
511 out.xp = Math.max(0, out.xp - twice)
512 out.encounters = Math.min(MAX_ENCOUNTERS, out.encounters)
513 out.bossSign = Math.min(1, out.bossSign)
514 // 振った数も差分で足す (振り直しは減る差分)
515 for (const k of STATS) out.alloc[k] = Math.max(0, store.alloc[k] + mine.alloc[k] - base.alloc[k])
516 // 2 つのセッションが同じ SP を使って、もらった分を超えたら、このセッションで新しく使った分 (振った・覚えた) は取り消す
517 const sp = spOf(out)
518 if (sp.used > sp.earned) {
519 for (const k of STATS) out.alloc[k] = Math.max(0, store.alloc[k] + Math.min(0, mine.alloc[k] - base.alloc[k]))
520 out.learned = { ...store.learned }
521 }
522 const day = mine.day > store.day ? mine.day : store.day
523 const mineToday = mine.day === day ? mine.dayTurns - (base.day === day ? base.dayTurns : 0) : 0
524 out.day = day
525 out.dayTurns = Math.max(0, (store.day === day ? store.dayTurns : 0) + mineToday)
526 return out
527}
528
529const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
530
531/** raw のうち known に無い欄 (この版が知らない、新しい版が書いた物)。物でなければ空 */
532export function unknownOf(raw: unknown, known: Iterable<string>): Record<string, unknown> {
533 if (!isObj(raw)) return {}
534 const k = new Set(known)
535 return Object.fromEntries(Object.entries(raw).filter(([key]) => !k.has(key)))
536}
537
538const CARD_KEYS = ['id', 'rarity', 'slot', 'name']
539
540/**
541 * store に書く形。書く直前に読んだ raw のうち、この版の知らない欄 (新しい版の欄・知らない実績やスキルやボス・枠・カードの欄) を残して mine を重ねる。
542 * 古い版のまま動いているセッションが、新しい版の記録を消さないように
543 */
544export function withUnknown(raw: unknown, mine: RpgSaved): Record<string, unknown> {
545 const o = isObj(raw) ? raw : {}
546 const eq = isObj(o.equip) ? o.equip : {}
547 const equip = unknownOf(eq, SLOTS)
548 for (const k of SLOTS) {
549 const c = mine.equip[k]
550 const was = eq[k]
551 if (c) equip[k] = { ...(isObj(was) && was.id === c.id ? unknownOf(was, CARD_KEYS) : {}), ...c }
552 }
553 return {
554 ...unknownOf(o, Object.keys(INITIAL_SAVED)),
555 ...mine,
556 achievements: { ...unknownOf(o.achievements, ACHIEVEMENTS.map(a => a.id)), ...mine.achievements },
557 learned: { ...unknownOf(o.learned, SKILLS.map(s => s.id)), ...mine.learned },
558 defeated: { ...unknownOf(o.defeated, BOSS_IDS), ...mine.defeated },
559 alloc: { ...unknownOf(o.alloc, STATS), ...mine.alloc },
560 equip,
561 // 同じ潜り (始めた時刻が同じ) なら、その中の知らない欄も残す
562 delve: mine.delve && { ...(isObj(o.delve) && o.delve.startedAt === mine.delve.startedAt ? unknownOf(o.delve, DELVE_KEYS) : {}), ...mine.delve },
563 // 終わった潜りの印も、同じ id の物の知らない欄を残す
564 doneDelves: mine.doneDelves.map(d => {
565 const was = Array.isArray(o.doneDelves) ? o.doneDelves.find(x => isObj(x) && x.id === d.id) : undefined
566 return { ...unknownOf(was, ['id', 'xp']), ...d }
567 }),
568 }
569}
570
571/**
572 * 古い版のまま state に残っている勇者 (開いたまま /reload-plugins した時) に、新しい値 (0.3.0 の戦いと成長・0.4.0 のボスの気配・0.6.0 のダンジョン) を足す。
573 * 今の値 (s・HP・経験値バー) はそのまま。もう新しい形ならそのまま返す
574 */
575export const upgradeHero = (h: RpgHero): RpgHero => {
576 const p = h as Partial<RpgHero>
577 return p.alloc !== undefined && p.bossSign !== undefined && p.deepest !== undefined && p.doneDelves !== undefined && p.epoch !== undefined
578 ? h
579 : { ...h, ...fromSaved(h), synced: fromSaved(h.synced) }
580}
581
582/** その時刻の日付 (日本時間 JST で YYYY-MM-DD。動いている環境の時刻帯には頼らない) */
583export const dayKey = jstDay
584
585/** 休んでからの疲れ (HP の減った %、下限なし) */
586export const fatigueAt = (h: RpgHero, now: number) => Math.floor(Math.max(0, now - h.s.restedAt) / HP_STEP_MS)
587
588export const hpPctAt = (h: RpgHero, now: number) => Math.max(HP_MIN, 100 - fatigueAt(h, now) - (h.s.wound ?? 0))
589
590/** 間が空いていたら休んだことにする (戦いの傷も治る) */
591const touch = (s: RpgSession, now: number): RpgSession =>
592 now - s.lastActiveAt >= REST_MS ? { ...s, restedAt: now, lastActiveAt: now, wound: 0 } : { ...s, lastActiveAt: now }
593
594export const gain = (h: RpgHero, xp: number): RpgHero => ({ ...h, xp: h.xp + xp, xpFrom: levelInfo(h.xp).frac })
595
596/** セッションの始まり: 冒険の回数を 1 つ増やす */
597export const startSession = (saved: RpgSaved, now: number): RpgHero => ({
598 ...saved,
599 sessions: saved.sessions + 1,
600 s: freshSession(now),
601 hpPct: 100,
602 xpFrom: levelInfo(saved.xp).frac,
603 synced: saved,
604})
605
606export const onTurnStart = (h: RpgHero, now: number): RpgHero => {
607 const s = touch(h.s, now)
608 return { ...h, s: { ...s, turnTools: 0, turnErrors: 0, isErrorPending: false }, hpPct: hpPctAt({ ...h, s }, now) }
609}
610
611export function onTurnEnd(h: RpgHero, now: number): RpgHero {
612 const day = dayKey(now)
613 const s = touch(h.s, now)
614 // ターンを終えるたびに魔物の気配が 1 つたまる
615 const next = {
616 ...gain(h, XP.turn),
617 s,
618 turns: h.turns + 1,
619 day,
620 dayTurns: (h.day === day ? h.dayTurns : 0) + 1,
621 encounters: Math.min(MAX_ENCOUNTERS, h.encounters + 1),
622 }
623 return { ...next, hpPct: hpPctAt(next, now) }
624}
625
626export function onTool(h: RpgHero, tool: string, isError: boolean, now: number): RpgHero {
627 const s0 = touch(h.s, now)
628 if (isError) {
629 const s = { ...s0, toolErr: s0.toolErr + 1, turnTools: s0.turnTools + 1, turnErrors: s0.turnErrors + 1, isErrorPending: true }
630 return { ...h, s, tools: h.tools + 1, errors: h.errors + 1, xpFrom: levelInfo(h.xp).frac }
631 }
632 const kind = toolKind(tool)
633 const isRecovery = s0.isErrorPending
634 const s = {
635 ...s0,
636 toolOk: s0.toolOk + 1,
637 turnTools: s0.turnTools + 1,
638 isErrorPending: false,
639 edits: s0.edits + (kind === 'edit' ? 1 : 0),
640 reads: s0.reads + (kind === 'read' ? 1 : 0),
641 shells: s0.shells + (kind === 'shell' ? 1 : 0),
642 recoveries: s0.recoveries + (isRecovery ? 1 : 0),
643 }
644 return {
645 ...gain(h, XP[kind] + (isRecovery ? XP.recovery : 0)),
646 s,
647 tools: h.tools + 1,
648 edits: h.edits + (kind === 'edit' ? 1 : 0),
649 reads: h.reads + (kind === 'read' ? 1 : 0),
650 shells: h.shells + (kind === 'shell' ? 1 : 0),
651 recoveries: h.recoveries + (isRecovery ? 1 : 0),
652 }
653}
654
655/** まだ取っていない実績のうち、条件を満たした物を取る */
656export function grant(h: RpgHero, now: number, isTurnEnd: boolean, owned?: RpgGachaCard[]): { hero: RpgHero; gained: Achievement[] } {
657 const level = levelInfo(h.xp).level
658 const gained = ACHIEVEMENTS.filter(a => h.achievements[a.id] === undefined && a.test({ h, level, isTurnEnd, owned }))
659 if (gained.length === 0) return { hero: h, gained }
660 const achievements = { ...h.achievements }
661 for (const a of gained) achievements[a.id] = now
662 return { hero: { ...h, achievements }, gained }
663}
664
665/** このセッションの作業で上がる分 (編集・調べ物・コマンドの回数) の上限 */
666export const WORK_CAP = 10
667
668/**
669 * 強さ。Lv・振った SP・装備・このセッションの作業 (上限 WORK_CAP) を足す。
670 * worn (画面用に持っている物だけにした装備) があればそれ、無ければ equip の分を足す
671 */
672export function statsOf(h: RpgHero) {
673 const info = levelInfo(h.xp)
674 const L = info.level
675 const a = h.alloc
676 const bonus = gearBonus(h.worn ?? h.equip)
677 const maxHp = 40 + L * 10 + bonus.hp + a.vit * 10
678 const maxMp = 20 + L * 5 + bonus.mp + a.int * 4
679 const tries = h.s.toolOk + h.s.toolErr
680 const work = { attack: Math.min(WORK_CAP, h.s.edits), magic: Math.min(WORK_CAP, h.s.reads), speed: Math.min(WORK_CAP, h.s.shells) }
681 const grit = h.s.recoveries
682 return {
683 ...info,
684 title: titleOf(L),
685 maxHp,
686 hp: Math.max(1, Math.round((maxHp * h.hpPct) / 100)),
687 maxMp,
688 mp: tries === 0 ? maxMp : Math.round((maxMp * h.s.toolOk) / tries),
689 attack: 6 + 2 * L + 2 * a.str + bonus.attack + work.attack,
690 defense: 1 + L + 2 * a.vit + bonus.hp / 5,
691 magic: 4 + 2 * L + 2 * a.int + bonus.magic + work.magic,
692 speed: 2 + L + 2 * a.agi + work.speed,
693 grit,
694 /** 会心の確率 (%): 5 + 根性 + 敏捷 (+ コードレビュー 10)。40 まで */
695 crit: Math.min(40, 5 + grit + a.agi + (h.learned.review !== undefined ? 10 : 0)),
696 bonus,
697 work,
698 sp: spOf(h),
699 earned: ACHIEVEMENTS.filter(x => h.achievements[x.id] !== undefined).length,
700 }
701}
702
703export const levelUpText = (level: number) => `レベルアップ! Lv ${level} ${titleOf(level)}`
704
705// ---------------------------------------------------------------- SVG
706
707const W = 380
708/** 昔の RPG の青い窓の色。暗いテーマ (今までの色) と、/config の theme が light 系の時の色 */
709const DARK_K = {
710 bg: '#0d1117',
711 win: '#0b1f5c',
712 winTop: '#14307f',
713 frame: '#ffffff',
714 text: '#ffffff',
715 weak: '#9fb3e6',
716 green: '#3fb950',
717 yellow: '#d29922',
718 red: '#f85149',
719 blue: '#58a6ff',
720 purple: '#bc8cff',
721 off: '#30363d',
722 offText: '#8b949e',
723 well: '#06103a',
724 badge: '#d29922',
725 badgeText: '#3b2300',
726 /** 装備で上がった分の色 */
727 gear: '#39c5cf',
728}
729const LIGHT_K: typeof DARK_K = {
730 bg: '#ffffff',
731 win: '#dbe5ff',
732 winTop: '#f3f6ff',
733 frame: '#1d3a8a',
734 text: '#0b1f5c',
735 weak: '#4a5f99',
736 green: '#1a7f37',
737 yellow: '#9a6700',
738 red: '#cf222e',
739 blue: '#0969da',
740 purple: '#8250df',
741 off: '#eaeef2',
742 offText: '#6e7781',
743 well: '#c3d2f7',
744 badge: '#f2cc60',
745 badgeText: '#3b2300',
746 gear: '#0e7c86',
747}
748// ponytail: 今の色はモジュールの変数。SVG を作る関数の頭で paint() を呼んで、テーマに合わせる
749export const K = { ...DARK_K }
750export const paint = () => Object.assign(K, isLight() ? LIGHT_K : DARK_K)
751export const MONO = 'ui-monospace, Consolas, monospace'
752const rarityColor = (r: Rarity) => ({ N: K.offText, R: K.blue, SR: K.yellow, SSR: K.purple })[r]
753
754export const svgDoc = (h: number, body: string) =>
755 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" font-family="sans-serif">` +
756 `<defs><linearGradient id="win" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${K.winTop}"/><stop offset="1" stop-color="${K.win}"/></linearGradient></defs>` +
757 `<rect width="${W}" height="${h}" fill="${K.bg}"/>${body}</svg>`
758
759/** 昔の RPG の窓: 青い地に白い二重の枠 */
760export const win = (x: number, y: number, w: number, h: number) =>
761 `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="url(#win)" stroke="${K.frame}" stroke-width="3"/>` +
762 `<rect x="${x + 5}" y="${y + 5}" width="${w - 10}" height="${h - 10}" rx="5" fill="none" stroke="${K.frame}" stroke-opacity="0.45" stroke-width="1"/>`
763
764// 勇者のドット絵 (12x12)。文字ごとに色: h 髪 / s 肌 / e 目 / b 服 / g 剣
765const SPRITE = [
766 '....hhhh....',
767 '...hhhhhh...',
768 '...hssssh...',
769 '...sesses...',
770 '...ssssss..g',
771 '....ssss..g.',
772 '..bbbbbbbbg.',
773 '.sbbbbbbb.s.',
774 '.s.bbbbbb...',
775 '...bb..bb...',
776 '...bb..bb...',
777 '..sss..sss..',
778]
779const SPRITE_COLORS: Record<string, string> = { h: '#f78166', s: '#f2cc8f', e: '#0d1117', b: '#58a6ff', g: '#c9d1d9' }
780
781export function sprite(x: number, y: number, px: number) {
782 let s = ''
783 SPRITE.forEach((row, r) => {
784 ;[...row].forEach((ch, c) => {
785 const color = SPRITE_COLORS[ch]
786 if (color) s += `<rect x="${x + c * px}" y="${y + r * px}" width="${px}" height="${px}" fill="${color}"/>`
787 })
788 })
789 // その場で小さく跳ねる
790 return `<g>${s}<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="1.4s" repeatCount="indefinite"/></g>`
791}
792
793/** 横棒。経験値は from から伸びる */
794export function bar(x: number, y: number, w: number, frac: number, color: string, from?: number) {
795 const fw = Math.max(0, Math.min(1, frac)) * w
796 const anim =
797 from !== undefined && Math.abs(from - frac) > 0.001
798 ? `<animate attributeName="width" from="${(Math.max(0, Math.min(1, from)) * w).toFixed(1)}" to="${fw.toFixed(1)}" dur="0.9s" calcMode="spline" keySplines="0.2 0.8 0.2 1" keyTimes="0;1" fill="freeze"/>`
799 : ''
800 return (
801 `<rect x="${x}" y="${y}" width="${w}" height="12" fill="${K.well}" stroke="${K.frame}" stroke-opacity="0.6"/>` +
802 `<rect x="${x + 1}" y="${y + 1}" width="${Math.max(0, fw - 2).toFixed(1)}" height="10" fill="${color}">${anim}</rect>` +
803 `<rect x="${x + 1}" y="${y + 1}" width="${Math.max(0, fw - 2).toFixed(1)}" height="3" fill="#ffffff" opacity="0.25">${anim}</rect>`
804 )
805}
806
807function badge(cx: number, cy: number, a: Achievement, isEarned: boolean) {
808 if (!isEarned) {
809 return (
810 `<circle cx="${cx}" cy="${cy}" r="19" fill="${K.off}" stroke="${K.offText}" stroke-dasharray="3 2"/>` +
811 `<text x="${cx}" y="${cy + 6}" font-size="17" fill="${K.offText}" text-anchor="middle">${a.glyph}</text>`
812 )
813 }
814 return (
815 `<circle cx="${cx}" cy="${cy}" r="19" fill="${K.badge}" stroke="${K.frame}" stroke-width="2"/>` +
816 `<circle cx="${cx}" cy="${cy}" r="14" fill="none" stroke="#ffffff" stroke-opacity="0.5"/>` +
817 `<text x="${cx}" y="${cy + 6}" font-size="17" font-weight="bold" fill="${K.badgeText}" text-anchor="middle">${a.glyph}</text>`
818 )
819}
820
821const SLOT_GLYPH: Record<RpgSlot, string> = { weapon: '武', armor: '防', charm: '守' }
822
823/** 枠の小さな札。着けていればレア度の色で塗る */
824function slotChip(x: number, y: number, slot: RpgSlot, card: RpgGachaCard | undefined) {
825 if (!card)
826 return (
827 `<rect x="${x}" y="${y}" width="22" height="22" rx="4" fill="${K.well}" stroke="${K.offText}" stroke-dasharray="2 2"/>` +
828 `<text x="${x + 11}" y="${y + 16}" font-size="12" fill="${K.offText}" text-anchor="middle">${SLOT_GLYPH[slot]}</text>`
829 )
830 const col = rarityColor(card.rarity)
831 return (
832 `<rect x="${x}" y="${y}" width="22" height="22" rx="4" fill="${col}" stroke="${K.frame}"/>` +
833 `<text x="${x + 11}" y="${y + 16}" font-size="12" font-weight="bold" fill="${K.badgeText}" text-anchor="middle">${SLOT_GLYPH[slot]}</text>`
834 )
835}
836
837/** 「装備で +n」の小さな字 (0 なら何も出さない) */
838const plus = (x: number, y: number, n: number, anchor = 'start') =>
839 n > 0 ? `<text x="${x}" y="${y}" font-size="12" font-weight="bold" font-family="${MONO}" fill="${K.gear}" text-anchor="${anchor}">+${n}</text>` : ''
840
841/** ステータスの画面 */
842export function statusSvg(h: RpgHero) {
843 paint()
844 const st = statsOf(h)
845 const gear = h.worn ?? h.equip
846 let s = ''
847 // ── 名前・レベル・称号・装備の札
848 s += win(8, 8, 364, 92)
849 s += `<rect x="20" y="20" width="68" height="68" fill="${K.well}" stroke="${K.frame}" stroke-opacity="0.5"/>` + sprite(30, 28, 4)
850 s += `<text x="102" y="42" font-size="20" font-weight="bold" fill="${K.text}">勇者</text>`
851 s += `<text x="160" y="42" font-size="20" font-weight="bold" font-family="${MONO}" fill="${K.text}">Lv ${st.level}</text>`
852 SLOTS.forEach((k, i) => (s += slotChip(284 + i * 26, 22, k, gear[k])))
853 s +=
854 `<text x="102" y="70" font-size="14" fill="${K.yellow}">` +
855 `<tspan>▶</tspan><tspan dx="4" fill="${K.text}">${esc(st.title)}</tspan>` +
856 `<animate attributeName="opacity" values="1;1;0.4;1" dur="1.6s" repeatCount="indefinite"/></text>`
857 s += `<text x="102" y="90" font-size="12" fill="${K.weak}">冒険 ${h.sessions} 回目 / 累計 ${h.turns} ターン</text>`
858
859 // ── HP / MP / 経験値 (装備で上がった最大値は棒の右に色を変えて出す)
860 s += win(8, 108, 364, 128)
861 const hpColor = h.hpPct > 50 ? K.green : h.hpPct > 25 ? K.yellow : K.red
862 const line = (y: number, label: string, frac: number, color: string, value: string, bonus: number, from?: number) =>
863 `<text x="24" y="${y + 11}" font-size="14" font-weight="bold" font-family="${MONO}" fill="${K.text}">${label}</text>` +
864 bar(64, y, 172, frac, color, from) +
865 plus(242, y + 11, bonus) +
866 `<text x="358" y="${y + 11}" font-size="13" font-family="${MONO}" fill="${K.text}" text-anchor="end">${value}</text>`
867 s += line(126, 'HP', st.hp / st.maxHp, hpColor, `${st.hp}/${st.maxHp}`, st.bonus.hp)
868 s += line(154, 'MP', st.mp / st.maxMp, K.blue, `${st.mp}/${st.maxMp}`, st.bonus.mp)
869 s += line(182, 'EX', st.frac, K.purple, st.level >= MAX_LEVEL ? 'MAX' : `${st.into}/${st.need}`, 0, h.xpFrom)
870 s +=
871 `<text x="24" y="222" font-size="12" fill="${K.weak}">` +
872 (st.level >= MAX_LEVEL ? 'これ以上は上がりません' : `つぎのレベルまで ${st.need - st.into}`) +
873 `</text><text x="358" y="222" font-size="12" fill="${K.weak}" text-anchor="end">HP は休むと戻る</text>`
874
875 // ── 強さ (Lv・振った SP・装備・今回の作業を足した値。装備の分は下の説明の右に色を変えて出す)
876 s += win(8, 244, 364, 134)
877 const cell = (x: number, y: number, label: string, value: number, note: string, bonus: number, color?: string) =>
878 `<text x="${x}" y="${y}" font-size="13" fill="${K.text}">${label}</text>` +
879 `<text x="${x + 150}" y="${y}" font-size="15" font-weight="bold" font-family="${MONO}" fill="${color ?? (bonus > 0 ? K.gear : K.text)}" text-anchor="end">${value}</text>` +
880 `<text x="${x}" y="${y + 16}" font-size="12" fill="${K.weak}">${note}</text>` +
881 (bonus > 0 ? `<text x="${x + 150}" y="${y + 16}" font-size="12" fill="${K.gear}" text-anchor="end">装備で +${bonus}</text>` : '')
882 s += cell(24, 270, '攻撃力', st.attack, '力と編集', st.bonus.attack)
883 s += cell(204, 270, '防御力', st.defense, '体力と防具', st.bonus.hp / 5)
884 s += cell(24, 312, '魔力', st.magic, '知恵と調べ物', st.bonus.magic)
885 s += cell(204, 312, '素早さ', st.speed, '敏捷とコマンド', 0)
886 s += cell(24, 354, '根性', st.grit, `立ち直り (会心 ${st.crit}%)`, 0)
887 s += cell(204, 354, 'SP', st.sp.free, '「成長」で使う', 0, st.sp.free > 0 ? K.yellow : undefined)
888
889 // ── 実績のバッジ (6 個ずつ並べる)
890 const rows = Math.ceil(ACHIEVEMENTS.length / 6)
891 const top = 386
892 s += win(8, top, 364, 34 + rows * 46)
893 s += `<text x="24" y="${top + 24}" font-size="13" font-weight="bold" fill="${K.text}">実績</text>`
894 s += `<text x="358" y="${top + 24}" font-size="12" fill="${K.weak}" text-anchor="end">${st.earned} / ${ACHIEVEMENTS.length}</text>`
895 ACHIEVEMENTS.forEach((a, i) => {
896 const cx = 44 + (i % 6) * 58
897 const cy = top + 54 + Math.floor(i / 6) * 46
898 s += badge(cx, cy, a, h.achievements[a.id] !== undefined)
899 })
900 return svgDoc(top + 34 + rows * 46 + 8, s)
901}
902
903const SLOT_NOTE: Record<RpgSlot, string> = { weapon: '攻撃力が上がる', armor: '最大 HP が上がる', charm: '魔力と最大 MP が上がる' }
904
905/** 装備の画面。owned はガチャで持っているカード (turn-gacha が無ければ undefined) */
906export function gearSvg(h: RpgHero, owned: RpgGachaCard[] | undefined) {
907 paint()
908 let s = win(8, 8, 364, 56)
909 s += `<text x="24" y="34" font-size="15" font-weight="bold" fill="${K.text}">装備</text>`
910 s += `<text x="24" y="52" font-size="12" fill="${K.weak}">ガチャで出たカードを身に着ける</text>`
911 if (owned === undefined) {
912 s += win(8, 72, 364, 92)
913 s += `<text x="190" y="108" font-size="14" font-weight="bold" fill="${K.text}" text-anchor="middle">turn-gacha を入れるとカードを装備できます</text>`
914 s += `<text x="190" y="132" font-size="12" fill="${K.weak}" text-anchor="middle">ガチャのカードが武器・防具・お守りになります</text>`
915 return svgDoc(172, s)
916 }
917 const worn = wornOf(h.equip, owned)
918 const b = gearBonus(worn)
919 const total = b.attack + b.hp + b.magic + b.mp
920 s += `<text x="358" y="34" font-size="12" fill="${total > 0 ? K.gear : K.weak}" text-anchor="end">${total > 0 ? '装備で強くなった' : 'まだ何も着けていない'}</text>`
921 let y = 72
922 for (const k of SLOTS) {
923 const card = worn[k]
924 const kinds = owned.filter(c => c.slot === k).length
925 s += win(8, y, 364, 72)
926 s += slotChip(24, y + 16, k, card)
927 s += `<text x="56" y="${y + 26}" font-size="13" font-weight="bold" fill="${K.text}">${SLOT_LABEL[k]}</text>`
928 s += `<text x="120" y="${y + 26}" font-size="12" fill="${K.weak}">${SLOT_NOTE[k]}</text>`
929 if (card) {
930 const col = rarityColor(card.rarity)
931 s +=
932 `<rect x="24" y="${y + 42}" width="36" height="18" rx="4" fill="none" stroke="${col}"/>` +
933 `<text x="42" y="${y + 55}" font-size="12" font-weight="bold" fill="${col}" text-anchor="middle">${card.rarity}</text>` +
934 `<text x="68" y="${y + 55}" font-size="13" fill="${K.text}">${esc(short(card.name, 15))}</text>` +
935 `<text x="358" y="${y + 55}" font-size="12" font-weight="bold" fill="${K.gear}" text-anchor="end">${gearEffect(k, card.rarity)}</text>`
936 } else {
937 s += `<text x="24" y="${y + 55}" font-size="12" fill="${K.offText}">${kinds > 0 ? `なし (着けられるカード ${kinds} 種)` : 'なし (この枠のカードをまだ持っていない)'}</text>`
938 }
939 y += 80
940 }
941 // ── 合計と、レア度ごとの上がり幅
942 s += win(8, y, 364, 64)
943 const sum = [`攻撃力 +${b.attack}`, `最大HP +${b.hp}`, `魔力 +${b.magic}`, `最大MP +${b.mp}`].join(' ')
944 s += `<text x="24" y="${y + 26}" font-size="12" font-weight="bold" fill="${total > 0 ? K.gear : K.weak}">${sum}</text>`
945 s += `<text x="24" y="${y + 46}" font-size="12" fill="${K.weak}">${RARITIES.slice().reverse().map(r => `${r} +${GEAR_POINTS[r]}`).join(' ')} (防具 5 倍・MP 3 倍)</text>`
946 if (owned.length === 0) s += `<text x="190" y="${y + 90}" font-size="12" fill="${K.weak}" text-anchor="middle">ガチャでカードを引くと装備できます</text>`
947 return svgDoc(y + (owned.length === 0 ? 104 : 72), s)
948}
949
950const dateLabel = (ms: number) => {
951 const d = new Date(ms)
952 return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
953}
954
955/** 称号と実績の画面: 今の称号と次の称号、称号の段、取った実績と、まだの実績 (ヒント付き) */
956export function achievementsSvg(h: RpgHero) {
957 paint()
958 const { level } = levelInfo(h.xp)
959 const title = titleOf(level)
960 const nextTitle = TITLES.find(([lv]) => lv > level)
961 const earned = ACHIEVEMENTS.filter(a => h.achievements[a.id] !== undefined)
962 const rest = ACHIEVEMENTS.filter(a => h.achievements[a.id] === undefined)
963 // ── 見出し: 称号と、実績の集まり具合
964 let s = win(8, 8, 364, 92)
965 s += `<text x="24" y="34" font-size="15" font-weight="bold" fill="${K.text}">▶ 実績の書</text>`
966 s += `<text x="358" y="34" font-size="12" fill="${K.weak}" text-anchor="end">${earned.length} / ${ACHIEVEMENTS.length}</text>`
967 s += bar(24, 44, 334, earned.length / ACHIEVEMENTS.length, K.badge)
968 s += `<text x="24" y="74" font-size="13" fill="${K.text}">称号 <tspan font-weight="bold" fill="${K.yellow}">${esc(title)}</tspan></text>`
969 s += `<text x="24" y="92" font-size="12" fill="${K.weak}">${nextTitle ? `つぎの称号は Lv ${nextTitle[0]} の「${esc(nextTitle[1])}」` : '称号はすべて手に入れた'}</text>`
970
971 // ── 称号の段 (2 列)
972 const tRows = Math.ceil(TITLES.length / 2)
973 let y = 108
974 s += win(8, y, 364, 36 + tRows * 22)
975 s += `<text x="24" y="${y + 24}" font-size="13" font-weight="bold" fill="${K.text}">称号の段</text>`
976 TITLES.forEach(([lv, name], i) => {
977 const x = i % 2 === 0 ? 24 : 196
978 const ty = y + 46 + Math.floor(i / 2) * 22
979 const isNow = name === title
980 const has = level >= lv
981 const col = isNow ? K.yellow : has ? K.text : K.offText
982 s += `<text x="${x}" y="${ty}" font-size="12" font-family="${MONO}" fill="${col}">Lv${String(lv).padStart(2, ' ')}</text>`
983 s += `<text x="${x + 40}" y="${ty}" font-size="12"${isNow ? ' font-weight="bold"' : ''} fill="${col}">${esc(name)}</text>`
984 })
985 y += 36 + tRows * 22 + 8
986
987 // ── 実績: 取った物、まだの物 (ヒント付き)
988 const rowH = 40
989 const top = y
990 let body = ''
991 const row = (a: Achievement, at: number | undefined) => {
992 const isEarned = at !== undefined
993 body +=
994 `<g transform="translate(0 ${y})">` +
995 `<line x1="20" y1="0" x2="360" y2="0" stroke="${K.frame}" stroke-opacity="0.15"/>` +
996 `<circle cx="36" cy="20" r="13" fill="${isEarned ? K.badge : K.off}" stroke="${isEarned ? K.frame : K.offText}"/>` +
997 `<text x="36" y="25" font-size="13" font-weight="bold" fill="${isEarned ? K.badgeText : K.offText}" text-anchor="middle">${a.glyph}</text>` +
998 `<text x="58" y="17" font-size="13" font-weight="bold" fill="${isEarned ? K.text : K.offText}">${esc(a.name)}</text>` +
999 `<text x="58" y="33" font-size="12" fill="${isEarned ? K.weak : K.offText}">${isEarned ? '' : 'ヒント: '}${esc(a.hint)}</text>` +
1000 `<text x="358" y="17" font-size="12" fill="${isEarned ? K.yellow : K.offText}" text-anchor="end">${isEarned ? dateLabel(at) : 'まだ'}</text>` +
1001 (a.isGacha ? `<text x="358" y="33" font-size="12" fill="${K.gear}" text-anchor="end">ガチャ連動</text>` : '') +
1002 `</g>`
1003 y += rowH
1004 }
1005 const section = (label: string, n: number) => {
1006 body += `<text x="24" y="${y + 22}" font-size="13" font-weight="bold" fill="${K.text}">${label}</text>`
1007 body += `<text x="358" y="${y + 22}" font-size="12" fill="${K.weak}" text-anchor="end">${n}</text>`
1008 y += 32
1009 }
1010 y += 4
1011 section('手に入れた実績', earned.length)
1012 if (earned.length === 0) {
1013 body += `<text x="24" y="${y + 14}" font-size="12" fill="${K.offText}">まだひとつもありません</text>`
1014 y += 24
1015 }
1016 for (const a of earned) row(a, h.achievements[a.id])
1017 section('まだの実績', rest.length)
1018 for (const a of rest) row(a, undefined)
1019 if (rest.length === 0) {
1020 body += `<text x="24" y="${y + 14}" font-size="12" fill="${K.yellow}">全部手に入れた</text>`
1021 y += 24
1022 }
1023 s += win(8, top, 364, y - top + 8) + body
1024 return svgDoc(y + 16, s)
1025}
1026
1027export type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
1028
1029/** Svg の alt と terminal で使う行 (ステータス) */
1030export function statusLines(h: RpgHero): Line[] {
1031 const st = statsOf(h)
1032 const marks = ACHIEVEMENTS.map(a => (h.achievements[a.id] !== undefined ? '●' : '○')).join('')
1033 const gear = h.worn ?? h.equip
1034 const worn = SLOTS.flatMap(k => {
1035 const c = gear[k]
1036 return c ? [`${SLOT_LABEL[k]} ${c.rarity} ${c.name}`] : []
1037 })
1038 const b = st.bonus
1039 return [
1040 { text: `勇者 Lv ${st.level} ${st.title}`, isBold: true },
1041 { text: `HP ${st.hp}/${st.maxHp} MP ${st.mp}/${st.maxMp}`, color: h.hpPct > 25 ? undefined : 'red' },
1042 { text: st.level >= MAX_LEVEL ? '経験値 MAX' : `経験値 ${st.into}/${st.need} (つぎまで ${st.need - st.into})`, color: 'magenta' },
1043 { text: `攻撃力 ${st.attack} 防御力 ${st.defense} 魔力 ${st.magic} 素早さ ${st.speed} 根性 ${st.grit}` },
1044 { text: `SP ${st.sp.free} (「成長」で使う) 会心 ${st.crit}%`, color: st.sp.free > 0 ? 'yellow' : undefined, isDim: st.sp.free === 0 },
1045 ...(worn.length > 0
1046 ? [{ text: `装備で 攻撃力 +${b.attack} 最大HP +${b.hp} 魔力 +${b.magic} 最大MP +${b.mp} (${worn.join(' / ')})`, color: 'cyan' }]
1047 : []),
1048 { text: `実績 ${st.earned}/${ACHIEVEMENTS.length} ${marks}`, color: 'yellow' },
1049 { text: `冒険 ${h.sessions} 回目 / 累計 ${h.turns} ターン`, isDim: true },
1050 ]
1051}
1052
1053/** 装備の画面の行 */
1054export function gearLines(h: RpgHero, owned: RpgGachaCard[] | undefined): Line[] {
1055 if (owned === undefined)
1056 return [
1057 { text: 'turn-gacha を入れるとカードを装備できます', isBold: true },
1058 { text: 'ガチャのカードが武器・防具・お守りになります', isDim: true },
1059 ]
1060 const worn = wornOf(h.equip, owned)
1061 const b = gearBonus(worn)
1062 return [
1063 ...SLOTS.map((k): Line => {
1064 const c = worn[k]
1065 return c ? { text: `${SLOT_LABEL[k]}: ${c.rarity} ${c.name} (${gearEffect(k, c.rarity)})`, color: 'cyan' } : { text: `${SLOT_LABEL[k]}: なし`, isDim: true }
1066 }),
1067 { text: `装備で 攻撃力 +${b.attack} 最大HP +${b.hp} 魔力 +${b.magic} 最大MP +${b.mp}` },
1068 ...(owned.length === 0 ? [{ text: 'ガチャでカードを引くと装備できます', isDim: true }] : []),
1069 ]
1070}
1071
1072/** 称号と実績の一覧の行 (取った物が先) */
1073export function achievementLines(h: RpgHero): Line[] {
1074 const { level } = levelInfo(h.xp)
1075 const next = TITLES.find(([lv]) => lv > level)
1076 const earned = ACHIEVEMENTS.filter(a => h.achievements[a.id] !== undefined)
1077 const rest = ACHIEVEMENTS.filter(a => h.achievements[a.id] === undefined)
1078 return [
1079 { text: `称号 ${titleOf(level)} (実績 ${earned.length}/${ACHIEVEMENTS.length})`, isBold: true },
1080 { text: next ? `つぎの称号 Lv ${next[0]} ${next[1]}` : '称号はすべて手に入れた', isDim: true },
1081 ...earned.map((a): Line => ({ text: `● ${a.name} (${dateLabel(h.achievements[a.id] ?? 0)}) ${a.hint}`, color: 'yellow' })),
1082 ...rest.map((a): Line => ({ text: `○ ${a.name} ─ ${a.hint}`, isDim: true })),
1083 ]
1084}
1085hooks/style.ts 315 lines1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7 bg: string
8 face: string
9 line: string
10 text: string
11 dim: string
12 weak: string
13 blue: string
14 green: string
15 yellow: string
16 red: string
17 purple: string
18 orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23 bg: '#0d1117',
24 face: '#161b22',
25 line: '#30363d',
26 text: '#c9d1d9',
27 dim: '#8b949e',
28 weak: '#8b949e',
29 blue: '#58a6ff',
30 green: '#3fb950',
31 yellow: '#d29922',
32 red: '#f85149',
33 purple: '#bc8cff',
34 orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39 bg: '#ffffff',
40 face: '#f6f8fa',
41 line: '#d0d7de',
42 text: '#1f2328',
43 dim: '#656d76',
44 weak: '#656d76',
45 blue: '#0969da',
46 green: '#1a7f37',
47 yellow: '#9a6700',
48 red: '#cf222e',
49 purple: '#8250df',
50 orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65 isLightNow = light
66 Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75 const rows = await $.config.list().catch(() => [])
76 setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81 on('config.set', { key: 'theme' }, async ($, e, next) => {
82 const r = await next(e)
83 if (r.deny === undefined) {
84 setTheme(isLightTheme(r.value))
85 $.ui.invalidate('ui.render')
86 }
87 return r
88 })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98 clean(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109 const h = jstDate(now).getUTCHours()
110 return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115 const m = jstDate(now).getUTCMonth() + 1
116 return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137 if (!dir.trim() || value === undefined) return false
138 const day = jstDay(now)
139 const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140 // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141 const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142 if (old === day) return false
143 await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144 return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149 try {
150 const b = JSON.parse(text) as Partial<Backup> | null
151 return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152 } catch {
153 return undefined
154 }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159 if (!dir.trim()) return []
160 const all = await Promise.all(
161 [0, 1, 2, 3, 4, 5, 6].map(wd =>
162 $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163 ),
164 )
165 return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177 let h = 0x811c9dc5
178 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179 return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187 const root = $.plugin.root
188 let manifest: string | undefined
189 for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190 manifest = await $.fs.read(p).then(t => t, () => undefined)
191 if (manifest !== undefined) break
192 }
193 if (manifest === undefined) return undefined
194 const names = await $.fs.list(`${root}/hooks`).then(
195 es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196 () => [] as string[],
197 )
198 const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199 return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207 const root = normPath($.plugin.root)
208 const at = root.lastIndexOf('/plugins/cache/')
209 if (at < 0) return false
210 const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211 try {
212 const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213 const mine = Object.entries(plugins)
214 .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215 .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216 .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217 return mine.length > 0 && !mine.includes(root)
218 } catch {
219 return false
220 }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225 loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234 if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235 staleAt = now
236 if (await replacedInCache($)) return (isStaleNow = true)
237 if (loadedPrint === undefined) return false
238 const p = await diskPrint($)
239 isStaleNow = p !== undefined && p !== loadedPrint
240 return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248 if (!(await isStale($, now))) return true
249 if (!toldStale) {
250 toldStale = true
251 $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252 }
253 return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264 `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268 `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275 const pad = 14
276 return [
277 `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278 `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279 `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280 sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281 growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282 ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287 `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288 `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292 `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293 `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294 `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298 `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305 const STEPS = 8
306 const d = 0.08
307 const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308 if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309 const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310 const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311 return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312 })
313 return frames.join('') + last
314}
315types/index.d.ts 241 lines1/** 装備の枠: 武器・防具・お守り */
2export type RpgSlot = 'weapon' | 'armor' | 'charm'
3
4/** SP を振る能力: 力・体力・知恵・素早さ */
5export type RpgStat = 'str' | 'vit' | 'int' | 'agi'
6
7/**
8 * turn-gacha のカード 1 種類。turn-gacha の契約 (types/index.d.ts の GachaCard) の写し。
9 * plugin.json の dependencies は使わない (dev-rpg は turn-gacha が無くても動く)。読んだ値は rpg.ts の gachaCards で確かめる
10 */
11export type RpgGachaCard = {
12 id: string
13 rarity: 'N' | 'R' | 'SR' | 'SSR'
14 slot: RpgSlot
15 name: string
16}
17
18/** 装備しているカード (枠ごと)。名前とレア度は装備した時の写し */
19export type RpgEquip = Partial<Record<RpgSlot, RpgGachaCard>>
20
21/** セッションをまたいで $.store に残す値 */
22export type RpgSaved = {
23 /** 累計の経験値 (レベルはここから計算する) */
24 xp: number
25 turns: number
26 tools: number
27 edits: number
28 reads: number
29 shells: number
30 recoveries: number
31 errors: number
32 /** 冒険 (セッション) の回数 */
33 sessions: number
34 /** 実績の id → 取った時刻 (ms) */
35 achievements: Record<string, number>
36 /** 今日の日付 (YYYY-MM-DD) とその日のターン数 */
37 day: string
38 dayTurns: number
39 /** 装備 (0.2.0 から。古い store には無い) */
40 equip: RpgEquip
41 /** ここから 0.3.0。戦った回数と勝った回数 */
42 battles: number
43 wins: number
44 /** たまっている遭遇 (ターンを終えるたびに 1 つ、5 つまで)。戦うと 1 つ減る */
45 encounters: number
46 /** SP を振った数 (能力ごと) */
47 alloc: Record<RpgStat, number>
48 /** 覚えたスキルの id → 覚えた時刻 (ms) */
49 learned: Record<string, number>
50 /** 倒したボスの id → 初めて倒した時刻 (ms) */
51 defeated: Record<string, number>
52 /** ここから 0.4.0。湧いたボスの気配 (0 か 1)。あれば気配を使わずにボスに挑めて、経験値が 1.5 倍 */
53 bossSign: number
54 /** ここから 0.6.0。ダンジョンで勝ち抜いた一番深い階 (0 はまだ)。2 つのセッションでは大きい方 */
55 deepest: number
56 /** 今潜っているダンジョン。入口にいれば null */
57 delve: RpgDelve | null
58 /**
59 * ここから 0.6.1。帰った・力尽きた潜り (新しい物が後ろ、20 件まで)。
60 * 別のセッションが同じ潜りを書き戻したり、袋をもう一度持ち帰ったりしないように
61 */
62 doneDelves: RpgDoneDelve[]
63 /**
64 * 記録の世代。やり直し・控えから戻すで 1 つ上がる。
65 * 合わせた時の世代と store の世代が違えば、別のセッションは差分を足さずに store を取る
66 */
67 epoch: number
68}
69
70/** 終わった潜り 1 つ: 潜りの id (始めた時刻) と、持ち帰った経験値 */
71export type RpgDoneDelve = { id: number; xp: number }
72
73/** 最後の階の戦いの写し (絵と一言に使う) */
74export type RpgDelveFight = {
75 floor: number
76 name: string
77 level: number
78 isBoss: boolean
79 shape: string
80 color: string
81 result: 'won' | 'lost'
82 /** 勇者が受けた量と、袋に入れた経験値 */
83 hitMe: number
84 loot: number
85}
86
87/** ダンジョンの 1 回の潜り。ターンを終えるたびに 1 歩進み、決まった歩数で次の階の敵と戦う */
88export type RpgDelve = {
89 /** 潜り始めた時刻 (同じ潜りかを見分ける) */
90 startedAt: number
91 /** 今いる階 (この階の敵とまだ戦っていない) */
92 floor: number
93 /** 次の階の敵までの歩み (0 から DELVE.steps まで) */
94 step: number
95 /** 潜っている間の HP と MP (%)。入口では 100 */
96 hp: number
97 mp: number
98 /** 袋の経験値。帰ると持ち帰り、力尽きると半分になる */
99 loot: number
100 /** 勝ち抜いた階の数 (この潜りの間) */
101 cleared: number
102 /** 乱数の種 (階ごとの敵と戦いが決まる) */
103 seed: number
104 last: RpgDelveFight | null
105}
106
107/** このセッションの間だけの値 */
108export type RpgSession = {
109 startedAt: number
110 /** 最後に休んだ時刻 (HP はここから減る) */
111 restedAt: number
112 lastActiveAt: number
113 toolOk: number
114 toolErr: number
115 edits: number
116 reads: number
117 shells: number
118 recoveries: number
119 /** 今のターンのツールの数とエラーの数 */
120 turnTools: number
121 turnErrors: number
122 /** 今のターンでエラーが出て、まだ成功していない */
123 isErrorPending: boolean
124 /** 戦いで減った HP (%)。休むと 0 に戻る (0.3.1 から。無ければ 0) */
125 wound?: number
126}
127
128export type RpgHero = RpgSaved & {
129 s: RpgSession
130 /** HP の割合 0-100 (1 分ごとに計算し、変わった時だけ書く) */
131 hpPct: number
132 /** 経験値バーのアニメの始まり (今のレベルの中の割合 0-1) */
133 xpFrom: number
134 /** 最後に store と合わせた時の値。保存はここからの差分だけを store に足す (別のセッションの分を消さない) */
135 synced: RpgSaved
136 /**
137 * 画面に出す時だけ置く: equip のうち、今もガチャの図鑑にあるカード。
138 * 無ければ (undefined) equip をそのまま使う。store には書かない
139 */
140 worn?: RpgEquip
141}
142
143/** 敵の癖: なし・MP を吸う・2 回動く・物理をかわす・力をためて大技 */
144export type RpgTrait = 'none' | 'drain' | 'double' | 'evade' | 'heavy'
145
146/** 戦っている敵 */
147export type RpgEnemy = {
148 id: string
149 name: string
150 level: number
151 isBoss: boolean
152 maxHp: number
153 hp: number
154 atk: number
155 def: number
156 spd: number
157 /** 倒した時の経験値 */
158 xp: number
159 trait: RpgTrait
160 /** ドット絵の形と色 */
161 shape: string
162 color: string
163}
164
165/** 戦いの始めに写した勇者の強さ (戦いの間の HP と MP はここで減る) */
166export type RpgFighter = {
167 maxHp: number
168 hp: number
169 maxMp: number
170 mp: number
171 atk: number
172 def: number
173 mag: number
174 spd: number
175 /** 会心の確率 (%) */
176 crit: number
177 /** 毎ラウンドの終わりに戻す HP (最大 HP に対する %) */
178 regen: number
179}
180
181/** 戦い 1 回。セッションの間だけ ($.state)。store には書かない */
182export type RpgBattle = {
183 enemy: RpgEnemy
184 me: RpgFighter
185 round: number
186 /** テストの盾の残りラウンド */
187 guard: number
188 /** ログ解読で敵の守りを下げた */
189 isExposed: boolean
190 /** 敵が力をためている (次の攻撃が 2 倍) */
191 isCharging: boolean
192 usedRollback: boolean
193 /** 乱数の種。1 手ごとに進める (同じ種なら同じ結果) */
194 seed: number
195 /** 新しい物が後ろ。6 行まで */
196 log: string[]
197 result: 'fight' | 'won' | 'lost' | 'fled'
198 /** 勝った時の経験値 */
199 gainedXp: number
200 /** 最後の一手で敵と勇者が受けた量 (絵の揺れと数字に使う) */
201 hitEnemy: number
202 hitMe: number
203}
204
205export type RpgTab = 'status' | 'gear' | 'achievements' | 'battle' | 'growth' | 'dungeon'
206
207/** 記録の控え 1 日分 (hooks/style.ts の Backup と同じ形) */
208export type RpgBackup = { day: string; value: unknown }
209
210/** 「控えから戻す」の画面。list が null なら閉じている。pick は確かめ中の日 */
211export type RpgRestore = { list: RpgBackup[] | null; pick: string | null }
212
213declare module 'claude-code' {
214 interface PluginState {
215 'dev-rpg': {
216 hero: RpgHero
217 tab: RpgTab
218 /** 「冒険をやり直す」を 1 回押して確認待ち */
219 isConfirmingReset: boolean
220 /** 最後の出来事の一言 (実績・レベルアップ)。空なら出さない */
221 news: string
222 /** 今の戦い。戦っていなければ null */
223 battle: RpgBattle | null
224 /** 「控えから戻す」の画面 */
225 restore: RpgRestore
226 }
227 /** turn-gacha が他の mod に見せている物のうち、dev-rpg が読む分だけ (書くのは turn-gacha) */
228 'turn-gacha': {
229 save: { book: Record<string, { count: number; firstAt: number }> }
230 cards: RpgGachaCard[]
231 }
232 /**
233 * daily-quest が他の mod に見せている物のうち、dev-rpg が読む分だけ (書くのは daily-quest)。
234 * daily-quest の types/index.d.ts の写し。入っていない時は undefined なので、読んだ値は rpg.ts の questRewards で確かめる
235 */
236 'daily-quest': {
237 today: { date: string; quests: { id: string; title: string; done: boolean; reward: { exp: number; pulls: number } }[] }
238 }
239 }
240}
241