diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..eb55b08
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+_*.png
+_verify.mjs
+.DS_Store
diff --git a/build.js b/build.js
new file mode 100644
index 0000000..a0ab3fb
--- /dev/null
+++ b/build.js
@@ -0,0 +1,17 @@
+// 빌드: src/engine.js 를 src/index.template.html 에 인라인해 단일 index.html 생성.
+// 최종 클라이언트 요건("단일 HTML 파일, 외부 에셋 0개")을 만족시키기 위한 개발용 도구.
+import { readFileSync, writeFileSync } from 'node:fs';
+
+const engine = readFileSync(new URL('./src/engine.js', import.meta.url), 'utf8')
+ // 모듈 export 키워드 제거 → 같은 스크립트 스코프의 일반 선언으로 인라인
+ .replace(/^export\s+/gm, '');
+
+const template = readFileSync(new URL('./src/index.template.html', import.meta.url), 'utf8');
+
+if (!template.includes('//__ENGINE__')) {
+ throw new Error('템플릿에 //__ENGINE__ 자리표시자가 없습니다.');
+}
+
+const out = template.replace('//__ENGINE__', engine);
+writeFileSync(new URL('./index.html', import.meta.url), out);
+console.log(`index.html 생성 완료 (${(out.length/1024).toFixed(1)} KB, 외부 에셋 0개)`);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f7e58f2
--- /dev/null
+++ b/index.html
@@ -0,0 +1,612 @@
+
+
+
+
+
+TETRIG
+
+
+
+
+
+
TETRIG
PHASE 1 · CORE
+
+
+
TIME0.0
+
LINES0
+
PPS0.00
+
+
+ ← → 이동
↓ 소프트드롭
Space 하드드롭
↑ / X 우회전
Z / Ctrl 좌회전
A 180°
C / Shift 홀드
R 재시작
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..62fc1fb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "tetrig",
+ "version": "0.1.0",
+ "description": "TETRIG — real-time competitive block stacker (client + Cloudflare Workers server)",
+ "type": "module",
+ "scripts": {
+ "test": "node --test",
+ "build": "node build.js",
+ "serve": "node build.js && node -e \"const h=require('http'),f=require('fs');h.createServer((q,s)=>{const p=q.url==='/'?'/index.html':q.url;try{s.end(f.readFileSync('.'+p))}catch(e){s.statusCode=404;s.end('404')}}).listen(8787,()=>console.log('http://localhost:8787'))\""
+ },
+ "license": "UNLICENSED",
+ "private": true
+}
diff --git a/src/engine.js b/src/engine.js
new file mode 100644
index 0000000..79e1e68
--- /dev/null
+++ b/src/engine.js
@@ -0,0 +1,355 @@
+// TETRIG — 코어 엔진 (Phase 1)
+// 순수 로직만: DOM/타이머 없음. 렌더링·입력·타이밍은 index.html(클라이언트)에서 처리한다.
+//
+// 좌표 규약 (명세서 §4.1 준수):
+// - 보드 좌표 (x, y): x 오른쪽 증가, y 위쪽 증가. y=0 이 보드 맨 아래.
+// - 킥 오프셋 (dx, dy): dy 양수 = 위쪽. 그대로 py += dy 로 적용된다.
+// - 회전 상태: 0=스폰, 1=CW, 2=180, 3=CCW.
+
+// ── 튜닝 상수 (조정 대상은 여기 한 곳에 모은다) ──────────────────
+export const CONFIG = {
+ BOARD_W: 10,
+ BOARD_H: 40, // 내부 높이 (하단 20행만 표시)
+ VISIBLE_H: 20,
+ NEXT_COUNT: 5, // 넥스트 표시 개수
+ LOCK_DELAY_MS: 500, // 락 딜레이
+ MAX_LOCK_RESETS: 15, // 이동/회전 성공 시 리셋, 최대 15회
+ DEFAULT_GRAVITY_MS: 800, // Phase 1 기본 낙하 간격 (SPRINT 값)
+ DAS_MS: 133,
+ ARR_MS: 10,
+ SDF: 20, // 소프트드롭 배율
+};
+
+export const PIECE_TYPES = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
+
+// 피스 색 (명세서 §13 v2 확정 — 프린트 톤)
+export const PIECE_COLORS = {
+ I: '#3EC1B6', O: '#F5C531', T: '#A76BF2', S: '#7FCC4C',
+ Z: '#F25C4C', J: '#4C7DF2', L: '#F2913D', G: '#46464F', // G = 가비지
+};
+
+// ── 피스 정의 (스폰 상태 셀 + 박스 크기) ──────────────────────────
+// 박스-로컬 좌표, y 위쪽 증가. STATES 는 4회전 상태를 자동 생성한다.
+const SPAWN = {
+ I: { box: 4, cells: [[0, 2], [1, 2], [2, 2], [3, 2]] },
+ O: { box: 2, cells: [[0, 0], [1, 0], [0, 1], [1, 1]] },
+ T: { box: 3, cells: [[0, 1], [1, 1], [2, 1], [1, 2]] },
+ S: { box: 3, cells: [[0, 1], [1, 1], [1, 2], [2, 2]] },
+ Z: { box: 3, cells: [[0, 2], [1, 2], [1, 1], [2, 1]] },
+ J: { box: 3, cells: [[0, 2], [0, 1], [1, 1], [2, 1]] },
+ L: { box: 3, cells: [[2, 2], [0, 1], [1, 1], [2, 1]] },
+};
+
+// 스폰 시 박스 원점의 보드 위치 (px, py). 셀은 (px+cx, py+cy).
+const SPAWN_POS = {
+ I: [3, 19], O: [4, 21], T: [3, 20], S: [3, 20],
+ Z: [3, 20], J: [3, 20], L: [3, 20],
+};
+
+// CW 회전: 박스 크기 b 에서 (x,y) -> (y, (b-1)-x) (y 위쪽 증가 기준)
+function rotateCW(cells, b) {
+ return cells.map(([x, y]) => [y, (b - 1) - x]);
+}
+
+function buildStates(type) {
+ const { box, cells } = SPAWN[type];
+ if (type === 'O') return [cells, cells, cells, cells]; // O는 회전 불변
+ const s0 = cells;
+ const s1 = rotateCW(s0, box);
+ const s2 = rotateCW(s1, box);
+ const s3 = rotateCW(s2, box);
+ return [s0, s1, s2, s3];
+}
+
+export const STATES = Object.fromEntries(PIECE_TYPES.map(t => [t, buildStates(t)]));
+
+// ── 킥테이블 (명세서 §4.1) ────────────────────────────────────────
+// 키: "from>to". 값: [ [dx,dy], ... ] 순서대로 시도.
+const KICKS_JLSTZ = {
+ '0>1': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
+ '1>0': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
+ '1>2': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
+ '2>1': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
+ '2>3': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
+ '3>2': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
+ '3>0': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
+ '0>3': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
+ // 180° (SRS+)
+ '0>2': [[0, 0], [0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0]],
+ '2>0': [[0, 0], [0, -1], [-1, -1], [1, -1], [-1, 0], [1, 0]],
+ '1>3': [[0, 0], [1, 0], [1, 2], [1, 1], [0, 2], [0, 1]],
+ '3>1': [[0, 0], [-1, 0], [-1, 2], [-1, 1], [0, 2], [0, 1]],
+};
+
+const KICKS_I = {
+ '0>1': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
+ '1>0': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
+ '1>2': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
+ '2>1': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
+ '2>3': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
+ '3>2': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
+ '3>0': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
+ '0>3': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
+ // I 180°는 (0,0)만
+ '0>2': [[0, 0]], '2>0': [[0, 0]], '1>3': [[0, 0]], '3>1': [[0, 0]],
+};
+
+export function kickTable(type, from, to) {
+ const key = `${from}>${to}`;
+ if (type === 'O') return [[0, 0]];
+ if (type === 'I') return KICKS_I[key] || [[0, 0]];
+ return KICKS_JLSTZ[key] || [[0, 0]];
+}
+
+// ── RNG (테스트를 위한 시드 가능 난수, mulberry32) ─────────────────
+export function makeRng(seed = 1) {
+ let a = seed >>> 0;
+ return function () {
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// ── 7-bag ─────────────────────────────────────────────────────────
+export class SevenBag {
+ constructor(rng = Math.random) {
+ this.rng = rng;
+ this.queue = [];
+ this._refill();
+ this._refill();
+ }
+ _refill() {
+ const bag = PIECE_TYPES.slice();
+ for (let i = bag.length - 1; i > 0; i--) {
+ const j = Math.floor(this.rng() * (i + 1));
+ [bag[i], bag[j]] = [bag[j], bag[i]];
+ }
+ this.queue.push(...bag);
+ }
+ next() {
+ if (this.queue.length <= CONFIG.NEXT_COUNT + 1) this._refill();
+ return this.queue.shift();
+ }
+ peek(n = CONFIG.NEXT_COUNT) {
+ while (this.queue.length < n) this._refill();
+ return this.queue.slice(0, n);
+ }
+}
+
+// ── 보드 유틸 ─────────────────────────────────────────────────────
+export function createBoard(w = CONFIG.BOARD_W, h = CONFIG.BOARD_H) {
+ return Array.from({ length: h }, () => new Array(w).fill(null));
+}
+
+// 절대 보드 셀 좌표 목록
+export function pieceCells(type, state, px, py) {
+ return STATES[type][state].map(([cx, cy]) => [px + cx, py + cy]);
+}
+
+// 충돌: x 범위 밖 / 바닥 아래(y<0) / 점유 셀 = 충돌. y >= H (버퍼 위)는 비어있음으로 허용.
+export function collides(board, cells) {
+ const W = board[0].length, H = board.length;
+ for (const [x, y] of cells) {
+ if (x < 0 || x >= W || y < 0) return true;
+ if (y < H && board[y][x]) return true;
+ }
+ return false;
+}
+
+// 라인 클리어: 가득 찬 행 제거 후 위에서 빈 행 채움. { cleared, rows } 반환.
+export function clearLines(board) {
+ const W = board[0].length, H = board.length;
+ const rows = [];
+ for (let y = 0; y < H; y++) {
+ if (board[y].every(c => c)) rows.push(y);
+ }
+ if (rows.length === 0) return { cleared: 0, rows };
+ const kept = board.filter((_, y) => !rows.includes(y));
+ while (kept.length < H) kept.push(new Array(W).fill(null));
+ for (let y = 0; y < H; y++) board[y] = kept[y];
+ return { cleared: rows.length, rows };
+}
+
+// 보드가 완전히 비었는가 (All Clear 판정)
+export function isBoardEmpty(board) {
+ return board.every(row => row.every(c => !c));
+}
+
+// ── T-spin 판정 (명세서 §4.2) ─────────────────────────────────────
+// lastMoveRotation 이고 T 피스일 때만 호출. 반환: 'none' | 'mini' | 'tspin'
+export function detectTSpin(board, type, state, px, py, lastMoveRotation) {
+ if (type !== 'T' || !lastMoveRotation) return 'none';
+ const W = board[0].length, H = board.length;
+ const cx = px + 1, cy = py + 1; // T 중심 (박스 (1,1))
+ const filled = (dx, dy) => {
+ const x = cx + dx, y = cy + dy;
+ if (x < 0 || x >= W || y < 0) return true; // 벽/바닥은 채움 취급
+ if (y >= H) return false; // 버퍼 위는 빈 것
+ return !!board[y][x];
+ };
+ const TL = filled(-1, 1), TR = filled(1, 1), BL = filled(-1, -1), BR = filled(1, -1);
+ const count = TL + TR + BL + BR;
+ if (count < 3) return 'none';
+ // 전면(포인팅 방향) 2모서리
+ let front;
+ if (state === 0) front = [TL, TR];
+ else if (state === 1) front = [TR, BR];
+ else if (state === 2) front = [BL, BR];
+ else front = [TL, BL];
+ return (front[0] && front[1]) ? 'tspin' : 'mini';
+}
+
+// 클리어가 B2B 대상("어려운" 클리어)인가: Quad 또는 라인 동반 T-spin 계열
+export function isDifficult(lines, tspin) {
+ return (lines === 4) || (tspin !== 'none' && lines >= 1);
+}
+
+// ── 게임 상태 (클라이언트가 타이밍을 구동) ─────────────────────────
+export class TetrigGame {
+ constructor({ seed = 1 } = {}) {
+ this.board = createBoard();
+ this.bag = new SevenBag(makeRng(seed));
+ this.hold = null;
+ this.holdUsed = false;
+ this.over = false;
+ this.lastMoveRotation = false;
+ this.lockResets = 0;
+ // 통계 (Phase 2 점수 계산용 상태)
+ this.combo = -1;
+ this.b2b = -1;
+ this.lastClear = null;
+ this.active = null;
+ this.spawn();
+ }
+
+ cells(a = this.active) {
+ return pieceCells(a.type, a.state, a.x, a.y);
+ }
+
+ spawn(type = this.bag.next()) {
+ const [px, py] = SPAWN_POS[type];
+ this.active = { type, state: 0, x: px, y: py };
+ this.lastMoveRotation = false;
+ this.lockResets = 0;
+ if (collides(this.board, this.cells())) {
+ this.over = true; // 톱아웃
+ return false;
+ }
+ return true;
+ }
+
+ peekNext(n = CONFIG.NEXT_COUNT) { return this.bag.peek(n); }
+
+ canFall() {
+ const c = this.cells().map(([x, y]) => [x, y - 1]);
+ return !collides(this.board, c);
+ }
+
+ // 락 딜레이 리셋 (이동/회전 성공 시). 최대 횟수 초과 시 false.
+ resetLockDelay() {
+ if (this.lockResets >= CONFIG.MAX_LOCK_RESETS) return false;
+ this.lockResets++;
+ return true;
+ }
+
+ move(dx) {
+ if (this.over) return false;
+ const a = this.active;
+ const c = pieceCells(a.type, a.state, a.x + dx, a.y);
+ if (collides(this.board, c)) return false;
+ a.x += dx;
+ this.lastMoveRotation = false;
+ return true;
+ }
+
+ softDrop() {
+ if (this.over) return false;
+ const a = this.active;
+ const c = pieceCells(a.type, a.state, a.x, a.y - 1);
+ if (collides(this.board, c)) return false;
+ a.y -= 1;
+ this.lastMoveRotation = false;
+ return true;
+ }
+
+ // dir: 'cw' | 'ccw' | '180'
+ rotate(dir) {
+ if (this.over) return false;
+ const a = this.active;
+ const to = dir === 'cw' ? (a.state + 1) % 4
+ : dir === 'ccw' ? (a.state + 3) % 4
+ : (a.state + 2) % 4;
+ const kicks = kickTable(a.type, a.state, to);
+ for (const [dx, dy] of kicks) {
+ const nx = a.x + dx, ny = a.y + dy;
+ const c = pieceCells(a.type, to, nx, ny);
+ if (!collides(this.board, c)) {
+ a.state = to; a.x = nx; a.y = ny;
+ this.lastMoveRotation = true;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ ghostCells() {
+ const a = this.active;
+ let dy = 0;
+ while (!collides(this.board, pieceCells(a.type, a.state, a.x, a.y - dy - 1))) dy++;
+ return pieceCells(a.type, a.state, a.x, a.y - dy);
+ }
+
+ hardDrop() {
+ if (this.over) return null;
+ const a = this.active;
+ let d = 0;
+ while (!collides(this.board, pieceCells(a.type, a.state, a.x, a.y - d - 1))) d++;
+ a.y -= d;
+ return this.lock();
+ }
+
+ holdSwap() {
+ if (this.over || this.holdUsed) return false;
+ const cur = this.active.type;
+ if (this.hold == null) {
+ this.hold = cur;
+ this.spawn();
+ } else {
+ const swap = this.hold;
+ this.hold = cur;
+ this.spawn(swap);
+ }
+ this.holdUsed = true;
+ return true;
+ }
+
+ // 현재 피스를 보드에 고정하고 클리어/판정/스폰까지 처리. 결과 객체 반환.
+ lock() {
+ const a = this.active;
+ const tspin = detectTSpin(this.board, a.type, a.state, a.x, a.y, this.lastMoveRotation);
+ for (const [x, y] of this.cells()) {
+ if (y >= 0 && y < this.board.length) this.board[y][x] = a.type;
+ }
+ const { cleared } = clearLines(this.board);
+ const allClear = cleared > 0 && isBoardEmpty(this.board);
+
+ // 콤보
+ this.combo = cleared > 0 ? this.combo + 1 : -1;
+ // B2B
+ const diff = isDifficult(cleared, tspin);
+ if (cleared > 0) {
+ this.b2b = diff ? this.b2b + 1 : -1;
+ }
+
+ const result = {
+ type: a.type, lines: cleared, tspin, allClear,
+ combo: this.combo, b2b: this.b2b, difficult: diff,
+ };
+ this.lastClear = result;
+ this.holdUsed = false;
+ this.spawn();
+ return result;
+ }
+}
diff --git a/src/index.template.html b/src/index.template.html
new file mode 100644
index 0000000..bb9e368
--- /dev/null
+++ b/src/index.template.html
@@ -0,0 +1,257 @@
+
+
+
+
+
+TETRIG
+
+
+
+
+
+
TETRIG
PHASE 1 · CORE
+
+
+
TIME0.0
+
LINES0
+
PPS0.00
+
+
+ ← → 이동
↓ 소프트드롭
Space 하드드롭
↑ / X 우회전
Z / Ctrl 좌회전
A 180°
C / Shift 홀드
R 재시작
+
+
+
+
+
+
+
+
diff --git a/test/engine.test.js b/test/engine.test.js
new file mode 100644
index 0000000..0e033f3
--- /dev/null
+++ b/test/engine.test.js
@@ -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);
+});