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); });