- 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).
255 lines
11 KiB
JavaScript
255 lines
11 KiB
JavaScript
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);
|
||
});
|