규약 세부화(유형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);
|
||||
}
|
||||
|
||||
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
||||
|
||||
@@ -3,30 +3,32 @@ id,type,keyword,question,text,flavor,left_text,left_axis,left_grade,left_tag,rig
|
||||
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,axis_threat,9,tag_madness,제안 거절,axis_hr,7,
|
||||
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,5,tag_sacrifice,작전 기각,axis_hr,2,
|
||||
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
|
||||
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,,,tag_coverup,프로젝트 보류,axis_hr,2,
|
||||
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,axis_pr,2,tag_coverup,프로젝트 보류,axis_hr,2,
|
||||
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,4,,군사력으로 제압,axis_external,4,
|
||||
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_threat,1,
|
||||
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_pr,1,
|
||||
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,4,tag_corruption,횡령 사실 인정,axis_external,8,
|
||||
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
|
||||
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,2,tag_alien_contact,폐쇄 상태 유지,axis_hr,2,
|
||||
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,,,tag_corruption,영구 봉인,,,
|
||||
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,axis_external,6,tag_corruption,영구 봉인,,,
|
||||
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,2,
|
||||
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_external,8,tag_ruthless,결사 방어 명령,,,
|
||||
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_pr,8,tag_ruthless,결사 방어 명령,,,
|
||||
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
|
||||
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,7,,즉각 폐기 처분,axis_hr,7,
|
||||
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
|
||||
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_external,9,
|
||||
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_pr,9,
|
||||
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,8,
|
||||
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,2,
|
||||
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,2,,윤리적 이유로 기각,axis_external,2,
|
||||
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
|
||||
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,10,tag_madness,모든 예비병력 투입,axis_external,4,tag_sacrifice
|
||||
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,10,tag_madness,모든 예비병력 투입,axis_pr,4,tag_sacrifice
|
||||
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,,,,조사팀 파견,axis_threat,4,
|
||||
3,위기,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,axis_external,4,,규정대로 반려,axis_threat,4,
|
||||
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,,,예산 전액 삭감,axis_external,7,
|
||||
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_hr,4,,의식 방관,axis_threat,6,
|
||||
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_threat,5,,의식 방관,axis_threat,6,
|
||||
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,,,,제안 단호히 거절,axis_external,2,
|
||||
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,axis_threat,7,,유물 파괴 명령,axis_external,7,
|
||||
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,4,,진정제 무상 배포,,,
|
||||
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,4,,진정제 무상 배포,axis_pr,2,
|
||||
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,,,트라우마 기억 소거,axis_external,1,
|
||||
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,
|
||||
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,"
|
||||
""
|
||||
"
|
||||
|
||||
|
Reference in New Issue
Block a user