デバッグ伝説 / Debug Legend — Claude での作業がゲームの燃料になる放置型 RPG / An idle RPG fueled by your work with Claude

Claude Code で作業するほど強くなる、放置型 RPG の mod(プラグイン)です。 Claude に答えてもらうと装備が出る「宝箱」がたまり、時間がたつと主人公が勝手にバグやエラーの敵と戦って、ステージを進んでいきます。
紹介動画(PV・約 60 秒): docs/pv/debug-legend-pv.mp4
デスクトップアプリ
Tomatonandesu/debug-legend を入力ターミナル(Claude Code のプロンプトで)
/plugin install debug-legend --marketplace Tomatonandesu/debug-legend
Add marketplace? には y、スコープはそのまま Enter で選びます。シェルからなら次のとおりです。
claude plugin install debug-legend --marketplace Tomatonandesu/debug-legend
/dl-open)でサイドの広い画面が開きます。コマンド:
| コマンド | 内容 |
|---|---|
/dl | いまの状態 |
/dl-open | サイド画面を開く |
/dl-export | セーブをファイルに書き出す(~/.claude/debug-legend/exports/) |
/dl-import [パス] | 書き出したセーブを読み込む |
/dl-export と /dl-import で移せます)。An idle RPG mod (plugin) for Claude Code that grows stronger the more you work with Claude. Every answer from Claude gives you a treasure chest that drops gear, and as time passes your hero fights bug-and-error monsters on its own and pushes through the stages.
Trailer (about 60 seconds, in Japanese): docs/pv/debug-legend-pv.mp4
Desktop app
Tomatonandesu/debug-legendTerminal (at the Claude Code prompt)
/plugin install debug-legend --marketplace Tomatonandesu/debug-legend
Answer y to Add marketplace? and press Enter for the scope. From a shell:
claude plugin install debug-legend --marketplace Tomatonandesu/debug-legend
/dl-open) for the wide side panel.Commands:
| Command | What it does |
|---|---|
/dl | Show your progress |
/dl-open | Open the side panel |
/dl-export | Export your save to a file (~/.claude/debug-legend/exports/) |
/dl-import [path] | Import an exported save |
/dl-export and /dl-import).hooks/register.tsx 385 lines1import { atom, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { BattleReport, SaveData } from '../types'
5import { syncCodex } from './codex'
6import { announceReport, autoLamp, openPane, withBossShare } from './actions'
7import { stopBgm, syncBgm } from './audio'
8import { detectWindows } from './winsound'
9import { runCheat, runDebugLog, runExport, runImport, runSound, runSummary } from './commands'
10import { DEV_CHEATS, SOUND, TIME, TRAVEL } from './config'
11import { secondsToNextEvent, travelLeft } from './battle'
12import { BUILD } from './build.generated'
13import { buildLabel, cheatsAllowed } from './build'
14import { clock, flushLog, note, setLogSession } from './debuglog'
15import { advanceShared, canLead, isBusyAt, leaseFor, markBusy, pruneBusy } from './lease'
16import { isPaneShown } from './audio'
17import { BOSS_STAGE, stageGroup } from './world'
18import { applyToolSuccess, applyTurn, dayKey } from './fuel'
19import type { PlayDay } from './calendar'
20import { count as countUp, countReport } from './missions'
21import { accruePicks, autoMine } from './mine'
22import { READY, featureDay, recordPlay } from './calendar'
23import type { $ } from './persist'
24import { background, load, mutate, restoreLost, serial, setWriter, show, store, track } from './persist'
25import { T, setDetectedLanguage, useLanguage } from './texts'
26import { tickFarm, waterAll } from './farm'
27import { tickGoldMine } from './goldmine'
28import { answerEgg } from './pets'
29import { awardBossStars } from './premium'
30import { drawBand, drawPane } from './screens'
31
32// デバッグ伝説: 燃料フック(回答・ツール・トークン・時間)、帯・サイド画面・ステータスライン、スラッシュコマンド。
33
34const workingState = atom({ plugin: 'debug-legend', key: 'isWorking' } as const, false)
35
36/** 作業中(このセッションのメインの turn が実行中)か。セーブに作業中の印を付け、ステージ進行が加速する(どのセッションの作業でも) */
37let isWorking = false
38/** このセッション(時間を進める役の持ち主の名前) */
39let me = 'local'
40/** このセッションでサイド画面が開いているか(3 秒ごとに確かめる) */
41let isWatching = false
42/** 時間を進める役か。記録には、変わったときだけ書く */
43let role = ''
44/** このセッションが最後に書いた lastTickAt(ほかのところで書かれたかを見分ける) */
45let lastWrite: number | undefined
46/** 画面に出している「作業中(3 倍速)」 */
47let shownBusy = false
48let tick: { cancel: () => void } | undefined
49/** 次の「できごと」(先頭の敵が倒れる・ボス戦の勝ち負け)の時刻に、戦闘を進めるための予約 */
50let nextEvent: { cancel: () => void } | undefined
51let soundCheck: { cancel: () => void } | undefined
52/** 開発用チートを使えるか。環境変数 DEBUG_LEGEND_DEV=1 は開発版(channel が dev)のときだけ効く。公開版(stamp_build.py --public)は無視する */
53let isCheating = false
54/** まだセーブに入れていないツール成功の回数(次のタイマーでまとめて入れる) */
55let pendingTools = 0
56
57/**
58 * 次の「できごと」の時刻(HP バーが 0 になる時刻と同じ)に戦闘を進めて、画面を描き直す予約をする。
59 * 10 秒ごとのタイマーだけだと、HP バーが 0 になってから敵が消えるまで最大 10 秒かかっていた(ベータの不具合)。
60 * 予約は 1 つだけ(新しく計算するたびに入れ替える)。10 秒より先(ボス戦の決着など)でも入れる
61 * (1.0.1 では入れていなかったので、ボス戦の決着が次の 10 秒のタイマーまで遅れていた)。
62 * save は「いま」の様子(時間を進める役でなければ、セーブを進めて見せている様子)。
63 */
64function scheduleNextEvent($: $, save: SaveData): void {
65 nextEvent?.cancel()
66 nextEvent = undefined
67 travelEnd?.cancel()
68 travelEnd = undefined
69 const speed = TIME.battleSpeed * (isBusyAt(save, save.lastTickAt) ? TIME.workingMultiplier : 1)
70 noteTravel($, save, speed)
71 const ms = (secondsToNextEvent(save) / speed) * 1000
72 if (Number.isFinite(ms)) {
73 // 少しだけ遅らせて、計算の端数で「まだ倒れていない」にならないように
74 const wait = Math.max(100, Math.ceil(ms) + 60)
75 const plannedAt = save.lastTickAt + wait
76 note(save.lastTickAt, 'schedule', `${where(save)} あと ${wait}ms(×${speed})`)
77 nextEvent = $.clock.after(wait, () =>
78 background(
79 $.clock.now().then(now => {
80 note(now, 'fire', `予約 ${now - plannedAt >= 0 ? '+' : ''}${now - plannedAt}ms`)
81 return advanceTime($)
82 }),
83 ),
84 )
85 }
86}
87
88/** 移動の終わりを記録するための予約(戦闘は止まったまま、記録だけ) */
89let travelEnd: { cancel: () => void } | undefined
90/** 始まりを記録した移動(同じ移動を 2 回書かない) */
91let travelNoted = ''
92
93/** 移動の始まり(すでに始まっていれば、その時刻)と終わり(実際に予約が発火した時刻)を記録する */
94function noteTravel($: $, save: SaveData, speed: number): void {
95 const travel = travelLeft(save)
96 if (travel <= 0) {
97 return
98 }
99 const startedAt = save.lastTickAt - Math.round(((TRAVEL.seconds - travel) / speed) * 1000)
100 const key = `${save.stage.chapter}-${save.stage.stage}@${Math.round((save.battleSeconds - (TRAVEL.seconds - travel)) * 10)}`
101 if (key !== travelNoted) {
102 travelNoted = key
103 note(startedAt, 'travel', `移動の始まり → ${save.stage.chapter}-${save.stage.stage}(${TRAVEL.seconds}s・×${speed}、着くのは ${Math.round((TRAVEL.seconds / speed) * 1000)}ms 後)`)
104 }
105 const wait = Math.max(0, Math.round((travel / speed) * 1000))
106 travelEnd = $.clock.after(wait, () =>
107 background($.clock.now().then(now => note(now, 'travel', `移動の終わり(着いた。ここから戦う)${save.stage.chapter}-${save.stage.stage}`))),
108 )
109}
110
111/** 記録に書く「どこにいるか」(例: 2-9 撃破 0.42/3、移動中なら「移動 0.8s」も) */
112const where = (save: SaveData): string =>
113 (save.stage.stage >= BOSS_STAGE && !save.isCleared
114 ? `${save.stage.chapter}-${save.stage.stage} ボス ${save.stage.bossTime.toFixed(1)}s`
115 : `${save.stage.chapter}-${save.stage.stage} 撃破 ${save.stage.kills.toFixed(2)}/${stageGroup(save.stage.chapter, save.stage.stage).length}`) +
116 (travelLeft(save) > 0 ? ` 移動 ${travelLeft(save).toFixed(1)}s` : '')
117
118/** 前に画面に出した様子(時間を進める役でないときの「撃破」の記録に使う) */
119let lastShown: SaveData | undefined
120
121/**
122 * 時間の進行を計算する(タイマーと、作業の始まり・終わりと、できごとの予約で呼ぶ)。
123 * 時間を進めて書くのは、役を持っているセッションだけ(hooks/lease.ts)。ほかのセッションは、セーブを同じ計算で
124 * いままで進めた様子を画面に出す(書くのは、ためておいたツールのゴールドと、自分の作業中の印が変わったときだけ)。
125 */
126async function advanceTime($: $) {
127 let result: { report: BattleReport; before: SaveData; isLeader: boolean } | undefined
128 const save = await serial(async () => {
129 const current = await load($)
130 if (current === undefined) {
131 return undefined
132 }
133 const now = await $.clock.now()
134 const day = dayKey(now, new Date(now).getTimezoneOffset())
135 const isLeader = canLead(current, me, now, isWatching)
136 noteRole(now, current, isLeader)
137 // ほかのプロセスの書き込みで消えた、このセッションの変更を入れ直す(hooks/persist.ts)
138 let changed = restoreLost(current, now, day)
139 // ためておいたツール成功のゴールドもここでまとめて入れる(ツールのたびにセーブを書かないように)
140 const tools = pendingTools
141 pendingTools = 0
142 if (tools > 0) {
143 changed = track(
144 changed,
145 (save, _now, today) => {
146 let next = save
147 for (let i = 0; i < tools; i += 1) {
148 next = applyToolSuccess(next, today).save
149 }
150 // ツール成功 5 回でつるはし 1 回(鉱山が開いてから。1 日 60 回まで)
151 return accruePicks(next, today, tools)
152 },
153 now,
154 day,
155 )
156 }
157 changed = markBusy(changed, me, now, isWorking)
158 if (isLeader) {
159 const advanced = advanceShared(changed, now)
160 // 倒した数・ボス戦の回数を数える(日課・週課・実績。セーブの進みといっしょに書くので、ずれない)
161 const rewarded = awardBossStars(advanced.save, advanced.gain.report.bossWins)
162 const counted = countReport(rewarded, advanced.gain.report, day)
163 // 自動採掘(10 分ごとに最大 10 回。閉じていた分も 8 時間まで追いつく)。叩いた回数・降りた階も数える
164 const mined = autoMine(counted, now)
165 const minedCounted = mined.hits > 0 || mined.stairs > 0 ? countUp(countUp(mined.save, 'mineHits', mined.hits, day), 'stairs', mined.stairs, day) : mined.save
166 // 金山銀山の「作業中だった時間」を足す・スプリンクラーとペットの小屋(大農場)。作業中の印が消える前に数える
167 const ticked = tickFarm(tickGoldMine(minedCounted, now), now, day)
168 const next = withBossShare(pruneBusy({ ...syncCodex(ticked), clock: leaseFor(me, now, isWatching) }), advanced.gain.report, now)
169 await store($, next)
170 lastWrite = next.lastTickAt
171 result = { report: advanced.gain.report, before: current, isLeader }
172 lastShown = next
173 return next
174 }
175 if (changed !== current) {
176 await store($, changed)
177 }
178 const projected = advanceShared(changed, now)
179 if (changed === current) {
180 await show($, projected.save)
181 }
182 result = { report: projected.gain.report, before: lastShown ?? current, isLeader }
183 lastShown = projected.save
184 return projected.save
185 })
186 const done = result as { report: BattleReport; before: SaveData; isLeader: boolean } | undefined
187 if (save !== undefined && done !== undefined) {
188 const { before, report } = done
189 // 群れが変わった(同じ 9 面をくり返すときは、倒した数が 0 に戻って移動が始まる)
190 const isNewGroup =
191 Math.floor(before.stage.kills + 1e-9) !== Math.floor(save.stage.kills + 1e-9) ||
192 before.stage.stage !== save.stage.stage ||
193 before.stage.chapter !== save.stage.chapter ||
194 save.stage.kills < before.stage.kills - 1e-9 ||
195 (travelLeft(save) > travelLeft(before) + 1e-9)
196 const isBossEnd = report.bossWins.length > 0 || report.bossLosses.length > 0
197 if (isNewGroup || (done.isLeader && isBossEnd)) {
198 const how = done.isLeader
199 ? `(+${report.seconds.toFixed(1)}s 撃破 ${report.kills.toFixed(2)}・ステージ +${report.stages}${report.bossWins.length > 0 ? '・ボス勝ち' : ''}${report.bossLosses.length > 0 ? '・ボス負け' : ''})`
200 : '(見るだけ: 時間はほかのセッションが進めている)'
201 note(save.lastTickAt, 'advance', `${where(before)} → ${where(save)}${how}`)
202 }
203 }
204 if (save !== undefined) {
205 const busy = isBusyAt(save, save.lastTickAt)
206 if (busy !== shownBusy) {
207 shownBusy = busy
208 await update($, workingState, () => busy)
209 }
210 scheduleNextEvent($, save)
211 background(flushLog($))
212 }
213 if (done !== undefined && save !== undefined) {
214 // お知らせと効果音は、時間を進めたセッションだけ(同じ撃破を、見るだけのセッションでくり返さない)
215 if (done.isLeader) {
216 announceReport($, done.report, done.before, save)
217 }
218 if (done.before.stage.stage !== save.stage.stage || done.before.isCleared !== save.isCleared) {
219 background(syncBgm($))
220 }
221 }
222
223 return save
224}
225
226/** 時間を進める役が変わったときに記録する(ほかのセッションが書いていたら、それも) */
227function noteRole(now: number, current: SaveData, isLeader: boolean): void {
228 const lease = current.clock
229 const owner = isLeader ? 'me' : `${lease?.owner ?? '?'}@${lease?.build ?? '?'}`
230 if (owner !== role) {
231 role = owner
232 note(
233 now,
234 'role',
235 isLeader
236 ? `このセッションが時間を進める(${lease === null ? '役は空き' : lease.owner === me ? '続けて' : `${lease.owner.slice(0, 4)} から引き継ぎ`})`
237 : `見るだけ(時間は s:${lease?.owner.slice(0, 4) ?? '?'}・ビルド ${lease?.build ?? '?'} が進めている)`,
238 )
239 }
240 // 役を持っているのに、ほかのところでセーブが書かれていた(役を知らないコードが書いた、など)
241 if (isLeader && lease?.owner === me && lastWrite !== undefined && current.lastTickAt !== lastWrite) {
242 note(now, 'other', `このセッションが書いていないセーブ(${clock(current.lastTickAt)} まで進んだ ${where(current)})`)
243 }
244}
245
246/** サイド画面が開いているか確かめる(変わったら記録する) */
247async function watchPane($: $): Promise<void> {
248 const shown = await isPaneShown($)
249 if (shown !== isWatching) {
250 isWatching = shown
251 note(await $.clock.now(), 'pane', shown ? 'サイド画面が見えている' : 'サイド画面が見えていない')
252 }
253}
254
255export const register: Register = on => {
256 on('session.start', async ($, e, next) => {
257 // 言語: Claude Code の言語設定(取れなければ日本語)。セーブの設定で「自動」以外を選んでいればそちら
258 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
259 setDetectedLanguage((settings as Record<string, unknown>).language)
260 const first = await serial(() => load($))
261 useLanguage(first?.settings.language ?? 'auto')
262
263 await $.command.register({ name: 'dl', description: T.command.dl, immediate: true })
264 await $.command.register({ name: 'dl-open', description: T.command.open, immediate: true })
265 await $.command.register({ name: 'dl-export', description: T.command.exportSave, immediate: true })
266 await $.command.register({ name: 'dl-import', description: T.command.importSave, argumentHint: T.command.importHint, immediate: true })
267 isCheating = cheatsAllowed(DEV_CHEATS, BUILD.channel, await $.env.get('DEBUG_LEGEND_DEV'))
268 await detectWindows($)
269 if (isCheating) {
270 await $.command.register({ name: 'dl-sound-test', description: T.command.sound, argumentHint: 'once | loop | stop | b64 | win', immediate: true })
271 await $.command.register({ name: 'dl-cheat', description: T.command.cheat, argumentHint: T.command.cheatHint, immediate: true })
272 }
273
274 // 時間を進める役の名前(セッションごと)。記録の行にも短い印を付ける
275 me = await $.session.id().catch(() => 'local')
276 setLogSession(me)
277 setWriter(me)
278 await watchPane($).catch(() => undefined)
279 // どのコードが、どこから読み込まれて動いているか(記録と /dl-version)
280 note(await $.clock.now(), 'start', `${buildLabel()} 読み込み元 ${$.plugin.root}`)
281 await $.command.register({ name: 'dl-version', description: T.command.version, immediate: true })
282 await $.command.register({ name: 'dl-debuglog', description: T.command.debuglog, argumentHint: 'on | off | clear', immediate: true })
283
284 // 前回からの時間を受け取る(間があいていればオフライン報酬、最大 8 時間。まとめは帯とサイド画面でお知らせ)
285 // (ホットリロードや再起動で、できごとの予約が消えても、ここでまた予約し直す)
286 background(advanceTime($))
287 tick?.cancel()
288 tick = $.clock.every(TIME.tickMs, () =>
289 background(
290 $.clock.now().then(now => {
291 note(now, 'tick', '10 秒のタイマー')
292 return advanceTime($).then(save => autoLamp($, save))
293 }),
294 ),
295 )
296 // サイド画面を開いた・閉じた・タブを変えたのに合わせて BGM を切り替える
297 soundCheck?.cancel()
298 soundCheck = $.clock.every(SOUND.checkMs, () => background(watchPane($).then(() => syncBgm($))))
299
300 return next(e)
301 })
302
303 on('turn.start', async ($, e, next) => {
304 if (e.agentId === undefined) {
305 // セーブに作業中の印を付ける(ここから 3 倍速。ここまでは、いままでの速さで進める)。
306 // 次のできごとの予約も、3 倍速で計算し直す(HP バーも 3 倍速で減る)
307 isWorking = true
308 background(advanceTime($))
309 }
310
311 return next(e)
312 })
313
314 on('turn.complete', async ($, e, next) => {
315 const result = await next(e)
316 if (e.agentId !== undefined) {
317 return result
318 }
319 const usage = e.usage
320 const tokens = usage === undefined ? 0 : usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens
321 background(
322 (async () => {
323 // 作業中の印を閉じる(ここまで 3 倍速)。ふだんの速さに戻ったので、次のできごとの予約を計算し直す
324 isWorking = false
325 await advanceTime($)
326 if (e.reason === 'answer') {
327 // 今日はじめての回答なら、遊んだ日 +1・ログインスタンプ・施設の解放(docs/DAILY.md)
328 let played: PlayDay | undefined
329 const save = await mutate($, (current, now, day) => {
330 const answered = answerEgg(countUp(applyTurn(current, day, tokens).save, 'answers', 1, day))
331 played = recordPlay(answered, day)
332 return waterAll(played.save, now)
333 })
334 const unlocked = played?.unlocked
335 // お知らせは、遊べる施設が開いた日に 1 回だけ(設定でオフにできる)
336 if (save !== undefined && unlocked !== undefined && READY.has(unlocked) && save.settings.notifyUnlock) {
337 $.ui.toast(T.unlockToast(featureDay(unlocked), T.featureName[unlocked] ?? unlocked))
338 }
339 }
340 })(),
341 )
342
343 return result
344 })
345
346 on('tool.call', async ($, e, next) => {
347 const ran = await next(e)
348 if (ran.deny === undefined && ran.isError !== true) {
349 pendingTools += 1
350 }
351
352 return ran
353 }).catch(($, e, next) => next(e))
354
355 on('command.run', { command: 'dl' }, async $ => ({ text: await runSummary($) }))
356 on('command.run', { command: 'dl-version' }, async $ => ({ text: T.versionLine(buildLabel(), $.plugin.root, $.plugin.name) }))
357 on('command.run', { command: 'dl-debuglog' }, async ($, e) => ({ text: await runDebugLog($, e.args) }))
358 on('command.run', { command: 'dl-open' }, async $ => ({ text: await openPane($) }))
359 on('command.run', { command: 'dl-export' }, async $ => ({ text: await runExport($) }))
360 on('command.run', { command: 'dl-import' }, async ($, e) => ({ text: await runImport($, e.args) }))
361 on('command.run', { command: 'dl-sound-test' }, async ($, e) => ({ text: isCheating ? await runSound($, e.args) : T.cheatOff }))
362 on('command.run', { command: 'dl-cheat' }, async ($, e) => ({ text: isCheating ? await runCheat($, e.args) : T.cheatOff }))
363
364 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
365 if (e.props.hasSurvey) {
366 return next(e)
367 }
368 const tree = await drawBand($, e)
369
370 return tree ?? next(e)
371 })
372
373 on('ui.render', { component: 'Pane', requestId: 'debug-legend' }, async ($, e) => drawPane($, e))
374
375 on('session.end', async ($, e, next) => {
376 stopBgm()
377 isWorking = false
378 await advanceTime($).catch(() => undefined)
379 // 時間を進める役を手放す(ほかのセッションがすぐ引き継げるように)
380 await mutate($, (save, now) => (save.clock?.owner === me ? { ...save, clock: { ...save.clock, until: now } } : save)).catch(() => undefined)
381
382 return next(e)
383 })
384}
385hooks/codex.ts 237 lines1import type { CodexState, SaveData } from '../types'
2import type { Reward } from './calendar'
3import { grant, isUnlocked, READY } from './calendar'
4import { CROPS, RECIPES } from './farm'
5import { BOSS_GEAR_IDS, CODEX_TIERS, NAME_VARIANTS, RARITY_COUNT, SET_IDS, SLOTS, codexGearKey } from './gear'
6import { DIARY_COUNT, FOSSIL_COUNT, GEMS, ORES } from './mine'
7import { smithAffinity } from './smith'
8import { CHAPTERS, CHAPTER_COUNT } from './world'
9
10// 図鑑(14 日目、docs/DAILY.md の 14 章)。$ を使わない純粋な関数。
11// 集めたものはなるべく既存のセーブから「いま持っているもの・やったこと」を読み取り(作物・化石・ペット・読み物)、
12// セーブに残らないもの(雑魚の討伐数・ボスの勝ち・売ってしまった装備・金色の作物・料理・鉱石)だけ codex に印を持つ。
13
14export const emptyCodex = (): CodexState => ({ kills: {}, bossWins: {}, bossBest: {}, seen: [], claimed: [] })
15
16/** 図鑑が遊べるか */
17export const isCodexOpen = (save: SaveData): boolean => READY.has('codex') && isUnlocked(save, 'codex')
18
19/** 雑魚 300 種(章の順。1 章 30 種) */
20export const MOB_IDS: readonly string[] = CHAPTERS.flatMap(chapter => chapter.enemies)
21export const BOSS_IDS: readonly string[] = CHAPTERS.map(chapter => chapter.boss)
22
23/** 討伐数の★(1・10・100。1 種あたりの倒す数がおよそ 1/10 になったので、もとの 1・100・1,000 の 1/10) */
24export const MOB_STAR_STEPS: readonly number[] = [1, 10, 100]
25export const mobStars = (kills: number): number => MOB_STAR_STEPS.filter(step => kills >= step).length
26
27export type CategoryId = 'mobs' | 'bosses' | 'gear' | 'crops' | 'dishes' | 'ores' | 'fossils' | 'pets' | 'reads'
28export const CATEGORIES: readonly CategoryId[] = ['mobs', 'bosses', 'gear', 'crops', 'dishes', 'ores', 'fossils', 'pets', 'reads']
29
30/** 料理(まだ作れない 'later' のフルコースは数えない。100% にできなくなるため) */
31export const DISH_IDS: readonly string[] = RECIPES.filter(recipe => recipe.requires !== 'later').map(recipe => recipe.id)
32export const ORE_IDS: readonly string[] = [...ORES, ...GEMS]
33
34/** 読み物の数: 鉱山の日記 12・親方の昔話 6・ノクスの正体 5・学者の手記 10・真のラスボスの伝承 7 */
35export const READ_COUNTS = { mine: DIARY_COUNT, master: 6, nox: 5, scholar: 10, sealed: 7 } as const
36
37/** 図鑑の 1 項目。id は画面で名前を引くときにも使う */
38export type Entry = { id: string; got: boolean; count?: number }
39
40const isSeen = (save: SaveData, key: string): boolean => save.codex.seen.includes(key)
41
42/** 学者の手記を読める数(遠征を終えた回数。冒険者セージが帰ってくるたびに 1 話) */
43const scholarCount = (save: SaveData): number => Math.min(READ_COUNTS.scholar, Math.floor(save.missions.counts.expeditions ?? 0))
44
45/** 親方の昔話を読める数(鍛冶屋が開いていれば 1 話、好感度が上がるたびに 1 話) */
46const masterCount = (save: SaveData): number => (isUnlocked(save, 'smith') ? Math.min(READ_COUNTS.master, 1 + smithAffinity(save)) : 0)
47
48/** 欄ごとの項目 */
49export function categoryEntries(save: SaveData, category: CategoryId): Entry[] {
50 if (category === 'mobs') {
51 return MOB_IDS.map(id => ({ id, got: (save.codex.kills[id] ?? 0) >= 1, count: save.codex.kills[id] ?? 0 }))
52 }
53 if (category === 'bosses') {
54 return [
55 ...BOSS_IDS.map((id, i) => ({ id, got: (save.codex.bossWins[i + 1] ?? 0) >= 1, count: save.codex.bossWins[i + 1] ?? 0 })),
56 { id: 'origin-moth', got: save.seal.trueBoss.beaten >= 1, count: save.seal.trueBoss.beaten },
57 ]
58 }
59 if (category === 'gear') {
60 const normal = SLOTS.flatMap(slot =>
61 Array.from({ length: NAME_VARIANTS }, (_, name) => Array.from({ length: RARITY_COUNT }, (_, rarity) => `gear:${slot}:${name}:${rarity}`)).flat(),
62 )
63 const sets = SET_IDS.flatMap(set => SLOTS.map(slot => `set:${set}:${slot}`))
64 const bosses = BOSS_GEAR_IDS.map(id => `boss:${id}`)
65
66 return [...normal, ...sets, ...bosses].map(id => ({ id, got: isSeen(save, id) }))
67 }
68 if (category === 'crops') {
69 return CROPS.flatMap(crop => [
70 { id: `crop:${crop.id}`, got: save.farm.harvested.includes(crop.id) },
71 { id: `gold:${crop.id}`, got: isSeen(save, `gold:${crop.id}`) },
72 ])
73 }
74 if (category === 'dishes') {
75 return DISH_IDS.map(id => ({ id: `dish:${id}`, got: isSeen(save, `dish:${id}`) }))
76 }
77 if (category === 'ores') {
78 return ORE_IDS.map(id => ({ id: `ore:${id}`, got: isSeen(save, `ore:${id}`) }))
79 }
80 if (category === 'fossils') {
81 return Array.from({ length: FOSSIL_COUNT }, (_, i) => ({ id: `fossil:${i + 1}`, got: save.mine.fossils.includes(i + 1) }))
82 }
83 if (category === 'pets') {
84 // ペットは 2 匹までで手放せないので、仲間にした枠(1 匹目・2 匹目)ごとに 3 形態。まだ居ない枠は未発見
85 return [0, 1].flatMap(slot => [1, 2, 3].map(form => ({ id: `pet:${slot}:${form}`, got: (save.pets.owned[slot]?.form ?? 0) >= form })))
86 }
87 const master = masterCount(save)
88 const scholar = scholarCount(save)
89
90 return [
91 ...Array.from({ length: READ_COUNTS.mine }, (_, i) => ({ id: `read:mine:${i}`, got: save.mine.diaries.includes(i) })),
92 ...Array.from({ length: READ_COUNTS.master }, (_, i) => ({ id: `read:master:${i}`, got: i < master })),
93 ...Array.from({ length: READ_COUNTS.nox }, (_, i) => ({ id: `read:nox:${i}`, got: save.blackMarket.stories.includes(`nox-${i + 1}`) })),
94 ...Array.from({ length: READ_COUNTS.scholar }, (_, i) => ({ id: `read:scholar:${i}`, got: i < scholar })),
95 ...Array.from({ length: READ_COUNTS.sealed }, (_, i) => ({ id: `read:sealed:${i}`, got: save.blackMarket.stories.includes(`sealed-${i + 1}`) })),
96 ]
97}
98
99export function categoryCount(save: SaveData, category: CategoryId): { got: number; total: number } {
100 const entries = categoryEntries(save, category)
101
102 return { got: entries.filter(entry => entry.got).length, total: entries.length }
103}
104
105/**
106 * 収集率(全部の欄の合計)。pct は 0〜100 の小数。
107 * 雑魚は 1 種を 0.1 として数える(雑魚の欄の重さをもとの 30 種ぶんの 30 に保ち、ほかの欄のごほうびの時期を変えないため)。
108 * got / total は重みをかけた数を四捨五入したもの(欄ごとの表示 12/300 などは本当の数)
109 */
110export function codexRate(save: SaveData): { got: number; total: number; pct: number } {
111 // 計算は 10 倍の整数で(0.1 の足し算の誤差を出さない)
112 let got = 0
113 let total = 0
114 for (const category of CATEGORIES) {
115 const count = categoryCount(save, category)
116 const weight = category === 'mobs' ? 1 : 10
117 got += count.got * weight
118 total += count.total * weight
119 }
120
121 return { got: Math.round(got / 10), total: Math.round(total / 10), pct: total === 0 ? 0 : (got / total) * 100 }
122}
123
124// ---- ごほうび ----
125
126/** 収集率の段階ごとのごほうび(戦闘力の上がり幅は gear.ts の CODEX_TIERS) */
127export const TIER_REWARDS: Record<number, readonly Reward[]> = {
128 10: [{ kind: 'stars', amount: 20 }],
129 25: [{ kind: 'psi', amount: 2 }],
130 50: [{ kind: 'stars', amount: 50 }, { kind: 'title', id: 'codex-explorer' }],
131 75: [{ kind: 'psi', amount: 3 }, { kind: 'stars', amount: 100 }],
132 100: [{ kind: 'stars', amount: 200 }, { kind: 'title', id: 'codex-master' }],
133}
134
135/** 欄ごとのコンプリートのごほうび */
136export const CATEGORY_REWARD: readonly Reward[] = [{ kind: 'stars', amount: 30 }, { kind: 'psi', amount: 1 }]
137
138export type Claim = { id: string; rewards: readonly Reward[]; power: number }
139
140/** いまもらえるごほうび(図鑑が開いていて、まだもらっていないもの) */
141export function claimable(save: SaveData): Claim[] {
142 if (!isCodexOpen(save)) {
143 return []
144 }
145 const { pct } = codexRate(save)
146 const claims: Claim[] = []
147 for (const tier of CODEX_TIERS) {
148 const id = `tier:${tier.pct}`
149 if (pct >= tier.pct - 1e-9 && !save.codex.claimed.includes(id)) {
150 claims.push({ id, rewards: TIER_REWARDS[tier.pct] ?? [], power: tier.power })
151 }
152 }
153 for (const category of CATEGORIES) {
154 const id = `cat:${category}`
155 const count = categoryCount(save, category)
156 if (count.total > 0 && count.got >= count.total && !save.codex.claimed.includes(id)) {
157 claims.push({ id, rewards: CATEGORY_REWARD, power: 0 })
158 }
159 }
160
161 return claims
162}
163
164/** ごほうびを受け取る(もらえないものは undefined) */
165export function claimCodex(save: SaveData, id: string): SaveData | undefined {
166 const claim = claimable(save).find(candidate => candidate.id === id)
167 if (claim === undefined) {
168 return undefined
169 }
170
171 return grant({ ...save, codex: { ...save.codex, claimed: [...save.codex.claimed, id] } }, claim.rewards)
172}
173
174// ---- 記録 ----
175
176/** 料理を作ったこと */
177export function recordDish(save: SaveData, id: string): SaveData {
178 const key = `dish:${id}`
179
180 return save.codex.seen.includes(key) ? save : { ...save, codex: { ...save.codex, seen: [...save.codex.seen, key] } }
181}
182
183/**
184 * いまのセーブから、見つけたものの印を足す(装備・鉱石・金色の作物・料理)。足すものが無ければ同じセーブを返す。
185 * 売ってしまった装備は gear.ts の judge が出たときに印を付ける。
186 */
187export function syncCodex(save: SaveData): SaveData {
188 const known = new Set(save.codex.seen)
189 const found: string[] = []
190 const see = (key: string): void => {
191 if (!known.has(key)) {
192 known.add(key)
193 found.push(key)
194 }
195 }
196 const items = [...SLOTS.map(slot => save.equipment[slot]), ...save.stash, save.pending]
197 for (const item of items) {
198 if (item === null) {
199 continue
200 }
201 see(codexGearKey(item))
202 if (item.set !== undefined) see(`set:${item.set}:${item.slot}`)
203 if (item.unique !== undefined) see(`boss:${item.unique}`)
204 }
205 for (const [id, count] of Object.entries(save.bag)) {
206 if (count <= 0) {
207 continue
208 }
209 if (ORE_IDS.includes(id)) see(`ore:${id}`)
210 const gold = /^crop-([a-z]+)-gold$/.exec(id)
211 if (gold !== null) see(`gold:${gold[1]}`)
212 if (id === 'dish-onigiri') see('dish:onigiri')
213 if (id === 'pet-meal') see('dish:pet-meal')
214 }
215 for (const buff of save.buffs) {
216 if (buff.dish !== undefined) see(`dish:${buff.dish}`)
217 }
218 if (save.farm.last?.quality === 'gold') see(`gold:${save.farm.last.crop}`)
219
220 return found.length === 0 ? save : { ...save, codex: { ...save.codex, seen: [...save.codex.seen, ...found] } }
221}
222
223/** 版 20 に上げたとき: 分かる範囲で図鑑に入れる(倒した章の雑魚とボスは 1 回、いまの装備・持ち物) */
224export function backfillCodex(save: SaveData): SaveData {
225 const kills: Record<string, number> = { ...save.codex.kills }
226 const bossWins: Record<number, number> = { ...save.codex.bossWins }
227 const cleared = new Set(save.beatenBosses)
228 for (let chapter = 1; chapter <= CHAPTER_COUNT; chapter += 1) {
229 if (cleared.has(chapter) || chapter < save.stage.chapter) {
230 for (const id of CHAPTERS[chapter - 1]?.enemies ?? []) kills[id] = Math.max(1, kills[id] ?? 0)
231 }
232 if (cleared.has(chapter)) bossWins[chapter] = Math.max(1, bossWins[chapter] ?? 0)
233 }
234
235 return syncCodex({ ...save, codex: { ...save.codex, kills, bossWins } })
236}
237hooks/actions.ts 923 lines1import type { AdventurerId, BattleReport, Drop, FeatureId, JobId, Language, PetSpecies, SaveData, Settings, SlotId } from '../types'
2import type { Effect } from './audio'
3import { dropEffect, playEffect, syncBgm } from './audio'
4import { heroTitle, itemName, rarityName } from './art'
5import { challengeBoss, changeJob, evolve } from './battle'
6import { markSeen, priceUnit } from './calendar'
7import { claimCodex, recordDish, syncCodex } from './codex'
8import { challengeTrueBoss, offerSoul, runTrial, startHardLoop } from './seal'
9import type { TrialResult, TrueBossRun } from './seal'
10import { addBuff } from './buffs'
11import { hasPsychicNode, learnPsychic as learnPsychicPure, recordPsychicProc, resetPsychic as resetPsychicPure } from './psychic'
12import { note } from './debuglog'
13import { claimAchievement, claimMission, count as countUp, swapDaily } from './missions'
14import { canDig, collectCell, ensureFloor, fightBoss, goToFloor, isMineOpen, picksOf, rollMine, swing, toggleAuto, toggleBombMode, upgradePickaxe, useBomb } from './mine'
15import {
16 buildDecor, buildGreenhouse, buyAnimal, buyFlowerSeed, chooseSignName, collectAnimal, cook as cookFood, expandFarm, farmLevel, feedAnimal,
17 fertilize as fertilizeFarmPlot, harvest as harvestFarmPlot, installSprinkler, killPest, patAnimal, plant as plantFarmCrop,
18} from './farm'
19import { collect as collectGoldMine, fixDerail, pickNugget, smeltGoldIngot, upgrade as upgradeGoldMine, useGoldCart } from './goldmine'
20import type { Upgrade as GoldMineUpgrade } from './goldmine'
21import type { Scope } from './missions'
22import { claimTrip as claimExpeditionTrip, startTrip as startExpeditionTrip } from './expedition'
23import type { ExpeditionSetup } from './expedition'
24import { chooseEgg, choosePairEgg, toggleActivePet, equipCollar, evolvePet as evolveCompanionPure, feedPet as feedCompanionPure, patPet as patCompanionPure, removeCollar, renamePet, setActivePet } from './pets'
25import { buy, greet, refresh, sellItem, takeAmulet, takeLuckyBag, useItem } from './shop'
26import { applyGoldTrim, combine as combineSmithGear, craftBossGear, craftSet, disassemble as disassembleSmithGear, enhance as enhanceSmithGear, equipStashed, inheritPlus, reforge as reforgeSmithGear, sellStashed, weeklyGuardGift } from './smith'
27import type { EnhanceResult } from './smith'
28import { GEAR, LAMP } from './config'
29import { combatPower, pullLamp, resolvePending, sellValue } from './gear'
30import type { $ } from './persist'
31import { background, load, mutate } from './persist'
32import type { BlackMarketItemId } from './premium'
33import { buyBlackMarket, buyPremium, exchangeSilver, finishBlackMarketIntro, visitBlackMarket } from './premium'
34import { T } from './texts'
35import { BOSS_STAGE, chapterOf } from './world'
36
37// ボタンやタイマーから呼ぶゲームの操作。
38
39export const PANE_ID = 'debug-legend'
40
41/** エピック以上が出たときだけトーストと X ポストのボタン(画面の外は控えめに) */
42const NOTABLE_FROM = 3
43
44const bestDrop = (drops: readonly Drop[]): Drop | undefined => [...drops].sort((a, b) => b.item.rarity - a.item.rarity)[0]
45
46function logPsychicProcs(before: SaveData | undefined, after: SaveData | undefined): void {
47 if (before === undefined || after === undefined || after.psychic.procCount <= before.psychic.procCount) return
48 const events = after.psychic.procLog.filter(event => event.total > before.psychic.procCount)
49 if (events.length === 0) {
50 const count = after.psychic.procCount - before.psychic.procCount
51 note(after.psychic.procAt, 'psychic', T.psychicProc(T.psychicNodeName[after.psychic.lastProc] ?? after.psychic.lastProc, count))
52 return
53 }
54 for (const event of events) {
55 note(event.at, 'psychic', T.psychicProc(T.psychicNodeName[event.id] ?? event.id, event.count))
56 }
57}
58
59/** ドロップを知らせる: 効果音(サイド画面を開いているとき)と、エピック以上のトースト(設定でオフにできる) */
60export function announce($: $, drops: readonly Drop[], settings: Settings): void {
61 const best = bestDrop(drops)
62 if (best === undefined) {
63 return
64 }
65 background(playEffect($, dropEffect(best.item.rarity), settings))
66 if (best.item.rarity >= NOTABLE_FROM && settings.notifyEpic) {
67 $.ui.toast(T.dropToast(rarityName(best.item.rarity), itemName(best.item)))
68 }
69}
70
71/** エピック以上が出ていれば、X にポストできるできごととして残す */
72export function withDropShare(save: SaveData, drops: readonly Drop[], now: number): SaveData {
73 const best = bestDrop(drops)
74
75 return best !== undefined && best.item.rarity >= NOTABLE_FROM ? { ...save, share: { kind: 'drop', item: best.item, at: now } } : save
76}
77
78/** ランプを最大 count 回叩く。出た装備を返す */
79export async function pressLamp($: $, count = 1): Promise<Drop[]> {
80 const before = await load($)
81 let drops: Drop[] = []
82 const save = await mutate($, (current, now, day) => {
83 // ほかのプロセスに消されて入れ直すときにも呼ばれるので、出た装備は毎回作り直す
84 const pulled: Drop[] = []
85 let next = current
86 for (let i = 0; i < count; i += 1) {
87 const one = pullLamp(next, now)
88 if (one === undefined) {
89 break
90 }
91 next = one.save
92 pulled.push(one.drop)
93 }
94 drops = pulled
95 if (pulled.length === 0) return current
96 // 開けた宝箱の数を数える(日課・週課・実績)
97 return syncCodex(withDropShare(countUp(next, 'chests', pulled.length, day), pulled, now))
98 })
99 if (save !== undefined && drops.length > 0) {
100 logPsychicProcs(before, save)
101 background(playEffect($, 'lamp', save.settings))
102 announce($, drops, save.settings)
103 }
104
105 return drops
106}
107
108/** 手動確認待ちの装備を決める */
109export async function decide($: $, choice: 'equip' | 'sell'): Promise<void> {
110 await mutate($, (save, now) => resolvePending(save, choice, now)?.save ?? save)
111}
112
113export type Toggle = { [K in keyof Settings]: Settings[K] extends boolean ? K : never }[keyof Settings]
114export type Volume = 'bgmVolume' | 'seVolume'
115
116export async function toggleSetting($: $, key: Toggle): Promise<void> {
117 await mutate($, save => ({ ...save, settings: { ...save.settings, [key]: !save.settings[key] } }))
118 await syncBgm($)
119}
120
121/** 音量を 1 段階上げ下げする(0〜10) */
122export async function changeVolume($: $, key: Volume, step: number): Promise<void> {
123 const save = await mutate($, current => ({ ...current, settings: { ...current.settings, [key]: Math.max(0, Math.min(10, current.settings[key] + step)) } }))
124 await syncBgm($)
125 if (save !== undefined && key === 'seVolume') {
126 await playEffect($, 'drop-common', save.settings)
127 }
128}
129
130const LANGUAGE_ORDER: readonly Language[] = ['auto', 'ja', 'en']
131
132/** 言語を 自動 → 日本語 → English → 自動 の順に切り替える */
133export async function cycleLanguage($: $): Promise<void> {
134 await mutate($, save => {
135 const next = LANGUAGE_ORDER[(LANGUAGE_ORDER.indexOf(save.settings.language) + 1) % LANGUAGE_ORDER.length] ?? 'auto'
136 return { ...save, settings: { ...save.settings, language: next } }
137 })
138}
139
140/** X ポストのできごとを閉じる */
141export async function dismissShare($: $): Promise<void> {
142 await mutate($, save => ({ ...save, share: null }))
143}
144
145/** タイマーから: 自動ランプがオンなら少しずつ叩く */
146export async function autoLamp($: $, save: SaveData | undefined): Promise<void> {
147 if (save !== undefined && save.settings.autoLamp && save.lamps > 0 && save.pending === null) {
148 await pressLamp($, GEAR.autoLampPerTick)
149 }
150}
151
152export async function openPane($: $): Promise<string> {
153 const opened = await $.ui.open({ id: PANE_ID, title: T.paneTitle })
154 background(syncBgm($))
155
156 return opened.isPlaced ? T.paneOpened : T.paneWaiting(opened.reason)
157}
158
159/** /dl とステータスタブで使う、いまの状態の行 */
160export function summaryLines(save: SaveData, day: string, expNeed: number, stagePercent: number): string[] {
161 const daily = save.daily.day === day ? save.daily : { turnLamps: 0, toolGold: 0, tokenBonus: 0 }
162
163 return [
164 T.status(save.level, combatPower(save)),
165 T.expLine(Math.floor(save.exp), expNeed),
166 T.percentIn(T.stageName(save.stage.chapter, save.stage.stage), stagePercent),
167 T.summaryDrops(T.drops(save.lamps), LAMP.stockCap, save.gold),
168 T.summaryToday(daily.turnLamps, LAMP.dailyTurnCap, daily.toolGold, daily.tokenBonus.toFixed(2)),
169 T.summaryTotal(save.totals.turns, save.totals.toolCalls, save.totals.tokens, save.totals.drops),
170 ]
171}
172
173/**
174 * 時間で進んだ結果を知らせる。画面の外(トースト)はボス撃破だけ(設定でオフにできる)。
175 * 効果音はサイド画面を開いているときだけ: ボス撃破・ボスに負け・ボス登場・レベルアップ。
176 */
177export function announceReport($: $, report: BattleReport, before: SaveData, after: SaveData): void {
178 const settings = after.settings
179 logPsychicProcs(before, after)
180 for (const chapter of report.bossWins) {
181 if (settings.notifyBoss) {
182 $.ui.toast(T.bossWinToast(T.enemy[chapterOf(chapter).boss] ?? ''))
183 }
184 }
185 // 本編のボスを初めて倒して魂を手に入れた(14 日目以降)
186 for (const chapter of after.souls) {
187 if (!before.souls.includes(chapter) && settings.notifyBoss) {
188 $.ui.toast(T.soulToast(chapter, T.enemy[chapterOf(chapter).boss] ?? ''))
189 }
190 }
191 const isBossNow = after.stage.stage >= BOSS_STAGE && !after.isCleared
192 const wasBoss = before.stage.stage >= BOSS_STAGE && !before.isCleared
193 const effect: Effect | undefined =
194 report.bossWins.length > 0 ? 'victory' : report.bossLosses.length > 0 ? 'hit' : isBossNow && !wasBoss ? 'boss-appear' : report.levels > 0 ? 'levelup' : undefined
195 if (effect !== undefined) {
196 background(playEffect($, effect, settings))
197 }
198}
199
200/** ボスを倒していれば、X にポストできるできごととして残す */
201export function withBossShare(save: SaveData, report: BattleReport, now: number): SaveData {
202 const chapter = report.bossWins[report.bossWins.length - 1]
203
204 return chapter === undefined ? save : { ...save, share: { kind: 'boss', chapter, at: now } }
205}
206
207/** 転職する */
208export async function chooseJob($: $, job: JobId): Promise<boolean> {
209 let isChanged = false
210 const save = await mutate($, (current, now) => {
211 const changed = changeJob(current, job, now)
212 isChanged = changed !== undefined
213 // X ポストのお知らせは、見習いから初めて職に就いたときだけ(横に移るたびには出さない)
214 const isFirst = current.job === 'novice'
215 return changed === undefined ? current : isFirst ? { ...changed, share: { kind: 'job', job, at: now } } : changed
216 })
217 if (isChanged && save !== undefined) {
218 background(playEffect($, 'jobchange', save.settings))
219 }
220
221 return isChanged
222}
223
224/** 進化する */
225export async function evolveHero($: $): Promise<boolean> {
226 let isEvolved = false
227 const save = await mutate($, (current, now) => {
228 const evolved = evolve(current)
229 isEvolved = evolved !== undefined
230 return evolved === undefined ? current : { ...evolved, share: { kind: 'evolve', job: evolved.job, evolution: evolved.evolution, at: now } }
231 })
232 if (isEvolved && save !== undefined) {
233 background(playEffect($, 'jobchange', save.settings))
234 $.ui.toast(T.evoDone(heroTitle(save.job, save.evolution)))
235 }
236
237 return isEvolved
238}
239
240/** 「ボスに挑む」 */
241export async function challenge($: $): Promise<void> {
242 await mutate($, save => challengeBoss(save) ?? save)
243 await syncBgm($)
244}
245
246/** 施設の説明を見終わった(タブの NEW の印が消える) */
247export async function seeFeature($: $, id: FeatureId): Promise<void> {
248 await mutate($, save => markSeen(save, id))
249}
250
251/** 施設の説明をもう一度見る */
252export async function replayIntro($: $, id: FeatureId): Promise<void> {
253 await mutate($, save => ({ ...save, calendar: { ...save.calendar, seen: save.calendar.seen.filter(other => other !== id) } }))
254}
255
256/** 称号を付ける(null で外す)。持っていない称号は付けない */
257export async function wearTitle($: $, id: string | null): Promise<void> {
258 await mutate($, save => (id !== null && !save.titles.owned.includes(id) ? save : { ...save, titles: { ...save.titles, current: id } }))
259}
260
261// ---- 商店 ----
262
263/** 変えられなかった(お金が足りない・上限など)ときは false */
264async function tryChange($: $, change: (save: SaveData, now: number, day: string) => SaveData | undefined): Promise<boolean> {
265 let isDone = false
266 await mutate($, (save, now, day) => {
267 const next = change(save, now, day)
268 isDone = next !== undefined
269 // 図鑑: 手に入れた装備・鉱石・金色の作物・料理の印をつける
270 return next === undefined ? save : syncCodex(next)
271 })
272
273 return isDone
274}
275
276export function learnPsychicNode($: $, id: string): Promise<boolean> {
277 return tryChange($, save => learnPsychicPure(save, id))
278}
279
280export function resetPsychicTree($: $): Promise<boolean> {
281 return tryChange($, save => resetPsychicPure(save))
282}
283
284export async function activateMidasTouch($: $): Promise<boolean> {
285 let activatedAt = 0
286 const done = await tryChange($, (save, now, day) => {
287 if (!hasPsychicNode(save, 'fortune-10') || save.psychic.midasDay === day) return undefined
288 const buffed = addBuff(save, 'midas', 4, { seconds: 10 * 60 })
289 if (buffed === undefined) return undefined
290 activatedAt = now
291 return recordPsychicProc({ ...buffed, psychic: { ...buffed.psychic, midasDay: day } }, 'fortune-10', now)
292 })
293 if (done) note(activatedAt, 'psychic', T.psychicProc(T.psychicNodeName['fortune-10'] ?? 'fortune-10', 1))
294 return done
295}
296
297export async function buyGood($: $, id: string, count: number): Promise<boolean> {
298 const isDone = await tryChange($, (save, _now, day) => {
299 const bought = buy(greet(save, day), day, id, count)
300 // 買った回数(まとめ買いも 1 回)を数える
301 return bought === undefined ? undefined : countUp(bought, 'shopBuys', 1, day)
302 })
303 if (isDone) {
304 const save = await load($)
305 if (save !== undefined) {
306 background(playEffect($, 'drop-common', save.settings))
307 }
308 }
309
310 return isDone
311}
312
313export function refreshShop($: $): Promise<boolean> {
314 return tryChange($, (save, _now, day) => refresh(save, day))
315}
316
317/** 街タブを開いたとき: その日はじめてなら店主にあいさつ(好感度 +2) */
318export function greetShop($: $): Promise<boolean> {
319 return tryChange($, (save, _now, day) => (save.shop.greetDay === day ? undefined : greet(save, day)))
320}
321
322export function sellGood($: $, id: string, count: number): Promise<boolean> {
323 return tryChange($, save => sellItem(save, id, count))
324}
325
326/** 持ち物を使う(宝箱チケット・薬・伝説箱・神話箱)。箱から出た装備はドロップと同じように知らせる */
327export async function useBagItem($: $, id: string): Promise<boolean> {
328 const before = await load($)
329 const isDone = await tryChange($, (save, now, day) => {
330 const used = useItem(save, id, now)
331 // 薬を使った回数を数える
332 return used === undefined || !id.startsWith('potion-') ? used : countUp(used, 'potions', 1, day)
333 })
334 const after = await load($)
335 if (isDone) logPsychicProcs(before, after)
336 if (isDone && after !== undefined && after.lastDrop !== null && after.lastDrop.item.id !== before?.lastDrop?.item.id) {
337 announce($, [after.lastDrop], after.settings)
338 } else if (!isDone && after !== undefined) {
339 $.ui.toast(T.useFailed)
340 }
341
342 return isDone
343}
344
345export function takeShopAmulet($: $): Promise<boolean> {
346 return tryChange($, (save, now, day) => takeAmulet(save, day, now))
347}
348
349export function takeShopLuckyBag($: $): Promise<boolean> {
350 return tryChange($, (save, _now, day) => takeLuckyBag(save, day))
351}
352
353export function buyPremiumGood($: $, id: string): Promise<boolean> {
354 return tryChange($, (save, now, day) => buyPremium(save, day, now, id))
355}
356
357export function visitNightMarket($: $): Promise<boolean> {
358 return tryChange($, (save, now) => visitBlackMarket(save, now))
359}
360
361export function buyNightMarketGood($: $, id: BlackMarketItemId): Promise<boolean> {
362 return tryChange($, (save, now) => buyBlackMarket(save, now, id))
363}
364
365export function finishNightMarketIntro($: $): Promise<boolean> {
366 return tryChange($, save => {
367 const next = finishBlackMarketIntro(save)
368 return next === save ? undefined : next
369 })
370}
371
372// ---- 鍛冶屋 ----
373
374export async function enhanceGear($: $, id: number, useGuardStone: boolean): Promise<boolean> {
375 let result = undefined as EnhanceResult | undefined
376 const done = await tryChange($, (save, _now, day) => {
377 result = enhanceSmithGear(save, id, useGuardStone)
378 if (result === undefined) return undefined
379 result = { ...result, save: weeklyGuardGift(result.save, day) ?? result.save }
380 return countUp(result.save, 'enhances', 1, day)
381 })
382 if (done && result !== undefined) {
383 $.ui.toast(T.smithAttempt(result.success, result.plus, result.protected))
384 }
385 return done
386}
387
388export function inheritGearPlus($: $, fromId: number, toId: number): Promise<boolean> {
389 return tryChange($, save => inheritPlus(save, fromId, toId))
390}
391
392export function combineGear($: $, ids: readonly number[]): Promise<boolean> {
393 return tryChange($, save => combineSmithGear(save, ids))
394}
395
396export function craftSetGear($: $, setId: string, slot: SlotId): Promise<boolean> {
397 return tryChange($, save => craftSet(save, setId, slot))
398}
399
400export function craftBossGearAction($: $, uniqueId: string): Promise<boolean> {
401 return tryChange($, save => craftBossGear(save, uniqueId))
402}
403
404export function equipStashGear($: $, id: number): Promise<boolean> {
405 return tryChange($, save => equipStashed(save, id))
406}
407
408export function sellStashGear($: $, id: number): Promise<boolean> {
409 return tryChange($, save => {
410 const item = save.stash.find(candidate => candidate.id === id)
411 const sold = sellStashed(save, id)
412 if (item === undefined || sold === undefined) return sold
413 return { ...sold, gold: sold.gold + sellValue(item, save) - sellValue(item) }
414 })
415}
416
417export function disassembleGear($: $, id: number): Promise<boolean> {
418 return tryChange($, save => disassembleSmithGear(save, id))
419}
420
421export function reforgeGear($: $, id: number): Promise<boolean> {
422 return tryChange($, save => reforgeSmithGear(save, id))
423}
424
425// ---- 仲間 ----
426
427export function choosePetEgg($: $, species: PetSpecies): Promise<boolean> {
428 return tryChange($, save => chooseEgg(save, species))
429}
430
431export function namePet($: $, id: string, name: string): Promise<boolean> {
432 return tryChange($, save => renamePet(save, id, name))
433}
434
435export function activatePet($: $, id: string): Promise<boolean> {
436 return tryChange($, save => setActivePet(save, id))
437}
438
439export function patPet($: $, id: string): Promise<boolean> {
440 return tryChange($, (save, _now, day) => patCompanionPure(save, id, day))
441}
442
443export function feedCompanion($: $, petId: string, item: 'treat' | 'pet-meal'): Promise<boolean> {
444 return tryChange($, save => feedCompanionPure(save, item, petId))
445}
446
447export function evolveCompanion($: $, id: string): Promise<boolean> {
448 return tryChange($, save => evolveCompanionPure(save, id))
449}
450
451export function equipPetCollar($: $, petId: string, collar: string): Promise<boolean> {
452 return tryChange($, save => equipCollar(save, petId, collar))
453}
454
455export function removePetCollar($: $, petId: string): Promise<boolean> {
456 return tryChange($, save => removeCollar(save, petId))
457}
458
459// ---- 遠征 ----
460
461export function sendExpedition($: $, id: AdventurerId, setup: ExpeditionSetup): Promise<boolean> {
462 return tryChange($, (save, now) => startExpeditionTrip(save, id, setup, now))
463}
464
465export async function collectExpedition($: $, id: AdventurerId): Promise<boolean> {
466 let result = undefined as ReturnType<typeof claimExpeditionTrip>
467 const done = await tryChange($, (save, now, day) => {
468 result = claimExpeditionTrip(save, id, now)
469 return result === undefined ? undefined : countUp(result.save, 'expeditions', 1, day)
470 })
471 if (done && result !== undefined) {
472 const current = await load($)
473 const summary = Object.entries(result.items).map(([item, amount]) => T.reward.item(T.bagItem[item] ?? item, amount)).join('・')
474 $.ui.toast(T.expClaimToast(T.adventurerName[id] ?? id, T.expOutcomeName[result.outcome] ?? result.outcome, summary, result.gold, result.stars))
475 if (result.drop !== undefined && current !== undefined) announce($, [result.drop], current.settings)
476 }
477
478 return done
479}
480
481// ---- 鉱山 ----
482
483/** 採集タブ(鉱山)を開いたとき: 日付が変わっていれば今日の分に切り替え(星鉄のつるはしの毎日のつるはし)、階を作っておく */
484export function visitMine($: $): Promise<boolean> {
485 return tryChange($, (save, _now, day) => {
486 const next = ensureFloor(rollMine(save, day))
487
488 return next === save || !isMineOpen(save) ? undefined : next
489 })
490}
491
492/** マスを押した結果: dug = 掘って開けた(賞品はマスに出る)、collected = 賞品をもらった、far = 閉じていてまだ掘れない、nopick = つるはしが無い、none = 何も起きない */
493export type DigStatus = 'dug' | 'collected' | 'far' | 'nopick' | 'none'
494
495/** 入れ直すときも、操作した階・周回でだけ行う。別の階にいれば、そちらのマスと行き先は保つ。 */
496function mineAction(change: (save: SaveData, now: number, day: string) => SaveData | undefined): (save: SaveData, now: number, day: string) => SaveData | undefined {
497 let origin: { floor: number; round: number } | undefined
498 return (save, now, day) => {
499 origin ??= { floor: save.mine.floor, round: save.mine.round }
500 if (save.mine.round !== origin.round) return undefined
501 const active = save.mine.floor
502 const target = active === origin.floor ? save : goToFloor(save, origin.floor, now)
503 if (target === undefined) return undefined
504 const next = change(target, now, day)
505 return next === undefined || active === origin.floor ? next : goToFloor(next, active, now)
506 }
507}
508
509/**
510 * マスを押す。閉じたマスは掘る(爆弾モードで爆弾があれば、3×3 を爆弾で開ける。爆弾はすぐもらう)、
511 * 賞品のある開いたマスはもらう、開いた階段は何も起きない(降りるのは moveMineFloor)。叩いた回数は日課・週課に数える。全部の階を掘り終えて鉱山が新しくなったら、お知らせを出す
512 */
513export async function digCell($: $, index: number, floor?: number, round?: number): Promise<DigStatus> {
514 const before = await load($)
515 const result: { status: DigStatus } = { status: 'none' }
516 let intent: 'dig' | 'fight' | 'collect' | undefined
517 let tool: 'bomb' | 'pick' | undefined
518 await tryChange($, mineAction((save, now, day) => {
519 result.status = 'none'
520 const rolled = rollMine(save, day)
521 if (!isMineOpen(rolled)) {
522 return undefined
523 }
524 const floored = ensureFloor(rolled)
525 // 画面を出したあとに、ほかのセッションが階や周回を変えていたら押し直してもらう。
526 if ((floor !== undefined && floored.mine.floor !== floor) || (round !== undefined && floored.mine.round !== round)) return undefined
527 const cell = floored.mine.grid[index]
528 if (cell === undefined) {
529 return undefined
530 }
531 const action = !cell.open ? 'dig' : cell.alive === true ? 'fight' : 'collect'
532 intent ??= action
533 // ほかのセッションが開けたマスで、掘る操作を「取る」や「戦う」に変えない。
534 if (action !== intent) return undefined
535 if (cell.open && cell.alive !== true) {
536 if (cell.prize !== undefined) {
537 const next = collectCell(floored, index, now)
538 result.status = next === undefined ? 'none' : 'collected'
539
540 return next
541 }
542 return undefined
543 }
544 if (!canDig(floored.mine.grid, index)) {
545 result.status = 'far'
546
547 return undefined
548 }
549 tool ??= floored.mine.bomb && (floored.bag.bomb ?? 0) > 0 ? 'bomb' : 'pick'
550 if (tool === 'bomb') {
551 const next = useBomb(floored, index, now)
552 result.status = next === undefined ? 'none' : 'dug'
553
554 return next
555 }
556 if (picksOf(floored) < 1) {
557 result.status = 'nopick'
558
559 return undefined
560 }
561 const done = swing(floored, index, now, true)
562 result.status = done === undefined ? 'none' : 'dug'
563
564 return done === undefined ? undefined : countUp(done.save, 'mineHits', done.hits, day)
565 }))
566 const { status } = result
567 if (status === 'far') $.ui.toast(T.mineFarToast)
568 else if (status === 'nopick') $.ui.toast(T.mineNoPickToast)
569 else if (status !== 'none') {
570 const after = await load($)
571 logPsychicProcs(before, after)
572 if (before !== undefined && after !== undefined && after.mine.round > before.mine.round) $.ui.toast(T.mineResetToast)
573 }
574
575 return status
576}
577
578/** 階 floor へ移る(行ったことのある階はどこへでも。deepest + 1 は降りられるときだけ)。新しい階(deepest + 1)に初めて降りたときだけ、降りた回数を数える */
579export function moveMineFloor($: $, floor: number): Promise<boolean> {
580 let round: number | undefined
581 return tryChange($, (save, now, day) => {
582 round ??= save.mine.round
583 if (save.mine.round !== round) return undefined
584 const rolled = rollMine(save, day)
585 const next = goToFloor(rolled, floor, now)
586 if (next === undefined) {
587 return undefined
588 }
589
590 return next.mine.deepest > rolled.mine.deepest ? countUp(next, 'stairs', 1, day) : next
591 })
592}
593
594/** 階段を降りる(次の階。moveMineFloor と同じ動き) */
595export function descendMine($: $): Promise<boolean> {
596 let floor: number | undefined
597 let round: number | undefined
598 return tryChange($, (save, now, day) => {
599 floor ??= save.mine.floor + 1
600 round ??= save.mine.round
601 if (save.mine.round !== round) return undefined
602 const rolled = rollMine(save, day)
603 const next = goToFloor(rolled, floor, now)
604 if (next === undefined) {
605 return undefined
606 }
607
608 return next.mine.deepest > rolled.mine.deepest ? countUp(next, 'stairs', 1, day) : next
609 })
610}
611
612/** ボスに挑む。勝てば 1、負ければ 0、挑めなければ -1 */
613export async function challengeMineBoss($: $): Promise<number> {
614 let result = -1
615 let floor = 0
616 await tryChange($, mineAction((save, now, day) => {
617 const rolled = rollMine(save, day)
618 const fought = fightBoss(rolled, now)
619 result = fought === undefined ? -1 : fought.isWin ? 1 : 0
620 floor = rolled.mine.floor
621
622 return fought?.save
623 }))
624 const save = await load($)
625 if (result === 1 && save !== undefined) {
626 background(playEffect($, 'victory', save.settings))
627 $.ui.toast(T.mineBossWinToast(floor))
628 } else if (result === 0 && save !== undefined) {
629 background(playEffect($, 'hit', save.settings))
630 }
631
632 return result
633}
634
635export function toggleBomb($: $): Promise<boolean> {
636 return tryChange($, save => toggleBombMode(save))
637}
638
639export function toggleAutoMine($: $): Promise<boolean> {
640 return tryChange($, (save, now) => toggleAuto(save, now))
641}
642
643export async function upgradeMinePickaxe($: $): Promise<boolean> {
644 const isDone = await tryChange($, save => upgradePickaxe(save))
645 if (isDone) {
646 const save = await load($)
647 if (save !== undefined) {
648 background(playEffect($, 'levelup', save.settings))
649 }
650 }
651
652 return isDone
653}
654
655// ---- 農場・台所 ----
656
657/** 種をまく */
658export function plantCrop($: $, index: number, crop: string): Promise<boolean> {
659 return tryChange($, (save, now) => plantFarmCrop(save, index, crop, now))
660}
661
662/** 肥料をまく */
663export function fertilizePlot($: $, index: number): Promise<boolean> {
664 return tryChange($, (save, now) => fertilizeFarmPlot(save, index, now))
665}
666
667/** 実った作物を収穫し、日課にも数える */
668export async function harvestPlot($: $, index: number): Promise<boolean> {
669 let leveledUp = false
670 const isDone = await tryChange($, (save, now, day) => {
671 const result = harvestFarmPlot(save, index, now)
672 leveledUp = result?.isLevelUp ?? false
673
674 return result === undefined ? undefined : countUp(result.save, 'harvests', 1, day)
675 })
676 if (isDone && leveledUp) {
677 const save = await load($)
678 if (save !== undefined) {
679 $.ui.toast(T.farmLevelUp(farmLevel(save)))
680 }
681 }
682
683 return isDone
684}
685
686/** 区画の害虫を駆除し、日課にも数える */
687export function clearFarmPest($: $, index: number): Promise<boolean> {
688 return tryChange($, (save, now, day) => {
689 const next = killPest(save, index, now)
690
691 return next === undefined ? undefined : countUp(next, 'pests', 1, day)
692 })
693}
694
695/** 料理を作り、日課にも数える */
696export function cookRecipe($: $, id: string): Promise<boolean> {
697 return tryChange($, (save, _now, day) => {
698 const next = cookFood(save, id)
699
700 return next === undefined ? undefined : countUp(recordDish(next, id), 'cooks', 1, day)
701 })
702}
703
704// ---- 日課・週課・実績 ----
705
706/** 日課・週課のごほうびを受け取る(まだ達成していなければ false) */
707export async function claimMissionReward($: $, scope: Scope, index: number): Promise<boolean> {
708 const isDone = await tryChange($, (save, _now, day) => claimMission(save, day, scope, index))
709 await playClaimSound($, isDone)
710
711 return isDone
712}
713
714/** 今日の日課を 1 つ入れ替える(1 日 1 回) */
715export function swapDailyMission($: $, index: number): Promise<boolean> {
716 return tryChange($, (save, _now, day) => swapDaily(save, day, index))
717}
718
719/** 実績のごほうびを受け取る */
720export async function claimAchievementReward($: $, id: string): Promise<boolean> {
721 const isDone = await tryChange($, save => claimAchievement(save, id))
722 await playClaimSound($, isDone)
723
724 return isDone
725}
726
727async function playClaimSound($: $, isDone: boolean): Promise<void> {
728 if (isDone) {
729 const save = await load($)
730 if (save !== undefined) {
731 background(playEffect($, 'drop-common', save.settings))
732 }
733 }
734}
735
736/** オフライン報酬のお知らせを閉じる */
737export async function dismissAway($: $): Promise<void> {
738 await mutate($, save => ({ ...save, away: null }))
739}
740
741// ---- 金山・銀山 ----
742
743/** 回収する(金鉱・銀鉱が持ち物に入る) */
744export async function collectGoldMineOre($: $): Promise<boolean> {
745 const isDone = await tryChange($, (save, now) => collectGoldMine(save, now))
746 if (isDone) {
747 const save = await load($)
748 if (save !== undefined) {
749 background(playEffect($, 'drop-common', save.settings))
750 }
751 }
752
753 return isDone
754}
755
756export function fixGoldMineDerail($: $): Promise<boolean> {
757 return tryChange($, (save, now) => fixDerail(save, now))
758}
759
760/** 金の塊を拾う(星のかけら) */
761export async function pickGoldNugget($: $): Promise<boolean> {
762 const isDone = await tryChange($, (save, now) => pickNugget(save, now))
763 if (isDone) {
764 $.ui.toast(T.goldNuggetToast)
765 }
766
767 return isDone
768}
769
770/** 金のトロッコを使う(1 日だけ生産 2 倍) */
771export function useGoldCartItem($: $): Promise<boolean> {
772 return tryChange($, (save, now) => useGoldCart(save, now))
773}
774
775export async function upgradeGoldMineStep($: $, kind: GoldMineUpgrade): Promise<boolean> {
776 const isDone = await tryChange($, (save, now) => upgradeGoldMine(save, kind, now))
777 if (isDone) {
778 const save = await load($)
779 if (save !== undefined) {
780 background(playEffect($, 'levelup', save.settings))
781 }
782 }
783
784 return isDone
785}
786
787export function smeltIngot($: $): Promise<boolean> {
788 return tryChange($, save => smeltGoldIngot(save))
789}
790
791/** 銀鉱 100 個を星のかけら 10 に換える(週の上限あり) */
792export function exchangeSilverForStars($: $): Promise<boolean> {
793 return tryChange($, (save, _now, day) => exchangeSilver(save, day))
794}
795
796/** 金鉱で装備に金の装飾を付ける */
797export function trimGear($: $, id: number): Promise<boolean> {
798 return tryChange($, save => applyGoldTrim(save, id))
799}
800
801// ---- 大農場 ----
802
803export function expandFarmPlots($: $): Promise<boolean> {
804 return tryChange($, save => expandFarm(save, priceUnit(save)))
805}
806
807export function buildFarmGreenhouse($: $): Promise<boolean> {
808 return tryChange($, save => buildGreenhouse(save, priceUnit(save)))
809}
810
811export function buyGreenhouseFlowerSeed($: $): Promise<boolean> {
812 return tryChange($, save => buyFlowerSeed(save, priceUnit(save)))
813}
814
815export function installFarmSprinkler($: $): Promise<boolean> {
816 return tryChange($, (save, now) => installSprinkler(save, now))
817}
818
819export function buildFarmDecor($: $, id: string): Promise<boolean> {
820 return tryChange($, save => buildDecor(save, id, priceUnit(save)))
821}
822
823export function chooseFarmSign($: $, index: number): Promise<boolean> {
824 return tryChange($, save => chooseSignName(save, index))
825}
826
827export function buyRanchAnimal($: $, id: string): Promise<boolean> {
828 return tryChange($, save => buyAnimal(save, id, priceUnit(save)))
829}
830
831export function feedRanchAnimal($: $, id: string, boost = false): Promise<boolean> {
832 return tryChange($, (save, now) => feedAnimal(save, id, now, boost))
833}
834
835export function patRanchAnimal($: $, id: string): Promise<boolean> {
836 return tryChange($, (save, _now, day) => patAnimal(save, id, day))
837}
838
839/** 家畜の産物をとり、日課の「収穫」にも数える */
840export function collectRanchProduct($: $, id: string): Promise<boolean> {
841 return tryChange($, (save, now, day) => {
842 const result = collectAnimal(save, id, now)
843
844 return result === undefined ? undefined : countUp(result.save, 'harvests', 1, day)
845 })
846}
847
848// ---- ペット 2 匹目 ----
849
850export function choosePairPetEgg($: $, species: PetSpecies): Promise<boolean> {
851 return tryChange($, save => choosePairEgg(save, species))
852}
853
854export function toggleCompanion($: $, id: string): Promise<boolean> {
855 return tryChange($, save => toggleActivePet(save, id))
856}
857
858// ---- 図鑑・封印の魔法陣 ----
859
860/** 図鑑のごほうびを受け取る */
861export async function claimCodexReward($: $, id: string): Promise<boolean> {
862 const isDone = await tryChange($, save => claimCodex(save, id))
863 await playClaimSound($, isDone)
864
865 return isDone
866}
867
868/** 台座に魂を捧げる */
869export async function offerSealSoul($: $, chapter: number): Promise<boolean> {
870 const isDone = await tryChange($, save => offerSoul(save, chapter))
871 if (isDone) {
872 const save = await load($)
873 if (save !== undefined) {
874 background(playEffect($, 'levelup', save.settings))
875 }
876 }
877
878 return isDone
879}
880
881/** 魂の試練に挑む。結果(勝ち負け・時間)を返す。挑めなければ undefined */
882export async function challengeSoulTrial($: $, chapter: number): Promise<TrialResult | undefined> {
883 let result: TrialResult | undefined
884 await tryChange($, (save, now) => {
885 // ほかのプロセスに消されて入れ直すときにも呼ばれるので、結果は毎回作り直す
886 result = runTrial(save, chapter, now)
887
888 return result?.save
889 })
890 const save = await load($)
891 if (result !== undefined && save !== undefined) {
892 background(playEffect($, result.outcome.isWin ? 'victory' : 'hit', save.settings))
893 $.ui.toast(result.outcome.isWin ? T.trialWinToast(chapter, result.outcome.killTime) : T.trialLoseToast(chapter))
894 }
895
896 return result
897}
898
899/** 真のラスボス「オリジン・モス」に挑む */
900export async function challengeOriginMoth($: $): Promise<TrueBossRun | undefined> {
901 let run: TrueBossRun | undefined
902 await tryChange($, (save, now) => {
903 run = challengeTrueBoss(save, now)
904
905 return run?.save
906 })
907 const save = await load($)
908 if (run !== undefined && save !== undefined) {
909 background(playEffect($, run.result.isWin ? 'victory' : 'hit', save.settings))
910 $.ui.toast(run.result.isWin ? T.trueBossWinToast : T.trueBossLoseToast)
911 }
912
913 return run
914}
915
916/** 「ハードで第 1 章から」 */
917export async function beginHardLoop($: $): Promise<boolean> {
918 const isDone = await tryChange($, save => startHardLoop(save))
919 await syncBgm($)
920
921 return isDone
922}
923hooks/audio.ts 111 lines1import { atom, read } from 'claude-code'
2
3import type { Settings } from '../types'
4import type { $ } from './persist'
5import { background } from './persist'
6import { setBgmWindows, playEffectWindows, stopBgmWindows, useWindowsSound } from './winsound'
7import type { BgmTrack } from './music'
8import { BGM_ASSETS, CROSSFADE_MS, trackFor } from './music'
9
10// BGM と効果音。音はサイド画面が表示されている間だけ(画面の外は控えめに)。
11// $.audio.play は鳴らせない環境(Windows のターミナル・デスクトップアプリなど)ではすぐ終わるか失敗するので、
12// 失敗は黙って無視する(何もしない)。
13
14const saveView = atom({ plugin: 'debug-legend', key: 'save' } as const, null)
15const tab = atom({ plugin: 'debug-legend', key: 'tab' } as const, 'battle')
16
17
18export type Effect =
19 | 'attack'
20 | 'crit'
21 | 'hit'
22 | 'defeat'
23 | 'lamp'
24 | 'drop-common'
25 | 'drop-rare'
26 | 'drop-epic'
27 | 'levelup'
28 | 'jobchange'
29 | 'boss-appear'
30 | 'victory'
31
32
33const PANE_ID = 'debug-legend'
34
35const bgmGain = (settings: Settings): number => (settings.isMuted ? 0 : settings.bgmVolume / 10)
36const seGain = (settings: Settings): number => (settings.isMuted ? 0 : settings.seVolume / 10)
37
38/** いま流している BGM(モジュールを読み直すと止まる) */
39let playing: { track: BgmTrack; gain: number; controller: AbortController } | undefined
40
41export function stopBgm(): void {
42 playing?.controller.abort()
43 playing = undefined
44 stopBgmWindows()
45}
46
47/** サイド画面が表示されているか */
48export async function isPaneShown($: $): Promise<boolean> {
49 const panes = await $.ui.panes().catch(() => [])
50
51 return panes.some(pane => pane.id === PANE_ID && pane.isShown && pane.isPlaced)
52}
53
54/** 流す BGM を切り替える(null で止める)。同じ曲・同じ音量なら何もしない */
55export function setBgm($: $, track: BgmTrack | null, settings: Settings): void {
56 const gain = bgmGain(settings)
57 if (track === null || gain === 0) {
58 stopBgm()
59 return
60 }
61 // Windows で「Windows で音を鳴らす」がオンなら PowerShell で(winsound.ts)
62 if (useWindowsSound(settings.windowsSound)) {
63 playing?.controller.abort()
64 playing = undefined
65 background(setBgmWindows($, track, gain))
66 return
67 }
68 stopBgmWindows()
69 if (playing !== undefined && playing.track === track && playing.gain === gain) {
70 return
71 }
72 // 新しい曲を先に鳴らし始めて、少し重ねてから前の曲を止める(短いクロスフェード)
73 const previous = playing
74 if (previous !== undefined) {
75 $.clock.after(CROSSFADE_MS, () => previous.controller.abort())
76 }
77 const controller = new AbortController()
78 playing = { track, gain, controller }
79 background($.audio.play({ asset: BGM_ASSETS[track] }, { shouldLoop: true, signal: controller.signal, gain }))
80}
81
82/** サイド画面・タブ・ステージに合わせて BGM を合わせる(タイマーと、画面を開いた・タブを変えたときに呼ぶ) */
83export async function syncBgm($: $): Promise<void> {
84 const save = await read($, saveView)
85 if (save === null) {
86 return
87 }
88 if (!(await isPaneShown($))) {
89 setBgm($, null, save.settings)
90 return
91 }
92 const current = await read($, tab)
93 setBgm($, trackFor({ chapter: save.stage.chapter, stage: save.stage.stage, isCleared: save.isCleared, tab: current }), save.settings)
94}
95
96/** 効果音を 1 回鳴らす(サイド画面が表示されているときだけ) */
97export async function playEffect($: $, effect: Effect, settings: Settings): Promise<void> {
98 const gain = seGain(settings)
99 if (gain === 0 || !(await isPaneShown($))) {
100 return
101 }
102 if (useWindowsSound(settings.windowsSound)) {
103 background(playEffectWindows($, `se-${effect}`, gain))
104 return
105 }
106 background($.audio.play({ asset: `sounds/se-${effect}.mp3` }, { gain }))
107}
108
109/** ドロップのレア度に合った効果音 */
110export const dropEffect = (rarity: number): Effect => (rarity >= 3 ? 'drop-epic' : rarity >= 2 ? 'drop-rare' : 'drop-common')
111hooks/winsound.ts 183 lines1import type { $ } from './persist'
2import { background } from './persist'
3import type { BgmTrack } from './music'
4import { CROSSFADE_MS, bgmLength } from './music'
5
6// Windows で音を鳴らす(設定「Windows で音を鳴らす」がオンで、Windows のときだけ)。
7// Claude Code の $.audio.play は Windows では鳴らないので、Windows 標準の PowerShell(System.Windows.Media.MediaPlayer)に鳴らさせる。
8// DESIGN.md のセキュリティ方針「外部プロセスを起動しない」の、ただ 1 つの例外。守っていること:
9// - 鳴らすのは mod に同梱した決まった音のファイルだけ(下の FILES の一覧から選ぶ。外から来た値は引数に入れない)
10// - 音量は 0〜1 の数字だけ、ファイルの場所は mod のフォルダ($.plugin.root)から作り、使えない文字が入っていたら鳴らさない
11// - 同時に鳴らす効果音の数・起動の間隔・1 分あたりの回数に上限。BGM は 1 つだけ、最長 10 分で自分で終わる
12
13export const FILES = {
14 'bgm-ch1': 'sounds\\bgm-ch1.mp3',
15 'bgm-ch2': 'sounds\\bgm-ch2.mp3',
16 'bgm-ch3': 'sounds\\bgm-ch3.mp3',
17 'bgm-ch4': 'sounds\\bgm-ch4.mp3',
18 'bgm-ch5': 'sounds\\bgm-ch5.mp3',
19 'bgm-ch6': 'sounds\\bgm-ch6.mp3',
20 'bgm-ch7': 'sounds\\bgm-ch7.mp3',
21 'bgm-ch8': 'sounds\\bgm-ch8.mp3',
22 'bgm-ch9': 'sounds\\bgm-ch9.mp3',
23 'bgm-ch10': 'sounds\\bgm-ch10.mp3',
24 'bgm-boss': 'sounds\\bgm-boss.mp3',
25 'bgm-boss-final': 'sounds\\bgm-boss-final.mp3',
26 'bgm-menu': 'sounds\\bgm-menu.mp3',
27 'se-attack': 'sounds\\se-attack.mp3',
28 'se-crit': 'sounds\\se-crit.mp3',
29 'se-hit': 'sounds\\se-hit.mp3',
30 'se-defeat': 'sounds\\se-defeat.mp3',
31 'se-lamp': 'sounds\\se-lamp.mp3',
32 'se-drop-common': 'sounds\\se-drop-common.mp3',
33 'se-drop-rare': 'sounds\\se-drop-rare.mp3',
34 'se-drop-epic': 'sounds\\se-drop-epic.mp3',
35 'se-levelup': 'sounds\\se-levelup.mp3',
36 'se-jobchange': 'sounds\\se-jobchange.mp3',
37 'se-boss-appear': 'sounds\\se-boss-appear.mp3',
38 'se-victory': 'sounds\\se-victory.mp3',
39} as const
40
41export type SoundFile = keyof typeof FILES
42export type BgmFile = BgmTrack
43
44export const LIMITS = {
45 /** 同時に鳴らす効果音の数 */
46 maxEffects: 3,
47 /** 効果音を起動する最短の間隔(ミリ秒) */
48 minGapMs: 150,
49 /** 1 分あたりの効果音の数 */
50 perMinute: 40,
51 /** 効果音 1 つを鳴らし終えるまで待つ時間(ミリ秒) */
52 effectMs: 2_500,
53 /** BGM を起動し直す最短の間隔(ミリ秒) */
54 bgmGapMs: 2_000,
55 /** BGM の 1 回の起動で鳴らし続ける最長の時間(分)。過ぎたら止まり、画面が開いていればまた起動する */
56 bgmMinutes: 10,
57}
58
59let isWindows = false
60
61/** Windows かどうかを調べておく(session.start で 1 回) */
62export async function detectWindows($: $): Promise<void> {
63 isWindows = (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
64}
65
66/** 設定がオンで、Windows のときだけ true */
67export const useWindowsSound = (isEnabled: boolean): boolean => isEnabled && isWindows
68
69export const isWindowsHost = (): boolean => isWindows
70
71/** 音のファイルの場所。mod のフォルダ + 決まったファイル名。使えない文字が入っていたら undefined(鳴らさない) */
72export function soundPath(root: string, file: SoundFile): string | undefined {
73 const full = `${root.replace(/\//g, '\\').replace(/\\+$/, '')}\\${FILES[file]}`
74
75 return /^[A-Za-z]:\\[^'"`$\r\n\0;&|<>]*\.mp3$/.test(full) ? full : undefined
76}
77
78const volume = (gain: number): string => (Number.isFinite(gain) ? Math.max(0, Math.min(1, gain)) : 0).toFixed(2)
79
80const open = (path: string, gain: number): string =>
81 `Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Volume = ${volume(gain)}; $p.Open([Uri]::new('${path}'));`
82
83/** 効果音 1 回の PowerShell の中身 */
84export const effectScript = (path: string, gain: number): string =>
85 `${open(path, gain)} $p.Play(); Start-Sleep -Milliseconds ${LIMITS.effectMs}; $p.Stop(); $p.Close()`
86
87/**
88 * BGM(ループ)の PowerShell の中身。始めの 0.6 秒で音量を 0 から上げる(フェードイン)。
89 * 0.1 秒ごとに再生位置を見て、曲の終わりに来たら頭に戻す(MP3 は表の長さより少し短いことがあるので、位置を見るのが確実。
90 * 位置が分からないときは表の長さで戻す)。0.5 秒ごとに 1 行書くのは、止めたいときにすぐ止められるようにするため。
91 */
92export const bgmScript = (path: string, gain: number, loopMs: number): string =>
93 `${open(path, 0)} $p.Play(); for ($v = 1; $v -le 6; $v++) { $p.Volume = ${volume(gain)} * $v / 6; Start-Sleep -Milliseconds 100 }; ` +
94 `$end = (Get-Date).AddMinutes(${LIMITS.bgmMinutes}); $first = $true; $near = [TimeSpan]::FromMilliseconds(80); ` +
95 `while ((Get-Date) -lt $end) { if (-not $first) { $p.Position = [TimeSpan]::Zero; $p.Play() }; $first = $false; $t = 0; ` +
96 `while ($t -lt ${Math.round(loopMs)} -and (Get-Date) -lt $end) { Start-Sleep -Milliseconds 100; $t += 100; ` +
97 `if ($t % 500 -eq 0) { [Console]::Out.WriteLine('.') }; ` +
98 `if ($p.NaturalDuration.HasTimeSpan -and $p.Position -ge ($p.NaturalDuration.TimeSpan - $near)) { break } } }; ` +
99 `$p.Stop(); $p.Close()`
100
101export const powershell = (script: string): string[] => ['powershell.exe', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script]
102
103// ---- 効果音 ----
104
105let running = 0
106let lastStart = 0
107const recent: number[] = []
108
109/** 効果音を 1 回鳴らす(上限を超えるときは鳴らさない) */
110export async function playEffectWindows($: $, file: SoundFile, gain: number): Promise<void> {
111 const now = await $.clock.now()
112 while (recent.length > 0 && now - (recent[0] ?? 0) > 60_000) {
113 recent.shift()
114 }
115 const path = soundPath($.plugin.root, file)
116 if (path === undefined || running >= LIMITS.maxEffects || now - lastStart < LIMITS.minGapMs || recent.length >= LIMITS.perMinute) {
117 return
118 }
119 running += 1
120 lastStart = now
121 recent.push(now)
122 try {
123 await $.process.run(powershell(effectScript(path, gain)), { timeoutMs: 10_000 })
124 } finally {
125 running -= 1
126 }
127}
128
129// ---- BGM ----
130
131let bgm: { file: BgmFile; gain: number; stop: () => void; isDone: boolean } | undefined
132let lastBgmStart = 0
133
134export function stopBgmWindows(): void {
135 bgm?.stop()
136 bgm = undefined
137}
138
139/** BGM を流す(同じ曲・同じ音量で流れていれば何もしない) */
140export async function setBgmWindows($: $, file: BgmFile, gain: number): Promise<void> {
141 if (bgm !== undefined && !bgm.isDone && bgm.file === file && bgm.gain === gain) {
142 return
143 }
144 const now = await $.clock.now()
145 const path = soundPath($.plugin.root, file)
146 if (path === undefined || now - lastBgmStart < LIMITS.bgmGapMs) {
147 return
148 }
149 // 曲を切り替えるときは、新しい曲を先に鳴らし始めて、少し重ねてから前の曲を止める(短いクロスフェード)
150 const previous = bgm
151 bgm = undefined
152 if (previous !== undefined) {
153 $.clock.after(CROSSFADE_MS, () => previous.stop())
154 }
155 lastBgmStart = now
156 const stream = $.process.spawn({ argv: powershell(bgmScript(path, gain, bgmLength(file))) })
157 const pieces = stream[Symbol.asyncIterator]()
158 const handle = {
159 file,
160 gain,
161 isDone: false,
162 stop: () => {
163 handle.isDone = true
164 background(Promise.resolve(pieces.return?.(undefined as never)))
165 },
166 }
167 bgm = handle
168 background(
169 (async () => {
170 try {
171 while (!handle.isDone) {
172 const piece = await pieces.next()
173 if (piece.done) {
174 break
175 }
176 }
177 } finally {
178 handle.isDone = true
179 }
180 })(),
181 )
182}
183hooks/commands.ts 502 lines1import { atom, read, update } from 'claude-code'
2
3import type { Drop, SaveData } from '../types'
4import { announce, summaryLines, withDropShare } from './actions'
5import { LAMP, LEVEL, SAVE_FILES, TIME } from './config'
6import { MAX_EVOLUTION, fight, stageProgress } from './battle'
7import { addExp, expToNext } from './fuel'
8import type { DayLog } from './sim'
9import { TYPICAL, firstDay, simulate } from './sim'
10import { BOSS_STAGE, CHAPTER_COUNT } from './world'
11import { ROADMAP_DAYS, addItem, setPlayedDays } from './calendar'
12import { enterFloor, ORES } from './mine'
13import { CROPS, WEATHERS, cheatWeather, growAll, seedId } from './farm'
14import { completeMissions, countReport } from './missions'
15import { cheatFinishTrips } from './expedition'
16import { PET_SPECIES, cheatHatch, cheatPetLevel } from './pets'
17import { awardBossStars, cheatOpenBlackMarket } from './premium'
18import { cheatReadyTrueBoss } from './seal'
19import { RARITY_COUNT, SLOTS, itemPower, judge, rollItem } from './gear'
20import type { $ } from './persist'
21import { background, load, mutate, serial, store, today } from './persist'
22import { SAVE_KEY, SaveFormatError, SaveTooNewError, exportText, importText, migrate, newSave } from './save'
23import { T } from './texts'
24import { buildLabel } from './build'
25import { clearLog, formatLog, isLogging, readLog } from './debuglog'
26import { isWindowsHost, playEffectWindows } from './winsound'
27
28// スラッシュコマンドの中身: 状態の表示、書き出し / 読み込み、開発用チート、音の確認。
29
30const problem = atom({ plugin: 'debug-legend', key: 'problem' } as const, null)
31
32let loop: AbortController | undefined
33
34// ---- 書き出し / 読み込み ----
35
36async function exportFolder($: $): Promise<string> {
37 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
38
39 return `${home.replace(/\\/g, '/')}/.claude/debug-legend/exports`
40}
41
42function stamp(now: number): string {
43 const local = new Date(now - new Date(now).getTimezoneOffset() * 60 * 1000).toISOString()
44
45 return local.slice(0, 19).replace(/[-:]/g, '').replace('T', '-')
46}
47
48/**
49 * いまのセーブをファイルに書き出し、そのパスを返す。
50 * 同じ秒に書き出しても上書きしないように、同じ名前があれば -2, -3 … と番号を付ける。
51 */
52async function writeExport($: $, save: SaveData, prefix: string): Promise<string> {
53 const now = await $.clock.now()
54 return writeJson($, exportText(save, now), prefix, now)
55}
56
57async function writeJson($: $, text: string, prefix: string, now: number): Promise<string> {
58 const base = `${await exportFolder($)}/${prefix}-${stamp(now)}`
59 for (let i = 1; i <= SAVE_FILES.maxNameTries; i += 1) {
60 const path = i === 1 ? `${base}.json` : `${base}-${i}.json`
61 if (!(await $.fs.exists(path))) {
62 await $.fs.write(path, text)
63 return path
64 }
65 }
66 throw new Error(T.exportNoName)
67}
68
69async function newestExport($: $): Promise<string | undefined> {
70 const folder = await exportFolder($)
71 const entries = await $.fs.list(folder).catch(() => [])
72 const newest = entries
73 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json') && entry.name.startsWith('save-'))
74 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
75
76 return newest === undefined ? undefined : `${folder}/${newest.name}`
77}
78
79export async function runExport($: $): Promise<string> {
80 const save = await serial(() => load($))
81 if (save === undefined) {
82 return T.paused((await read($, problem)) ?? '')
83 }
84 const path = await writeExport($, save, 'save')
85 const copied = await $.ui.copy({ text: exportText(save, await $.clock.now()) }).catch(() => ({ isCopied: false }))
86
87 return T.exported(path, copied.isCopied)
88}
89
90export async function runImport($: $, args: string): Promise<string> {
91 const arg = args.trim().replace(/^["']|["']$/g, '')
92 let text: string
93 if (arg.startsWith('{')) {
94 text = arg
95 } else {
96 const path = arg === '' ? await newestExport($) : arg
97 if (path === undefined) {
98 return T.importNothing(await exportFolder($))
99 }
100 try {
101 // 大きすぎるファイルは中身を読まずに断る
102 const { size } = await $.fs.stat(path)
103 if (size > SAVE_FILES.importMaxBytes) {
104 return T.importFailed(T.saveError.tooLarge(SAVE_FILES.importMaxBytes))
105 }
106 text = await $.fs.read(path)
107 } catch (error) {
108 return T.importFailed(error instanceof Error ? error.message : String(error))
109 }
110 }
111
112 return serial(async () => {
113 let incoming: SaveData
114 try {
115 incoming = importText(text, await $.clock.now())
116 } catch (error) {
117 return T.importFailed(error instanceof Error ? error.message : String(error))
118 }
119 // 読み込む前のセーブを残しておく(読めないセーブでも、生のまま残す)
120 const before = await $.store.get(SAVE_KEY)
121 let old: SaveData | undefined
122 let backup: string
123 try {
124 old = migrateOrNew(before, incoming.lastTickAt)
125 backup = old === undefined
126 ? await writeJson($, JSON.stringify(before, null, 2), 'before-import-raw', await $.clock.now())
127 : await writeExport($, old, 'before-import')
128 } catch (error) {
129 return T.importFailed(error instanceof Error ? error.message : String(error))
130 }
131 // 書いた変更の印は前のセーブから引き継ぐ(読み込んだセーブに、消されたと思った変更を入れ直さないように)
132 // 担当の印(clock)と作業中の印(busy)はほかの端末・セッションのものなので持ち込まない
133 await store($, { ...incoming, lastTickAt: await $.clock.now(), applied: old?.applied ?? [], clock: null, busy: [] })
134 await update($, problem, () => null)
135
136 return T.imported(backup)
137 })
138}
139
140function migrateOrNew(stored: unknown, now: number): SaveData | undefined {
141 try {
142 return migrate(stored, now)
143 } catch (error) {
144 if (error instanceof SaveTooNewError || error instanceof SaveFormatError) return undefined
145 throw error
146 }
147}
148
149// ---- 開発用チート ----
150
151/** 開発用チート。使えるかどうか(DEV_CHEATS / DEBUG_LEGEND_DEV)は register.tsx で決める */
152export async function runCheat($: $, args: string): Promise<string> {
153 // 何行かまとめて貼ったときは、1 行ずつ順に動かす(行の頭の /dl-cheat は取る)
154 const lines = args.split(/\r?\n/).map(line => line.trim().replace(/^\/dl-cheat\s*/, '')).filter(line => line !== '')
155 if (lines.length > 1) {
156 const results: string[] = []
157 for (const line of lines) {
158 results.push(await runCheat($, line))
159 }
160 return results.join('\n')
161 }
162 const [head = '', value = ''] = args.trim().split(/\s+/)
163 const n = Number(value)
164 if (head === 'max' && args.trim() === 'max') {
165 return cheatMax($)
166 }
167 if (head === 'stars') {
168 if (value === '' || !Number.isInteger(n) || n < 0 || n > 100_000) return T.cheatHelp
169 await mutate($, save => ({ ...save, wallet: { ...save.wallet, stars: save.wallet.stars + n } }))
170 return T.cheatStars(n)
171 }
172 if (head === 'psi') {
173 if (value === '' || !Number.isInteger(n) || n < 0 || n > 100_000) return T.cheatHelp
174 await mutate($, save => ({ ...save, wallet: { ...save.wallet, psi: save.wallet.psi + n } }))
175 return T.cheatPsi(n)
176 }
177 if (head === 'night' && args.trim() === 'night') {
178 await mutate($, (save, now) => cheatOpenBlackMarket(save, now))
179 return T.cheatNight
180 }
181 if (head === 'grow' && args.trim() === 'grow') {
182 await mutate($, (save, now) => growAll(save, now))
183 return T.cheatGrow
184 }
185 if (head === 'weather') {
186 const input = value.toLocaleLowerCase()
187 const weather = WEATHERS.find(id => id === input || T.farmWeatherName[id]?.toLocaleLowerCase() === input)
188 if (weather === undefined) {
189 return T.cheatHelp
190 }
191 await mutate($, (save, _now, day) => cheatWeather(save, day, weather) ?? save)
192 return T.cheatWeatherResult(T.farmWeatherName[weather] ?? weather)
193 }
194 if (head === 'seed') {
195 const parts = args.trim().split(/\s+/)
196 const amount = Number(parts[parts.length - 1])
197 const label = parts.slice(1, -1).join(' ').toLocaleLowerCase()
198 const crop = CROPS.find(item => item.id === label || T.farmCropName[item.id]?.toLocaleLowerCase() === label)
199 if (crop === undefined || !Number.isInteger(amount) || amount < 1 || amount > 9_999) {
200 return T.cheatHelp
201 }
202 await mutate($, save => addItem(save, seedId(crop.id), amount))
203 return T.cheatSeed(T.farmCropName[crop.id] ?? crop.id, amount)
204 }
205 if (head === 'ff' && n > 0) {
206 // 閉じていた扱いにはしない(お知らせは出さない)
207 await mutate($, (save, now, day) => {
208 const fought = fight(save, n * 60 * TIME.battleSpeed, now)
209 // 倒した数も数える(日課・週課・実績の確認用)
210 const rewarded = awardBossStars(fought.save, fought.report.bossWins)
211 return { ...countReport(rewarded, fought.report, day), lastTickAt: now }
212 })
213 return T.cheatFf(n)
214 }
215 if (head === 'stage') {
216 const [, ch = '', st = ''] = args.trim().split(/\s+/)
217 const chapter = Number(ch)
218 const stage = Number(st || '1')
219 if (!(Number.isInteger(chapter) && chapter >= 1 && chapter <= CHAPTER_COUNT && Number.isInteger(stage) && stage >= 1 && stage <= BOSS_STAGE)) {
220 return T.cheatHelp
221 }
222 await mutate($, s => ({ ...s, stage: { chapter, stage, kills: 0, bossTime: 0 }, boss: { retryAt: null, last: s.boss.last }, isCleared: false }))
223 return T.cheatStage(chapter, stage)
224 }
225 if (head === 'away' && n > 0) {
226 // n 分閉じていたことにする(オフライン報酬のお知らせの確認用)
227 await mutate($, (save, now) => ({ ...save, lastTickAt: now - n * 60 * 1000 }))
228 return T.cheatAway(n)
229 }
230 if (head === 'sim') {
231 const lines = (['backend-knight', 'frontend-mage', 'infra-sniper'] as const).map(job => {
232 const logs = simulate({ ...TYPICAL, job }, 45)
233 const day30 = logs[29]
234 const day = (test: (log: DayLog) => boolean): string => String(firstDay(logs, test) ?? '—')
235 return [
236 T.simLine(T.jobName[job], firstDay(logs, l => l.level >= 20), firstDay(logs, l => l.isCleared), day30?.level ?? 0, day30?.chapter ?? 0),
237 T.simEvolution(T.jobName[job], day(l => l.job !== 'novice'), day(l => l.evolution >= 1), day(l => l.evolution >= 2), day(l => l.evolution >= 3)),
238 ].join('\n')
239 })
240 return [T.simHeader(TYPICAL.workingHours, TYPICAL.idleHours, TYPICAL.turns), ...lines].join('\n')
241 }
242 if (head === 'drops' && Number.isInteger(n) && n > 0) {
243 const save = await mutate($, s => ({ ...s, lamps: Math.min(LAMP.stockCap, s.lamps + n) }))
244 return T.cheatDrops(n, save?.lamps ?? 0)
245 }
246 if (head === 'evo' && Number.isInteger(n) && n >= 0 && n <= MAX_EVOLUTION) {
247 await mutate($, s => ({ ...s, evolution: n }))
248 return T.cheatEvo(n)
249 }
250 if (head === 'lv' && Number.isInteger(n) && n >= 1) {
251 await mutate($, s => addExp({ ...s, level: n, exp: 0 }, 0))
252 return T.cheatLv(n)
253 }
254 if (head === 'item' && Number.isInteger(n) && n >= 0 && n < RARITY_COUNT) {
255 // 指定したレア度の装備を 1 つ出す(光の柱やトーストの確認用。ランプは減らない)
256 let drops: Drop[] = []
257 await mutate($, (save, now) => {
258 drops = []
259 if (save.pending !== null) {
260 return save
261 }
262 const rolled = rollItem(save)
263 const item = { ...rolled.item, rarity: n, power: itemPower(rolled.item.slot, save.level, n, 0.5) }
264 const judged = judge(rolled.save, item, now)
265 drops = [judged.drop]
266 return withDropShare(judged.save, drops, now)
267 })
268 const after = await serial(() => load($))
269 if (after !== undefined) {
270 announce($, drops, after.settings)
271 }
272 return drops.length > 0 ? T.cheatItem(T.rarity[n] ?? '') : T.lampBlocked
273 }
274 if (head === 'day' && /^\+?\d+$/.test(value)) {
275 // 遊んだ日数を決める(+N で N 日進める)。スタンプは押さない
276 const save = await mutate($, s => setPlayedDays(s, value.startsWith('+') ? s.calendar.playedDays + n : n))
277 return T.cheatDay(save?.calendar.playedDays ?? 0)
278 }
279 if (head === 'missions' && (value === 'done' || value === 'week')) {
280 // 今日の日課(week なら今週の週課)を達成済みにする。受け取りはボタンから
281 await mutate($, (save, _now, day) => completeMissions(save, day, value === 'week' ? 'weekly' : 'daily'))
282 return T.cheatMissions(value === 'week' ? 'weekly' : 'daily')
283 }
284 if (head === 'egg') {
285 const input = value.toLocaleLowerCase()
286 const species = PET_SPECIES.find(id => id === input || T.petSpeciesName[id]?.toLocaleLowerCase() === input)
287 if (species === undefined) return T.cheatHelp
288 await mutate($, save => cheatHatch(save, species))
289 return T.cheatEgg(T.petSpeciesName[species] ?? species)
290 }
291 if (head === 'petlv' && Number.isInteger(n) && n >= 1 && n <= 60) {
292 const save = await mutate($, current => cheatPetLevel(current, n))
293 return save?.pets.owned.length ? T.cheatPetLevel(n) : T.cheatHelp
294 }
295 if (head === 'trip' && value === 'done') {
296 await mutate($, (save, now) => cheatFinishTrips(save, now))
297 return T.cheatTripDone
298 }
299 if (head === 'pick' && Number.isInteger(n) && n > 0) {
300 // つるはしを n 回足す(鉱山の確認用)
301 const save = await mutate($, s => addItem(s, 'pick', n))
302 return T.cheatPick(n, save?.bag.pick ?? 0)
303 }
304 if (head === 'ore') {
305 const parts = args.trim().split(/\s+/)
306 const id = parts[1] ?? ''
307 const amount = Number(parts[2])
308 if (!(ORES as readonly string[]).includes(id) && !/^ore-boss-[1-5]$/.test(id) && !['ore-gold', 'ore-silver', 'wood'].includes(id)) return T.cheatHelp
309 if (!Number.isInteger(amount) || amount < 1 || amount > 9_999) return T.cheatHelp
310 const save = await mutate($, current => addItem(current, id, amount))
311 return T.cheatOre(T.bagItem[id] ?? id, amount, save?.bag[id] ?? 0)
312 }
313 if (head === 'gm' && value === 'full') {
314 // 金山・銀山のトロッコを満杯にする(回収から 48 時間たったことにする)
315 await mutate($, (save, now) => ({ ...save, goldMine: { ...save.goldMine, collectedAt: now - 48 * 3_600_000, collectWork: save.goldMine.workMs, workAt: now, workSpans: [], spansFrom: now, settledAt: now - 48 * 3_600_000, settledGold: [], settledSilver: [] } }))
316 return T.cheatGoldMine
317 }
318 if (head === 'plus') {
319 const parts = args.trim().split(/\s+/)
320 const slotText = parts[1] ?? ''
321 const plus = Number(parts[2])
322 const slot = SLOTS.find(candidate => candidate === slotText || T.slot[candidate] === slotText)
323 if (slot === undefined || !Number.isInteger(plus) || plus < 0 || plus > 15) return T.cheatHelp
324 let changed = false
325 await mutate($, save => {
326 const item = save.equipment[slot]
327 if (item === null) return save
328 changed = true
329 return { ...save, equipment: { ...save.equipment, [slot]: { ...item, plus } } }
330 })
331 return changed ? T.cheatPlus(T.slot[slot], plus) : T.cheatPlusMissing(T.slot[slot])
332 }
333 if (head === 'floor' && Number.isInteger(n) && n >= 1) {
334 // 地下 n 階に移る(新しいマス)
335 await mutate($, s => enterFloor(s, n))
336 return T.cheatFloor(n)
337 }
338 if (head === 'unlock' && value === 'all') {
339 const save = await mutate($, s => setPlayedDays(s, Math.max(s.calendar.playedDays, ROADMAP_DAYS)))
340 return T.cheatDay(save?.calendar.playedDays ?? 0)
341 }
342 if (head === 'soul' && value === 'all') {
343 // 10 体の魂をすべて手に入れる(台座に捧げるのは自分で)
344 await mutate($, s => ({ ...s, souls: Array.from({ length: CHAPTER_COUNT }, (_, i) => i + 1) }))
345 return T.cheatSoul
346 }
347 if (head === 'trueboss' && args.trim() === 'trueboss') {
348 // 魂を捧げ終え、本編もクリア済みにして、いますぐ真のラスボスに挑める状態にする
349 await mutate($, s => cheatReadyTrueBoss(s))
350 return T.cheatTrueBoss
351 }
352 if (head === 'reset') {
353 return serial(async () => {
354 const now = await $.clock.now()
355 const old = migrateOrNew(await $.store.get(SAVE_KEY), now) ?? newSave(now)
356 const backup = await writeExport($, old, 'before-reset')
357 // 書いた変更の印は引き継ぐ(作り直したセーブに、前の変更を入れ直さないように)
358 await store($, { ...newSave(now), applied: old.applied })
359 await update($, problem, () => null)
360 return T.cheatReset(backup)
361 })
362 }
363
364 return T.cheatHelp
365}
366
367/** 開発用: 全部を最強にする(テスト用。ほかのチートを順に動かし、最後に全部の枠へ最高レア度・+15 の装備を付ける) */
368async function cheatMax($: $): Promise<string> {
369 const owned = (await serial(() => load($)))?.pets.owned.map(pet => pet.species) ?? []
370 const steps = [
371 'unlock all',
372 `lv ${LEVEL.max}`,
373 `evo ${MAX_EVOLUTION}`,
374 `stage ${CHAPTER_COUNT}`,
375 'stars 100000',
376 'psi 100000',
377 `drops ${LAMP.stockCap}`,
378 'pick 9999',
379 ...[...ORES, 'ore-gold', 'ore-silver', 'wood', ...Array.from({ length: 5 }, (_, i) => `ore-boss-${i + 1}`)].map(id => `ore ${id} 9999`),
380 ...CROPS.map(crop => `seed ${crop.id} 9999`),
381 'gm full',
382 'grow',
383 // 持っていない種類から 2 匹まで(同じ種類は孵さない)
384 ...PET_SPECIES.filter(species => !owned.includes(species)).map(species => `egg ${species}`),
385 'missions done',
386 'missions week',
387 'trip done',
388 'night',
389 'soul all',
390 'trueboss',
391 ]
392 for (const step of steps) {
393 await runCheat($, step)
394 }
395 await mutate($, save => {
396 let next = save
397 const equipment = { ...save.equipment }
398 for (const slot of SLOTS) {
399 const rolled = rollItem(next)
400 next = rolled.save
401 const rarity = RARITY_COUNT - 1
402 equipment[slot] = { ...rolled.item, slot, rarity, power: itemPower(slot, next.level, rarity, 1), plus: 15 }
403 }
404 // ペットは 2 匹とも Lv.60(第 3 形態)
405 const owned = next.pets.owned.map(pet => ({ ...pet, level: 60, exp: 0, form: 3 as const, affinity: 100 }))
406 return { ...next, equipment, pets: { ...next.pets, owned } }
407 })
408
409 return T.cheatMax
410}
411
412// ---- 音の確認(M1 の実機確認用。M4 で本物の BGM / 効果音に置き換える) ----
413
414async function logSound($: $, entry: Record<string, unknown>): Promise<void> {
415 const list = (await $.store.get('soundLog')) as unknown[] | undefined
416 await $.store.set('soundLog', [...(Array.isArray(list) ? list : []), entry].slice(-20))
417}
418
419function playAndLog($: $, kind: string, play: () => Promise<void>): void {
420 background(
421 (async () => {
422 const startedAt = await $.clock.now()
423 const surfaces = await $.session.surfaces()
424 let result = 'resolved'
425 try {
426 await play()
427 } catch (error) {
428 result = `rejected: ${error instanceof Error ? error.message : String(error)}`
429 }
430 const ms = (await $.clock.now()) - startedAt
431 await logSound($, { at: startedAt, kind, surfaces, result, ms })
432 $.ui.toast(T.soundResult(kind, result, ms))
433 })(),
434 )
435}
436
437export async function runSound($: $, args: string): Promise<string> {
438 const kind = args.trim() || 'once'
439 if (kind === 'once') {
440 playAndLog($, kind, () => $.audio.play({ asset: 'sounds/test-beep.wav' }))
441 } else if (kind === 'b64') {
442 const { base64 } = await $.fs.read(`${$.plugin.root}/sounds/test-beep.wav`, { as: 'bytes' })
443 playAndLog($, kind, () => $.audio.play({ base64, mime: 'audio/wav' }))
444 } else if (kind === 'loop') {
445 loop?.abort()
446 const controller = new AbortController()
447 loop = controller
448 playAndLog($, kind, () => $.audio.play({ asset: 'sounds/test-loop.wav' }, { shouldLoop: true, signal: controller.signal, gain: 0.6 }))
449 // 止め忘れても 30 秒で止まる
450 $.clock.after(30_000, () => controller.abort())
451 } else if (kind === 'win') {
452 // Windows の PowerShell で 1 回鳴らす(設定がオフでも試せる。Windows のときだけ)
453 if (!isWindowsHost()) {
454 return T.soundWinOnly
455 }
456 playAndLog($, kind, () => playEffectWindows($, 'se-levelup', 0.8))
457 } else if (kind === 'stop') {
458 if (loop === undefined || loop.signal.aborted) {
459 return T.soundNothing
460 }
461 loop.abort()
462 loop = undefined
463 return T.soundStopped
464 } else {
465 return T.soundHelp
466 }
467
468 return T.soundStart(kind)
469}
470
471/** /dl-debuglog: 実機の記録を見る(on / off で記録の切り替え、clear で消す)。全部をファイルにも書き出す */
472export async function runDebugLog($: $, args: string): Promise<string> {
473 const arg = args.trim()
474 if (arg === 'on' || arg === 'off') {
475 await mutate($, save => ({ ...save, settings: { ...save.settings, debugLog: arg === 'on' } }))
476 return arg === 'on' ? T.debugLogOn : T.debugLogOff
477 }
478 if (arg === 'clear') {
479 await clearLog($)
480 return T.debugLogCleared
481 }
482 const list = await readLog($)
483 if (list.length === 0) {
484 return isLogging() ? T.debugLogEmpty : T.debugLogOff
485 }
486 const now = await $.clock.now()
487 const path = `${await exportFolder($)}/debuglog-${stamp(now)}.txt`
488 await $.fs.write(path, `${T.debugLogHeader(buildLabel())}\n${formatLog(list)}\n`)
489
490 return `${T.debugLogShown(Math.min(60, list.length), list.length, path)}\n${formatLog(list.slice(-60))}`
491}
492
493export async function runSummary($: $): Promise<string> {
494 const save = await serial(() => load($))
495 if (save === undefined) {
496 return T.paused((await read($, problem)) ?? '')
497 }
498 const percent = Math.floor(stageProgress(save) * 100)
499
500 return summaryLines(save, await today($), expToNext(save.level), percent).join('\n')
501}
502hooks/config.ts 204 lines1// デバッグ伝説の調整用の数値はすべてここに集める(仮の値。開発用チートで試しながら決める)。
2
3/**
4 * 開発用チート(/dl-cheat)を使えるようにするか。公開版は false。
5 * 開発版(stamp_build.py で作った channel が dev)なら、環境変数 DEBUG_LEGEND_DEV=1 を付けて Claude Code を起動すれば、false のままでも使える(register.tsx)。
6 * 公開版(stamp_build.py --public で channel が public)は、この環境変数を無視する。
7 */
8export const DEV_CHEATS = false
9
10/** ランプ(装備ドロップ)回数 */
11export const LAMP = {
12 /** 回答 1 回(turn.complete)でたまる回数 */
13 perTurn: 1,
14 /** 回答でたまる 1 日の上限 */
15 dailyTurnCap: 120,
16 /** ストックの上限(これ以上はたまらない) */
17 stockCap: 200,
18}
19
20/** ツール呼び出し(tool.call)が成功したときのゴールド */
21export const GOLD = {
22 perToolCall: 2,
23 /** ツール呼び出しでもらえる 1 日の上限 */
24 dailyToolCap: 600,
25}
26
27/**
28 * トークンのおまけボーナス。1 回の回答のトークン数から「ランプ何回分のおまけか」を決める。
29 * ボーナス = perDecade × log10(トークン ÷ floorTokens)。1 万トークンで +0.1 回、10 万でも +0.2 回。
30 * 端数はためておき、1 回分たまるとランプ +1。
31 */
32export const TOKEN_BONUS = {
33 /** これより少ないトークンはおまけなし */
34 floorTokens: 1_000,
35 /** トークンが 10 倍になるごとに増えるおまけ(ランプ何回分か) */
36 perDecade: 0.1,
37 /** 1 回の回答でもらえるおまけの上限 */
38 perTurnCap: 0.3,
39 /** 1 日にもらえるおまけの上限(ランプ何回分か) */
40 dailyCap: 10,
41}
42
43/** 時間によるステージ進行 */
44export const TIME = {
45 /** 何ミリ秒ごとに進行を計算するか(セッションが開いている間) */
46 tickMs: 10_000,
47 /** 1 秒あたりの戦闘の秒数(何もしていないとき) */
48 battleSpeed: 1,
49 /** Claude が作業中(turn 実行中)の倍率 */
50 workingMultiplier: 3,
51 /** オフライン分をまとめて受け取れる最大時間 */
52 offlineCapMs: 8 * 60 * 60 * 1000,
53 /** これより長く間があいたら「オフライン報酬」として扱う(それ未満は普通の進行) */
54 offlineThresholdMs: 60_000,
55}
56
57/**
58 * 戦闘とステージの強さ(仮の値。目安: 最初の転職 Lv20 まで 2〜3 日、10 章クリアまで約 1 か月)。
59 * n = ステージの通し番号(第 1 章 1 ステージ目が 0、第 10 章 10 ステージ目が 99)。
60 * tests/pace.test.ts のシミュレーションと /dl-cheat sim で、ペースを確かめながら調整する。
61 */
62export const BALANCE = {
63 /**
64 * 雑魚を倒す速さの倍率(ベータ 2 回目で 2 倍に)。上げると、雑魚の HP・1 匹を倒す最短の秒数・1 匹の経験値とゴールドが
65 * その分だけ小さくなるので、全体のペース(転職・進化・クリアまでの日数)は変わらない。ボスの強さと報酬は変わらない。
66 */
67 killSpeed: 2,
68 /** 群れの中の 1 匹を倒すのにかかる最短の秒数(強すぎても一瞬で進まないように。killSpeed で割る) */
69 minSecondsPerEnemy: 6,
70 /** 戦闘力 1 あたりの 1 秒のダメージ */
71 dpsPerPower: 0.5,
72 /** 雑魚の HP = enemyHp ×(1 + enemyHpPerStage × n)× enemyHpGrowth ^ n ÷ killSpeed */
73 enemyHp: 1000,
74 enemyHpPerStage: 0.6,
75 enemyHpGrowth: 1.004,
76 /** ボスの HP = enemyHp ×(1 + enemyHpPerStage × n)× enemyHpGrowth ^ n × bossHpRate(killSpeed では変わらない) */
77 bossHpRate: 2.4,
78 /** ボス戦の制限時間(秒)。この間に倒せないか、先に倒れたら負け */
79 bossSeconds: 30,
80 /** ボスの 1 秒の攻撃 = enemyHp ×(1 + enemyHpPerStage × n)× enemyHpGrowth ^ n × bossAttackRate */
81 bossAttackRate: 0.025,
82 /** 戦闘力 1 あたりの主人公の HP */
83 hpPerPower: 4,
84 /** 雑魚 1 体の経験値 = expPerKill × expGrowth ^ n ÷ killSpeed。ボスは expPerKill × expGrowth ^ n × bossRewardRate */
85 // 1.0.4: 群れと群れのあいだの移動(TRAVEL.seconds)で戦っている時間が減った分、1.0 → 1.1・0.2 → 0.22 に上げた
86 // (/dl-cheat sim で、転職・進化・全クリアの日数が移動なしのときと同じになることを確かめた。NOTES.md)
87 // 2.1.0: 群れが 1〜3 匹 → 10〜30 匹になり、移動(1.2 秒)が 10〜30 匹ごとになって戦っている時間が増えたので、1.1 → 1.0・0.22 → 0.2 に戻した
88 // (/dl-cheat sim の日数が 2.0.1 と ±1 日以内。NOTES.md の 2.1.0)
89 expPerKill: 1.0,
90 expGrowth: 1.014,
91 goldPerKill: 0.2,
92 bossRewardRate: 40,
93 /** ボスに負けたら 1 つ前のステージで稼ぎ、この秒数(戦闘時間)たったら自動で再挑戦 */
94 bossRetrySeconds: 20 * 60,
95}
96
97/**
98 * 職ごとの特徴(騎士 = 防御・粘り、魔導士 = 範囲攻撃、狙撃手 = 会心)。
99 * 転職は横に移るだけ(強くはならない)なので、3 職とも「強さの合計」がだいたい同じになるように、得意の分だけ攻撃を少し下げている。
100 */
101export const JOBS = {
102 novice: { attack: 1, hp: 1, defense: 0, aoe: 1, critRate: 0.05, critDamage: 2 },
103 'backend-knight': { attack: 0.9, hp: 1.5, defense: 0.4, aoe: 1, critRate: 0.05, critDamage: 2 },
104 'frontend-mage': { attack: 0.9, hp: 0.9, defense: 0, aoe: 1.3, critRate: 0.05, critDamage: 2 },
105 'infra-sniper': { attack: 0.9, hp: 0.95, defense: 0, aoe: 1, critRate: 0.25, critDamage: 2.3 },
106}
107
108/**
109 * 転職: Lv がここまで来たら 3 職のどれかを選べる(目安: 約 1 週間)。そのあとは自由に移れる。
110 * cooldownMs: 一度移ったら、次に移れるまでの時間。
111 */
112export const JOB_CHANGE = { level: 20, cooldownMs: 30 * 60 * 1000 }
113
114/**
115 * 進化: シニア → スタッフ → プリンシパル(目安: 約 10 日・20 日・30 日)。
116 * levels[i] = i+1 段目に進化できる Lv。powerRate[i] = i 段目の戦闘力の倍率(0 段目 = まだ進化していない)。
117 * 段階は転職しても引き継ぐ。
118 */
119export const EVOLUTION = { levels: [24, 34, 44], powerRate: [1, 1.35, 1.8, 2.4] }
120
121/** レベルと戦闘力(仮の式) */
122export const LEVEL = {
123 max: 200,
124 /** Lv n → n+1 に必要な経験値 = base × n ^ exponent */
125 expBase: 40,
126 expExponent: 1.6,
127 /** 戦闘力 = powerBase + Lv × powerPerLevel + 装備の強さの合計 */
128 powerBase: 100,
129 powerPerLevel: 55,
130}
131
132/** 1 日の区切り(ローカル時刻の何時に日付が変わるか) */
133export const DAY_START_HOUR = 4
134
135/** 装備とランプ(仮の値) */
136export const GEAR = {
137 /** レア度ごとの出やすさ(コモン〜ミシック、合計は 100 でなくてよい) */
138 rarityWeights: [50, 28, 14, 6, 1.6, 0.4],
139 /** レア度ごとの強さの倍率 */
140 rarityPower: [1, 1.4, 2, 3, 4.5, 7],
141 /** 枠ごとの基本の強さ */
142 slotPower: { weapon: 30, head: 14, body: 20, arms: 14, legs: 14, charm: 18 },
143 /** ドロップ時の Lv が 1 上がるごとに強さが何割増えるか */
144 perLevel: 0.12,
145 /** 強さのぶれ(±何割) */
146 spread: 0.15,
147 /** 売ったときのゴールド = 強さ × これ × (レア度 + 1) */
148 sellRate: 0.5,
149 /** 「レア以上は手動で確認」のレア(0 始まりの番号) */
150 manualFrom: 2,
151 /** 自動ランプが 1 回のタイマーで叩く最大回数 */
152 autoLampPerTick: 3,
153}
154
155/**
156 * 戦闘画面の見た目(仮の値)。攻撃の速さは見た目だけで、進み方(1 秒のダメージ)は変わらない。
157 * 1 回の攻撃の長さ = attackSeconds ÷(1 + perLevel ×(Lv − 1)+ perEvolution × 進化の段階)、ただし minSeconds より短くしない。
158 */
159export const BATTLE = {
160 /** 最初の 1 回の攻撃の長さ(秒)。ベータ 1 回目の 2.4 秒の 3 倍の速さ */
161 attackSeconds: 0.8,
162 attackSpeedPerLevel: 0.012,
163 attackSpeedPerEvolution: 0.25,
164 /** いちばん速いときの 1 回の攻撃の長さ(秒) */
165 minSeconds: 0.32,
166 /** 敵を倒すまでの攻撃回数(演出だけ。実際の進行は時間で決まる) */
167 hitsToDefeat: 3,
168 /** 何回に 1 回会心が出るか(演出) */
169 critEvery: 3,
170}
171
172/**
173 * 移動(キノコ伝説ふう): 群れを全部倒して次の群れが出るたびに、主人公がその場で走り、背景が左へ流れ、
174 * 次の群れが右から歩いて入ってくる。そのあいだ戦闘は止まっている(戦闘の秒数で数える。作業中の 3 倍速なら 1/3 の長さ)。
175 * ステージが変わるときは、移動の途中で暗転して「STAGE x-y」(章が変わるときは章の名前)が出る。
176 */
177export const TRAVEL = {
178 /** 移動の長さ(戦闘の秒数)。ここだけ変えれば、戦闘の計算・予約・HP バー・画面の動きがそろって変わる */
179 seconds: 1.2,
180}
181
182/** X ポスト */
183export const SHARE = {
184 /** 配布ページの URL(まだ無いので空。空ならポストに付けない) */
185 pageUrl: '',
186 /** X の投稿画面を開くリンク */
187 intentUrl: 'https://x.com/intent/post',
188}
189
190/** 音(BGM はサイド画面が表示されている間だけ流れる) */
191export const SOUND = {
192 /** サイド画面が表示されているかを確かめる間隔(ミリ秒) */
193 checkMs: 3_000,
194}
195
196/** セーブの書き出し / 読み込み */
197export const SAVE_FILES = {
198 /** 読み込むファイル(または貼り付けた JSON)の大きさの上限。これより大きいものは読まない */
199 importMaxBytes: 1_000_000,
200 /** 同じ名前のファイルがあったとき、-2, -3 … と番号を付けて試す最大の数 */
201 maxNameTries: 100,
202}
203
204hooks/battle.ts 528 lines1import type { BattleReport, JobId, SaveData } from '../types'
2import { BALANCE, BATTLE, EVOLUTION, JOBS, JOB_CHANGE, TRAVEL } from './config'
3import { addExp } from './level'
4import { isUnlocked, READY } from './calendar'
5import { buffRate, useCharge } from './buffs'
6import { combatPower, hasBossGear, setCount } from './gear'
7import { bossRetrySeconds as psychicRetrySeconds, hasPsychicNode, psychicBossMemory, psychicStarReward, recordPsychicProc, rememberPsychicBoss } from './psychic'
8import { activePets, petBattleEffects, shareBattleExp } from './pets'
9import { BOSS_STAGE, CHAPTER_COUNT, WAVE, farmStage, stageGroup } from './world'
10
11// 時間で進む戦闘。$ を使わない純粋な関数(同じセーブと秒数なら同じ結果)。
12// 雑魚: 1 ステージ = 1 つの群れ(10〜30 匹、3 匹ずつの波で戦う)。1 秒に倒せる数 = 1 秒のダメージ ÷ 雑魚の HP(1 匹あたりの最短の秒数あり)。全員倒すとステージクリア。
13// ボス(各章 10 ステージ目): 制限時間内に倒せれば次の章へ。倒せないか先に倒れたら負けて、その章の 1〜9 面をめぐって稼ぎ(面はランダム)、しばらくして再挑戦。
14// 移動: 群れを全部倒す・ボスの勝ち負けが決まる・ボスに挑むたびに、次の群れ(ボス)のところまで TRAVEL.seconds 移動する。
15// そのあいだは戦闘が止まっている(倒した数もボス戦の秒数も進まない)。移動の残りは stage.travel。
16
17const B = BALANCE
18
19/** ハード周回(docs/DAILY.md の 14 章): 敵の HP・攻撃 3 倍、経験値・ゴールド 2.5 倍。ふつう(周回 0)は 1 倍で、計算は変わらない */
20export const HARD = { enemy: 3, reward: 2.5 }
21export const hardEnemy = (save: Pick<SaveData, 'hard'>): number => (save.hard.loop > 0 ? HARD.enemy : 1)
22export const hardReward = (save: Pick<SaveData, 'hard'>): number => (save.hard.loop > 0 ? HARD.reward : 1)
23
24/** 雑魚の群れを倒し切ったときの図鑑の討伐数 */
25export function recordKills(save: SaveData, ids: readonly string[]): SaveData {
26 const kills = { ...save.codex.kills }
27 for (const id of ids) {
28 kills[id] = Math.min(999_999, (kills[id] ?? 0) + 1)
29 }
30
31 return { ...save, codex: { ...save.codex, kills } }
32}
33
34/** 図鑑のボスの勝ち(回数と最短の勝ち時間) */
35function recordBossWin(save: SaveData, chapter: number, seconds: number): SaveData {
36 const best = save.codex.bossBest[chapter]
37
38 return {
39 ...save,
40 codex: {
41 ...save.codex,
42 bossWins: { ...save.codex.bossWins, [chapter]: Math.min(999_999, (save.codex.bossWins[chapter] ?? 0) + 1) },
43 bossBest: best === undefined || seconds < best ? { ...save.codex.bossBest, [chapter]: seconds } : save.codex.bossBest,
44 },
45 }
46}
47
48/** 移動の残りの秒数(戦闘の秒数) */
49export const travelLeft = (save: SaveData): number => save.stage.travel ?? 0
50
51/** 次の群れ(ボス)のところに着いたばかりの位置(ここから移動する)。from は前にいた面(面がとぶときだけ書く) */
52const arrive = (chapter: number, stage: number, from?: number) => ({ chapter, stage, kills: 0, bossTime: 0, travel: TRAVEL.seconds, ...(from === undefined ? {} : { from }) })
53
54/** 稼ぎの次の面(その章の 1〜9 面から、いまの面以外をランダムに。戦闘の秒数から決めるので、同じセーブなら同じ面) */
55const farmArrive = (save: SaveData) =>
56 arrive(save.stage.chapter, farmStage(save.stage.chapter, save.stage.stage, Math.round(save.battleSeconds * 1000)), save.stage.stage)
57
58/** ステージの通し番号(第 1 章 1 ステージ目が 0) */
59export const stageIndex = (chapter: number, stage: number): number => (chapter - 1) * BOSS_STAGE + (stage - 1)
60
61/** ステージの強さの基準(雑魚を倒す速さ killSpeed をかける前の、雑魚 1 匹の HP) */
62const baseHp = (n: number): number => B.enemyHp * (1 + B.enemyHpPerStage * n) * Math.pow(B.enemyHpGrowth, n)
63const baseExp = (n: number): number => B.expPerKill * Math.pow(B.expGrowth, n)
64const baseGold = (n: number): number => B.goldPerKill * Math.pow(B.expGrowth, n)
65
66/** 雑魚 1 匹の HP(倒す速さが 2 倍なら半分) */
67export const enemyHp = (n: number): number => baseHp(n) / B.killSpeed
68export const bossHp = (n: number): number => baseHp(n) * B.bossHpRate
69export const bossAttack = (n: number): number => baseHp(n) * B.bossAttackRate
70/** 雑魚 1 匹の経験値・ゴールド(倒す速さが 2 倍なら半分。1 秒あたりは同じ) */
71export const expPerKill = (n: number): number => baseExp(n) / B.killSpeed
72export const goldPerKill = (n: number): number => baseGold(n) / B.killSpeed
73export const bossExp = (n: number): number => baseExp(n) * B.bossRewardRate
74export const bossGold = (n: number): number => baseGold(n) * B.bossRewardRate
75
76export type HeroStats = {
77 power: number
78 /** 雑魚 1 体への 1 秒のダメージ(会心込み、範囲なし) */
79 mobDps: number
80 /** 雑魚相手の 1 秒のダメージ(会心・範囲込み) */
81 dps: number
82 /** ボス相手の 1 秒のダメージ(会心込み、範囲なし) */
83 bossDps: number
84 hp: number
85 /** 受けるダメージの倍率(騎士は小さい) */
86 damageTaken: number
87 /** フクロウの手伝いによるボス戦の延長 */
88 bossTimeBonus: number
89}
90
91export function heroStats(save: SaveData, pet = petBattleEffects(save)): HeroStats {
92 const job = JOBS[save.job]
93 const power = combatPower(save)
94 const iron = setCount(save, 'iron-guardian')
95 const crystal = setCount(save, 'crystal-sage')
96 const mithril = setCount(save, 'mithril-swift')
97 const adamant = setCount(save, 'adamant-lord')
98 const attackSet = (adamant >= 2 ? 0.1 : 0) + (hasBossGear(save, 'loop-ring') ? 0.05 : 0)
99 const critRate = Math.min(1, job.critRate + buffRate(save, 'crit') + pet.critRate + (hasBossGear(save, 'zero-day-dagger') ? 0.08 : 0) + (hasPsychicNode(save, 'mind-3') ? 0.02 : 0))
100 const critDamage = job.critDamage + (adamant >= 4 ? 0.2 : 0) + (hasPsychicNode(save, 'mind-6') ? 0.1 : 0)
101 const companionBonus = hasBossGear(save, 'dependency-staff') ? 0.2 : 0
102 const petAttack = pet.attackRate + companionBonus
103 const psychicAttack = (hasPsychicNode(save, 'mind-1') ? 0.03 : 0) + (hasPsychicNode(save, 'mind-2') ? 0.04 : 0)
104 const hit = power * B.dpsPerPower * job.attack * (1 + critRate * (critDamage - 1)) * (1 + buffRate(save, 'power') + petAttack + attackSet + psychicAttack)
105 const attackSpeed = mithril >= 2 ? 1.1 : 1
106 const doubleStrike = hasPsychicNode(save, 'mind-7') ? 1.1 : 1
107 const hydraArea = hasBossGear(save, 'hydra-armor') ? 1.15 : 1
108 const bossAttackRate = hasBossGear(save, 'daemon-helm') ? 1.125 : 1
109 const meteor = hasPsychicNode(save, 'mind-5') ? hit * attackSpeed * doubleStrike * (4 / 45) : 0
110 const bossDamageRate =
111 1 + pet.bossRate + (hasPsychicNode(save, 'mind-8') ? 0.08 : 0) + (hasPsychicNode(save, 'future-9') ? psychicBossMemory(save, save.stage.chapter) * 0.05 : 0)
112 const mobDps = hit * attackSpeed * doubleStrike + meteor
113 // セット 6 個の生存効果は平均 HP、盾・鎖・外套は受けるダメージの近似値として反映する。
114 const hpRate = 1 + buffRate(save, 'bossHp') + buffRate(save, 'heal') + pet.hpRate + (iron >= 2 ? 0.1 : 0) + (iron >= 6 ? 0.2 : 0) + (hasPsychicNode(save, 'future-1') ? 0.05 : 0)
115 const damageRate = (iron >= 4 ? 0.9 : 1) * (hasBossGear(save, 'deadlock-chain') ? 1 / 1.15 : 1) * (hasBossGear(save, 'not-found-cloak') ? 0.9 : 1) * (hasBossGear(save, 'merge-shield') ? 0.9 : 1) * (hasPsychicNode(save, 'future-2') ? 0.96 : 1)
116
117 return {
118 power,
119 mobDps,
120 dps: mobDps * job.aoe * hydraArea * (hasPsychicNode(save, 'mind-4') ? 1.05 : 1),
121 bossDps: (hit * attackSpeed * doubleStrike * bossAttackRate + meteor) * bossDamageRate,
122 hp: power * B.hpPerPower * job.hp * hpRate,
123 damageTaken: Math.max(0.1, (1 - job.defense) * (1 - pet.protectRate) * damageRate),
124 bossTimeBonus: pet.bossSeconds + (crystal >= 6 ? 4 : 0) + (hasPsychicNode(save, 'future-5') ? 5 : 0) + (hasPsychicNode(save, 'future-10') ? 10 : 0),
125 }
126}
127
128/** いまのステージの群れの数 */
129export const groupSize = (save: SaveData): number => Math.max(1, stageGroup(save.stage.chapter, save.stage.stage).length)
130
131/** まだ倒していない数 */
132export const remainingFoes = (save: SaveData): number => Math.max(0, groupSize(save) - Math.floor(save.stage.kills + 1e-9))
133
134/** いまの波(3 匹ずつ)の残り。範囲攻撃(魔導士)の条件に使う */
135export function waveLeft(foes: number, kills: number): number {
136 const defeated = Math.floor(kills + 1e-9)
137
138 return Math.max(0, Math.min(foes, (Math.floor(defeated / WAVE) + 1) * WAVE) - defeated)
139}
140
141/** 1 秒に倒せる雑魚の数。範囲攻撃(魔導士)は、いまの波の残りが 2 匹以上のときだけ効く(上限もその分だけ上がる) */
142export function killRate(save: SaveData): number {
143 return killRateWithHero(save, heroStats(save), buffRate(save, 'killSpeed'), setCount(save, 'mithril-swift') >= 4, hasBossGear(save, 'hydra-armor'), groupSize(save))
144}
145
146function killRateWithHero(save: SaveData, hero: HeroStats, killSpeed: number, mithrilFast: boolean, hydra: boolean, foes: number): number {
147 const n = stageIndex(save.stage.chapter, save.stage.stage)
148 const aoe = waveLeft(foes, save.stage.kills) >= 2
149 ? JOBS[save.job].aoe * (hydra ? 1.15 : 1) * (hasPsychicNode(save, 'mind-4') ? 1.05 : 1)
150 : 1
151
152 // ポップコーン(料理): 雑魚を倒す速さ
153 return Math.min((aoe * B.killSpeed) / B.minSecondsPerEnemy, (hero.mobDps * aoe) / (enemyHp(n) * hardEnemy(save))) * (1 + killSpeed) * (mithrilFast ? 1.1 : 1)
154}
155
156export type BossOutcome = { isWin: boolean; killTime: number; surviveTime: number; duration: number }
157
158/** いまの強さでボスと戦ったらどうなるか */
159export function bossOutcome(save: SaveData): BossOutcome {
160 return bossOutcomeWithHero(save, heroStats(save))
161}
162
163function bossOutcomeWithHero(save: SaveData, hero: HeroStats): BossOutcome {
164 const n = stageIndex(save.stage.chapter, BOSS_STAGE)
165 const hard = hardEnemy(save)
166
167 return customBossOutcome(save, hero, bossHp(n) * hard, bossAttack(n) * hard, B.bossSeconds)
168}
169
170/**
171 * 強さ(HP・1 秒の攻撃・制限時間)を決めたボスと戦ったらどうなるか。本編のボス・魂の試練が同じ考え方で決める。
172 * 超能力の開幕ダメージ・怒り・タイムリープも本編と同じように効く
173 */
174export function customBossOutcome(save: SaveData, hero: HeroStats, hp: number, attack: number, baseSeconds: number): BossOutcome {
175 const openingDamage = hasPsychicNode(save, 'mind-10') ? 0.08 : 0
176 const hpAfterOpening = hp * (1 - openingDamage)
177 const hasRage = hasPsychicNode(save, 'mind-9')
178 const damageBeforeRage = hasRage ? Math.max(0, hpAfterOpening - hp * 0.3) : hpAfterOpening
179 const damageAfterRage = hpAfterOpening - damageBeforeRage
180 const killTime = damageBeforeRage / hero.bossDps + damageAfterRage / (hero.bossDps * (hasRage ? 1.15 : 1))
181 const surviveTime = hero.hp / (attack * hero.damageTaken)
182 const leapSeconds = hasPsychicNode(save, 'future-10') ? 10 : 0
183 const limit = Math.min(baseSeconds + hero.bossTimeBonus, surviveTime + leapSeconds)
184 const isWin = killTime <= limit
185
186 return { isWin, killTime, surviveTime, duration: isWin ? killTime : limit }
187}
188
189export const emptyReport = (): BattleReport => ({ seconds: 0, kills: 0, exp: 0, gold: 0, levels: 0, stages: 0, bossWins: [], bossLosses: [] })
190
191/** 1 回の計算で進める最大の秒数(レベルアップを強さに反映させるため、区切って計算する) */
192const STEP = 300
193
194/**
195 * seconds 秒ぶん戦う。ステージが進み、経験値・ゴールドが入り、ボス戦の勝ち負けが決まる。
196 * @param now ボス戦の結果に残す時刻
197 */
198export function fight(input: SaveData, seconds: number, now: number): { save: SaveData; report: BattleReport } {
199 const report = emptyReport()
200 let save = input
201 let left = Math.max(0, seconds)
202 const startLevel = save.level
203 const rewardedBosses = new Set(save.beatenBosses)
204 // ハード周回の報酬倍率(周回は fight の中では変わらない)
205 const rewardRate = hardReward(save)
206 // fight 内ではセーブが毎回コピーされる。強さに関係する値とバフの有効期限だけを見て使い回す。
207 // ペットの経験値は毎回変わるが、手伝いの倍率が変わるのは形態などが変わったときだけ。
208 type Factors = { hero: HeroStats; killSpeed: number; mithrilFast: boolean; hydra: boolean; expMultiplier: number; goldMultiplier: number }
209 // 図鑑のごほうび(戦闘力)とハードの周回も、変わったら強さを覚え直す
210 let cached: { level: number; evolution: number; job: SaveData['job']; equipment: SaveData['equipment']; buffs: SaveData['buffs']; psychic: SaveData['psychic']; codexClaimed: string[]; hardLoop: number; petKey: string; validUntil: number; factors: Factors } | undefined
211 const factors = (): Factors => {
212 const petKey = save.pets.active.length === 0 ? '' : activePets(save).map(pet => `${pet.id}:${pet.species}:${pet.form}:${pet.affinity}:${pet.collar}`).join('|')
213 if (cached !== undefined && cached.level === save.level && cached.evolution === save.evolution && cached.job === save.job &&
214 cached.equipment === save.equipment && cached.buffs === save.buffs && cached.psychic === save.psychic && cached.codexClaimed === save.codex.claimed && cached.hardLoop === save.hard.loop && cached.petKey === petKey && save.battleSeconds < cached.validUntil) {
215 return cached.factors
216 }
217 const pet = petBattleEffects(save)
218 const goldRate = (setCount(save, 'copper-miner') >= 4 ? 0.1 : 0) + (hasBossGear(save, 'debt-crown') ? 0.15 : 0)
219 const next: Factors = {
220 hero: heroStats(save, pet),
221 killSpeed: buffRate(save, 'killSpeed'),
222 mithrilFast: setCount(save, 'mithril-swift') >= 4,
223 hydra: hasBossGear(save, 'hydra-armor'),
224 expMultiplier: 1 + buffRate(save, 'exp') + pet.expRate + (setCount(save, 'crystal-sage') >= 2 ? 0.08 : 0),
225 goldMultiplier: 1 + buffRate(save, 'gold') + pet.goldRate + goldRate + (hasPsychicNode(save, 'fortune-1') ? 0.05 : 0),
226 }
227 const validUntil = save.buffs.reduce((until, buff) => buff.until !== undefined && buff.until > save.battleSeconds ? Math.min(until, buff.until) : until, Infinity)
228 cached = { level: save.level, evolution: save.evolution, job: save.job, equipment: save.equipment, buffs: save.buffs, psychic: save.psychic, codexClaimed: save.codex.claimed, hardLoop: save.hard.loop, petKey, validUntil, factors: next }
229 return next
230 }
231 const gain = (base: number, baseGold: number, isMob = true): void => {
232 // 学びの薬・金運の薬(商店)
233 const { expMultiplier, goldMultiplier } = factors()
234 const exp = base * expMultiplier
235 const gold = baseGold * goldMultiplier * (isMob ? 1 + buffRate(save, 'midas') : 1)
236 // 雑魚 1 匹のゴールドは 1 に満たないので、端数はセーブにためて、1 になったぶんだけ足す
237 const total = save.goldFraction + gold
238 const whole = Math.floor(total)
239 save = addExp({ ...save, gold: save.gold + whole, goldFraction: Math.min(total - whole, 0.999999) }, exp)
240 save = shareBattleExp(save, exp)
241 report.exp += exp
242 report.gold += whole
243 }
244
245 for (let guard = 0; left > 1e-6 && guard < 100_000; guard += 1) {
246 const { chapter, stage } = save.stage
247 // 移動中: 戦闘は止まっている(時間だけ進む)
248 const travel = travelLeft(save)
249 if (travel > 1e-9) {
250 const step = Math.min(left, travel)
251 left -= step
252 save = { ...save, stage: { ...save.stage, travel: step >= travel ? 0 : travel - step }, battleSeconds: save.battleSeconds + step }
253 continue
254 }
255 if (stage >= BOSS_STAGE && !save.isCleared) {
256 const outcome = bossOutcomeWithHero(save, factors().hero)
257 const bossStart = save.stage.bossTime
258 if (bossStart <= 1e-9) {
259 if (hasPsychicNode(save, 'mind-10')) save = recordPsychicProc(save, 'mind-10', now)
260 if (hasPsychicNode(save, 'future-5')) save = recordPsychicProc(save, 'future-5', now)
261 if (hasPsychicNode(save, 'future-10')) {
262 const baseBonus = factors().hero.bossTimeBonus - 10
263 const wouldLose = outcome.killTime > Math.min(B.bossSeconds + baseBonus, outcome.surviveTime) + 1e-9
264 if (wouldLose) save = recordPsychicProc(save, 'future-10', now)
265 }
266 }
267 const need = Math.max(0, outcome.duration - save.stage.bossTime)
268 if (left < need) {
269 const meteorCount = hasPsychicNode(save, 'mind-5') ? Math.floor((bossStart + left) / 45) - Math.floor(bossStart / 45) : 0
270 if (meteorCount > 0) save = recordPsychicProc(save, 'mind-5', now, meteorCount)
271 save = { ...save, stage: { ...save.stage, bossTime: save.stage.bossTime + left }, battleSeconds: save.battleSeconds + left }
272 left = 0
273 break
274 }
275 left -= need
276 // 薬草のおかゆ(ボス戦 1 回だけ HP 回復)は、勝ち負けが決まったら使い切る
277 const meteorCount = hasPsychicNode(save, 'mind-5') ? Math.floor((bossStart + need) / 45) - Math.floor(bossStart / 45) : 0
278 if (meteorCount > 0) save = recordPsychicProc(save, 'mind-5', now, meteorCount)
279 save = useCharge({ ...save, battleSeconds: save.battleSeconds + need }, 'heal')
280 const n = stageIndex(chapter, BOSS_STAGE)
281 if (outcome.isWin) {
282 report.bossWins.push(chapter)
283 gain(bossExp(n) * rewardRate, bossGold(n) * rewardRate, false)
284 save = recordBossWin(save, chapter, outcome.killTime)
285 // 14 日目以降、本編のボスを初めて倒すとその章の魂(それより前に倒したボスは魂の試練で取る)
286 if (!rewardedBosses.has(chapter) && READY.has('codex') && isUnlocked(save, 'codex') && !save.souls.includes(chapter)) {
287 save = { ...save, souls: [...save.souls, chapter].sort((a, b) => a - b) }
288 }
289 if (hasPsychicNode(save, 'fortune-9')) {
290 const baseStars = rewardedBosses.has(chapter) ? 10 : 50
291 const bonus = psychicStarReward(save, baseStars) - baseStars
292 if (bonus > 0) save = { ...save, wallet: { ...save.wallet, stars: save.wallet.stars + bonus } }
293 }
294 rewardedBosses.add(chapter)
295 save = rememberPsychicBoss(save, chapter, true)
296 const isLast = chapter >= CHAPTER_COUNT
297 save = {
298 ...save,
299 isCleared: isLast,
300 // 全部クリアしたら最後の章の 1〜9 面をめぐって稼ぎ続ける
301 stage: isLast ? farmArrive(save) : arrive(chapter + 1, 1),
302 boss: { retryAt: null, last: { chapter, won: true, at: now } },
303 }
304 report.stages += 1
305 } else {
306 report.bossLosses.push(chapter)
307 save = rememberPsychicBoss(save, chapter, false)
308 save = {
309 ...save,
310 stage: farmArrive(save),
311 boss: { retryAt: save.battleSeconds + bossRetrySeconds(save), last: { chapter, won: false, at: now } },
312 }
313 }
314 continue
315 }
316
317 const n = stageIndex(chapter, stage)
318 const { hero, killSpeed, mithrilFast, hydra } = factors()
319 const foes = groupSize(save)
320 const rate = killRateWithHero(save, hero, killSpeed, mithrilFast, hydra, foes)
321 // 次の 1 匹を倒すまで(残りの数で範囲攻撃の効き方が変わるので、1 匹ずつ区切る)
322 const need = (Math.floor(save.stage.kills + 1e-9) + 1 - save.stage.kills) / rate
323 const step = Math.min(left, need, STEP)
324 const beforeSeconds = save.battleSeconds
325 const beforeDefeated = Math.floor(save.stage.kills + 1e-9)
326 const kills = rate * step
327 left -= step
328 report.kills += kills
329 save = { ...save, stage: { ...save.stage, kills: save.stage.kills + kills }, battleSeconds: save.battleSeconds + step }
330 if (hasPsychicNode(save, 'mind-5')) {
331 const meteorCount = Math.floor(save.battleSeconds / 45) - Math.floor(beforeSeconds / 45)
332 if (meteorCount > 0) save = recordPsychicProc(save, 'mind-5', now, meteorCount)
333 }
334 gain(kills * expPerKill(n) * rewardRate, kills * goldPerKill(n) * rewardRate)
335
336 if (save.stage.kills >= foes - 1e-6) {
337 // ボスに負けたあとは、再挑戦の時間までその章の 1〜9 面をランダムにめぐって稼ぐ(全部クリアしたあとも)。
338 // 再挑戦の時間を過ぎていたら、どの面にいてもボスへ
339 save = recordKills(save, stageGroup(chapter, stage))
340 const isFarmingNow = save.isCleared || save.boss.retryAt !== null
341 const isWaiting = isFarmingNow && (save.isCleared || save.battleSeconds < (save.boss.retryAt ?? 0))
342 if (isWaiting) {
343 save = { ...save, stage: farmArrive(save) }
344 } else {
345 report.stages += 1
346 save = { ...save, stage: isFarmingNow ? arrive(chapter, BOSS_STAGE, stage) : arrive(chapter, stage + 1) }
347 }
348 } else {
349 // 波(3 匹)を倒し終えたところで、ボスに負けて稼いでいて再挑戦の時間が来ていたら、面の最後まで待たずにボスへ向かう
350 const defeated = Math.floor(save.stage.kills + 1e-9)
351 if (defeated > beforeDefeated && defeated % WAVE === 0 && !save.isCleared && save.boss.retryAt !== null && save.battleSeconds >= save.boss.retryAt) {
352 save = recordKills(save, stageGroup(chapter, stage).slice(0, defeated))
353 report.stages += 1
354 save = { ...save, stage: arrive(chapter, BOSS_STAGE, stage) }
355 }
356 }
357 }
358
359 report.seconds = seconds
360 report.levels = save.level - startLevel
361
362 return { save, report }
363}
364
365/** 「ボスに挑む」ボタン: 負けたあと稼いでいる間に、待たずにすぐ再挑戦する */
366export function challengeBoss(save: SaveData): SaveData | undefined {
367 if (!isFarming(save)) {
368 return undefined
369 }
370
371 return { ...save, stage: arrive(save.stage.chapter, BOSS_STAGE, save.stage.stage), boss: { ...save.boss, retryAt: null } }
372}
373
374/** 転職が解放されているか(Lv が足りているか) */
375export const isJobUnlocked = (save: SaveData): boolean => save.level >= JOB_CHANGE.level
376
377/** 次に転職できるまでの残り時間(ミリ秒)。0 ならいま転職できる */
378export function jobCooldownLeft(save: SaveData, now: number): number {
379 return save.jobChangedAt === null ? 0 : Math.max(0, save.jobChangedAt + JOB_CHANGE.cooldownMs - now)
380}
381
382/** いまその職に移れるか */
383export const canChangeJob = (save: SaveData, now: number): boolean => isJobUnlocked(save) && jobCooldownLeft(save, now) === 0
384
385/**
386 * 転職: 3 職の間を横に移る(見習いには戻らない)。強さの合計は変わらず、得意(防御・範囲・会心)だけが変わる。
387 * 進化の段階はそのまま引き継ぐ。
388 */
389export function changeJob(save: SaveData, job: JobId, now: number): SaveData | undefined {
390 if (!canChangeJob(save, now) || job === 'novice' || job === save.job) {
391 return undefined
392 }
393
394 return { ...save, job, jobChangedAt: now }
395}
396
397export const MAX_EVOLUTION = EVOLUTION.levels.length
398
399/** 次の段階に進化できる Lv(もう最後なら undefined) */
400export const nextEvolutionLevel = (save: SaveData): number | undefined => EVOLUTION.levels[save.evolution]
401
402/** いま進化できるか(職を選んでいて、Lv が足りている) */
403export function canEvolve(save: SaveData): boolean {
404 const need = nextEvolutionLevel(save)
405
406 return save.job !== 'novice' && need !== undefined && save.level >= need
407}
408
409/** 進化: 1 段階上がる(見た目・オーラ・強さが変わる) */
410export function evolve(save: SaveData): SaveData | undefined {
411 return canEvolve(save) ? { ...save, evolution: save.evolution + 1 } : undefined
412}
413
414/** 1 回の攻撃の長さ(秒)。Lv と進化で速くなる(見た目だけ) */
415export function attackSeconds(save: SaveData): number {
416 const speed = 1 + BATTLE.attackSpeedPerLevel * (save.level - 1) + BATTLE.attackSpeedPerEvolution * save.evolution
417
418 return Math.max(BATTLE.minSeconds, Math.round((BATTLE.attackSeconds / speed) * 100) / 100)
419}
420
421/** 1 回の攻撃のダメージ(見た目の数字)。1 秒のダメージ × 1 回の攻撃の長さ */
422export const hitDamage = (save: SaveData): number => Math.max(1, Math.round(heroStats(save).bossDps * attackSeconds(save)))
423
424/** いまのステージの進み具合(0〜1)。ボス戦は制限時間のうち何割たったか */
425export function stageProgress(save: SaveData): number {
426 if (save.stage.stage >= BOSS_STAGE && !save.isCleared) {
427 return Math.min(1, save.stage.bossTime / Math.max(1e-6, bossOutcome(save).duration))
428 }
429
430 return Math.min(1, save.stage.kills / groupSize(save))
431}
432
433/** ボスの残り HP の割合(0〜1) */
434export function bossHpLeft(save: SaveData): number {
435 const n = stageIndex(save.stage.chapter, BOSS_STAGE)
436 return bossHpFraction(save, heroStats(save), save.stage.bossTime, bossHp(n) * hardEnemy(save))
437}
438
439function bossHpFraction(save: SaveData, hero: HeroStats, elapsed: number, hp: number): number {
440 const openingDamage = hasPsychicNode(save, 'mind-10') ? 0.08 : 0
441 const hasRage = hasPsychicNode(save, 'mind-9')
442 const untilRage = hasRage ? Math.max(0, (0.7 - openingDamage) * hp / hero.bossDps) : Infinity
443 const beforeRage = Math.min(elapsed, untilRage) * hero.bossDps
444 const afterRage = Math.max(0, elapsed - untilRage) * hero.bossDps * (hasRage ? 1.15 : 1)
445
446 return Math.max(0, 1 - openingDamage - (beforeRage + afterRage) / hp)
447}
448
449/** ボスに負けて、その章の 1〜9 面をめぐって稼いでいるところか */
450export const isFarming = (save: SaveData): boolean => !save.isCleared && save.boss.retryAt !== null && save.stage.stage < BOSS_STAGE
451
452/** 再挑戦まであと何秒(戦闘時間) */
453export const retryIn = (save: SaveData): number => Math.max(0, (save.boss.retryAt ?? 0) - save.battleSeconds)
454
455/** ボス敗北後の再挑戦待ち時間(未来視の「再起」を含む) */
456export function bossRetrySeconds(save: SaveData): number {
457 return psychicRetrySeconds(save, B.bossRetrySeconds)
458}
459
460/** HP バーの動き: いまの割合 from(0〜1)のまま delay 秒(実時間、移動の残り)待ってから、seconds 秒かけて to まで減る */
461export type HpBar = { from: number; to: number; seconds: number; delay?: number }
462
463export type HpTimeline = {
464 hero: HpBar
465 /** 群れの先頭の敵(いま斬られている敵)。ボス戦では無し */
466 front: HpBar | null
467 boss: HpBar | null
468}
469
470const clamp01 = (value: number): number => Math.max(0, Math.min(1, value))
471
472/**
473 * HP バーが止まる下限(5%)。バーが 0 になるのは、本当に敵が倒れた(ボス戦なら勝ち負けが決まった)ときの描き直しで
474 * バーごと消えるとき。描いた時刻と戦闘の計算が少しずれても「0 なのに倒れない」に見えないようにする安全策。
475 */
476export const HP_FLOOR = 0.05
477const floor = (value: number): number => Math.max(HP_FLOOR, value)
478
479/**
480 * いまから先の HP の動き(描くときに 1 回だけ計算し、あとは Svg の中の動きで減らしていく。数字が変わるたびに Svg を作り直さない)。
481 * 主人公の HP はボスの攻撃でだけ減る(雑魚は弱くて傷つかない)。0 になったらボス戦は負け(いままでのボス戦の仕組みと同じ)。
482 * @param speed 実時間 1 秒あたりの戦闘の秒数(作業中は 3、ふだんは 1)
483 */
484export function hpTimeline(save: SaveData, speed: number): HpTimeline {
485 const full: HpBar = { from: 1, to: 1, seconds: 0 }
486 // 移動中は減らない。着いてから減り始める(secondsToNextEvent と同じく、移動の残りを先に足す)
487 const delay = travelLeft(save) / Math.max(0.1, speed)
488 if (save.stage.stage >= BOSS_STAGE && !save.isCleared) {
489 const n = stageIndex(save.stage.chapter, BOSS_STAGE)
490 const hero = heroStats(save)
491 const outcome = bossOutcome(save)
492 const elapsed = Math.min(save.stage.bossTime, outcome.duration)
493 const left = Math.max(0, outcome.duration - elapsed) / Math.max(0.1, speed)
494 const hard = hardEnemy(save)
495 const heroAt = (time: number) => clamp01(1 - (time * bossAttack(n) * hard * hero.damageTaken) / hero.hp)
496 const bossAt = (time: number) => clamp01(bossHpFraction(save, hero, time, bossHp(n) * hard))
497 // 先に倒れて負けるなら、主人公の HP はちょうど 0 まで(計算の端数で 0.0000001 などが残らないように)
498 const isDown = !outcome.isWin && outcome.surviveTime <= outcome.duration + 1e-9
499 return {
500 hero: { from: floor(heroAt(elapsed)), to: floor(isDown ? 0 : heroAt(outcome.duration)), seconds: left, delay },
501 front: null,
502 boss: { from: floor(bossAt(elapsed)), to: floor(outcome.isWin ? 0 : bossAt(outcome.duration)), seconds: left, delay },
503 }
504 }
505 const progress = save.stage.kills - Math.floor(save.stage.kills + 1e-9)
506 const rate = killRate(save)
507
508 return { hero: full, front: { from: floor(clamp01(1 - progress)), to: HP_FLOOR, seconds: (1 - progress) / Math.max(1e-6, rate) / Math.max(0.1, speed), delay }, boss: null }
509}
510
511/** 主人公の HP の割合(いま)。帯の細いバーに使う */
512export const heroHpNow = (save: SaveData): number => hpTimeline(save, 1).hero.from
513
514/**
515 * 次に戦闘の「できごと」が起きるまでの戦闘の秒数: 雑魚戦は先頭の敵が倒れるまで、ボス戦は勝ち負けが決まるまで。
516 * 移動中なら、移動の残りを先に足す(移動が終わってから戦いが始まる)。
517 * HP バーが 0(ボス戦は止まる位置)になるのも、ちょうどこの時刻(hpTimeline と同じ計算)。
518 */
519export function secondsToNextEvent(save: SaveData): number {
520 const travel = travelLeft(save)
521 if (save.stage.stage >= BOSS_STAGE && !save.isCleared) {
522 return travel + Math.max(0, bossOutcome(save).duration - save.stage.bossTime)
523 }
524 const progress = save.stage.kills - Math.floor(save.stage.kills + 1e-9)
525
526 return travel + (1 - progress) / Math.max(1e-6, killRate(save))
527}
528hooks/build.generated.ts 5 lines1// このファイルは mod/scripts/stamp_build.py が作る。手で書き換えない。
2// どのコードが動いているかを画面と /dl-version で確かめるための識別子。
3
4export const BUILD = { version: '2.4.4', builtAt: '2026-10-25T09:30:07Z', base: 'b63298b', code: '95b34e7b', channel: 'public' } as const
5hooks/build.ts 12 lines1import { BUILD } from './build.generated'
2
3// どのコードが動いているか(mod/scripts/stamp_build.py が作る build.generated.ts から)
4
5/** 例: 1.0.1(2026-10-18T05:00:53Z・aebf0bb・7699a730) */
6export const buildLabel = (): string => `${BUILD.version}(${BUILD.builtAt}・${BUILD.base}・${BUILD.code})`
7
8/** 開発用チート(/dl-cheat)を使えるか。環境変数は開発版(channel が dev)のときだけ効く。公開版(--public で作った版)は無視する */
9export function cheatsAllowed(devCheats: boolean, channel: string, envValue: string | undefined): boolean {
10 return channel === 'dev' && (devCheats || envValue === '1')
11}
12hooks/debuglog.ts 101 lines1import type { $ } from './persist'
2
3// 不具合を調べるための実機の記録(撃破・群れの入れ替わり・ステージの移り変わり・戦闘画面の作り直し・タイマーの発火など)。
4// 時刻つきで $.store の debugLog に直近 400 件まで残す(/dl-debuglog で見る。設定タブでオフにできる)。
5// セーブと同じく、記録もセッションみんなで 1 つ。どのセッションが書いたかを、セッションの短い印(s:xxxx)で残す。
6// 作業の内容やプロンプトは記録しない(ゲームの状態だけ)。
7
8export const LOG_KEY = 'debugLog'
9export const LOG_LIMIT = 400
10
11// 400 件をまとめても、画面の文字数の上限(131,072)に収まる長さ。
12const TEXT_LIMIT = 240
13const KIND_LIMIT = 24
14// 時差を引いても Date の範囲をはみ出さないよう、端の 1 日は使わない。
15const DATE_LIMIT = 8.64e15 - 86_400_000
16
17export type LogEntry = { t: number; kind: string; text: string; who?: string }
18
19let isOn = true
20let who = ''
21const pending: LogEntry[] = []
22
23/** 保存先から来た記録も、使う欄の型と長さを確かめる。 */
24function entryOf(value: unknown): LogEntry | undefined {
25 if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
26 const raw = value as Record<string, unknown>
27 if (typeof raw.t !== 'number' || !Number.isFinite(raw.t) || raw.t < 0 || raw.t > DATE_LIMIT || typeof raw.kind !== 'string' || typeof raw.text !== 'string') return undefined
28
29 return {
30 t: Math.floor(raw.t),
31 kind: raw.kind.slice(0, KIND_LIMIT),
32 text: raw.text.slice(0, TEXT_LIMIT),
33 ...(typeof raw.who === 'string' ? { who: raw.who.slice(0, 4) } : {}),
34 }
35}
36
37function entriesOf(value: unknown): LogEntry[] {
38 return Array.isArray(value) ? value.slice(-LOG_LIMIT).map(entryOf).filter((entry): entry is LogEntry => entry !== undefined) : []
39}
40
41/** このセッションの短い印(記録の行に付ける) */
42export function setLogSession(id: string): void {
43 who = id.slice(0, 4)
44}
45
46/** 記録を取るか(セーブの設定 settings.debugLog から。persist.ts の show で合わせる) */
47export function setLogging(on: boolean): void {
48 isOn = on
49 if (!on) {
50 pending.length = 0
51 }
52}
53
54export const isLogging = (): boolean => isOn
55
56/** 1 件書きとめる(すぐには保存しない。flushLog でまとめて保存する。描画の途中でも呼べる) */
57export function note(t: number, kind: string, text: string): void {
58 if (!isOn) {
59 return
60 }
61 const entry = entryOf(who === '' ? { t, kind, text } : { t, kind, text, who })
62 if (entry === undefined) return
63 pending.push(entry)
64 if (pending.length > LOG_LIMIT) {
65 pending.splice(0, pending.length - LOG_LIMIT)
66 }
67}
68
69/** 書きとめた分を $.store に保存する(直近 400 件まで。ほかのセッションの分と時刻の順に並べる) */
70export async function flushLog($: $): Promise<void> {
71 if (pending.length === 0) {
72 return
73 }
74 const fresh = pending.splice(0, pending.length)
75 const old = await $.store.get(LOG_KEY).catch(() => undefined)
76 const list = entriesOf(old)
77 await $.store.set(LOG_KEY, [...list, ...fresh].sort((a, b) => a.t - b.t).slice(-LOG_LIMIT))
78}
79
80export async function readLog($: $): Promise<LogEntry[]> {
81 await flushLog($)
82 const list = await $.store.get(LOG_KEY).catch(() => undefined)
83
84 return entriesOf(list)
85}
86
87export async function clearLog($: $): Promise<void> {
88 pending.length = 0
89 await $.store.delete(LOG_KEY)
90}
91
92/** 時刻を「14:00:02.351」の形に(ローカル時刻) */
93export function clock(t: number): string {
94 const local = new Date(t - new Date(t).getTimezoneOffset() * 60_000).toISOString()
95
96 return local.slice(11, 23)
97}
98
99export const formatLog = (list: readonly LogEntry[]): string =>
100 entriesOf(list).map(entry => `${clock(entry.t)} ${entry.who === undefined ? '' : `s:${entry.who} `}${entry.kind.padEnd(8)} ${entry.text}`).join('\n')
101hooks/lease.ts 103 lines1import type { BusyMark, SaveData, TimeLease } from '../types'
2import { BUILD } from './build.generated'
3import { TIME } from './config'
4import { applyTime } from './fuel'
5import type { TimeGain } from './fuel'
6import { emptyReport } from './battle'
7
8// 時間を進める役(1 つのセッションだけ)と、作業中の印(どのセッションの作業でも 3 倍速)。
9// セーブ($.store)は、同じパソコンで動いているセッションみんなで 1 つ。前は、それぞれのセッションが自分の 10 秒のタイマーで
10// 戦闘を進めて書いていたので、違うバージョンのコードが混ざると、倒す速さの違う計算が交互に入って
11// 「予約した時刻に倒れていない」になっていた(1.0.1 の実機の記録)。
12// いまは、時間を進めて書くのは「役」を持っているセッションだけ。ほかのセッションは、セーブから同じ計算で
13// いまの様子を出す(書かない)。役は 30 秒ごとに更新し、更新が止まったら(セッションを閉じたなど)ほかが引き継ぐ。
14
15/** 役の期限(役のセッションは 10 秒ごとのタイマーでも更新する) */
16export const LEASE_MS = 30_000
17/** 作業中の印を更新する間隔 */
18const BUSY_RENEW_MS = 5_000
19/** 作業中の印をいくつまで残すか */
20const BUSY_LIMIT = 16
21
22/** 作業中の印の終わり(終わっていなければ、最後に見えた時刻から LEASE_MS) */
23const busyEnd = (mark: BusyMark): number => mark.to ?? mark.seen + LEASE_MS
24
25/** その時刻に、どれかのセッションが作業中か(3 倍速) */
26export function isBusyAt(save: SaveData, t: number): boolean {
27 return save.busy.some(mark => mark.from <= t && t < busyEnd(mark))
28}
29
30/** このセッションが時間を進める役を持てるか */
31export function canLead(save: SaveData, me: string, now: number, isWatching: boolean): boolean {
32 const lease = save.clock
33 if (lease === null || lease.owner === me || lease.until <= now) {
34 return true
35 }
36 // 新しいコードが引き継ぐ(古いほうは書くのをやめる)
37 if (lease.builtAt !== BUILD.builtAt) {
38 return lease.builtAt < BUILD.builtAt
39 }
40
41 // 同じコードなら、サイド画面を見ているセッションが引き継ぐ(音やお知らせが、見ているところで出るように)
42 return isWatching && !lease.isWatching
43}
44
45export function leaseFor(me: string, now: number, isWatching: boolean): TimeLease {
46 return { owner: me, build: BUILD.code, builtAt: BUILD.builtAt, until: now + LEASE_MS, isWatching }
47}
48
49/**
50 * 前回からいまのところまで時間を進める(作業中の印で速さを区切る)。役のセッションはこれを書き、
51 * ほかのセッションは書かずに画面に出す(同じ計算なので、どのセッションで見ても同じになる)。
52 */
53export function advanceShared(input: SaveData, now: number): { save: SaveData; gain: TimeGain } {
54 const elapsed = now - input.lastTickAt
55 if (!(elapsed > 0) || elapsed >= TIME.offlineThresholdMs) {
56 // 時間が進んでいない、または間があいた(オフライン報酬、作業中の速さは無し)
57 return applyTime(input, now, false)
58 }
59 const cuts = [...new Set(input.busy.flatMap(mark => [mark.from, busyEnd(mark)]))].filter(t => t > input.lastTickAt && t < now).sort((a, b) => a - b)
60 let save = input
61 const report = emptyReport()
62 let seconds = 0
63 for (const end of [...cuts, now]) {
64 const at = save.lastTickAt
65 const step = applyTime(save, end, isBusyAt(input, (at + end) / 2))
66 save = step.save
67 seconds += step.gain.seconds
68 const r = step.gain.report
69 report.kills += r.kills
70 report.exp += r.exp
71 report.gold += r.gold
72 report.levels += r.levels
73 report.stages += r.stages
74 report.bossWins.push(...r.bossWins)
75 report.bossLosses.push(...r.bossLosses)
76 }
77 report.seconds = seconds
78
79 return { save, gain: { seconds, offlineMs: 0, report } }
80}
81
82/** 終わった作業中の印を片づける(時間をそこまで進めたあと) */
83export const pruneBusy = (save: SaveData): SaveData => ({ ...save, busy: save.busy.filter(mark => busyEnd(mark) > save.lastTickAt).slice(-BUSY_LIMIT) })
84
85/** このセッションの作業の始まり・続き・終わりを印にする */
86export function markBusy(save: SaveData, me: string, now: number, isWorking: boolean): SaveData {
87 const open = save.busy.find(mark => mark.owner === me && mark.to === null && busyEnd(mark) > now)
88 if (isWorking) {
89 if (open !== undefined) {
90 // 続いている印は、ときどきだけ更新する(見るだけのセッションが、作業中に毎回セーブを書かないように)
91 return now - open.seen < BUSY_RENEW_MS ? save : { ...save, busy: save.busy.map(mark => (mark === open ? { ...mark, seen: now } : mark)) }
92 }
93 // すでに時間を進めたところより前からは始めない
94 const from = Math.max(now, save.lastTickAt)
95 return { ...save, busy: [...save.busy, { owner: me, from, to: null, seen: now }].slice(-BUSY_LIMIT) }
96 }
97 if (open === undefined) {
98 return save
99 }
100
101 return { ...save, busy: save.busy.map(mark => (mark === open ? { ...mark, to: Math.max(mark.from, now) } : mark)) }
102}
103