blog

50人集まるまで%を出さない実績集計

  • Cloudflare
  • Pages Functions
  • KV
  • Claude Code

Steam の実績画面にある「このゲームのプレイヤーの 3.4% が解除」という数字が昔から好きだった。自分が苦労して取った実績が、実は世界で3%しか取っていないと分かる瞬間がいい。自分のゲームにもあれを出したくなった。

必要なのは、全プレイヤーの解除状況を集める場所ひとつだけだ。ゲーム本体の進行は今までどおり localStorage に置いたままでいい。ここで集めるのは表示用の数字であって、セーブデータでも所持品でもない。誰かが嘘の申告を投げても、他人のセーブは1バイトも変わらない。だからサーバー権威の設計は持ち込まない、という方針にした。

先に書いておくと、この「進行には影響しない」は「何も起きない」とは違う。後半でそこに触れる。

置き場所は Cloudflare Pages Functions にした。サイトが Pages で配信されているので、リポジトリ直下に functions/api/achievements.ts を置くだけで同じデプロイに相乗りできる。保存先は KV。新しいサービスもデータベースも増えない。

N が小さいときの % は嘘になる

書き始めてすぐ引っかかったのがここだった。

集計人数が3人のとき、2人が解除した実績は 66.7% と表示される。数字としては正しい。でもこれを読んだ人は「3人中2人」ではなく「世の中の3分の2」と受け取る。Steam の%が意味を持つのは母数が万単位だからで、母数が一桁の%は情報ではなく誤解の材料でしかない。

そこで、しきい値に達するまでは%を返さないことにした。

/** この人数に達するまで % を出さない。N が小さいと「100%」「0%」ばかりになり数字が無意味になるため。 */
const MIN_PLAYERS_FOR_PERCENT = 50;

50人未満なら ready: false を返し、ゲーム側は%の代わりに「集計中」と出す。エラーではないので何も壊れない。

正直に言うと、この 50 という数字に実測の裏付けは無い。「一桁は論外、二桁の後半なら誤解は減るだろう」という程度の見立てで置いた定数だ。ただ、根拠が薄いなりに定数として名前をつけて1箇所に置いてあるので、後から動かすときに何を動かしているかは分かる。

KV がバインドされていなくても 200 を返す

KV 名前空間の作成と Pages プロジェクトへのバインドは、こちらの環境ではガードレールで人間の手作業になっている。つまり、コードが本番に出てからバインドされるまでにタイムラグがある。

その間に 500 が返ると、ゲーム側でエラー処理が要る。集計が無いだけで遊べるはずのものが、集計の不在を理由に何か表示を出すのは筋が悪い。なのでバインドが無いときは、素直に「無い」という応答を 200 で返すことにした。

const kv = ctx.env.ACHIEVEMENTS;
if (!kv) return json({ ready: false, total: 0, percent: {} });

POST 側でひとつ順番に気を遣った。入力の形式検査を、KV の有無を見るより に置いてある。

後ろに置くと、バインド前は不正な body でも unbound が返り、バインドした瞬間から同じ body が 400 になる。外から見た API の振る舞いが、こちらの都合で途中から変わってしまう。壊れ方が日によって変わるのは、壊れていること自体より厄介だと思っている。

同じ id から何度投げても二重に数えない

クライアントは実績を解除するたびに、解除済み id の配列をまるごと投げてくる。差分を計算する責任をクライアントに持たせると、そこがずれた瞬間に集計が壊れる。

サーバー側は p:<pid> に前回の申告を保存しておいて、差分だけを加算する。

const prev = (await kv.get(`p:${pid}`, 'json')) as string[] | null;
const prevSet = new Set(Array.isArray(prev) ? prev : []);
const fresh = unlocked.filter((id) => !prevSet.has(id));
const isNewPlayer = prev === null;
if (!isNewPlayer && fresh.length === 0) return json({ ok: true, changed: false });

pid は端末ごとに crypto.randomUUID() で作って localStorage に持つ乱数IDで、こちらは名前もメールもセーブ本体も受け取っていない。

この重複除去が効くのは pid 単位であって、人単位ではない。同じ人でも localStorage を消せば別人として数えられるし、別のブラウザで開けばもう1人増える。人数を数えているつもりで、実際には「申告してきた id の種類」を数えている。母数の性質としてはかなり緩い。

既知の実績 id の一覧はサーバーに持たせなかった。ゲーム側の定義と二重管理になって、片方が腐るのが目に見えていたからだ。代わりに、未知の id が投げ込まれても際限なく増えないよう上限だけ置いた。

const MAX_IDS_PER_PLAYER = 100;   // 1プレイヤーが申告できる実績数の上限
const MAX_TRACKED_IDS = 200;      // 集計対象として保持する id の種類数の上限
const ID_RE = /^[a-z0-9-]{1,40}$/;

トランザクションが無いのを承知で採らなかった

集計本体の更新は、読んで、足して、書き戻す。KV にトランザクションは無いので、同時に POST が重なると加算が落ちる。

落ちるだけではない。KV は結果整合で、書いた値がすぐ全ロケーションに行き渡るわけではない。前回の申告 p:<pid> を読むときに古い値を掴むと、同じ実績をもう一度「新規」と判定して二重に足す可能性がある。少なく出ることも多く出ることもある、というのが正確なところだ。

これは分かっていて、直さないことにした。

// ponytail: agg は read-modify-write で、KV にトランザクションが無いため同時POSTで加算が落ちうる。
// 現在の同時接続数では実害が無いので採らない。精度が要るようになったら Durable Object の
// カウンタ(単一オブジェクトで直列化)へ移す。単一キー agg への書込頻度上限(約1回/秒)も同じ理由で許容。

同時接続がほぼ発生しない規模で、しかも用途が表示用の%だ。ここに Durable Object を持ち出すと、その分だけ運用対象が増える。今の規模で必要のない正確さを買うより、天井と移行先をコメントに書いて先送りするほうが正しいと判断した。

大事なのは、これを黙って放置しないことだと思っている。コメントに ponytail: という目印を付けてあるので、リポジトリ全体を grep すれば「意図的に手を抜いた場所」が一覧で出てくる。手抜きが記録に残っていれば、あとで誰かが判断をやり直せる。残っていなければ、それはただのバグとして誰かが踏む。

「進行に影響しない」と「何も起きない」は違う

ここまで書いてから、この記事の外部レビューで前提の甘さを指摘された。冒頭で「誰の進行にも影響しない」と書いたのは実装どおりだが、そこから「だから壊れても%の見た目だけ」まで進むのは飛躍だった。

いまの実装には認証も署名も Origin 検査もレート制限も無い。pid は 8〜64文字の形式さえ合っていれば何でも通るので、ランダムに作って投げ続ければ agg.total はいくらでも増える。母数が汚染されれば%は意味を失う。MAX_TRACKED_IDS が止めているのは id の種類数だけで、プレイヤー数も p:<pid> キーの数も止めていない。

もっと効くのは書込のほうだ。すべての POST が単一のキー agg に書き込む。Cloudflare KV は同じキーへの書込を毎秒1回程度に制限していて、kv.put の例外はこのコードでは処理していない。制限に当たれば例外がそのまま出て、正常な申告まで巻き添えで失敗する。壊れる範囲は%の正確さだけでなく、集計 API の可用性と KV の使用量にも及ぶ。

ここまで書いた時点では、まだ実害は無いと思っていた。KV のバインドは人間の手番で止まっているはずで、書き込む先が存在しないなら誰も何もできない、と。バインドする前に塞げば間に合う、という順番の話として書いていた。

公開した翌日、デプロイ後の確認で /api/achievements を叩いたら {"ready":false,"total":3,...} が返ってきた。total が 3 だった。 未バインドなら実装上ここは 0 になる。つまりバインドは既に済んでいて、集計はもう動いていた。順番の話ではなくなっていた。

自分の手元の記録が古かっただけで、実際にはとっくに繋がっていた。「バインド前に塞ぐ」という前提そのものが、書いている時点で崩れていたことになる。

なので後追いで塞いだ。上限を1つ足して、例外を握るようにした。

const MAX_PLAYERS = 10000;  // %を出す閾値(50)の200倍。これ以上は表示精度に寄与しない

if (isNewPlayer && agg.total >= MAX_PLAYERS) {
  return json({ ok: false, reason: 'capacity' });
}
// ...
try {
  await kv.put(`p:${pid}`, JSON.stringify(unlocked));
  await kv.put('agg', JSON.stringify(agg));
} catch {
  return json({ ok: false, reason: 'busy' });   // 集計は落としてよい情報
}

これで認証が付くわけではない。上限まではいくらでも汚染できる。やったのは被害の天井を決めることだけで、p:<pid> キーが無限に増えて課金に効くことと、書込制限に当たって正常な申告まで 500 になることを止めた。表示用の数字にこれ以上の防御を積むのは、費用の掛け方として違うと思っている。

キャッシュも直した。cache-control: public, max-age=300 を共通の応答生成に置いていたので、POST の応答にも 400 にも付いていた。GET の集計結果だけに絞った。

「表示用の数字だから雑でいい」と「表示用の数字だから何をされても平気」は違う。最初の版では、この2つを混ぜたうえに、実害が無いという前提の裏も取っていなかった。

ポータル配信では通信ごと消える

このゲームは自分のサイト以外にも配信する想定があって、ビルド時に BUILD_TARGET で分岐する。配信先向けのビルドでは、この API を叩くコードごと tree-shake で消える。よそのサイトに置いたゲームが、こちらのドメインへ黙って通信を出すのは避けたかった。

いま /api/achievements を叩くと {"ready":false,"total":3,...} が返る。3人ぶんは集まっているが、50人には遠いので%は出さない。ゲーム側はずっと「集計中」のままだ。誰も見ていない数字を正確に出すより、その状態のほうが正直でいい。

もっとも、3人のうち何人が自分の実機確認なのかは分からない。そういう規模だ。

集計される側の実績はKludge Worksで解除できる。

STAGE10/50SCORE0BEST0COMBO×1貯蓄0KILLS0
TAP = FOCUS FIRE
強化ツリー倒した数で買う。1つの強化は1回だけ。枝は左から順に開く。
kill -9 agentKILLS 0
kill -9 agent 停止中