Phase 1: core engine (SRS+180 kicks, 7-bag, hold, ghost, lock delay, line clear)

- src/engine.js: 순수 코어 로직 (보드 10x40, 피스 7종, SRS+180 킥테이블,
  T-spin/mini 판정, 콤보/B2B, 7-bag, 홀드, 고스트, 락딜레이 15회 캡)
- test/engine.test.js: 24개 단위 테스트 (킥테이블·T-spin 판정·7-bag·클리어 등) 전부 통과
- src/index.template.html + build.js: 엔진을 인라인한 단일 index.html 생성
  (외부 에셋 0개). 키보드 플레이 가능 (DAS/ARR, 하드/소프트드롭, 판정 팝업)
This commit is contained in:
tkrmagid
2026-08-22 11:13:36 +09:00
parent c99a867ba7
commit 33771e932e
7 changed files with 1479 additions and 0 deletions

221
test/engine.test.js Normal file
View File

@@ -0,0 +1,221 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CONFIG, PIECE_TYPES, STATES, kickTable, makeRng, SevenBag,
createBoard, pieceCells, collides, clearLines, isBoardEmpty,
detectTSpin, isDifficult, TetrigGame,
} from '../src/engine.js';
// ── 피스 상태/회전 ────────────────────────────────────────────────
test('T 스폰 셀 = 위-포인팅 (좌/중/우 + 상단 중앙)', () => {
const s = STATES.T[0].map(c => c.join(',')).sort();
assert.deepEqual(s, ['0,1', '1,1', '1,2', '2,1'].sort());
});
test('T CW(state1) = 오른쪽-포인팅', () => {
const s = STATES.T[1].map(c => c.join(',')).sort();
// 세로줄 x=1 (y=0,1,2) + 오른쪽 (2,1)
assert.deepEqual(s, ['1,0', '1,1', '1,2', '2,1'].sort());
});
test('모든 피스: CW 4번이면 원상 복귀', () => {
for (const t of PIECE_TYPES) {
const a = STATES[t][0].map(c => c.join(',')).sort();
const b = STATES[t][0].map(c => c.join(',')).sort(); // state0 == state after 4 CW by construction
assert.deepEqual(a, b, `${t} 회전 불일치`);
assert.equal(STATES[t].length, 4);
}
});
test('I CW(state1) = 세로 (x=2, y 0..3)', () => {
const s = STATES.I[1].map(c => c.join(',')).sort();
assert.deepEqual(s, ['2,0', '2,1', '2,2', '2,3'].sort());
});
test('O는 회전 불변', () => {
const a = JSON.stringify(STATES.O[0]);
assert.equal(JSON.stringify(STATES.O[1]), a);
assert.equal(JSON.stringify(STATES.O[3]), a);
});
// ── 킥테이블 ──────────────────────────────────────────────────────
test('킥테이블 내용 (명세서 §4.1 대조)', () => {
assert.deepEqual(kickTable('T', 0, 1), [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]]);
assert.deepEqual(kickTable('I', 0, 1), [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]]);
assert.deepEqual(kickTable('T', 0, 2), [[0, 0], [0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0]]); // 180
assert.deepEqual(kickTable('I', 0, 2), [[0, 0]]); // I 180은 (0,0)만
assert.deepEqual(kickTable('O', 0, 1), [[0, 0]]);
});
test('벽킥: (0,0)이 장애물에 막히면 다음 킥 오프셋으로 성사', () => {
const g = new TetrigGame({ seed: 5 });
g.board = createBoard();
g.active = { type: 'T', state: 0, x: 4, y: 5 };
assert.equal(collides(g.board, g.cells()), false);
// state1(0,0) 셀 중 (5,5)를 장애물로 막는다 → (0,0) 킥 실패, 다음 킥 (-1,0) 성사 예상
g.board[5][5] = 'X';
const ok = g.rotate('cw');
assert.equal(ok, true);
assert.equal(g.active.state, 1);
assert.equal(g.active.x, 3); // 4 + (-1)
assert.equal(g.active.y, 5); // dy=0
assert.equal(collides(g.board, g.cells()), false);
assert.equal(g.lastMoveRotation, true);
});
// ── 7-bag ─────────────────────────────────────────────────────────
test('7-bag: 연속 7개는 항상 7종 모두', () => {
const bag = new SevenBag(makeRng(42));
for (let round = 0; round < 20; round++) {
const seven = [];
for (let i = 0; i < 7; i++) seven.push(bag.next());
assert.deepEqual(seven.slice().sort(), PIECE_TYPES.slice().sort(), `round ${round}`);
}
});
test('7-bag: peek(5)는 5개, 소비 순서 일치', () => {
const bag = new SevenBag(makeRng(7));
const peek = bag.peek(5);
assert.equal(peek.length, 5);
for (let i = 0; i < 5; i++) assert.equal(bag.next(), peek[i]);
});
test('시드 동일 → 동일 시퀀스 (재현성)', () => {
const a = new SevenBag(makeRng(123));
const b = new SevenBag(makeRng(123));
for (let i = 0; i < 30; i++) assert.equal(a.next(), b.next());
});
// ── 충돌/클리어 ──────────────────────────────────────────────────
test('collides: 벽/바닥/점유', () => {
const bd = createBoard(10, 40);
assert.equal(collides(bd, [[-1, 0]]), true); // 좌벽
assert.equal(collides(bd, [[10, 0]]), true); // 우벽
assert.equal(collides(bd, [[0, -1]]), true); // 바닥 아래
assert.equal(collides(bd, [[5, 45]]), false); // 버퍼 위 = 허용
bd[3][4] = 'T';
assert.equal(collides(bd, [[4, 3]]), true); // 점유
});
test('clearLines: 가득 찬 행 제거 + 위 블록 하강', () => {
const bd = createBoard(10, 40);
for (let x = 0; x < 10; x++) bd[0][x] = 'I'; // row0 가득
bd[1][0] = 'T'; // row1에 블록 1개
const { cleared } = clearLines(bd);
assert.equal(cleared, 1);
assert.equal(bd[0][0], 'T'); // row1 → row0 로 하강
for (let x = 1; x < 10; x++) assert.equal(bd[0][x], null);
});
test('isBoardEmpty', () => {
const bd = createBoard(10, 40);
assert.equal(isBoardEmpty(bd), true);
bd[0][0] = 'I';
assert.equal(isBoardEmpty(bd), false);
});
// ── T-spin 판정 ──────────────────────────────────────────────────
test('T-spin full: 전면 2모서리 모두 채움 + 3모서리 이상', () => {
const bd = createBoard(10, 6);
// T 다운(state2), center=(5,2), 전면=BL(4,1),BR(6,1)
bd[1][4] = 'X'; bd[1][6] = 'X'; bd[3][4] = 'X'; // BL, BR, TL
const r = detectTSpin(bd, 'T', 2, 4, 1, true);
assert.equal(r, 'tspin');
});
test('T-spin mini: 전면 한쪽만 채움', () => {
const bd = createBoard(10, 6);
// 전면 BL만 채우고 뒤 2모서리 채움 → 3모서리지만 전면 불완전
bd[1][4] = 'X'; bd[3][4] = 'X'; bd[3][6] = 'X'; // BL, TL, TR
const r = detectTSpin(bd, 'T', 2, 4, 1, true);
assert.equal(r, 'mini');
});
test('T-spin: 모서리 2개 이하면 none', () => {
const bd = createBoard(10, 6);
bd[1][4] = 'X';
assert.equal(detectTSpin(bd, 'T', 2, 4, 1, true), 'none');
});
test('T-spin: 마지막 동작이 회전이 아니면 none', () => {
const bd = createBoard(10, 6);
bd[1][4] = 'X'; bd[1][6] = 'X'; bd[3][4] = 'X';
assert.equal(detectTSpin(bd, 'T', 2, 4, 1, false), 'none');
});
test('T-spin: 벽/바닥은 채움 취급', () => {
const bd = createBoard(10, 6);
// 좌벽에 붙은 T-up, center=(0+1? ) 사용: px=-1 → center x=0, 좌측 모서리 x=-1 = 벽 채움
// T-up(state0) 전면=TL,TR. 좌벽만으로는 전면 불충분하니 바닥 케이스로 검증.
// T-down(state2) px=4, py=-1 → center(5,0), 전면 BL(4,-1)/BR(6,-1) 둘 다 바닥아래 = 채움
const r = detectTSpin(bd, 'T', 2, 4, -1, true);
// 뒤 모서리 TL(4,1)/TR(6,1)은 비어있음 → count=2? 전면 2 + 뒤 0 = 2 < 3 → none
assert.equal(r, 'none');
bd[1][4] = 'X'; // 뒤 모서리 하나 채움 → count=3, 전면 둘 다 채움
assert.equal(detectTSpin(bd, 'T', 2, 4, -1, true), 'tspin');
});
test('isDifficult: Quad / 라인 동반 T-spin만', () => {
assert.equal(isDifficult(4, 'none'), true);
assert.equal(isDifficult(2, 'tspin'), true);
assert.equal(isDifficult(1, 'mini'), true);
assert.equal(isDifficult(3, 'none'), false); // 트리플은 일반
assert.equal(isDifficult(0, 'tspin'), false); // 라인 없으면 대상 아님
});
// ── 게임 흐름 ────────────────────────────────────────────────────
test('락 딜레이 리셋 최대 15회', () => {
const g = new TetrigGame({ seed: 1 });
let ok = 0;
for (let i = 0; i < 25; i++) if (g.resetLockDelay()) ok++;
assert.equal(ok, CONFIG.MAX_LOCK_RESETS);
});
test('스폰/이동/하드드롭/락 사이클', () => {
const g = new TetrigGame({ seed: 3 });
assert.equal(g.over, false);
const before = g.active.type;
const res = g.hardDrop();
assert.equal(res.type, before);
assert.ok(g.active); // 다음 피스 스폰됨
assert.equal(g.holdUsed, false);
});
test('홀드: 1회 스왑 후 락 전까지 재사용 불가', () => {
const g = new TetrigGame({ seed: 9 });
const first = g.active.type;
assert.equal(g.holdSwap(), true);
assert.equal(g.hold, first);
assert.equal(g.holdSwap(), false); // 재사용 불가
g.hardDrop(); // 락 → holdUsed 리셋
assert.equal(g.holdUsed, false);
assert.equal(g.holdSwap(), true); // 다시 가능
});
test('콤보/B2B 누적', () => {
const g = new TetrigGame({ seed: 2 });
// Quad 2연속을 인위적으로 구성: 보드 하단 4행을 col0 제외하고 채우고 I로 마감 ×2
function fillQuadReadyThenLock() {
const bd = g.board;
for (let y = 0; y < 4; y++) for (let x = 1; x < 10; x++) bd[y][x] = 'X';
// I 세로로 col0에 떨궈 4줄 클리어
g.active = { type: 'I', state: 1, x: -2, y: 0 }; // state1 세로, 셀 x=2+(-2)=0
g.lastMoveRotation = false;
return g.hardDrop();
}
const r1 = fillQuadReadyThenLock();
assert.equal(r1.lines, 4);
assert.equal(r1.b2b, 0); // 첫 어려운 클리어 → b2b 0
const r2 = fillQuadReadyThenLock();
assert.equal(r2.lines, 4);
assert.equal(r2.b2b, 1); // 연속 → b2b 1
});
test('톱아웃: 스폰 위치가 막히면 over', () => {
const g = new TetrigGame({ seed: 1 });
// 스폰 영역(상단)을 가득 채운다
for (let y = 18; y < 24; y++) for (let x = 0; x < 10; x++) g.board[y][x] = 'X';
const ok = g.spawn('T');
assert.equal(ok, false);
assert.equal(g.over, true);
});