Files
ProjectSS/client/src/core/engine/council.js
T
kyoung5seo cde44c4f2c 2차 구현 완료 (§17: 상점·규약·실적 감사·명분) - 플레이는 해봤지만 피드백은 아직 정리 안함
라운드 구조(결재 8건 → 상점 → 의회 → 다음 라운드)를 얹고, 신규 파일들로 구현했습니다. Playwright로 라운드 전체를 실제로 돌려 확인 완료(콘솔 에러 0건).

만든 것
엔진 (core/engine/)

council.js — §7 명분 산출/개입, §9.2 규약(유형A 5종 카탈로그 + 유형C 징벌 2종), §9.3 실적 요구치 공식
shop.js — §8.3 상점 진열/가격/리롤
UI

ShopScreen — 오염 카드 직접 소각(토큰), 진열 4~6개 무작위, 리롤 1회
CouncilScreen — 실적 감사(토큰·명분 납입으로 요구치 충족) + 규약 후보 3개 + 명분 개입(절차/여론/위기/내부/혼합)
DeskBar/CombinePanel — 징벌 규약의 게이지·토큰 가산 반영, 활성 규약이 막은 족보 표시
라운드 루프: CoreGameScreen이 결재 8건마다 자동으로 상점→의회로 전환하고, 의회 결의 시 실적 판정→징벌 부과/해제→규약 표결→다음 라운드로 넘어갑니다. 4대 게이지 100% 도달 시 게임오버 화면도 추가했습니다(엔딩 분기는 4차라 텍스트만).

검증 (Playwright)
8회 스와이프 → 상점 화면 전환 → 의회 화면(요구실적 25, 후보 3개) → 결의 확정 → 2라운드 진입, 상단에 ⚖ 통과된 규약명 표시 → 합본 패널 열면 해당 규약이 칩으로 뜨고 filterHandsByProtocols가 막는 족보를 실제로 걸러냄. 스크린샷 다수 확인, 콘솔 에러 0건. 상점 아이템 버튼 텍스트가 화면 밖으로 살짝 잘리는 버그도 발견해서 즉시 수정했습니다.

의도적으로 축소한 부분 (문서에 근거 있음)
상점은 "오염 카드 직접 처리" 한 품목뿐 — 자산 구매(§10)·소모품(§12)·전례 매입(§11)은 전부 3차 항목이라 카탈로그 자체가 없습니다. §17 로드맵 순서를 지키기 위한 의도적 축소입니다.
규약 표결은 가중 무작위 — 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6)은 4차 범위. 지금은 후보 3개 중 하나를 무작위로 "통과"시킵니다.
혼합 명분("표 뒤집기")은 문자 그대로 구현할 표가 없어서, "후보 하나를 직접 통과 지정"으로 대체했습니다 — 최종 효과(원하는 규약 통과)는 같지만 4차에서 실제 위원회가 생기면 반드시 재설계해야 할 자리표시자로 코드에 표시해 뒀습니다.
§7.3 이념색 페널티(파벌 우호도 하락)는 스킵 — 8대 상임이사회 우호도 추적 자체가 4차 항목이라 지금은 걸 게 없습니다.
라운드당 결재 수 8건, 상점/징벌 가격 수치는 전부 가안 — 플레이해보고 조정 대상입니다.
2026-09-05 01:39:55 +09:00

150 lines
6.8 KiB
JavaScript

// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
// 뽑는 절차는 "가중 무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
/* ── §9.3 실적 감사 ── */
// 요구치 곡선은 가안: 1R 25, 라운드당 +20.
export const requiredScore = (round) => 25 + (round - 1) * 20;
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 토큰 납입×5 + 명분 납입×20.
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
combineScore + tokensPaid * 5 + justificationsPaid * 20;
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만 ── */
export const PROTOCOL_TYPE_A = [
{
id: 'protocol_flush_ban',
name: '동일 부서 일괄 처리 금지 규약',
effect: { kind: 'disable_hand', patternId: 'flush_axis' },
flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.',
},
{
id: 'protocol_straight_ban',
name: '연번 상신 금지 규약',
effect: { kind: 'disable_hand', patternId: 'straight' },
flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.',
},
{
id: 'protocol_straightflush_ban',
name: '전결 처리 금지 규약',
effect: { kind: 'disable_hand', patternId: 'straight_flush' },
flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.',
},
{
id: 'protocol_fourkind_ban',
name: '전면 개편 금지 규약',
effect: { kind: 'disable_hand', patternId: 'four_kind' },
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
},
{
id: 'protocol_min_grade',
name: '기밀 등급 심사 강화 규약',
effect: { kind: 'min_grade', minGrade: 2 },
flavor: '일반 문서 단독으로는 더 이상 합본 상신이 승인되지 않습니다.',
},
];
export const PROTOCOL_TYPE_C = [
{
id: 'penalty_gauge_accel',
name: '미결 서류 관리 규정',
effect: { kind: 'gauge_surcharge', amount: 1.0 },
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
},
{
id: 'penalty_token_surcharge',
name: '긴급 재정 감사 대응',
effect: { kind: 'token_surcharge', amount: 2 },
flavor: '감사 대응 인력 편성 비용이 모든 토큰 지출에 얹힙니다.',
},
];
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개를 뽑는다 (유형 A만, §17 2차 범위). */
export const rollProtocolCandidates = () => pick(PROTOCOL_TYPE_A, 3);
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 가중 무작위로 대신한다. */
export const resolveProtocolVote = (candidates) => {
if (candidates.length === 0) return null;
return candidates[Math.floor(Math.random() * candidates.length)];
};
export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
/* ── §7.2 명분 개입 — 규약 후보에 개입한다. 실제 13인 표결이 없어 "1표 뒤집기"(혼합 명분)는
* 문자 그대로 구현할 표가 없다. 대신 혼합 명분은 "후보 중 하나를 직접 지정 통과"로 대체했다 —
* 표 조작의 최종 효과(원하는 규약을 통과시킨다)는 같지만, 이건 §9.4~9.6의 실제 위원회가
* 생기면 반드시 다시 설계해야 할 자리표시자다. */
export const JUSTIFICATION_INTERVENTIONS = {
절차: { label: '규약 이월 (이번 라운드 규약 보류)', needsTarget: false },
여론: { label: '후보 1개 강제 탈락', needsTarget: true },
위기: { label: '누적 징벌 규약 1개 조기 해제', needsTarget: 'penalty' },
내부: { label: '이번 라운드 실적 요구치 하향 (-20%)', needsTarget: false },
혼합: { label: '후보 1개 직접 통과 지정', needsTarget: true },
};
/**
* 명분 개입을 규약 후보에 반영한다 (§7.2 규약 개입).
* interventions: [{ type: '절차'|'여론'|'위기'|'내부'|'혼합', targetCandidateId?, targetPenaltyId? }]
* @returns {{ candidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear }}
*/
export function applyInterventions(candidates, interventions) {
let pool = [...candidates];
let forcedWinnerId = null;
let deferVote = false;
let requiredScoreMultiplier = 1;
let penaltyIdToClear = null;
interventions.forEach((iv) => {
if (iv.type === '절차') deferVote = true;
if (iv.type === '여론' && iv.targetCandidateId) {
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
}
if (iv.type === '내부') requiredScoreMultiplier *= 0.8;
if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
// 혼합 명분: 실제 표 뒤집기가 아니라 "직접 통과 지정"으로 대체한 자리표시자 — 위 §7.2 주석 참고.
if (iv.type === '혼합' && iv.targetCandidateId) forcedWinnerId = iv.targetCandidateId;
});
return { candidates: pool, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear };
}
/* ── 합본 판정에 규약 효과를 얹는다 ── */
/** 활성 유형 A 규약(들)을 반영해 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;
}
/** 활성 징벌(유형 C)이 게이지 상승률에 더하는 가산분 합계. */
export function penaltyGaugeSurcharge(penalties) {
return penalties.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0);
}
/** 활성 징벌(유형 C)이 토큰 지출 가산에 더하는 합계. */
export function penaltyTokenSurcharge(penalties) {
return penalties.reduce((sum, p) => sum + (p.effect.kind === 'token_surcharge' ? p.effect.amount : 0), 0);
}