/gacha で、ターンが終わるたびに引く開発ネタのカード (60 種) と図鑑をペインで見る。daily-quest のお題をこなすと、ごほうびでおまけに引く。記録は毎日控えを取り、控えから戻せる

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。
Anthropic とは関係のない、個人が作った非公式の 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 | /jikkyo | Claude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西 |
| 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 | /canvas | Claude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる |
| rule-switch | /rules | 「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる |
| wait-games | /games | 長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる |
| spec-board | /specs | SpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ) |
| 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 | /trophies | dev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も |
| mod-check | /mod-check | mod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする |
| compact-cue | 入力欄の上に自動で出る・/cue | コンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない |
| ship-guard | 常時・/guard | Claude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す |
| rule-watch | 入力欄の上に自動で出る・/rulewatch | Claude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・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 | 常時・/runguard | Claude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る |
| week-card | /week | 1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 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>
好きな 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 して、そのフォルダから読ませることもできます。
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;...
/haiku の「自動で詠む」で止められますtaskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認ですgit config --global --add safe.directory <フォルダ> を足してください$.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消しますgit grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してくださいnpm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取りますhooks/register.tsx 323 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GachaSave } from '../types'
5import {
6 addDraw,
7 bookLines,
8 bookSvg,
9 cardLines,
10 cardSvg,
11 claimedFrom,
12 EMPTY,
13 fromSaved,
14 goalsMet,
15 KEEP_CLAIMED,
16 manualLeft,
17 PUBLIC_CARDS,
18 pull,
19 questRewards,
20 statsLines,
21 statsSvg,
22 withUnknown,
23} from './gacha'
24import { backup, backupDir, backupOf, closeRestore, isBackedToday, isRestoring, resetRestore, restoreButton, restoreCard, setBackupDir } from './backup'
25import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
26
27const PANE = 'turn-gacha'
28const TITLE = 'ガチャ'
29const DESCRIPTION = 'ターンが終わるたびに引いた開発ネタのカードと図鑑を見る'
30const STORE_KEY = 'save'
31
32const SAVE = atom({ plugin: 'turn-gacha', key: 'save' } as const, EMPTY as GachaSave)
33const VIEW = atom({ plugin: 'turn-gacha', key: 'view' } as const, 'card' as 'card' | 'book')
34const NOTE = atom({ plugin: 'turn-gacha', key: 'note' } as const, '')
35const ERRORS = atom({ plugin: 'turn-gacha', key: 'turnErrors' } as const, 0)
36/** 他の mod (dev-rpg) が読むカードの表 */
37const CARDS_REF = { plugin: 'turn-gacha', key: 'cards' } as const
38/** daily-quest の今日のお題 (読むだけ。daily-quest が無ければ undefined のまま) */
39const QUEST_TODAY = { plugin: 'daily-quest', key: 'today' } as const
40const CLAIM_KEY = 'questClaimed'
41
42type $ = EngineInterface
43
44const BROKEN = '記録を読めなかったので、引いた分を残せません(上書きしないよう止めています)'
45
46let ownsMain = false
47
48// store に書く物は 1 本ずつ流す。ボタンの連打やターン終わりと重なると、同じ値を読んで 1 枚消える
49let chain: Promise<unknown> = Promise.resolve()
50const serial = (f: () => Promise<unknown>) => (chain = chain.then(f, f))
51
52/** store の値 (と、知らない欄を残して書くための生の値)。キーが無ければ空、読めない時 (例外・形が違う) は undefined */
53async function readStore($: $): Promise<{ saved: GachaSave; raw: unknown } | undefined> {
54 try {
55 const raw = await $.store.get(STORE_KEY)
56 if (raw === undefined) return { saved: EMPTY, raw }
57 return typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? { saved: fromSaved(raw), raw } : undefined
58 } catch {
59 return undefined
60 }
61}
62
63/** store を state に写す。読めなければ知らせるだけ。その日はじめてなら、読んだ生の値を控える */
64async function load($: $) {
65 const got = await readStore($)
66 if (got) await update($, SAVE, () => got.saved)
67 else await update($, NOTE, () => BROKEN)
68 await backupAll($, got?.raw)
69}
70
71/** 図鑑 (save) と受け取り済みのお題 (questClaimed) を、その日はじめての時だけ控える。save の生の値は読んだ物があれば渡す */
72async function backupAll($: $, raw?: unknown) {
73 if (!backupDir) return
74 const now = await $.clock.now()
75 if (!isBackedToday(STORE_KEY, now)) await backup($, STORE_KEY, raw ?? (await $.store.get(STORE_KEY).catch(() => undefined)), now)
76 if (!isBackedToday(CLAIM_KEY, now)) await backup($, CLAIM_KEY, await $.store.get(CLAIM_KEY).catch(() => undefined), now)
77}
78
79/**
80 * day の控えに戻す。図鑑はその日の値で置き換え、受け取り済みのお題は今の物と合わせる
81 * (古い日に戻しても、今日もう受け取ったごほうびをもう一度引かないように)。古い版のままなら戻さない
82 */
83const restoreDay = ($: $, day: string) =>
84 serial(async () => {
85 if (!(await mayWrite($, await $.clock.now()))) return
86 const value = await backupOf($, STORE_KEY, day)
87 if (typeof value !== 'object' || value === null || Array.isArray(value)) {
88 $.ui.toast('控えを読めなかったので、戻していません', { timeoutMs: 8000 })
89 return
90 }
91 const isSaved = await $.store.set(STORE_KEY, value).then(
92 () => true,
93 () => false,
94 )
95 if (!isSaved) {
96 $.ui.toast('控えの記録を書けませんでした', { timeoutMs: 8000 })
97 return
98 }
99 // 図鑑は戻した。受け取り済みのお題は今の物に足す (今の物が読めない形なら書かない)
100 const old = claimedFrom(await backupOf($, CLAIM_KEY, day))
101 const isMerged =
102 old.length === 0 ||
103 (await $.store.get(CLAIM_KEY).then(
104 async now => {
105 if (now !== undefined && !Array.isArray(now)) return false
106 await $.store.set(CLAIM_KEY, [...new Set([...claimedFrom(now), ...old])].slice(-KEEP_CLAIMED))
107 return true
108 },
109 () => false,
110 ).catch(() => false))
111 await load($)
112 await update($, NOTE, () => '')
113 closeRestore($)
114 $.ui.toast(isMerged ? `${day} の記録に戻しました` : `${day} の記録に戻しました (受け取り済みのお題は合わせられませんでした)`)
115 })
116
117/**
118 * count 枚引いて store と state に書く。SR 以上だけトースト。store に残せたら true。
119 * 書く直前に store を読み直し、引いた分だけ足す (別のセッションの分を消さない)。serial の中で呼ぶ
120 */
121async function drawNow($: $, flags: { isClean: boolean; isManual: boolean }, count = 1): Promise<boolean> {
122 const now = await $.clock.now()
123 const got = await readStore($)
124 if (!got) {
125 await update($, NOTE, () => BROKEN)
126 return false
127 }
128 const saved = got.saved
129 if (flags.isManual && manualLeft(saved, now) <= 0) {
130 await update($, SAVE, () => saved)
131 await update($, NOTE, () => '今日の手動はおしまいです(また明日。ターンが終わるたびには引けます)')
132 return false
133 }
134 // 日をまたいで動き続けるセッションも、その日はじめて書く前に控える
135 await backupAll($, got.raw)
136 // 新しい版が入ったのに古い版のまま動いている時は、引かない (書くと新しい版の欄を消す。トーストで開き直しを頼む)
137 if (!(await mayWrite($, now))) return false
138 const cards = Array.from({ length: count }, () => pull(Math.random(), Math.random(), flags.isClean))
139 const next = cards.reduce((s, card) => addDraw(s, card, now, flags), saved)
140 // この版の知らない欄 (新しい版が足した物・履歴の知らない要素) は読んだまま残す
141 const isSaved = await $.store.set(STORE_KEY, withUnknown(got.raw, next, next.history.slice(0, count))).then(
142 () => true,
143 () => false,
144 )
145 if (!isSaved) {
146 // 残せなかったカードは見せない (画面は store の値のまま)
147 await update($, SAVE, () => saved)
148 await update($, NOTE, () => '引いたカードを保存できませんでした')
149 return false
150 }
151 await update($, SAVE, () => next)
152 await update($, VIEW, () => 'card')
153 // 図鑑の実績はその場で数える (保存はしない)
154 const before = new Set(goalsMet(saved).map(g => g.id))
155 const gained = goalsMet(next).filter(g => !before.has(g.id))
156 await update($, NOTE, () => (gained.length > 0 ? `図鑑の実績「${gained.map(g => g.name).join('」「')}」を達成` : ''))
157 for (const card of cards) if (card.rarity === 'SR' || card.rarity === 'SSR') $.ui.toast(`${card.rarity}!${card.name}`)
158 return true
159}
160
161const draw = ($: $, flags: { isClean: boolean; isManual: boolean }) => serial(() => drawNow($, flags))
162
163/**
164 * daily-quest でこなしたお題のごほうびを 1 回だけ受け取り、その回数だけその場で引く。
165 * 先に引いて図鑑に残し、残せた時だけ受け取った印 (store の questClaimed、書く直前に読み直す) を書く。
166 * 引けなかった (読めない・古い版・書けない) 時は印を付けないので、次のターンにもう一度引く。
167 * daily-quest が無ければ何もしない
168 */
169async function claim($: $) {
170 let today: unknown
171 try {
172 today = (await $.state.get(QUEST_TODAY)).value
173 } catch {
174 return
175 }
176 if (questRewards(today, []).length === 0) return
177 let got: ReturnType<typeof questRewards> = []
178 await serial(async () => {
179 const claimed = await $.store.get(CLAIM_KEY).then(claimedFrom, () => undefined)
180 if (!claimed) return
181 const fresh = questRewards(today, claimed)
182 if (fresh.length === 0 || !(await mayWrite($, await $.clock.now()))) return
183 const n = fresh.reduce((k, g) => k + g.pulls, 0)
184 if (n > 0 && !(await drawNow($, { isClean: false, isManual: false }, n))) return
185 // ponytail: 読んでから書くまでの間に別のセッションが受け取ると 2 度引ける。印を書けなかった時も次のターンにもう一度引く (多めに引けるが、ごほうびは消えない)
186 const isSaved = await $.store.set(CLAIM_KEY, [...claimed, ...fresh.map(g => g.key)].slice(-KEEP_CLAIMED)).then(
187 () => true,
188 () => false,
189 )
190 if (isSaved) got = fresh
191 })
192 const pulls = got.reduce((n, g) => n + g.pulls, 0)
193 if (got.length > 0) {
194 const msg = `お題「${got.map(g => g.title).join('」「')}」のごほうびで ${pulls} 回引いた`
195 await update($, NOTE, n => (n === '' ? msg : `${msg} / ${n}`))
196 }
197}
198
199const openPane = async ($: $) => {
200 await load($)
201 await $.ui.open({ id: PANE, title: TITLE })
202 return { text: 'ガチャを開きました。' }
203}
204
205export const register: Register = (on, options) => {
206 setBackupDir(options)
207 followTheme(on)
208
209 on('session.start', async ($, e, next) => {
210 const started = await next(e)
211 await readTheme($)
212 await rememberVersion($).catch(() => {})
213 resetRestore()
214 await $.state.set(CARDS_REF, PUBLIC_CARDS).catch(() => {})
215 // /gacha が拒まれたら (組み込みと被ったら) /turn-gacha にする
216 const got = await $.command.register({ name: 'gacha', description: DESCRIPTION }).catch(() => undefined)
217 ownsMain = got?.command === 'gacha'
218 if (!ownsMain) await $.command.register({ name: 'turn-gacha', description: DESCRIPTION }).catch(() => {})
219 await load($)
220 // 別のセッションでこなしたお題の分もここで拾う
221 await claim($).catch(() => {})
222 return started
223 })
224
225 // /gacha を持てなかった時は、元の /gacha に回す
226 on('command.run', { command: 'gacha' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
227 // /gacha を持てた時は /turn-gacha を登録していないので、その名前の持ち主に回す
228 on('command.run', { command: 'turn-gacha' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
229
230 // メインのターンだけ数える (サブエージェントの分は agentId 付き)
231 on('turn.start', async ($, e, next) => {
232 if (e.agentId === undefined) await update($, ERRORS, () => 0)
233 return next(e)
234 })
235
236 // 答えて終わったターンだけ引く (中断・エラー・拒否では引かない)。
237 // お題のごほうびは next の後に見る (daily-quest は next の前に進みを書くので、読み込みの順によらず同じターンで届く)
238 on('turn.complete', async ($, e, next) => {
239 const done = await next(e)
240 if (e.agentId === undefined && e.reason === 'answer') {
241 const isClean = (await read($, ERRORS)) === 0
242 await draw($, { isClean, isManual: false })
243 }
244 if (e.agentId === undefined) await claim($).catch(() => {})
245 return done
246 }).catch(($, e, next) => next(e))
247
248 // 見るだけ。メインのターンで失敗したツールを数える。断られた呼び出し (deny) は数えない
249 on('tool.call', async ($, e, next) => {
250 const ran = await next(e)
251 if (e.agentId === undefined && !('deny' in ran && ran.deny !== undefined) && ran.isError === true) await update($, ERRORS, n => n + 1)
252 return ran
253 }).catch(($, e, next) => next(e))
254
255 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
256 const t = $.ui.resolve(e)
257 const { Box, Text, Button } = t
258 // terminal の表にも Svg の名前があるので、画面の種類でも分ける
259 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
260 const save = await read($, SAVE)
261 const view = await read($, VIEW)
262 const note = await read($, NOTE)
263 const left = manualLeft(save, await $.clock.now())
264 const latest = save.history[0]
265
266 const textBox = (key: string, lines: ReturnType<typeof cardLines>) => (
267 <Box key={key} flexDirection="column">
268 {lines.map(l => (
269 <Text color={l.color} dimColor={l.isDim} bold={l.isBold}>
270 {l.text}
271 </Text>
272 ))}
273 </Box>
274 )
275 const alt = (lines: ReturnType<typeof cardLines>) => lines.map(l => l.text).join('\n')
276
277 let body
278 if (view === 'book') {
279 const lines = bookLines(save)
280 body = Svg ? <Svg key="book-svg" source={bookSvg(save)} alt={alt(lines)} /> : textBox('book-lines', lines)
281 } else {
282 const card = cardLines(latest)
283 const stats = statsLines(save, left)
284 // カードのアニメは引いた時だけ頭から。数の変わる物は別の Svg にする
285 body = Svg ? (
286 <Box key="card-view" flexDirection="column" gap={1}>
287 <Svg key="card-svg" source={cardSvg(latest)} alt={alt(card)} />
288 <Svg key="stats-svg" source={statsSvg(save, left)} alt={alt(stats)} />
289 </Box>
290 ) : (
291 <Box key="card-view" flexDirection="column" gap={1}>
292 {textBox('card-lines', card)}
293 {textBox('stats-lines', stats)}
294 </Box>
295 )
296 }
297
298 // 戻す欄を開いている間は、その欄の「戻す」を主にする (主のボタンは 1 つ)
299 const isOpen = isRestoring()
300 return (
301 <Box flexDirection="column" gap={1}>
302 {body}
303 <Box key="actions" flexDirection="column">
304 <Button
305 key="pull" dimColor={left <= 0 || isOpen}
306 label={left > 0 ? `もう一回引く(今日あと ${left} 回)` : '今日の手動はおしまい'}
307 variant={left > 0 && !isOpen ? 'primary' : 'secondary'}
308 onPress={() => void draw($, { isClean: false, isManual: true })}
309 />
310 <Button
311 key="book" dimColor
312 label={view === 'book' ? 'カードに戻る' : '図鑑'}
313 onPress={() => void update($, VIEW, v => (v === 'book' ? 'card' : 'book'))}
314 />
315 {restoreButton($, t, STORE_KEY)}
316 </Box>
317 {restoreCard($, t, day => void restoreDay($, day))}
318 {note !== '' && <Text color="#d29922">{note}</Text>}
319 </Box>
320 )
321 })
322}
323hooks/gacha.ts 514 lines1// カードの表・抽選・保存の形・SVG と terminal 用の行。どれも純関数
2import type { GachaCard, GachaDraw, GachaRarity, GachaSave, GachaSlot } from '../types'
3import { C, clean, esc, header, HEADER_H, jstDay, short } from './style'
4
5export type Card = GachaCard & { line: string }
6
7const c = (id: string, rarity: GachaRarity, slot: GachaSlot, name: string, line: string): Card => ({ id, rarity, slot, name, line })
8
9/** カードの表 (N 24 / R 18 / SR 12 / SSR 6)。id は保存に使うので変えない。slot は dev-rpg で装備する枠 */
10export const CARDS: readonly Card[] = [
11 c('n01', 'N', 'weapon', '消し忘れた console.log', '本番で誰かが読んでいる'),
12 c('n02', 'N', 'charm', 'TODO: あとで直す', 'あとは来ない'),
13 c('n03', 'N', 'armor', '行末の空白', '差分にだけ姿を見せる'),
14 c('n04', 'N', 'armor', 'タブと空白の混在', '静かな戦争'),
15 c('n05', 'N', 'charm', '謎の数字 42', '意味は誰も知らない'),
16 c('n06', 'N', 'weapon', 'コメントアウトした旧コード', 'いつか使うかもしれない'),
17 c('n07', 'N', 'charm', '開きっぱなしのタブ 50 枚', 'どれが本物の資料か'),
18 c('n08', 'N', 'weapon', '長すぎる変数名', '説明だけはできている'),
19 c('n09', 'N', 'charm', 'final_v3_本当の最終版', '最終版はまだ先'),
20 c('n10', 'N', 'weapon', '再起動で直ったバグ', '原因は闇の中'),
21 c('n11', 'N', 'charm', '読まれない README', '書いた日が一番輝いていた'),
22 c('n12', 'N', 'armor', '黄色い警告', 'エラーじゃないから'),
23 c('n13', 'N', 'armor', '空っぽの catch', '何も起きなかったことにする'),
24 c('n14', 'N', 'weapon', 'どこかで見たコード片', '出どころは覚えていない'),
25 c('n15', 'N', 'armor', '終わらないビルド', 'お茶を淹れる時間'),
26 c('n16', 'N', 'charm', '使われない import', 'いつかの名残'),
27 // 0.3.0 で足した分
28 c('n17', 'N', 'armor', 'ローカルでは動く', 'ほかの場所では知らない'),
29 c('n18', 'N', 'weapon', 'コピペで増えた関数', '少しずつ違う五兄弟'),
30 c('n19', 'N', 'charm', '付箋だらけのモニター', '一枚はがすと二枚増える'),
31 c('n20', 'N', 'armor', '流れ続けるログ', '目当ての一行はいつも上'),
32 c('n21', 'N', 'weapon', '読めない正規表現', '書いた本人も読めない'),
33 c('n22', 'N', 'charm', '名前が temp のファイル', '一時的に三年いる'),
34 c('n23', 'N', 'armor', '赤いままの CI の印', 'もう誰も気にしない'),
35 c('n24', 'N', 'weapon', '紛れ込んだ全角空白', '見た目は同じ、中身は違う'),
36 c('r01', 'R', 'weapon', 'きれいな差分', 'レビューが一瞬で終わる'),
37 c('r02', 'R', 'armor', '型エラー 0 件', '赤い波線が消えた'),
38 c('r03', 'R', 'charm', '意味のあるコミットメッセージ', '半年後の自分が喜ぶ'),
39 c('r04', 'R', 'armor', 'すぐ終わった CI', '待つ間もなかった'),
40 c('r05', 'R', 'weapon', '見つかった再現手順', '半分は直ったも同然'),
41 c('r06', 'R', 'weapon', '消せたデッドコード', '消すのは書くより気持ちいい'),
42 c('r07', 'R', 'armor', '効いたキャッシュ', '二回目は一瞬'),
43 c('r08', 'R', 'charm', '読める関数名', 'コメントがいらない'),
44 c('r09', 'R', 'armor', '通った lint', '文句ひとつない'),
45 c('r10', 'R', 'charm', '小さなプルリク', '誰もこわがらない'),
46 c('r11', 'R', 'weapon', '先に書いたテスト', '迷わず進める'),
47 c('r12', 'R', 'charm', '当たった仮説', 'ログが味方した'),
48 c('r13', 'R', 'weapon', '効いたブレークポイント', '止まってほしい所で止まった'),
49 c('r14', 'R', 'armor', '足りていた型注釈', '補完がすらすら出る'),
50 c('r15', 'R', 'charm', '先に読んだエラー文', '答えはだいたい書いてある'),
51 c('r16', 'R', 'weapon', 'ちょうどいい粒度のコミット', '戻すのも簡単'),
52 c('r17', 'R', 'armor', 'どこでも同じ環境', 'みんなの手元で同じ動き'),
53 c('r18', 'R', 'charm', '分かりやすい変更の説明', 'レビューする人が助かる'),
54 c('s01', 'SR', 'weapon', '一行で直ったバグ', '三時間さがして一行'),
55 c('s02', 'SR', 'armor', '競合なしのマージ', '何事もなかったように混ざる'),
56 c('s03', 'SR', 'charm', 'ぴたりと決まった名前', '命名の神が降りてきた'),
57 c('s04', 'SR', 'armor', '金曜夕方の無事なデプロイ', '誰も呼び出されない週末'),
58 c('s05', 'SR', 'weapon', '二倍速くなった処理', '計測は嘘をつかない'),
59 c('s06', 'SR', 'armor', '静かなビルドログ', '警告がひとつも出ない'),
60 c('s07', 'SR', 'charm', '一度で通ったレビュー', '承認がすぐに付いた'),
61 c('s08', 'SR', 'charm', '説明書どおりに動いた API', '書いてある通りだった'),
62 c('s09', 'SR', 'weapon', '一晩で終わった移行', '朝には新しい版'),
63 c('s10', 'SR', 'armor', 'ゼロになった依存の警告', '監査がきれいに通る'),
64 c('s11', 'SR', 'charm', '最初から付いていた型', '型定義を探さなくていい'),
65 c('s12', 'SR', 'weapon', '半分になったバンドル', '読み込みが目に見えて速い'),
66 c('x01', 'SSR', 'weapon', '一発で通ったテスト', '書いて走らせたら全部緑'),
67 c('x02', 'SSR', 'charm', 'つかまえた幽霊バグ', 'たまにしか出ないあの子をついに'),
68 c('x03', 'SSR', 'weapon', '千行の削除', '機能はそのまま、コードは半分'),
69 c('x04', 'SSR', 'armor', '障害ゼロの一年', 'ずっと平和だった'),
70 c('x05', 'SSR', 'armor', '一度も落ちない夜間ビルド', '毎朝ずっと緑'),
71 c('x06', 'SSR', 'charm', '誰でも読める設計図', '新しい人が初日から動ける'),
72]
73
74export const RARITIES: readonly GachaRarity[] = ['SSR', 'SR', 'R', 'N']
75const BY_ID = new Map(CARDS.map(x => [x.id, x]))
76export const cardOf = (id: string) => BY_ID.get(id)
77
78/** 出る確率 (%)。ノーミス (そのターンのエラーが 0) なら SR と SSR が少し上がる */
79export const rates = (isClean: boolean): Record<GachaRarity, number> =>
80 isClean ? { SSR: 3, SR: 13, R: 28, N: 56 } : { SSR: 2, SR: 10, R: 28, N: 60 }
81
82/** 0 以上 1 未満の数 2 つから 1 枚選ぶ。r1 でレア度、r2 でそのレア度の中の 1 枚 */
83export function pull(r1: number, r2: number, isClean: boolean): Card {
84 const p = rates(isClean)
85 let edge = 0
86 let rarity: GachaRarity = 'N'
87 for (const r of RARITIES) {
88 // 百分率の整数で足す (0.02 + 0.1 のような小数の誤差を避ける)
89 edge += p[r]
90 if (r1 * 100 < edge) {
91 rarity = r
92 break
93 }
94 }
95 const pool = CARDS.filter(x => x.rarity === rarity)
96 return pool[Math.min(pool.length - 1, Math.floor(r2 * pool.length))] ?? CARDS[0]!
97}
98
99export const MANUAL_PER_DAY = 3
100export const KEEP_HISTORY = 10
101
102export const EMPTY: GachaSave = { book: {}, total: 0, manualDay: '', manualUsed: 0, history: [] }
103
104/** 日本時間 (JST) の日付 YYYY-MM-DD (動いている環境のタイムゾーンには頼らない) */
105export const dayKey = jstDay
106
107/** 今日あと何回手で引けるか */
108export const manualLeft = (s: GachaSave, now: number) => MANUAL_PER_DAY - (s.manualDay === dayKey(now) ? s.manualUsed : 0)
109
110const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0)
111
112/** store から読んだ値を形にそろえる。知らないカードや壊れた値は捨てる */
113export function fromSaved(raw: unknown): GachaSave {
114 if (typeof raw !== 'object' || raw === null) return EMPTY
115 const o = raw as Record<string, unknown>
116 const book: GachaSave['book'] = {}
117 if (typeof o.book === 'object' && o.book !== null)
118 for (const [id, v] of Object.entries(o.book as Record<string, unknown>)) {
119 const x = (v ?? {}) as Record<string, unknown>
120 if (BY_ID.has(id) && num(x.count) > 0) book[id] = { count: num(x.count), firstAt: num(x.firstAt) }
121 }
122 const history = (Array.isArray(o.history) ? o.history : [])
123 .filter((d): d is GachaDraw => typeof d === 'object' && d !== null && BY_ID.has(String((d as GachaDraw).cardId)))
124 .slice(0, KEEP_HISTORY)
125 .map(d => ({ cardId: d.cardId, at: num(d.at), isNew: d.isNew === true, isClean: d.isClean === true, isManual: d.isManual === true }))
126 return {
127 book,
128 total: num(o.total),
129 manualDay: typeof o.manualDay === 'string' ? o.manualDay : '',
130 manualUsed: num(o.manualUsed),
131 history,
132 }
133}
134
135const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
136
137/** raw のうち known に無い欄 (この版が知らない、新しい版が書いた物)。物でなければ空 */
138export function unknownOf(raw: unknown, known: Iterable<string>): Record<string, unknown> {
139 if (!isObj(raw)) return {}
140 const k = new Set(known)
141 return Object.fromEntries(Object.entries(raw).filter(([key]) => !k.has(key)))
142}
143
144/**
145 * store に書く形。書く直前に読んだ raw のうち、この版の知らない欄 (新しい版の欄・知らないカードの所持・所持の欄) を残して mine を重ねる。
146 * 古い版のまま動いているセッションが、新しい版で増えたカードの記録を消さないように。
147 * 履歴は読んだ生の要素をそのまま残し (知らないカード・知らない欄ごと)、前に added (今回引いた分) を足す。
148 * 長さは KEEP_HISTORY か、読んだ長さ (新しい版がもっと残していれば) の長い方まで
149 */
150export function withUnknown(raw: unknown, mine: GachaSave, added: readonly GachaDraw[] = []): Record<string, unknown> {
151 const o = isObj(raw) ? raw : {}
152 const was = isObj(o.book) ? o.book : {}
153 const book = unknownOf(was, BY_ID.keys())
154 for (const [id, v] of Object.entries(mine.book)) book[id] = { ...unknownOf(was[id], ['count', 'firstAt']), ...v }
155 const old: unknown[] = Array.isArray(o.history) ? o.history : []
156 const history = [...added, ...old].slice(0, Math.max(KEEP_HISTORY, old.length))
157 return { ...unknownOf(o, Object.keys(EMPTY)), ...mine, book, history }
158}
159
160/** 1 枚引いた後の保存の形 */
161export function addDraw(s: GachaSave, card: Card, now: number, flags: { isClean: boolean; isManual: boolean }): GachaSave {
162 const had = s.book[card.id]
163 const draw: GachaDraw = { cardId: card.id, at: now, isNew: !had, isClean: flags.isClean, isManual: flags.isManual }
164 const today = dayKey(now)
165 return {
166 book: { ...s.book, [card.id]: { count: (had?.count ?? 0) + 1, firstAt: had?.firstAt ?? now } },
167 total: s.total + 1,
168 manualDay: flags.isManual ? today : s.manualDay,
169 manualUsed: flags.isManual ? (s.manualDay === today ? s.manualUsed : 0) + 1 : s.manualUsed,
170 history: [draw, ...s.history].slice(0, KEEP_HISTORY),
171 }
172}
173
174/** レア度ごとの持っている種類・全種類・枚数 */
175export function rarityStats(s: GachaSave) {
176 return RARITIES.map(r => {
177 const all = CARDS.filter(x => x.rarity === r)
178 const owned = all.filter(x => s.book[x.id])
179 return { rarity: r, owned: owned.length, all: all.length, count: owned.reduce((n, x) => n + (s.book[x.id]?.count ?? 0), 0) }
180 })
181}
182
183export const ownedKinds = (s: GachaSave) => CARDS.filter(x => s.book[x.id]).length
184
185/** 他の mod に見せるカードの表 (一言は除く)。state の cards に置く */
186export const PUBLIC_CARDS: GachaCard[] = CARDS.map(({ id, rarity, slot, name }) => ({ id, rarity, slot, name }))
187
188// ── daily-quest のごほうび
189
190/** 受け取ったお題 (`${date}:${id}`) を覚えておく数 */
191export const KEEP_CLAIMED = 60
192/** 1 つのお題で引ける回数の上限 (外から来る数は信用しない) */
193const MAX_PULLS = 5
194
195/**
196 * daily-quest の state の today から、こなしたお題のうちまだ受け取っていない物。
197 * daily-quest が無い・形が違う時は空。題名は制御文字を落として短くする
198 */
199export function questRewards(today: unknown, claimed: readonly string[]): { key: string; title: string; pulls: number }[] {
200 if (typeof today !== 'object' || today === null) return []
201 const { date, quests } = today as { date?: unknown; quests?: unknown }
202 if (typeof date !== 'string' || !Array.isArray(quests)) return []
203 return quests.flatMap(q => {
204 if (typeof q !== 'object' || q === null) return []
205 const { id, title, done, reward } = q as Record<string, unknown>
206 const pulls = (reward as { pulls?: unknown } | undefined)?.pulls
207 if (done !== true || typeof id !== 'string' || typeof pulls !== 'number' || !Number.isFinite(pulls)) return []
208 const key = clean(`${date}:${id}`).slice(0, 80)
209 if (claimed.includes(key)) return []
210 return [{ key, title: short(clean(String(title ?? '')), 30), pulls: Math.max(0, Math.min(MAX_PULLS, Math.floor(pulls))) }]
211 })
212}
213
214/** store の questClaimed を読む (壊れていれば空) */
215export const claimedFrom = (raw: unknown): string[] => (Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string').slice(-KEEP_CLAIMED) : [])
216
217// ── 図鑑の実績 (図鑑から毎回数えるので、保存はしない)
218
219export type BookGoal = { id: string; name: string; hint: string; test: (s: GachaSave) => boolean }
220const hasAll = (s: GachaSave, r: GachaRarity) => CARDS.every(x => x.rarity !== r || s.book[x.id])
221const kindsOf = (r: GachaRarity) => CARDS.filter(x => x.rarity === r).length
222/** 図鑑の半分 (カードを足しても実績の数が表と合うように、表から数える) */
223export const HALF = Math.ceil(CARDS.length / 2)
224
225export const BOOK_GOALS: readonly BookGoal[] = [
226 { id: 'first', name: 'はじめの一枚', hint: '1 枚引く', test: s => ownedKinds(s) >= 1 },
227 { id: 'ssr-first', name: '虹色の出会い', hint: 'SSR を 1 枚引く', test: s => CARDS.some(x => x.rarity === 'SSR' && s.book[x.id]) },
228 { id: 'n-all', name: 'N コンプ', hint: `N を ${kindsOf('N')} 種そろえる`, test: s => hasAll(s, 'N') },
229 { id: 'half', name: '図鑑の半分', hint: `${HALF} 種そろえる`, test: s => ownedKinds(s) >= HALF },
230 { id: 'sr-all', name: 'SR コンプ', hint: `SR を ${kindsOf('SR')} 種そろえる`, test: s => hasAll(s, 'SR') },
231 { id: 'all', name: `全 ${CARDS.length} 種`, hint: '全部そろえる', test: s => ownedKinds(s) >= CARDS.length },
232]
233
234export const goalsMet = (s: GachaSave) => BOOK_GOALS.filter(g => g.test(s))
235
236// ── 描画
237
238/** 絵の中身として決まっている色 (金の枠の真ん中) */
239const GOLD_MID = '#f2cc60'
240/** レア度の色 (SSR は虹の代わりの紫)。テーマで変わるので呼ぶたびに読む */
241export const rarityColor = (r: GachaRarity) => ({ N: C.weak, R: C.blue, SR: C.yellow, SSR: C.purple })[r]
242const W = 380
243
244const wide = (ch: string) => /[⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch)
245const units = (s: string) => [...s].reduce((n, ch) => n + (wide(ch) ? 2 : 1), 0)
246
247/** 幅 (半角いくつ分) に収まるように行を分ける。最大 2 行、はみ出しは … */
248export function wrap(s: string, max: number): string[] {
249 const out: string[] = []
250 let cur = ''
251 for (const ch of s) {
252 if (units(cur + ch) > max) {
253 out.push(cur)
254 cur = ''
255 }
256 cur += ch
257 }
258 out.push(cur)
259 if (out.length <= 2) return out
260 const second = out[1] ?? ''
261 return [out[0] ?? '', `${second.slice(0, -1)}…`]
262}
263
264const svgDoc = (h: number, defs: string, body: string) =>
265 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" font-family="sans-serif">` +
266 `<defs>${defs}</defs><rect width="${W}" height="${h}" fill="${C.bg}"/>${body}</svg>`
267
268// 虹 (SSR)。グラデーションを横に流し続ける
269const RAINBOW =
270 `<linearGradient id="rainbow" x1="0" y1="0" x2="0.5" y2="0.5" spreadMethod="repeat">` +
271 ['#f85149', '#f78166', '#f2cc60', '#3fb950', '#58a6ff', '#bc8cff', '#f85149']
272 .map((col, i) => `<stop offset="${(i / 6).toFixed(3)}" stop-color="${col}"/>`)
273 .join('') +
274 `<animateTransform attributeName="gradientTransform" type="translate" from="0 0" to="0.5 0.5" dur="2.5s" repeatCount="indefinite"/>` +
275 `</linearGradient>`
276const GOLD =
277 `<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">` +
278 `<stop offset="0" stop-color="${C.yellow}"/><stop offset="0.5" stop-color="${GOLD_MID}"/><stop offset="1" stop-color="${C.yellow}"/></linearGradient>`
279// 光の筋 (SR と SSR)
280const SHINE =
281 `<linearGradient id="shine" x1="0" y1="0" x2="1" y2="0">` +
282 `<stop offset="0" stop-color="#ffffff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff6d5" stop-opacity="0.55"/>` +
283 `<stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>`
284const GLOW = `<filter id="glow" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="7"/></filter>`
285
286const strokeOf = (r: GachaRarity) => (r === 'SSR' ? 'url(#rainbow)' : r === 'SR' ? 'url(#gold)' : rarityColor(r))
287
288/** 4 つの角を持つきらめき */
289const sparkle = (x: number, y: number, size: number, begin: number) =>
290 `<path d="M${x} ${y - size} Q${x} ${y} ${x + size} ${y} Q${x} ${y} ${x} ${y + size} Q${x} ${y} ${x - size} ${y} Q${x} ${y} ${x} ${y - size}Z" fill="#ffffff" opacity="0">` +
291 `<animate attributeName="opacity" values="0;1;0" dur="1.6s" begin="${begin}s" repeatCount="indefinite"/></path>`
292
293const star = (x: number, y: number, r: number, fill: string) => {
294 const pts = Array.from({ length: 10 }, (_, i) => {
295 const a = (Math.PI / 5) * i - Math.PI / 2
296 const rr = i % 2 === 0 ? r : r * 0.45
297 return `${(x + rr * Math.cos(a)).toFixed(1)},${(y + rr * Math.sin(a)).toFixed(1)}`
298 })
299 return `<polygon points="${pts.join(' ')}" fill="${fill}"/>`
300}
301
302const STARS: Record<GachaRarity, number> = { N: 1, R: 2, SR: 3, SSR: 4 }
303
304/** 最新のカードを大きく。めくれて現れ、レア度で枠と光が変わる */
305export function cardSvg(draw: GachaDraw | undefined): string {
306 const H = 330
307 const card = draw ? cardOf(draw.cardId) : undefined
308 if (!draw || !card) {
309 return svgDoc(
310 H,
311 '',
312 `<rect x="80" y="14" width="220" height="300" rx="16" fill="${C.face}" stroke="${C.line}" stroke-width="2" stroke-dasharray="6 6"/>` +
313 `<text x="190" y="150" font-size="40" font-weight="bold" fill="${C.line}" text-anchor="middle">?</text>` +
314 `<text x="190" y="196" font-size="13" fill="${C.weak}" text-anchor="middle">まだ引いていません</text>` +
315 `<text x="190" y="218" font-size="12" fill="${C.weak}" text-anchor="middle">ターンが終わると 1 回引けます</text>`,
316 )
317 }
318 const r = card.rarity
319 const stroke = strokeOf(r)
320 const color = rarityColor(r)
321 const isHigh = r === 'SR' || r === 'SSR'
322 // カードの中身は中心 (0,0) から描き、外の g でめくる
323 let body = ''
324 // 後ろの光 (SR 以上は脈打つ)
325 if (r !== 'N')
326 body +=
327 `<rect x="-112" y="-152" width="224" height="304" rx="18" fill="none" stroke="${stroke}" stroke-width="10" filter="url(#glow)" opacity="${isHigh ? 0.9 : 0.5}">` +
328 (isHigh ? `<animate attributeName="opacity" values="0.35;0.95;0.35" dur="2s" repeatCount="indefinite"/>` : '') +
329 `</rect>`
330 body += `<rect x="-110" y="-150" width="220" height="300" rx="16" fill="${C.face}" stroke="${stroke}" stroke-width="4"/>`
331 body += `<rect x="-100" y="-140" width="200" height="280" rx="10" fill="none" stroke="${stroke}" stroke-width="1" opacity="0.5"/>`
332 // レア度の大きな文字と星
333 body += `<text x="0" y="-100" font-size="34" font-weight="bold" fill="${stroke}" text-anchor="middle">${r}</text>`
334 const n = STARS[r]
335 for (let i = 0; i < n; i++) body += star((i - (n - 1) / 2) * 22, -76, 8, r === 'SSR' ? 'url(#rainbow)' : color)
336 // 真ん中の紋章
337 body +=
338 `<circle cx="0" cy="-12" r="40" fill="${C.bg}" stroke="${stroke}" stroke-width="2"/>` +
339 `<circle cx="0" cy="-12" r="31" fill="none" stroke="${color}" stroke-width="1" stroke-dasharray="3 4" opacity="0.8">` +
340 `<animateTransform attributeName="transform" type="rotate" from="0 0 -12" to="360 0 -12" dur="${isHigh ? 6 : 14}s" repeatCount="indefinite"/></circle>` +
341 `<text x="0" y="-5" font-size="20" font-weight="bold" fill="${C.text}" text-anchor="middle">${esc(card.id.toUpperCase())}</text>`
342 // 名前と一言
343 const name = wrap(card.name, 22)
344 name.forEach((t, i) => {
345 body += `<text x="0" y="${name.length === 1 ? 58 : 50 + i * 21}" font-size="17" font-weight="bold" fill="${C.text}" text-anchor="middle">${esc(t)}</text>`
346 })
347 wrap(card.line, 30).forEach((t, i) => {
348 body += `<text x="0" y="${97 + i * 16}" font-size="12" fill="${C.weak}" text-anchor="middle">${esc(t)}</text>`
349 })
350 // 下の札
351 const tags: [string, string][] = []
352 if (draw.isNew) tags.push(['NEW', C.green])
353 if (draw.isClean) tags.push(['ノーミス', C.yellow])
354 tags.push([draw.isManual ? '手動' : 'ターン', C.weak])
355 let x = -tags.reduce((w, [t]) => w + units(t) * 6.5 + 18, 0) / 2 + 4
356 for (const [t, col] of tags) {
357 const w = units(t) * 6.5 + 14
358 body +=
359 `<rect x="${x}" y="122" width="${w}" height="18" rx="9" fill="none" stroke="${col}"/>` +
360 `<text x="${x + w / 2}" y="135" font-size="12" fill="${col}" text-anchor="middle">${esc(t)}</text>`
361 x += w + 4
362 }
363 // 光の筋 (SR 以上)
364 if (isHigh)
365 body +=
366 `<g clip-path="url(#card)"><rect x="-260" y="-160" width="90" height="320" fill="url(#shine)" transform="skewX(-20)">` +
367 `<animate attributeName="x" values="-260;300" dur="${r === 'SSR' ? 1.8 : 2.6}s" begin="0.6s" repeatCount="indefinite"/></rect></g>`
368 if (r === 'SSR') body += sparkle(-92, -128, 9, 0.2) + sparkle(94, -60, 7, 0.9) + sparkle(-88, 100, 8, 1.3) + sparkle(90, 130, 10, 0.5)
369
370 const defs =
371 (r === 'SSR' ? RAINBOW : '') +
372 (r === 'SR' ? GOLD : '') +
373 (isHigh ? `${SHINE}<clipPath id="card"><rect x="-110" y="-150" width="220" height="300" rx="16"/></clipPath>` : '') +
374 (r !== 'N' ? GLOW : '')
375 // めくれる: 横の倍率を 0 から 1 へ。SSR は一度ふくらむ
376 const flip =
377 r === 'SSR'
378 ? `<animateTransform attributeName="transform" type="scale" values="0 1;1.08 1.08;1 1" keyTimes="0;0.7;1" dur="0.9s" fill="freeze"/>`
379 : `<animateTransform attributeName="transform" type="scale" values="0 1;1 1" dur="0.5s" calcMode="spline" keyTimes="0;1" keySplines="0.2 0.8 0.2 1" fill="freeze"/>`
380 return svgDoc(H, defs, `<g transform="translate(190 165)"><g>${flip}${body}</g></g>`)
381}
382
383/** 図鑑のコンプ率リング・レア度別の所持数・直近 10 回 */
384export function statsSvg(s: GachaSave, left: number): string {
385 const owned = ownedKinds(s)
386 const pct = Math.round((owned / CARDS.length) * 100)
387 const R = 44
388 const len = 2 * Math.PI * R
389 const arc = (owned / CARDS.length) * len
390 let b = `<rect x="8" y="8" width="364" height="140" rx="8" fill="${C.face}" stroke="${C.line}"/>`
391 b +=
392 `<circle cx="70" cy="70" r="${R}" fill="none" stroke="${C.bg}" stroke-width="12"/>` +
393 `<circle cx="70" cy="70" r="${R}" fill="none" stroke="${owned === CARDS.length ? C.yellow : C.green}" stroke-width="12" stroke-linecap="round" ` +
394 `stroke-dasharray="${arc.toFixed(1)} ${len.toFixed(1)}" transform="rotate(-90 70 70)">` +
395 `<animate attributeName="stroke-dasharray" from="0 ${len.toFixed(1)}" to="${arc.toFixed(1)} ${len.toFixed(1)}" dur="0.8s" fill="freeze"/></circle>` +
396 `<text x="70" y="76" font-size="20" font-weight="bold" fill="${C.text}" text-anchor="middle">${pct}%</text>` +
397 `<text x="70" y="136" font-size="12" fill="${C.weak}" text-anchor="middle">${owned} / ${CARDS.length} 種</text>`
398 rarityStats(s).forEach((x, i) => {
399 const y = 22 + i * 26
400 const col = rarityColor(x.rarity)
401 b +=
402 `<rect x="136" y="${y}" width="36" height="18" rx="4" fill="none" stroke="${col}"/>` +
403 `<text x="154" y="${y + 13}" font-size="12" font-weight="bold" fill="${col}" text-anchor="middle">${x.rarity}</text>` +
404 `<rect x="180" y="${y + 6}" width="96" height="6" rx="3" fill="${C.bg}"/>` +
405 (x.owned ? `<rect x="180" y="${y + 6}" width="${((x.owned / x.all) * 96).toFixed(1)}" height="6" rx="3" fill="${col}"/>` : '') +
406 `<text x="284" y="${y + 13}" font-size="12" fill="${C.text}">${x.owned}/${x.all}種</text>` +
407 `<text x="364" y="${y + 13}" font-size="12" fill="${C.weak}" text-anchor="end">${x.count}枚</text>`
408 })
409 b += `<text x="136" y="136" font-size="12" fill="${C.weak}">累計 ${s.total} 回 / 今日の手動 あと ${Math.max(0, left)} 回</text>`
410
411 let y = 176
412 b += `<text x="12" y="${y}" font-size="13" font-weight="bold" fill="${C.text}">直近 ${KEEP_HISTORY} 回</text>`
413 y += 10
414 if (s.history.length === 0) {
415 b += `<text x="12" y="${y + 18}" font-size="12" fill="${C.weak}">まだ引いていません</text>`
416 y += 30
417 }
418 for (const d of s.history) {
419 const card = cardOf(d.cardId)
420 if (!card) continue
421 const col = rarityColor(card.rarity)
422 b +=
423 `<rect x="12" y="${y + 4}" width="36" height="18" rx="4" fill="${col}" opacity="0.2"/>` +
424 `<text x="30" y="${y + 17}" font-size="12" font-weight="bold" fill="${col}" text-anchor="middle">${card.rarity}</text>` +
425 `<text x="56" y="${y + 17}" font-size="13" fill="${C.text}">${esc(wrap(card.name, 34)[0] ?? '')}</text>` +
426 (d.isNew ? `<text x="364" y="${y + 17}" font-size="12" fill="${C.green}" text-anchor="end">NEW</text>` : '')
427 y += 24
428 }
429 return svgDoc(y + 10, '', b)
430}
431
432/** 装備の枠の一文字 (dev-rpg で装備できる場所) */
433export const SLOT_GLYPH: Record<GachaSlot, string> = { weapon: '武', armor: '防', charm: '守' }
434
435/** 図鑑の一覧。上に図鑑の実績、持っていないカードは ??? */
436export function bookSvg(s: GachaSave): string {
437 const met = new Set(goalsMet(s).map(g => g.id))
438 let b = header('図鑑', `${ownedKinds(s)} / ${CARDS.length} 種、実績 ${met.size} / ${BOOK_GOALS.length}`, C.purple)
439 let y = HEADER_H + 8
440 BOOK_GOALS.forEach((g, i) => {
441 const cx = i % 2 === 0 ? 8 : 192
442 const cy = y + Math.floor(i / 2) * 42
443 const isMet = met.has(g.id)
444 b +=
445 `<rect x="${cx}" y="${cy}" width="180" height="36" rx="6" fill="${isMet ? C.face : C.bg}" stroke="${isMet ? C.yellow : C.line}"${isMet ? '' : ' stroke-dasharray="4 3"'}/>` +
446 `<text x="${cx + 10}" y="${cy + 16}" font-size="12" font-weight="bold" fill="${isMet ? C.yellow : C.weak}">${esc(g.name)}</text>` +
447 `<text x="${cx + 10}" y="${cy + 30}" font-size="12" fill="${C.weak}">${isMet ? '達成' : esc(g.hint)}</text>`
448 })
449 y += Math.ceil(BOOK_GOALS.length / 2) * 42 + 6
450 for (const x of rarityStats(s)) {
451 const col = rarityColor(x.rarity)
452 b +=
453 `<text x="12" y="${y + 18}" font-size="14" font-weight="bold" fill="${col}">${x.rarity}</text>` +
454 `<text x="368" y="${y + 18}" font-size="12" fill="${C.weak}" text-anchor="end">${x.owned} / ${x.all} 種</text>`
455 y += 28
456 CARDS.filter(k => k.rarity === x.rarity).forEach((k, i) => {
457 const cx = i % 2 === 0 ? 8 : 192
458 const cy = y + Math.floor(i / 2) * 36
459 const has = s.book[k.id]
460 b += `<rect x="${cx}" y="${cy}" width="180" height="30" rx="6" fill="${has ? C.face : C.bg}" stroke="${has ? col : C.line}"/>`
461 b += has
462 ? `<text x="${cx + 8}" y="${cy + 20}" font-size="12" fill="${C.text}">${esc(wrap(k.name, 19)[0] ?? '')}${wrap(k.name, 19).length > 1 ? '…' : ''}</text>` +
463 `<text x="${cx + 150}" y="${cy + 20}" font-size="12" fill="${C.weak}" text-anchor="end">${SLOT_GLYPH[k.slot]}</text>` +
464 `<text x="${cx + 172}" y="${cy + 20}" font-size="12" fill="${col}" text-anchor="end">${has.count}</text>`
465 : `<text x="${cx + 8}" y="${cy + 20}" font-size="12" fill="${C.weak}">???</text>`
466 })
467 y += Math.ceil(x.all / 2) * 36 + 8
468 }
469 return svgDoc(y, '', b)
470}
471
472export type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
473const TERM_COLOR: Record<GachaRarity, string | undefined> = { N: undefined, R: 'blue', SR: 'yellow', SSR: 'magenta' }
474
475/** Svg の alt と terminal で使う、カードの行 */
476export function cardLines(draw: GachaDraw | undefined): Line[] {
477 const card = draw ? cardOf(draw.cardId) : undefined
478 if (!draw || !card) return [{ text: 'まだ引いていません(ターンが終わると 1 回引けます)', isDim: true }]
479 const tags = [draw.isNew ? 'NEW' : '', draw.isClean ? 'ノーミス補正' : '', draw.isManual ? '手で引いた' : 'ターン終わり'].filter(Boolean)
480 return [
481 { text: `【${card.rarity}】${card.name}`, color: TERM_COLOR[card.rarity], isBold: true },
482 { text: `「${card.line}」`, isDim: true },
483 { text: tags.join(' / '), isDim: true },
484 ]
485}
486
487/** Svg の alt と terminal で使う、図鑑と履歴の行 */
488export function statsLines(s: GachaSave, left: number): Line[] {
489 const owned = ownedKinds(s)
490 return [
491 { text: `図鑑 ${owned} / ${CARDS.length} 種 (${Math.round((owned / CARDS.length) * 100)}%)`, isBold: true },
492 { text: rarityStats(s).map(x => `${x.rarity} ${x.owned}/${x.all}種 ${x.count}枚`).join(' / ') },
493 { text: `累計 ${s.total} 回 / 今日の手動 あと ${Math.max(0, left)} 回`, isDim: true },
494 ...s.history.flatMap(d => {
495 const card = cardOf(d.cardId)
496 return card ? [{ text: `${card.rarity.padEnd(3)} ${card.name}${d.isNew ? ' NEW' : ''}`, color: TERM_COLOR[card.rarity] }] : []
497 }),
498 ]
499}
500
501/** Svg の alt と terminal で使う、図鑑の一覧の行 */
502export function bookLines(s: GachaSave): Line[] {
503 const met = new Set(goalsMet(s).map(g => g.id))
504 const goals: Line[] = BOOK_GOALS.map(g =>
505 met.has(g.id) ? { text: `実績 ${g.name} 達成`, color: 'yellow' } : { text: `実績 ${g.name} まだ(${g.hint})`, isDim: true },
506 )
507 return [...goals, ...CARDS.map((k): Line => {
508 const has = s.book[k.id]
509 return has
510 ? { text: `${k.rarity.padEnd(3)} ${k.name} ${has.count}枚`, color: TERM_COLOR[k.rarity] }
511 : { text: `${k.rarity.padEnd(3)} ???`, isDim: true }
512 })]
513}
514hooks/backup.tsx 102 lines1// 記録の控え (設定 backupDir) と、ペインの「控えから戻す」欄 (docs/mod-guide.md の 9 節)。
2// turn-gacha・daily-quest・wait-games が同じ物を持つ。戻す欄の開け閉めは $.state に置かず、
3// モジュールの値で持って描き直しを頼む (state の key を増やさない)
4import type { ElementConstructor, BoxProps, ButtonProps, EngineInterface, PluginOptions, TextProps } from 'claude-code'
5
6import { C, backupDaily, jstDay, listBackups } from './style'
7
8type $ = EngineInterface
9
10const isAbsolute = (p: string) => /^([a-zA-Z]:[\\/]|[\\/])/.test(p)
11
12/** 記録の控えのフォルダ (設定 backupDir)。絶対パスだけ。空なら控えない */
13export let backupDir = ''
14export const setBackupDir = (options: PluginOptions) => {
15 const dir = String(options.backupDir ?? '').trim()
16 backupDir = isAbsolute(dir) ? dir : ''
17}
18
19/** key ごとに、今日もう控えを確かめた日 (1 日 1 回だけファイルを読みに行く) */
20const backedDay = new Map<string, string>()
21export const isBackedToday = (key: string, now: number) => !backupDir || backedDay.get(key) === jstDay(now)
22
23/** その日はじめての時だけ、store の生の値 (知らない欄ごと) を控える */
24export async function backup($: $, key: string, raw: unknown, now: number) {
25 if (isBackedToday(key, now) || raw === undefined) return
26 backedDay.set(key, jstDay(now))
27 // 書けない時も、その日はもう試さない (ターンのたびに読み書きしない)
28 await backupDaily($, backupDir, key, raw, now).catch(() => {})
29}
30
31/** day の key の控えの値。無い・読めない時は undefined */
32export async function backupOf($: $, key: string, day: string) {
33 return (await listBackups($, backupDir, key).catch(() => [])).find(b => b.day === day)?.value
34}
35
36// ---- 戻す欄 ----
37
38/** null は閉じている。days は控えのある日 (新しい順)、pick は選んだ日 ('' はまだ) */
39let view: { days: string[]; pick: string } | null = null
40export const isRestoring = () => view !== null
41
42function show($: $, v: typeof view) {
43 view = v
44 $.ui.invalidate('ui.render')
45}
46
47/** セッションの始まりに閉じる (リロードの前の欄を残さない) */
48export const resetRestore = () => {
49 view = null
50 backedDay.clear()
51}
52
53export function closeRestore($: $) {
54 show($, null)
55}
56
57/** 控えのある日を並べて開く。key は日を並べる元の記録 */
58export async function openRestore($: $, key: string) {
59 const days = (await listBackups($, backupDir, key).catch(() => [])).map(b => b.day)
60 show($, { days, pick: '' })
61}
62
63type Parts = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; Button: ElementConstructor<ButtonProps> }
64
65/** 「控えから戻す」のボタン (脇の操作)。開いている時は「戻すのをやめる」 */
66export function restoreButton($: $, { Button }: Parts, key: string) {
67 return <Button key="restore" dimColor label={view ? '戻すのをやめる' : '控えから戻す'} onPress={() => void (view ? closeRestore($) : openRestore($, key))} />
68}
69
70/** 戻す欄: 日を選ぶ → 確かめる → 戻す。onRestore は選んだ日を受け取って書く */
71export function restoreCard($: $, { Box, Text, Button }: Parts, onRestore: (day: string) => void) {
72 const v = view
73 if (!v) return null
74 return (
75 <Box key="restore-card" flexDirection="column" borderStyle="round" borderColor={C.yellow} paddingX={1} paddingY={1} gap={1}>
76 <Text bold color={C.yellow}>
77 控えから戻す
78 </Text>
79 {!backupDir ? (
80 <Text dimColor>設定の backupDir を入れると控えが取れます</Text>
81 ) : v.days.length === 0 ? (
82 <Text dimColor>まだ控えがありません (1 日 1 回取ります)</Text>
83 ) : (
84 <Box flexDirection="row" flexWrap="wrap" gap={1}>
85 {v.days.map(day => (
86 <Button key={`day-${day}`} dimColor={v.pick !== day} label={day} onPress={() => show($, view ? { ...view, pick: day } : null)} />
87 ))}
88 </Box>
89 )}
90 {v.pick ? (
91 <Box flexDirection="column" gap={1}>
92 <Text>{`${v.pick}: この日の記録に戻しますか。今の記録は上書きされます`}</Text>
93 <Box flexDirection="row" gap={1}>
94 <Button key="restore-ok" variant="primary" label="戻す" onPress={() => onRestore(v.pick)} />
95 <Button key="restore-no" dimColor label="やめる" onPress={() => show($, view ? { ...view, pick: '' } : null)} />
96 </Box>
97 </Box>
98 ) : null}
99 </Box>
100 )
101}
102hooks/style.ts 315 lines1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7 bg: string
8 face: string
9 line: string
10 text: string
11 dim: string
12 weak: string
13 blue: string
14 green: string
15 yellow: string
16 red: string
17 purple: string
18 orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23 bg: '#0d1117',
24 face: '#161b22',
25 line: '#30363d',
26 text: '#c9d1d9',
27 dim: '#8b949e',
28 weak: '#8b949e',
29 blue: '#58a6ff',
30 green: '#3fb950',
31 yellow: '#d29922',
32 red: '#f85149',
33 purple: '#bc8cff',
34 orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39 bg: '#ffffff',
40 face: '#f6f8fa',
41 line: '#d0d7de',
42 text: '#1f2328',
43 dim: '#656d76',
44 weak: '#656d76',
45 blue: '#0969da',
46 green: '#1a7f37',
47 yellow: '#9a6700',
48 red: '#cf222e',
49 purple: '#8250df',
50 orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65 isLightNow = light
66 Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75 const rows = await $.config.list().catch(() => [])
76 setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81 on('config.set', { key: 'theme' }, async ($, e, next) => {
82 const r = await next(e)
83 if (r.deny === undefined) {
84 setTheme(isLightTheme(r.value))
85 $.ui.invalidate('ui.render')
86 }
87 return r
88 })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98 clean(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109 const h = jstDate(now).getUTCHours()
110 return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115 const m = jstDate(now).getUTCMonth() + 1
116 return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137 if (!dir.trim() || value === undefined) return false
138 const day = jstDay(now)
139 const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140 // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141 const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142 if (old === day) return false
143 await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144 return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149 try {
150 const b = JSON.parse(text) as Partial<Backup> | null
151 return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152 } catch {
153 return undefined
154 }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159 if (!dir.trim()) return []
160 const all = await Promise.all(
161 [0, 1, 2, 3, 4, 5, 6].map(wd =>
162 $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163 ),
164 )
165 return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177 let h = 0x811c9dc5
178 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179 return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187 const root = $.plugin.root
188 let manifest: string | undefined
189 for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190 manifest = await $.fs.read(p).then(t => t, () => undefined)
191 if (manifest !== undefined) break
192 }
193 if (manifest === undefined) return undefined
194 const names = await $.fs.list(`${root}/hooks`).then(
195 es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196 () => [] as string[],
197 )
198 const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199 return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207 const root = normPath($.plugin.root)
208 const at = root.lastIndexOf('/plugins/cache/')
209 if (at < 0) return false
210 const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211 try {
212 const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213 const mine = Object.entries(plugins)
214 .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215 .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216 .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217 return mine.length > 0 && !mine.includes(root)
218 } catch {
219 return false
220 }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225 loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234 if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235 staleAt = now
236 if (await replacedInCache($)) return (isStaleNow = true)
237 if (loadedPrint === undefined) return false
238 const p = await diskPrint($)
239 isStaleNow = p !== undefined && p !== loadedPrint
240 return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248 if (!(await isStale($, now))) return true
249 if (!toldStale) {
250 toldStale = true
251 $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252 }
253 return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264 `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268 `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275 const pad = 14
276 return [
277 `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278 `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279 `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280 sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281 growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282 ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287 `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288 `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292 `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293 `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294 `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298 `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305 const STEPS = 8
306 const d = 0.08
307 const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308 if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309 const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310 const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311 return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312 })
313 return frames.join('') + last
314}
315types/index.d.ts 71 lines1export type GachaRarity = 'N' | 'R' | 'SR' | 'SSR'
2
3/** dev-rpg で装備する枠: 武器・防具・お守り */
4export type GachaSlot = 'weapon' | 'armor' | 'charm'
5
6/** カード 1 種類 (他の mod に見せる形。一言は含まない) */
7export type GachaCard = {
8 /** 保存に使う id。変わらない */
9 id: string
10 rarity: GachaRarity
11 slot: GachaSlot
12 name: string
13}
14
15/** 1 回引いた結果 */
16export type GachaDraw = {
17 cardId: string
18 /** 引いた時刻 (ms) */
19 at: number
20 /** 初めて引いたカード */
21 isNew: boolean
22 /** そのターンのエラーが 0 で、SR 以上が出やすくなっていた */
23 isClean: boolean
24 /** ボタンで引いた */
25 isManual: boolean
26}
27
28/** 図鑑の 1 種類分 */
29export type GachaOwned = { count: number; firstAt: number }
30
31/** $.store に残す物 (state にも同じ物を写す) */
32export type GachaSave = {
33 /** カードの id ごとの所持数と初めて引いた日時。持っていないカードはキーが無い */
34 book: Record<string, GachaOwned>
35 /** 累計で引いた回数 */
36 total: number
37 /** 手動で引いた日 (YYYY-MM-DD) と、その日に引いた回数 */
38 manualDay: string
39 manualUsed: number
40 /** 新しい順、最大 10 */
41 history: GachaDraw[]
42}
43
44/**
45 * 他の mod が読んでよい物 (書けるのは turn-gacha だけ):
46 * - save.book: 持っているカードの id と枚数。セッションの始まりに store から写し、引くたびに置き換わる
47 * - cards: カード 60 種の表 (id・レア度・枠・名前)。セッションの始まりに 1 回置く
48 * どちらも turn-gacha が入っていない、またはまだ読み込む前は undefined
49 */
50declare module 'claude-code' {
51 interface PluginState {
52 /**
53 * daily-quest が他の mod に見せている物のうち、turn-gacha が読む分だけ (書くのは daily-quest)。
54 * daily-quest の types/index.d.ts の写し。入っていない時は undefined なので、読んだ値は gacha.ts の questRewards で確かめる
55 */
56 'daily-quest': {
57 today: { date: string; quests: { id: string; title: string; done: boolean; reward: { exp: number; pulls: number } }[] }
58 }
59 'turn-gacha': {
60 save: GachaSave
61 cards: GachaCard[]
62 /** カードを大きく出すか、図鑑の一覧か */
63 view: 'card' | 'book'
64 /** ボタンの結果などの一言。空なら出さない */
65 note: string
66 /** 今のメインのターンで失敗したツールの数 */
67 turnErrors: number
68 }
69 }
70}
71