규약 세부화(유형D·13석 표결·툴팁) + 밸런스 피드백 5건 반영
같은 날 두 차례 플레이테스트에서 나온 이슈를 이어서 처리했다.
**의회 세부화**
- 유형 D(이득 규약) 5종 신설 — 토큰 지급/게이지 완화/지출 가산 완화/
주목도 한도 증가/실적 요구치 하향. 내부 명분("강제 상정")이 여태 불리한
유형 A만 올릴 수 있어 실속 없던 문제를 해결 — 상정 풀에 D가 포함되는 게
내부 명분이 존재하는 이유
- 13석 표결을 실제 표 수로 구현(tallyVotes, 최대잔여법 + 결정적 해시).
혼합 명분이 "직접 통과 지정" 자리표시자에서 §7.3 스펙대로 실제 1표
이동으로 교체 — 동점은 스윙 전 득표가 많던 쪽이 이겨 1표 차에서만 뒤집힘.
의회 화면에 후보별 예상 득표·표 차·뒤집기 가능 여부를 미리 노출
- 공보 축 오염 선택지 1→7개로 보강(서사 기준 6개 재배정), 축 분포 균형화
**툴팁 신설 (InfoTooltip·ProtocolChip)**
- 규약·징벌·명분을 클릭하면 정확히 무엇이 막혔는지 설명이 뜬다. 문구는
UI가 추측하지 않고 council.js의 protocolEffectDetail·justificationDetail이
카탈로그에서 생성. 모바일 대상이라 hover가 아닌 클릭으로 연다
**크리티컬 버그: 주목도 미초기화**
- 라운드 종료 후 한도 판정에 총 주목도(이월 포함)를 쓰고 있어, 2라운드가
시작하자마자 이미 한도를 넘어 버스트가 무한 반복되고 있었다. 한도 판정은
"이번 라운드에 받은 카드"만 세도록 분리(roundAttention), 게이지·토큰
가산은 기존대로 총계(이월 포함) 기준 유지
**밸런스 피드백 5건**
- 결재 카드가 고정으로 보인다는 지적 → 실제 버그였다. cardIndex를 그냥
+1씩 돌려 CSV 순서 그대로 결정론적으로 반복되고 있었다. Fisher–Yates
셔플 큐로 교체(큐 소진 시 재셔플)
- 위협 축도 거의 안 나온다 → 위 셔플 버그가 원인의 상당 부분. 명백한
오배정 1건도 발견해 수정(id11 "군사 개입 지시": 고대 신 소환 저지가
인사 축으로 잘못 들어가 있었음 → 위협 축)
- 게이지 상승이 너무 낮음 → 등급 합계(주목도) 단독 기준이 실제 카드
등급 분포(대부분 1~4급)와 맞지 않아 3~4장을 쌓아도 최하 구간을 못
벗어났다. 카드 장수를 1차 기준으로 되돌리고 등급 배율(평균등급/5)을
곱하는 방식으로 재설계 — 3~4장 구간에서 6배 점프(0.5%→3.0%)
- 좌우 모두 오염 없는 완전 무행동 카드 1건(id15) 수정 — 좌측에 대외/6급
부여(기존 tag_corruption과 정합)
- 오염 카드 한쪽만 있어 안전 선택 메리트가 없다는 지적은 카드 데이터가
아니라 게이지 곡선 문제로 판단, 위 재설계로 해결(카드 재배분 안 함)
**문서**
- 결정 노트 2건: 2026-09-17-주목도-라운드-분리,
2026-09-17-게이지상승-장수기준-복원(기각한 대안 포함)
- 기획서 §4.2-A/B, §7.3, §9.2, §17 갱신. DEVLOG 2건 기록
Playwright로 전 구간 검증: 셔플(3회 재접속 시 다른 카드, 15연속 무반복),
게이지 상승률 표시, 13석 표결 예상치, 규약/명분 툴팁, 2라운드 진입 시
주목도 0으로 리셋 확인. 콘솔 에러 0건, 빌드 클린.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0a065068c1
commit
6398599199
@@ -1,11 +1,18 @@
|
||||
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
|
||||
// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
|
||||
// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
|
||||
// 뽑는 절차는 "무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
|
||||
// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
|
||||
// React 의존 없는 순수 함수 + 카탈로그.
|
||||
//
|
||||
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
|
||||
// 특히 내부 명분이 "실적 요구치 하향"에서 "후보 1개 강제 상정"으로 바뀌었다.
|
||||
// 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 실적 감사 ── */
|
||||
|
||||
@@ -16,50 +23,56 @@ export const requiredScore = (round) => 25 + (round - 1) * 20;
|
||||
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
|
||||
combineScore + tokensPaid * 5 + justificationsPaid * 20;
|
||||
|
||||
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만.
|
||||
* 유형 B(금리형)·D(이득)는 §20.2 "규약 상세 목록 — 소량 배치로 작성" 대상이라 아직 카탈로그가 없다.
|
||||
* 유형 D가 없으면 내부 명분의 "강제 상정"이 전략적으로 빈약하다 — 이득 규약이 추가되면 살아난다. */
|
||||
/* ── §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: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.',
|
||||
},
|
||||
@@ -69,17 +82,64 @@ export const PROTOCOL_TYPE_C = [
|
||||
{
|
||||
id: 'penalty_gauge_accel',
|
||||
name: '미결 서류 관리 규정',
|
||||
type: 'C',
|
||||
effect: { kind: 'gauge_surcharge', amount: 1.0 },
|
||||
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
|
||||
},
|
||||
{
|
||||
id: 'penalty_token_surcharge',
|
||||
name: '긴급 재정 감사 대응',
|
||||
type: 'C',
|
||||
effect: { kind: 'token_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_token', 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: 'token_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 = [];
|
||||
@@ -90,53 +150,163 @@ const pick = (arr, n) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위).
|
||||
* 매 라운드 규약 후보 3개를 뽑는다.
|
||||
* carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3).
|
||||
* 이득 규약(D)은 낮은 확률로 한 자리만 차지한다.
|
||||
*/
|
||||
export const rollProtocolCandidates = (carriedOver = []) => {
|
||||
const carriedIds = new Set(carriedOver.map((c) => c.id));
|
||||
const fresh = pick(PROTOCOL_TYPE_A.filter((p) => !carriedIds.has(p.id)), Math.max(0, 3 - carriedOver.length));
|
||||
return [...carriedOver, ...fresh];
|
||||
};
|
||||
const slots = Math.max(0, 3 - carriedOver.length);
|
||||
const out = [...carriedOver];
|
||||
|
||||
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 무작위로 대신한다. */
|
||||
export const resolveProtocolVote = (candidates) => {
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
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 PROTOCOL_TYPE_A.filter((p) => !onBoard.has(p.id));
|
||||
return TABLEABLE_PROTOCOLS.filter((p) => !onBoard.has(p.id));
|
||||
};
|
||||
|
||||
/* ── §7.3 ① 안건 조작 — 명분 4종 + 혼합 ──
|
||||
* ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회(§9.4~9.6, 4차)가 있어야 대상이 존재한다.
|
||||
* ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다.
|
||||
*
|
||||
* 혼합 명분의 "표결 결과 1표 뒤집기"는 실제 표가 없어 문자 그대로 구현할 수 없다.
|
||||
* 최종 효과(원하는 규약이 통과된다)만 같게 "직접 통과 지정"으로 대체했고,
|
||||
* 실제 위원회가 생기면 반드시 다시 설계해야 할 자리표시자다. */
|
||||
export const JUSTIFICATION_INTERVENTIONS = {
|
||||
절차: { label: '후보 1개 다음 라운드로 이월', target: 'candidate' },
|
||||
여론: { label: '후보 1개 강제 탈락 + 새 후보로 교체', target: 'candidate' },
|
||||
위기: { label: '누적 징벌 규약 1개 조기 해제', target: 'penalty' },
|
||||
내부: { label: '규약 1개 강제 상정', target: 'pool' },
|
||||
혼합: { label: '후보 1개 직접 통과 지정 (표 뒤집기)', target: 'candidate' },
|
||||
/* ── §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, forcedWinnerId, deferred, penaltyIdToClear }}
|
||||
* deferred: 다음 라운드 후보로 이월할 규약 목록 (절차 명분)
|
||||
* @returns {{ candidates, voteSwingTargetId, deferred, penaltyIdToClear }}
|
||||
*/
|
||||
export function applyInterventions(candidates, interventions) {
|
||||
let pool = [...candidates];
|
||||
let forcedWinnerId = null;
|
||||
let voteSwingTargetId = null;
|
||||
const deferred = [];
|
||||
let penaltyIdToClear = null;
|
||||
|
||||
@@ -150,24 +320,127 @@ export function applyInterventions(candidates, interventions) {
|
||||
}
|
||||
if (iv.type === '여론' && iv.targetCandidateId) {
|
||||
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
||||
// 강제 탈락시킨 자리에 새 후보 1개를 무작위로 채운다 (§7.3).
|
||||
const replacement = pick(availableForTabling(pool), 1)[0];
|
||||
if (replacement) pool = [...pool, replacement];
|
||||
// 교체 후보는 개입을 지정하는 시점(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 = PROTOCOL_TYPE_A.find((p) => p.id === 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) forcedWinnerId = iv.targetCandidateId;
|
||||
if (iv.type === '혼합' && iv.targetCandidateId) voteSwingTargetId = iv.targetCandidateId;
|
||||
});
|
||||
|
||||
return { candidates: pool, forcedWinnerId, deferred, penaltyIdToClear };
|
||||
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 'token_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_token':
|
||||
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 tokenSurchargeOf(protocols) {
|
||||
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'token_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 grantTokenOf(protocol) {
|
||||
return protocol && protocol.effect.kind === 'grant_token' ? protocol.effect.amount : 0;
|
||||
}
|
||||
|
||||
/* ── 합본 판정에 규약 효과를 얹는다 ── */
|
||||
|
||||
/** 활성 유형 A 규약(들)을 반영해 detectHands 결과를 거른다. */
|
||||
/** 활성 규약을 반영해 detectHands 결과를 거른다. */
|
||||
export function filterHandsByProtocols(matches, selection, activeProtocols) {
|
||||
let allowed = matches;
|
||||
activeProtocols.forEach((p) => {
|
||||
@@ -181,13 +454,3 @@ export function filterHandsByProtocols(matches, selection, activeProtocols) {
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user