SLOPSHOPPER

debug-legend

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

new
v2.4.4MITupdated 2026-10-25roi-roi61/debug-legend/mod
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (4 | import { PART as P1 } from './art-part-1.generated' ^ error: No matching export in "slop-empty:/home/runner/work/slopshopper/slopshopper/.cache/src/roi-roi
README

デバッグ伝説 / Debug Legend

日本語 | English


日本語

Claude Code で作業するほど強くなる、放置型 RPG の mod(プラグイン)です。 Claude に答えてもらうと装備が出る「宝箱」がたまり、時間がたつと主人公が勝手にバグやエラーの敵と戦って、ステージを進んでいきます。

  • 全 10 章 × 10 ステージ。各章の最後にボス(マージコンフリクト、無限ループの竜、ガベージコレクター…)
  • 雑魚は各章 30 種・全 300 種。1 ステージに 10〜30 匹が 3 匹ずつ襲ってくる
  • 装備 6 枠 × レア度 6 段階。強ければ自動で装備、弱ければ売却
  • Lv20 で 3 系統に転職: バックエンド騎士(防御)/フロントエンド魔導士(範囲攻撃)/インフラ狙撃手(会心)。さらに 3 段階の進化
  • 遊んだ日ごとに新しい施設が開く 14 日間の冒険: 商店・鉱山・農場・日課・ペット・遠征・鍛冶屋・超能力・金山・図鑑…そして真のラスボス
  • 閉じていた間の分も、最大 8 時間ぶんまとめて受け取り
  • 日本語 / English

紹介動画(PV・約 60 秒): docs/pv/debug-legend-pv.mp4

インストール

デスクトップアプリ

  1. 入力欄の横の + を押す
  2. Plugins → Add marketplace で Tomatonandesu/debug-legend を入力
  3. Add plugin で 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

遊び方

  • 入力欄の上の帯に、主人公と敵、ステージの進み具合、「宝箱 ×N」が出ます。画面を開く(または /dl-open)でサイドの広い画面が開きます。
  • 戦闘タブ: 上が戦闘、下が育成です。宝箱を開けると装備が 1 つ出ます。
  • 装備タブ: いまの装備 6 枠。職と進化タブ: Lv20 で職を選び、進化・超能力。街・採集・仲間タブ: 施設(開いたものから遊べます)。暦タブ: 14 日間の予定・図鑑。ステータスタブ: 詳しい数字。設定タブ: 宝箱を自動で開ける・自動判断・通知・音量・帯の折りたたみ・言語。
  • 燃料:
  • Claude の回答 1 回 = 宝箱 1 個(1 日の上限あり、ストックは 200 まで)
  • ツールの成功 = 少しのゴールド
  • トークン = 少しのおまけ(たくさん使っても得はしません)
  • 時間 = ステージが進む(Claude が作業中は 3 倍速)
  • ボスに負けたら、その章のステージをめぐって稼ぎ、しばらくして再挑戦します(ボスに挑むですぐ再挑戦も)。
  • 転職・エピック以上のドロップ・ボス撃破のあとは X でポストのリンクが出ます。

コマンド:

コマンド内容
/dlいまの状態
/dl-openサイド画面を開く
/dl-exportセーブをファイルに書き出す(~/.claude/debug-legend/exports/)
/dl-import [パス]書き出したセーブを読み込む

プライバシー

  • プロンプトや作業の内容を外部に送りません。 mod が使うのは「回答が終わった」「ツールが成功した」「使ったトークン数」「時間」だけで、プロンプト・回答・ツールの中身は使いません(保存も送信もしません)。
  • セーブはこのパソコンの中(Claude Code の設定フォルダ)にだけ保存されます。
  • X ポストはリンクを開くだけです。mod から X に何かを送ることはありません。本文はゲームの情報(職・Lv・戦闘力・装備・ボス)だけで、作業の内容は入りません。

既知の制限

  • 音(BGM・効果音)は、サイド画面を開いている間だけ鳴ります。Windows では、Claude Code の mod の音の仕組みが対応していないため、初めは鳴りません(既定ではオフ)。設定タブの「Windows で音を鳴らす(PowerShell を使う)」をオンにすると、Windows 標準の PowerShell を起動して、mod に同梱した音のファイルだけを鳴らします(ほかのファイルは鳴らさず、外へは何も送りません)。macOS などでは設定なしで鳴ります。
  • ターミナルでは文字だけの簡単な表示です。スマホ(Remote Control)向けの調整はしていません(パソコンでの利用を想定しています)。
  • セーブは端末ごとです(別のパソコンとは同期しません。/dl-export と /dl-import で移せます)。

クレジット

  • ドット絵(キャラクター・敵・背景): OpenAI Codex で生成した画像を、ドット絵に加工したもの
  • BGM・効果音: Python(numpy)で合成したオリジナル
  • コード: MIT ライセンス(LICENSE)

English

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.

  • 10 chapters × 10 stages, with a boss at the end of each chapter (Merge Conflict, Infinite Loop Dragon, Garbage Collector…)
  • 30 kinds of monsters per chapter, 300 in all. Each stage sends 10–30 of them at you, three at a time
  • 6 gear slots × 6 rarities. Stronger gear is equipped automatically, weaker gear is sold
  • Change class at Lv20: Backend Knight (defense) / Frontend Mage (area attacks) / Infra Sniper (critical hits), then rank up three times
  • A 14-day adventure that opens a new place each day you play: Shop, Mine, Farm, Quests, Pets, Expeditions, Blacksmith, Psychic Powers, Gold Mines, Codex… and a true final boss
  • Collect up to 8 hours of progress from while you were away
  • 日本語 / English

Trailer (about 60 seconds, in Japanese): docs/pv/debug-legend-pv.mp4

Install

Desktop app

  1. Click + next to the prompt box
  2. Plugins → Add marketplace, and enter Tomatonandesu/debug-legend
  3. Add plugin → debug-legend

Terminal (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

How to play

  • The bar above the prompt shows your hero, the enemy, stage progress and "Chests ×N". Press Open (or /dl-open) for the wide side panel.
  • Battle tab: the fight on top, your build below. Open a chest to get one item.
  • Gear tab: your 6 slots. Class & Rank tab: choose a class at Lv20, rank up, psychic powers. Town, Gather and Companions tabs: the places you have opened. Calendar tab: the 14-day roadmap and the codex. Status tab: the numbers. Settings tab: open chests automatically, auto decide, notifications, volume, folding the bar, language.
  • Fuel:
  • Each answer from Claude = 1 chest (daily cap, stock up to 200)
  • Successful tools = a little gold
  • Tokens = a small bonus (spending more does not help)
  • Time = stage progress (3× faster while Claude is working)
  • Lose to a boss and you train on that chapter's stages for a while before trying again (or press Challenge the boss).
  • After a class change, an Epic or better drop, or a boss defeat, a Post on X link appears.

Commands:

CommandWhat it does
/dlShow your progress
/dl-openOpen the side panel
/dl-exportExport your save to a file (~/.claude/debug-legend/exports/)
/dl-import [path]Import an exported save

Privacy

  • Your prompts and work are never sent anywhere. The mod only uses the facts that an answer finished, that a tool succeeded, how many tokens were used, and the time. It never uses the content of prompts, answers or tools (nothing is stored or sent).
  • Your save stays on this computer (in Claude Code's config folder).
  • Posting on X only opens a link. The mod sends nothing to X. The text holds game info only (class, level, power, gear, bosses), never your work.

Known limitations

  • Music and sound effects play only while the side panel is open. On Windows nothing plays by default (Claude Code's mod audio does not support Windows yet), and the Windows sound setting is off. If you turn on "Play sound on Windows (uses PowerShell)" in the Settings tab, the mod starts the built-in Windows PowerShell and plays only the sound files bundled with the mod (nothing else is played, and nothing is sent anywhere). On macOS and others, sound works without any setting.
  • The terminal shows a simple text-only view. The screens are not tuned for phones (Remote Control); the mod is made for desktop use.
  • Saves are per device (no sync; move them with /dl-export and /dl-import).

Credits

  • Pixel art (characters, enemies, backgrounds): images generated with OpenAI Codex, then converted to pixel art
  • Music and sound effects: original, synthesized with Python (numpy)
  • Code: MIT License (LICENSE)
Source 65 files
hooks/register.tsx 385 lines
1import { 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}
385
hooks/codex.ts 237 lines
1import 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}
237
hooks/actions.ts 923 lines
1import 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}
923
hooks/audio.ts 111 lines
1import { 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')
111
hooks/winsound.ts 183 lines
1import 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}
183
hooks/commands.ts 502 lines
1import { 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}
502
hooks/config.ts 204 lines
1// デバッグ伝説の調整用の数値はすべてここに集める(仮の値。開発用チートで試しながら決める)。
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
204
hooks/battle.ts 528 lines
1import 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}
528
hooks/build.generated.ts 5 lines
1// このファイルは 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
5
hooks/build.ts 12 lines
1import { 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}
12
hooks/debuglog.ts 101 lines
1import 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')
101
hooks/lease.ts 103 lines
1import 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