봇을 함수 하나로 짜서 붙이는 15×15 격자 아레나. 이 문서는 밤이 돌린 리그의 결과다 — 순위표, 페어별 승률, 결정성 검증, 아레나 수치 조정 이력, 그리고 밤이 직접 짜서 판에 올린 도전자 두 벌.
합격선 통과 결정성 2,000/2,000
행이 열을 상대로 거둔 승률. 굵은 테두리가 관측된 순환 고리다.
가위바위보 구조가 실제로 돈다 — 러셔 > 저격수 > 거북이 > 러셔.
러셔 → 저격수 36.0% · 저격수 → 거북이 77.0% · 거북이 → 러셔 53.5%
같은 2,000판을 두 번 돌려 매 판의 리플레이 해시를 맞대어 본다. 해시에는 시드·수치판·양쪽 봇 이름과 매 틱의 명령·좌표·HP·에너지·위반 수가 전부 들어간다 — 한 틱이라도 갈리면 값이 달라진다.
카드가 준 씨앗값에서 시작해, 합격선에 못 미치는 이유가 규칙 자체에 있을 때만 수치를 건드렸다. 조정은 최대 2회로 묶여 있다.
카드가 준 씨앗값. 조정 전 기준선.
씨앗값에서는 방어·공격 비용(1)이 매 틱 회복량(1)과 같아서 둘 다 무한히 반복된다. 그래서 거북이는 원리적으로 죽지 않았고(1차 리그 79판 전부 무승부), 서로 매 틱 베는 짝은 3틱 만에 같이 죽었다(동시 KO 46판). 판이 끝나는 이유의 절반이 「아무도 못 이겨서」였다. 두 비용을 회복량 위로 올려 「지금 쓸까 아낄까」가 매 틱의 선택이 되게 했다 — 이게 서야 guard가 strike를 잡고, 되받아칠 창이 생긴다.
비용을 올렸더니 이번엔 은행이 너무 작았다. 상한 3에 회복 1이면 벤 직후(잔량 1) 다음 틱에 웅크릴 수 없다 — 즉 「베고 나서 몸을 지킨다」가 규칙상 불가능하다. 그래서 붙은 두 봇은 선택의 여지 없이 서로 베고 같이 죽었다(2차 리그 동시 KO 29판). 상한을 5로 올려 strike(2)+guard(2)가 한 은행 안에 들어오게 했다 — 공격 뒤에 몸을 지킬지, 한 번 더 벨지가 비로소 선택이 된다. 저격수의 dash(1)+strike(2)+strike(2) 연타도 여기서 성립한다.
1차 리그 2,000판을 읽고 나서 쓴 봇들이다. 규칙은 사람과 똑같다 — 같은 API, 같은 5ms 제한, 같은 샌드박스. 아침에 이걸 읽고 이길 봇을 짜는 게 이 게임의 진짜 첫 판이다.
상대가 다음 틱에 설 수 있는 칸을 전부 세어, 내 옆칸이 하나뿐일 때 그 자리를 미리 후려친다.
(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' };
};
})()
상대의 직전 명령 → 다음 명령 표를 memory에 쌓아 다음 수를 예측하고, 그 예측의 카운터를 낸다.
(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' };
};
})()