diff --git a/tools/map/rogue-map.mjs b/tools/map/rogue-map.mjs new file mode 100644 index 0000000..ece0b88 --- /dev/null +++ b/tools/map/rogue-map.mjs @@ -0,0 +1,84 @@ +// 로그라이크 맵 절차 생성 — StS식 경로 걷기. +// ⚠️ 전투 규칙과 마찬가지로 tools/deck/gen-slaydeck.mjs 의 Lua(GenerateMap)와 로직 동기화 유지할 것. +// (Lua는 math.random, 여기는 주입 rng — 수치 동일성이 아니라 구조 규칙 동일성이 대상) + +export const ROWS = 7; // 걷는 행 1..7, 보스는 row 8 +export const COLS = 4; +export const PATHS = 4; + +// 행별 타입 가중치 (설계 문서 표와 동일) +function rowWeights(row) { + if (row === ROWS) { + return [['rest', 50], ['combat', 25], ['shop', 10], ['elite', 8], ['treasure', 7]]; + } + if (row >= 4) { + return [['combat', 45], ['elite', 16], ['shop', 12], ['rest', 12], ['treasure', 15]]; + } + return [['combat', 45], ['shop', 12], ['rest', 12]]; +} + +// rng: () => [0,1) 균등 난수 (mulberry32 등) +export function generateMap(rng) { + const randInt = (lo, hi) => lo + Math.floor(rng() * (hi - lo + 1)); // [lo,hi] + const nodes = {}; + const start = []; + const nodeId = (r, c) => `r${r}c${c}`; + + const ensureNode = (r, c) => { + const id = nodeId(r, c); + if (!nodes[id]) nodes[id] = { type: 'combat', row: r, col: c, next: [] }; + return id; + }; + const addNext = (id, nid) => { + if (!nodes[id].next.includes(nid)) nodes[id].next.push(nid); + }; + + nodes.boss = { type: 'boss', row: ROWS + 1, col: 0, next: [] }; + + // 시작열: 셔플 앞 2개(상이 보장) + 랜덤 2개 + const cols = [1, 2, 3, 4]; + for (let i = cols.length - 1; i >= 1; i--) { + const j = randInt(0, i); + [cols[i], cols[j]] = [cols[j], cols[i]]; + } + const starts = [cols[0], cols[1], randInt(1, COLS), randInt(1, COLS)]; + + for (let p = 0; p < PATHS; p++) { + let c = starts[p]; + const sid = ensureNode(1, c); + if (!start.includes(sid)) start.push(sid); + for (let r = 1; r < ROWS; r++) { + let nc = c + randInt(-1, 1); + if (nc < 1) nc = 1; + if (nc > COLS) nc = COLS; + ensureNode(r + 1, nc); + addNext(nodeId(r, c), nodeId(r + 1, nc)); + c = nc; + } + addNext(nodeId(ROWS, c), 'boss'); + } + + // 타입 배정 — 행 오름차순 (1~2행은 combat 고정) + for (let r = 3; r <= ROWS; r++) { + for (let c = 1; c <= COLS; c++) { + const id = nodeId(r, c); + const node = nodes[id]; + if (!node) continue; + // elite 부모 검사 (연속 엘리트 방지) + let eliteParent = false; + for (const pn of Object.values(nodes)) { + if (pn.row === r - 1 && pn.type === 'elite' && pn.next.includes(id)) eliteParent = true; + } + const w = rowWeights(r).map(([t, wt]) => [t, t === 'elite' && eliteParent ? 0 : wt]); + const total = w.reduce((s, [, wt]) => s + wt, 0); + const roll = rng() * total; + let acc = 0; + for (const [t, wt] of w) { + acc += wt; + if (roll <= acc) { node.type = t; break; } + } + } + } + + return { nodes, start }; +} diff --git a/tools/map/rogue-map.test.mjs b/tools/map/rogue-map.test.mjs new file mode 100644 index 0000000..708cf47 --- /dev/null +++ b/tools/map/rogue-map.test.mjs @@ -0,0 +1,120 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateMap, ROWS, COLS } from './rogue-map.mjs'; +import { mulberry32 } from '../balance/sim-balance.mjs'; + +function gen(seed) { + return generateMap(mulberry32(seed)); +} + +test('결정성: 동일 시드 동일 맵', () => { + assert.deepEqual(gen(1), gen(1)); + assert.deepEqual(gen(42), gen(42)); +}); + +test('시작 노드 2개 이상 (경로 1·2 시작열 상이)', () => { + for (let s = 1; s <= 30; s++) { + const { start } = gen(s); + assert.ok(start.length >= 2, `seed ${s}: start ${start.length}개`); + assert.equal(new Set(start).size, start.length); + } +}); + +test('연결성: 모든 노드가 시작점에서 도달 가능하고 boss에 도달함', () => { + for (let s = 1; s <= 30; s++) { + const { nodes, start } = gen(s); + // 시작점에서 전방 BFS + const fwd = new Set(start); + const queue = [...start]; + while (queue.length) { + const id = queue.shift(); + for (const nid of nodes[id].next) { + if (!fwd.has(nid)) { fwd.add(nid); queue.push(nid); } + } + } + for (const id of Object.keys(nodes)) { + assert.ok(fwd.has(id), `seed ${s}: ${id} 시작점에서 도달 불가`); + } + // boss에서 역방향 BFS + const back = new Set(['boss']); + let changed = true; + while (changed) { + changed = false; + for (const [id, n] of Object.entries(nodes)) { + if (!back.has(id) && n.next.some((x) => back.has(x))) { back.add(id); changed = true; } + } + } + for (const id of Object.keys(nodes)) { + assert.ok(back.has(id), `seed ${s}: ${id}에서 boss 도달 불가`); + } + } +}); + +test('타입 규칙: 1~2행 combat만, elite·treasure는 4행부터, shop·rest는 3행부터', () => { + for (let s = 1; s <= 50; s++) { + const { nodes } = gen(s); + for (const [id, n] of Object.entries(nodes)) { + if (n.row <= 2) assert.equal(n.type, 'combat', `seed ${s}: ${id} (row ${n.row}) = ${n.type}`); + if (n.type === 'elite' || n.type === 'treasure') assert.ok(n.row >= 4, `seed ${s}: ${id} ${n.type} row ${n.row}`); + if (n.type === 'shop' || n.type === 'rest') assert.ok(n.row >= 3, `seed ${s}: ${id} ${n.type} row ${n.row}`); + } + } +}); + +test('boss: row 8 단일 노드, 7행 노드는 전부 boss로 연결', () => { + for (let s = 1; s <= 30; s++) { + const { nodes } = gen(s); + const bosses = Object.entries(nodes).filter(([, n]) => n.type === 'boss'); + assert.equal(bosses.length, 1, `seed ${s}`); + assert.equal(bosses[0][0], 'boss'); + assert.equal(bosses[0][1].row, ROWS + 1); + for (const [id, n] of Object.entries(nodes)) { + if (n.row === ROWS) { + assert.deepEqual(n.next, ['boss'], `seed ${s}: ${id}`); + } + } + } +}); + +test('간선 제약: row+1로만, 열 차이 1 이하 (boss 간선 제외)', () => { + for (let s = 1; s <= 30; s++) { + const { nodes } = gen(s); + for (const [id, n] of Object.entries(nodes)) { + for (const nid of n.next) { + if (nid === 'boss') continue; + const t = nodes[nid]; + assert.equal(t.row, n.row + 1, `seed ${s}: ${id}→${nid}`); + assert.ok(Math.abs(t.col - n.col) <= 1, `seed ${s}: ${id}→${nid} 열 차이 ${Math.abs(t.col - n.col)}`); + } + } + } +}); + +test('elite 연속 금지: elite 부모를 가진 노드는 elite 아님', () => { + for (let s = 1; s <= 100; s++) { + const { nodes } = gen(s); + for (const [id, n] of Object.entries(nodes)) { + if (n.type !== 'elite') continue; + for (const nid of n.next) { + assert.notEqual(nodes[nid].type, 'elite', `seed ${s}: ${id}(elite) → ${nid}(elite)`); + } + } + } +}); + +test('그리드 범위: 행 1..8, 열 1..4 (boss 제외)', () => { + const { nodes } = gen(7); + for (const [id, n] of Object.entries(nodes)) { + if (id === 'boss') continue; + assert.ok(n.row >= 1 && n.row <= ROWS); + assert.ok(n.col >= 1 && n.col <= COLS); + assert.equal(id, `r${n.row}c${n.col}`); + } +}); + +test('다양성: 서로 다른 시드는 (대체로) 다른 맵', () => { + const a = JSON.stringify(gen(1)); + let diff = 0; + for (let s = 2; s <= 11; s++) if (JSON.stringify(gen(s)) !== a) diff++; + assert.ok(diff >= 9, `10개 중 ${diff}개만 상이`); +});