SLOPSHOPPER

dev-rpg

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

newpaneguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-rpg
│ ┃ 冒険の書 ✕ › fix the failing auth test and add an audit log call │ ┃ 勇者 Lv 1 見習いコーダー │ ┃ HP 50/50 MP 22/25 ⏺ Read(src/auth.ts) │ ┃ 経験値 56/60 (つぎまで 4) ⎿ Read 6 lines │ ┃ 攻撃力 11 防御力 2 魔力 8 素早さ 6 根性 ⏺ Update(src/auth.ts) │ ┃ 1 ⎿ Added 2 lines, removed 1 line │ ┃ SP 2 (「成長」で使う) 会心 6% ⏺ Bash(bun test) │ ┃ 実績 2/24 ●●○○○○○○○○○○○○○○○○○○○○○○ ⎿ 3 pass, 1 fail │ ┃ 冒険 1 回目 / 累計 1 ターン │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 実績「初めての Bash」を手に入れた │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ 戦う (気配 1) ] │ ┃ [ ダンジョン (記録 0 階) ] › /rpg │ ┃ [ 成長 (SP 2) ] │ ┃ [ 実績を見る ] │ ┃ [ 装備 ] │ ┃ [ 控えから戻す ] │ ┃ [ 冒険をやり直す ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 冒険の書
勇者 Lv 1 見習いコーダー HP 50/50 MP 22/25 経験値 56/60 (つぎまで 4) 攻撃力 11 防御力 2 魔力 8 素早さ 6 根性 1 SP 2 (「成長」で使う) 会心 6% 実績 2/24 ●●○○○○○○○○○○○○○○○○○○○○○○ 冒険 1 回目 / 累計 1 ターン 実績「初めての Bash」を手に入れた [ 戦う (気配 1) ] [ ダンジョン (記録 0 階) ] [ 成長 (SP 2) ] [ 実績を見る ] [ 装備 ] [ 控えから戻す ] [ 冒険をやり直す ]
README

claude-code-mods

English

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。

Anthropic とは関係のない、個人が作った非公式の mod です。

入っている mod

mod呼び方何をするか
mod-hub/mod画面のある mod(ペインや帯を出す物)をアイコン付きで並べる。名前・説明で絞る、固定して上に置く、カテゴリー別(開発・状況・道具・遊び)・名前順・最近開いた順。見出しに季節の小さな飾り
cc-dash/dashセッションの計器盤。経過・ターン・ツール・エラーのタイル、ターンの所要時間の棒グラフ、ツールのタイムライン、使用量のリング型ゲージ。「自分の並び」で出す枠と順を自分で決められる
work-bar入力欄の上に自動で出る作業中は経過時間・今のツール・流れる光の帯。待ち中は前のターンのまとめと定型文のボタン
aqua-pet/aquarium作業に反応する水槽。魚は 1 匹ずつターンで 稚魚 → 若魚 → 成魚 → 大物 と育ち、節目で新しい魚(全 10 種、後ほど珍しい)が来る。エラーで赤く震える。えさ・名前・掃除・魚の一覧のボタン。日本時間の時刻で水の色と光が(夕は橙で沈む日、夜は月と星)、季節で飾り(花びら・泡・落ち葉・雪)が変わる。turn-gacha で集めたカードでかざり(水草・石・流木・貝・つぼ・城・宝箱・沈没船)が手に入り、底の 5 つの置き場に置ける(城と沈没船の周りを魚が回る)。記録は毎日控えを取り、ペインから戻せる
project-board/projectsフォルダに並んだ引き継ぎメモ(*.md)の鮮度グラフと一覧・詳細
git-pane/git今のフォルダの git の様子。ブランチ・変更行数の差分バー・直近コミット。git には書き込まない
token-meter/tokensターンごとのトークンの積み上げ棒とキャッシュの当たり率
focus-timer/focus-timerポモドーロ。減っていくリングと今日の本数
session-recap/session-recapセッションのふりかえり。ターンの帯のタイムラインと、押した時だけ作る要約
edit-log/editsこのセッションで Claude が Edit / Write などの編集ツールで変えたファイルの時系列。押すと git の差分(HEAD と比べる)と「レビューして」
todo-board/todos今のフォルダの TODO / FIXME / HACK / XXX の件数・推移・一覧。git の外はフォルダを歩いて読む。種類のボタンで絞り、押すと「片付けて」
test-runner/testrun設定したテストのコマンドをボタンで走らせる。信号と直近 20 回の結果、失敗したら「直して」。Claude が走らせたテストも拾い、落ちている間は入力欄の上に 1 行出す。落ちたままコミットしようとしたらトーストで知らせる
prompt-book/promptsよく使う依頼文を保存して、ボタンで入力欄へ。タグ・使った回数・並び替え
live-commentary/jikkyoClaude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西
turn-gacha/gachaターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。daily-quest のお題をこなすと、おまけで引ける。記録は毎日控えを取り、「控えから戻す」で戻せる
dev-rpg/rpg作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。daily-quest のお題をこなすと経験値が入る。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる
code-omikuji/omikuji今日のコード運勢。筒を振ると棒の番号が回って止まり、和紙のおみくじが開く。文は 238 通りと、その季節の日だけ出る一言
agent-watch/agent-watch動いているサブエージェントの帯のタイムライン(経過・ツール回数・状態)。ボタンでその会話へ切り替え、メインへ戻る
canvas-pane/canvasClaude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる
rule-switch/rules「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる
wait-games/games長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる
spec-board/specsSpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ)
ci-pane/ci今のブランチの PR(状態・レビュー・チェックの丸)と GitHub Actions の結果。gh を使う。何かを起こす操作は無い
port-watch/ports待ち受け中の TCP ポートと、セッション中に増えた物の印。URL を写すボタンと、開発用の実行環境だけ 2 回押しで止めるボタン
pre-check/precheckコミット前の確かめ。変更の要約・秘密情報らしい文字列(足した行だけ、伏せて表示)・衝突の印・1MB 超のファイル・テスト(ボタンで)
face-band/face で出す / 隠す待ち・考え中・ツール実行中・答え終わり・エラーで変わる Claude の顔。日本時間の時間帯と季節で小物が変わる(夜は眠そうにナイトキャップ、夏は麦わら帽子、冬はマフラーなど)。入れただけでは出ない(最初はオフ)。work-bar と並べて出る
done-chime/chime長いターン(既定 30 秒以上)が終わったら音を鳴らす。音の種類・しきい値・音量・試し鳴らし
turn-haiku/haikuターンが終わるたびに、やった事を五七五の 1 句にして縦書きの短冊に。季節の季語の候補を添えて詠む。過去の句の一覧
session-timeline/timelineこのセッションのターンとツール呼び出しを横の帯の年表で。ツールの種類で色分け、失敗は赤い印、サブエージェントは細い帯、1 分以上のターンは黄の縁
file-map/filemapこのセッションで読んだ・書いたファイルを、フォルダごとの四角(ツリーマップ)で。大きいほど多く触った所、橙は書いた物、青は読んだだけ
toast-inbox/inboxほかの mod が出したトーストを 100 件まで残す受け箱(設定でステータス行も)。未読の数・mod ごとの件数・mod で絞る・全部既読・空にする。通知の行を押すと、それを出した mod のペインを開く
usage-history/history日をまたいだ使い方の記録。日ごとのターン数か出力トークンの棒(7 日 / 30 日)、合計と 1 日平均、曜日と時間帯のヒートマップ。直近 7 日の数をほかの mod(week-card)にも見せる。記録は毎日控えを取れる
fail-log/errorsツールが失敗した記録を 200 件まで残す。似た失敗をまとめた「よく落ちる物」の棒、最近の一覧、今日・通算の数。サブエージェントの分は紫で分ける
daily-quest/quest日替わりのお題 3 つ(ターン・ファイルを直す・テスト・コミットなど 8 種から)。進みの棒・ごほうび・連続日数・直近 7 日。ごほうびは dev-rpg が経験値、turn-gacha がおまけの抽選で受け取る。記録は毎日控えを取り、「控えから戻す」で戻せる
trophy-shelf/trophiesdev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も
mod-check/mod-checkmod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする
compact-cue入力欄の上に自動で出る・/cueコンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない
ship-guard常時・/guardClaude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す
rule-watch入力欄の上に自動で出る・/rulewatchClaude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・AskUserQuestion を使わずに「〜しますか?」で終わった・コミットの後に引き継ぎメモや開発ログを書いていない・開発ログを書いたのに「📝」の 1 行が無い・締めの言葉なのに開発ログが無い、の 6 つ。ペインは数と記録、「Claude に伝える」で入力欄に一言入れる
enc-guard常時・/enc文字コード・改行の罠を Claude が踏んだ時に、直し方を Claude に伝える(設定で止めるにもできる)。-Encoding の無い Set-Content / Add-Content、日本語入りで BOM の無い .ps1、ASCII 以外の字が入った .bat / .cmd、Windows で encoding= / newline= の無い Python の open
release-draft/release前のタグから今までのコミットと、版が上がった plugin.json / package.json を集めて、CHANGELOG の下書き(Added / Changed / Fixed)と次の版の候補を出す。「これで CHANGELOG を書いて」で Claude に頼む。git は読むだけ
reply-frame常時返答をオレンジ、自分の発言を青の角丸の枠で囲む
danger-frame常時危ないシェルのコマンドの行を赤い枠で囲む
long-turn-toast常時長くかかったターン(既定 120 秒超)をトーストで知らせる
usage-pane/usage-pane使用量の JSON を 3 本のバーで表示
devlog-pane/devlogフォルダにある今日の開発ログ(YYYY-MM-DD.md)を表示
run-guard常時・/runguardClaude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る
week-card/week1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 1 枚のカードにまとめ、週報に貼る文(Markdown)を入力欄に入れる

reply-frame は takosasi-dev/reply-frame でも公開しています。ここに入っているのは、自分の発言の枠を足した新しい版です。

画面

VS Code のペインに描かれる SVG の部分です(架空のデータ。ボタンなどは省いています)。node tools/shots.mts で作り直せます(--light で明るいテーマ)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.png" width="250" alt="mod-hub"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.png" width="250" alt="cc-dash"></td> <td align="center" valign="top"><b>work-bar</b><br><img src="docs/images/work-bar.png" width="250" alt="work-bar"></td> </tr> <tr> <td align="center" valign="top"><b>face-band</b><br><img src="docs/images/face-band.png" width="250" alt="face-band"></td> <td align="center" valign="top"><b>aqua-pet</b><br><img src="docs/images/aqua-pet.png" width="250" alt="aqua-pet"></td> <td align="center" valign="top"><b>project-board</b><br><img src="docs/images/project-board.png" width="250" alt="project-board"></td> </tr> <tr> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane.png" width="250" alt="git-pane"></td> <td align="center" valign="top"><b>token-meter</b><br><img src="docs/images/token-meter.png" width="250" alt="token-meter"></td> <td align="center" valign="top"><b>focus-timer</b><br><img src="docs/images/focus-timer.png" width="250" alt="focus-timer"></td> </tr> <tr> <td align="center" valign="top"><b>session-recap</b><br><img src="docs/images/session-recap.png" width="250" alt="session-recap"></td> <td align="center" valign="top"><b>edit-log</b><br><img src="docs/images/edit-log.png" width="250" alt="edit-log"></td> <td align="center" valign="top"><b>todo-board</b><br><img src="docs/images/todo-board.png" width="250" alt="todo-board"></td> </tr> <tr> <td align="center" valign="top"><b>test-runner</b><br><img src="docs/images/test-runner.png" width="250" alt="test-runner"></td> <td align="center" valign="top"><b>pre-check</b><br><img src="docs/images/pre-check.png" width="250" alt="pre-check"></td> <td align="center" valign="top"><b>ci-pane</b><br><img src="docs/images/ci-pane.png" width="250" alt="ci-pane"></td> </tr> <tr> <td align="center" valign="top"><b>spec-board</b><br><img src="docs/images/spec-board.png" width="250" alt="spec-board"></td> <td align="center" valign="top"><b>port-watch</b><br><img src="docs/images/port-watch.png" width="250" alt="port-watch"></td> <td align="center" valign="top"><b>prompt-book</b><br><img src="docs/images/prompt-book.png" width="250" alt="prompt-book"></td> </tr> <tr> <td align="center" valign="top"><b>agent-watch</b><br><img src="docs/images/agent-watch.png" width="250" alt="agent-watch"></td> <td align="center" valign="top"><b>canvas-pane</b><br><img src="docs/images/canvas-pane.png" width="250" alt="canvas-pane"></td> <td align="center" valign="top"><b>rule-switch</b><br><img src="docs/images/rule-switch.png" width="250" alt="rule-switch"></td> </tr> <tr> <td align="center" valign="top"><b>live-commentary</b><br><img src="docs/images/live-commentary.png" width="250" alt="live-commentary"></td> <td align="center" valign="top"><b>turn-gacha</b><br><img src="docs/images/turn-gacha.png" width="250" alt="turn-gacha"></td> <td align="center" valign="top"><b>dev-rpg</b><br><img src="docs/images/dev-rpg.png" width="250" alt="dev-rpg"></td> </tr> <tr> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.png" width="250" alt="code-omikuji"></td> <td align="center" valign="top"><b>turn-haiku</b><br><img src="docs/images/turn-haiku.png" width="250" alt="turn-haiku"></td> <td align="center" valign="top"><b>wait-games</b><br><img src="docs/images/wait-games.png" width="250" alt="wait-games"></td> </tr> <tr> <td align="center" valign="top"><b>session-timeline</b><br><img src="docs/images/session-timeline.png" width="250" alt="session-timeline"></td> <td align="center" valign="top"><b>file-map</b><br><img src="docs/images/file-map.png" width="250" alt="file-map"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.png" width="250" alt="usage-history"></td> </tr> <tr> <td align="center" valign="top"><b>toast-inbox</b><br><img src="docs/images/toast-inbox.png" width="250" alt="toast-inbox"></td> <td align="center" valign="top"><b>done-chime</b><br><img src="docs/images/done-chime.png" width="250" alt="done-chime"></td> <td align="center" valign="top"><b>fail-log</b><br><img src="docs/images/fail-log.png" width="250" alt="fail-log"></td> </tr> <tr> <td align="center" valign="top"><b>daily-quest</b><br><img src="docs/images/daily-quest.png" width="250" alt="daily-quest"></td> <td align="center" valign="top"><b>trophy-shelf</b><br><img src="docs/images/trophy-shelf.png" width="250" alt="trophy-shelf"></td> <td align="center" valign="top"><b>mod-check</b><br><img src="docs/images/mod-check.png" width="250" alt="mod-check"></td> </tr> <tr> <td align="center" valign="top"><b>compact-cue</b><br><img src="docs/images/compact-cue.png" width="250" alt="compact-cue"></td> <td align="center" valign="top"><b>ship-guard</b><br><img src="docs/images/ship-guard.png" width="250" alt="ship-guard"></td> <td align="center" valign="top"><b>rule-watch</b><br><img src="docs/images/rule-watch.png" width="250" alt="rule-watch"></td> </tr> <tr> <td align="center" valign="top"><b>enc-guard</b><br><img src="docs/images/enc-guard.png" width="250" alt="enc-guard"></td> <td align="center" valign="top"><b>release-draft</b><br><img src="docs/images/release-draft.png" width="250" alt="release-draft"></td> <td align="center" valign="top"><b>run-guard</b><br><img src="docs/images/run-guard.png" width="250" alt="run-guard"></td> </tr> <tr> <td align="center" valign="top"><b>week-card</b><br><img src="docs/images/week-card.png" width="250" alt="week-card"></td> </tr> </table>

動く絵

ペインの絵は開いた時に 1 回動きます(数字が増える・棒が伸びる・おみくじが開く)。node tools/gif.mts <mod> で作り直せます。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.gif" width="250" alt="mod-hub(動く絵)"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.gif" width="250" alt="cc-dash(動く絵)"></td> </tr> <tr> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.gif" width="250" alt="usage-history(動く絵)"></td> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.gif" width="250" alt="code-omikuji(動く絵)"></td> </tr> </table>

明るいテーマ

Claude Code の /config で theme を明るい物(light で始まる物)にすると、SVG も明るい色で描きます(VS Code の配色は mod から読めないので、Claude Code の theme に合わせます)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub-light.png" width="250" alt="mod-hub(明るいテーマ)"></td> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane-light.png" width="250" alt="git-pane(明るいテーマ)"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history-light.png" width="250" alt="usage-history(明るいテーマ)"></td> </tr> </table>

動作環境

  • Claude Code 2.1.300 以降(関数フックの mod を読み込める版)
  • VS Code で描くには、VS Code 拡張 2.1.300 以降が要ります。2.1.288 までの拡張は mod の描画に対応していません
  • ターミナル・デスクトップアプリでも動きます。ターミナルでは SVG の代わりに文字だけの表示になります

入れ方

好きな mod だけ入れられます。冒険の書(dev-rpg)とガチャ(turn-gacha)の 2 本だけが欲しい時は、単体版の claude-code-dev-rpg からも入れられます(中身は同じなので、どちらか片方から入れてください)。ターミナルの Claude Code の入力欄で:

/plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:

claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-mods を入れ、Add plugin で選びます。

mod-hub に並ぶのは、mod-hub より後に読み込まれる mod だけです。mod-hub を最初に入れてください。カテゴリーとアイコンはこのリポジトリの mod だけに付いていて、ほかの mod は「その他」に入ります。toast-inbox と mod-check も、ほかの mod より後から読み込まれる物を見るので、mod-hub の次に入れるのがおすすめです。

コマンドの名前がほかのプラグインや組み込みと被った時は、/edit-log・/todo-board・/test-runner・/prompt-book・/live-commentary・/turn-gacha・/dev-rpg・/code-omikuji・/gitpane・/token-meter・/mod-hub・/canvas-pane・/rule-switch・/wait-games・/spec-board・/ci-pane・/port-watch・/pre-check・/face-band・/done-chime・/turn-haiku・/session-timeline・/file-map・/toast-inbox・/usage-history・/fail-log・/compact-cue・/ship-guard・/rule-watch・/enc-guard・/release-draft・/daily-quest・/trophy-shelf・/modcheck・/run-guard・/week-card のように mod の名前のコマンドになります。

まとめて入れる

claude plugin install は 1 回に 1 本だけ受け取ります。まとめて入れる時は、下の行のかたまりをシェル(bash・zsh・PowerShell のどれでも)にそのまま貼ってください。最初の 1 本でマーケットプレイスが足され、2 本目からはそれを使います。スコープは既定で user(全プロジェクト)で、-s project を付けると今のプロジェクトだけに入ります。mod-hub・toast-inbox・mod-check を先に入れてから、ほしいカテゴリーを開いて貼ります。

最初に(mod-hub・toast-inbox・mod-check)

claude plugin install mod-hub --marketplace takosasi-dev/claude-code-mods
claude plugin install toast-inbox --marketplace takosasi-dev/claude-code-mods
claude plugin install mod-check --marketplace takosasi-dev/claude-code-mods
claude plugin install project-board --marketplace takosasi-dev/claude-code-mods
claude plugin install git-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install edit-log --marketplace takosasi-dev/claude-code-mods
claude plugin install todo-board --marketplace takosasi-dev/claude-code-mods
claude plugin install test-runner --marketplace takosasi-dev/claude-code-mods
claude plugin install spec-board --marketplace takosasi-dev/claude-code-mods
claude plugin install ci-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install port-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install pre-check --marketplace takosasi-dev/claude-code-mods
claude plugin install fail-log --marketplace takosasi-dev/claude-code-mods
claude plugin install ship-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install enc-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install release-draft --marketplace takosasi-dev/claude-code-mods
claude plugin install run-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
claude plugin install work-bar --marketplace takosasi-dev/claude-code-mods
claude plugin install token-meter --marketplace takosasi-dev/claude-code-mods
claude plugin install session-recap --marketplace takosasi-dev/claude-code-mods
claude plugin install agent-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install session-timeline --marketplace takosasi-dev/claude-code-mods
claude plugin install file-map --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-history --marketplace takosasi-dev/claude-code-mods
claude plugin install compact-cue --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install devlog-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install focus-timer --marketplace takosasi-dev/claude-code-mods
claude plugin install prompt-book --marketplace takosasi-dev/claude-code-mods
claude plugin install canvas-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-switch --marketplace takosasi-dev/claude-code-mods
claude plugin install done-chime --marketplace takosasi-dev/claude-code-mods
claude plugin install aqua-pet --marketplace takosasi-dev/claude-code-mods
claude plugin install live-commentary --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-mods
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-mods
claude plugin install code-omikuji --marketplace takosasi-dev/claude-code-mods
claude plugin install wait-games --marketplace takosasi-dev/claude-code-mods
claude plugin install face-band --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-haiku --marketplace takosasi-dev/claude-code-mods
claude plugin install daily-quest --marketplace takosasi-dev/claude-code-mods
claude plugin install trophy-shelf --marketplace takosasi-dev/claude-code-mods
claude plugin install week-card --marketplace takosasi-dev/claude-code-mods
claude plugin install reply-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install danger-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install long-turn-toast --marketplace takosasi-dev/claude-code-mods

全部(48 本)をまとめて(bash / zsh):

for m in mod-hub toast-inbox mod-check cc-dash work-bar aqua-pet project-board git-pane token-meter focus-timer session-recap edit-log todo-board test-runner prompt-book live-commentary turn-gacha dev-rpg code-omikuji agent-watch canvas-pane rule-switch wait-games spec-board ci-pane port-watch pre-check face-band done-chime turn-haiku session-timeline file-map usage-history compact-cue fail-log ship-guard rule-watch enc-guard release-draft daily-quest trophy-shelf reply-frame danger-frame long-turn-toast usage-pane devlog-pane run-guard week-card; do claude plugin install "$m" --marketplace takosasi-dev/claude-code-mods; done

PowerShell:

foreach ($m in 'mod-hub','toast-inbox','mod-check','cc-dash','work-bar','aqua-pet','project-board','git-pane','token-meter','focus-timer','session-recap','edit-log','todo-board','test-runner','prompt-book','live-commentary','turn-gacha','dev-rpg','code-omikuji','agent-watch','canvas-pane','rule-switch','wait-games','spec-board','ci-pane','port-watch','pre-check','face-band','done-chime','turn-haiku','session-timeline','file-map','usage-history','compact-cue','fail-log','ship-guard','rule-watch','enc-guard','release-draft','daily-quest','trophy-shelf','reply-frame','danger-frame','long-turn-toast','usage-pane','devlog-pane','run-guard','week-card') { claude plugin install $m --marketplace takosasi-dev/claude-code-mods }

この一覧は node tools/plugin_dirs.mts --readme で作り直せます(--en で英語版)。

clone したフォルダから読ませる

リポジトリを clone して、そのフォルダから読ませることもできます。

  • フォルダをマーケットプレイスとして足す: 上の行の takosasi-dev/claude-code-mods を clone 先のパス(例 D:\work\claude-code-mods)に変えて入れます。mod はそのフォルダから直接読まれるので、git pull の後に /reload-plugins で新しい版になります
  • CLAUDE_CODE_PLUGIN_DIRS で読ませる: 入れずに、セッションを始める時に毎回フォルダから読みます。値は clone 先で node tools/plugin_dirs.mts を走らせると出ます(別の場所を指すなら node tools/plugin_dirs.mts D:\work\claude-code-mods)。mod-hub・toast-inbox・mod-check を先頭に、全 mod のフォルダを Windows は ;、ほかは : でつないだ 1 行です。これを環境変数か、~/.claude/settings.json の env の CLAUDE_CODE_PLUGIN_DIRS に入れます(道具は settings.json を書き換えません)。要らない mod はその行から消してください
D:\work\claude-code-mods\mods\mod-hub;D:\work\claude-code-mods\mods\toast-inbox;D:\work\claude-code-mods\mods\cc-dash;...

気を付けること

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