라운드 접수량 버그 수정 + 토큰→코인 용어 통일 + 외부 배포(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:
co-authored by
Claude Sonnet 5
parent
6398599199
commit
20207ff23c
+7
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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})`}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ── 합본 판정에 규약 효과를 얹는다 ── */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user