// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4. // 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 없음, 슬롯 무제한. 여기서 다루는 desk는 // 순수 배열 [{ id, axis, grade }] 이며, React state는 이 배열을 그대로 들고 있다가 // 이 모듈의 함수로 파생값(축별 스택, 패널티)을 계산한다. React/훅 의존 없음 — 순수 함수만. import { AXES } from './symbols.js'; let seq = 0; // meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다. export const makeDeskCard = (axis, grade, meta = {}) => ({ id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`, axis, grade, name: meta.name || null, flavor: meta.flavor || null, tag: meta.tag || null, }); /** 축별 적재 장수. { axis_threat: 3, axis_hr: 1, ... } 형태로 항상 4축 전부를 채워 반환한다. */ export function countByAxis(deskCards) { const out = Object.fromEntries(AXES.map((a) => [a, 0])); deskCards.forEach((c) => { out[c.axis] = (out[c.axis] || 0) + 1; }); return out; } /** 축별 등급 분포. { axis_threat: {1: 2, 3: 1}, ... } — DeskBar의 축×등급 매트릭스 표시용. */ export function gradeBreakdownByAxis(deskCards) { const out = Object.fromEntries(AXES.map((a) => [a, {}])); deskCards.forEach((c) => { out[c.axis][c.grade] = (out[c.axis][c.grade] || 0) + 1; }); return out; } export const totalCount = (deskCards) => deskCards.length; /* ── A. 게이지 상시 상승 (§4.2-A, 플레이테스트 피드백 반영 재조정) ── * 최초 구현은 "장수"만 셌는데, 1급 기밀이나 일반 문서나 똑같이 취급돼 위기감이 없었다. * 그래서 장수 대신 등급 가중 "오염 압력"(P)을 쓴다: 등급1=1, 등급2=2, 등급3=3, 등급4=5. * 구간도 초반 체감이 되도록 올렸다 — 정확한 수치는 가안, 플레이테스트로 계속 조정한다. */ const GRADE_WEIGHT = { 1: 1, 2: 2, 3: 3, 4: 5 }; /** 해당 축의 오염 압력(P) = Σ 카드별 등급 가중치. */ export function axisPressure(deskCards, axis) { return deskCards .filter((c) => c.axis === axis) .reduce((sum, c) => sum + (GRADE_WEIGHT[c.grade] || 0), 0); } const GAUGE_TIERS = [ { max: 0, rate: 0 }, { max: 2, rate: 1.0 }, { max: 6, rate: 2.0 }, { max: 12, rate: 3.5 }, { max: 20, rate: 6.0 }, { max: 30, rate: 9.0 }, { max: Infinity, rate: 13.0 }, ]; /** 오염 압력(P)에 따른 결재 1건당 게이지 상승률(%). */ export function gaugeRiseRate(pressure) { const tier = GAUGE_TIERS.find((t) => pressure <= t.max); return tier ? tier.rate : 13.0; } /** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다. */ export function gaugeRiseRateByAxis(deskCards) { const out = {}; AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)); }); return out; } /** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */ export function applyPassiveGaugeRise(gauges, deskCards) { const next = { ...gauges }; AXES.forEach((axis) => { const gaugeKey = axisGaugeKey(axis); const rate = gaugeRiseRate(axisPressure(deskCards, axis)); next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate); }); return next; } // symbols.js의 AXIS_META를 다시 import하면 순환 우려가 없으므로 바로 가져와 쓴다. import { AXIS_META } from './symbols.js'; function axisGaugeKey(axis) { return AXIS_META[axis].gauge; } /* ── B. 토큰 지출 가산 (§4.2-B) — 총 적재 장수에 따라 모든 토큰 지출에 가산 ── */ const SURCHARGE_TIERS = [ { max: 7, surcharge: 0 }, { max: 14, surcharge: 1 }, { max: 21, surcharge: 2 }, { max: Infinity, surcharge: 4 }, ]; /** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액. */ export function tokenSurcharge(deskCards) { const total = totalCount(deskCards); const tier = SURCHARGE_TIERS.find((t) => total <= t.max); return tier ? tier.surcharge : 4; } /** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */ export function removeFromDesk(deskCards, idsToRemove) { const idSet = new Set(idsToRemove); return deskCards.filter((c) => !idSet.has(c.id)); } export function addToDesk(deskCards, newCards) { return [...deskCards, ...newCards]; }