// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사). // React 의존 없는 순수 함수 + 카탈로그. // // 2026-09-15 결정 반영: 코인 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3). // 2026-09-17 반영: // - 유형 D(이득 규약) 카탈로그 신설. 내부 명분("강제 상정")이 불리한 유형 A만 올릴 수 있어 // 실속이 없던 문제를 해결한다 — 이제 이득 규약을 직접 안건에 올릴 수 있다. // - 13석 표결을 실제 표 수로 구현. 혼합 명분이 "직접 통과 지정"(무조건 통과)이라는 // 자리표시자였는데, 이제 §7.3 스펙대로 **1표만 움직인다** — 1표 차 승부일 때만 뒤집힌다. // 임원 개개인의 우호도·이중첩자(§9.5~9.6)는 여전히 4차 범위이며, 그때 이 무작위 배분이 // "임원별 투표"로 교체된다. 표 집계 자체는 §9.5가 공개를 요구하므로 화면에 노출한다. // - 툴팁용 효과 설명(protocolEffectDetail)을 엔진에서 생성한다 — 규약이 정확히 무엇을 // 막는지 UI가 자체 문구로 추측하지 않도록, 카탈로그와 설명을 한곳에 둔다. import { HAND_DEFS } from './hands.js'; /* ── §9.3 실적 감사 ── */ // 요구치 곡선은 가안: 1라운드 25, 라운드당 +20 (§9.3). export const requiredScore = (round) => 25 + (round - 1) * 20; // 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 코인 납입×5 + 명분 납입×20. export const settleScore = ({ combineScore, coinsPaid, justificationsPaid }) => combineScore + coinsPaid * 5 + justificationsPaid * 20; /* ── §9.2 규약 카탈로그 ── * 유형 A(족보표 변경)·C(징벌)·D(이득)까지 구현. 유형 B(금리형)는 §20.2 콘텐츠 작업 대상. */ export const PROTOCOL_TYPE_A = [ { id: 'protocol_flush_ban', name: '동일 부서 일괄 처리 금지 규약', type: 'A', effect: { kind: 'disable_hand', patternId: 'flush_axis' }, flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.', }, { id: 'protocol_straight_ban', name: '연번 상신 금지 규약', type: 'A', effect: { kind: 'disable_hand', patternId: 'straight' }, flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.', }, { id: 'protocol_straightflush_ban', name: '전결 처리 금지 규약', type: 'A', effect: { kind: 'disable_hand', patternId: 'straight_flush' }, flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.', }, { id: 'protocol_fourkind_ban', name: '전면 개편 금지 규약', type: 'A', effect: { kind: 'disable_hand', patternId: 'four_kind' }, flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.', }, { id: 'protocol_fullhouse_ban', name: '종합 감사 제한 규약', type: 'A', effect: { kind: 'disable_hand', patternId: 'full_house' }, flavor: '3+2 형태의 묶음 상신은 감사 항목을 흐린다는 지적이 있었습니다.', }, { id: 'protocol_min_grade', name: '기밀 등급 심사 강화 규약', type: 'A', effect: { kind: 'min_grade', minGrade: 4 }, flavor: '등급 4 미만 문서만으로는 더 이상 합본 상신이 승인되지 않습니다.', }, { id: 'protocol_pair_ban', name: '중복 접수 금지 규약', type: 'A', effect: { kind: 'disable_hand', patternId: 'pair' }, flavor: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.', }, ]; export const PROTOCOL_TYPE_C = [ { id: 'penalty_gauge_accel', name: '미결 서류 관리 규정', type: 'C', effect: { kind: 'gauge_surcharge', amount: 1.0 }, flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.', }, { id: 'penalty_coin_surcharge', name: '긴급 재정 감사 대응', type: 'C', effect: { kind: 'coin_surcharge', amount: 2 }, flavor: '감사 대응 인력 편성 비용이 모든 코인 지출에 얹힙니다.', }, ]; // 유형 D — 이득 규약. §9.2 "드물게 통과"이므로 후보 등장 확률을 낮게 잡는다(D_CANDIDATE_CHANCE). // 자산 지급형은 자산 시스템(§10, 3차)이 없어 제외했고, 코인·족보 완화·한도 완화로 구성했다. export const PROTOCOL_TYPE_D = [ { id: 'boon_budget_grant', name: '예비비 긴급 편성', type: 'D', effect: { kind: 'grant_coin', amount: 12 }, flavor: '이월 예산 일부를 국장 재량으로 집행할 수 있도록 의결되었습니다.', }, { id: 'boon_workload_relief', name: '업무 경감 조치', type: 'D', effect: { kind: 'gauge_surcharge', amount: -1.0 }, flavor: '과중 업무에 대한 한시적 완화 조치가 승인되었습니다.', }, { id: 'boon_admin_simplify', name: '행정 간소화 지침', type: 'D', effect: { kind: 'coin_surcharge', amount: -2 }, flavor: '중복 결재 단계를 축소해 집행 비용을 낮춥니다.', }, { id: 'boon_cap_extension', name: '임시 결재 권한 확대', type: 'D', effect: { kind: 'attention_cap_bonus', amount: 8 }, flavor: '한시적으로 국장 전결 범위가 확대되었습니다. 더 오래 끌고 갈 수 있습니다.', }, { id: 'boon_audit_relief', name: '실적 기준 완화', type: 'D', effect: { kind: 'required_score_delta', amount: -20 }, flavor: '이번 회기의 실적 요구 기준이 하향 조정되었습니다.', }, ]; /** 후보로 올라올 수 있는 전체 규약 (유형 A + D). 징벌(C)은 감사 실패로만 부과된다. */ export const TABLEABLE_PROTOCOLS = [...PROTOCOL_TYPE_A, ...PROTOCOL_TYPE_D]; const D_CANDIDATE_CHANCE = 0.3; // §9.2 "드물게 통과" — 후보 3개 중 1개가 D일 확률 const pick = (arr, n) => { const pool = [...arr]; const out = []; while (out.length < n && pool.length > 0) { out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); } return out; }; /** * 매 라운드 규약 후보 3개를 뽑는다. * carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3). * 이득 규약(D)은 낮은 확률로 한 자리만 차지한다. */ export const rollProtocolCandidates = (carriedOver = []) => { const carriedIds = new Set(carriedOver.map((c) => c.id)); const slots = Math.max(0, 3 - carriedOver.length); const out = [...carriedOver]; if (slots > 0 && Math.random() < D_CANDIDATE_CHANCE) { const boon = pick(PROTOCOL_TYPE_D.filter((p) => !carriedIds.has(p.id)), 1); out.push(...boon); } const need = Math.max(0, 3 - out.length); const taken = new Set(out.map((c) => c.id)); out.push(...pick(PROTOCOL_TYPE_A.filter((p) => !taken.has(p.id)), need)); return out; }; export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0]; /** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상. 이득 규약(D)이 여기 포함되는 것이 * 내부 명분의 존재 이유다(§7.3: "원하는 이득 규약을 올릴 때 사용"). */ export const availableForTabling = (candidates) => { const onBoard = new Set(candidates.map((c) => c.id)); return TABLEABLE_PROTOCOLS.filter((p) => !onBoard.has(p.id)); }; /* ── §9.2 13석 표결 ── */ export const COUNCIL_SEATS = 13; // 같은 후보 조합이면 항상 같은 표가 나오도록 하는 결정적 해시. 명분으로 안건을 만질 때마다 // 표가 무작위로 흔들리면 플레이어가 개입 결과를 판단할 수 없다. const hashStr = (s) => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }; /** * 후보 목록에 13석을 배분한다 (최대잔여법). 결정적이므로 같은 조합 = 같은 표. * @returns {Array<{...candidate, votes:number}>} */ export function tallyVotes(candidates) { if (candidates.length === 0) return []; const key = candidates.map((c) => c.id).join('|'); const weights = candidates.map((c) => 1 + (hashStr(`${key}#${c.id}`) % 100) / 100); const total = weights.reduce((a, b) => a + b, 0); const raw = weights.map((w) => (w / total) * COUNCIL_SEATS); const seats = raw.map(Math.floor); const remainder = COUNCIL_SEATS - seats.reduce((a, b) => a + b, 0); const byFrac = raw .map((r, i) => ({ i, frac: r - Math.floor(r) })) .sort((a, b) => b.frac - a.frac); for (let k = 0; k < remainder; k++) seats[byFrac[k % byFrac.length].i]++; return candidates.map((c, i) => ({ ...c, votes: seats[i] })); } /** * 혼합 명분 — §7.3 "표결 결과 1표 뒤집기". 지정 후보에 1표를 더하고 선두에서 1표를 뺀다. * 1표 차 승부가 아니면 결과가 바뀌지 않는다 — 그게 이 명분의 한계이자 판단 지점이다. * * baseVotes(스윙 전 득표)를 남기는 이유: +1/−1은 표 차를 2 좁히므로 2표 차가 동점이 된다. * 동점을 상정 순서로 가르면 배열 위치에 따라 2표 차가 뒤집히기도 해서 일관성이 없다. * 그래서 동점은 "원래 표가 많던 쪽"이 이기고, 결과적으로 1표 차에서만 뒤집힌다. */ export function applyVoteSwing(tallied, targetId) { const withBase = tallied.map((c) => ({ ...c, baseVotes: c.votes })); if (!targetId || withBase.length === 0) return withBase; const leader = [...withBase] .sort((a, b) => b.votes - a.votes) .find((c) => c.id !== targetId); if (!leader) return withBase; return withBase.map((c) => { if (c.id === targetId) return { ...c, votes: c.votes + 1 }; if (c.id === leader.id) return { ...c, votes: Math.max(0, c.votes - 1) }; return c; }); } /** 최다 득표 후보가 통과한다. 동점이면 스윙 전 득표가 많던 쪽, 그것도 같으면 먼저 상정된 쪽. */ export function resolveProtocolVote(tallied) { if (!tallied || tallied.length === 0) return null; return tallied.reduce((best, c) => { if (c.votes !== best.votes) return c.votes > best.votes ? c : best; const cBase = c.baseVotes ?? c.votes; const bestBase = best.baseVotes ?? best.votes; return cBase > bestBase ? c : best; }, tallied[0]); } /* ── §7.3 ① 안건 조작 — 명분 4종 + 혼합 ── * ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회의 개별 임원(§9.4~9.6, 4차)이 있어야 대상이 존재한다. * ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다. */ export const JUSTIFICATION_INTERVENTIONS = { 절차: { label: '후보 1개 다음 라운드로 이월', target: 'candidate', detail: '지금 표결에서 빼지만 다음 라운드 후보로 다시 올라온다. 당장의 위험만 미루는 것.', }, 여론: { label: '후보 1개 강제 탈락 + 새 후보로 교체', target: 'candidate', detail: '지정한 후보를 완전히 없애고 빈자리를 새 후보로 채운다. 새 후보가 더 나쁠 수도 있다.', }, 위기: { label: '누적 징벌 규약 1개 조기 해제', target: 'penalty', detail: '영구 누적되는 징벌 규약(유형 C) 하나를 즉시 없앤다.', }, 내부: { label: '규약 1개 강제 상정', target: 'pool', detail: '후보에 없던 규약을 안건에 올린다. 이득 규약을 올려 통과를 노리는 용도.', }, 혼합: { label: '표결 1표 이동 (지정 후보 +1, 선두 −1)', target: 'candidate', detail: '1표 차 승부일 때만 결과가 뒤집힌다. 표 차가 2 이상이면 소용없다.', }, }; // 명분 종류별 출처(어느 축을 합본해야 나오는지)와 용도 — 툴팁용 (§7.1~7.3). const JUSTIFICATION_SOURCE = { 절차: '대외 축(도장)만으로 합본할 때 산출', 여론: '공보 축(확성기)만으로 합본할 때 산출', 위기: '위협 축(소용돌이)만으로 합본할 때 산출', 내부: '인사 축(손)만으로 합본할 때 산출', 혼합: '축이 섞인 합본에서 산출', }; /** @returns {{ summary, detail }} 명분 1종의 출처·용도 설명. */ export function justificationDetail(type) { const iv = JUSTIFICATION_INTERVENTIONS[type]; return { summary: iv ? iv.label : '용도 없음', detail: [ JUSTIFICATION_SOURCE[type] || '', iv ? iv.detail : '', '실적 납입으로 돌리면 1개당 실적 20점. 쓰지 않은 명분은 이번 의회가 끝나면 소멸한다.', '1개 미만(예: 0.5)은 사용할 수 없다 — 같은 종류를 더 모아야 한다.', ].filter(Boolean).join(' · '), }; } /** * 명분 개입을 규약 후보에 반영한다 (§7.3 ①). * interventions: [{ type, targetCandidateId?, targetPenaltyId?, targetPoolProtocolId? }] * @returns {{ candidates, voteSwingTargetId, deferred, penaltyIdToClear }} */ export function applyInterventions(candidates, interventions) { let pool = [...candidates]; let voteSwingTargetId = null; const deferred = []; let penaltyIdToClear = null; interventions.forEach((iv) => { if (iv.type === '절차' && iv.targetCandidateId) { const target = pool.find((c) => c.id === iv.targetCandidateId); if (target) { deferred.push(target); pool = pool.filter((c) => c.id !== iv.targetCandidateId); } } if (iv.type === '여론' && iv.targetCandidateId) { pool = pool.filter((c) => c.id !== iv.targetCandidateId); // 교체 후보는 개입을 지정하는 시점(UI)에서 미리 뽑아 replacementId로 실어 보낸다. // 여기서 무작위로 뽑으면 의회 화면의 표 미리보기와 확정 결과가 어긋난다. const replacement = iv.replacementId ? TABLEABLE_PROTOCOLS.find((p) => p.id === iv.replacementId) : pick(availableForTabling(pool), 1)[0]; if (replacement && !pool.some((c) => c.id === replacement.id)) pool = [...pool, replacement]; } if (iv.type === '내부' && iv.targetPoolProtocolId) { const tabled = TABLEABLE_PROTOCOLS.find((p) => p.id === iv.targetPoolProtocolId); if (tabled && !pool.some((c) => c.id === tabled.id)) pool = [...pool, tabled]; } if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId; if (iv.type === '혼합' && iv.targetCandidateId) voteSwingTargetId = iv.targetCandidateId; }); return { candidates: pool, voteSwingTargetId, deferred, penaltyIdToClear }; } /* ── 규약 효과 설명 (툴팁용) ── * "무엇이 막혔는지 정확히" 알려주려면 족보 이름·조건까지 짚어야 한다. UI가 문구를 자체 * 추측하지 않도록 카탈로그 옆에서 같이 생성한다. */ const TYPE_LABEL = { A: '족보 제한', C: '징벌', D: '이득' }; /** @returns {{ typeLabel, summary, detail, tone }} tone: 'bad' | 'good' | 'neutral' */ export function protocolEffectDetail(protocol) { const e = protocol.effect; const typeLabel = TYPE_LABEL[protocol.type] || '규약'; switch (e.kind) { case 'disable_hand': { const def = HAND_DEFS[e.patternId]; return { typeLabel, summary: `「${def.name}」 족보 사용 불가`, detail: `조건 「${def.condition}」. 이 족보는 합본 판정에서 제외되어, 해당 조합을 골라도 성립하지 않습니다 (실적 ${def.score} · 명분 ${def.baseJustification} 손실).`, tone: 'bad', }; } case 'min_grade': return { typeLabel, summary: `등급 ${e.minGrade} 미만 카드 합본 불가`, detail: `선택한 카드 중 등급 ${e.minGrade} 미만이 하나라도 있으면 어떤 족보도 성립하지 않습니다. 저등급 카드는 상점에서 직접 처리해야 합니다.`, tone: 'bad', }; case 'gauge_surcharge': return { typeLabel, summary: `결재당 게이지 ${e.amount > 0 ? '+' : ''}${e.amount.toFixed(1)}%p`, detail: e.amount > 0 ? `결재 1건마다 4대 게이지 전부가 추가로 ${e.amount.toFixed(1)}%p씩 더 오릅니다. 주목도와 무관하게 항상 붙습니다.` : `결재 1건마다 4대 게이지 상승분이 ${Math.abs(e.amount).toFixed(1)}%p씩 줄어듭니다.`, tone: e.amount > 0 ? 'bad' : 'good', }; case 'coin_surcharge': return { typeLabel, summary: `코인 지출 가산 ${e.amount > 0 ? '+' : ''}${e.amount}`, detail: e.amount > 0 ? `상점 소각·리롤 등 모든 코인 지출에 ${e.amount}이(가) 추가로 붙습니다.` : `모든 코인 지출 가산이 ${Math.abs(e.amount)} 줄어듭니다 (최소 0).`, tone: e.amount > 0 ? 'bad' : 'good', }; case 'grant_coin': return { typeLabel, summary: `통과 즉시 코인 +${e.amount}`, detail: `표결 통과 시점에 코인 ${e.amount}을(를) 한 번 지급합니다. 지속 효과는 없습니다.`, tone: 'good', }; case 'attention_cap_bonus': return { typeLabel, summary: `주목도 한도 +${e.amount}`, detail: `다음 라운드의 주목도 한도가 ${e.amount} 늘어납니다. 버스트 전까지 카드를 더 받을 수 있습니다.`, tone: 'good', }; case 'required_score_delta': return { typeLabel, summary: `실적 요구치 ${e.amount > 0 ? '+' : ''}${e.amount}`, detail: `다음 의회의 실적 요구치가 ${Math.abs(e.amount)} ${e.amount < 0 ? '낮아집니다' : '높아집니다'}.`, tone: e.amount < 0 ? 'good' : 'bad', }; default: return { typeLabel, summary: '효과 미정', detail: '', tone: 'neutral' }; } } /* ── 활성 규약 효과 합산 ── * 징벌(C)과 활성 규약(A/D)이 같은 종류의 수치를 건드리므로, 호출부에서 둘을 합친 배열을 넘긴다. */ /** 결재당 게이지 상승에 더해지는 가산분 합계 (이득 규약이면 음수). */ export function gaugeSurchargeOf(protocols) { return protocols.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0); } /** 모든 코인 지출에 더해지는 가산분 합계 (이득 규약이면 음수). */ export function coinSurchargeOf(protocols) { return protocols.reduce((sum, p) => sum + (p.effect.kind === 'coin_surcharge' ? p.effect.amount : 0), 0); } /** 이번 라운드 주목도 한도에 더해지는 보너스. */ export function attentionCapBonusOf(protocols) { return protocols.reduce((sum, p) => sum + (p.effect.kind === 'attention_cap_bonus' ? p.effect.amount : 0), 0); } /** 실적 요구치에 더해지는 증감분. */ export function requiredScoreDeltaOf(protocols) { return protocols.reduce((sum, p) => sum + (p.effect.kind === 'required_score_delta' ? p.effect.amount : 0), 0); } /** 통과 즉시 1회 지급되는 코인. */ export function grantCoinOf(protocol) { return protocol && protocol.effect.kind === 'grant_coin' ? protocol.effect.amount : 0; } /* ── 합본 판정에 규약 효과를 얹는다 ── */ /** 활성 규약을 반영해 detectHands 결과를 거른다. */ export function filterHandsByProtocols(matches, selection, activeProtocols) { let allowed = matches; activeProtocols.forEach((p) => { if (p.effect.kind === 'disable_hand') { allowed = allowed.filter((id) => id !== p.effect.patternId); } if (p.effect.kind === 'min_grade') { const violatesMinGrade = selection.some((c) => c.grade < p.effect.minGrade); if (violatesMinGrade) allowed = []; } }); return allowed; }