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
+
HOLD
+
+
TIME0.0
+
LINES0
+
PPS0.00
+
+
+ ← → 이동
↓ 소프트드롭
Space 하드드롭
↑ / X 우회전
Z / Ctrl 좌회전
A 180°
C / Shift 홀드
R 재시작 +
+
+
+ + +
TOP OUT
+
+
+
NEXT
+
+
+ + + 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
+
HOLD
+
+
TIME0.0
+
LINES0
+
PPS0.00
+
+
+ ← → 이동
↓ 소프트드롭
Space 하드드롭
↑ / X 우회전
Z / Ctrl 좌회전
A 180°
C / Shift 홀드
R 재시작 +
+
+
+ + +
TOP OUT
+
+
+
NEXT
+
+
+ + + 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); +});