// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4. // 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 개념 없음, 슬롯 무제한. 여기서 다루는 desk는 // 순수 배열 [{ id, axis, grade, name, flavor, tag }]이며, React state는 이 배열을 그대로 들고 // 있다가 이 모듈의 함수로 파생값(주목도, 페널티)을 계산한다. React/훅 의존 없음 — 순수 함수만. // // 2026-09-15 결정 반영: 페널티 기준을 "카드 장수" → "등급 합계"로 전환하고 이를 주목도라 부른다. // 등급 편차가 1~10으로 커진 만큼, 장수만 세면 특급 기밀과 일반 문서가 똑같이 취급돼 압박이 // 정직하지 않다. 등급 자체가 곧 가중치이므로 별도 가중치 테이블은 없다. // // 이월(§4.1): 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남는다. 별도 이월 시스템이 // 없는 이유가 바로 이것 — "합본이 닿은 카드만 사라진다"는 규칙 하나로 자연히 성립한다. import { AXES, AXIS_META, clampGrade } 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: clampGrade(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: {7: 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; /* ── 주목도 (§4.2) — 페널티 산정의 단일 기준 ── */ /** 해당 축의 주목도 = 그 축 카드들의 등급 합. */ export function axisAttention(deskCards, axis) { return deskCards .filter((c) => c.axis === axis) .reduce((sum, c) => sum + c.grade, 0); } /** 책상 전체 주목도 = 모든 카드의 등급 합. */ export function totalAttention(deskCards) { return deskCards.reduce((sum, c) => sum + c.grade, 0); } export function attentionByAxis(deskCards) { const out = {}; AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); }); return out; } /* ── A. 게이지 상시 상승 (§4.2-A) — 축별 주목도 기준 누진 ── */ const GAUGE_TIERS = [ { max: 0, rate: 0 }, { max: 15, rate: 0.5 }, { max: 30, rate: 1.5 }, { max: 40, rate: 3.0 }, ]; const GAUGE_OVERRUN_FROM = 41; const GAUGE_OVERRUN_BASE = 5.0; /** 축 주목도에 따른 결재 1건당 게이지 상승률(%). 41 이상은 초과분 10당 +1.0% 추가 누진. */ export function gaugeRiseRate(attention) { const tier = GAUGE_TIERS.find((t) => attention <= t.max); if (tier) return tier.rate; const overrun = attention - GAUGE_OVERRUN_FROM; return GAUGE_OVERRUN_BASE + Math.floor(overrun / 10) * 1.0; } /** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다. * extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */ export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) { const out = {}; AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat; }); return out; } /** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */ export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) { const next = { ...gauges }; AXES.forEach((axis) => { const gaugeKey = AXIS_META[axis].gauge; const rate = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat; next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate); }); return next; } /* ── B. 라운드 종료 판단 (§4.2-B, 2026-09-16 결정 재수정) — 블랙잭식 하드 캡. * 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월). * 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다. * 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 — * "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원. */ const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀 const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안 /** 라운드 번호에 따른 이번 라운드의 주목도 한도. */ export function roundAttentionCap(round) { return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH; } /** 한도 대비 총 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */ export function attentionZone(deskCards, cap) { const total = totalAttention(deskCards); if (total > cap) return 'bust'; if (total >= cap - 5) return 'warning'; return 'normal'; } /* ── C. 토큰 지출 가산 (§4.2-C) — 총 주목도 기준 ── */ const SURCHARGE_TIERS = [ { max: 20, surcharge: 0 }, { max: 40, surcharge: 1 }, { max: 60, surcharge: 2 }, { max: Infinity, surcharge: 4 }, ]; /** 총 주목도 기준 모든 토큰 지출에 붙는 가산액. * extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyTokenSurcharge). */ export function tokenSurcharge(deskCards, extraFlat = 0) { const total = totalAttention(deskCards); const tier = SURCHARGE_TIERS.find((t) => total <= t.max); return (tier ? tier.surcharge : 4) + extraFlat; } /** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */ 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]; }