Files
tetrig/test/engine.test.js
tkrmagid 33771e932e 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, 하드/소프트드롭, 판정 팝업)
2026-08-22 11:13:36 +09:00

222 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});