라운드 접수량 버그 수정 + 토큰→코인 용어 통일 + 외부 배포(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">
<head>
<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" />
<title>projectss</title>
<!-- 모바일 브라우저 상단/하단 바를 다크 UI와 맞춘다 -->
<meta name="theme-color" content="#121212" />
<meta name="description" content="관료주의 코스믹 호러 덱빌딩 — 종말을 결재하시겠습니까?" />
<title>Project SS — 종말을 결재하시겠습니까?</title>
</head>
<body>
<div id="root"></div>
+3 -1
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"deploy": "bash ../deploy.sh",
"deploy:dry": "bash ../deploy.sh --dry-run"
},
"dependencies": {
"framer-motion": "^12.40.0",
+3 -3
View File
@@ -104,7 +104,7 @@
.combine-card.selected {
opacity: 1;
outline: 2px solid var(--color-token-budget);
outline: 2px solid var(--color-coin);
border-radius: 6px;
}
@@ -148,7 +148,7 @@
}
.combine-match:hover {
border-color: var(--color-token-budget);
border-color: var(--color-coin);
}
.match-top {
@@ -162,7 +162,7 @@
font-weight: 700;
}
.match-score { color: var(--color-token-budget); }
.match-score { color: var(--color-coin); }
.match-detail {
font-size: 11px;
+3 -3
View File
@@ -10,7 +10,7 @@ import './CombinePanel.css';
// 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다.
//
// 2026-09-15 결정 반영: 선택은 최대 5장(§5.1.1). 족보에 실제로 쓰이는 카드만 소멸하므로,
// 각 족보 후보가 어떤 카드를 가져가고 토큰이 얼마 나오는지(등급 합계, §8.2)를 미리 보여준다.
// 각 족보 후보가 어떤 카드를 가져가고 코인이 얼마 나오는지(등급 합계, §8.2)를 미리 보여준다.
export default function CombinePanel({ deskCards, onConfirm, onClose, activeProtocols = [] }) {
const [selectedIds, setSelectedIds] = useState([]);
@@ -93,7 +93,7 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
{matches.map((patternId) => {
const preview = resolveCombine(selection, patternId);
if (!preview) return null;
const { pattern, usedCards, tokens, justification, performanceScore } = preview;
const { pattern, usedCards, coins, justification, performanceScore } = preview;
return (
<button key={patternId} className="combine-match" onClick={() => confirm(patternId)}>
<div className="match-top">
@@ -101,7 +101,7 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
<span className="match-score">실적 +{performanceScore}</span>
</div>
<div className="match-detail">
토큰 +{tokens} · 명분 {justification.type} +{justification.amount} · {usedCards.length}장 소멸
코인 +{coins} · 명분 {justification.type} +{justification.amount} · {usedCards.length}장 소멸
</div>
<div className="match-used">
{usedCards.map((c) => (
@@ -45,6 +45,14 @@
color: var(--color-risk-critical);
}
/* 빌드 식별자 — 항상 보이되 시선을 끌지는 않게. 테스터가 스크린샷만 찍어도 버전이 남는다. */
.core-build-stamp {
margin-left: auto;
opacity: 0.4;
font-size: 10px;
white-space: nowrap;
}
.core-status-penalty {
color: var(--color-entropy-critical);
}
+47 -36
View File
@@ -1,3 +1,4 @@
/* global __BUILD_INFO__ */
import React, { useMemo, useState } from 'react';
import Papa from 'papaparse';
import cardsCsvRaw from '../../data/cards.csv?raw';
@@ -10,18 +11,18 @@ import ShopScreen from './ShopScreen';
import CouncilScreen from './CouncilScreen';
import { pickPollutionEntry } from '../engine/pollutionPool';
import {
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge,
roundAttention, roundAttentionCap,
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, coinSurcharge,
roundAttentionCap,
} from '../engine/desk';
import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop';
import {
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
applyInterventions, gaugeSurchargeOf, tokenSurchargeOf, attentionCapBonusOf,
requiredScoreDeltaOf, grantTokenOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS,
applyInterventions, gaugeSurchargeOf, coinSurchargeOf, attentionCapBonusOf,
requiredScoreDeltaOf, grantCoinOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS,
} from '../engine/council';
import './CoreGameScreen.css';
// 코어 루프 그레이박스 — §17 1차(기호·책상·족보·토큰) + 2차(상점·규약·실적감사·명분).
// 코어 루프 그레이박스 — §17 1차(기호·책상·족보·코인) + 2차(상점·규약·실적감사·명분).
//
// 2026-09-16 결정 반영 (라운드/회기 이층 구조 폐지, 결정 노트:
// 2026-09-16-라운드-주목도-하드캡-복원): 라운드 하나 = 결재하다 정산(상점)→의회로 가는 단일
@@ -33,7 +34,7 @@ import './CoreGameScreen.css';
// 자산·전례·소모품(§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 buildCard = (row) => ({
@@ -82,7 +83,7 @@ export default function CoreGameScreen() {
const [drawQueue, setDrawQueue] = useState(() => shuffle(deck));
const [deskCards, setDeskCards] = useState([]);
const [gauges, setGauges] = useState(INITIAL_GAUGES);
const [tokens, setTokens] = useState(INITIAL_TOKENS);
const [coins, setCoins] = useState(INITIAL_COINS);
const [score, setScore] = useState(0);
const [justifications, setJustifications] = useState({}); // { 명분종류: 수량 }
const [showCombine, setShowCombine] = useState(false);
@@ -93,6 +94,11 @@ export default function CoreGameScreen() {
const [phase, setPhase] = useState('approval'); // 'approval' | 'shop' | 'council'
const [round, setRound] = useState(1);
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 [activeProtocol, setActiveProtocol] = useState(null); // §9.2 유형A, 이번 라운드 1개
const [deferredCandidates, setDeferredCandidates] = useState([]); // 절차 명분 이월분 (§7.3)
@@ -108,8 +114,8 @@ export default function CoreGameScreen() {
// 이득 규약이면 음수라 페널티가 상쇄된다.
const effectiveProtocols = [...penalties, ...activeProtocols];
const gaugeExtraFlat = gaugeSurchargeOf(effectiveProtocols);
const tokenExtraFlat = tokenSurchargeOf(effectiveProtocols);
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
const coinExtraFlat = coinSurchargeOf(effectiveProtocols);
const surcharge = coinSurcharge(deskCards, coinExtraFlat);
const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0);
const attentionCap = roundAttentionCap(round, attentionCapBonusOf(activeProtocols));
@@ -150,15 +156,16 @@ export default function CoreGameScreen() {
const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat);
setGauges(nextGauges);
// setState는 비동기라, 이번 카드 반영 후의 책상 상태를 한도 판정에 바로 써야 하므로
// setState는 비동기라, 이번 카드 반영 후의 접수량을 한도 판정에 바로 써야 하므로
// 로컬 변수로 미리 계산한다.
let nextDesk = deskCards;
let nextIntake = roundIntake;
if (side.axis) {
const grade = side.grade || 1;
const entry = pickPollutionEntry(side.axis, grade);
const newCard = makeDeskCard(side.axis, grade, entry || {}, round);
nextDesk = addToDesk(deskCards, [newCard]);
setDeskCards(nextDesk);
setDeskCards((prev) => addToDesk(prev, [newCard]));
nextIntake = roundIntake + grade;
setRoundIntake(nextIntake);
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
} else {
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
@@ -169,9 +176,9 @@ export default function CoreGameScreen() {
if (checkGameOver(nextGauges)) return;
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
// 이월 카드는 한도 판정에서 빠진다(roundAttention) — 안 그러면 다음 라운드가 시작하자마자
// 버스트가 반복된다.
if (roundAttention(nextDesk, round) > attentionCap) {
// roundIntake는 순수 누적 카운터라 합본해도 줄지 않는다 — 그래야 "합본을 반복하면 라운드가
// 안 끝나는" 문제 없이 한도가 실제로 라운드를 마감시킨다.
if (nextIntake > attentionCap) {
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
goToShop();
return;
@@ -181,14 +188,14 @@ export default function CoreGameScreen() {
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
const handleEndRound = () => {
if (phase !== 'approval') return;
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${roundAttention(deskCards, round)}/${attentionCap}`);
pushLog(`제${round}라운드 자율 종료(정산하기) — 접수량 ${roundIntake}/${attentionCap}`);
goToShop();
};
const handleCombineConfirm = (result) => {
// 족보에 실제로 쓰인 카드만 소멸한다 — 선택했지만 안 쓰인 카드는 책상에 남는다 (§4.1 이월).
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로 제한한다.
// 0.5짜리 페어는 두 번 모아야 1개가 되므로, 하위 족보만 반복하면 정치 자원이 잘 안 쌓인다.
setJustifications((prev) => {
@@ -201,7 +208,7 @@ export default function CoreGameScreen() {
});
setScore((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);
};
@@ -210,13 +217,13 @@ export default function CoreGameScreen() {
const card = deskCards.find((c) => c.id === cardId);
if (!card) return;
const price = purgePrice(card, surcharge);
setTokens((prev) => prev - price);
setCoins((prev) => prev - price);
setDeskCards((prev) => removeFromDesk(prev, [cardId]));
setShopDisplay((prev) => prev.filter((c) => c.id !== cardId));
pushLog(`상점: 『${card.name || '오염 카드'}』(등급 ${card.grade}) 직접 소각 — 토큰 -${price}`);
pushLog(`상점: 『${card.name || '오염 카드'}』(등급 ${card.grade}) 직접 소각 — 코인 -${price}`);
};
const handleShopReroll = () => {
setTokens((prev) => prev - REROLL_COST);
setCoins((prev) => prev - REROLL_COST);
setShopDisplay(rollShopDisplay(deskCards));
setShopRerolled(true);
};
@@ -227,19 +234,19 @@ export default function CoreGameScreen() {
};
/* ── 의회 (§9) ── */
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
const handleCouncilResolve = ({ coinsPaid, justificationsPaidCount, interventions }) => {
const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } =
applyInterventions(councilCandidates, interventions);
// 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D).
const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols));
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20.
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 코인 납입×5 + 명분 납입×20.
const finalScore = settleScore({
combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount,
combineScore: roundScore, coinsPaid, justificationsPaid: justificationsPaidCount,
});
const passed = finalScore >= finalRequired;
setTokens((prev) => prev - tokensPaid);
setCoins((prev) => prev - coinsPaid);
setJustifications({}); // §7.1: 다음 의회까지만 유효 — 쓰지 않은 것도 여기서 소멸
// 징벌 규약 갱신. setState updater 안에서 로그를 찍으면 StrictMode의 이중 호출 때문에
@@ -275,10 +282,10 @@ export default function CoreGameScreen() {
setActiveProtocol(winner);
if (winner) {
pushLog(`규약 통과: 『${winner.name}』 ${winner.votes}/${COUNCIL_SEATS}표 — 다음 라운드 적용`);
const grant = grantTokenOf(winner);
const grant = grantCoinOf(winner);
if (grant > 0) {
setTokens((prev) => prev + grant);
pushLog(`이득 규약 『${winner.name}』 — 토큰 +${grant} 즉시 지급`);
setCoins((prev) => prev + grant);
pushLog(`이득 규약 『${winner.name}』 — 코인 +${grant} 즉시 지급`);
}
} else {
pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
@@ -286,6 +293,7 @@ export default function CoreGameScreen() {
setRound((r) => r + 1);
setRoundScore(0);
setRoundIntake(0); // 새 라운드는 접수량 0에서 다시 시작한다 (블랙잭의 새 핸드)
setPhase('approval');
};
@@ -312,13 +320,11 @@ export default function CoreGameScreen() {
<DeskBar
deskCards={deskCards}
gauges={gauges}
tokens={tokens}
round={round}
attentionCap={attentionCap}
coins={coins}
onOpenCombine={() => setShowCombine(true)}
onOpenGuide={() => setShowGuide(true)}
gaugeExtraFlat={gaugeExtraFlat}
tokenExtraFlat={tokenExtraFlat}
coinExtraFlat={coinExtraFlat}
/>
<div className="core-status-row">
@@ -327,6 +333,11 @@ export default function CoreGameScreen() {
<span>명분 {justificationTotal}개</span>
{activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />}
{penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)}
{/* 빌드 식별자 — 외부 테스터 피드백을 "어느 빌드였나"에 붙여 정리하기 위한 표시.
vite.config.js가 빌드 시각+커밋 해시를 주입한다. */}
<span className="core-build-stamp" title="빌드 식별자 (피드백 시 함께 적어 주세요)">
{__BUILD_INFO__}
</span>
</div>
{phase === 'approval' && (
@@ -334,7 +345,7 @@ export default function CoreGameScreen() {
card={currentCard}
onSwipe={handleSwipe}
onEndRound={handleEndRound}
roundAttention={roundAttention(deskCards, round)}
roundIntake={roundIntake}
attentionCap={attentionCap}
/>
)}
@@ -342,7 +353,7 @@ export default function CoreGameScreen() {
{phase === 'shop' && (
<ShopScreen
display={shopDisplay}
tokens={tokens}
coins={coins}
deskSurcharge={surcharge}
onPurge={handleShopPurge}
onReroll={handleShopReroll}
@@ -355,7 +366,7 @@ export default function CoreGameScreen() {
<CouncilScreen
round={round}
combineScore={roundScore}
tokens={tokens}
coins={coins}
justifications={justifications}
penalties={penalties}
candidates={councilCandidates}
+9 -5
View File
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
import { SymbolFace } from './SymbolBadge';
import { attentionZone } from '../engine/desk';
import './CoreSwipeCard.css';
// §3 핵심 요건: "스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다." 텍스트는 서사만 담당한다.
@@ -12,9 +13,12 @@ import './CoreSwipeCard.css';
//
// 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 y = useMotionValue(0);
const controls = useAnimation();
@@ -45,8 +49,8 @@ export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundAttentio
if (!card) return null;
const remaining = Math.max(0, attentionCap - roundAttention);
const zone = remaining === 0 ? 'bust' : remaining <= 5 ? 'warning' : 'normal';
const remaining = Math.max(0, attentionCap - roundIntake);
const zone = attentionZone(roundIntake, attentionCap); // desk.js와 같은 기준 (§4.2-B)
return (
<div className="core-card-container">
@@ -96,7 +100,7 @@ export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundAttentio
{onEndRound && (
<div className={`core-round-action zone-${zone}`}>
<span className="core-round-attention">
이번 라운드 주목도 <strong>{roundAttention}</strong> / {attentionCap}
이번 라운드 접수량 <strong>{roundIntake}</strong> / {attentionCap}
{remaining > 0
? <span className="core-round-remaining"> · 한도까지 {remaining}</span>
: <span className="core-round-remaining"> · 한도 도달</span>}
+12 -12
View File
@@ -41,7 +41,7 @@
font-weight: 700;
}
.audit-met { color: var(--color-token-budget); }
.audit-met { color: var(--color-coin); }
.audit-short { color: var(--color-risk-critical); }
.council-audit-breakdown {
@@ -50,7 +50,7 @@
margin: 6px 0 12px;
}
.council-token-pay {
.council-coin-pay {
display: flex;
align-items: center;
gap: 8px;
@@ -62,7 +62,7 @@
border-radius: 5px;
}
.council-token-pay button {
.council-coin-pay button {
background: rgba(255, 255, 255, 0.1);
border: none;
color: var(--color-text);
@@ -72,18 +72,18 @@
cursor: pointer;
}
.council-token-pay button:disabled {
.council-coin-pay button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.council-token-committed {
.council-coin-committed {
min-width: 14px;
text-align: center;
color: var(--color-token-budget);
color: var(--color-coin);
}
.council-token-note {
.council-coin-note {
opacity: 0.5;
font-size: 11px;
}
@@ -168,7 +168,7 @@
}
.council-justification-item button:hover {
border-color: var(--color-token-budget);
border-color: var(--color-coin);
}
.council-justification-item button:disabled {
@@ -223,7 +223,7 @@
}
.council-candidate.forced {
border-color: var(--color-token-budget);
border-color: var(--color-coin);
}
.candidate-name {
@@ -259,7 +259,7 @@
.council-required-delta {
font-size: 11px;
color: var(--color-token-budget);
color: var(--color-coin);
font-weight: 400;
}
@@ -306,7 +306,7 @@
.candidate-votes.lead {
opacity: 1;
color: var(--color-token-budget);
color: var(--color-coin);
font-weight: 700;
}
@@ -317,7 +317,7 @@
}
.council-candidate.winning {
border-color: var(--color-token-budget);
border-color: var(--color-coin);
}
.council-candidate.added {
+13 -12
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { Coins } from 'lucide-react';
import {
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, justificationDetail,
availableForTabling, applyInterventions, tallyVotes, applyVoteSwing, resolveProtocolVote,
@@ -11,16 +12,16 @@ import './CouncilScreen.css';
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
// 상정되지 않는다. 플레이어는 표결에 직접 개입 못 하고 명분으로 안건 자체를 조작할 뿐이다(§9.2).
//
// 2026-09-15 결정: 토큰 1종, 명분 개입 4갈래(§7.3). 명분은 종류별 "수량"이라 1개 이상이어야 쓴다.
// 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, requiredDelta = 0, onResolve,
round, combineScore, coins, justifications, penalties, candidates, requiredDelta = 0, onResolve,
}) {
const required = Math.max(0, requiredScore(round) + requiredDelta);
const [tokensPaid, setTokensPaid] = useState(0);
const [coinsPaid, setCoinsPaid] = useState(0);
const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
const [scorePaidCount, setScorePaidCount] = useState(0);
const [interventions, setInterventions] = useState([]);
@@ -30,7 +31,7 @@ export default function CouncilScreen({
const remainingOf = (type) => (justifications[type] || 0) - (spent[type] || 0);
const currentTotal = settleScore({
combineScore, tokensPaid, justificationsPaid: scorePaidCount,
combineScore, coinsPaid, justificationsPaid: scorePaidCount,
});
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 confirm = () => {
onResolve({ tokensPaid, justificationsPaidCount: scorePaidCount, interventions });
onResolve({ coinsPaid, justificationsPaidCount: scorePaidCount, interventions });
};
return (
@@ -102,15 +103,15 @@ export default function CouncilScreen({
</span>
</div>
<p className="council-audit-breakdown">
족보 {combineScore} + 토큰 납입 {tokensPaid}×5 + 명분 납입 {scorePaidCount}×20
족보 {combineScore} + 코인 납입 {coinsPaid}×5 + 명분 납입 {scorePaidCount}×20
</p>
<div className="council-token-pay">
<span>토큰 {tokens - tokensPaid} 보유</span>
<button onClick={() => setTokensPaid((n) => Math.max(0, n - 1))} disabled={tokensPaid === 0}>-</button>
<span className="council-token-committed">{tokensPaid}</span>
<button onClick={() => setTokensPaid((n) => Math.min(tokens, n + 1))} disabled={tokensPaid >= tokens}>+</button>
<span className="council-token-note">납입 1개 = 실적 5</span>
<div className="council-coin-pay">
<span><Coins size={13} className="coin-icon" /> 코인 {coins - coinsPaid} 보유</span>
<button onClick={() => setCoinsPaid((n) => Math.max(0, n - 1))} disabled={coinsPaid === 0}>-</button>
<span className="council-coin-committed">{coinsPaid}</span>
<button onClick={() => setCoinsPaid((n) => Math.min(coins, n + 1))} disabled={coinsPaid >= coins}>+</button>
<span className="council-coin-note">납입 1개 = 실적 5</span>
</div>
</section>
+2 -26
View File
@@ -8,10 +8,6 @@
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 {
display: flex;
align-items: center;
@@ -29,24 +25,8 @@
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-tokens { 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-coins { opacity: 0.8; }
.desk-bar-actions {
display: flex;
@@ -71,7 +51,7 @@
}
.desk-combine-btn {
background: var(--color-token-budget);
background: var(--color-coin);
color: #0a0a0a;
border-color: transparent;
}
@@ -167,7 +147,3 @@
opacity: 1;
}
.desk-carried {
opacity: 0.55;
font-size: 11px;
}
+12 -27
View File
@@ -1,53 +1,40 @@
import React, { useState } from 'react';
import { Coins } from 'lucide-react';
import { AXES, AXIS_META } from '../engine/symbols';
import {
totalCount, totalAttention, roundAttention, attentionByAxis, attentionZone, tokenSurcharge,
gaugeRiseRateByAxis,
totalCount, totalAttention, attentionByAxis, coinSurcharge, gaugeRiseRateByAxis,
} from '../engine/desk';
import { AxisIcon } from './SymbolBadge';
import DeskDetailPopup from './DeskDetailPopup';
import './DeskBar.css';
// 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다."
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)이고, 이제 주목도가 **이번 라운드의
// 한도(attentionCap)를 넘으면 그 자리에서 라운드가 강제 종료**된다(§4.2-B, 결정 노트
// 2026-09-16-라운드-주목도-하드캡-복원). 그래서 총 주목도뿐 아니라 한도까지 항상 보여야
// "한 장 더 받을지, 지금 정산할지" 판단이 선다.
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)다.
//
// 2026-09-17 수정: (1) "정산하기" 버튼은 결재 카드 아래(CoreSwipeCard)로 옮겼다 — 툴바 구석에
// 있으면 핵심 결정 버튼인 걸 모른다는 피드백. (2) 한도 판정에 쓰는 주목도는 **이번 라운드분**
// 이다. 책상 전체 주목도는 이월 카드까지 포함해 게이지·토큰 가산에 계속 작용하므로 둘 다 보여준다.
// 2026-09-18 결정(역할 분리): 이번 라운드 접수량/한도("카드를 더 받을지" 판단)는 결재 화면
// (CoreSwipeCard)이 전담한다. 여기 DeskBar는 **책상에 지금 쌓여 있는 것**(이월 포함 총
// 주목도, 축별 분포, 게이지·지출 가산)만 보여준다 — "무엇을 합본할 수 있는가"에 필요한 정보다.
const MAX_GRADE_CHIPS = 6;
export default function DeskBar({
deskCards, gauges, tokens, round, attentionCap, onOpenCombine, onOpenGuide,
gaugeExtraFlat = 0, tokenExtraFlat = 0,
deskCards, gauges, coins, onOpenCombine, onOpenGuide,
gaugeExtraFlat = 0, coinExtraFlat = 0,
}) {
const [detailAxis, setDetailAxis] = useState(null);
const total = totalCount(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, round);
const zoneLabel = {
normal: null,
warning: `한도 근접 (${attentionCap - 5}~${attentionCap}) — 정산할지 고려하십시오`,
bust: `한도 초과! (${attentionCap}+) — 이번 라운드는 강제 종료됩니다`,
}[zone];
const surcharge = coinSurcharge(deskCards, coinExtraFlat);
return (
<div className={`desk-bar zone-${zone}`}>
<div className="desk-bar">
<div className="desk-bar-top">
<div className="desk-total">
책상 {total}장 · <span className="desk-attention">이번 라운드 {current}/{attentionCap}</span>
{carried > 0 && <span className="desk-carried"> (이월 {carried})</span>}
책상 {total}장 · <span className="desk-attention">주목도 {deskAttention}</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 className="desk-bar-actions">
<button className="desk-guide-btn" onClick={onOpenGuide}>족보표</button>
@@ -57,8 +44,6 @@ export default function DeskBar({
</div>
</div>
{zoneLabel && <div className="desk-zone-banner">{zoneLabel}</div>}
<div className="desk-bar-rows">
{AXES.map((axis) => {
const cards = deskCards.filter((c) => c.axis === axis);
@@ -47,7 +47,7 @@
}
.guide-hint strong {
color: var(--color-token-budget);
color: var(--color-coin);
}
.guide-list {
@@ -75,7 +75,7 @@
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-condition {
@@ -34,7 +34,7 @@ export default function HandGuidePopup({ onClose }) {
"같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 한 번에
{' '}{MIN_COMBINE_CARDS}~{MAX_COMBINE_CARDS}장을 선택하면 성립하는 족보가 자동 판정되며,
족보에 실제로 쓰인 카드만 소멸한다 (나머지는 책상에 남는다).
토큰은 <strong>사용된 카드의 등급 합계</strong>만큼 나오고, 등급 {TOP_SECRET_THRESHOLD} 이상
코인은 <strong>사용된 카드의 등급 합계</strong>만큼 나오고, 등급 {TOP_SECRET_THRESHOLD} 이상
카드가 끼면 명분이 +1 된다.
</p>
<div className="guide-list">
+2 -2
View File
@@ -33,7 +33,7 @@
.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);
color: var(--color-coin);
}
/* 물음표 배지 — 누르면 설명이 나온다는 신호. 이게 없으면 툴팁의 존재 자체를 모른다. */
@@ -101,7 +101,7 @@
}
.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 {
font-size: 11px;
+2 -2
View File
@@ -22,7 +22,7 @@
line-height: 1.5;
}
.shop-tokens {
.shop-coins {
font-family: var(--font-typewriter);
font-size: 13px;
padding: 8px 12px;
@@ -61,7 +61,7 @@
.shop-buy-btn {
font-family: var(--font-typewriter);
font-size: 11px;
background: var(--color-token-budget);
background: var(--color-coin);
color: #0a0a0a;
border: none;
border-radius: 4px;
+6 -5
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { Coins } from 'lucide-react';
import SymbolBadge from './SymbolBadge';
import { purgePrice, REROLL_COST, canAfford } from '../engine/shop';
import './ShopScreen.css';
@@ -6,18 +7,18 @@ import './ShopScreen.css';
// 상점 (§8.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 (
<div className="shop-screen">
<div className="shop-header">
<h2>정산 — 상점</h2>
<p className="shop-hint">
족보가 안 맞는 오염 카드를 토큰으로 직접 처리할 수 있습니다 (명분·실적은 없음).
족보가 안 맞는 오염 카드를 코인으로 직접 처리할 수 있습니다 (명분·실적은 없음).
가격은 등급만큼이며 지출 가산 +{deskSurcharge}이 얹힙니다.
</p>
</div>
<div className="shop-tokens">보유 토큰 {tokens}</div>
<div className="shop-coins"><Coins size={14} className="coin-icon" /> 보유 코인 {coins}</div>
<div className="shop-grid">
{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>
<button
className="shop-buy-btn"
disabled={!canAfford(tokens, price)}
disabled={!canAfford(coins, price)}
onClick={() => onPurge(card.id)}
>
소각 · {price}
@@ -42,7 +43,7 @@ export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, on
<div className="shop-actions">
<button
className="shop-reroll-btn"
disabled={rerolled || !canAfford(tokens, REROLL_COST)}
disabled={rerolled || !canAfford(coins, REROLL_COST)}
onClick={onReroll}
>
{rerolled ? '리롤 완료' : `진열 다시 뽑기 (${REROLL_COST})`}
+23 -23
View File
@@ -1,7 +1,7 @@
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
// React 의존 없는 순수 함수 + 카탈로그.
//
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
// 2026-09-15 결정 반영: 코인 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
// 2026-09-17 반영:
// - 유형 D(이득 규약) 카탈로그 신설. 내부 명분("강제 상정")이 불리한 유형 A만 올릴 수 있어
// 실속이 없던 문제를 해결한다 — 이제 이득 규약을 직접 안건에 올릴 수 있다.
@@ -19,9 +19,9 @@ import { HAND_DEFS } from './hands.js';
// 요구치 곡선은 가안: 1라운드 25, 라운드당 +20 (§9.3).
export const requiredScore = (round) => 25 + (round - 1) * 20;
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 토큰 납입×5 + 명분 납입×20.
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
combineScore + tokensPaid * 5 + justificationsPaid * 20;
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 코인 납입×5 + 명분 납입×20.
export const settleScore = ({ combineScore, coinsPaid, justificationsPaid }) =>
combineScore + coinsPaid * 5 + justificationsPaid * 20;
/* ── §9.2 규약 카탈로그 ──
* 유형 A(족보표 변경)·C(징벌)·D(이득)까지 구현. 유형 B(금리형)는 §20.2 콘텐츠 작업 대상. */
@@ -87,22 +87,22 @@ export const PROTOCOL_TYPE_C = [
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
},
{
id: 'penalty_token_surcharge',
id: 'penalty_coin_surcharge',
name: '긴급 재정 감사 대응',
type: 'C',
effect: { kind: 'token_surcharge', amount: 2 },
flavor: '감사 대응 인력 편성 비용이 모든 토큰 지출에 얹힙니다.',
effect: { kind: 'coin_surcharge', amount: 2 },
flavor: '감사 대응 인력 편성 비용이 모든 코인 지출에 얹힙니다.',
},
];
// 유형 D — 이득 규약. §9.2 "드물게 통과"이므로 후보 등장 확률을 낮게 잡는다(D_CANDIDATE_CHANCE).
// 자산 지급형은 자산 시스템(§10, 3차)이 없어 제외했고, 토큰·족보 완화·한도 완화로 구성했다.
// 자산 지급형은 자산 시스템(§10, 3차)이 없어 제외했고, 코인·족보 완화·한도 완화로 구성했다.
export const PROTOCOL_TYPE_D = [
{
id: 'boon_budget_grant',
name: '예비비 긴급 편성',
type: 'D',
effect: { kind: 'grant_token', amount: 12 },
effect: { kind: 'grant_coin', amount: 12 },
flavor: '이월 예산 일부를 국장 재량으로 집행할 수 있도록 의결되었습니다.',
},
{
@@ -116,7 +116,7 @@ export const PROTOCOL_TYPE_D = [
id: 'boon_admin_simplify',
name: '행정 간소화 지침',
type: 'D',
effect: { kind: 'token_surcharge', amount: -2 },
effect: { kind: 'coin_surcharge', amount: -2 },
flavor: '중복 결재 단계를 축소해 집행 비용을 낮춥니다.',
},
{
@@ -375,20 +375,20 @@ export function protocolEffectDetail(protocol) {
: `결재 1건마다 4대 게이지 상승분이 ${Math.abs(e.amount).toFixed(1)}%p씩 줄어듭니다.`,
tone: e.amount > 0 ? 'bad' : 'good',
};
case 'token_surcharge':
case 'coin_surcharge':
return {
typeLabel,
summary: `토큰 지출 가산 ${e.amount > 0 ? '+' : ''}${e.amount}`,
summary: `코인 지출 가산 ${e.amount > 0 ? '+' : ''}${e.amount}`,
detail: e.amount > 0
? `상점 소각·리롤 등 모든 토큰 지출에 ${e.amount}이(가) 추가로 붙습니다.`
: `모든 토큰 지출 가산이 ${Math.abs(e.amount)} 줄어듭니다 (최소 0).`,
? `상점 소각·리롤 등 모든 코인 지출에 ${e.amount}이(가) 추가로 붙습니다.`
: `모든 코인 지출 가산이 ${Math.abs(e.amount)} 줄어듭니다 (최소 0).`,
tone: e.amount > 0 ? 'bad' : 'good',
};
case 'grant_token':
case 'grant_coin':
return {
typeLabel,
summary: `통과 즉시 토큰 +${e.amount}`,
detail: `표결 통과 시점에 토큰 ${e.amount}을(를) 한 번 지급합니다. 지속 효과는 없습니다.`,
summary: `통과 즉시 코인 +${e.amount}`,
detail: `표결 통과 시점에 코인 ${e.amount}을(를) 한 번 지급합니다. 지속 효과는 없습니다.`,
tone: 'good',
};
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);
}
/** 모든 토큰 지출에 더해지는 가산분 합계 (이득 규약이면 음수). */
export function tokenSurchargeOf(protocols) {
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'token_surcharge' ? p.effect.amount : 0), 0);
/** 모든 코인 지출에 더해지는 가산분 합계 (이득 규약이면 음수). */
export function coinSurchargeOf(protocols) {
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'coin_surcharge' ? p.effect.amount : 0), 0);
}
/** 이번 라운드 주목도 한도에 더해지는 보너스. */
@@ -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);
}
/** 통과 즉시 1회 지급되는 토큰. */
export function grantTokenOf(protocol) {
return protocol && protocol.effect.kind === 'grant_token' ? protocol.effect.amount : 0;
/** 통과 즉시 1회 지급되는 코인. */
export function grantCoinOf(protocol) {
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;
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
// round: 이 카드를 받은 라운드 번호. 한도 판정(roundAttention)이 이월 카드를 빼고 세기 위해 필요하다.
// round: 이 카드를 받은 라운드 번호(기록용 — 한도 판정에는 더 이상 쓰이지 않는다. 아래 참고).
export const makeDeskCard = (axis, grade, meta = {}, round = 1) => ({
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
axis,
@@ -53,22 +53,11 @@ export function axisAttention(deskCards, axis) {
}
/** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티
* (게이지 상승 A · 토큰 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */
* (게이지 상승 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); });
@@ -142,7 +131,14 @@ export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 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_GROWTH = 5; // 라운드당 가산분, 가안
@@ -152,15 +148,15 @@ 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, round) {
const current = roundAttention(deskCards, round);
if (current > cap) return 'bust';
if (current >= cap - 5) return 'warning';
/** 한도 대비 이번 라운드 접수량 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과).
* intake: CoreGameScreen이 들고 있는 누적 카운터 값 — 합본해도 줄지 않는다. */
export function attentionZone(intake, cap) {
if (intake > cap) return 'bust';
if (intake >= cap - 5) return 'warning';
return 'normal';
}
/* ── C. 토큰 지출 가산 (§4.2-C) — 총 주목도 기준 ── */
/* ── C. 코인 지출 가산 (§4.2-C) — 총 주목도 기준 ── */
const SURCHARGE_TIERS = [
{ max: 20, surcharge: 0 },
@@ -169,10 +165,10 @@ const SURCHARGE_TIERS = [
{ max: Infinity, surcharge: 4 },
];
/** 총 주목도 기준 모든 토큰 지출에 붙는 가산액.
* extraFlat: 활성 규약이 얹는 가산분(council.js의 tokenSurchargeOf) — 이득 규약이면 음수라
/** 총 주목도 기준 모든 코인 지출에 붙는 가산액.
* extraFlat: 활성 규약이 얹는 가산분(council.js의 coinSurchargeOf) — 이득 규약이면 음수라
* 0 밑으로 내려갈 수 있으므로, 지출이 되레 할인되지 않도록 0에서 끊는다. */
export function tokenSurcharge(deskCards, extraFlat = 0) {
export function coinSurcharge(deskCards, extraFlat = 0) {
const total = totalAttention(deskCards);
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat);
+5 -5
View File
@@ -8,7 +8,7 @@
// 이제는 선택 집합 안에서 족보를 이루는 카드를 찾아낸다. 그래서 5장을 골라도 페어만
// 성립하면 페어로 상신할 수 있고, 그 경우 족보에 실제로 쓰인 2장만 소멸한다
// (나머지는 §4.1 이월 원칙에 따라 책상에 남는다).
// - 토큰은 1종이고 산출량은 등급 합계다 (§8.2). 축은 명분 종류만 결정한다 (§7.2).
// - 코인은 1종이고 산출량은 등급 합계다 (§8.2). 축은 명분 종류만 결정한다 (§7.2).
// - 명분 +1 보너스 기준이 "등급 4" → "등급 8 이상"으로 바뀌었다 (§7.1).
import { justificationOf, TOP_SECRET_THRESHOLD } from './symbols.js';
@@ -81,7 +81,7 @@ function findRun(cards, length = 5) {
/**
* 선택 집합에서 지정 족보를 이루는 카드들을 찾는다. 성립하지 않으면 null.
* 여러 조합이 가능하면 등급 합이 큰 쪽을 고른다 — 토큰 산출이 등급 합계이므로 그게 이득이다.
* 여러 조합이 가능하면 등급 합이 큰 쪽을 고른다 — 코인 산출이 등급 합계이므로 그게 이득이다.
*/
export function findHandCards(selection, patternId) {
const byGrade = groupByGrade(selection);
@@ -170,14 +170,14 @@ export function resolveCombine(selection, patternId) {
const hasTopSecret = used.some((c) => c.grade >= TOP_SECRET_THRESHOLD);
const justificationAmount = def.baseJustification + (hasTopSecret ? 1 : 0);
// §8.2: 토큰은 1종, 산출량은 합본에 사용된 카드의 기밀 등급 합계.
const tokens = sumGrades(used);
// §8.2: 코인은 1종, 산출량은 합본에 사용된 카드의 기밀 등급 합계.
const coins = sumGrades(used);
return {
pattern: def,
usedCards: used,
justification: { type: justificationType, amount: justificationAmount },
tokens,
coins,
performanceScore: def.score,
removeIds: used.map((c) => c.id),
};
+4 -4
View File
@@ -2,10 +2,10 @@
// 자산 구매·소모품·전례 매입은 각각 §10·§12·§11(3차 범위)이 있어야 성립하는데 아직 없다.
// 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구,
// 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다.
// 진열 규칙(무작위 4~6개, 1회 리롤 가능·토큰 소모)만 그대로 §8.3을 따른다.
// 진열 규칙(무작위 4~6개, 1회 리롤 가능·코인 소모)만 그대로 §8.3을 따른다.
//
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2, §19). 가격은 단일 숫자다.
// 등급 1~10 확장에 맞춰 처리 비용도 등급에 비례한다 — 합본하면 등급 합계만큼 토큰을 벌지만
// 2026-09-15 결정 반영: 코인 1종 통합(§8.2, §19). 가격은 단일 숫자다.
// 등급 1~10 확장에 맞춰 처리 비용도 등급에 비례한다 — 합본하면 등급 합계만큼 코인을 벌지만
// 직접 처리하면 그만큼 잃으므로, 탈출구를 쓰는 대가가 등급에 정직하게 비례한다.
export const SHOP_DISPLAY_MIN = 4;
@@ -29,4 +29,4 @@ export function rollShopDisplay(deskCards) {
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 확장, 점(●) 표기 폐기.
// 카드 중앙에 큰 숫자, 축 인장은 모서리에 작게 — 트럼프 랭크+슈트 레이아웃 (§3.2).
// 토큰은 1종으로 통합됐으므로(§8.2, §19) 축은 더 이상 토큰 종류를 결정하지 않는다.
// 축이 결정하는 것은 "명분 종류"(질)뿐이고, 토큰 양(양)은 등급 합계가 결정한다 (§7.2).
// 코인은 1종으로 통합됐으므로(§8.2, §19) 축은 더 이상 코인 종류를 결정하지 않는다.
// 축이 결정하는 것은 "명분 종류"(질)뿐이고, 코인 양(양)은 등급 합계가 결정한다 (§7.2).
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-personnel: #E53935;
--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-swipe-left: #D32F2F;
@@ -42,8 +46,19 @@ body {
}
#root {
width: 100vw;
width: 100%;
/* 모바일 필수: 100vh는 "주소창이 숨은 상태" 기준 높이라, 주소창이 보이는 동안 화면 아래
60~100px이 잘린다. body가 overflow:hidden이어서 스크롤로 닿을 수도 없다 — 화면 맨 아래에
있는 "정산하기" 버튼이 가려지는 실제 문제가 된다. dvh(동적 뷰포트 높이)는 주소창 노출
상태를 반영한다. vh는 dvh 미지원 구형 브라우저용 폴백으로 남긴다. */
height: 100vh;
height: 100dvh;
display: flex;
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 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/
export default defineConfig({
// 상대 경로로 빌드한다. NAS Web Station이 문서 루트 하위 폴더(/ProjectSS/ 등)로 서빙하므로
// 기본값('/')이면 asset을 도메인 루트에서 찾아 404 → 빈 화면이 된다. './'로 두면 어느
// 서브패스에 떨어뜨려도 그대로 동작해서, 폴더를 옮길 때 재빌드할 필요가 없다.
base: './',
plugins: [react()],
define: {
__BUILD_INFO__: JSON.stringify(buildInfo),
},
})