봇 콜로세움 · 리그 리포트

봇을 함수 하나로 짜서 붙이는 15×15 격자 아레나. 이 문서는 밤이 돌린 리그의 결과다 — 순위표, 페어별 승률, 결정성 검증, 아레나 수치 조정 이력, 그리고 밤이 직접 짜서 판에 올린 도전자 두 벌.

수치판 adj2 · 시드 1000~1199 (200개) · 내장 5종 라운드로빈 2,000판 2.2초 · 오픈 리그 4,200판 · 2026-08-19 21:10 UTC

✓ 크래시·무한루프 0건제한초과로 워커를 죽인 횟수 0 · 위반 누적 0 · 몰수 0✓ 200틱 초과 무승부율 20% 이하6.7% (134/2000판)✓ 지배 전략 부재 — 최고 승률 75% 이하 (drunkard 제외)최고 저격수 73.0%✓ 최하위도 15% 이상 (drunkard 제외)최하위 미러 32.3%✓ 가위바위보 순환 구조 관측 (러셔·거북이·저격수)러셔 > 저격수 > 거북이 > 러셔

합격선 통과 결정성 2,000/2,000

1. 순위표 — 내장 5종

라운드로빈2,000판 · 무승부 6.7%
#
승률
평균 HP
평균 틱
위반
1
저격수
73.0%
584
96
120
1.29
22.5
0
2
러셔
62.7%
502
98
200
1.13
20.2
0
3
거북이
62.4%
499
50
251
1.14
43.4
0
4
미러
32.3%
258
23
519
0.61
25.3
0
5
주정뱅이
2.9%
23
1
776
0.07
61.0
0

2. 페어별 승률 행렬

행이 열을 상대로 거둔 승률. 굵은 테두리가 관측된 순환 고리다.

내장 5종러셔 > 저격수 > 거북이 > 러셔
저격수
러셔
거북이
미러
주정뱅이
저격수
33%
77%
84%
99%
러셔
36%
35%
80%
100%
거북이
11%
54%
87%
98%
미러
12%
14%
12%
91%
주정뱅이
1%
0%
2%
9%

가위바위보 구조가 실제로 돈다 — 러셔 > 저격수 > 거북이 > 러셔.
러셔 → 저격수 36.0% · 저격수 → 거북이 77.0% · 거북이 → 러셔 53.5%

3. 판이 끝난 이유

종료 사유2,000판
HP 0
1848판
동시 KO
133판 (전부 무승부)
200틱 초과
19판 (그중 HP 동률 1판이 무승부)
몰수
0판
무승부 합계
134판 · 6.7%
사고
제한 초과 0회 · 샌드박스 위반 0회

4. 결정성 검증

리플레이 해시 (SHA-1)2000/2000

같은 2,000판을 두 번 돌려 매 판의 리플레이 해시를 맞대어 본다. 해시에는 시드·수치판·양쪽 봇 이름과 매 틱의 명령·좌표·HP·에너지·위반 수가 전부 들어간다 — 한 틱이라도 갈리면 값이 달라진다.

일치
2,000 / 2,000판 ✓ 100%
난수
엔진·봇 어디에도 Math.random 이 없다. 샌드박스가 봇 스코프에서 이름째 막고, 테스트가 소스를 훑어 확인한다.
부동소수
틱 해결은 전부 정수 연산이다. 시드 PRNG 도 32비트 정수(splitmix32)만 쓴다.
브라우저 ↔ Node
같은 엔진 소스와 같은 샌드박스 규약을 양쪽이 공유한다. 교차 검증은 npm run crosscheck.

5. 아레나 수치 조정 이력

카드가 준 씨앗값에서 시작해, 합격선에 못 미치는 이유가 규칙 자체에 있을 때만 수치를 건드렸다. 조정은 최대 2회로 묶여 있다.

수치판 3개 지금 수록: adj2
  1. seed 카드가 준 씨앗값

    카드가 준 씨앗값. 조정 전 기준선.

  2. adj1 cost.strike 1 → 2 · cost.guard 1 → 2

    씨앗값에서는 방어·공격 비용(1)이 매 틱 회복량(1)과 같아서 둘 다 무한히 반복된다. 그래서 거북이는 원리적으로 죽지 않았고(1차 리그 79판 전부 무승부), 서로 매 틱 베는 짝은 3틱 만에 같이 죽었다(동시 KO 46판). 판이 끝나는 이유의 절반이 「아무도 못 이겨서」였다. 두 비용을 회복량 위로 올려 「지금 쓸까 아낄까」가 매 틱의 선택이 되게 했다 — 이게 서야 guard가 strike를 잡고, 되받아칠 창이 생긴다.

  3. adj2 energyMax 3 → 5

    비용을 올렸더니 이번엔 은행이 너무 작았다. 상한 3에 회복 1이면 벤 직후(잔량 1) 다음 틱에 웅크릴 수 없다 — 즉 「베고 나서 몸을 지킨다」가 규칙상 불가능하다. 그래서 붙은 두 봇은 선택의 여지 없이 서로 베고 같이 죽었다(2차 리그 동시 KO 29판). 상한을 5로 올려 strike(2)+guard(2)가 한 은행 안에 들어오게 했다 — 공격 뒤에 몸을 지킬지, 한 번 더 벨지가 비로소 선택이 된다. 저격수의 dash(1)+strike(2)+strike(2) 연타도 여기서 성립한다.

지금 수록된 수치

15×15 · 좌우 미러 대칭 · 시작 위치도 대칭
HP · 에너지
hp 3 · 에너지 0~5 (매 틱 +1)
비용
move 0 · dash 1(2칸) · strike 2(데미지 1) · guard 2 · wait 0(총 +2)
판정
200틱 초과 시 HP 비교 · 동률이면 무승부
샌드박스
틱당 5ms · 위반 20회면 몰수

6. 오픈 리그 — 내장 5종 + 밤의 도전자

7종 라운드로빈4,200판 · 무승부 9.4%
#
승률
평균 HP
평균 틱
위반
1
저격수
60.5%
726
183
291
1.00
20.7
0
2
거북이
60.4%
725
99
376
0.99
66.0
0
3
55.2%
662
118
420
0.97
43.5
0
4
박자
54.8%
658
154
388
0.93
39.0
0
5
러셔
51.6%
619
192
389
0.86
18.9
0
6
미러
32.8%
393
41
766
0.54
23.6
0
7
주정뱅이
1.9%
23
1
1176
0.05
60.8
0
페어별 승률행이 열을 상대로
저격수
거북이
박자
러셔
미러
주정뱅이
저격수
77%
32%
39%
33%
84%
99%
거북이
11%
66%
48%
54%
87%
98%
44%
28%
43%
37%
79%
100%
박자
42%
34%
52%
57%
45%
100%
러셔
36%
35%
40%
19%
80%
100%
미러
12%
12%
21%
47%
14%
91%
주정뱅이
1%
2%
0%
0%
0%
9%

7. 밤이 짠 도전자

1차 리그 2,000판을 읽고 나서 쓴 봇들이다. 규칙은 사람과 똑같다 — 같은 API, 같은 5ms 제한, 같은 샌드박스. 아침에 이걸 읽고 이길 봇을 짜는 게 이 게임의 진짜 첫 판이다.

trapper

상대가 다음 틱에 설 수 있는 칸을 전부 세어, 내 옆칸이 하나뿐일 때 그 자리를 미리 후려친다.

왜 이렇게 짰나
씨앗 봇들은 전부 「지금 인접한가」만 보고 벤다. 그런데 판정은 이동이 끝난 뒤의 좌표로 나므로, 실제로 노려야 할 건 상대가 지금 있는 칸이 아니라 다음 틱에 「설 칸」이다. 상대의 좌표·에너지·벽 지도가 다 보이니 상대가 갈 수 있는 칸(제자리·4방향 이동·4방향 대시)은 정확히 계산된다. 그중 내 옆칸이 딱 하나뿐인 자리 — 즉 상대가 나에게 붙으려면 반드시 밟아야 하는 칸 — 이 있으면 거기를 미리 벤다. 나머지 시간에는 거리 2를 지키며 은행만 채운다: 리그의 교훈 ①에 따르면 먼저 들어가는 쪽이 지고, ②에 따르면 기다리는 쪽은 공짜로 강해지기 때문이다.
(function () {
  var VEC = { N: [0, -1], S: [0, 1], E: [1, 0], W: [-1, 0] };
  var ORDER = ['N', 'S', 'E', 'W'];
  function open(obs, x, y) {
    return x >= 0 && y >= 0 && x < obs.size && y < obs.size && obs.walls[y][x] === 0;
  }
  function dist(a, b) { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); }
  function dirTo(dx, dy) {
    if (Math.abs(dx) >= Math.abs(dy)) return dx > 0 ? 'E' : dx < 0 ? 'W' : (dy > 0 ? 'S' : 'N');
    return dy > 0 ? 'S' : dy < 0 ? 'N' : (dx > 0 ? 'E' : 'W');
  }
  /** 벽을 피해 목표로 가는 첫 걸음 (너비 우선). 갈 곳이 없으면 null */
  function stepToward(obs, tx, ty) {
    var n = obs.size, s = obs.self;
    if (s.x === tx && s.y === ty) return null;
    var prev = new Int32Array(n * n).fill(-1);
    var seen = new Uint8Array(n * n);
    var q = [s.y * n + s.x];
    seen[q[0]] = 1;
    var head = 0, goal = -1;
    while (head < q.length) {
      var cur = q[head++];
      var cx = cur % n, cy = (cur - cx) / n;
      if (cx === tx && cy === ty) { goal = cur; break; }
      for (var i = 0; i < 4; i++) {
        var v = VEC[ORDER[i]];
        var nx = cx + v[0], ny = cy + v[1];
        if (!open(obs, nx, ny)) continue;
        var ni = ny * n + nx;
        if (seen[ni]) continue;
        seen[ni] = 1;
        prev[ni] = cur;
        q.push(ni);
      }
    }
    if (goal < 0) return null;
    var node = goal;
    while (prev[node] !== -1 && prev[node] !== s.y * n + s.x) node = prev[node];
    if (prev[node] === -1) return null;
    var gx = node % n, gy = (node - gx) / n;
    return dirTo(gx - s.x, gy - s.y);
  }
  /** 상대에게서 멀어지는 한 걸음 */
  function stepAway(obs) {
    var s = obs.self, f = obs.foe, best = null, bestD = -1;
    for (var i = 0; i < 4; i++) {
      var d = ORDER[i], v = VEC[d];
      var nx = s.x + v[0], ny = s.y + v[1];
      if (!open(obs, nx, ny)) continue;
      var dd = Math.abs(nx - f.x) + Math.abs(ny - f.y);
      if (dd > bestD) { bestD = dd; best = d; }
    }
    return best;
  }
  /** 시드 고정 난수 — Math.random 은 아레나에서 막혀 있다. 동점인 선택지를 가를 때만 쓴다. */
  function rnd(obs, memory, n) {
    if (memory.__s === undefined) memory.__s = obs.seed >>> 0;
    memory.__s = (memory.__s + 0x9e3779b9) >>> 0;
    var z = memory.__s;
    z = Math.imul(z ^ (z >>> 16), 0x21f0aaad) >>> 0;
    z = Math.imul(z ^ (z >>> 15), 0x735a2d97) >>> 0;
    return ((z ^ (z >>> 15)) >>> 0) % n;
  }
  /** 한 걸음 옮기면 상대와 붙는 방향들 (거리 2에서 "사이 칸"을 찾는 데 쓴다) */
  function gapDirs(obs) {
    var s = obs.self, f = obs.foe, out = [];
    for (var i = 0; i < 4; i++) {
      var d = ORDER[i], v = VEC[d];
      var nx = s.x + v[0], ny = s.y + v[1];
      if (!open(obs, nx, ny)) continue;
      if (Math.abs(nx - f.x) + Math.abs(ny - f.y) === 1) out.push(d);
    }
    return out;
  }
  /**
   * 지난 틱에 움직이려 했는데 제자리인가 — 둘이 같은 칸을 노려 서로 묶인 상태다.
   * 틱마다 딱 한 번만 부를 것(자리를 기억한다).
   */
  function stalled(obs, memory) {
    var s = obs.self;
    var tried = obs.lastSelfCmd && (obs.lastSelfCmd.cmd === 'move' || obs.lastSelfCmd.cmd === 'dash');
    var same = memory.__px === s.x && memory.__py === s.y;
    // 두 틱 전 자리로 돌아왔다 = 서로 상대의 옛 자리를 쫓는 2주기 진동이다
    var loop = memory.__qx === s.x && memory.__qy === s.y;
    memory.__qx = memory.__px; memory.__qy = memory.__py;
    memory.__px = s.x; memory.__py = s.y;
    return !!(tried && (same || loop));
  }
  /** 지금 설 수 있는 방향들 */
  function openDirs(obs) {
    var s = obs.self, out = [];
    for (var i = 0; i < 4; i++) {
      var v = VEC[ORDER[i]];
      if (open(obs, s.x + v[0], s.y + v[1])) out.push(ORDER[i]);
    }
    return out;
  }
  /** 그 방향으로 대시하면 어디에 서나 (막히면 null) */
  function dashLanding(obs, dir) {
    var s = obs.self, v = VEC[dir], x = s.x, y = s.y;
    for (var k = 0; k < obs.config.dashDistance; k++) {
      x += v[0]; y += v[1];
      if (!open(obs, x, y)) return null;
    }
    return { x: x, y: y };
  }
  /**
   * 상대에게 붙는 한 걸음.
   * 거리 2에서는 붙을 수 있는 칸이 여럿인데, 둘 다 같은 규칙으로 최단을 고르면
   * 영원히 같은 칸에서 부딪쳐 둘 다 제자리가 된다 — 그래서 그 자리만 무작위로 고른다.
   */
  function closeIn(obs, memory) {
    var s = obs.self, f = obs.foe;
    if (dist(s, f) === 2) {
      var g = gapDirs(obs);
      if (g.length) return g[rnd(obs, memory, g.length)];
    }
    return stepToward(obs, f.x, f.y);
  }
  function strikeDir(obs) {
    var s = obs.self, f = obs.foe;
    var dx = f.x - s.x, dy = f.y - s.y;
    if (Math.abs(dx) + Math.abs(dy) !== 1) return null;
    return dirTo(dx, dy);
  }


  /** 상대가 다음 틱에 설 수 있는 칸 전부 (제자리 + 이동 4 + 대시 4) */
  function foeReach(obs) {
    var f = obs.foe, c = obs.config, out = [{ x: f.x, y: f.y }];
    for (var i = 0; i < 4; i++) {
      var v = VEC[ORDER[i]];
      var mx = f.x + v[0], my = f.y + v[1];
      if (!open(obs, mx, my)) continue;
      out.push({ x: mx, y: my });
      if (f.energy >= c.cost.dash) {
        var x = mx, y = my, ok = true;
        for (var k = 1; k < c.dashDistance; k++) {
          x += v[0]; y += v[1];
          if (!open(obs, x, y)) { ok = false; break; }
        }
        if (ok) out.push({ x: x, y: y });
      }
    }
    return out;
  }

  return function trapper(obs, memory) {
    var s = obs.self, f = obs.foe, c = obs.config;
    var stuck = stalled(obs, memory);
    var canS = s.energy >= c.cost.strike, canG = s.energy >= c.cost.guard;
    var foeS = f.energy >= c.cost.strike, foeG = f.energy >= c.cost.guard;
    var sd = strikeDir(obs);

    if (sd) {
      if (canS && !foeG) return { cmd: 'strike', dir: sd };            // 막을 수 없다 — 확정타
      if (canS && !foeS) return { cmd: 'strike', dir: sd };            // 되받이가 없다 — 공짜
      // 눈감은 교환은 안 한다(교훈 ④). 상대가 방금 벴으면 이번 틱엔 못 벤다고 보고 되받는다.
      var justStruck = obs.lastFoeCmd && obs.lastFoeCmd.cmd === 'strike';
      if (justStruck && canS && rnd(obs, memory, 5) < 4) return { cmd: 'strike', dir: sd };
      if (foeS && canG && rnd(obs, memory, 10) < 6) return { cmd: 'guard' };
      if (s.energy < c.energyMax) return { cmd: 'wait' };              // 마르지 않는 쪽이 이긴다
      if (canS) return { cmd: 'strike', dir: sd };
      return { cmd: 'wait' };
    }

    // ── 덫: 상대가 나에게 붙으려면 반드시 밟아야 하는 칸이 하나뿐이면 거기를 먼저 벤다
    if (canS && !stuck) {
      var reach = foeReach(obs);
      var hits = [];
      for (var i = 0; i < 4; i++) {
        var v = VEC[ORDER[i]];
        var tx = s.x + v[0], ty = s.y + v[1];
        var n = 0;
        for (var j = 0; j < reach.length; j++) if (reach[j].x === tx && reach[j].y === ty) n++;
        if (n > 0) hits.push({ dir: ORDER[i], n: n });
      }
      if (hits.length === 1) return { cmd: 'strike', dir: hits[0].dir };
      if (hits.length > 1 && rnd(obs, memory, 10) < 4) {
        return { cmd: 'strike', dir: hits[rnd(obs, memory, hits.length)].dir };
      }
    }

    // 200틱을 넘기면 HP 가 같을 때 무승부다 — 기다리기만 해서는 이길 수 없다.
    // 판이 반을 넘었는데 앞서 있지 않으면, 손해를 감수하고 들어간다.
    var mustAct = obs.tick * 2 > c.tickLimit && s.hp <= f.hp;

    var d = dist(s, f);
    if (d <= 2) {
      // 은행이 안 찼으면 채우고, 찼는데 상대가 벨 힘이 없으면 그때 들어간다(교훈 ①의 뒤집기)
      if (s.energy < c.energyMax && !mustAct) return { cmd: 'wait' };
      if (!foeS || mustAct) {
        var into = closeIn(obs, memory);
        if (into) return { cmd: 'move', dir: into };
      }
      return { cmd: 'wait' };
    }
    var step = stuck ? null : closeIn(obs, memory);
    if (stuck) {
      var alts = openDirs(obs);
      if (alts.length) step = alts[rnd(obs, memory, alts.length)];
    }
    if (step) return { cmd: 'move', dir: step };
    return { cmd: 'wait' };
  };
})()
박자cadence

상대의 직전 명령 → 다음 명령 표를 memory에 쌓아 다음 수를 예측하고, 그 예측의 카운터를 낸다.

왜 이렇게 짰나
이 아레나의 에너지 경제(비용 2 · 회복 1 · wait 는 +2)는 봇을 강제로 박자에 태운다 — 「쓰고 채우고 쓰고 채우고」. 그래서 상대의 다음 수는 직전 수만 알아도 상당 부분 맞는다. memory 가 틱을 넘어 이어지는 자유 객체라는 점을 그대로 쓴다: 상대의 (직전 명령 → 이번 명령) 전이 횟수를 표로 쌓고, 지금 상대의 직전 명령으로 그 행을 꺼내 가장 잦은 수를 예측한 뒤 가위바위보의 반대쪽을 낸다 — strike 라 보면 guard, move·wait 라 보면 strike, guard 라 보면 베지 않고 은행을 채운다(상대만 유지비를 문다). 표본이 얇을 때는 예측을 믿지 않고 확정 정보(교훈 ③)만 쓴다.
(function () {
  var VEC = { N: [0, -1], S: [0, 1], E: [1, 0], W: [-1, 0] };
  var ORDER = ['N', 'S', 'E', 'W'];
  function open(obs, x, y) {
    return x >= 0 && y >= 0 && x < obs.size && y < obs.size && obs.walls[y][x] === 0;
  }
  function dist(a, b) { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); }
  function dirTo(dx, dy) {
    if (Math.abs(dx) >= Math.abs(dy)) return dx > 0 ? 'E' : dx < 0 ? 'W' : (dy > 0 ? 'S' : 'N');
    return dy > 0 ? 'S' : dy < 0 ? 'N' : (dx > 0 ? 'E' : 'W');
  }
  /** 벽을 피해 목표로 가는 첫 걸음 (너비 우선). 갈 곳이 없으면 null */
  function stepToward(obs, tx, ty) {
    var n = obs.size, s = obs.self;
    if (s.x === tx && s.y === ty) return null;
    var prev = new Int32Array(n * n).fill(-1);
    var seen = new Uint8Array(n * n);
    var q = [s.y * n + s.x];
    seen[q[0]] = 1;
    var head = 0, goal = -1;
    while (head < q.length) {
      var cur = q[head++];
      var cx = cur % n, cy = (cur - cx) / n;
      if (cx === tx && cy === ty) { goal = cur; break; }
      for (var i = 0; i < 4; i++) {
        var v = VEC[ORDER[i]];
        var nx = cx + v[0], ny = cy + v[1];
        if (!open(obs, nx, ny)) continue;
        var ni = ny * n + nx;
        if (seen[ni]) continue;
        seen[ni] = 1;
        prev[ni] = cur;
        q.push(ni);
      }
    }
    if (goal < 0) return null;
    var node = goal;
    while (prev[node] !== -1 && prev[node] !== s.y * n + s.x) node = prev[node];
    if (prev[node] === -1) return null;
    var gx = node % n, gy = (node - gx) / n;
    return dirTo(gx - s.x, gy - s.y);
  }
  /** 상대에게서 멀어지는 한 걸음 */
  function stepAway(obs) {
    var s = obs.self, f = obs.foe, best = null, bestD = -1;
    for (var i = 0; i < 4; i++) {
      var d = ORDER[i], v = VEC[d];
      var nx = s.x + v[0], ny = s.y + v[1];
      if (!open(obs, nx, ny)) continue;
      var dd = Math.abs(nx - f.x) + Math.abs(ny - f.y);
      if (dd > bestD) { bestD = dd; best = d; }
    }
    return best;
  }
  /** 시드 고정 난수 — Math.random 은 아레나에서 막혀 있다. 동점인 선택지를 가를 때만 쓴다. */
  function rnd(obs, memory, n) {
    if (memory.__s === undefined) memory.__s = obs.seed >>> 0;
    memory.__s = (memory.__s + 0x9e3779b9) >>> 0;
    var z = memory.__s;
    z = Math.imul(z ^ (z >>> 16), 0x21f0aaad) >>> 0;
    z = Math.imul(z ^ (z >>> 15), 0x735a2d97) >>> 0;
    return ((z ^ (z >>> 15)) >>> 0) % n;
  }
  /** 한 걸음 옮기면 상대와 붙는 방향들 (거리 2에서 "사이 칸"을 찾는 데 쓴다) */
  function gapDirs(obs) {
    var s = obs.self, f = obs.foe, out = [];
    for (var i = 0; i < 4; i++) {
      var d = ORDER[i], v = VEC[d];
      var nx = s.x + v[0], ny = s.y + v[1];
      if (!open(obs, nx, ny)) continue;
      if (Math.abs(nx - f.x) + Math.abs(ny - f.y) === 1) out.push(d);
    }
    return out;
  }
  /**
   * 지난 틱에 움직이려 했는데 제자리인가 — 둘이 같은 칸을 노려 서로 묶인 상태다.
   * 틱마다 딱 한 번만 부를 것(자리를 기억한다).
   */
  function stalled(obs, memory) {
    var s = obs.self;
    var tried = obs.lastSelfCmd && (obs.lastSelfCmd.cmd === 'move' || obs.lastSelfCmd.cmd === 'dash');
    var same = memory.__px === s.x && memory.__py === s.y;
    // 두 틱 전 자리로 돌아왔다 = 서로 상대의 옛 자리를 쫓는 2주기 진동이다
    var loop = memory.__qx === s.x && memory.__qy === s.y;
    memory.__qx = memory.__px; memory.__qy = memory.__py;
    memory.__px = s.x; memory.__py = s.y;
    return !!(tried && (same || loop));
  }
  /** 지금 설 수 있는 방향들 */
  function openDirs(obs) {
    var s = obs.self, out = [];
    for (var i = 0; i < 4; i++) {
      var v = VEC[ORDER[i]];
      if (open(obs, s.x + v[0], s.y + v[1])) out.push(ORDER[i]);
    }
    return out;
  }
  /** 그 방향으로 대시하면 어디에 서나 (막히면 null) */
  function dashLanding(obs, dir) {
    var s = obs.self, v = VEC[dir], x = s.x, y = s.y;
    for (var k = 0; k < obs.config.dashDistance; k++) {
      x += v[0]; y += v[1];
      if (!open(obs, x, y)) return null;
    }
    return { x: x, y: y };
  }
  /**
   * 상대에게 붙는 한 걸음.
   * 거리 2에서는 붙을 수 있는 칸이 여럿인데, 둘 다 같은 규칙으로 최단을 고르면
   * 영원히 같은 칸에서 부딪쳐 둘 다 제자리가 된다 — 그래서 그 자리만 무작위로 고른다.
   */
  function closeIn(obs, memory) {
    var s = obs.self, f = obs.foe;
    if (dist(s, f) === 2) {
      var g = gapDirs(obs);
      if (g.length) return g[rnd(obs, memory, g.length)];
    }
    return stepToward(obs, f.x, f.y);
  }
  function strikeDir(obs) {
    var s = obs.self, f = obs.foe;
    var dx = f.x - s.x, dy = f.y - s.y;
    if (Math.abs(dx) + Math.abs(dy) !== 1) return null;
    return dirTo(dx, dy);
  }


  var KINDS = ['move', 'dash', 'strike', 'guard', 'wait'];
  function idx(cmd) { for (var i = 0; i < KINDS.length; i++) if (KINDS[i] === cmd) return i; return -1; }

  return function cadence(obs, memory) {
    var s = obs.self, f = obs.foe, c = obs.config;
    var stuck = stalled(obs, memory);
    var canS = s.energy >= c.cost.strike, canG = s.energy >= c.cost.guard;
    var foeS = f.energy >= c.cost.strike, foeG = f.energy >= c.cost.guard;
    var sd = strikeDir(obs);

    // ── 상대의 (직전 → 이번) 전이표를 쌓는다
    if (!memory.tbl) { memory.tbl = []; for (var i = 0; i <= KINDS.length; i++) memory.tbl.push([0, 0, 0, 0, 0]); }
    var now = obs.lastFoeCmd ? idx(obs.lastFoeCmd.cmd) : -1;
    if (memory.prev !== undefined && now >= 0) memory.tbl[memory.prev + 1][now] += 1;
    memory.prev = now;

    // ── 다음 수 예측 (표본이 얇으면 안 믿는다)
    var row = memory.tbl[now + 1], total = 0, best = -1, bestN = 0;
    for (var k = 0; k < KINDS.length; k++) {
      total += row[k];
      if (row[k] > bestN) { bestN = row[k]; best = k; }
    }
    var confident = total >= 4 && bestN * 2 > total;
    var guess = confident ? KINDS[best] : null;

    if (sd) {
      if (canS && !foeG) return { cmd: 'strike', dir: sd };          // 확정 정보가 예측보다 세다
      if (canS && !foeS) return { cmd: 'strike', dir: sd };
      if (guess === 'strike' && canG && foeS) return { cmd: 'guard' };            // 예측 → 카운터
      if ((guess === 'move' || guess === 'wait' || guess === 'dash') && canS) return { cmd: 'strike', dir: sd };
      if (guess === 'guard') return { cmd: 'wait' };                 // 헛되이 웅크리게 두고 나만 채운다
      if (foeS && canG && rnd(obs, memory, 10) < 5) return { cmd: 'guard' };
      if (canS && rnd(obs, memory, 10) < 6) return { cmd: 'strike', dir: sd };
      return { cmd: 'wait' };
    }

    // 200틱을 넘기면 HP 가 같을 때 무승부다 — 앞서 있지 않으면 언젠가는 들어가야 한다
    var mustAct = obs.tick * 2 > c.tickLimit && s.hp <= f.hp;

    var d = dist(s, f);
    var gaps = gapDirs(obs);
    if (gaps.length) {
      // 상대가 들어올 거라고 보면 그 칸을 벤다. 아니면 은행을 채우고 기다린다.
      if (canS && (guess === 'move' || guess === 'dash' || !foeS)) {
        if (guess === 'move' || guess === 'dash') return { cmd: 'strike', dir: gaps[rnd(obs, memory, gaps.length)] };
      }
      if ((!foeS || mustAct) && (s.energy >= c.energyMax || mustAct)) {
        var into = closeIn(obs, memory);
        if (into) return { cmd: 'move', dir: into };
      }
      if (s.energy < c.energyMax) return { cmd: 'wait' };
      if (canS && rnd(obs, memory, 10) < 4) return { cmd: 'strike', dir: gaps[rnd(obs, memory, gaps.length)] };
      return { cmd: 'wait' };
    }

    if (d > 2) {
      var step = stuck ? null : closeIn(obs, memory);
      if (stuck) {
        var alts = openDirs(obs);
        if (alts.length) step = alts[rnd(obs, memory, alts.length)];
      }
      if (step) return { cmd: 'move', dir: step };
    }
    return { cmd: 'wait' };
  };
})()
botarena · 봇 콜로세움 — 코드를 써서 노는 격자 아레나
이 리포트는 npm run league 이 매번 새로 굽는다. 사람이 손으로 고치지 않는다.