blog

シード数を変えたら判定が反転した

  • Claude Code
  • テスト
  • ゲーム開発

リポジトリに FIXME が1つだけ残っていた。タスクバーで動くゲームの、ステージごとのノルマ(何体倒せば次の面へ行けるか)を決める関数だ。

const baseQuota = 45 + safe * 5;
if (safe >= 2 && safe <= 4) return baseQuota - 12;
if (safe >= 6 && safe <= 13) return 111;
return Math.min(150, baseQuota);

真ん中の2行が説明できない。なぜ 12 なのか、なぜ 111 なのか。曲線も非単調で、6〜13面が一律111体、14面で115体に戻る。自分のプロジェクトには「数字を合わせるためだけの値を作らない」という規則があって、これはその規則に正面から反している。書いた本人がそう認めて FIXME を付けていた。

消しに行った。

消すと落ちる。ここまでは書いてあったとおり

2行を消して Math.min(150, 45 + stage * 5) だけにすると、受け入れ判定が2つ落ちる。

FIXME にもそう書いてあった。ここまでは想定どおりだ。

このゲームのバランスは、18条件(画面幅2種 × 累計撃破数3種 × 強化3種)を固定シードで決定的にシミュレーションして判定している。乱数を引数で受ける純粋な関数にしてあるので、同じシードなら何度回しても同じ数字が出る。

落ちた原因はたぶんシードが少ないからだろう、と考えた。自分のリポジトリの規則にもそう書いてある。ばらつきの大きい指標で判定するなら、指標を替えるかシード数を増やせ、と。

5シードを20シードにした。通った。

40で落ちて、60で通った

念のため40シードでも測った。落ちた。

シード数復帰待ち(全習得・最小の条件)判定
51.86%落ちる
202.04%通る
401.96%落ちる
602.32%通る

落ちるのは毎回まったく同じ1条件だった(画面幅1280・累計5000体・全強化)。シードを増やしても値が収束せず、2%の境界の上を行ったり来たりしている

個々のシードを見ると理由が分かる。同じ条件でも、あるシードでは復帰待ちが 0.66%、別のシードでは 40% になる。変動係数は条件によって 0.45 から 1.9。平均が 2.00 のすぐ上に張り付いていて、その平均を作っている値が二桁ばらついている。

つまり、この判定は測れていない

60を採れば緑にできた

ここが気持ち悪いところだ。60シードを採用すれば、暫定の2行を消して、全部緑で出荷できた。テストは通る。コミットメッセージには「説明できない値を消して単調式に戻した」と書ける。誰も文句を言わない。

でもそれは、区間分岐の代わりにシード数を当てはめているだけだ。合う数字を探して代入したことに変わりはない。

自分の規則には「基準を下げて通さない」と書いてある。今回わかったのは、その亜種があることだった。基準を1ミリも動かさずに、測り方だけを当たりが出るまで回す。 しきい値は 2% のまま、判定文も一字も変えていない。それでも合否は操作できる。

コストも測っておいた。5シードから60シードにすると、サイト側のテストが9秒から92秒になる。10倍払って、それでも収束していない。

ピンから漏れていたのは、説明できない値だった

もう1つ、ついでに見つかったことがある。この関数の値を固定しているテストは3行だけだった。

expect(stageQuota(1)).toBe(50);
expect(stageQuota(20)).toBe(145);
expect(stageQuota(10_000)).toBe(150);

1面・20面・10000面。区間分岐が効く 2〜4面と 6〜13面を、1つも通っていない。 説明できる部分だけがピン留めされていて、説明できない部分は誰にも固定されていなかった。

たぶん偶然ではない。値の意味を説明できるときは、テストにも自然と書ける。説明できない値は、テストに何を書けばいいかも分からない。だから書かれない。説明できなさは、ピンの穴として現れる。

直すべきは式ではなかった

直すべきは分岐でも式でもなく、判定に使っている指標のほうだった。

よく見ると、落ちていた2つの判定はどちらも小さい整数の平均を実数に見せていただけだった。復帰待ちの割合は、ほぼ「ダウン回数 × 2.4秒 ÷ 経過時間」で決まる。1回あたり 0.667% で、実質そこでしか動かない。⚠ 恒等式ではない。測定窓の末尾で落ちると復帰が途中で切れて、その1回は 2.4 秒ぶんまるごと乗らない(終了1秒前に落ちれば 1.0 秒ぶんで、割合にして 0.389% ずれる)。刻みが崩れるのはこの端だけだ。2% という基準は、実は「6分で3回落ちる」と同じことを言っている。面のクリア数のほうも、0〜7 の整数を 1.667 倍しているだけだ。

整数を平均すると、シード1本の外れ値がそのまま効く。あるシードは 3回しか落ちないのに、別のシードは 38回落ちる。その平均を 2.00 と比べていた。

なので指標を書き換えた。

1シードあたりで見れば、2% と「3回」は同じところを指している(3 × 2400ms ÷ 360000ms = 2.0%)。3.33〜10面/10分と 2〜6面/6分も同じだ。しきい値の意味は動かしていない。

ただし通る分布の集合は変わっている。平均を中央値に替えたので、同じ数字でも受け入れる形が違う。ダウン回数が [0, 0, 0, 0, 15] なら平均3で通るが、中央値は0で落ちる。[2, 2, 3, 3, 3] なら平均2.6で落ちるが、中央値3で通る。どちらが厳しいとも言えない。 片方がもう片方を含んでいない。

これは自分の規則では「測り方を改善した」に当たる。しきい値を動かしてよいのは理由を明記できるときだけ、と決めてある。整数5個の平均が1本の外れ値で決まるのをやめた、というのがその理由だ。通る分布を変えた自覚はあるので、そこは隠さずに書いておく。

これでシード数を 5・20・60 と変えても、最小値は3回のまま動かなくなった。

分岐は、両方とも効いていた

指標を替えたあとで、もう一度2行を外してみた。落ちた。ただし今度は理由がはっきり出た

つまりこの2行は、「1面は60〜180秒」という設計を両端で守るために入っていた。片方は遅すぎるのを防ぎ、もう片方は速すぎるのを防いでいる。説明できなかったのは分岐の存在理由ではなく、12 と 111 という値の導出だけだった。

そこで FIXME を外し、2つの値に名前を付けて、上の反例をコメントに書いた。単調な式にしたい人は、この2つの反例を両方満たす式を持ってくればいい。シード数の表もそのまま残してある。同じ探索を4回繰り返さなくて済むように。

ピンも足した。この関数の値を固定していた3行に、区間分岐が効く 2・4・6・13・14 面を追加した。説明できない値ほどピンから漏れるというのは、直したあとも覚えておきたい。

暫定の値は残った。でも暫定である理由は、もう説明できる。

この関数が動かしているステージ制のゲームと、同じ考え方でバランスを固定した放置ゲームは、Kludge Worksから遊べる。

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