라운드 접수량 버그 수정 + 토큰→코인 용어 통일 + 외부 배포(NAS)

플레이 피드백 4건(주목도 미차감, 상점 빈 상품, 상점 UX 오해, 재화 명칭)을
검토해 방향이 확정된 항목만 반영하고, 외부 테스터에게 링크를 공유할 수 있게
시놀로지 NAS 배포까지 준비했다.

## 게임 로직

- 라운드 접수량 크리티컬 버그 수정: 합본을 반복하면 라운드가 안 끝나던 문제.
  deskCards에서 파생시키던 값을 순수 누적 카운터(roundIntake)로 교체 — 결정
  노트: 2026-09-18-라운드-접수량-누적카운터
- DeskBar/CoreSwipeCard 정보 역할 분리: "이번 라운드 접수량/한도"는 결재
  화면 전담, DeskBar는 책상에 쌓인 것(총 주목도·축별 분포)만 표시
- 토큰 → 코인 용어 일괄 변경: core/ 전체(식별자·CSS·주석·UI)와 기획서 SSOT
  동기화, lucide Coins 아이콘 추가

## 배포

- vite base를 상대 경로로 변경 (Web Station 서브패스 서빙 시 asset 404 방지)
- 모바일 100vh → 100dvh (주소창 노출 시 하단 버튼 가림 방지)
- 빌드 식별자 표시, 타이틀/테마색/description 정리
- deploy.sh + npm run deploy — SSH 키 권한 문제(777 → StrictModes 거부)와
  NAS rsync 게이트를 scp -O로 우회해 실제 배포까지 검증 완료
  → https://play.mint-grape.duckdns.org/projectss/

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kyoung5seo
2026-09-18 23:20:22 +09:00
co-authored by Claude Sonnet 5
parent 6398599199
commit 20207ff23c
27 changed files with 451 additions and 239 deletions
+7 -2
View File
@@ -2,9 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<!-- maximum-scale/user-scalable은 넣지 않는다 — 확대를 막으면 작은 글씨를 읽어야 하는
테스터의 접근성을 해친다. 대신 레이아웃이 390px 폭에서 깨지지 않도록 맞췄다. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>projectss</title> <!-- 모바일 브라우저 상단/하단 바를 다크 UI와 맞춘다 -->
<meta name="theme-color" content="#121212" />
<meta name="description" content="관료주의 코스믹 호러 덱빌딩 — 종말을 결재하시겠습니까?" />
<title>Project SS — 종말을 결재하시겠습니까?</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -1
View File
@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"deploy": "bash ../deploy.sh",
"deploy:dry": "bash ../deploy.sh --dry-run"
}, },
"dependencies": { "dependencies": {
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
+3 -3
View File
@@ -104,7 +104,7 @@
.combine-card.selected { .combine-card.selected {
opacity: 1; opacity: 1;
outline: 2px solid var(--color-token-budget); outline: 2px solid var(--color-coin);
border-radius: 6px; border-radius: 6px;
} }
@@ -148,7 +148,7 @@
} }
.combine-match:hover { .combine-match:hover {
border-color: var(--color-token-budget); border-color: var(--color-coin);
} }
.match-top { .match-top {
@@ -162,7 +162,7 @@
font-weight: 700; font-weight: 700;
} }
.match-score { color: var(--color-token-budget); } .match-score { color: var(--color-coin); }
.match-detail { .match-detail {
font-size: 11px; font-size: 11px;
+3 -3
View File
@@ -10,7 +10,7 @@ import './CombinePanel.css';
// 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다. // 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다.
// //
// 2026-09-15 결정 반영: 선택은 최대 5장(§5.1.1). 족보에 실제로 쓰이는 카드만 소멸하므로, // 2026-09-15 결정 반영: 선택은 최대 5장(§5.1.1). 족보에 실제로 쓰이는 카드만 소멸하므로,
// 각 족보 후보가 어떤 카드를 가져가고 토큰이 얼마 나오는지(등급 합계, §8.2)를 미리 보여준다. // 각 족보 후보가 어떤 카드를 가져가고 코인이 얼마 나오는지(등급 합계, §8.2)를 미리 보여준다.
export default function CombinePanel({ deskCards, onConfirm, onClose, activeProtocols = [] }) { export default function CombinePanel({ deskCards, onConfirm, onClose, activeProtocols = [] }) {
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
@@ -93,7 +93,7 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
{matches.map((patternId) => { {matches.map((patternId) => {
const preview = resolveCombine(selection, patternId); const preview = resolveCombine(selection, patternId);
if (!preview) return null; if (!preview) return null;
const { pattern, usedCards, tokens, justification, performanceScore } = preview; const { pattern, usedCards, coins, justification, performanceScore } = preview;
return ( return (
<button key={patternId} className="combine-match" onClick={() => confirm(patternId)}> <button key={patternId} className="combine-match" onClick={() => confirm(patternId)}>
<div className="match-top"> <div className="match-top">
@@ -101,7 +101,7 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
<span className="match-score">실적 +{performanceScore}</span> <span className="match-score">실적 +{performanceScore}</span>
</div> </div>
<div className="match-detail"> <div className="match-detail">
토큰 +{tokens} · 명분 {justification.type} +{justification.amount} · {usedCards.length}장 소멸 코인 +{coins} · 명분 {justification.type} +{justification.amount} · {usedCards.length}장 소멸
</div> </div>
<div className="match-used"> <div className="match-used">
{usedCards.map((c) => ( {usedCards.map((c) => (
@@ -45,6 +45,14 @@
color: var(--color-risk-critical); color: var(--color-risk-critical);
} }
/* 빌드 식별자 — 항상 보이되 시선을 끌지는 않게. 테스터가 스크린샷만 찍어도 버전이 남는다. */
.core-build-stamp {
margin-left: auto;
opacity: 0.4;
font-size: 10px;
white-space: nowrap;
}
.core-status-penalty { .core-status-penalty {
color: var(--color-entropy-critical); color: var(--color-entropy-critical);
} }
+47 -36
View File
@@ -1,3 +1,4 @@
/* global __BUILD_INFO__ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import Papa from 'papaparse'; import Papa from 'papaparse';
import cardsCsvRaw from '../../data/cards.csv?raw'; import cardsCsvRaw from '../../data/cards.csv?raw';
@@ -10,18 +11,18 @@ import ShopScreen from './ShopScreen';
import CouncilScreen from './CouncilScreen'; import CouncilScreen from './CouncilScreen';
import { pickPollutionEntry } from '../engine/pollutionPool'; import { pickPollutionEntry } from '../engine/pollutionPool';
import { import {
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge, makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, coinSurcharge,
roundAttention, roundAttentionCap, roundAttentionCap,
} from '../engine/desk'; } from '../engine/desk';
import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop'; import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop';
import { import {
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty, requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
applyInterventions, gaugeSurchargeOf, tokenSurchargeOf, attentionCapBonusOf, applyInterventions, gaugeSurchargeOf, coinSurchargeOf, attentionCapBonusOf,
requiredScoreDeltaOf, grantTokenOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS, requiredScoreDeltaOf, grantCoinOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS,
} from '../engine/council'; } from '../engine/council';
import './CoreGameScreen.css'; import './CoreGameScreen.css';
// 코어 루프 그레이박스 — §17 1차(기호·책상·족보·토큰) + 2차(상점·규약·실적감사·명분). // 코어 루프 그레이박스 — §17 1차(기호·책상·족보·코인) + 2차(상점·규약·실적감사·명분).
// //
// 2026-09-16 결정 반영 (라운드/회기 이층 구조 폐지, 결정 노트: // 2026-09-16 결정 반영 (라운드/회기 이층 구조 폐지, 결정 노트:
// 2026-09-16-라운드-주목도-하드캡-복원): 라운드 하나 = 결재하다 정산(상점)→의회로 가는 단일 // 2026-09-16-라운드-주목도-하드캡-복원): 라운드 하나 = 결재하다 정산(상점)→의회로 가는 단일
@@ -33,7 +34,7 @@ import './CoreGameScreen.css';
// 자산·전례·소모품(§10~12, 3차)과 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6, 4차)은 // 자산·전례·소모품(§10~12, 3차)과 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6, 4차)은
// 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 무작위로 흉내만 낸다. // 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 무작위로 흉내만 낸다.
const INITIAL_TOKENS = 5; // §8.4 초기 지급 — 토큰 0으로 아무것도 못 하는 데드락 방지 const INITIAL_COINS = 5; // §8.4 초기 지급 — 코인 0으로 아무것도 못 하는 데드락 방지
const JUSTIFICATION_CAP = 5; // §7.1 보관 최대 5개 const JUSTIFICATION_CAP = 5; // §7.1 보관 최대 5개
const buildCard = (row) => ({ const buildCard = (row) => ({
@@ -82,7 +83,7 @@ export default function CoreGameScreen() {
const [drawQueue, setDrawQueue] = useState(() => shuffle(deck)); const [drawQueue, setDrawQueue] = useState(() => shuffle(deck));
const [deskCards, setDeskCards] = useState([]); const [deskCards, setDeskCards] = useState([]);
const [gauges, setGauges] = useState(INITIAL_GAUGES); const [gauges, setGauges] = useState(INITIAL_GAUGES);
const [tokens, setTokens] = useState(INITIAL_TOKENS); const [coins, setCoins] = useState(INITIAL_COINS);
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [justifications, setJustifications] = useState({}); // { 명분종류: 수량 } const [justifications, setJustifications] = useState({}); // { 명분종류: 수량 }
const [showCombine, setShowCombine] = useState(false); const [showCombine, setShowCombine] = useState(false);
@@ -93,6 +94,11 @@ export default function CoreGameScreen() {
const [phase, setPhase] = useState('approval'); // 'approval' | 'shop' | 'council' const [phase, setPhase] = useState('approval'); // 'approval' | 'shop' | 'council'
const [round, setRound] = useState(1); const [round, setRound] = useState(1);
const [roundScore, setRoundScore] = useState(0); // §9.3: 이번 라운드 중 합본한 족보 점수 합산 const [roundScore, setRoundScore] = useState(0); // §9.3: 이번 라운드 중 합본한 족보 점수 합산
// 2026-09-18 결정(순수 누적 카운터, 결정 노트: 2026-09-18-라운드-접수량-누적카운터):
// 이번 라운드 접수량 = 카드를 받을 때마다 등급만큼 더하는 카운터. 라운드가 바뀔 때만 0으로
// 리셋되고, 합본으로 책상 카드가 사라져도 줄지 않는다 — desk.js에서 deskCards를 필터링해
// 파생시켰더니 합본할 때마다 줄어서 "합본을 반복하면 라운드가 안 끝나는" 문제가 있었다.
const [roundIntake, setRoundIntake] = useState(0);
const [penalties, setPenalties] = useState([]); // §9.2 유형C, 영구 누적 const [penalties, setPenalties] = useState([]); // §9.2 유형C, 영구 누적
const [activeProtocol, setActiveProtocol] = useState(null); // §9.2 유형A, 이번 라운드 1개 const [activeProtocol, setActiveProtocol] = useState(null); // §9.2 유형A, 이번 라운드 1개
const [deferredCandidates, setDeferredCandidates] = useState([]); // 절차 명분 이월분 (§7.3) const [deferredCandidates, setDeferredCandidates] = useState([]); // 절차 명분 이월분 (§7.3)
@@ -108,8 +114,8 @@ export default function CoreGameScreen() {
// 이득 규약이면 음수라 페널티가 상쇄된다. // 이득 규약이면 음수라 페널티가 상쇄된다.
const effectiveProtocols = [...penalties, ...activeProtocols]; const effectiveProtocols = [...penalties, ...activeProtocols];
const gaugeExtraFlat = gaugeSurchargeOf(effectiveProtocols); const gaugeExtraFlat = gaugeSurchargeOf(effectiveProtocols);
const tokenExtraFlat = tokenSurchargeOf(effectiveProtocols); const coinExtraFlat = coinSurchargeOf(effectiveProtocols);
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat); const surcharge = coinSurcharge(deskCards, coinExtraFlat);
const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0); const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0);
const attentionCap = roundAttentionCap(round, attentionCapBonusOf(activeProtocols)); const attentionCap = roundAttentionCap(round, attentionCapBonusOf(activeProtocols));
@@ -150,15 +156,16 @@ export default function CoreGameScreen() {
const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat); const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat);
setGauges(nextGauges); setGauges(nextGauges);
// setState는 비동기라, 이번 카드 반영 후의 책상 상태를 한도 판정에 바로 써야 하므로 // setState는 비동기라, 이번 카드 반영 후의 접수량을 한도 판정에 바로 써야 하므로
// 로컬 변수로 미리 계산한다. // 로컬 변수로 미리 계산한다.
let nextDesk = deskCards; let nextIntake = roundIntake;
if (side.axis) { if (side.axis) {
const grade = side.grade || 1; const grade = side.grade || 1;
const entry = pickPollutionEntry(side.axis, grade); const entry = pickPollutionEntry(side.axis, grade);
const newCard = makeDeskCard(side.axis, grade, entry || {}, round); const newCard = makeDeskCard(side.axis, grade, entry || {}, round);
nextDesk = addToDesk(deskCards, [newCard]); setDeskCards((prev) => addToDesk(prev, [newCard]));
setDeskCards(nextDesk); nextIntake = roundIntake + grade;
setRoundIntake(nextIntake);
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`); pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
} else { } else {
pushLog(`『${side.text}』 결재됨 — 오염 없음`); pushLog(`『${side.text}』 결재됨 — 오염 없음`);
@@ -169,9 +176,9 @@ export default function CoreGameScreen() {
if (checkGameOver(nextGauges)) return; if (checkGameOver(nextGauges)) return;
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1). // §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
// 이월 카드는 한도 판정에서 빠진다(roundAttention) — 안 그러면 다음 라운드가 시작하자마자 // roundIntake는 순수 누적 카운터라 합본해도 줄지 않는다 — 그래야 "합본을 반복하면 라운드가
// 버스트가 반복된다. // 안 끝나는" 문제 없이 한도가 실제로 라운드를 마감시킨다.
if (roundAttention(nextDesk, round) > attentionCap) { if (nextIntake > attentionCap) {
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`); pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
goToShop(); goToShop();
return; return;
@@ -181,14 +188,14 @@ export default function CoreGameScreen() {
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */ /** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
const handleEndRound = () => { const handleEndRound = () => {
if (phase !== 'approval') return; if (phase !== 'approval') return;
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${roundAttention(deskCards, round)}/${attentionCap}`); pushLog(`제${round}라운드 자율 종료(정산하기) — 접수량 ${roundIntake}/${attentionCap}`);
goToShop(); goToShop();
}; };
const handleCombineConfirm = (result) => { const handleCombineConfirm = (result) => {
// 족보에 실제로 쓰인 카드만 소멸한다 — 선택했지만 안 쓰인 카드는 책상에 남는다 (§4.1 이월). // 족보에 실제로 쓰인 카드만 소멸한다 — 선택했지만 안 쓰인 카드는 책상에 남는다 (§4.1 이월).
setDeskCards((prev) => removeFromDesk(prev, result.removeIds)); setDeskCards((prev) => removeFromDesk(prev, result.removeIds));
setTokens((prev) => prev + result.tokens); // §8.2 토큰 1종, 등급 합계만큼 setCoins((prev) => prev + result.coins); // §8.2 코인 1종, 등급 합계만큼
// §7.1 명분: 족보표의 명분 열이 곧 "수량"이다(0.5~5). 종류(4종)별로 누적하고 총합 5로 제한한다. // §7.1 명분: 족보표의 명분 열이 곧 "수량"이다(0.5~5). 종류(4종)별로 누적하고 총합 5로 제한한다.
// 0.5짜리 페어는 두 번 모아야 1개가 되므로, 하위 족보만 반복하면 정치 자원이 잘 안 쌓인다. // 0.5짜리 페어는 두 번 모아야 1개가 되므로, 하위 족보만 반복하면 정치 자원이 잘 안 쌓인다.
setJustifications((prev) => { setJustifications((prev) => {
@@ -201,7 +208,7 @@ export default function CoreGameScreen() {
}); });
setScore((prev) => prev + result.performanceScore); setScore((prev) => prev + result.performanceScore);
setRoundScore((prev) => prev + result.performanceScore); setRoundScore((prev) => prev + result.performanceScore);
pushLog(`합본 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 토큰 +${result.tokens}, 명분 ${result.justification.type} +${result.justification.amount} (${result.removeIds.length}장 소멸)`); pushLog(`합본 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 코인 +${result.coins}, 명분 ${result.justification.type} +${result.justification.amount} (${result.removeIds.length}장 소멸)`);
setShowCombine(false); setShowCombine(false);
}; };
@@ -210,13 +217,13 @@ export default function CoreGameScreen() {
const card = deskCards.find((c) => c.id === cardId); const card = deskCards.find((c) => c.id === cardId);
if (!card) return; if (!card) return;
const price = purgePrice(card, surcharge); const price = purgePrice(card, surcharge);
setTokens((prev) => prev - price); setCoins((prev) => prev - price);
setDeskCards((prev) => removeFromDesk(prev, [cardId])); setDeskCards((prev) => removeFromDesk(prev, [cardId]));
setShopDisplay((prev) => prev.filter((c) => c.id !== cardId)); setShopDisplay((prev) => prev.filter((c) => c.id !== cardId));
pushLog(`상점: 『${card.name || '오염 카드'}』(등급 ${card.grade}) 직접 소각 — 토큰 -${price}`); pushLog(`상점: 『${card.name || '오염 카드'}』(등급 ${card.grade}) 직접 소각 — 코인 -${price}`);
}; };
const handleShopReroll = () => { const handleShopReroll = () => {
setTokens((prev) => prev - REROLL_COST); setCoins((prev) => prev - REROLL_COST);
setShopDisplay(rollShopDisplay(deskCards)); setShopDisplay(rollShopDisplay(deskCards));
setShopRerolled(true); setShopRerolled(true);
}; };
@@ -227,19 +234,19 @@ export default function CoreGameScreen() {
}; };
/* ── 의회 (§9) ── */ /* ── 의회 (§9) ── */
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => { const handleCouncilResolve = ({ coinsPaid, justificationsPaidCount, interventions }) => {
const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } = const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } =
applyInterventions(councilCandidates, interventions); applyInterventions(councilCandidates, interventions);
// 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D). // 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D).
const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols)); const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols));
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20. // §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 코인 납입×5 + 명분 납입×20.
const finalScore = settleScore({ const finalScore = settleScore({
combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount, combineScore: roundScore, coinsPaid, justificationsPaid: justificationsPaidCount,
}); });
const passed = finalScore >= finalRequired; const passed = finalScore >= finalRequired;
setTokens((prev) => prev - tokensPaid); setCoins((prev) => prev - coinsPaid);
setJustifications({}); // §7.1: 다음 의회까지만 유효 — 쓰지 않은 것도 여기서 소멸 setJustifications({}); // §7.1: 다음 의회까지만 유효 — 쓰지 않은 것도 여기서 소멸
// 징벌 규약 갱신. setState updater 안에서 로그를 찍으면 StrictMode의 이중 호출 때문에 // 징벌 규약 갱신. setState updater 안에서 로그를 찍으면 StrictMode의 이중 호출 때문에
@@ -275,10 +282,10 @@ export default function CoreGameScreen() {
setActiveProtocol(winner); setActiveProtocol(winner);
if (winner) { if (winner) {
pushLog(`규약 통과: 『${winner.name}』 ${winner.votes}/${COUNCIL_SEATS}표 — 다음 라운드 적용`); pushLog(`규약 통과: 『${winner.name}』 ${winner.votes}/${COUNCIL_SEATS}표 — 다음 라운드 적용`);
const grant = grantTokenOf(winner); const grant = grantCoinOf(winner);
if (grant > 0) { if (grant > 0) {
setTokens((prev) => prev + grant); setCoins((prev) => prev + grant);
pushLog(`이득 규약 『${winner.name}』 — 토큰 +${grant} 즉시 지급`); pushLog(`이득 규약 『${winner.name}』 — 코인 +${grant} 즉시 지급`);
} }
} else { } else {
pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.'); pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
@@ -286,6 +293,7 @@ export default function CoreGameScreen() {
setRound((r) => r + 1); setRound((r) => r + 1);
setRoundScore(0); setRoundScore(0);
setRoundIntake(0); // 새 라운드는 접수량 0에서 다시 시작한다 (블랙잭의 새 핸드)
setPhase('approval'); setPhase('approval');
}; };
@@ -312,13 +320,11 @@ export default function CoreGameScreen() {
<DeskBar <DeskBar
deskCards={deskCards} deskCards={deskCards}
gauges={gauges} gauges={gauges}
tokens={tokens} coins={coins}
round={round}
attentionCap={attentionCap}
onOpenCombine={() => setShowCombine(true)} onOpenCombine={() => setShowCombine(true)}
onOpenGuide={() => setShowGuide(true)} onOpenGuide={() => setShowGuide(true)}
gaugeExtraFlat={gaugeExtraFlat} gaugeExtraFlat={gaugeExtraFlat}
tokenExtraFlat={tokenExtraFlat} coinExtraFlat={coinExtraFlat}
/> />
<div className="core-status-row"> <div className="core-status-row">
@@ -327,6 +333,11 @@ export default function CoreGameScreen() {
<span>명분 {justificationTotal}개</span> <span>명분 {justificationTotal}개</span>
{activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />} {activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />}
{penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)} {penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)}
{/* 빌드 식별자 — 외부 테스터 피드백을 "어느 빌드였나"에 붙여 정리하기 위한 표시.
vite.config.js가 빌드 시각+커밋 해시를 주입한다. */}
<span className="core-build-stamp" title="빌드 식별자 (피드백 시 함께 적어 주세요)">
{__BUILD_INFO__}
</span>
</div> </div>
{phase === 'approval' && ( {phase === 'approval' && (
@@ -334,7 +345,7 @@ export default function CoreGameScreen() {
card={currentCard} card={currentCard}
onSwipe={handleSwipe} onSwipe={handleSwipe}
onEndRound={handleEndRound} onEndRound={handleEndRound}
roundAttention={roundAttention(deskCards, round)} roundIntake={roundIntake}
attentionCap={attentionCap} attentionCap={attentionCap}
/> />
)} )}
@@ -342,7 +353,7 @@ export default function CoreGameScreen() {
{phase === 'shop' && ( {phase === 'shop' && (
<ShopScreen <ShopScreen
display={shopDisplay} display={shopDisplay}
tokens={tokens} coins={coins}
deskSurcharge={surcharge} deskSurcharge={surcharge}
onPurge={handleShopPurge} onPurge={handleShopPurge}
onReroll={handleShopReroll} onReroll={handleShopReroll}
@@ -355,7 +366,7 @@ export default function CoreGameScreen() {
<CouncilScreen <CouncilScreen
round={round} round={round}
combineScore={roundScore} combineScore={roundScore}
tokens={tokens} coins={coins}
justifications={justifications} justifications={justifications}
penalties={penalties} penalties={penalties}
candidates={councilCandidates} candidates={councilCandidates}
+9 -5
View File
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion'; import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
import { SymbolFace } from './SymbolBadge'; import { SymbolFace } from './SymbolBadge';
import { attentionZone } from '../engine/desk';
import './CoreSwipeCard.css'; import './CoreSwipeCard.css';
// §3 핵심 요건: "스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다." 텍스트는 서사만 담당한다. // §3 핵심 요건: "스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다." 텍스트는 서사만 담당한다.
@@ -12,9 +13,12 @@ import './CoreSwipeCard.css';
// //
// 2026-09-17 피드백: "정산하기"는 카드를 더 받을지 라운드를 끝낼지 정하는 핵심 결정인데 // 2026-09-17 피드백: "정산하기"는 카드를 더 받을지 라운드를 끝낼지 정하는 핵심 결정인데
// 화면 오른쪽 위 툴바에 있으니 눈에 안 들어온다는 지적. 결재 카드 바로 아래, 선택을 내리는 // 화면 오른쪽 위 툴바에 있으니 눈에 안 들어온다는 지적. 결재 카드 바로 아래, 선택을 내리는
// 자리로 옮기고 지금 주목도/한도를 버튼 옆에 같이 붙였다 — 블랙잭에서 "스탠드"를 누르기 전에 // 자리로 옮기고 지금 접수량/한도를 버튼 옆에 같이 붙였다 — 블랙잭에서 "스탠드"를 누르기 전에
// 내 패 합계를 보는 것과 같다. // 내 패 합계를 보는 것과 같다.
export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundAttention = 0, attentionCap = 0 }) { //
// 2026-09-18: 이번 라운드 접수량/한도는 "카드를 더 받을지" 판단에 쓰는 정보라 결재 화면(여기)
// 소관이다. 책상 전체의 주목도(이월 포함)는 DeskBar가 따로 보여준다 — 둘은 용도가 다르다.
export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundIntake = 0, attentionCap = 0 }) {
const x = useMotionValue(0); const x = useMotionValue(0);
const y = useMotionValue(0); const y = useMotionValue(0);
const controls = useAnimation(); const controls = useAnimation();
@@ -45,8 +49,8 @@ export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundAttentio
if (!card) return null; if (!card) return null;
const remaining = Math.max(0, attentionCap - roundAttention); const remaining = Math.max(0, attentionCap - roundIntake);
const zone = remaining === 0 ? 'bust' : remaining <= 5 ? 'warning' : 'normal'; const zone = attentionZone(roundIntake, attentionCap); // desk.js와 같은 기준 (§4.2-B)
return ( return (
<div className="core-card-container"> <div className="core-card-container">
@@ -96,7 +100,7 @@ export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundAttentio
{onEndRound && ( {onEndRound && (
<div className={`core-round-action zone-${zone}`}> <div className={`core-round-action zone-${zone}`}>
<span className="core-round-attention"> <span className="core-round-attention">
이번 라운드 주목도 <strong>{roundAttention}</strong> / {attentionCap} 이번 라운드 접수량 <strong>{roundIntake}</strong> / {attentionCap}
{remaining > 0 {remaining > 0
? <span className="core-round-remaining"> · 한도까지 {remaining}</span> ? <span className="core-round-remaining"> · 한도까지 {remaining}</span>
: <span className="core-round-remaining"> · 한도 도달</span>} : <span className="core-round-remaining"> · 한도 도달</span>}
+12 -12
View File
@@ -41,7 +41,7 @@
font-weight: 700; font-weight: 700;
} }
.audit-met { color: var(--color-token-budget); } .audit-met { color: var(--color-coin); }
.audit-short { color: var(--color-risk-critical); } .audit-short { color: var(--color-risk-critical); }
.council-audit-breakdown { .council-audit-breakdown {
@@ -50,7 +50,7 @@
margin: 6px 0 12px; margin: 6px 0 12px;
} }
.council-token-pay { .council-coin-pay {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -62,7 +62,7 @@
border-radius: 5px; border-radius: 5px;
} }
.council-token-pay button { .council-coin-pay button {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border: none; border: none;
color: var(--color-text); color: var(--color-text);
@@ -72,18 +72,18 @@
cursor: pointer; cursor: pointer;
} }
.council-token-pay button:disabled { .council-coin-pay button:disabled {
opacity: 0.3; opacity: 0.3;
cursor: not-allowed; cursor: not-allowed;
} }
.council-token-committed { .council-coin-committed {
min-width: 14px; min-width: 14px;
text-align: center; text-align: center;
color: var(--color-token-budget); color: var(--color-coin);
} }
.council-token-note { .council-coin-note {
opacity: 0.5; opacity: 0.5;
font-size: 11px; font-size: 11px;
} }
@@ -168,7 +168,7 @@
} }
.council-justification-item button:hover { .council-justification-item button:hover {
border-color: var(--color-token-budget); border-color: var(--color-coin);
} }
.council-justification-item button:disabled { .council-justification-item button:disabled {
@@ -223,7 +223,7 @@
} }
.council-candidate.forced { .council-candidate.forced {
border-color: var(--color-token-budget); border-color: var(--color-coin);
} }
.candidate-name { .candidate-name {
@@ -259,7 +259,7 @@
.council-required-delta { .council-required-delta {
font-size: 11px; font-size: 11px;
color: var(--color-token-budget); color: var(--color-coin);
font-weight: 400; font-weight: 400;
} }
@@ -306,7 +306,7 @@
.candidate-votes.lead { .candidate-votes.lead {
opacity: 1; opacity: 1;
color: var(--color-token-budget); color: var(--color-coin);
font-weight: 700; font-weight: 700;
} }
@@ -317,7 +317,7 @@
} }
.council-candidate.winning { .council-candidate.winning {
border-color: var(--color-token-budget); border-color: var(--color-coin);
} }
.council-candidate.added { .council-candidate.added {
+13 -12
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Coins } from 'lucide-react';
import { import {
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, justificationDetail, requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, justificationDetail,
availableForTabling, applyInterventions, tallyVotes, applyVoteSwing, resolveProtocolVote, availableForTabling, applyInterventions, tallyVotes, applyVoteSwing, resolveProtocolVote,
@@ -11,16 +12,16 @@ import './CouncilScreen.css';
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이 // 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
// 상정되지 않는다. 플레이어는 표결에 직접 개입 못 하고 명분으로 안건 자체를 조작할 뿐이다(§9.2). // 상정되지 않는다. 플레이어는 표결에 직접 개입 못 하고 명분으로 안건 자체를 조작할 뿐이다(§9.2).
// //
// 2026-09-15 결정: 토큰 1종, 명분 개입 4갈래(§7.3). 명분은 종류별 "수량"이라 1개 이상이어야 쓴다. // 2026-09-15 결정: 코인 1종, 명분 개입 4갈래(§7.3). 명분은 종류별 "수량"이라 1개 이상이어야 쓴다.
// 2026-09-17: §9.2의 13석 표결을 실제 표 수로 구현하고, 개입을 반영한 **예상 득표를 미리** 보여준다. // 2026-09-17: §9.2의 13석 표결을 실제 표 수로 구현하고, 개입을 반영한 **예상 득표를 미리** 보여준다.
// 혼합 명분이 1표만 움직이는 이상(§7.3), 표 차를 모르면 쓸지 말지 판단할 수 없기 때문이다. // 혼합 명분이 1표만 움직이는 이상(§7.3), 표 차를 모르면 쓸지 말지 판단할 수 없기 때문이다.
// 임원 개개인의 우호도·이중첩자(§9.5)는 4차 범위 — 지금은 안건 조합에 따른 결정적 배분이다. // 임원 개개인의 우호도·이중첩자(§9.5)는 4차 범위 — 지금은 안건 조합에 따른 결정적 배분이다.
export default function CouncilScreen({ export default function CouncilScreen({
round, combineScore, tokens, justifications, penalties, candidates, requiredDelta = 0, onResolve, round, combineScore, coins, justifications, penalties, candidates, requiredDelta = 0, onResolve,
}) { }) {
const required = Math.max(0, requiredScore(round) + requiredDelta); const required = Math.max(0, requiredScore(round) + requiredDelta);
const [tokensPaid, setTokensPaid] = useState(0); const [coinsPaid, setCoinsPaid] = useState(0);
const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 } const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
const [scorePaidCount, setScorePaidCount] = useState(0); const [scorePaidCount, setScorePaidCount] = useState(0);
const [interventions, setInterventions] = useState([]); const [interventions, setInterventions] = useState([]);
@@ -30,7 +31,7 @@ export default function CouncilScreen({
const remainingOf = (type) => (justifications[type] || 0) - (spent[type] || 0); const remainingOf = (type) => (justifications[type] || 0) - (spent[type] || 0);
const currentTotal = settleScore({ const currentTotal = settleScore({
combineScore, tokensPaid, justificationsPaid: scorePaidCount, combineScore, coinsPaid, justificationsPaid: scorePaidCount,
}); });
const met = currentTotal >= required; const met = currentTotal >= required;
@@ -80,7 +81,7 @@ export default function CouncilScreen({
const margin = sortedTally.length >= 2 ? sortedTally[0].votes - sortedTally[1].votes : null; const margin = sortedTally.length >= 2 ? sortedTally[0].votes - sortedTally[1].votes : null;
const confirm = () => { const confirm = () => {
onResolve({ tokensPaid, justificationsPaidCount: scorePaidCount, interventions }); onResolve({ coinsPaid, justificationsPaidCount: scorePaidCount, interventions });
}; };
return ( return (
@@ -102,15 +103,15 @@ export default function CouncilScreen({
</span> </span>
</div> </div>
<p className="council-audit-breakdown"> <p className="council-audit-breakdown">
족보 {combineScore} + 토큰 납입 {tokensPaid}×5 + 명분 납입 {scorePaidCount}×20 족보 {combineScore} + 코인 납입 {coinsPaid}×5 + 명분 납입 {scorePaidCount}×20
</p> </p>
<div className="council-token-pay"> <div className="council-coin-pay">
<span>토큰 {tokens - tokensPaid} 보유</span> <span><Coins size={13} className="coin-icon" /> 코인 {coins - coinsPaid} 보유</span>
<button onClick={() => setTokensPaid((n) => Math.max(0, n - 1))} disabled={tokensPaid === 0}>-</button> <button onClick={() => setCoinsPaid((n) => Math.max(0, n - 1))} disabled={coinsPaid === 0}>-</button>
<span className="council-token-committed">{tokensPaid}</span> <span className="council-coin-committed">{coinsPaid}</span>
<button onClick={() => setTokensPaid((n) => Math.min(tokens, n + 1))} disabled={tokensPaid >= tokens}>+</button> <button onClick={() => setCoinsPaid((n) => Math.min(coins, n + 1))} disabled={coinsPaid >= coins}>+</button>
<span className="council-token-note">납입 1개 = 실적 5</span> <span className="council-coin-note">납입 1개 = 실적 5</span>
</div> </div>
</section> </section>
+2 -26
View File
@@ -8,10 +8,6 @@
font-family: var(--font-typewriter); font-family: var(--font-typewriter);
} }
/* 경고/한도초과(bust) 구간을 테두리로 알린다 (§4.2-B) — bust는 실제로 라운드를 강제 종료시킨다. */
.desk-bar.zone-warning { border-bottom-color: var(--color-risk-critical); }
.desk-bar.zone-bust { border-bottom-color: var(--color-entropy-critical); }
.desk-bar-top { .desk-bar-top {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -29,24 +25,8 @@
font-size: 14px; font-size: 14px;
} }
.zone-warning .desk-attention { color: var(--color-risk-critical); }
.zone-bust .desk-attention { color: var(--color-entropy-critical); }
.desk-surcharge { color: var(--color-risk-critical); } .desk-surcharge { color: var(--color-risk-critical); }
.desk-tokens { opacity: 0.8; } .desk-coins { opacity: 0.8; }
.desk-zone-banner {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(255, 215, 0, 0.1);
color: var(--color-risk-critical);
}
.zone-bust .desk-zone-banner {
background: rgba(139, 0, 0, 0.18);
color: #ff8a80;
}
.desk-bar-actions { .desk-bar-actions {
display: flex; display: flex;
@@ -71,7 +51,7 @@
} }
.desk-combine-btn { .desk-combine-btn {
background: var(--color-token-budget); background: var(--color-coin);
color: #0a0a0a; color: #0a0a0a;
border-color: transparent; border-color: transparent;
} }
@@ -167,7 +147,3 @@
opacity: 1; opacity: 1;
} }
.desk-carried {
opacity: 0.55;
font-size: 11px;
}
+12 -27
View File
@@ -1,53 +1,40 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Coins } from 'lucide-react';
import { AXES, AXIS_META } from '../engine/symbols'; import { AXES, AXIS_META } from '../engine/symbols';
import { import {
totalCount, totalAttention, roundAttention, attentionByAxis, attentionZone, tokenSurcharge, totalCount, totalAttention, attentionByAxis, coinSurcharge, gaugeRiseRateByAxis,
gaugeRiseRateByAxis,
} from '../engine/desk'; } from '../engine/desk';
import { AxisIcon } from './SymbolBadge'; import { AxisIcon } from './SymbolBadge';
import DeskDetailPopup from './DeskDetailPopup'; import DeskDetailPopup from './DeskDetailPopup';
import './DeskBar.css'; import './DeskBar.css';
// 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다." // 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다."
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)이고, 이제 주목도가 **이번 라운드의 // 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)다.
// 한도(attentionCap)를 넘으면 그 자리에서 라운드가 강제 종료**된다(§4.2-B, 결정 노트
// 2026-09-16-라운드-주목도-하드캡-복원). 그래서 총 주목도뿐 아니라 한도까지 항상 보여야
// "한 장 더 받을지, 지금 정산할지" 판단이 선다.
// //
// 2026-09-17 수정: (1) "정산하기" 버튼은 결재 카드 아래(CoreSwipeCard)로 옮겼다 — 툴바 구석에 // 2026-09-18 결정(역할 분리): 이번 라운드 접수량/한도("카드를 더 받을지" 판단)는 결재 화면
// 있으면 핵심 결정 버튼인 걸 모른다는 피드백. (2) 한도 판정에 쓰는 주목도는 **이번 라운드분** // (CoreSwipeCard)이 전담한다. 여기 DeskBar는 **책상에 지금 쌓여 있는 것**(이월 포함 총
// 이다. 책상 전체 주목도는 이월 카드까지 포함해 게이지·토큰 가산에 계속 작용하므로 둘 다 보여준다. // 주목도, 축별 분포, 게이지·지출 가산)만 보여준다 — "무엇을 합본할 수 있는가"에 필요한 정보다.
const MAX_GRADE_CHIPS = 6; const MAX_GRADE_CHIPS = 6;
export default function DeskBar({ export default function DeskBar({
deskCards, gauges, tokens, round, attentionCap, onOpenCombine, onOpenGuide, deskCards, gauges, coins, onOpenCombine, onOpenGuide,
gaugeExtraFlat = 0, tokenExtraFlat = 0, gaugeExtraFlat = 0, coinExtraFlat = 0,
}) { }) {
const [detailAxis, setDetailAxis] = useState(null); const [detailAxis, setDetailAxis] = useState(null);
const total = totalCount(deskCards); const total = totalCount(deskCards);
const deskAttention = totalAttention(deskCards); const deskAttention = totalAttention(deskCards);
const current = roundAttention(deskCards, round);
const carried = deskAttention - current;
const perAxis = attentionByAxis(deskCards); const perAxis = attentionByAxis(deskCards);
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat); const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat); const surcharge = coinSurcharge(deskCards, coinExtraFlat);
const zone = attentionZone(deskCards, attentionCap, round);
const zoneLabel = {
normal: null,
warning: `한도 근접 (${attentionCap - 5}~${attentionCap}) — 정산할지 고려하십시오`,
bust: `한도 초과! (${attentionCap}+) — 이번 라운드는 강제 종료됩니다`,
}[zone];
return ( return (
<div className={`desk-bar zone-${zone}`}> <div className="desk-bar">
<div className="desk-bar-top"> <div className="desk-bar-top">
<div className="desk-total"> <div className="desk-total">
책상 {total}장 · <span className="desk-attention">이번 라운드 {current}/{attentionCap}</span> 책상 {total}장 · <span className="desk-attention">주목도 {deskAttention}</span>
{carried > 0 && <span className="desk-carried"> (이월 {carried})</span>}
{surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</span>} {surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</span>}
<span className="desk-tokens"> · 토큰 {tokens}</span> <span className="desk-coins"> · <Coins size={13} className="coin-icon" /> {coins}</span>
</div> </div>
<div className="desk-bar-actions"> <div className="desk-bar-actions">
<button className="desk-guide-btn" onClick={onOpenGuide}>족보표</button> <button className="desk-guide-btn" onClick={onOpenGuide}>족보표</button>
@@ -57,8 +44,6 @@ export default function DeskBar({
</div> </div>
</div> </div>
{zoneLabel && <div className="desk-zone-banner">{zoneLabel}</div>}
<div className="desk-bar-rows"> <div className="desk-bar-rows">
{AXES.map((axis) => { {AXES.map((axis) => {
const cards = deskCards.filter((c) => c.axis === axis); const cards = deskCards.filter((c) => c.axis === axis);
@@ -47,7 +47,7 @@
} }
.guide-hint strong { .guide-hint strong {
color: var(--color-token-budget); color: var(--color-coin);
} }
.guide-list { .guide-list {
@@ -75,7 +75,7 @@
flex: 1; flex: 1;
} }
.guide-score { font-size: 12px; color: var(--color-token-budget); } .guide-score { font-size: 12px; color: var(--color-coin); }
.guide-justification { font-size: 12px; opacity: 0.65; } .guide-justification { font-size: 12px; opacity: 0.65; }
.guide-condition { .guide-condition {
@@ -34,7 +34,7 @@ export default function HandGuidePopup({ onClose }) {
"같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 한 번에 "같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 한 번에
{' '}{MIN_COMBINE_CARDS}~{MAX_COMBINE_CARDS}장을 선택하면 성립하는 족보가 자동 판정되며, {' '}{MIN_COMBINE_CARDS}~{MAX_COMBINE_CARDS}장을 선택하면 성립하는 족보가 자동 판정되며,
족보에 실제로 쓰인 카드만 소멸한다 (나머지는 책상에 남는다). 족보에 실제로 쓰인 카드만 소멸한다 (나머지는 책상에 남는다).
토큰은 <strong>사용된 카드의 등급 합계</strong>만큼 나오고, 등급 {TOP_SECRET_THRESHOLD} 이상 코인은 <strong>사용된 카드의 등급 합계</strong>만큼 나오고, 등급 {TOP_SECRET_THRESHOLD} 이상
카드가 끼면 명분이 +1 된다. 카드가 끼면 명분이 +1 된다.
</p> </p>
<div className="guide-list"> <div className="guide-list">
+2 -2
View File
@@ -33,7 +33,7 @@
.info-tip-trigger.tone-good { .info-tip-trigger.tone-good {
background: rgba(76, 175, 80, 0.12); background: rgba(76, 175, 80, 0.12);
border-color: rgba(76, 175, 80, 0.4); border-color: rgba(76, 175, 80, 0.4);
color: var(--color-token-budget); color: var(--color-coin);
} }
/* 물음표 배지 — 누르면 설명이 나온다는 신호. 이게 없으면 툴팁의 존재 자체를 모른다. */ /* 물음표 배지 — 누르면 설명이 나온다는 신호. 이게 없으면 툴팁의 존재 자체를 모른다. */
@@ -101,7 +101,7 @@
} }
.tone-bad .info-tip-summary { color: var(--symbol-threat, #ff7b6b); } .tone-bad .info-tip-summary { color: var(--symbol-threat, #ff7b6b); }
.tone-good .info-tip-summary { color: var(--color-token-budget); } .tone-good .info-tip-summary { color: var(--color-coin); }
.info-tip-detail { .info-tip-detail {
font-size: 11px; font-size: 11px;
+2 -2
View File
@@ -22,7 +22,7 @@
line-height: 1.5; line-height: 1.5;
} }
.shop-tokens { .shop-coins {
font-family: var(--font-typewriter); font-family: var(--font-typewriter);
font-size: 13px; font-size: 13px;
padding: 8px 12px; padding: 8px 12px;
@@ -61,7 +61,7 @@
.shop-buy-btn { .shop-buy-btn {
font-family: var(--font-typewriter); font-family: var(--font-typewriter);
font-size: 11px; font-size: 11px;
background: var(--color-token-budget); background: var(--color-coin);
color: #0a0a0a; color: #0a0a0a;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
+6 -5
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Coins } from 'lucide-react';
import SymbolBadge from './SymbolBadge'; import SymbolBadge from './SymbolBadge';
import { purgePrice, REROLL_COST, canAfford } from '../engine/shop'; import { purgePrice, REROLL_COST, canAfford } from '../engine/shop';
import './ShopScreen.css'; import './ShopScreen.css';
@@ -6,18 +7,18 @@ import './ShopScreen.css';
// 상점 (§8.3) — 라운드 종료 후 의회 진입 전 정산 단계. 자산 구매·소모품·전례 매입은 // 상점 (§8.3) — 라운드 종료 후 의회 진입 전 정산 단계. 자산 구매·소모품·전례 매입은
// 각각 §10·§12·§11(3차 범위)이 있어야 성립해서 아직 없다. 지금 성립하는 품목은 // 각각 §10·§12·§11(3차 범위)이 있어야 성립해서 아직 없다. 지금 성립하는 품목은
// "오염 카드 직접 처리" 하나뿐이다 — 족보가 안 맞을 때의 탈출구, 명분·실적은 안 준다. // "오염 카드 직접 처리" 하나뿐이다 — 족보가 안 맞을 때의 탈출구, 명분·실적은 안 준다.
export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, onReroll, rerolled, onContinue }) { export default function ShopScreen({ display, coins, deskSurcharge, onPurge, onReroll, rerolled, onContinue }) {
return ( return (
<div className="shop-screen"> <div className="shop-screen">
<div className="shop-header"> <div className="shop-header">
<h2>정산 — 상점</h2> <h2>정산 — 상점</h2>
<p className="shop-hint"> <p className="shop-hint">
족보가 안 맞는 오염 카드를 토큰으로 직접 처리할 수 있습니다 (명분·실적은 없음). 족보가 안 맞는 오염 카드를 코인으로 직접 처리할 수 있습니다 (명분·실적은 없음).
가격은 등급만큼이며 지출 가산 +{deskSurcharge}이 얹힙니다. 가격은 등급만큼이며 지출 가산 +{deskSurcharge}이 얹힙니다.
</p> </p>
</div> </div>
<div className="shop-tokens">보유 토큰 {tokens}</div> <div className="shop-coins"><Coins size={14} className="coin-icon" /> 보유 코인 {coins}</div>
<div className="shop-grid"> <div className="shop-grid">
{display.length === 0 && <p className="shop-empty">진열할 오염 카드가 없습니다.</p>} {display.length === 0 && <p className="shop-empty">진열할 오염 카드가 없습니다.</p>}
@@ -29,7 +30,7 @@ export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, on
<span className="shop-item-name">{card.name || '미상 서류'}</span> <span className="shop-item-name">{card.name || '미상 서류'}</span>
<button <button
className="shop-buy-btn" className="shop-buy-btn"
disabled={!canAfford(tokens, price)} disabled={!canAfford(coins, price)}
onClick={() => onPurge(card.id)} onClick={() => onPurge(card.id)}
> >
소각 · {price} 소각 · {price}
@@ -42,7 +43,7 @@ export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, on
<div className="shop-actions"> <div className="shop-actions">
<button <button
className="shop-reroll-btn" className="shop-reroll-btn"
disabled={rerolled || !canAfford(tokens, REROLL_COST)} disabled={rerolled || !canAfford(coins, REROLL_COST)}
onClick={onReroll} onClick={onReroll}
> >
{rerolled ? '리롤 완료' : `진열 다시 뽑기 (${REROLL_COST})`} {rerolled ? '리롤 완료' : `진열 다시 뽑기 (${REROLL_COST})`}
+23 -23
View File
@@ -1,7 +1,7 @@
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사). // 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
// React 의존 없는 순수 함수 + 카탈로그. // React 의존 없는 순수 함수 + 카탈로그.
// //
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3). // 2026-09-15 결정 반영: 코인 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
// 2026-09-17 반영: // 2026-09-17 반영:
// - 유형 D(이득 규약) 카탈로그 신설. 내부 명분("강제 상정")이 불리한 유형 A만 올릴 수 있어 // - 유형 D(이득 규약) 카탈로그 신설. 내부 명분("강제 상정")이 불리한 유형 A만 올릴 수 있어
// 실속이 없던 문제를 해결한다 — 이제 이득 규약을 직접 안건에 올릴 수 있다. // 실속이 없던 문제를 해결한다 — 이제 이득 규약을 직접 안건에 올릴 수 있다.
@@ -19,9 +19,9 @@ import { HAND_DEFS } from './hands.js';
// 요구치 곡선은 가안: 1라운드 25, 라운드당 +20 (§9.3). // 요구치 곡선은 가안: 1라운드 25, 라운드당 +20 (§9.3).
export const requiredScore = (round) => 25 + (round - 1) * 20; export const requiredScore = (round) => 25 + (round - 1) * 20;
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 토큰 납입×5 + 명분 납입×20. // 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 코인 납입×5 + 명분 납입×20.
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) => export const settleScore = ({ combineScore, coinsPaid, justificationsPaid }) =>
combineScore + tokensPaid * 5 + justificationsPaid * 20; combineScore + coinsPaid * 5 + justificationsPaid * 20;
/* ── §9.2 규약 카탈로그 ── /* ── §9.2 규약 카탈로그 ──
* 유형 A(족보표 변경)·C(징벌)·D(이득)까지 구현. 유형 B(금리형)는 §20.2 콘텐츠 작업 대상. */ * 유형 A(족보표 변경)·C(징벌)·D(이득)까지 구현. 유형 B(금리형)는 §20.2 콘텐츠 작업 대상. */
@@ -87,22 +87,22 @@ export const PROTOCOL_TYPE_C = [
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.', flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
}, },
{ {
id: 'penalty_token_surcharge', id: 'penalty_coin_surcharge',
name: '긴급 재정 감사 대응', name: '긴급 재정 감사 대응',
type: 'C', type: 'C',
effect: { kind: 'token_surcharge', amount: 2 }, effect: { kind: 'coin_surcharge', amount: 2 },
flavor: '감사 대응 인력 편성 비용이 모든 토큰 지출에 얹힙니다.', flavor: '감사 대응 인력 편성 비용이 모든 코인 지출에 얹힙니다.',
}, },
]; ];
// 유형 D — 이득 규약. §9.2 "드물게 통과"이므로 후보 등장 확률을 낮게 잡는다(D_CANDIDATE_CHANCE). // 유형 D — 이득 규약. §9.2 "드물게 통과"이므로 후보 등장 확률을 낮게 잡는다(D_CANDIDATE_CHANCE).
// 자산 지급형은 자산 시스템(§10, 3차)이 없어 제외했고, 토큰·족보 완화·한도 완화로 구성했다. // 자산 지급형은 자산 시스템(§10, 3차)이 없어 제외했고, 코인·족보 완화·한도 완화로 구성했다.
export const PROTOCOL_TYPE_D = [ export const PROTOCOL_TYPE_D = [
{ {
id: 'boon_budget_grant', id: 'boon_budget_grant',
name: '예비비 긴급 편성', name: '예비비 긴급 편성',
type: 'D', type: 'D',
effect: { kind: 'grant_token', amount: 12 }, effect: { kind: 'grant_coin', amount: 12 },
flavor: '이월 예산 일부를 국장 재량으로 집행할 수 있도록 의결되었습니다.', flavor: '이월 예산 일부를 국장 재량으로 집행할 수 있도록 의결되었습니다.',
}, },
{ {
@@ -116,7 +116,7 @@ export const PROTOCOL_TYPE_D = [
id: 'boon_admin_simplify', id: 'boon_admin_simplify',
name: '행정 간소화 지침', name: '행정 간소화 지침',
type: 'D', type: 'D',
effect: { kind: 'token_surcharge', amount: -2 }, effect: { kind: 'coin_surcharge', amount: -2 },
flavor: '중복 결재 단계를 축소해 집행 비용을 낮춥니다.', flavor: '중복 결재 단계를 축소해 집행 비용을 낮춥니다.',
}, },
{ {
@@ -375,20 +375,20 @@ export function protocolEffectDetail(protocol) {
: `결재 1건마다 4대 게이지 상승분이 ${Math.abs(e.amount).toFixed(1)}%p씩 줄어듭니다.`, : `결재 1건마다 4대 게이지 상승분이 ${Math.abs(e.amount).toFixed(1)}%p씩 줄어듭니다.`,
tone: e.amount > 0 ? 'bad' : 'good', tone: e.amount > 0 ? 'bad' : 'good',
}; };
case 'token_surcharge': case 'coin_surcharge':
return { return {
typeLabel, typeLabel,
summary: `토큰 지출 가산 ${e.amount > 0 ? '+' : ''}${e.amount}`, summary: `코인 지출 가산 ${e.amount > 0 ? '+' : ''}${e.amount}`,
detail: e.amount > 0 detail: e.amount > 0
? `상점 소각·리롤 등 모든 토큰 지출에 ${e.amount}이(가) 추가로 붙습니다.` ? `상점 소각·리롤 등 모든 코인 지출에 ${e.amount}이(가) 추가로 붙습니다.`
: `모든 토큰 지출 가산이 ${Math.abs(e.amount)} 줄어듭니다 (최소 0).`, : `모든 코인 지출 가산이 ${Math.abs(e.amount)} 줄어듭니다 (최소 0).`,
tone: e.amount > 0 ? 'bad' : 'good', tone: e.amount > 0 ? 'bad' : 'good',
}; };
case 'grant_token': case 'grant_coin':
return { return {
typeLabel, typeLabel,
summary: `통과 즉시 토큰 +${e.amount}`, summary: `통과 즉시 코인 +${e.amount}`,
detail: `표결 통과 시점에 토큰 ${e.amount}을(를) 한 번 지급합니다. 지속 효과는 없습니다.`, detail: `표결 통과 시점에 코인 ${e.amount}을(를) 한 번 지급합니다. 지속 효과는 없습니다.`,
tone: 'good', tone: 'good',
}; };
case 'attention_cap_bonus': case 'attention_cap_bonus':
@@ -418,9 +418,9 @@ export function gaugeSurchargeOf(protocols) {
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0); return protocols.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0);
} }
/** 모든 토큰 지출에 더해지는 가산분 합계 (이득 규약이면 음수). */ /** 모든 코인 지출에 더해지는 가산분 합계 (이득 규약이면 음수). */
export function tokenSurchargeOf(protocols) { export function coinSurchargeOf(protocols) {
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'token_surcharge' ? p.effect.amount : 0), 0); return protocols.reduce((sum, p) => sum + (p.effect.kind === 'coin_surcharge' ? p.effect.amount : 0), 0);
} }
/** 이번 라운드 주목도 한도에 더해지는 보너스. */ /** 이번 라운드 주목도 한도에 더해지는 보너스. */
@@ -433,9 +433,9 @@ export function requiredScoreDeltaOf(protocols) {
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'required_score_delta' ? p.effect.amount : 0), 0); return protocols.reduce((sum, p) => sum + (p.effect.kind === 'required_score_delta' ? p.effect.amount : 0), 0);
} }
/** 통과 즉시 1회 지급되는 토큰. */ /** 통과 즉시 1회 지급되는 코인. */
export function grantTokenOf(protocol) { export function grantCoinOf(protocol) {
return protocol && protocol.effect.kind === 'grant_token' ? protocol.effect.amount : 0; return protocol && protocol.effect.kind === 'grant_coin' ? protocol.effect.amount : 0;
} }
/* ── 합본 판정에 규약 효과를 얹는다 ── */ /* ── 합본 판정에 규약 효과를 얹는다 ── */
+19 -23
View File
@@ -14,7 +14,7 @@ import { AXES, AXIS_META, clampGrade } from './symbols.js';
let seq = 0; let seq = 0;
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다. // meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
// round: 이 카드를 받은 라운드 번호. 한도 판정(roundAttention)이 이월 카드를 빼고 세기 위해 필요하다. // round: 이 카드를 받은 라운드 번호(기록용 — 한도 판정에는 더 이상 쓰이지 않는다. 아래 참고).
export const makeDeskCard = (axis, grade, meta = {}, round = 1) => ({ export const makeDeskCard = (axis, grade, meta = {}, round = 1) => ({
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`, id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
axis, axis,
@@ -53,22 +53,11 @@ export function axisAttention(deskCards, axis) {
} }
/** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티 /** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티
* (게이지 상승 A · 토큰 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */ * (게이지 상승 A · 코인 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */
export function totalAttention(deskCards) { export function totalAttention(deskCards) {
return deskCards.reduce((sum, c) => sum + c.grade, 0); 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) { export function attentionByAxis(deskCards) {
const out = {}; const out = {};
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); }); AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
@@ -142,7 +131,14 @@ export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 — * 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원. * "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원.
* *
* 2026-09-17: 판정 기준을 총 주목도 → 이번 라운드 주목도로 교체 (roundAttention 주석 참고). */ * 2026-09-18 결정 (누적 카운터로 전환, 결정 노트: 2026-09-18-라운드-접수량-누적카운터):
* 이번 라운드 접수량을 "책상에서 이번 라운드 카드를 필터링해 합산"하는 파생값으로 뒀더니,
* 합본으로 그 카드들이 책상에서 사라지는 순간 접수량도 같이 줄었다. 블랙잭은 낸 카드를
* 버려서 핸드 합계를 낮출 수 없는데, 이 파생 방식은 그걸 가능하게 해서 "합본을 반복하면
* 라운드가 끝나지 않는" 문제를 낳았다(피드백: "합본을 많이 하면 라운드가 너무 길어진다").
* 그래서 접수량은 더 이상 desk.js가 deskCards에서 계산하지 않는다 — CoreGameScreen이
* 카드를 받을 때마다 등급만큼 더하는 **순수 누적 카운터**(라운드가 바뀔 때만 0으로 리셋)로
* 관리하고, 이 모듈의 함수들은 그 값을 인자로 받기만 한다. */
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀 const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안 const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
@@ -152,15 +148,15 @@ export function roundAttentionCap(round, bonus = 0) {
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH + bonus; return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH + bonus;
} }
/** 한도 대비 이번 라운드 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */ /** 한도 대비 이번 라운드 접수량 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과).
export function attentionZone(deskCards, cap, round) { * intake: CoreGameScreen이 들고 있는 누적 카운터 값 — 합본해도 줄지 않는다. */
const current = roundAttention(deskCards, round); export function attentionZone(intake, cap) {
if (current > cap) return 'bust'; if (intake > cap) return 'bust';
if (current >= cap - 5) return 'warning'; if (intake >= cap - 5) return 'warning';
return 'normal'; return 'normal';
} }
/* ── C. 토큰 지출 가산 (§4.2-C) — 총 주목도 기준 ── */ /* ── C. 코인 지출 가산 (§4.2-C) — 총 주목도 기준 ── */
const SURCHARGE_TIERS = [ const SURCHARGE_TIERS = [
{ max: 20, surcharge: 0 }, { max: 20, surcharge: 0 },
@@ -169,10 +165,10 @@ const SURCHARGE_TIERS = [
{ max: Infinity, surcharge: 4 }, { max: Infinity, surcharge: 4 },
]; ];
/** 총 주목도 기준 모든 토큰 지출에 붙는 가산액. /** 총 주목도 기준 모든 코인 지출에 붙는 가산액.
* extraFlat: 활성 규약이 얹는 가산분(council.js의 tokenSurchargeOf) — 이득 규약이면 음수라 * extraFlat: 활성 규약이 얹는 가산분(council.js의 coinSurchargeOf) — 이득 규약이면 음수라
* 0 밑으로 내려갈 수 있으므로, 지출이 되레 할인되지 않도록 0에서 끊는다. */ * 0 밑으로 내려갈 수 있으므로, 지출이 되레 할인되지 않도록 0에서 끊는다. */
export function tokenSurcharge(deskCards, extraFlat = 0) { export function coinSurcharge(deskCards, extraFlat = 0) {
const total = totalAttention(deskCards); const total = totalAttention(deskCards);
const tier = SURCHARGE_TIERS.find((t) => total <= t.max); const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat); return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat);
+5 -5
View File
@@ -8,7 +8,7 @@
// 이제는 선택 집합 안에서 족보를 이루는 카드를 찾아낸다. 그래서 5장을 골라도 페어만 // 이제는 선택 집합 안에서 족보를 이루는 카드를 찾아낸다. 그래서 5장을 골라도 페어만
// 성립하면 페어로 상신할 수 있고, 그 경우 족보에 실제로 쓰인 2장만 소멸한다 // 성립하면 페어로 상신할 수 있고, 그 경우 족보에 실제로 쓰인 2장만 소멸한다
// (나머지는 §4.1 이월 원칙에 따라 책상에 남는다). // (나머지는 §4.1 이월 원칙에 따라 책상에 남는다).
// - 토큰은 1종이고 산출량은 등급 합계다 (§8.2). 축은 명분 종류만 결정한다 (§7.2). // - 코인은 1종이고 산출량은 등급 합계다 (§8.2). 축은 명분 종류만 결정한다 (§7.2).
// - 명분 +1 보너스 기준이 "등급 4" → "등급 8 이상"으로 바뀌었다 (§7.1). // - 명분 +1 보너스 기준이 "등급 4" → "등급 8 이상"으로 바뀌었다 (§7.1).
import { justificationOf, TOP_SECRET_THRESHOLD } from './symbols.js'; import { justificationOf, TOP_SECRET_THRESHOLD } from './symbols.js';
@@ -81,7 +81,7 @@ function findRun(cards, length = 5) {
/** /**
* 선택 집합에서 지정 족보를 이루는 카드들을 찾는다. 성립하지 않으면 null. * 선택 집합에서 지정 족보를 이루는 카드들을 찾는다. 성립하지 않으면 null.
* 여러 조합이 가능하면 등급 합이 큰 쪽을 고른다 — 토큰 산출이 등급 합계이므로 그게 이득이다. * 여러 조합이 가능하면 등급 합이 큰 쪽을 고른다 — 코인 산출이 등급 합계이므로 그게 이득이다.
*/ */
export function findHandCards(selection, patternId) { export function findHandCards(selection, patternId) {
const byGrade = groupByGrade(selection); const byGrade = groupByGrade(selection);
@@ -170,14 +170,14 @@ export function resolveCombine(selection, patternId) {
const hasTopSecret = used.some((c) => c.grade >= TOP_SECRET_THRESHOLD); const hasTopSecret = used.some((c) => c.grade >= TOP_SECRET_THRESHOLD);
const justificationAmount = def.baseJustification + (hasTopSecret ? 1 : 0); const justificationAmount = def.baseJustification + (hasTopSecret ? 1 : 0);
// §8.2: 토큰은 1종, 산출량은 합본에 사용된 카드의 기밀 등급 합계. // §8.2: 코인은 1종, 산출량은 합본에 사용된 카드의 기밀 등급 합계.
const tokens = sumGrades(used); const coins = sumGrades(used);
return { return {
pattern: def, pattern: def,
usedCards: used, usedCards: used,
justification: { type: justificationType, amount: justificationAmount }, justification: { type: justificationType, amount: justificationAmount },
tokens, coins,
performanceScore: def.score, performanceScore: def.score,
removeIds: used.map((c) => c.id), removeIds: used.map((c) => c.id),
}; };
+4 -4
View File
@@ -2,10 +2,10 @@
// 자산 구매·소모품·전례 매입은 각각 §10·§12·§11(3차 범위)이 있어야 성립하는데 아직 없다. // 자산 구매·소모품·전례 매입은 각각 §10·§12·§11(3차 범위)이 있어야 성립하는데 아직 없다.
// 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구, // 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구,
// 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다. // 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다.
// 진열 규칙(무작위 4~6개, 1회 리롤 가능·토큰 소모)만 그대로 §8.3을 따른다. // 진열 규칙(무작위 4~6개, 1회 리롤 가능·코인 소모)만 그대로 §8.3을 따른다.
// //
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2, §19). 가격은 단일 숫자다. // 2026-09-15 결정 반영: 코인 1종 통합(§8.2, §19). 가격은 단일 숫자다.
// 등급 1~10 확장에 맞춰 처리 비용도 등급에 비례한다 — 합본하면 등급 합계만큼 토큰을 벌지만 // 등급 1~10 확장에 맞춰 처리 비용도 등급에 비례한다 — 합본하면 등급 합계만큼 코인을 벌지만
// 직접 처리하면 그만큼 잃으므로, 탈출구를 쓰는 대가가 등급에 정직하게 비례한다. // 직접 처리하면 그만큼 잃으므로, 탈출구를 쓰는 대가가 등급에 정직하게 비례한다.
export const SHOP_DISPLAY_MIN = 4; export const SHOP_DISPLAY_MIN = 4;
@@ -29,4 +29,4 @@ export function rollShopDisplay(deskCards) {
return out; return out;
} }
export const canAfford = (tokens, price) => tokens >= price; export const canAfford = (coins, price) => coins >= price;
+2 -2
View File
@@ -4,8 +4,8 @@
// //
// 2026-09-15 결정 반영: 기밀 등급 1~4 → 1~10 확장, 점(●) 표기 폐기. // 2026-09-15 결정 반영: 기밀 등급 1~4 → 1~10 확장, 점(●) 표기 폐기.
// 카드 중앙에 큰 숫자, 축 인장은 모서리에 작게 — 트럼프 랭크+슈트 레이아웃 (§3.2). // 카드 중앙에 큰 숫자, 축 인장은 모서리에 작게 — 트럼프 랭크+슈트 레이아웃 (§3.2).
// 토큰은 1종으로 통합됐으므로(§8.2, §19) 축은 더 이상 토큰 종류를 결정하지 않는다. // 코인은 1종으로 통합됐으므로(§8.2, §19) 축은 더 이상 코인 종류를 결정하지 않는다.
// 축이 결정하는 것은 "명분 종류"(질)뿐이고, 토큰 양(양)은 등급 합계가 결정한다 (§7.2). // 축이 결정하는 것은 "명분 종류"(질)뿐이고, 코인 양(양)은 등급 합계가 결정한다 (§7.2).
export const AXES = ['axis_threat', 'axis_hr', 'axis_pr', 'axis_external']; export const AXES = ['axis_threat', 'axis_hr', 'axis_pr', 'axis_external'];
+17 -2
View File
@@ -20,7 +20,11 @@
--color-token-budget: #4CAF50; --color-token-budget: #4CAF50;
--color-token-personnel: #E53935; --color-token-personnel: #E53935;
--color-token-info: #3F51B5; --color-token-info: #3F51B5;
/* 신규 코어 시스템(client/src/core/)은 재화를 "코인" 1종으로 통합했다(§8.2) — 구 3종
토큰 색상 중 예산색(--color-token-budget)을 코인 강조색으로 재사용한다. 기존 변수는
참조용 레거시 컴포넌트(client/src/components/)가 계속 쓰므로 남겨 두고 별칭만 추가한다. */
--color-coin: var(--color-token-budget);
--color-tag-neutral: #E0E0E0; --color-tag-neutral: #E0E0E0;
--color-swipe-left: #D32F2F; --color-swipe-left: #D32F2F;
@@ -42,8 +46,19 @@ body {
} }
#root { #root {
width: 100vw; width: 100%;
/* 모바일 필수: 100vh는 "주소창이 숨은 상태" 기준 높이라, 주소창이 보이는 동안 화면 아래
60~100px이 잘린다. body가 overflow:hidden이어서 스크롤로 닿을 수도 없다 — 화면 맨 아래에
있는 "정산하기" 버튼이 가려지는 실제 문제가 된다. dvh(동적 뷰포트 높이)는 주소창 노출
상태를 반영한다. vh는 dvh 미지원 구형 브라우저용 폴백으로 남긴다. */
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* 코인 수치 옆에 붙는 lucide Coins 아이콘 공용 정렬 — DeskBar/ShopScreen/CouncilScreen 공유. */
.coin-icon {
vertical-align: -2px;
color: var(--color-coin);
}
+24
View File
@@ -1,7 +1,31 @@
import { execSync } from 'node:child_process'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// 빌드 식별자 — 여러 명에게 며칠에 걸쳐 피드백을 받으면 "어느 빌드였나"가 반드시 꼬인다.
// 화면 구석에 이 값을 박아 두면 피드백을 버전에 붙여 정리할 수 있다.
const buildInfo = (() => {
let sha = 'nogit'
try {
sha = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim()
} catch {
// 깃 없는 환경(배포 서버 등)에서 빌드해도 실패하지 않게 둔다
}
const d = new Date()
const p = (n) => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}·${sha}`
})()
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
// 상대 경로로 빌드한다. NAS Web Station이 문서 루트 하위 폴더(/ProjectSS/ 등)로 서빙하므로
// 기본값('/')이면 asset을 도메인 루트에서 찾아 404 → 빈 화면이 된다. './'로 두면 어느
// 서브패스에 떨어뜨려도 그대로 동작해서, 폴더를 옮길 때 재빌드할 필요가 없다.
base: './',
plugins: [react()], plugins: [react()],
define: {
__BUILD_INFO__: JSON.stringify(buildInfo),
},
}) })
Executable
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# ProjectSS 배포 — 시놀로지 NAS(Web Station, 정적 웹사이트)로 빌드 결과물을 올린다.
#
# 구조:
# client/dist --(scp over ssh)--> NAS:$REMOTE_DIR --(Web Station nginx)--> 공개 URL
#
# rsync가 아니라 scp인 이유: 이 NAS의 /usr/bin/rsync는 SSH 전송 모드에서도
# "rsync error: rsync service is no running (code 43)"로 막힌다. DSM의
# 제어판 → 파일 서비스 → rsync 활성화 + 사용자 애플리케이션 권한(rsync) 부여를
# 둘 다 했는데도 동일했다 — 시놀로지 쪽에 rsync-over-ssh용 별도 게이트(아마
# rsync daemon 설정 안의 사용자 화이트리스트)가 더 있는 것으로 보이는데, 이
# 프로젝트 빌드 결과물이 400KB 안팎이라 rsync의 증분 전송 이점이 없어서 원인을
# 더 파고들지 않고 scp(-O, 레거시 exec 프로토콜)로 우회했다. 이 NAS는 SFTP
# 서브시스템도 안 열려 있어(-O 없는 최신 scp는 "subsystem request failed")
# 반드시 -O가 필요하다.
#
# 사전 준비(각 1회):
# 1) NAS에서 SSH 활성화 (제어판 → 터미널 및 SNMP)
# 2) 비밀번호 없이 배포하려면 SSH 키 등록:
# ssh-keygen -t ed25519 -C projectss-deploy # 키가 없을 때만
# ssh-copy-id -p 225 kyoung5@192.168.0.6
# 시놀로지는 키 인증이 홈 디렉터리 권한에 민감하다 — ssh-copy-id 직후에도
# 비밀번호를 계속 물으면 NAS에 비밀번호로 한 번 접속해 다음을 확인:
# chmod 755 ~ && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
#
# 사용법:
# ./deploy.sh # 빌드 후 배포
# ./deploy.sh --no-build # 이미 빌드된 dist를 그대로 배포
# ./deploy.sh --dry-run # 실제로 올리지 않고 무엇을 보낼지만 확인
set -euo pipefail
# ── 설정 ──────────────────────────────────────────────────────────────────
SSH_USER="kyoung5"
SSH_HOST="192.168.0.6"
SSH_PORT="225"
# Web Station 문서 루트가 web/web_games 이므로 그 하위 폴더에 올린다.
# 여기를 바꾸면 공개 URL도 같이 바뀐다 (PUBLIC_URL도 함께 수정).
REMOTE_DIR="/volume1/web/web_games/projectss"
PUBLIC_URL="https://play.mint-grape.duckdns.org/projectss/"
# ──────────────────────────────────────────────────────────────────────────
cd "$(dirname "$0")"
DO_BUILD=1
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--no-build) DO_BUILD=0 ;;
--dry-run) DRY_RUN=1 ;;
*) echo "알 수 없는 옵션: $arg" >&2; exit 1 ;;
esac
done
if [[ $DO_BUILD -eq 1 ]]; then
echo "▶ 빌드"
(cd client && npm run build)
fi
if [[ ! -f client/dist/index.html ]]; then
echo "✗ client/dist/index.html 이 없다. 먼저 빌드할 것." >&2
exit 1
fi
if [[ $DRY_RUN -eq 1 ]]; then
echo "▶ (dry-run) 아래 파일들을 ${SSH_USER}@${SSH_HOST}:${REMOTE_DIR} 로 보낼 예정 — 그 전에 원격 폴더를 비운다:"
find client/dist -type f | sed 's/^client\/dist/ /'
exit 0
fi
echo "▶ 배포 → ${SSH_USER}@${SSH_HOST}:${REMOTE_DIR}"
# 해시 붙은 옛 asset 파일이 남아 쌓이지 않게, 매번 원격 폴더를 비우고 새로 올린다
# (rsync --delete와 같은 효과). REMOTE_DIR 전용 폴더만 지우므로 다른 경로는 안 건드린다.
ssh -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" \
"rm -rf '${REMOTE_DIR}' && mkdir -p '${REMOTE_DIR}'"
scp -O -P "${SSH_PORT}" -r client/dist/* "${SSH_USER}@${SSH_HOST}:${REMOTE_DIR}/"
echo
echo "✓ 배포 완료"
echo " $PUBLIC_URL"
+78 -2
View File
@@ -14,13 +14,89 @@
연결이 끊겨 커밋 전 작업이 통째로 유실됐다(재구현으로 복구). 왜 끊겼는지 원인 조사 안 됨 — 연결이 끊겨 커밋 전 작업이 통째로 유실됐다(재구현으로 복구). 왜 끊겼는지 원인 조사 안 됨 —
재발 방지책 필요. 재발 방지책 필요.
- **라운드 주목도 한도(21 + 라운드당 +5), 상점 가격, 게이지 상승 장수 구간 수치 등은 전부 - **라운드 주목도 한도(21 + 라운드당 +5), 상점 가격, 게이지 상승 장수 구간 수치 등은 전부
가안** — 실측 플레이로 튜닝 예정. 3~4장 구간의 6배 점프(0.5%→3.0%)가 적당한지부터 확인할 것. 가안** — 실측 플레이로 튜닝 예정. 2026-09-18에 접수량을 누적 카운터로 바꾸면서 한도가
실질적으로 더 타이트해졌으므로(§20.1과 연동해) 다시 볼 것. 결정 노트:
[[2026-09-18-라운드-접수량-누적카운터]]
- **이월 누적이 실제로 압박이 되는지 미검증** — 주목도 라운드 분리(2026-09-17) 이후 이월분은 - **이월 누적이 실제로 압박이 되는지 미검증** — 주목도 라운드 분리(2026-09-17) 이후 이월분은
게이지·토큰 가산에만 작용한다. 이월을 쌓아두고 매 라운드 한도까지 새로 받는 플레이가 지배 게이지·코인 가산에만 작용한다. 이월을 쌓아두고 매 라운드 한도까지 새로 받는 플레이가 지배
전략이 되지 않는지 확인 필요. 결정 노트: [[2026-09-17-주목도-라운드-분리]] 전략이 되지 않는지 확인 필요. 결정 노트: [[2026-09-17-주목도-라운드-분리]]
- **상점이 사실상 비어 있음** — 지금 유일한 품목(오염 카드 직접 처리)이 의회 실적 납입(코인
1개=실적 5점, §9.3)과 경쟁이 안 돼 "살 이유가 없다"는 피드백. 자산·소모품 등 3차 품목이
들어와야 근본 해결(§20.2 이후 우선 작업 후보). 코인 납입 환산율 하향은 상점이 채워진 뒤
다시 볼 것 — 지금 건드리면 상점 완성 후 또 바뀌어야 한다.
--- ---
## 2026-09-18 — 외부 플레이테스트 배포 준비 (시놀로지 NAS) + 모바일 실기 대응
**무엇을 왜:** 이제부터 외부 테스터에게 링크를 공유해 피드백을 받기로 해서, "모바일에서 링크만
누르면 플레이되고, 배포는 한 줄로 끝나는" 상태를 만들었다. 기존 NAS 인프라(Web Station nginx +
DuckDNS + 인증서)를 그대로 쓴다 — 조사해 보니 서브도메인 DNS·443 포워딩·인증서가 모두 이미
동작 중이었고(구 HTML 프로토타입이 같은 경로로 서빙되고 있었다), 실제 블로커는 두 개뿐이었다.
- **서브패스 asset 경로 (배포 시 빈 화면이 되는 블로커)**: Web Station 문서 루트 하위 폴더로
서빙하는데 Vite 기본 `base: '/'`는 asset을 도메인 루트에서 찾아 전부 404가 된다.
`base: './'`(상대 경로)로 바꿨다 — 특정 서브패스에 고정되지 않아 폴더를 옮겨도 재빌드가
필요 없다. 로컬에서 `/projectss/` 하위로 서빙해 프로덕션 빌드가 실제로 뜨는 것까지 확인
- **모바일 `100vh` → `100dvh`**: `100vh`는 주소창이 숨은 상태 기준 높이라, 주소창이 보이는
동안 화면 아래 60~100px이 잘린다. `body`가 `overflow:hidden`이어서 스크롤로 닿을 수도 없어
**화면 맨 아래의 "정산하기" 버튼이 가려지는** 실제 문제였다. `vh`는 구형 폴백으로 남겼다.
`#root`의 `width: 100vw`도 `100%`로 바꿨다(100vw는 데스크톱 스크롤바 폭만큼 가로 넘침)
- **빌드 식별자 표시**: 여러 명에게 며칠에 걸쳐 피드백을 받으면 "어느 빌드였나"가 반드시
꼬인다. `vite.config.js`가 빌드 시각+커밋 해시(`0918-2239·6398599`)를 주입하고 상태 줄
오른쪽에 흐리게 표시한다 — 테스터가 스크린샷만 찍어도 버전이 남는다
- **`<title>`·`theme-color`·`description`**: 링크를 공유·북마크할 때 보이는 이름을 게임 제목으로
바꾸고, 모바일 브라우저 바 색을 다크 UI에 맞췄다. 확대 차단(`user-scalable=no`)은 작은 글씨를
읽어야 하는 테스터의 접근성을 해쳐서 넣지 않았다
- **`deploy.sh` + `npm run deploy`**: `vite build` 후 NAS에 올린다. `--dry-run`으로 보낼 파일
목록만 먼저 확인 가능. 배포용 SSH 키(ed25519) 생성, `ssh-copy-id`로 등록 완료 — 이후 비밀번호
없이 배포된다. 실제 배포까지 검증 완료: **https://play.mint-grape.duckdns.org/projectss/**
- **배포 과정에서 겪은 삽질 2건, 재발 방지용으로 기록**:
1. `ssh-copy-id` 직후에도 계속 비밀번호를 요구함 → 홈 디렉터리가 `drwxrwxrwx`(777),
`~/.ssh`도 777, `authorized_keys`도 777이었다. OpenSSH는 키 소유자 외 그룹/기타 쓰기
권한이 있으면 StrictModes로 그 키를 무시하고 조용히 비밀번호 인증으로 넘어간다 —
클라이언트 쪽엔 에러가 안 보여서 원인 파악이 까다로웠다. `chmod 755 ~ && chmod 700
~/.ssh && chmod 600 ~/.ssh/authorized_keys`로 해결
2. 키 인증은 성공했는데(`Authenticated ... using "publickey"`) **rsync만** 항상
`rsync error: rsync service is no running (code 43)`로 실패. DSM 제어판의
"파일 서비스 → rsync 활성화"와 사용자 애플리케이션 권한(rsync 체크)을 둘 다 켜고
`rsyncd.service`가 `active (running)`인 것까지 확인했는데도 동일 에러 — SSH 전송
모드(`-e ssh`)인데도 시놀로지의 `/usr/bin/rsync`가 별도 게이트(아마 daemon 설정의
사용자 화이트리스트)에 걸리는 것으로 보이나, 이 프로젝트 빌드 결과물이 400KB 안팎이라
rsync의 증분 전송 이점이 없어서 원인을 더 파지 않고 **`scp -O`(레거시 exec 프로토콜)로
교체**했다. 이 NAS는 SFTP 서브시스템도 꺼져 있어(`-O` 없는 최신 scp는 "subsystem
request failed") `-O`가 필수. `rm -rf "$REMOTE_DIR" && mkdir -p` 후 scp로 옮겨
rsync `--delete`와 같은 "매번 깨끗하게" 효과를 낸다
- iPhone 13 크기(390×664)로 실기 검증: 터치 스와이프 정상(CDP로 실제 터치 입력 전송), 결재
카드·정산하기·합본 버튼 모두 화면 안, 상점·의회 화면도 스크롤 없이 들어감, 실패 요청 0건,
콘솔 에러 0건. 배포 후 공개 URL에서 최신 빌드 asset 해시가 그대로 서빙되는 것까지 확인
## 2026-09-18 — 라운드 접수량 버그 수정 + 책상/결재 화면 정보 재배치 + 토큰→코인 용어 통일
**무엇을 왜:** 플레이 피드백 4건(주목도 미차감 버그, 상점 빈 상품, 상점 UX 오해, 재화 명칭)을
먼저 원인 분석·해결안으로 정리해 사용자에게 제시하고, 방향이 확정된 항목만 반영했다. 상점
품목 확충은 3차(자산·소모품) 이후로, 코인 납입 환산율 조정은 상점이 채워진 뒤로 보류.
- **라운드 접수량 크리티컬 버그 수정**: 합본을 여러 번 하면 라운드가 끝나지 않던 문제
(피드백: "합본을 많이 하면 라운드가 너무 길어진다"). `roundAttention(deskCards, round)`이
책상 카드를 필터링해 매번 다시 합산하는 파생값이라, 합본으로 카드가 사라지면 접수량도 같이
줄어 한도(21)에 다시 도달할 때까지 계속 새로 받을 수 있었다. CoreGameScreen이 직접 들고
관리하는 순수 누적 카운터(`roundIntake`, 라운드가 바뀔 때만 0으로 리셋)로 교체 — 결정 노트:
[[2026-09-18-라운드-접수량-누적카운터]]
- **DeskBar/CoreSwipeCard 정보 역할 분리**: "이번 라운드 접수량/한도"(카드를 더 받을지 판단)는
결재 화면(CoreSwipeCard)이 전담하고, DeskBar는 "책상에 지금 쌓인 것"(총 주목도, 축별 분포,
게이지·지출 가산)만 보여주도록 재배치. 중복 표시·죽은 CSS(zone 배너 등) 정리. `attentionZone`
판정을 desk.js 단일 함수로 통일해 CoreSwipeCard가 갖고 있던 로컬 중복 로직 제거
- **토큰 → 코인 용어 일괄 변경**: 상점이 본격 가동되면서 "토큰"보다 "코인"이 재화를 직관적으로
떠올리게 한다는 피드백. `core/` 전체(식별자·CSS 클래스·주석·UI 텍스트, 17개 파일)와 기획서
SSOT를 함께 갱신 — §10.4 자산 스키마의 구 3종 토큰 JSON 예시는 3차 미구현 상태라 제외했다.
lucide `Coins` 아이콘을 책상 바·상점·의회의 코인 표시에 추가. `--color-token-budget` CSS
변수는 레거시 참조용 컴포넌트가 계속 쓰므로 그대로 두고 `--color-coin` 별칭만 신설(같은 값을
가리킴) — `--color-token-info`는 재화와 무관한 범용 강조색이라 이름을 바꾸지 않았다
- Playwright로 검증: 4장 소멸하는 합본 전후 접수량이 17→17로 불변임을 직접 확인(이전엔 이
케이스가 버그였다), 버스트 임계 정상 작동(주목도 25에서 한도 21 초과 종료) 확인, 전체 페이지
텍스트에 "토큰" 잔존 0건·코인 아이콘 렌더 확인, 콘솔 에러 0건
## 2026-09-17 — 플레이 피드백 5건 반영 (합본 결재 리뉴얼 후속) ## 2026-09-17 — 플레이 피드백 5건 반영 (합본 결재 리뉴얼 후속)
**무엇을 왜:** 합본 결재 리뉴얼(같은 날 앞선 작업) 이후 나온 밸런스 피드백 5건. 그중 "결재 **무엇을 왜:** 합본 결재 리뉴얼(같은 날 앞선 작업) 이후 나온 밸런스 피드백 5건. 그중 "결재
+39 -39
View File
@@ -45,7 +45,7 @@
↓ ↓
미사용 카드는 다음 라운드로 이월 미사용 카드는 다음 라운드로 이월
↓ ↓
[정산 단계] 상점 — 토큰으로 자산·소모품·전례 구매 [정산 단계] 상점 — 코인으로 자산·소모품·전례 구매
↓ ↓
[의회 단계] 실적 감사(이번 라운드 누적치) + 규약 표결 → 다음 라운드 룰 변경 [의회 단계] 실적 감사(이번 라운드 누적치) + 규약 표결 → 다음 라운드 룰 변경
↓ ↓
@@ -61,7 +61,7 @@
1. **자율 종료 — "정산하기"**: 주목도가 한도 밑인 동안, 플레이어가 아무 때나 정산하기를 눌러 라운드를 마치고 상점으로 넘어갈 수 있다. "한 장 더 받을까 말까"의 선택권은 항상 플레이어에게 있다. 1. **자율 종료 — "정산하기"**: 주목도가 한도 밑인 동안, 플레이어가 아무 때나 정산하기를 눌러 라운드를 마치고 상점으로 넘어갈 수 있다. "한 장 더 받을까 말까"의 선택권은 항상 플레이어에게 있다.
2. **강제 종료 — 버스트**: 결재 직후 총 주목도가 이번 라운드의 한도를 넘으면 그 자리에서 즉시 라운드가 끝난다. **합본 기회 없이** 카드는 그대로 이월된다(§4.1). 2. **강제 종료 — 버스트**: 결재 직후 총 주목도가 이번 라운드의 한도를 넘으면 그 자리에서 즉시 라운드가 끝난다. **합본 기회 없이** 카드는 그대로 이월된다(§4.1).
한도는 라운드가 갈수록 커진다: `한도 = 21 + (라운드 번호 − 1) × 5`. 후반 라운드일수록 더 크게 쌓을 수 있지만, 그만큼 게이지·토큰 가산도 이미 높은 상태에서 쌓는 것이라 실질 위험은 줄지 않는다. 한도는 라운드가 갈수록 커진다: `한도 = 21 + (라운드 번호 − 1) × 5`. 후반 라운드일수록 더 크게 쌓을 수 있지만, 그만큼 게이지·코인 가산도 이미 높은 상태에서 쌓는 것이라 실질 위험은 줄지 않는다.
> **블랙잭 유비**: 주목도=카드 합, 한도=21(1라운드 기준)이다. 한도 밑에서 멈추는 게 "스탠드", 계속 받는 게 "히트", 넘기면 "버스트"다. §1.1의 핵심 재미("지금 정리할 것인가, 더 모아서 크게 처리할 것인가")가 여기서 가장 직접적으로 드러난다 — 의회의 실적 압박은 합본·상점 자산 구매로 넘어서는 재미로 이어진다. > **블랙잭 유비**: 주목도=카드 합, 한도=21(1라운드 기준)이다. 한도 밑에서 멈추는 게 "스탠드", 계속 받는 게 "히트", 넘기면 "버스트"다. §1.1의 핵심 재미("지금 정리할 것인가, 더 모아서 크게 처리할 것인가")가 여기서 가장 직접적으로 드러난다 — 의회의 실적 압박은 합본·상점 자산 구매로 넘어서는 재미로 이어진다.
@@ -70,7 +70,7 @@
- **하드 캡 재도입 (2026-09-16, 구 결정 번복)**: 애초엔 하드 캡을 없애고 초과분 소프트 페널티로 대체했으나(§19 참고), 실제로 캡이 없으니 "지금 정리할까 더 모을까"의 리스크가 사라져 그 고민 자체가 무의미해졌다. 최초 임계치(21)는 낮게, 라운드가 갈수록 한도가 커지는 성장 곡선으로 초반 로그라이크 가혹함 문제를 완화했다. - **하드 캡 재도입 (2026-09-16, 구 결정 번복)**: 애초엔 하드 캡을 없애고 초과분 소프트 페널티로 대체했으나(§19 참고), 실제로 캡이 없으니 "지금 정리할까 더 모을까"의 리스크가 사라져 그 고민 자체가 무의미해졌다. 최초 임계치(21)는 낮게, 라운드가 갈수록 한도가 커지는 성장 곡선으로 초반 로그라이크 가혹함 문제를 완화했다.
- **상신은 언제든 가능**: 카드가 2장뿐이어도 상신할 수 있다. - **상신은 언제든 가능**: 카드가 2장뿐이어도 상신할 수 있다.
- **자율 종료도 언제든 가능**: 한도 밑이면 원하는 시점에 정산으로 넘어갈 수 있다. - **자율 종료도 언제든 가능**: 한도 밑이면 원하는 시점에 정산으로 넘어갈 수 있다.
- **버스트해도 이월은 자동**: 강제 종료로 합본을 못 해도 카드는 소멸하지 않고 다음 라운드로 넘어간다 (§4.1). 그 라운드에서 놓친 토큰·명분·실적 기회 자체가 손해다 — 별도 징벌이 필요 없다. - **버스트해도 이월은 자동**: 강제 종료로 합본을 못 해도 카드는 소멸하지 않고 다음 라운드로 넘어간다 (§4.1). 그 라운드에서 놓친 코인·명분·실적 기회 자체가 손해다 — 별도 징벌이 필요 없다.
--- ---
@@ -113,7 +113,7 @@
| 방향 | 의미 | | 방향 | 의미 |
|---|---| |---|---|
| 좌 / 우 | 두 개의 오염 카드 선택지 중 하나를 책상에 적재. **토큰 증감 없음** | | 좌 / 우 | 두 개의 오염 카드 선택지 중 하나를 책상에 적재. **코인 증감 없음** |
| 위 | 보류 (1~2칸 소형 슬롯, 보조 장치) | | 위 | 보류 (1~2칸 소형 슬롯, 보조 장치) |
**프리뷰 표기** **프리뷰 표기**
@@ -148,11 +148,11 @@
``` ```
주목도(축별) = 해당 축에 적재된 카드들의 등급 합 → A. 게이지 상승 주목도(축별) = 해당 축에 적재된 카드들의 등급 합 → A. 게이지 상승
주목도(총계) = 책상 전체 카드들의 등급 합 (이월 포함) → C. 토큰 지출 가산 주목도(총계) = 책상 전체 카드들의 등급 합 (이월 포함) → C. 코인 지출 가산
주목도(이번 라운드) = 이번 라운드에 받은 카드들의 등급 합 → B. 라운드 한도 판정 주목도(이번 라운드) = 이번 라운드에 받은 카드들의 등급 합 → B. 라운드 한도 판정
``` ```
> **2026-09-17 결정**: 한도 판정만 "이번 라운드분"으로 분리한다. 한도 판정에 총계를 쓰면 이월 카드(§4.1) 때문에 라운드가 시작하자마자 이미 한도를 넘어 있어 **버스트가 무한 반복**된다(실제 플레이에서 라운드가 계속 자동으로 넘어가는 버그로 드러남). 블랙잭에서 각 핸드가 0부터 시작하듯, 한도는 "이번 판에 새로 떠안은 무게"만 센다. 이월된 카드의 압박은 사라지지 않고 총계 기준인 게이지 상승·토큰 가산으로 계속 작용한다. 결정 노트: `2026-09-17-주목도-라운드-분리`. > **2026-09-17 결정**: 한도 판정만 "이번 라운드분"으로 분리한다. 한도 판정에 총계를 쓰면 이월 카드(§4.1) 때문에 라운드가 시작하자마자 이미 한도를 넘어 있어 **버스트가 무한 반복**된다(실제 플레이에서 라운드가 계속 자동으로 넘어가는 버그로 드러남). 블랙잭에서 각 핸드가 0부터 시작하듯, 한도는 "이번 판에 새로 떠안은 무게"만 센다. 이월된 카드의 압박은 사라지지 않고 총계 기준인 게이지 상승·코인 가산으로 계속 작용한다. 결정 노트: `2026-09-17-주목도-라운드-분리`.
**A. 게이지 상시 상승** — 축별 **카드 장수**를 1차 기준으로, 등급 배율을 곱해 매 결재마다 대응 게이지 상승 (가안, 플레이 후 조정) **A. 게이지 상시 상승** — 축별 **카드 장수**를 1차 기준으로, 등급 배율을 곱해 매 결재마다 대응 게이지 상승 (가안, 플레이 후 조정)
@@ -188,7 +188,7 @@
> **결정 이력**: 최초엔 "주목도 21 초과 시 즉시 강제 종료"안을 로그라이크 초반에 과하다는 이유로 기각하고 초과분 누진 페널티(소프트 오버런)로 대체했었다. 그런데 하드 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됐다 — 그래서 하드 캡을 되살리되, 한도 자체가 라운드마다 커지는 성장 곡선을 더해 초반 가혹함 문제만 따로 해결했다. 상세 근거는 결정 노트 `2026-09-16-라운드-주목도-하드캡-복원` 참고. > **결정 이력**: 최초엔 "주목도 21 초과 시 즉시 강제 종료"안을 로그라이크 초반에 과하다는 이유로 기각하고 초과분 누진 페널티(소프트 오버런)로 대체했었다. 그런데 하드 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됐다 — 그래서 하드 캡을 되살리되, 한도 자체가 라운드마다 커지는 성장 곡선을 더해 초반 가혹함 문제만 따로 해결했다. 상세 근거는 결정 노트 `2026-09-16-라운드-주목도-하드캡-복원` 참고.
**C. 토큰 지출 가산** — 총 주목도에 따라 모든 토큰 지출에 가산 **C. 코인 지출 가산** — 총 주목도에 따라 모든 코인 지출에 가산
| 총 주목도 | 가산 | | 총 주목도 | 가산 |
|---|---| |---|---|
@@ -245,7 +245,7 @@
1. **페널티 소멸** — 사용된 오염 카드가 책상에서 **완전 소멸**하여 패시브 페널티 즉시 감소 (가장 큰 유인) 1. **페널티 소멸** — 사용된 오염 카드가 책상에서 **완전 소멸**하여 패시브 페널티 즉시 감소 (가장 큰 유인)
2. **명분 산출** — 축이 종류를 결정 (§7) 2. **명분 산출** — 축이 종류를 결정 (§7)
3. **토큰 획득** — 등급 합계가 양을 결정 (§8.2) 3. **코인 획득** — 등급 합계가 양을 결정 (§8.2)
4. **실적 점수** — §9.3 4. **실적 점수** — §9.3
5. **자산 트리거** — 패턴 반응형 자산 발동 5. **자산 트리거** — 패턴 반응형 자산 발동
@@ -305,11 +305,11 @@
> **유통기한은 필수.** 무한 저장 시 플레이어가 명분을 쌓아 한 번에 터뜨려 정치가 무의미해진다. 관료제 정합성도 확보된다 — 명분은 시의성이 생명이며 다음 라운드로 넘기면 효력을 잃는다. > **유통기한은 필수.** 무한 저장 시 플레이어가 명분을 쌓아 한 번에 터뜨려 정치가 무의미해진다. 관료제 정합성도 확보된다 — 명분은 시의성이 생명이며 다음 라운드로 넘기면 효력을 잃는다.
### 7.2. 명분은 4종을 유지한다 (토큰과의 차이) ### 7.2. 명분은 4종을 유지한다 (코인과의 차이)
토큰은 **양의 재화**이므로 1종으로 통합한다(§8). 명분은 **질의 재화**다 — 어떤 축을 합본했느냐가 어떤 정치 카드를 쥐었느냐로 직결되고, 이념색 페널티까지 따라붙는다. 1종으로 통합하면 "이번엔 절차 명분이 필요하니 대외 합본을 하자"는 계획이 사라지고 축 4개를 나눈 이유 자체가 소멸한다. 코인은 **양의 재화**이므로 1종으로 통합한다(§8). 명분은 **질의 재화**다 — 어떤 축을 합본했느냐가 어떤 정치 카드를 쥐었느냐로 직결되고, 이념색 페널티까지 따라붙는다. 1종으로 통합하면 "이번엔 절차 명분이 필요하니 대외 합본을 하자"는 계획이 사라지고 축 4개를 나눈 이유 자체가 소멸한다.
> **역할 분담**: 토큰이 **양**을, 명분이 **질**을 담당한다. 합본 시 **축은 명분 종류를, 등급 합계는 토큰 양을** 결정한다. > **역할 분담**: 코인이 **양**을, 명분이 **질**을 담당한다. 합본 시 **축은 명분 종류를, 등급 합계는 코인 양을** 결정한다.
### 7.3. 용도 (4갈래) ### 7.3. 용도 (4갈래)
@@ -359,17 +359,17 @@
### 8.1. 원칙 ### 8.1. 원칙
> **결재 단계에서 토큰 증감을 완전히 제거한다.** 토큰이 결재 순간에 개입하면 "어느 축의 오염을 받을까"라는 질문이 "예산이 부족하니 오른쪽"으로 덮어씌워진다. 손패가 없어 토큰을 배분할 수도 없으므로, 결재 단계의 토큰 수급은 선택을 왜곡하기만 한다. > **결재 단계에서 코인 증감을 완전히 제거한다.** 코인이 결재 순간에 개입하면 "어느 축의 오염을 받을까"라는 질문이 "예산이 부족하니 오른쪽"으로 덮어씌워진다. 손패가 없어 코인을 배분할 수도 없으므로, 결재 단계의 코인 수급은 선택을 왜곡하기만 한다.
### 8.2. 토큰은 1종 (합본에서만 산출) ### 8.2. 코인은 1종 (합본에서만 산출)
토큰은 상점 재화이므로 종류를 나눌 이유가 없다. **단일 토큰**으로 통합한다. 코인은 상점 재화이므로 종류를 나눌 이유가 없다. **단일 코인**으로 통합한다.
**산출량 = 합본에 사용된 카드의 기밀 등급 합계** (가안: 등급 1장당 등급 수치만큼) **산출량 = 합본에 사용된 카드의 기밀 등급 합계** (가안: 등급 1장당 등급 수치만큼)
> 예: 2급 + 3급 + 3급 합본 → 토큰 8 > 예: 2급 + 3급 + 3급 합본 → 코인 8
> >
> 축은 명분 종류를, 등급은 토큰 양을 결정한다. 따라서 플레이어는 **"어떤 축을 모을까"(정치)와 "어떤 등급을 모을까"(경제)를 별개로 판단**하게 된다. 고등급 오염은 위험하지만 돈이 된다. > 축은 명분 종류를, 등급은 코인 양을 결정한다. 따라서 플레이어는 **"어떤 축을 모을까"(정치)와 "어떤 등급을 모을까"(경제)를 별개로 판단**하게 된다. 고등급 오염은 위험하지만 돈이 된다.
### 8.3. 상점 (정산 단계) ### 8.3. 상점 (정산 단계)
@@ -382,13 +382,13 @@
| **소모품(뉴스 카드) 구매** | 인장·등급 변경 도구 | | **소모품(뉴스 카드) 구매** | 인장·등급 변경 도구 |
| **전례 매입** | 내러티브 태그 획득 | | **전례 매입** | 내러티브 태그 획득 |
모든 품목은 단일 토큰으로 지불한다. 모든 품목은 단일 코인으로 지불한다.
**진열 규칙**: 매 정산마다 무작위 4~6개 진열, 1회 리롤 가능(토큰 소모). 이번 회차 지부장 파벌에 따라 특정 아키타입 자산 등장률 ×3, 가중 파벌이 없는 아키타입은 S급 미등장. **진열 규칙**: 매 정산마다 무작위 4~6개 진열, 1회 리롤 가능(코인 소모). 이번 회차 지부장 파벌에 따라 특정 아키타입 자산 등장률 ×3, 가중 파벌이 없는 아키타입은 S급 미등장.
### 8.4. 초기 지급 ### 8.4. 초기 지급
취임 시 기본 토큰 지급 + 첫 합본까지의 유예 필요. 토큰 0으로 아무것도 못 하는 데드락 구간이 발생하지 않도록 할 것. 취임 시 기본 코인 지급 + 첫 합본까지의 유예 필요. 코인 0으로 아무것도 못 하는 데드락 구간이 발생하지 않도록 할 것.
--- ---
@@ -436,15 +436,15 @@
| 예산 긴축 | 상점 가격 1.5배 | | 예산 긴축 | 상점 가격 1.5배 |
| 보고 체계 간소화 | (이득) 모든 합본 필요 장수 -1 | | 보고 체계 간소화 | (이득) 모든 합본 필요 장수 -1 |
**유형 C. 징벌 규약** — 실적 미달 시에만 자동 부과, **영구 누적, 상한 없음**. 결재당 게이지 추가 상승 / 특정 축 토큰 산출 봉쇄 / 상점 슬롯 감소 등. 해제 수단: 위기 명분, 또는 다음 라운드 실적 초과 달성. **유형 C. 징벌 규약** — 실적 미달 시에만 자동 부과, **영구 누적, 상한 없음**. 결재당 게이지 추가 상승 / 특정 축 코인 산출 봉쇄 / 상점 슬롯 감소 등. 해제 수단: 위기 명분, 또는 다음 라운드 실적 초과 달성.
> **상한을 두지 않는 이유**: 매 라운드 부과되는 것이 아니라 실적 미달 시에만 쌓이므로 회복 불가 상태로 직행하지 않는다. 오히려 게이지가 차지 않고 탄핵도 오지 않는 안정 상태에서 런이 무한정 늘어지는 것을 막는 **자연스러운 종료 압력**으로 기능한다. 누적될수록 실적 달성이 어려워지므로 결국 종결로 수렴한다. > **상한을 두지 않는 이유**: 매 라운드 부과되는 것이 아니라 실적 미달 시에만 쌓이므로 회복 불가 상태로 직행하지 않는다. 오히려 게이지가 차지 않고 탄핵도 오지 않는 안정 상태에서 런이 무한정 늘어지는 것을 막는 **자연스러운 종료 압력**으로 기능한다. 누적될수록 실적 달성이 어려워지므로 결국 종결로 수렴한다.
> >
> **UI 처리**: 누적 5개 초과 시 목록을 접거나 아이콘 스택으로 압축 표시할 것. 상시 전체 나열은 화면을 잠식한다. > **UI 처리**: 누적 5개 초과 시 목록을 접거나 아이콘 스택으로 압축 표시할 것. 상시 전체 나열은 화면을 잠식한다.
**유형 D. 이득 규약** — 드물게 통과. 자산 지급, 족보 완화, 토큰 지급. **유형 D. 이득 규약** — 드물게 통과. 자산 지급, 족보 완화, 코인 지급.
구현된 5종(가안): 토큰 즉시 지급 / 결재당 게이지 상승 완화 / 토큰 지출 가산 완화 / **주목도 한도 증가** / 실적 요구치 하향. 후보 3개 중 하나가 D일 확률은 30% — §9.2의 "드물게 통과"를 확률로 옮긴 값이다. 자산 지급형은 자산 시스템(§10, 3차)이 생긴 뒤 추가한다. 구현된 5종(가안): 코인 즉시 지급 / 결재당 게이지 상승 완화 / 코인 지출 가산 완화 / **주목도 한도 증가** / 실적 요구치 하향. 후보 3개 중 하나가 D일 확률은 30% — §9.2의 "드물게 통과"를 확률로 옮긴 값이다. 자산 지급형은 자산 시스템(§10, 3차)이 생긴 뒤 추가한다.
> **내부 명분과의 연결**: 유형 D가 없으면 §7.3의 내부 명분("후보 강제 상정")이 불리한 규약만 올릴 수 있어 쓸 이유가 없다. 상정 가능 목록에 이득 규약이 포함되는 것이 내부 명분의 존재 이유다. > **내부 명분과의 연결**: 유형 D가 없으면 §7.3의 내부 명분("후보 강제 상정")이 불리한 규약만 올릴 수 있어 쓸 이유가 없다. 상정 가능 목록에 이득 규약이 포함되는 것이 내부 명분의 존재 이유다.
@@ -453,7 +453,7 @@
> **라운드당 합본 횟수를 강제하지 않는다.** 처리 장수나 합본 횟수를 요구하면 플레이어 선택이 감소하고 전략성이 떨어진다. **다중 통화 결산**으로 여러 경로를 허용한다. > **라운드당 합본 횟수를 강제하지 않는다.** 처리 장수나 합본 횟수를 요구하면 플레이어 선택이 감소하고 전략성이 떨어진다. **다중 통화 결산**으로 여러 경로를 허용한다.
``` ```
실적 점수 = Σ(이번 라운드 중 상신한 모든 족보 점수) + (토큰 납입 × 5) + (명분 납입 × 20) 실적 점수 = Σ(이번 라운드 중 상신한 모든 족보 점수) + (코인 납입 × 5) + (명분 납입 × 20)
``` ```
> 한 라운드 안에서 합본(상신)을 몇 번 하든 — 자율 종료 전까지 여러 번 상신할 수도, 버스트로 강제 종료돼 한 번도 못 할 수도 있다 — 그동안 상신한 족보 점수를 전부 합산한다. > 한 라운드 안에서 합본(상신)을 몇 번 하든 — 자율 종료 전까지 여러 번 상신할 수도, 버스트로 강제 종료돼 한 번도 못 할 수도 있다 — 그동안 상신한 족보 점수를 전부 합산한다.
@@ -464,14 +464,14 @@
**UI** **UI**
``` ```
[제3차 정례회의 요구 실적: 65] [제3차 정례회의 요구 실적: 65]
현재 확보: 50 (족보 25 + 토큰 납입 25) 현재 확보: 50 (족보 25 + 코인 납입 25)
부족: 15 → 명분 1개 납입 시 충족 부족: 15 → 명분 1개 납입 시 충족
``` ```
### 9.4. 13인 위원회 ### 9.4. 13인 위원회
- **상임 이사회 8명**: 고정 이념. 국장의 성향 태그에 직접 반응. - **상임 이사회 8명**: 고정 이념. 국장의 성향 태그에 직접 반응.
- **글로벌 지부장 5명**: 12개 파벌 풀에서 매 회차 무작위 선발. 실리(토큰·자산·축 처리 실적)에 반응. - **글로벌 지부장 5명**: 12개 파벌 풀에서 매 회차 무작위 선발. 실리(코인·자산·축 처리 실적)에 반응.
### 9.5. 정체 추론 (아발론 구조) ### 9.5. 정체 추론 (아발론 구조)
@@ -491,7 +491,7 @@
``` ```
매 회차 지부장 중 1~2명이 실제로는 반대 진영. 표시는 우호이나 실제 표는 반대로 나간다. 매 회차 지부장 중 1~2명이 실제로는 반대 진영. 표시는 우호이나 실제 표는 반대로 나간다.
**단서 = 표결 이력**: 매 라운드 표결 결과가 기록되고 **언제든 열람 가능한 UI**로 제공된다. "우호 표시인데 반대표를 던진" 이력이 쌓이면 정체가 드러난다. 즉시 확인은 명분 조사 또는 토큰 지불. **단서 = 표결 이력**: 매 라운드 표결 결과가 기록되고 **언제든 열람 가능한 UI**로 제공된다. "우호 표시인데 반대표를 던진" 이력이 쌓이면 정체가 드러난다. 즉시 확인은 명분 조사 또는 코인 지불.
> **필수 요건**: 정보가 부족하면 추론이 아니라 운이 된다. 배신자 1명 기준 **3~4라운드면 이력만으로 특정 가능**하도록 표결 빈도와 이력 가시성을 확보할 것. > **필수 요건**: 정보가 부족하면 추론이 아니라 운이 된다. 배신자 1명 기준 **3~4라운드면 이력만으로 특정 가능**하도록 표결 빈도와 이력 가시성을 확보할 것.
@@ -518,7 +518,7 @@
| 아키타입 | 정의 | | 아키타입 | 정의 |
|---|---| |---|---|
| `purge` | 책상의 오염 직접 제거 | | `purge` | 책상의 오염 직접 제거 |
| `convert` | 오염을 토큰·명분으로 환전 | | `convert` | 오염을 코인·명분으로 환전 |
| `quarantine` | 오염을 페널티 없는 상태로 묶어두되 조건부 복귀 | | `quarantine` | 오염을 페널티 없는 상태로 묶어두되 조건부 복귀 |
| `skip` | 패시브 페널티 무시·경감 | | `skip` | 패시브 페널티 무시·경감 |
@@ -530,7 +530,7 @@
| **적재 반응형** | 오염 적재 시 발동 (`on_pollution_gain`) | 높음 | | **적재 반응형** | 오염 적재 시 발동 (`on_pollution_gain`) | 높음 |
| **누적형** | 스택 영구 누적 후 임계마다 발동 | 높음 | | **누적형** | 스택 영구 누적 후 임계마다 발동 | 높음 |
| **재발동형** | 다른 자산 효과 복제·재트리거 | 중간 | | **재발동형** | 다른 자산 효과 복제·재트리거 | 중간 |
| **토큰 환산형** | 보유 토큰·명분 비례 효과 | 중간 | | **코인 환산형** | 보유 코인·명분 비례 효과 | 중간 |
| **메타형** | 게이지·자산 수·책상 상태 조건 | 중간 | | **메타형** | 게이지·자산 수·책상 상태 조건 | 중간 |
> **누적형·재발동형은 노선 무관으로 등장시켜 범용 증폭기 역할을 부여할 것.** 발라트로에서 성장형·재배치형 조커가 어떤 빌드에도 꽂히는 것과 같다. > **누적형·재발동형은 노선 무관으로 등장시켜 범용 증폭기 역할을 부여할 것.** 발라트로에서 성장형·재배치형 조커가 어떤 빌드에도 꽂히는 것과 같다.
@@ -565,7 +565,7 @@
| 경로 | 방식 | | 경로 | 방식 |
|---|---| |---|---|
| 상점 구매 | 주 경로. 토큰 소비 | | 상점 구매 | 주 경로. 코인 소비 |
| 극비 프로젝트 결재 | 스와이프 중 등장, 즉시 획득 | | 극비 프로젝트 결재 | 스와이프 중 등장, 즉시 획득 |
| 이득 규약 | 의회 표결 결과 보상 | | 이득 규약 | 의회 표결 결과 보상 |
@@ -613,7 +613,7 @@
### 11.3. 금지 사항 ### 11.3. 금지 사항
- **태그를 소모 재화로 쓰지 말 것** — 페이즈 전환 카운터가 깨진다. 누적만 되며 소모되지 않는다. - **태그를 소모 재화로 쓰지 말 것** — 페이즈 전환 카운터가 깨진다. 누적만 되며 소모되지 않는다.
- **태그가 직접 명분·토큰을 산출하지 말 것** — 명분 인플레이션으로 합본할 이유가 사라진다. 태그는 **규칙만 바꾸고 재화는 만들지 않는다.** - **태그가 직접 명분·코인을 산출하지 말 것** — 명분 인플레이션으로 합본할 이유가 사라진다. 태그는 **규칙만 바꾸고 재화는 만들지 않는다.**
### 11.4. 연대기 시스템 ### 11.4. 연대기 시스템
@@ -683,7 +683,7 @@
| 노선 | 특기 | 상징 연결 | | 노선 | 특기 | 상징 연결 |
|---|---|---| |---|---|---|
| **이지스 재단** | **정면 대응** — 모든 합본의 필요 장수 -1. 단 명분 산출 없음 (페널티 제거·토큰만) | 방패는 막을 뿐 공세로 나아가지 않는다. 기준선 노선이므로 규칙이 가장 단순 | | **이지스 재단** | **정면 대응** — 모든 합본의 필요 장수 -1. 단 명분 산출 없음 (페널티 제거·코인만) | 방패는 막을 뿐 공세로 나아가지 않는다. 기준선 노선이므로 규칙이 가장 단순 |
| **프로메테우스 랩** | **배양 합본** — 합본 사용 카드 1장을 소멸시키지 않고 '검체'로 남겨 다음 합본에 재사용 | 훔친 불을 가둬 배양한다 | | **프로메테우스 랩** | **배양 합본** — 합본 사용 카드 1장을 소멸시키지 않고 '검체'로 남겨 다음 합본에 재사용 | 훔친 불을 가둬 배양한다 |
| **우로보로스 협의회** | **순환 합본** — 단계적 상신 성립 시 마지막 카드가 처음으로 돌아가 연쇄 발동 | 꼬리를 삼키는 뱀 | | **우로보로스 협의회** | **순환 합본** — 단계적 상신 성립 시 마지막 카드가 처음으로 돌아가 연쇄 발동 | 꼬리를 삼키는 뱀 |
@@ -732,7 +732,7 @@
| 분류 | 조건 | | 분류 | 조건 |
|---|---| |---|---|
| **기본 붕괴** | 엔트로피 100% (세계 멸망) / 조직 저항 100% (사임) / 대중 공황 100% (문명 붕괴) / 탄핵 방어 실패 (정치적 탄핵) | | **기본 붕괴** | 엔트로피 100% (세계 멸망) / 조직 저항 100% (사임) / 대중 공황 100% (문명 붕괴) / 탄핵 방어 실패 (정치적 탄핵) |
| **변주 붕괴** | 기본 붕괴 + 특정 토큰·자산·전례 조합 보유 | | **변주 붕괴** | 기본 붕괴 + 특정 코인·자산·전례 조합 보유 |
| **노선 엔딩** | 각성한 노선의 전용 조건 달성 (각성 시 목표로 제시) | | **노선 엔딩** | 각성한 노선의 전용 조건 달성 (각성 시 목표로 제시) |
| **전례 연쇄 엔딩** | 특정 전례를 정해진 순서로 수립 | | **전례 연쇄 엔딩** | 특정 전례를 정해진 순서로 수립 |
@@ -772,7 +772,7 @@
1. [x] **기호 체계 + 책상 상태 상시 노출 UI** (§3) 1. [x] **기호 체계 + 책상 상태 상시 노출 UI** (§3)
2. [x] **책상 적재 + 누진 패시브 페널티 A·B** (§4) 2. [x] **책상 적재 + 누진 패시브 페널티 A·B** (§4)
3. [x] **표준 족보 판정 및 합본 실행** (§5) — §2.2 주목도 하드 캡(버스트)·자율 정산 로직까지 포함 3. [x] **표준 족보 판정 및 합본 실행** (§5) — §2.2 주목도 하드 캡(버스트)·자율 정산 로직까지 포함
4. [x] **합본 기반 토큰 산출** (§8.2) 4. [x] **합본 기반 코인 산출** (§8.2)
### 2차 — 압박 완성 ### 2차 — 압박 완성
5. [~] **상점** (§8.3) — "오염 카드 직접 처리"만 구현. 자산 구매·소모품·전례 매입은 3차가 5. [~] **상점** (§8.3) — "오염 카드 직접 처리"만 구현. 자산 구매·소모품·전례 매입은 3차가
@@ -816,7 +816,7 @@
- [ ] 족보 봉쇄 규약이 빌드에 따라 다르게 아픈가? - [ ] 족보 봉쇄 규약이 빌드에 따라 다르게 아픈가?
- [ ] 실적 미달을 감수하는 선택이 실제로 성립하는가? - [ ] 실적 미달을 감수하는 선택이 실제로 성립하는가?
- [ ] "일은 잘했는데 정치적으로 죽는" 상황이 발생하는가? - [ ] "일은 잘했는데 정치적으로 죽는" 상황이 발생하는가?
- [ ] 상점이 토큰 획득에 의미를 부여하는가? - [ ] 상점이 코인 획득에 의미를 부여하는가?
**빌드 (3차)** **빌드 (3차)**
- [ ] 자산 발동 로그로 엔진이 도는 것이 체감되는가? - [ ] 자산 발동 로그로 엔진이 도는 것이 체감되는가?
@@ -841,7 +841,7 @@
| 항목 | 결정 | 근거 | | 항목 | 결정 | 근거 |
|---|---|---| |---|---|---|
| 토큰 종류 | **1종 통합** | 상점 재화이므로 양만 의미 있음. 종류 분화는 번거로움만 추가 | | 코인 종류 | **1종 통합** | 상점 재화이므로 양만 의미 있음. 종류 분화는 번거로움만 추가 |
| 명분 종류 | **4종 유지** | 질의 재화. 통합 시 축 선택의 의미가 소멸 | | 명분 종류 | **4종 유지** | 질의 재화. 통합 시 축 선택의 의미가 소멸 |
| 합본 사용 카드 | **완전 소멸** | 프로메테우스 '배양'이 예외로 성립하려면 기본이 소멸이어야 함 | | 합본 사용 카드 | **완전 소멸** | 프로메테우스 '배양'이 예외로 성립하려면 기본이 소멸이어야 함 |
| 이지스 합본 특기 | **필요 장수 -1, 명분 없음** | 합본은 쉽고 정치는 어려운 구조가 노선 컨셉과 일치 | | 이지스 합본 특기 | **필요 장수 -1, 명분 없음** | 합본은 쉽고 정치는 어려운 구조가 노선 컨셉과 일치 |
@@ -859,12 +859,12 @@
- 이중첩자 수 1명 vs 2명 (2명이면 추론 난도 급상승) - 이중첩자 수 1명 vs 2명 (2명이면 추론 난도 급상승)
- 신임 위원회 지명 인원 3명의 적정성 - 신임 위원회 지명 인원 3명의 적정성
- 실적 요구치 상승 곡선 (라운드당 +20 가안) - 실적 요구치 상승 곡선 (라운드당 +20 가안)
- 토큰 납입 5점 / 명분 납입 20점 환산 비율 - 코인 납입 5점 / 명분 납입 20점 환산 비율
- 페이즈별 오염 공급량·등급 분포 곡선 - 페이즈별 오염 공급량·등급 분포 곡선
- 각성 임계치 15의 적정성 - 각성 임계치 15의 적정성
- 이지스 재단: 「명분 산출 없음」 vs 「명분 절반(내림)」 비교 검증 - 이지스 재단: 「명분 산출 없음」 vs 「명분 절반(내림)」 비교 검증
- 합본 토큰 산출량 산식 (등급 합계 그대로 vs 계수 적용) - 합본 코인 산출량 산식 (등급 합계 그대로 vs 계수 적용)
- **§4.2-A/C 게이지 상승·토큰 가산의 누진 구간 수치 전체 (1~15/16~30/31~40/41+ 등)** - **§4.2-A/C 게이지 상승·코인 가산의 누진 구간 수치 전체 (1~15/16~30/31~40/41+ 등)**
- **§5.1 족보표의 명분·실적 점수 배율 (상하위 격차가 지금 과한지 부족한지)** - **§5.1 족보표의 명분·실적 점수 배율 (상하위 격차가 지금 과한지 부족한지)**
- **§2.2/§4.2-B 라운드 주목도 한도의 시작값(21)·증가폭(+5) 적정성 — 라운드를 몇 번쯤 버틸 수 있어야 하는지와 연동해서 정할 것** - **§2.2/§4.2-B 라운드 주목도 한도의 시작값(21)·증가폭(+5) 적정성 — 라운드를 몇 번쯤 버틸 수 있어야 하는지와 연동해서 정할 것**
@@ -914,7 +914,7 @@
| 적재 반응형 | `pollution_reactive` | | 적재 반응형 | `pollution_reactive` |
| 누적형 | `cumulative` | | 누적형 | `cumulative` |
| 재발동형 | `repeater` | | 재발동형 | `repeater` |
| 토큰 환산형 | `token_scaling` | | 코인 환산형 | `coin_scaling` |
| 메타형 | `meta` | | 메타형 | `meta` |
| **주기형** (§21 ③에서 추가) | `periodic` | | **주기형** (§21 ③에서 추가) | `periodic` |
@@ -0,0 +1,25 @@
---
created: 2026-09-18
---
%% 파일명: 2026-09-18-라운드-접수량-누적카운터. 규칙 문서엔 규칙만, 이유는 여기에 %%
## 결정
- 이번 라운드 접수량(§4.2-B 한도 판정 기준)을 **책상 카드에서 파생시키는 값**이 아니라, **CoreGameScreen이 직접 들고 있는 순수 누적 카운터**로 바꾼다.
- 카드를 받을 때마다 그 등급만큼 카운터를 더한다. 합본으로 책상 카드가 사라져도 이 카운터는 줄지 않는다.
- 카운터는 라운드가 바뀔 때(의회 결의 후)만 0으로 리셋된다.
- 책상 전체 주목도(이월 포함, 게이지·코인 가산의 기준)는 지금처럼 `deskCards`에서 그대로 파생시킨다 — 이번 결정은 "라운드 한도 판정용" 수치에만 적용된다.
## 배경
- 플레이 피드백: "합본을 많이 하게 되면 라운드가 너무 길어진다. 지루함을 유발함."
- 원인: 기존 `roundAttention(deskCards, round)`이 "책상에 남아 있는, 이번 라운드에 받은 카드"를 매번 다시 필터링해 합산하는 파생값이었다. 합본하면 그 카드들이 책상에서 사라지므로 접수량도 같이 줄었다 — 5장 받아 한도(21)에 걸린 뒤 합본하면 0으로 리셋되고, 다시 5장을 받을 수 있는 구조였다. 이론상 무한 반복이 가능했다.
- 블랙잭 유비(§2.2)로 보면 명백한 모순이었다: 블랙잭은 받은 카드를 버려서 핸드 합계를 낮출 수 없다. §1.1의 핵심 재미("지금 정리할 것인가, 더 모아서 크게 처리할 것인가")는 "정리해도 이번 판에 받은 기록 자체는 남는다"는 전제 위에서만 성립하는 긴장이다.
- 관료제 프레이밍과도 맞아떨어진다 — "접수 대장은 지워지지 않는다"(처리를 해도 이번 회기에 접수한 기록은 남는다).
## 검토했으나 기각한 대안
- **오염 카드가 더 어렵게 등장하도록 조정**: 운이 나쁘면 여전히 무한에 가깝게 라운드가 늘어질 수 있어 근본 해결이 아니었다. 또한 합본 카타르시스(§18 체크리스트)를 깎는 역방향 수정이라 기각.
- **합본해도 주목도가 전혀 안 깎이게 함**: §5.2 "페널티 소멸"이 합본의 가장 큰 보상인데, 이걸 없애면 대규모 합본이 판을 뒤집는다는 §4.3 원칙이 죽는다. 책상 총 주목도(게이지·코인 가산 기준)는 지금처럼 계속 깎이게 두고, **한도 판정용 수치만** 분리해 안 깎이게 하는 절충안을 택했다.
- **이월 카드에 할인율을 적용해 합산**: 화면 숫자로 한도까지 몇 점 남았는지 암산이 안 돼 §3 "0.5초 판단" 요건에 어긋난다 — 2026-09-17 결정(주목도-라운드-분리)에서 같은 이유로 기각한 전례와 일관성을 유지했다.
## 재검토 조건
- 한도(§4.2-B, 시작 21 + 라운드당 +5)가 이 변경으로 실질적으로 더 타이트해졌다 — 신규 카드만으로는 라운드당 합본 1회 안팎이 표준이 된다(평균 등급 4.14 기준 한도 21 ÷ 4.14 ≈ 5장). 실측 플레이 후 한도 시작값·증가폭을 재조정할 것(§20.1 기존 튜닝 항목과 연동).
- 라운드 내 여러 번 상신(§9.3 "합본을 몇 번 하든 그동안 점수를 합산")이 실제로 자주 일어나는지, 아니면 사실상 라운드당 1회로 굳어지는지 확인 필요.