규약 세부화(유형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,7 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import SymbolBadge from './SymbolBadge';
|
||||
import { detectHands, resolveCombine, MAX_COMBINE_CARDS, MIN_COMBINE_CARDS } from '../engine/hands';
|
||||
import { filterHandsByProtocols } from '../engine/council';
|
||||
import { filterHandsByProtocols, protocolEffectDetail } from '../engine/council';
|
||||
import ProtocolChip from './ProtocolChip';
|
||||
import './CombinePanel.css';
|
||||
|
||||
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
|
||||
@@ -56,7 +57,7 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
||||
{activeProtocols.length > 0 && (
|
||||
<div className="combine-protocols">
|
||||
{activeProtocols.map((p) => (
|
||||
<span key={p.id} className="combine-protocol-chip">⚖ {p.name}</span>
|
||||
<ProtocolChip key={p.id} protocol={p} prefix="⚖ " />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -84,7 +85,10 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
||||
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
|
||||
)}
|
||||
{blockedByProtocol && (
|
||||
<p className="combine-blocked">규약 때문에 이 선택은 합본할 수 없습니다.</p>
|
||||
<p className="combine-blocked">
|
||||
규약 때문에 이 선택은 합본할 수 없습니다 —{' '}
|
||||
{activeProtocols.map((p) => protocolEffectDetail(p).summary).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{matches.map((patternId) => {
|
||||
const preview = resolveCombine(selection, patternId);
|
||||
|
||||
@@ -5,17 +5,19 @@ import DeskBar from './DeskBar';
|
||||
import CoreSwipeCard from './CoreSwipeCard';
|
||||
import CombinePanel from './CombinePanel';
|
||||
import HandGuidePopup from './HandGuidePopup';
|
||||
import ProtocolChip from './ProtocolChip';
|
||||
import ShopScreen from './ShopScreen';
|
||||
import CouncilScreen from './CouncilScreen';
|
||||
import { pickPollutionEntry } from '../engine/pollutionPool';
|
||||
import {
|
||||
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge,
|
||||
totalAttention, roundAttentionCap,
|
||||
roundAttention, roundAttentionCap,
|
||||
} from '../engine/desk';
|
||||
import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop';
|
||||
import {
|
||||
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
|
||||
applyInterventions, penaltyGaugeSurcharge, penaltyTokenSurcharge,
|
||||
applyInterventions, gaugeSurchargeOf, tokenSurchargeOf, attentionCapBonusOf,
|
||||
requiredScoreDeltaOf, grantTokenOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS,
|
||||
} from '../engine/council';
|
||||
import './CoreGameScreen.css';
|
||||
|
||||
@@ -57,13 +59,27 @@ const buildCard = (row) => ({
|
||||
|
||||
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
|
||||
|
||||
// 2026-09-17 피드백: 결재 카드가 "고정으로 등장하는 것 같다"는 지적 — 실제로 버그였다.
|
||||
// 예전엔 cardIndex를 그냥 +1씩 돌려서 CSV 파일 순서 그대로, 매 실행마다 똑같은 순서로
|
||||
// 반복됐다(진짜 무작위가 아니라 결정론적 순환). Fisher–Yates로 섞은 뒤 앞에서부터 뽑고,
|
||||
// 큐가 비면 다시 섞어 채운다 — 한 바퀴 안에서는 같은 카드가 두 번 나오지 않으면서도
|
||||
// 실행할 때마다, 그리고 한 바퀴가 끝날 때마다 순서가 달라진다.
|
||||
const shuffle = (arr) => {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
};
|
||||
|
||||
export default function CoreGameScreen() {
|
||||
const deck = useMemo(() => {
|
||||
const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true });
|
||||
return parsed.data.map(buildCard);
|
||||
}, []);
|
||||
|
||||
const [cardIndex, setCardIndex] = useState(0);
|
||||
const [drawQueue, setDrawQueue] = useState(() => shuffle(deck));
|
||||
const [deskCards, setDeskCards] = useState([]);
|
||||
const [gauges, setGauges] = useState(INITIAL_GAUGES);
|
||||
const [tokens, setTokens] = useState(INITIAL_TOKENS);
|
||||
@@ -88,13 +104,22 @@ export default function CoreGameScreen() {
|
||||
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
|
||||
|
||||
const activeProtocols = activeProtocol ? [activeProtocol] : [];
|
||||
const gaugeExtraFlat = penaltyGaugeSurcharge(penalties);
|
||||
const tokenExtraFlat = penaltyTokenSurcharge(penalties);
|
||||
// 징벌(유형 C)과 활성 규약(유형 A/D)이 같은 수치를 건드리므로 합쳐서 합산한다.
|
||||
// 이득 규약이면 음수라 페널티가 상쇄된다.
|
||||
const effectiveProtocols = [...penalties, ...activeProtocols];
|
||||
const gaugeExtraFlat = gaugeSurchargeOf(effectiveProtocols);
|
||||
const tokenExtraFlat = tokenSurchargeOf(effectiveProtocols);
|
||||
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
||||
const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0);
|
||||
const attentionCap = roundAttentionCap(round);
|
||||
const attentionCap = roundAttentionCap(round, attentionCapBonusOf(activeProtocols));
|
||||
|
||||
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
|
||||
const currentCard = drawQueue[0] || null;
|
||||
const advanceCard = () => {
|
||||
setDrawQueue((prev) => {
|
||||
const rest = prev.slice(1);
|
||||
return rest.length > 0 ? rest : shuffle(deck);
|
||||
});
|
||||
};
|
||||
|
||||
const checkGameOver = (nextGauges) => {
|
||||
const hit = Object.entries(nextGauges).find(([, v]) => v >= 100);
|
||||
@@ -131,7 +156,7 @@ export default function CoreGameScreen() {
|
||||
if (side.axis) {
|
||||
const grade = side.grade || 1;
|
||||
const entry = pickPollutionEntry(side.axis, grade);
|
||||
const newCard = makeDeskCard(side.axis, grade, entry || {});
|
||||
const newCard = makeDeskCard(side.axis, grade, entry || {}, round);
|
||||
nextDesk = addToDesk(deskCards, [newCard]);
|
||||
setDeskCards(nextDesk);
|
||||
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
|
||||
@@ -139,12 +164,14 @@ export default function CoreGameScreen() {
|
||||
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
|
||||
}
|
||||
|
||||
setCardIndex((i) => (i + 1) % deck.length);
|
||||
advanceCard();
|
||||
|
||||
if (checkGameOver(nextGauges)) return;
|
||||
|
||||
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
|
||||
if (totalAttention(nextDesk) > attentionCap) {
|
||||
// 이월 카드는 한도 판정에서 빠진다(roundAttention) — 안 그러면 다음 라운드가 시작하자마자
|
||||
// 버스트가 반복된다.
|
||||
if (roundAttention(nextDesk, round) > attentionCap) {
|
||||
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
|
||||
goToShop();
|
||||
return;
|
||||
@@ -154,7 +181,7 @@ export default function CoreGameScreen() {
|
||||
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
|
||||
const handleEndRound = () => {
|
||||
if (phase !== 'approval') return;
|
||||
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${totalAttention(deskCards)}/${attentionCap}`);
|
||||
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${roundAttention(deskCards, round)}/${attentionCap}`);
|
||||
goToShop();
|
||||
};
|
||||
|
||||
@@ -201,10 +228,11 @@ export default function CoreGameScreen() {
|
||||
|
||||
/* ── 의회 (§9) ── */
|
||||
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
|
||||
const { candidates: remainingCandidates, forcedWinnerId, deferred, penaltyIdToClear } =
|
||||
const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } =
|
||||
applyInterventions(councilCandidates, interventions);
|
||||
|
||||
const finalRequired = requiredScore(round);
|
||||
// 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D).
|
||||
const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols));
|
||||
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20.
|
||||
const finalScore = settleScore({
|
||||
combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount,
|
||||
@@ -241,12 +269,20 @@ export default function CoreGameScreen() {
|
||||
pushLog(`절차 명분으로 『${deferred.map((d) => d.name).join(', ')}』 다음 라운드 이월`);
|
||||
}
|
||||
|
||||
const winner = forcedWinnerId
|
||||
? remainingCandidates.find((c) => c.id === forcedWinnerId) || null
|
||||
: resolveProtocolVote(remainingCandidates);
|
||||
// §9.2 13석 표결. 혼합 명분은 1표만 움직이므로 1표 차 승부에서만 결과가 뒤집힌다 (§7.3).
|
||||
const tallied = applyVoteSwing(tallyVotes(remainingCandidates), voteSwingTargetId);
|
||||
const winner = resolveProtocolVote(tallied);
|
||||
setActiveProtocol(winner);
|
||||
if (winner) pushLog(`규약 통과: 『${winner.name}』 — 다음 라운드 적용`);
|
||||
else pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
|
||||
if (winner) {
|
||||
pushLog(`규약 통과: 『${winner.name}』 ${winner.votes}/${COUNCIL_SEATS}표 — 다음 라운드 적용`);
|
||||
const grant = grantTokenOf(winner);
|
||||
if (grant > 0) {
|
||||
setTokens((prev) => prev + grant);
|
||||
pushLog(`이득 규약 『${winner.name}』 — 토큰 +${grant} 즉시 지급`);
|
||||
}
|
||||
} else {
|
||||
pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
|
||||
}
|
||||
|
||||
setRound((r) => r + 1);
|
||||
setRoundScore(0);
|
||||
@@ -277,11 +313,10 @@ export default function CoreGameScreen() {
|
||||
deskCards={deskCards}
|
||||
gauges={gauges}
|
||||
tokens={tokens}
|
||||
round={round}
|
||||
attentionCap={attentionCap}
|
||||
onOpenCombine={() => setShowCombine(true)}
|
||||
onOpenGuide={() => setShowGuide(true)}
|
||||
onEndRound={handleEndRound}
|
||||
canEndRound={phase === 'approval'}
|
||||
gaugeExtraFlat={gaugeExtraFlat}
|
||||
tokenExtraFlat={tokenExtraFlat}
|
||||
/>
|
||||
@@ -290,11 +325,19 @@ export default function CoreGameScreen() {
|
||||
<span>제{round}라운드</span>
|
||||
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
||||
<span>명분 {justificationTotal}개</span>
|
||||
{penalties.length > 0 && <span className="core-status-penalty">⚠ 징벌 {penalties.length}</span>}
|
||||
{activeProtocol && <span className="core-status-protocol">⚖ {activeProtocol.name}</span>}
|
||||
{activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />}
|
||||
{penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)}
|
||||
</div>
|
||||
|
||||
{phase === 'approval' && <CoreSwipeCard card={currentCard} onSwipe={handleSwipe} />}
|
||||
{phase === 'approval' && (
|
||||
<CoreSwipeCard
|
||||
card={currentCard}
|
||||
onSwipe={handleSwipe}
|
||||
onEndRound={handleEndRound}
|
||||
roundAttention={roundAttention(deskCards, round)}
|
||||
attentionCap={attentionCap}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'shop' && (
|
||||
<ShopScreen
|
||||
@@ -316,6 +359,7 @@ export default function CoreGameScreen() {
|
||||
justifications={justifications}
|
||||
penalties={penalties}
|
||||
candidates={councilCandidates}
|
||||
requiredDelta={requiredScoreDeltaOf(activeProtocols)}
|
||||
onResolve={handleCouncilResolve}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -123,3 +123,50 @@
|
||||
opacity: 0.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 라운드 종료 액션 — 결재 카드 바로 아래. 주목도/한도를 같이 보여줘야 "한 장 더"가 판단이 된다. */
|
||||
.core-round-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: min(420px, 92vw);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
font-family: var(--font-typewriter);
|
||||
}
|
||||
|
||||
.core-round-action.zone-warning { border-color: var(--color-risk-critical); }
|
||||
.core-round-action.zone-bust { border-color: var(--symbol-threat, #ff7b6b); }
|
||||
|
||||
.core-round-attention {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.core-round-attention strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.zone-warning .core-round-attention strong { color: var(--color-risk-critical); }
|
||||
.zone-bust .core-round-attention strong { color: var(--symbol-threat, #ff7b6b); }
|
||||
|
||||
.core-round-remaining { opacity: 0.6; }
|
||||
|
||||
.core-end-round-btn {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: var(--color-token-info);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.core-end-round-btn:hover { filter: brightness(1.15); }
|
||||
|
||||
@@ -9,7 +9,12 @@ import './CoreSwipeCard.css';
|
||||
// 실제로 뭘 하는 선택인지(대사)가 안 읽혔다. 그래서 좌/우 슬롯을 카드 위 고정 영역으로 빼고,
|
||||
// 기호 + 선택 대사를 처음부터 항상 같이 보여준다. 드래그 제스처·물리는 기존
|
||||
// components/SwipeCard.jsx를 재사용했다 (기획서 "재사용 자산").
|
||||
export default function CoreSwipeCard({ card, onSwipe }) {
|
||||
//
|
||||
// 2026-09-17 피드백: "정산하기"는 카드를 더 받을지 라운드를 끝낼지 정하는 핵심 결정인데
|
||||
// 화면 오른쪽 위 툴바에 있으니 눈에 안 들어온다는 지적. 결재 카드 바로 아래, 선택을 내리는
|
||||
// 자리로 옮기고 지금 주목도/한도를 버튼 옆에 같이 붙였다 — 블랙잭에서 "스탠드"를 누르기 전에
|
||||
// 내 패 합계를 보는 것과 같다.
|
||||
export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundAttention = 0, attentionCap = 0 }) {
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const controls = useAnimation();
|
||||
@@ -40,6 +45,9 @@ export default function CoreSwipeCard({ card, onSwipe }) {
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
const remaining = Math.max(0, attentionCap - roundAttention);
|
||||
const zone = remaining === 0 ? 'bust' : remaining <= 5 ? 'warning' : 'normal';
|
||||
|
||||
return (
|
||||
<div className="core-card-container">
|
||||
<motion.div
|
||||
@@ -83,6 +91,21 @@ export default function CoreSwipeCard({ card, onSwipe }) {
|
||||
{card.flavor && <p className="core-card-flavor">"{card.flavor}"</p>}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 결재 카드 바로 아래 — "한 장 더 받을까, 여기서 멈출까"를 같은 시선 안에서 판단한다. */}
|
||||
{onEndRound && (
|
||||
<div className={`core-round-action zone-${zone}`}>
|
||||
<span className="core-round-attention">
|
||||
이번 라운드 주목도 <strong>{roundAttention}</strong> / {attentionCap}
|
||||
{remaining > 0
|
||||
? <span className="core-round-remaining"> · 한도까지 {remaining}</span>
|
||||
: <span className="core-round-remaining"> · 한도 도달</span>}
|
||||
</span>
|
||||
<button type="button" className="core-end-round-btn" onClick={onEndRound}>
|
||||
정산하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
.council-audit-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
/* 규약 델타까지 붙으면 좁은 화면에서 오른쪽이 잘린다 — 줄바꿈을 허용한다. */
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -251,3 +254,82 @@
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 2026-09-17: 13석 표결 표시 + 툴팁 배치 ── */
|
||||
|
||||
.council-required-delta {
|
||||
font-size: 11px;
|
||||
color: var(--color-token-budget);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.council-margin {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.council-margin-hint {
|
||||
color: var(--color-risk-critical);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.council-penalty-list,
|
||||
.council-pool-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.council-penalty-slot.targetable,
|
||||
.council-pool-slot.targetable {
|
||||
outline: 1px dashed var(--color-risk-critical);
|
||||
outline-offset: 2px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.candidate-votes {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.candidate-votes.lead {
|
||||
opacity: 1;
|
||||
color: var(--color-token-budget);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.candidate-state {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.council-candidate.winning {
|
||||
border-color: var(--color-token-budget);
|
||||
}
|
||||
|
||||
.council-candidate.added {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.council-confirm-sub {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.council-margin-hint-weak {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, availableForTabling,
|
||||
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, justificationDetail,
|
||||
availableForTabling, applyInterventions, tallyVotes, applyVoteSwing, resolveProtocolVote,
|
||||
COUNCIL_SEATS,
|
||||
} from '../engine/council';
|
||||
import InfoTooltip from './InfoTooltip';
|
||||
import ProtocolChip from './ProtocolChip';
|
||||
import './CouncilScreen.css';
|
||||
|
||||
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
|
||||
// 상정되지 않는다. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은 4차 범위라 없다 — 후보 1개는
|
||||
// 무작위로 뽑힌 "통과"를 그대로 쓴다. 플레이어는 표결에 직접 개입 못 하고 명분으로
|
||||
// 안건 자체를 조작할 뿐이라는 원칙(§9.2)만 지켰다.
|
||||
// 상정되지 않는다. 플레이어는 표결에 직접 개입 못 하고 명분으로 안건 자체를 조작할 뿐이다(§9.2).
|
||||
//
|
||||
// 2026-09-15 결정 반영: 토큰 1종, 명분 개입 4갈래 재정의(§7.3).
|
||||
// 명분은 종류별 "수량"(§5.1 표의 명분 열)이다 — 1개 이상 있어야 실적 납입이나 개입에 쓸 수 있고,
|
||||
// 페어(0.5)만 반복하면 반쪽만 쌓여 아무것도 못 한다.
|
||||
// 2026-09-15 결정: 토큰 1종, 명분 개입 4갈래(§7.3). 명분은 종류별 "수량"이라 1개 이상이어야 쓴다.
|
||||
// 2026-09-17: §9.2의 13석 표결을 실제 표 수로 구현하고, 개입을 반영한 **예상 득표를 미리** 보여준다.
|
||||
// 혼합 명분이 1표만 움직이는 이상(§7.3), 표 차를 모르면 쓸지 말지 판단할 수 없기 때문이다.
|
||||
// 임원 개개인의 우호도·이중첩자(§9.5)는 4차 범위 — 지금은 안건 조합에 따른 결정적 배분이다.
|
||||
export default function CouncilScreen({
|
||||
round, combineScore, tokens, justifications, penalties, candidates, onResolve,
|
||||
round, combineScore, tokens, justifications, penalties, candidates, requiredDelta = 0, onResolve,
|
||||
}) {
|
||||
const required = requiredScore(round);
|
||||
const required = Math.max(0, requiredScore(round) + requiredDelta);
|
||||
|
||||
const [tokensPaid, setTokensPaid] = useState(0);
|
||||
const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
|
||||
@@ -51,8 +54,16 @@ export default function CouncilScreen({
|
||||
penalty: 'targetPenaltyId',
|
||||
pool: 'targetPoolProtocolId',
|
||||
}[targetKind];
|
||||
const iv = { type: pending.type, [key]: targetId };
|
||||
// 여론 명분의 교체 후보는 지금 확정해 둔다 — 확정 시점에 무작위로 뽑으면 아래 예상 득표와
|
||||
// 실제 결과가 달라진다.
|
||||
if (pending.type === '여론') {
|
||||
const pool = availableForTabling(candidates.filter((c) => c.id !== targetId));
|
||||
const replacement = pool[Math.floor(Math.random() * pool.length)];
|
||||
if (replacement) iv.replacementId = replacement.id;
|
||||
}
|
||||
spend(pending.type);
|
||||
setInterventions((prev) => [...prev, { type: pending.type, [key]: targetId }]);
|
||||
setInterventions((prev) => [...prev, iv]);
|
||||
setPending(null);
|
||||
};
|
||||
|
||||
@@ -60,6 +71,14 @@ export default function CouncilScreen({
|
||||
const tablingPool = availableForTabling(candidates);
|
||||
const heldTypes = Object.keys(justifications).filter((t) => remainingOf(t) > 0);
|
||||
|
||||
// 지금까지의 개입을 반영한 최종 안건과 예상 득표. 확정 로직(CoreGameScreen)과 같은 함수를
|
||||
// 같은 순서로 쓰고 tallyVotes가 결정적이라 미리보기와 결과가 일치한다.
|
||||
const preview = applyInterventions(candidates, interventions);
|
||||
const tallied = applyVoteSwing(tallyVotes(preview.candidates), preview.voteSwingTargetId);
|
||||
const projectedWinner = resolveProtocolVote(tallied);
|
||||
const sortedTally = [...tallied].sort((a, b) => b.votes - a.votes);
|
||||
const margin = sortedTally.length >= 2 ? sortedTally[0].votes - sortedTally[1].votes : null;
|
||||
|
||||
const confirm = () => {
|
||||
onResolve({ tokensPaid, justificationsPaidCount: scorePaidCount, interventions });
|
||||
};
|
||||
@@ -70,7 +89,14 @@ export default function CouncilScreen({
|
||||
|
||||
<section className="council-audit">
|
||||
<div className="council-audit-row">
|
||||
<span>요구 실적 {required}</span>
|
||||
<span>
|
||||
요구 실적 {required}
|
||||
{requiredDelta !== 0 && (
|
||||
<span className="council-required-delta">
|
||||
{' '}({requiredDelta > 0 ? '+' : ''}{requiredDelta} 규약 적용)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={met ? 'audit-met' : 'audit-short'}>
|
||||
현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
|
||||
</span>
|
||||
@@ -97,15 +123,17 @@ export default function CouncilScreen({
|
||||
return (
|
||||
<section className="council-penalties">
|
||||
<h3>누적 징벌 규약 {penalties.length}개</h3>
|
||||
{shown.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`council-penalty-chip ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
|
||||
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
|
||||
>
|
||||
⚠ {p.name}
|
||||
</div>
|
||||
))}
|
||||
<div className="council-penalty-list">
|
||||
{shown.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className={`council-penalty-slot ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
|
||||
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
|
||||
>
|
||||
<ProtocolChip protocol={p} prefix="⚠ " />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(true)}>
|
||||
+{hidden}개 더 보기
|
||||
@@ -125,11 +153,20 @@ export default function CouncilScreen({
|
||||
{heldTypes.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
|
||||
{heldTypes.map((type) => {
|
||||
const spec = JUSTIFICATION_INTERVENTIONS[type];
|
||||
const info = justificationDetail(type);
|
||||
const remaining = remainingOf(type);
|
||||
const usable = remaining >= 1;
|
||||
return (
|
||||
<div key={type} className="council-justification-item">
|
||||
<span className="justification-badge">{type} {remaining}개</span>
|
||||
<InfoTooltip
|
||||
title={`${type} 명분`}
|
||||
badge={`보유 ${remaining}개`}
|
||||
summary={info.summary}
|
||||
detail={info.detail}
|
||||
tone={usable ? 'neutral' : 'bad'}
|
||||
>
|
||||
<span className="justification-badge">{type} {remaining}개</span>
|
||||
</InfoTooltip>
|
||||
{!usable && <span className="justification-partial">1개 미만 — 사용 불가</span>}
|
||||
<button onClick={() => payForScore(type)} disabled={!usable}>실적 납입 (+20)</button>
|
||||
{spec && (
|
||||
@@ -151,51 +188,97 @@ export default function CouncilScreen({
|
||||
</section>
|
||||
|
||||
<section className="council-candidates">
|
||||
<h3>규약 후보</h3>
|
||||
<h3>
|
||||
규약 표결 — {COUNCIL_SEATS}석
|
||||
{margin !== null && (
|
||||
<span className="council-margin">
|
||||
{margin === 0 ? ' · 동점' : ` · 1위와 2위 ${margin}표 차`}
|
||||
{margin <= 1 && !preview.voteSwingTargetId && (
|
||||
<span className="council-margin-hint"> — 혼합 명분 1표로 뒤집을 수 있음</span>
|
||||
)}
|
||||
{margin >= 2 && !preview.voteSwingTargetId && (
|
||||
<span className="council-margin-hint-weak"> — 혼합 명분으로는 못 뒤집음</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{candidates.map((c) => {
|
||||
const deferredHere = interventions.some((iv) => iv.type === '절차' && iv.targetCandidateId === c.id);
|
||||
const removed = interventions.some((iv) => iv.type === '여론' && iv.targetCandidateId === c.id);
|
||||
const forced = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
|
||||
const swung = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
|
||||
const gone = deferredHere || removed;
|
||||
const tallyRow = tallied.find((t) => t.id === c.id);
|
||||
const isWinner = projectedWinner && projectedWinner.id === c.id;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`council-candidate ${gone ? 'removed' : ''} ${forced ? 'forced' : ''} ${pendingTargetKind === 'candidate' ? 'targetable' : ''}`}
|
||||
className={`council-candidate ${gone ? 'removed' : ''} ${isWinner ? 'winning' : ''} ${pendingTargetKind === 'candidate' ? 'targetable' : ''}`}
|
||||
onClick={() => pendingTargetKind === 'candidate' && completeTarget(c.id, 'candidate')}
|
||||
>
|
||||
<div className="candidate-name">
|
||||
{c.name}
|
||||
{deferredHere && ' (다음 라운드로 이월)'}
|
||||
{removed && ' (강제 탈락)'}
|
||||
{forced && ' (통과 지정)'}
|
||||
<div className="candidate-head">
|
||||
<ProtocolChip protocol={c} />
|
||||
{!gone && tallyRow && (
|
||||
<span className={`candidate-votes ${isWinner ? 'lead' : ''}`}>
|
||||
{tallyRow.votes}표{isWinner ? ' · 통과 예상' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="candidate-flavor">{c.flavor}</div>
|
||||
{(gone || swung) && (
|
||||
<div className="candidate-state">
|
||||
{deferredHere && '다음 라운드로 이월됨'}
|
||||
{removed && '강제 탈락됨'}
|
||||
{swung && '혼합 명분으로 +1표'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{interventions.filter((iv) => iv.type === '내부').map((iv, i) => (
|
||||
<div key={`tabled-${i}`} className="council-candidate forced">
|
||||
<div className="candidate-name">
|
||||
{tablingPool.find((p) => p.id === iv.targetPoolProtocolId)?.name} (강제 상정)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 개입으로 새로 들어온 안건(여론 교체분·내부 강제 상정분) */}
|
||||
{preview.candidates
|
||||
.filter((c) => !candidates.some((orig) => orig.id === c.id))
|
||||
.map((c) => {
|
||||
const tallyRow = tallied.find((t) => t.id === c.id);
|
||||
const isWinner = projectedWinner && projectedWinner.id === c.id;
|
||||
return (
|
||||
<div key={c.id} className={`council-candidate added ${isWinner ? 'winning' : ''}`}>
|
||||
<div className="candidate-head">
|
||||
<ProtocolChip protocol={c} />
|
||||
{tallyRow && (
|
||||
<span className={`candidate-votes ${isWinner ? 'lead' : ''}`}>
|
||||
{tallyRow.votes}표{isWinner ? ' · 통과 예상' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="candidate-state">명분으로 새로 올라온 안건</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{preview.candidates.length === 0 && (
|
||||
<p className="council-empty">안건이 모두 사라졌습니다 — 이번 라운드는 통과 규약 없이 넘어갑니다.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pendingTargetKind === 'pool' && (
|
||||
<section className="council-pool">
|
||||
<h3>상정 가능 규약</h3>
|
||||
<h3>상정 가능 규약 — 올릴 안건을 고르십시오</h3>
|
||||
{tablingPool.length === 0 && <p className="council-empty">상정할 규약이 남지 않았습니다.</p>}
|
||||
{tablingPool.map((p) => (
|
||||
<div key={p.id} className="council-candidate targetable" onClick={() => completeTarget(p.id, 'pool')}>
|
||||
<div className="candidate-name">{p.name}</div>
|
||||
<div className="candidate-flavor">{p.flavor}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="council-pool-list">
|
||||
{tablingPool.map((p) => (
|
||||
<span key={p.id} className="council-pool-slot targetable" onClick={() => completeTarget(p.id, 'pool')}>
|
||||
<ProtocolChip protocol={p} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button className="council-confirm-btn" onClick={confirm}>의회 결의 확정</button>
|
||||
<button className="council-confirm-btn" onClick={confirm}>
|
||||
의회 결의 확정
|
||||
{projectedWinner && <span className="council-confirm-sub"> — 『{projectedWinner.name}』 통과 예정</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,25 +82,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.desk-end-round-btn {
|
||||
font-family: var(--font-typewriter);
|
||||
border: 1px solid var(--color-token-info);
|
||||
color: var(--color-token-info);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
padding: 7px 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desk-end-round-btn:disabled {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.desk-bar-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -185,3 +166,8 @@
|
||||
color: var(--color-risk-critical);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.desk-carried {
|
||||
opacity: 0.55;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AXES, AXIS_META } from '../engine/symbols';
|
||||
import {
|
||||
totalCount, totalAttention, attentionByAxis, attentionZone, tokenSurcharge, gaugeRiseRateByAxis,
|
||||
totalCount, totalAttention, roundAttention, attentionByAxis, attentionZone, tokenSurcharge,
|
||||
gaugeRiseRateByAxis,
|
||||
} from '../engine/desk';
|
||||
import { AxisIcon } from './SymbolBadge';
|
||||
import DeskDetailPopup from './DeskDetailPopup';
|
||||
@@ -11,21 +12,27 @@ import './DeskBar.css';
|
||||
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)이고, 이제 주목도가 **이번 라운드의
|
||||
// 한도(attentionCap)를 넘으면 그 자리에서 라운드가 강제 종료**된다(§4.2-B, 결정 노트
|
||||
// 2026-09-16-라운드-주목도-하드캡-복원). 그래서 총 주목도뿐 아니라 한도까지 항상 보여야
|
||||
// "한 장 더 받을지, 지금 정산할지" 판단이 선다. "정산하기" 버튼이 그 자율 종료 액션이다.
|
||||
// "한 장 더 받을지, 지금 정산할지" 판단이 선다.
|
||||
//
|
||||
// 2026-09-17 수정: (1) "정산하기" 버튼은 결재 카드 아래(CoreSwipeCard)로 옮겼다 — 툴바 구석에
|
||||
// 있으면 핵심 결정 버튼인 걸 모른다는 피드백. (2) 한도 판정에 쓰는 주목도는 **이번 라운드분**
|
||||
// 이다. 책상 전체 주목도는 이월 카드까지 포함해 게이지·토큰 가산에 계속 작용하므로 둘 다 보여준다.
|
||||
const MAX_GRADE_CHIPS = 6;
|
||||
|
||||
export default function DeskBar({
|
||||
deskCards, gauges, tokens, attentionCap, onOpenCombine, onOpenGuide, onEndRound, canEndRound,
|
||||
deskCards, gauges, tokens, round, attentionCap, onOpenCombine, onOpenGuide,
|
||||
gaugeExtraFlat = 0, tokenExtraFlat = 0,
|
||||
}) {
|
||||
const [detailAxis, setDetailAxis] = useState(null);
|
||||
|
||||
const total = totalCount(deskCards);
|
||||
const attention = totalAttention(deskCards);
|
||||
const deskAttention = totalAttention(deskCards);
|
||||
const current = roundAttention(deskCards, round);
|
||||
const carried = deskAttention - current;
|
||||
const perAxis = attentionByAxis(deskCards);
|
||||
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
|
||||
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
||||
const zone = attentionZone(deskCards, attentionCap);
|
||||
const zone = attentionZone(deskCards, attentionCap, round);
|
||||
|
||||
const zoneLabel = {
|
||||
normal: null,
|
||||
@@ -37,7 +44,8 @@ export default function DeskBar({
|
||||
<div className={`desk-bar zone-${zone}`}>
|
||||
<div className="desk-bar-top">
|
||||
<div className="desk-total">
|
||||
책상 {total}장 · <span className="desk-attention">주목도 {attention}/{attentionCap}</span>
|
||||
책상 {total}장 · <span className="desk-attention">이번 라운드 {current}/{attentionCap}</span>
|
||||
{carried > 0 && <span className="desk-carried"> (이월 {carried})</span>}
|
||||
{surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</span>}
|
||||
<span className="desk-tokens"> · 토큰 {tokens}</span>
|
||||
</div>
|
||||
@@ -46,9 +54,6 @@ export default function DeskBar({
|
||||
<button className="desk-combine-btn" onClick={onOpenCombine} disabled={total < 2}>
|
||||
합본 결재
|
||||
</button>
|
||||
<button className="desk-end-round-btn" onClick={onEndRound} disabled={!canEndRound}>
|
||||
정산하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
.info-tip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.info-tip-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 3px 8px;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.info-tip-trigger:hover,
|
||||
.info-tip-trigger.open {
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.info-tip-trigger.tone-bad {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
border-color: rgba(255, 120, 107, 0.35);
|
||||
color: var(--symbol-threat, #ff7b6b);
|
||||
}
|
||||
|
||||
.info-tip-trigger.tone-good {
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
border-color: rgba(76, 175, 80, 0.4);
|
||||
color: var(--color-token-budget);
|
||||
}
|
||||
|
||||
/* 물음표 배지 — 누르면 설명이 나온다는 신호. 이게 없으면 툴팁의 존재 자체를 모른다. */
|
||||
.info-tip-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: var(--color-text);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-tip-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: max(260px, 100%);
|
||||
max-width: min(340px, 84vw);
|
||||
padding: 10px 12px;
|
||||
background: #101014;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
|
||||
font-family: var(--font-ui);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.info-tip-pop.tone-bad { border-color: rgba(255, 120, 107, 0.5); }
|
||||
.info-tip-pop.tone-good { border-color: rgba(76, 175, 80, 0.5); }
|
||||
|
||||
.info-tip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-tip-badge {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-tip-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.info-tip-summary {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tone-bad .info-tip-summary { color: var(--symbol-threat, #ff7b6b); }
|
||||
.tone-good .info-tip-summary { color: var(--color-token-budget); }
|
||||
|
||||
.info-tip-detail {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 화면 오른쪽 끝에 붙은 트리거는 팝업을 왼쪽으로 정렬해 잘리지 않게 한다. */
|
||||
.info-tip-wrap.align-right .info-tip-pop {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import './InfoTooltip.css';
|
||||
|
||||
// 규약·징벌·명분이 "정확히 무엇을 하는지" 눌러서 확인하는 툴팁 (플레이테스트 피드백).
|
||||
// 규약 이름만 보고는 어떤 족보가 막혔는지 알 수 없다는 지적이 있었다 — 그래서 설명 문구는
|
||||
// UI가 추측하지 않고 엔진 카탈로그(council.js protocolEffectDetail 등)에서 받아 온다.
|
||||
//
|
||||
// 모바일 대상(§1)이라 hover가 아니라 **클릭/탭**으로 연다. 바깥을 누르거나 Esc로 닫는다.
|
||||
export default function InfoTooltip({ title, badge, summary, detail, tone = 'neutral', children, className = '' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onDocDown = (e) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDocDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span className={`info-tip-wrap ${className}`} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`info-tip-trigger tone-${tone} ${open ? 'open' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{children}
|
||||
<span className="info-tip-mark" aria-hidden="true">?</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<span className={`info-tip-pop tone-${tone}`} role="tooltip">
|
||||
<span className="info-tip-head">
|
||||
{badge && <span className="info-tip-badge">{badge}</span>}
|
||||
<span className="info-tip-title">{title}</span>
|
||||
</span>
|
||||
{summary && <span className="info-tip-summary">{summary}</span>}
|
||||
{detail && <span className="info-tip-detail">{detail}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import InfoTooltip from './InfoTooltip';
|
||||
import { protocolEffectDetail } from '../engine/council';
|
||||
|
||||
// 규약·징벌을 이름 + 물음표 배지로 보여주고, 누르면 "정확히 무엇이 막혔는지"를 띄운다.
|
||||
// 설명 문구는 council.js의 카탈로그에서 생성하므로 화면마다 달라지지 않는다.
|
||||
// 상태 표시줄 · 의회 화면 · 합본 패널에서 공용으로 쓴다.
|
||||
export default function ProtocolChip({ protocol, prefix, className = '' }) {
|
||||
if (!protocol) return null;
|
||||
const { typeLabel, summary, detail, tone } = protocolEffectDetail(protocol);
|
||||
return (
|
||||
<InfoTooltip
|
||||
title={protocol.name}
|
||||
badge={`유형 ${protocol.type} · ${typeLabel}`}
|
||||
summary={summary}
|
||||
detail={`${detail}${protocol.flavor ? `\n\n"${protocol.flavor}"` : ''}`}
|
||||
tone={tone}
|
||||
className={className}
|
||||
>
|
||||
<span className="protocol-chip-name">
|
||||
{prefix}{protocol.name}
|
||||
</span>
|
||||
</InfoTooltip>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import { AXES, AXIS_META, clampGrade } from './symbols.js';
|
||||
|
||||
let seq = 0;
|
||||
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
|
||||
export const makeDeskCard = (axis, grade, meta = {}) => ({
|
||||
// round: 이 카드를 받은 라운드 번호. 한도 판정(roundAttention)이 이월 카드를 빼고 세기 위해 필요하다.
|
||||
export const makeDeskCard = (axis, grade, meta = {}, round = 1) => ({
|
||||
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
|
||||
axis,
|
||||
grade: clampGrade(grade),
|
||||
round,
|
||||
name: meta.name || null,
|
||||
flavor: meta.flavor || null,
|
||||
tag: meta.tag || null,
|
||||
@@ -50,50 +52,85 @@ export function axisAttention(deskCards, axis) {
|
||||
.reduce((sum, c) => sum + c.grade, 0);
|
||||
}
|
||||
|
||||
/** 책상 전체 주목도 = 모든 카드의 등급 합. */
|
||||
/** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티
|
||||
* (게이지 상승 A · 토큰 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */
|
||||
export function totalAttention(deskCards) {
|
||||
return deskCards.reduce((sum, c) => sum + c.grade, 0);
|
||||
}
|
||||
|
||||
/** 이번 라운드 주목도 = 이번 라운드에 새로 받은 카드의 등급 합. 하드 캡(버스트) 판정 전용.
|
||||
*
|
||||
* 왜 총 주목도와 분리했나 (2026-09-17): 한도 판정에 책상 전체를 쓰면, 이월된 카드(§4.1) 때문에
|
||||
* 라운드가 시작하자마자 이미 한도를 넘어 있어 즉시 버스트가 반복된다 — 실제 플레이에서
|
||||
* 라운드가 계속 자동으로 넘어가는 버그로 드러났다. 블랙잭에서 각 핸드가 0부터 시작하듯
|
||||
* 한도 판정은 "이번 판에 내가 새로 떠안은 무게"만 센다. 이월 카드의 압박은 사라지지 않고
|
||||
* 총 주목도(게이지·토큰 가산)로 계속 작용한다. */
|
||||
export function roundAttention(deskCards, round) {
|
||||
return deskCards.filter((c) => c.round === round).reduce((sum, c) => sum + c.grade, 0);
|
||||
}
|
||||
|
||||
export function attentionByAxis(deskCards) {
|
||||
const out = {};
|
||||
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 주목도 기준 누진 ── */
|
||||
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 카드 "장수" 기준 급누진 + 등급 배율.
|
||||
*
|
||||
* 2026-09-17 재설계: 등급 합계(attention)만으로는 상승이 너무 느렸다 — 이 게임 카드의 등급이
|
||||
* 대부분 1~4에 몰려 있어서(§17 카드 데이터 현황), 같은 축 카드 서너 장을 쌓아도 attention이
|
||||
* 낮은 구간에 계속 머물러 위협이 전혀 체감되지 않았다(플레이 피드백). 그래서 1차 기준을
|
||||
* "쌓인 장수"로 되돌리되, 등급이 여전히 의미를 갖도록 평균 등급 배율을 곱한다 —
|
||||
* 저등급 카드만 쌓이면 완만하고, 고등급이 섞이면 같은 장수에서도 훨씬 아프다.
|
||||
* 피드백 원문: "누적 카드 3장 이상일 때부터 증가값이 엄청 커졌으면 함". */
|
||||
|
||||
const GAUGE_TIERS = [
|
||||
{ max: 0, rate: 0 },
|
||||
{ max: 15, rate: 0.5 },
|
||||
{ max: 30, rate: 1.5 },
|
||||
{ max: 40, rate: 3.0 },
|
||||
const GAUGE_COUNT_TIERS = [
|
||||
{ max: 2, base: 0.5 }, // 1~2장: 아직 완만
|
||||
{ max: 4, base: 3.0 }, // 3~4장: 여기서 확 뛴다 (피드백 핵심 요구)
|
||||
{ max: 6, base: 6.0 },
|
||||
{ max: 9, base: 10.0 },
|
||||
];
|
||||
const GAUGE_OVERRUN_FROM = 41;
|
||||
const GAUGE_OVERRUN_BASE = 5.0;
|
||||
const GAUGE_COUNT_OVERRUN_FROM = 10;
|
||||
const GAUGE_COUNT_OVERRUN_BASE = 15.0;
|
||||
const GAUGE_COUNT_OVERRUN_STEP = 2.0; // 10장부터 1장마다 가산
|
||||
|
||||
/** 축 주목도에 따른 결재 1건당 게이지 상승률(%). 41 이상은 초과분 10당 +1.0% 추가 누진. */
|
||||
export function gaugeRiseRate(attention) {
|
||||
const tier = GAUGE_TIERS.find((t) => attention <= t.max);
|
||||
if (tier) return tier.rate;
|
||||
const overrun = attention - GAUGE_OVERRUN_FROM;
|
||||
return GAUGE_OVERRUN_BASE + Math.floor(overrun / 10) * 1.0;
|
||||
const GAUGE_GRADE_BASELINE = 5; // 이 등급이 "평균" — 배율 1.0 기준점 (등급 범위 1~10의 중앙)
|
||||
|
||||
/** 축에 쌓인 장수 기준 결재 1건당 게이지 상승 기본값(%). */
|
||||
function gaugeCountBase(count) {
|
||||
const tier = GAUGE_COUNT_TIERS.find((t) => count <= t.max);
|
||||
if (tier) return tier.base;
|
||||
return GAUGE_COUNT_OVERRUN_BASE + (count - GAUGE_COUNT_OVERRUN_FROM) * GAUGE_COUNT_OVERRUN_STEP;
|
||||
}
|
||||
|
||||
/** 축 카드 수·등급 합계로 결재 1건당 게이지 상승률(%)을 낸다.
|
||||
* base(장수 구간) × (평균 등급 / 5) — 고등급이 섞이면 같은 장수에서도 더 아프다. */
|
||||
export function gaugeRiseRate(count, attention) {
|
||||
if (count <= 0) return 0;
|
||||
const base = gaugeCountBase(count);
|
||||
const avgGrade = attention / count;
|
||||
return base * (avgGrade / GAUGE_GRADE_BASELINE);
|
||||
}
|
||||
|
||||
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
|
||||
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
|
||||
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
|
||||
const counts = countByAxis(deskCards);
|
||||
const out = {};
|
||||
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat; });
|
||||
// extraFlat(징벌 규약 §9.2 유형C)은 "모든 축의 게이지 상승"이라 카드 유무와 무관하게 항상 더한다.
|
||||
AXES.forEach((axis) => {
|
||||
out[axis] = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
|
||||
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
||||
const counts = countByAxis(deskCards);
|
||||
const next = { ...gauges };
|
||||
AXES.forEach((axis) => {
|
||||
const gaugeKey = AXIS_META[axis].gauge;
|
||||
const rate = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat;
|
||||
const rate = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
|
||||
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
|
||||
});
|
||||
return next;
|
||||
@@ -103,21 +140,23 @@ export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
||||
* 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월).
|
||||
* 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다.
|
||||
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
|
||||
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원. */
|
||||
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원.
|
||||
*
|
||||
* 2026-09-17: 판정 기준을 총 주목도 → 이번 라운드 주목도로 교체 (roundAttention 주석 참고). */
|
||||
|
||||
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
|
||||
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
|
||||
|
||||
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. */
|
||||
export function roundAttentionCap(round) {
|
||||
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH;
|
||||
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. bonus: 이득 규약의 한도 보너스(§9.2 유형 D). */
|
||||
export function roundAttentionCap(round, bonus = 0) {
|
||||
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH + bonus;
|
||||
}
|
||||
|
||||
/** 한도 대비 총 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
|
||||
export function attentionZone(deskCards, cap) {
|
||||
const total = totalAttention(deskCards);
|
||||
if (total > cap) return 'bust';
|
||||
if (total >= cap - 5) return 'warning';
|
||||
/** 한도 대비 이번 라운드 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
|
||||
export function attentionZone(deskCards, cap, round) {
|
||||
const current = roundAttention(deskCards, round);
|
||||
if (current > cap) return 'bust';
|
||||
if (current >= cap - 5) return 'warning';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
@@ -131,11 +170,12 @@ const SURCHARGE_TIERS = [
|
||||
];
|
||||
|
||||
/** 총 주목도 기준 모든 토큰 지출에 붙는 가산액.
|
||||
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyTokenSurcharge). */
|
||||
* extraFlat: 활성 규약이 얹는 가산분(council.js의 tokenSurchargeOf) — 이득 규약이면 음수라
|
||||
* 0 밑으로 내려갈 수 있으므로, 지출이 되레 할인되지 않도록 0에서 끊는다. */
|
||||
export function tokenSurcharge(deskCards, extraFlat = 0) {
|
||||
const total = totalAttention(deskCards);
|
||||
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
||||
return (tier ? tier.surcharge : 4) + extraFlat;
|
||||
return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat);
|
||||
}
|
||||
|
||||
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
||||
|
||||
Reference in New Issue
Block a user