2048 퍼즐게임 + 랭킹보드 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 로그인(승인 회원) 전용 2048 게임 페이지를 추가하고, 최고 점수 기준 TOP 10 랭킹보드를 붙인다.
Architecture: Jekyll 정적 페이지(/game.html) + vanilla JS 게임 로직(순수 함수로 분리해 테스트 가능) + Firestore game_scores 컬렉션. 기존 board.html/stats.js/auth-guard.js 패턴을 그대로 따른다. 빌드 스텝 없음, CDN import 방식의 Firebase SDK 사용.
Tech Stack: Jekyll, vanilla JS (ES modules), Firebase Auth + Firestore (firebasejs 10.12.2 CDN), Node node:test/node:assert (로직 자기검증용, 브라우저에는 미포함).
Global Constraints
- 로그인 안 한 사용자는 게임 페이지 접근 불가 →
/login/으로 리다이렉트 (스펙: “로그인 안하면 안되게”). - 승인(
status === "approved") 회원만 플레이 가능 (기존requireApproved규칙과 동일). - 외부 게임 라이브러리 추가 금지 — 순수 JS로 구현.
- 점수는 클라이언트 계산, 서버 재검증 없음 (스펙에서 확정).
- 닉네임당 제출 횟수 제한 없음,
bestScore만 갱신. - Firestore 문서 ID는
uid, 컬렉션명game_scores, 필드는{ nickname, bestScore, updatedAt }— 이름을 임의로 바꾸지 않는다. - 기존 파일 배치 패턴을 따른다: 페이지는 루트에
.html(front matter 포함), 로직은assets/js/, 스타일은assets/css/.
File Structure
- Create:
game.html— 페이지 shell (front matter + DOM + 게임/랭킹보드 마크업),board.html패턴을 따름. - Create:
assets/js/game2048.js— 순수 게임 로직 (그리드 이동/병합/게임오버 판정). DOM/Firebase 의존 없음. 테스트 대상. - Create:
assets/js/game2048.test.mjs—game2048.js의 자기검증 테스트 (node:test 기반,functions/index.test.js와 같은 스타일). - Create:
assets/js/game.js— 페이지 컨트롤러.requireApproved로 인증 가드, DOM 렌더링, 키보드/터치 입력, Firestore 점수 제출/랭킹 로드. - Create:
assets/css/game.css— 게임 그리드/타일/랭킹보드 스타일.pages.css의 CSS 변수 재사용. - Modify:
_config.yml:23-30— navigation에게임메뉴 항목 추가.
Task 1: 순수 게임 로직 (game2048.js) + 테스트
Files:
- Create:
assets/js/game2048.js - Test:
assets/js/game2048.test.mjs
Interfaces:
- Produces:
createEmptyGrid(): number[][]— 4x4, 모든 칸 0addRandomTile(grid: number[][]): number[][]— 빈 칸 중 하나에 90% 확률로 2, 10% 확률로 4를 놓은 새 그리드를 반환. 빈 칸이 없으면 원본과 동일한 그리드를 반환.moveLeft(grid: number[][]): { grid: number[][], moved: boolean, scoreGained: number }— 왼쪽으로 슬라이드+병합.move(grid: number[][], direction: "left"|"right"|"up"|"down"): { grid: number[][], moved: boolean, scoreGained: number }— 4방향 이동. 내부적으로 회전 후moveLeft재사용.isGameOver(grid: number[][]): boolean— 빈 칸도 없고 인접 병합도 불가능하면 true.
- Step 1: 실패하는 테스트 작성
// assets/js/game2048.test.mjs
import assert from "node:assert";
import { test } from "node:test";
import {
createEmptyGrid, addRandomTile, move, isGameOver
} from "./game2048.js";
test("createEmptyGrid: 4x4 모두 0", () => {
const grid = createEmptyGrid();
assert.strictEqual(grid.length, 4);
grid.forEach(row => {
assert.strictEqual(row.length, 4);
row.forEach(cell => assert.strictEqual(cell, 0));
});
});
test("addRandomTile: 빈 칸 하나에만 2 또는 4가 채워진다", () => {
const grid = createEmptyGrid();
const next = addRandomTile(grid);
const nonZero = next.flat().filter(v => v !== 0);
assert.strictEqual(nonZero.length, 1);
assert.ok(nonZero[0] === 2 || nonZero[0] === 4);
});
test("addRandomTile: 빈 칸이 없으면 그대로 반환", () => {
const full = [[2,2,2,2],[2,2,2,2],[2,2,2,2],[2,2,2,2]];
const next = addRandomTile(full);
assert.deepStrictEqual(next, full);
});
test("move left: 같은 값 두 개가 만나면 병합되고 점수가 오른다", () => {
const grid = [
[2, 2, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];
const result = move(grid, "left");
assert.deepStrictEqual(result.grid[0], [4, 0, 0, 0]);
assert.strictEqual(result.moved, true);
assert.strictEqual(result.scoreGained, 4);
});
test("move left: 세 개 연속은 앞쪽 두 개만 병합된다 (2 2 2 0 -> 4 2 0 0)", () => {
const grid = [
[2, 2, 2, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];
const result = move(grid, "left");
assert.deepStrictEqual(result.grid[0], [4, 2, 0, 0]);
});
test("move right: 오른쪽 정렬 및 병합", () => {
const grid = [
[0, 0, 2, 2],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];
const result = move(grid, "right");
assert.deepStrictEqual(result.grid[0], [0, 0, 0, 4]);
});
test("move up: 위쪽 정렬 및 병합", () => {
const grid = [
[2, 0, 0, 0],
[2, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];
const result = move(grid, "up");
assert.strictEqual(result.grid[0][0], 4);
assert.strictEqual(result.grid[1][0], 0);
});
test("move down: 아래쪽 정렬 및 병합", () => {
const grid = [
[2, 0, 0, 0],
[2, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];
const result = move(grid, "down");
assert.strictEqual(result.grid[3][0], 4);
assert.strictEqual(result.grid[2][0], 0);
});
test("move: 변화가 없으면 moved=false", () => {
const grid = [
[2, 4, 8, 16],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];
const result = move(grid, "left");
assert.strictEqual(result.moved, false);
assert.strictEqual(result.scoreGained, 0);
});
test("isGameOver: 빈 칸이 있으면 false", () => {
const grid = [
[2, 4, 8, 16],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];
assert.strictEqual(isGameOver(grid), false);
});
test("isGameOver: 꽉 찼지만 인접 병합 가능하면 false", () => {
const grid = [
[2, 4, 2, 4],
[4, 2, 4, 2],
[2, 4, 2, 4],
[4, 2, 4, 4]
];
assert.strictEqual(isGameOver(grid), false);
});
test("isGameOver: 꽉 차고 병합 불가능하면 true", () => {
const grid = [
[2, 4, 2, 4],
[4, 2, 4, 2],
[2, 4, 2, 4],
[4, 2, 4, 2]
];
assert.strictEqual(isGameOver(grid), true);
});
- Step 2: 테스트 실행해서 실패 확인
Run: node --test assets/js/game2048.test.mjs
Expected: FAIL (game2048.js가 아직 없어서 모듈을 찾을 수 없다는 에러)
- Step 3: game2048.js 구현
// assets/js/game2048.js
const SIZE = 4;
export function createEmptyGrid() {
return Array.from({ length: SIZE }, () => Array(SIZE).fill(0));
}
export function addRandomTile(grid) {
const empties = [];
grid.forEach((row, r) => row.forEach((v, c) => { if (v === 0) empties.push([r, c]); }));
if (empties.length === 0) return grid;
const next = grid.map(row => [...row]);
const [r, c] = empties[Math.floor(Math.random() * empties.length)];
next[r][c] = Math.random() < 0.9 ? 2 : 4;
return next;
}
function slideAndMergeRow(row) {
const compact = row.filter(v => v !== 0);
let scoreGained = 0;
const merged = [];
for (let i = 0; i < compact.length; i++) {
if (compact[i] === compact[i + 1]) {
const value = compact[i] * 2;
merged.push(value);
scoreGained += value;
i++;
} else {
merged.push(compact[i]);
}
}
while (merged.length < SIZE) merged.push(0);
return { row: merged, scoreGained };
}
export function moveLeft(grid) {
let scoreGained = 0;
const newGrid = grid.map(row => {
const { row: newRow, scoreGained: gained } = slideAndMergeRow(row);
scoreGained += gained;
return newRow;
});
const moved = JSON.stringify(newGrid) !== JSON.stringify(grid);
return { grid: newGrid, moved, scoreGained };
}
function rotateClockwise(grid) {
return grid[0].map((_, c) => grid.map(row => row[c]).reverse());
}
function rotateCounterClockwise(grid) {
return grid[0].map((_, c) => grid.map(row => row[row.length - 1 - c]));
}
export function move(grid, direction) {
if (direction === "left") return moveLeft(grid);
if (direction === "right") {
const rotated = grid.map(row => [...row].reverse());
const result = moveLeft(rotated);
return { ...result, grid: result.grid.map(row => [...row].reverse()) };
}
if (direction === "up") {
const rotated = rotateCounterClockwise(grid);
const result = moveLeft(rotated);
return { ...result, grid: rotateClockwise(result.grid) };
}
if (direction === "down") {
const rotated = rotateClockwise(grid);
const result = moveLeft(rotated);
return { ...result, grid: rotateCounterClockwise(result.grid) };
}
throw new Error(`Unknown direction: ${direction}`);
}
export function isGameOver(grid) {
const hasEmpty = grid.some(row => row.some(v => v === 0));
if (hasEmpty) return false;
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const v = grid[r][c];
if (c < SIZE - 1 && grid[r][c + 1] === v) return false;
if (r < SIZE - 1 && grid[r + 1][c] === v) return false;
}
}
return true;
}
- Step 4: 테스트 실행해서 통과 확인
Run: node --test assets/js/game2048.test.mjs
Expected: PASS (모든 테스트 통과)
- Step 5: 커밋
git add assets/js/game2048.js assets/js/game2048.test.mjs
git commit -m "feat: add pure 2048 game logic with tests"
Task 2: 게임 페이지 마크업 + 스타일
Files:
- Create:
game.html - Create:
assets/css/game.css - Modify:
_config.yml:23-30
Interfaces:
- Consumes: 없음 (마크업/스타일만, Task 1의 로직은 아직 연결하지 않음)
- Produces:
game.html이 기대하는 DOM 요소 ID들 — Task 3의game.js가 이 ID로 바인딩한다.#game-grid— 4x4 타일을 렌더링할 컨테이너#game-score— 현재 점수 표시#game-best— 이번 세션 로컬 최고점 표시(선택적 UI, 실제 랭킹은 아래)#game-over-msg— 게임오버 메시지 (기본display:none)#btn-restart— 다시 시작 버튼#my-rank— 내 순위/최고점수 표시 영역#leaderboard-list— TOP 10 리스트를 렌더링할 컨테이너 (<ol>또는<div>)
- Step 1:
_config.yml에 네비게이션 항목 추가
_config.yml:23-30을 다음으로 교체:
navigation:
# - title: 소개
# url: /index.html /about /contact
- title: 회칙
url: /rules
- title: 명예의 전당
url: /honor
- title: 콩툰
url: /toon
- title: 게임 일정
url: /board
- title: 2048
url: /game
# - title: 공식 이벤트
# url: /event
- Step 2:
assets/css/game.css작성
/* =========================================
game.css - 2048 게임 페이지
pages.css의 CSS 변수 재사용
========================================= */
.game-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 10px;
width: min(100%, 400px);
aspect-ratio: 1 / 1;
margin: 0 auto;
padding: 10px;
background: var(--input-border);
border-radius: var(--radius);
touch-action: none;
}
.game-tile {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-weight: 700;
font-size: 1.4rem;
background: var(--card-bg);
color: var(--text-primary);
}
.game-tile[data-value="0"] { background: rgba(255,255,255,0.4); }
.game-tile[data-value="2"] { background: #eee4da; }
.game-tile[data-value="4"] { background: #ede0c8; }
.game-tile[data-value="8"] { background: #f2b179; color: #fff; }
.game-tile[data-value="16"] { background: #f59563; color: #fff; }
.game-tile[data-value="32"] { background: #f67c5f; color: #fff; }
.game-tile[data-value="64"] { background: #f65e3b; color: #fff; }
.game-tile[data-value="128"] { background: #edcf72; color: #fff; font-size: 1.2rem; }
.game-tile[data-value="256"] { background: #edcc61; color: #fff; font-size: 1.2rem; }
.game-tile[data-value="512"] { background: #edc850; color: #fff; font-size: 1.2rem; }
.game-tile[data-value="1024"] { background: #edc53f; color: #fff; font-size: 1rem; }
.game-tile[data-value="2048"] { background: #edc22e; color: #fff; font-size: 1rem; }
.game-header {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 400px;
margin: 0 auto 16px;
}
.game-score-box {
background: var(--text-primary);
color: #fff;
border-radius: var(--radius-sm);
padding: 8px 16px;
text-align: center;
min-width: 80px;
}
.game-score-box .label { font-size: 0.7rem; opacity: 0.7; }
.game-score-box .value { font-size: 1.2rem; font-weight: 700; }
#game-over-msg {
text-align: center;
margin: 16px auto;
max-width: 400px;
color: var(--danger);
font-weight: 700;
}
.leaderboard-row {
display: flex;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--divider);
font-size: 0.9rem;
}
.leaderboard-row.is-me {
background: var(--accent-light);
font-weight: 700;
}
.leaderboard-rank {
color: var(--text-muted);
width: 28px;
display: inline-block;
}
- Step 3:
game.html작성
---
layout: default
title: 2048
permalink: /game/
---
<link rel="stylesheet" href="/assets/css/pages.css">
<link rel="stylesheet" href="/assets/css/game.css">
<div class="page-wrapper">
<div class="page-card page-card--wide">
<p class="page-card__title">2048</p>
<p style="color:var(--text-secondary);font-size:0.9rem;margin-bottom:24px">
화살표 키 또는 스와이프로 타일을 움직여 2048을 만들어보세요.
</p>
<div class="game-header">
<div id="my-rank" style="font-size:0.85rem;color:var(--text-secondary)"></div>
<div class="game-score-box">
<div class="label">SCORE</div>
<div class="value" id="game-score">0</div>
</div>
</div>
<div class="game-board" id="game-grid"></div>
<div id="game-over-msg" style="display:none">
게임 종료! <button id="btn-restart" class="btn-write" style="border:none;cursor:pointer">다시 시작</button>
</div>
<div style="text-align:center;margin-top:12px" id="restart-wrap">
<button id="btn-restart-top" class="btn-view">다시 시작</button>
</div>
<div class="section-divider" style="margin:32px 0"></div>
<p class="board-section-title">랭킹보드 TOP 10</p>
<div id="leaderboard-list">불러오는 중...</div>
</div>
</div>
<script type="module" src="/assets/js/game.js"></script>
- Step 4: 로컬에서 Jekyll 빌드해 페이지가 렌더링되는지 확인
Run: bundle exec jekyll build
Expected: 빌드 성공, _site/game/index.html 생성됨
- Step 5: 커밋
git add game.html assets/css/game.css _config.yml
git commit -m "feat: add game page markup and styles"
Task 3: 게임 컨트롤러 (game.js) — 인증가드, 입력, Firestore 연동
Files:
- Create:
assets/js/game.js - Modify: 없음 (읽기만)
assets/js/auth-guard.js,assets/js/firebase-init.js,assets/js/game2048.js
Interfaces:
- Consumes:
requireApproved(callback)from./auth-guard.js—callback(user, userData),userData.nickname사용dbfrom./firebase-init.jscreateEmptyGrid, addRandomTile, move, isGameOverfrom./game2048.js- Firestore:
doc, getDoc, setDoc, collection, getDocs, serverTimestampfrom firebasejs 10.12.2 firestore CDN - DOM IDs from Task 2:
#game-grid,#game-score,#game-over-msg,#btn-restart,#btn-restart-top,#my-rank,#leaderboard-list
- Produces: 없음 (최종 소비자, 페이지 전용 스크립트)
동작 요구사항:
- 비로그인/미승인 회원은
requireApproved가 자동으로/login/으로 리다이렉트 (구현 불필요, 그냥 호출만 하면 됨) - 게임 시작 시 빈 그리드에 타일 2개 추가
- 화살표 키(
ArrowLeft/Right/Up/Down) 및 터치 스와이프로move()호출 moved === true일 때만addRandomTile호출 및 점수 누적, 재렌더링isGameOver(grid)가 true면 게임오버 메시지 표시하고 최종 점수를submitScore로 제출submitScore(uid, nickname, score): 기존game_scores/{uid}문서의bestScore보다 score가 크면setDoc으로 갱신, 아니면 아무것도 안 함- 페이지 로드 시 TOP 10 랭킹 및 내 순위를 Firestore에서 읽어와 렌더링
-
“다시 시작” 버튼은 그리드 초기화 후 새 게임 시작
- Step 1:
assets/js/game.js작성
import { db } from "./firebase-init.js";
import { requireApproved } from "./auth-guard.js";
import { createEmptyGrid, addRandomTile, move, isGameOver } from "./game2048.js";
import {
doc, getDoc, setDoc, collection, getDocs, serverTimestamp
} from "https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js";
let grid = createEmptyGrid();
let score = 0;
let gameOver = false;
let currentUser = null;
let currentUserData = null;
requireApproved((user, userData) => {
currentUser = user;
currentUserData = userData;
startNewGame();
loadLeaderboard();
});
function startNewGame() {
grid = addRandomTile(addRandomTile(createEmptyGrid()));
score = 0;
gameOver = false;
document.getElementById("game-over-msg").style.display = "none";
render();
}
function render() {
const gridEl = document.getElementById("game-grid");
gridEl.innerHTML = "";
grid.flat().forEach(value => {
const tile = document.createElement("div");
tile.className = "game-tile";
tile.dataset.value = String(value);
tile.textContent = value === 0 ? "" : String(value);
gridEl.appendChild(tile);
});
document.getElementById("game-score").textContent = String(score);
}
function handleMove(direction) {
if (gameOver) return;
const result = move(grid, direction);
if (!result.moved) return;
grid = addRandomTile(result.grid);
score += result.scoreGained;
render();
if (isGameOver(grid)) {
gameOver = true;
document.getElementById("game-over-msg").style.display = "block";
submitScore(currentUser.uid, currentUserData.nickname, score)
.then(loadLeaderboard);
}
}
document.addEventListener("keydown", (e) => {
const map = {
ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down"
};
if (map[e.key]) {
e.preventDefault();
handleMove(map[e.key]);
}
});
let touchStartX = 0;
let touchStartY = 0;
document.getElementById("game-grid").addEventListener("touchstart", (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
document.getElementById("game-grid").addEventListener("touchend", (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;
if (Math.abs(dx) > Math.abs(dy)) {
handleMove(dx > 0 ? "right" : "left");
} else {
handleMove(dy > 0 ? "down" : "up");
}
});
document.getElementById("btn-restart").addEventListener("click", startNewGame);
document.getElementById("btn-restart-top").addEventListener("click", startNewGame);
async function submitScore(uid, nickname, finalScore) {
const ref = doc(db, "game_scores", uid);
const snap = await getDoc(ref);
const currentBest = snap.exists() ? (snap.data().bestScore || 0) : 0;
if (finalScore <= currentBest) return;
await setDoc(ref, {
nickname,
bestScore: finalScore,
updatedAt: serverTimestamp()
});
}
async function loadLeaderboard() {
const snap = await getDocs(collection(db, "game_scores"));
const entries = snap.docs
.map(d => ({ uid: d.id, ...d.data() }))
.sort((a, b) => (b.bestScore || 0) - (a.bestScore || 0));
const top10 = entries.slice(0, 10);
const listEl = document.getElementById("leaderboard-list");
listEl.innerHTML = top10.length
? top10.map((e, i) => `
<div class="leaderboard-row ${currentUser && e.uid === currentUser.uid ? "is-me" : ""}">
<span><span class="leaderboard-rank">${i + 1}</span>${e.nickname}</span>
<span>${e.bestScore}</span>
</div>`).join("")
: `<p class="text-muted small">아직 기록이 없습니다.</p>`;
const myRankEl = document.getElementById("my-rank");
if (!currentUser) { myRankEl.textContent = ""; return; }
const myIndex = entries.findIndex(e => e.uid === currentUser.uid);
myRankEl.textContent = myIndex === -1
? "아직 기록이 없습니다"
: `내 순위: ${myIndex + 1}위 (최고 ${entries[myIndex].bestScore}점)`;
}
- Step 2: 로컬 서버로 수동 테스트
Run: bundle exec jekyll serve
브라우저에서 http://localhost:4000/game/ 접속:
- 로그인 안 한 상태로 접속 →
/login/으로 리다이렉트되는지 확인 - 로그인(승인 회원)한 상태로 접속 → 게임 그리드가 타일 2개로 시작하는지 확인
- 화살표 키로 타일이 이동/병합되는지 확인
- 모바일 뷰(브라우저 개발자도구)에서 스와이프 동작 확인
- “다시 시작” 버튼 클릭 시 새 게임 시작 확인
- 게임오버까지 플레이(또는 그리드를 임시로 거의 채운 상태로 테스트)해서 점수 제출 및 랭킹보드 갱신 확인
Expected: 위 6가지 모두 정상 동작
- Step 3: 커밋
git add assets/js/game.js
git commit -m "feat: wire up 2048 game controller with auth guard and leaderboard"
Task 4: Firestore 보안 규칙 확인 및 반영
Files:
- 없음 (Firebase 콘솔 또는
firestore.rules— 저장소에 규칙 파일이 없으므로 콘솔에서 관리 중일 가능성이 높음)
Interfaces:
- Consumes: 없음
-
Produces: 없음 (인프라 설정 작업)
- Step 1: 현재 Firestore 보안 규칙 확인
Firebase 콘솔(https://console.firebase.google.com/project/jdk-member-board/firestore/rules)에서 현재 규칙을 확인한다. (firebase-init.js:8의 projectId 참고)
- Step 2:
game_scores규칙 추가
기존 규칙에 다음 블록이 없다면 추가한다 (기존 users/posts 규칙 스타일에 맞춰 조정):
match /game_scores/{uid} {
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == uid;
}
- Step 3: 콘솔에서 규칙 게시(Publish) 후 동작 확인
/game/에서 점수 제출이 실패하지 않는지, 다른 사용자의 game_scores 문서를 임의로 덮어쓸 수 없는지(브라우저 콘솔에서 다른 uid로 setDoc 시도 시 permission-denied 에러가 나는지) 확인한다.
- Step 4: 커밋 (규칙 변경을 저장소에도 기록하고 싶다면)
프로젝트에 firestore.rules 파일이 없으므로 이 프로젝트는 콘솔 관리 방식을 유지한다. 별도 커밋 불필요. 확인 완료 후 다음 태스크로 진행.
Self-Review Notes
- 스펙의 “로그인 안 하면 접근 불가”는 Task 3의
requireApproved호출로 충족됨. - 스펙의 “매니페스트 제한 없음, 최고점수만 갱신”은 Task 3의
submitScore(currentBest보다 클 때만 setDoc)로 충족됨. - 스펙의 “클라이언트가 계산, 그대로 Firestore 저장”은 서버 재검증 로직을 의도적으로 넣지 않음으로써 충족됨.
- 스펙의 “TOP 10 표시 + 본인 순위”는 Task 3의
loadLeaderboard에서top10렌더링과myRankEl갱신으로 충족됨. - 스펙의 “새 독립 페이지” 및 “네비게이션”은 Task 2에서
game.html+_config.yml수정으로 충족됨. - 스펙의 보안 규칙 섹션은 Task 4로 별도 분리(코드 변경이 아닌 인프라 설정이라 커밋 불가능한 작업이므로 분리했음).