플레이 피드백 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>
457 lines
20 KiB
JavaScript
457 lines
20 KiB
JavaScript
// 의회 엔진 — 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;
|
||
}
|