Files
ProjectSS/client/src/core/engine/council.js
T
kyoung5seoandClaude Sonnet 5 20207ff23c 라운드 접수량 버그 수정 + 토큰→코인 용어 통일 + 외부 배포(NAS)
플레이 피드백 4건(주목도 미차감, 상점 빈 상품, 상점 UX 오해, 재화 명칭)을
검토해 방향이 확정된 항목만 반영하고, 외부 테스터에게 링크를 공유할 수 있게
시놀로지 NAS 배포까지 준비했다.

## 게임 로직

- 라운드 접수량 크리티컬 버그 수정: 합본을 반복하면 라운드가 안 끝나던 문제.
  deskCards에서 파생시키던 값을 순수 누적 카운터(roundIntake)로 교체 — 결정
  노트: 2026-09-18-라운드-접수량-누적카운터
- DeskBar/CoreSwipeCard 정보 역할 분리: "이번 라운드 접수량/한도"는 결재
  화면 전담, DeskBar는 책상에 쌓인 것(총 주목도·축별 분포)만 표시
- 토큰 → 코인 용어 일괄 변경: core/ 전체(식별자·CSS·주석·UI)와 기획서 SSOT
  동기화, lucide Coins 아이콘 추가

## 배포

- vite base를 상대 경로로 변경 (Web Station 서브패스 서빙 시 asset 404 방지)
- 모바일 100vh → 100dvh (주소창 노출 시 하단 버튼 가림 방지)
- 빌드 식별자 표시, 타이틀/테마색/description 정리
- deploy.sh + npm run deploy — SSH 키 권한 문제(777 → StrictModes 거부)와
  NAS rsync 게이트를 scp -O로 우회해 실제 배포까지 검증 완료
  → https://play.mint-grape.duckdns.org/projectss/

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:20:22 +09:00

457 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 의회 엔진 — 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;
}