Files
tetrig/test/engine.test.js
tkrmagid c9463fe46b Phase 2+3: full solo app (3 modes, scoring, menus, settings, records)
- engine: 점수 시스템(§5) 추가 — scoreClear/levelForLines/gravityMsForLevel + 테스트(27개 통과).
- 클라이언트를 멀티스크린 PWA로 확장:
  · 메인 ①(라이트 네오브루탈, 3카드+시즌바+탭바+광고 슬롯)
  · 솔로 모드선택 시트 ⑧(SPRINT/TIME ATTACK/ENDLESS + 베스트 기록)
  · 인게임 ②(§11 터치, 모드별 중력/레벨/타이머/점수)
  · 솔로 결과 화면(기록 갱신·NEW RECORD·광고 슬롯)
  · 설정(DAS/ARR/SDF 슬라이더·고스트·햅틱, localStorage)
  · 멀티/커스텀/리더보드/프로필은 서버 단계까지 "준비 중"
- SPRINT(40줄 타임)·TIME ATTACK(2분 점수·레벨가속)·ENDLESS(무한, 톱아웃 시 보드만 리셋) 구현.
- 기록은 localStorage 저장. 헤드리스로 전 화면·플레이·점수·네비게이션 검증(에러 0).
2026-08-22 15:57:36 +09:00

255 lines
11 KiB
JavaScript
Raw Permalink 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,
levelForLines, gravityMsForLevel, scoreClear,
} 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);
});
// ── 점수·레벨 (명세서 §5) ──
test('레벨/중력 곡선', () => {
assert.equal(levelForLines(0), 1);
assert.equal(levelForLines(9), 1);
assert.equal(levelForLines(10), 2);
assert.equal(levelForLines(35), 4);
assert.equal(gravityMsForLevel(1), 1000);
assert.equal(gravityMsForLevel(2), 800);
assert.equal(gravityMsForLevel(20), 50); // 하한
});
test('점수표: 기본 클리어 ×레벨', () => {
const mk = (lines, tspin = 'none', allClear = false, combo = -1, b2b = -1) =>
({ lines, tspin, allClear, combo, b2b });
assert.equal(scoreClear(mk(1), 1), 100); // Single
assert.equal(scoreClear(mk(2), 1), 300); // Double
assert.equal(scoreClear(mk(4), 1), 800); // Quad
assert.equal(scoreClear(mk(1), 3), 300); // ×레벨
});
test('점수표: T-spin / B2B / 콤보 / All Clear', () => {
// T-spin Double = 1200
assert.equal(scoreClear({ lines: 2, tspin: 'tspin', allClear: false, combo: -1, b2b: -1 }, 1), 1200);
// B2B Quad = 800×1.5 = 1200
assert.equal(scoreClear({ lines: 4, tspin: 'none', allClear: false, combo: -1, b2b: 1 }, 1), 1200);
// 콤보: Single + 콤보1 → 100 + 50×1 = 150
assert.equal(scoreClear({ lines: 1, tspin: 'none', allClear: false, combo: 1, b2b: -1 }, 1), 150);
// All Clear Quad = 800 + 2000 = 2800; B2B AC Quad = 1200 + 3200 = 4400
assert.equal(scoreClear({ lines: 4, tspin: 'none', allClear: true, combo: -1, b2b: -1 }, 1), 2800);
assert.equal(scoreClear({ lines: 4, tspin: 'none', allClear: true, combo: -1, b2b: 1 }, 1), 4400);
});