2차 구현 완료 (§17: 상점·규약·실적 감사·명분) - 플레이는 해봤지만 피드백은 아직 정리 안함
라운드 구조(결재 8건 → 상점 → 의회 → 다음 라운드)를 얹고, 신규 파일들로 구현했습니다. Playwright로 라운드 전체를 실제로 돌려 확인 완료(콘솔 에러 0건).
만든 것
엔진 (core/engine/)
council.js — §7 명분 산출/개입, §9.2 규약(유형A 5종 카탈로그 + 유형C 징벌 2종), §9.3 실적 요구치 공식
shop.js — §8.3 상점 진열/가격/리롤
UI
ShopScreen — 오염 카드 직접 소각(토큰), 진열 4~6개 무작위, 리롤 1회
CouncilScreen — 실적 감사(토큰·명분 납입으로 요구치 충족) + 규약 후보 3개 + 명분 개입(절차/여론/위기/내부/혼합)
DeskBar/CombinePanel — 징벌 규약의 게이지·토큰 가산 반영, 활성 규약이 막은 족보 표시
라운드 루프: CoreGameScreen이 결재 8건마다 자동으로 상점→의회로 전환하고, 의회 결의 시 실적 판정→징벌 부과/해제→규약 표결→다음 라운드로 넘어갑니다. 4대 게이지 100% 도달 시 게임오버 화면도 추가했습니다(엔딩 분기는 4차라 텍스트만).
검증 (Playwright)
8회 스와이프 → 상점 화면 전환 → 의회 화면(요구실적 25, 후보 3개) → 결의 확정 → 2라운드 진입, 상단에 ⚖ 통과된 규약명 표시 → 합본 패널 열면 해당 규약이 칩으로 뜨고 filterHandsByProtocols가 막는 족보를 실제로 걸러냄. 스크린샷 다수 확인, 콘솔 에러 0건. 상점 아이템 버튼 텍스트가 화면 밖으로 살짝 잘리는 버그도 발견해서 즉시 수정했습니다.
의도적으로 축소한 부분 (문서에 근거 있음)
상점은 "오염 카드 직접 처리" 한 품목뿐 — 자산 구매(§10)·소모품(§12)·전례 매입(§11)은 전부 3차 항목이라 카탈로그 자체가 없습니다. §17 로드맵 순서를 지키기 위한 의도적 축소입니다.
규약 표결은 가중 무작위 — 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6)은 4차 범위. 지금은 후보 3개 중 하나를 무작위로 "통과"시킵니다.
혼합 명분("표 뒤집기")은 문자 그대로 구현할 표가 없어서, "후보 하나를 직접 통과 지정"으로 대체했습니다 — 최종 효과(원하는 규약 통과)는 같지만 4차에서 실제 위원회가 생기면 반드시 재설계해야 할 자리표시자로 코드에 표시해 뒀습니다.
§7.3 이념색 페널티(파벌 우호도 하락)는 스킵 — 8대 상임이사회 우호도 추적 자체가 4차 항목이라 지금은 걸 게 없습니다.
라운드당 결재 수 8건, 상점/징벌 가격 수치는 전부 가안 — 플레이해보고 조정 대상입니다.
This commit is contained in:
@@ -103,6 +103,26 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.combine-protocols {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-protocol-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 215, 0, 0.1);
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
border: 1px solid rgba(255, 215, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-blocked {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
.combine-match {
|
.combine-match {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import SymbolBadge from './SymbolBadge';
|
import SymbolBadge from './SymbolBadge';
|
||||||
import { detectHands, resolveCombine, HAND_DEFS } from '../engine/hands';
|
import { detectHands, resolveCombine, HAND_DEFS } from '../engine/hands';
|
||||||
|
import { filterHandsByProtocols } from '../engine/council';
|
||||||
import './CombinePanel.css';
|
import './CombinePanel.css';
|
||||||
|
|
||||||
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
|
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
|
||||||
// 여러 족보가 동시 성립하면 최고 족보를 기본값으로 보여주되, 하위 족보도 선택할 수 있게 한다 (§5.3) —
|
// 여러 족보가 동시 성립하면 최고 족보를 기본값으로 보여주되, 하위 족보도 선택할 수 있게 한다 (§5.3) —
|
||||||
// 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다.
|
// 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다.
|
||||||
export default function CombinePanel({ deskCards, onConfirm, onClose }) {
|
// activeProtocols: 현재 라운드에 적용 중인 §9.2 유형A 규약 — 특정 족보를 무효화하거나
|
||||||
|
// 최소 등급을 강제한다.
|
||||||
|
export default function CombinePanel({ deskCards, onConfirm, onClose, activeProtocols = [] }) {
|
||||||
const [selectedIds, setSelectedIds] = useState([]);
|
const [selectedIds, setSelectedIds] = useState([]);
|
||||||
|
|
||||||
const selection = useMemo(
|
const selection = useMemo(
|
||||||
() => deskCards.filter((c) => selectedIds.includes(c.id)),
|
() => deskCards.filter((c) => selectedIds.includes(c.id)),
|
||||||
[deskCards, selectedIds]
|
[deskCards, selectedIds]
|
||||||
);
|
);
|
||||||
const matches = useMemo(() => detectHands(selection), [selection]);
|
const rawMatches = useMemo(() => detectHands(selection), [selection]);
|
||||||
|
const matches = useMemo(
|
||||||
|
() => filterHandsByProtocols(rawMatches, selection, activeProtocols),
|
||||||
|
[rawMatches, selection, activeProtocols]
|
||||||
|
);
|
||||||
|
const blockedByProtocol = rawMatches.length > 0 && matches.length === 0;
|
||||||
|
|
||||||
const toggle = (id) => {
|
const toggle = (id) => {
|
||||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
@@ -34,6 +42,14 @@ export default function CombinePanel({ deskCards, onConfirm, onClose }) {
|
|||||||
|
|
||||||
<p className="combine-hint">책상에서 카드를 선택하면 성립하는 족보를 판정합니다.</p>
|
<p className="combine-hint">책상에서 카드를 선택하면 성립하는 족보를 판정합니다.</p>
|
||||||
|
|
||||||
|
{activeProtocols.length > 0 && (
|
||||||
|
<div className="combine-protocols">
|
||||||
|
{activeProtocols.map((p) => (
|
||||||
|
<span key={p.id} className="combine-protocol-chip">⚖ {p.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="combine-desk-grid">
|
<div className="combine-desk-grid">
|
||||||
{deskCards.length === 0 && <p className="combine-empty">책상이 비어 있습니다.</p>}
|
{deskCards.length === 0 && <p className="combine-empty">책상이 비어 있습니다.</p>}
|
||||||
{deskCards.map((c) => (
|
{deskCards.map((c) => (
|
||||||
@@ -49,9 +65,12 @@ export default function CombinePanel({ deskCards, onConfirm, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="combine-results">
|
<div className="combine-results">
|
||||||
{selection.length > 0 && matches.length === 0 && (
|
{selection.length > 0 && matches.length === 0 && !blockedByProtocol && (
|
||||||
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
|
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
|
{blockedByProtocol && (
|
||||||
|
<p className="combine-blocked">규약 때문에 이 선택은 합본할 수 없습니다.</p>
|
||||||
|
)}
|
||||||
{matches.map((patternId) => {
|
{matches.map((patternId) => {
|
||||||
const def = HAND_DEFS[patternId];
|
const def = HAND_DEFS[patternId];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -40,3 +40,29 @@
|
|||||||
.core-log-line {
|
.core-log-line {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.core-status-protocol {
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-gameover {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-gameover h1 {
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-gameover-note {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ import DeskBar from './DeskBar';
|
|||||||
import CoreSwipeCard from './CoreSwipeCard';
|
import CoreSwipeCard from './CoreSwipeCard';
|
||||||
import CombinePanel from './CombinePanel';
|
import CombinePanel from './CombinePanel';
|
||||||
import HandGuidePopup from './HandGuidePopup';
|
import HandGuidePopup from './HandGuidePopup';
|
||||||
|
import ShopScreen from './ShopScreen';
|
||||||
|
import CouncilScreen from './CouncilScreen';
|
||||||
import { pickPollutionEntry } from '../engine/pollutionPool';
|
import { pickPollutionEntry } from '../engine/pollutionPool';
|
||||||
import { makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk } from '../engine/desk';
|
import { makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge } from '../engine/desk';
|
||||||
|
import { rollShopDisplay, purgePrice, payTokens, REROLL_COST } from '../engine/shop';
|
||||||
|
import {
|
||||||
|
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
|
||||||
|
applyInterventions, penaltyGaugeSurcharge, penaltyTokenSurcharge,
|
||||||
|
} from '../engine/council';
|
||||||
import './CoreGameScreen.css';
|
import './CoreGameScreen.css';
|
||||||
|
|
||||||
// 코어 루프 그레이박스 (§2·§17 1차) — 기호 체계 + 책상 적재/누진 페널티(A·B) + 표준 족보 판정/합본 +
|
// 코어 루프 그레이박스 — §17 1차(기호·책상·족보·토큰)에 이어 2차(상점·규약·실적감사·명분)까지.
|
||||||
// 합본 기반 토큰 산출까지만 구현한다. 상점·규약·의회·자산·전례·엔딩은 2~4차 범위라 아직 없다.
|
// 라운드 구조: 결재 N건 → 정산(상점) → 의회(실적감사+규약) → 다음 라운드 (§2).
|
||||||
// 기존 GameScreen.jsx/engine/pollution.js는 참조용으로만 남기고 건드리지 않는다 — 코어시스템
|
// 자산·전례·소모품(§10~12, 3차)과 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6, 4차)은
|
||||||
// 기획서 자체가 "기존 코드는 참조용, 신규 개발" 원칙을 명시하고 있다.
|
// 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 가중 무작위로 흉내만 낸다.
|
||||||
|
// 기존 GameScreen.jsx/engine/pollution.js는 참조용으로만 남기고 건드리지 않는다.
|
||||||
|
|
||||||
|
const APPROVALS_PER_ROUND = 8;
|
||||||
|
|
||||||
const buildCard = (row) => ({
|
const buildCard = (row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -37,6 +47,8 @@ const buildCard = (row) => ({
|
|||||||
|
|
||||||
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
|
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
|
||||||
const INITIAL_TOKENS = { budget: 0, personnel: 0, info: 0 };
|
const INITIAL_TOKENS = { budget: 0, personnel: 0, info: 0 };
|
||||||
|
let justificationSeq = 0;
|
||||||
|
const makeJustificationId = () => `just-${Date.now().toString(36)}-${(justificationSeq++).toString(36)}`;
|
||||||
|
|
||||||
export default function CoreGameScreen() {
|
export default function CoreGameScreen() {
|
||||||
const deck = useMemo(() => {
|
const deck = useMemo(() => {
|
||||||
@@ -54,20 +66,44 @@ export default function CoreGameScreen() {
|
|||||||
const [showGuide, setShowGuide] = useState(false);
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
const [log, setLog] = useState([]);
|
const [log, setLog] = useState([]);
|
||||||
|
|
||||||
|
// 라운드 구조 (§2)
|
||||||
|
const [phase, setPhase] = useState('approval'); // 'approval' | 'shop' | 'council'
|
||||||
|
const [round, setRound] = useState(1);
|
||||||
|
const [approvalsThisRound, setApprovalsThisRound] = useState(0);
|
||||||
|
const [roundScore, setRoundScore] = useState(0);
|
||||||
|
const [penalties, setPenalties] = useState([]); // §9.2 유형C, 영구 누적
|
||||||
|
const [activeProtocol, setActiveProtocol] = useState(null); // §9.2 유형A, 이번 라운드 1개
|
||||||
|
const [shopDisplay, setShopDisplay] = useState([]);
|
||||||
|
const [shopRerolled, setShopRerolled] = useState(false);
|
||||||
|
const [councilCandidates, setCouncilCandidates] = useState([]);
|
||||||
|
const [gameOver, setGameOver] = useState(null);
|
||||||
|
|
||||||
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
|
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
|
||||||
|
|
||||||
|
const activeProtocols = activeProtocol ? [activeProtocol] : [];
|
||||||
|
const gaugeExtraFlat = penaltyGaugeSurcharge(penalties);
|
||||||
|
const tokenExtraFlat = penaltyTokenSurcharge(penalties);
|
||||||
|
|
||||||
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
|
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
|
||||||
|
|
||||||
|
const checkGameOver = (nextGauges) => {
|
||||||
|
const hit = Object.entries(nextGauges).find(([, v]) => v >= 100);
|
||||||
|
if (hit) {
|
||||||
|
const [gaugeKey] = hit;
|
||||||
|
const label = { entropy: '엔트로피 100% — 세계 멸망', resistance: '조직 저항 100% — 사임', panic: '대중 공황 100% — 문명 붕괴', risk: '위험 수용 계수 100%' }[gaugeKey];
|
||||||
|
setGameOver(label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSwipe = (direction) => {
|
const handleSwipe = (direction) => {
|
||||||
if (!currentCard) return;
|
if (!currentCard || phase !== 'approval') return;
|
||||||
const side = direction === 'right' ? currentCard.right : currentCard.left;
|
const side = direction === 'right' ? currentCard.right : currentCard.left;
|
||||||
|
|
||||||
// A. 게이지 상시 상승 — 이번 결재 시점 책상 상태 기준으로 축별 누진율만큼 올린다 (§4.2-A).
|
const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat);
|
||||||
setGauges((prev) => applyPassiveGaugeRise(prev, deskCards));
|
setGauges(nextGauges);
|
||||||
|
|
||||||
// 선택한 쪽에 축이 지정돼 있으면 오염 카드 1장을 책상에 적재한다.
|
|
||||||
// pollution_pool.csv에서 실제 이름·사연을 붙여서, "이 선택이 어떤 서류를 남겼는지"가
|
|
||||||
// 축 라벨이 아니라 구체적인 사건으로 보이게 한다 (플레이테스트 피드백).
|
|
||||||
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);
|
||||||
@@ -79,6 +115,19 @@ export default function CoreGameScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCardIndex((i) => (i + 1) % deck.length);
|
setCardIndex((i) => (i + 1) % deck.length);
|
||||||
|
|
||||||
|
if (checkGameOver(nextGauges)) return;
|
||||||
|
|
||||||
|
const nextApprovals = approvalsThisRound + 1;
|
||||||
|
if (nextApprovals >= APPROVALS_PER_ROUND) {
|
||||||
|
setApprovalsThisRound(0);
|
||||||
|
setShopDisplay(rollShopDisplay(deskCards));
|
||||||
|
setShopRerolled(false);
|
||||||
|
setPhase('shop');
|
||||||
|
pushLog(`제${round}라운드 결재 마감 — 정산 단계로 이동`);
|
||||||
|
} else {
|
||||||
|
setApprovalsThisRound(nextApprovals);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCombineConfirm = (result) => {
|
const handleCombineConfirm = (result) => {
|
||||||
@@ -88,12 +137,95 @@ export default function CoreGameScreen() {
|
|||||||
Object.entries(result.tokens).forEach(([k, v]) => { next[k] = (next[k] || 0) + v; });
|
Object.entries(result.tokens).forEach(([k, v]) => { next[k] = (next[k] || 0) + v; });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setJustifications((prev) => [...prev, result.justification]);
|
setJustifications((prev) => {
|
||||||
|
const next = [...prev, { id: makeJustificationId(), ...result.justification }];
|
||||||
|
return next.length > 5 ? next.slice(next.length - 5) : next; // §7.1 최대 5개 보관
|
||||||
|
});
|
||||||
setScore((prev) => prev + result.performanceScore);
|
setScore((prev) => prev + result.performanceScore);
|
||||||
|
setRoundScore((prev) => prev + result.performanceScore);
|
||||||
pushLog(`합본 성립: 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 명분(${result.justification.type}) +${result.justification.amount}`);
|
pushLog(`합본 성립: 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 명분(${result.justification.type}) +${result.justification.amount}`);
|
||||||
setShowCombine(false);
|
setShowCombine(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── 상점 (§8.3) ── */
|
||||||
|
const handleShopPurge = (cardId) => {
|
||||||
|
const card = deskCards.find((c) => c.id === cardId);
|
||||||
|
if (!card) return;
|
||||||
|
const price = purgePrice(card, tokenSurcharge(deskCards, tokenExtraFlat));
|
||||||
|
setTokens((prev) => payTokens(prev, price));
|
||||||
|
setDeskCards((prev) => removeFromDesk(prev, [cardId]));
|
||||||
|
setShopDisplay((prev) => prev.filter((c) => c.id !== cardId));
|
||||||
|
pushLog(`상점: 『${card.name || '오염 카드'}』 직접 소각 (예산 -${price.budget})`);
|
||||||
|
};
|
||||||
|
const handleShopReroll = () => {
|
||||||
|
setTokens((prev) => payTokens(prev, REROLL_COST));
|
||||||
|
setShopDisplay(rollShopDisplay(deskCards));
|
||||||
|
setShopRerolled(true);
|
||||||
|
};
|
||||||
|
const handleShopContinue = () => {
|
||||||
|
setCouncilCandidates(rollProtocolCandidates());
|
||||||
|
setPhase('council');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 의회 (§9) ── */
|
||||||
|
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
|
||||||
|
const { candidates: remainingCandidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear } =
|
||||||
|
applyInterventions(councilCandidates, interventions);
|
||||||
|
|
||||||
|
const finalRequired = Math.round(requiredScore(round) * requiredScoreMultiplier);
|
||||||
|
const tokensPaidCount = Object.values(tokensPaid).reduce((a, b) => a + b, 0);
|
||||||
|
const finalScore = settleScore({ combineScore: roundScore, tokensPaid: tokensPaidCount, justificationsPaid: justificationsPaidCount });
|
||||||
|
const passed = finalScore >= finalRequired;
|
||||||
|
|
||||||
|
setTokens((prev) => payTokens(prev, tokensPaid));
|
||||||
|
setJustifications([]); // §7.1: 다음 의회까지만 유효, 이후 소멸 — 쓰지 않은 것도 여기서 사라진다
|
||||||
|
|
||||||
|
setPenalties((prev) => {
|
||||||
|
let next = prev;
|
||||||
|
if (penaltyIdToClear) next = next.filter((p) => p.id !== penaltyIdToClear);
|
||||||
|
if (!passed) {
|
||||||
|
const penalty = rollPenalty();
|
||||||
|
next = [...next, { ...penalty, id: `${penalty.id}-${Date.now().toString(36)}` }];
|
||||||
|
pushLog(`실적 감사 미달 (${finalScore}/${finalRequired}) — 징벌 규약 『${penalty.name}』 부과`);
|
||||||
|
} else {
|
||||||
|
pushLog(`실적 감사 통과 (${finalScore}/${finalRequired})`);
|
||||||
|
if (finalScore >= finalRequired * 1.5 && next.length > 0) {
|
||||||
|
pushLog(`실적 초과 달성 — 징벌 규약 『${next[0].name}』 조기 해제`);
|
||||||
|
next = next.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deferVote) {
|
||||||
|
pushLog('절차 명분으로 이번 라운드 규약 결정이 이월되었습니다.');
|
||||||
|
} else {
|
||||||
|
const winner = forcedWinnerId
|
||||||
|
? remainingCandidates.find((c) => c.id === forcedWinnerId) || null
|
||||||
|
: resolveProtocolVote(remainingCandidates);
|
||||||
|
setActiveProtocol(winner);
|
||||||
|
if (winner) pushLog(`규약 통과: 『${winner.name}』 — 다음 라운드 적용`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRound((r) => r + 1);
|
||||||
|
setRoundScore(0);
|
||||||
|
setPhase('approval');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gameOver) {
|
||||||
|
return (
|
||||||
|
<div className="core-game-screen">
|
||||||
|
<div className="core-gameover">
|
||||||
|
<h1>결재 불가</h1>
|
||||||
|
<p>{gameOver}</p>
|
||||||
|
<p className="core-gameover-note">
|
||||||
|
엔딩 분기(§15)는 4차 범위라 아직 없다 — 여기서 그레이박스 루프가 멈춘다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentCard) {
|
if (!currentCard) {
|
||||||
return <div className="core-loading">카드를 불러오는 중...</div>;
|
return <div className="core-loading">카드를 불러오는 중...</div>;
|
||||||
}
|
}
|
||||||
@@ -105,15 +237,43 @@ export default function CoreGameScreen() {
|
|||||||
gauges={gauges}
|
gauges={gauges}
|
||||||
onOpenCombine={() => setShowCombine(true)}
|
onOpenCombine={() => setShowCombine(true)}
|
||||||
onOpenGuide={() => setShowGuide(true)}
|
onOpenGuide={() => setShowGuide(true)}
|
||||||
|
gaugeExtraFlat={gaugeExtraFlat}
|
||||||
|
tokenExtraFlat={tokenExtraFlat}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="core-status-row">
|
<div className="core-status-row">
|
||||||
<span>실적 {score}</span>
|
<span>{round}R · 결재 {approvalsThisRound}/{APPROVALS_PER_ROUND}</span>
|
||||||
|
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
||||||
<span>예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}</span>
|
<span>예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}</span>
|
||||||
<span>명분 {justifications.length}개 보유</span>
|
<span>명분 {justifications.length}개 보유</span>
|
||||||
|
{activeProtocol && <span className="core-status-protocol">⚖ {activeProtocol.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CoreSwipeCard card={currentCard} onSwipe={handleSwipe} />
|
{phase === 'approval' && <CoreSwipeCard card={currentCard} onSwipe={handleSwipe} />}
|
||||||
|
|
||||||
|
{phase === 'shop' && (
|
||||||
|
<ShopScreen
|
||||||
|
display={shopDisplay}
|
||||||
|
tokens={tokens}
|
||||||
|
deskSurcharge={tokenSurcharge(deskCards, tokenExtraFlat)}
|
||||||
|
onPurge={handleShopPurge}
|
||||||
|
onReroll={handleShopReroll}
|
||||||
|
rerolled={shopRerolled}
|
||||||
|
onContinue={handleShopContinue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'council' && (
|
||||||
|
<CouncilScreen
|
||||||
|
round={round}
|
||||||
|
combineScore={roundScore}
|
||||||
|
tokens={tokens}
|
||||||
|
justifications={justifications}
|
||||||
|
penalties={penalties}
|
||||||
|
candidates={councilCandidates}
|
||||||
|
onResolve={handleCouncilResolve}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="core-log">
|
<div className="core-log">
|
||||||
{log.map((line, i) => <div key={i} className="core-log-line">{line}</div>)}
|
{log.map((line, i) => <div key={i} className="core-log-line">{line}</div>)}
|
||||||
@@ -124,6 +284,7 @@ export default function CoreGameScreen() {
|
|||||||
deskCards={deskCards}
|
deskCards={deskCards}
|
||||||
onConfirm={handleCombineConfirm}
|
onConfirm={handleCombineConfirm}
|
||||||
onClose={() => setShowCombine(false)}
|
onClose={() => setShowCombine(false)}
|
||||||
|
activeProtocols={activeProtocols}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
.council-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-screen h2 {
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-screen h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-audit {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-audit-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-met { color: var(--color-token-budget); }
|
||||||
|
.audit-short { color: var(--color-risk-critical); }
|
||||||
|
|
||||||
|
.council-audit-breakdown {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 6px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-token-pay {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-token-row button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-token-row button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-token-committed {
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-token-budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalties {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(255, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.25);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-chip.targetable {
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 1px dashed var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justifications {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-badge {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justification-item button {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 11px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justification-item button:hover {
|
||||||
|
border-color: var(--color-token-budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-pending-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.targetable {
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 1px dashed var(--color-token-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.removed {
|
||||||
|
opacity: 0.4;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.forced {
|
||||||
|
border-color: var(--color-token-budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-flavor {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-deferred-note {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-confirm-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
background: var(--color-token-info);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS } from '../engine/council';
|
||||||
|
import './CouncilScreen.css';
|
||||||
|
|
||||||
|
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
|
||||||
|
// 상정되지 않는다. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은 4차 범위라 없다 — 후보 1개는
|
||||||
|
// 가중 무작위로 뽑힌 "통과"를 그대로 쓴다. 플레이어는 표결에 직접 개입 못 하고 명분으로
|
||||||
|
// 안건 자체를 조작할 뿐이라는 원칙(§9.2)만 지켰다.
|
||||||
|
export default function CouncilScreen({
|
||||||
|
round, combineScore, tokens, justifications, penalties, candidates, onResolve,
|
||||||
|
}) {
|
||||||
|
const required = requiredScore(round);
|
||||||
|
|
||||||
|
const [tokenPayments, setTokenPayments] = useState({ budget: 0, personnel: 0, info: 0 });
|
||||||
|
const [usedIds, setUsedIds] = useState([]);
|
||||||
|
const [scorePaidIds, setScorePaidIds] = useState([]);
|
||||||
|
const [interventions, setInterventions] = useState([]);
|
||||||
|
const [pending, setPending] = useState(null); // { type, justificationId }
|
||||||
|
|
||||||
|
const tokensPaidCount = Object.values(tokenPayments).reduce((a, b) => a + b, 0);
|
||||||
|
const currentTotal = settleScore({ combineScore, tokensPaid: tokensPaidCount, justificationsPaid: scorePaidIds.length });
|
||||||
|
const met = currentTotal >= required;
|
||||||
|
|
||||||
|
const payToken = (type) => {
|
||||||
|
if (tokenPayments[type] >= (tokens[type] || 0)) return;
|
||||||
|
setTokenPayments((p) => ({ ...p, [type]: p[type] + 1 }));
|
||||||
|
};
|
||||||
|
const unpayToken = (type) => {
|
||||||
|
if (tokenPayments[type] <= 0) return;
|
||||||
|
setTokenPayments((p) => ({ ...p, [type]: p[type] - 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const payJustificationForScore = (j) => {
|
||||||
|
setUsedIds((prev) => [...prev, j.id]);
|
||||||
|
setScorePaidIds((prev) => [...prev, j.id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIntervention = (type, j) => {
|
||||||
|
const spec = JUSTIFICATION_INTERVENTIONS[type];
|
||||||
|
if (!spec.needsTarget) {
|
||||||
|
setUsedIds((prev) => [...prev, j.id]);
|
||||||
|
setInterventions((prev) => [...prev, { type, justificationId: j.id }]);
|
||||||
|
} else {
|
||||||
|
setPending({ type, justificationId: j.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeTarget = (targetId, targetKind) => {
|
||||||
|
if (!pending) return;
|
||||||
|
setUsedIds((prev) => [...prev, pending.justificationId]);
|
||||||
|
setInterventions((prev) => [...prev, {
|
||||||
|
type: pending.type,
|
||||||
|
justificationId: pending.justificationId,
|
||||||
|
...(targetKind === 'candidate' ? { targetCandidateId: targetId } : { targetPenaltyId: targetId }),
|
||||||
|
}]);
|
||||||
|
setPending(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableJustifications = justifications.filter((j) => !usedIds.includes(j.id));
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
onResolve({ tokensPaid: tokenPayments, justificationsPaidCount: scorePaidIds.length, interventions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="council-screen">
|
||||||
|
<h2>제{round}차 정례회의</h2>
|
||||||
|
|
||||||
|
<section className="council-audit">
|
||||||
|
<div className="council-audit-row">
|
||||||
|
<span>요구 실적 {required}</span>
|
||||||
|
<span className={met ? 'audit-met' : 'audit-short'}>
|
||||||
|
현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="council-audit-breakdown">
|
||||||
|
족보 {combineScore} + 토큰 납입 {tokensPaidCount}×5 + 명분 납입 {scorePaidIds.length}×20
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="council-token-pay">
|
||||||
|
{['budget', 'personnel', 'info'].map((t) => (
|
||||||
|
<div key={t} className="council-token-row">
|
||||||
|
<span>{{ budget: '예산', personnel: '인력', info: '정보' }[t]} {tokens[t] - tokenPayments[t]}</span>
|
||||||
|
<button onClick={() => unpayToken(t)} disabled={tokenPayments[t] === 0}>-</button>
|
||||||
|
<span className="council-token-committed">{tokenPayments[t]}</span>
|
||||||
|
<button onClick={() => payToken(t)} disabled={tokenPayments[t] >= tokens[t]}>+</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{penalties.length > 0 && (
|
||||||
|
<section className="council-penalties">
|
||||||
|
<h3>누적 징벌 규약</h3>
|
||||||
|
{penalties.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={`council-penalty-chip ${pending?.type === '위기' ? 'targetable' : ''}`}
|
||||||
|
onClick={() => pending?.type === '위기' && completeTarget(p.id, 'penalty')}
|
||||||
|
>
|
||||||
|
⚠ {p.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="council-justifications">
|
||||||
|
<h3>보유 명분 {availableJustifications.length}개</h3>
|
||||||
|
{availableJustifications.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
|
||||||
|
{availableJustifications.map((j) => (
|
||||||
|
<div key={j.id} className="council-justification-item">
|
||||||
|
<span className="justification-badge">{j.type} +{j.amount}</span>
|
||||||
|
<button onClick={() => payJustificationForScore(j)}>실적 납입 (+20)</button>
|
||||||
|
{Object.entries(JUSTIFICATION_INTERVENTIONS)
|
||||||
|
.filter(([type]) => type === j.type)
|
||||||
|
.map(([type, spec]) => (
|
||||||
|
<button key={type} onClick={() => startIntervention(type, j)}>{spec.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pending && (
|
||||||
|
<p className="council-pending-hint">
|
||||||
|
"{pending.type}" 개입 대상을 아래 규약 후보{pending.type === '위기' ? '(또는 위 징벌)' : ''}에서 클릭하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="council-candidates">
|
||||||
|
<h3>규약 후보</h3>
|
||||||
|
{candidates.map((c) => {
|
||||||
|
const removed = interventions.some((iv) => iv.type === '여론' && iv.targetCandidateId === c.id);
|
||||||
|
const forced = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`council-candidate ${removed ? 'removed' : ''} ${forced ? 'forced' : ''} ${pending?.type === '여론' || pending?.type === '혼합' ? 'targetable' : ''}`}
|
||||||
|
onClick={() => (pending?.type === '여론' || pending?.type === '혼합') && completeTarget(c.id, 'candidate')}
|
||||||
|
>
|
||||||
|
<div className="candidate-name">{c.name}{removed && ' (탈락됨)'}{forced && ' (강제 통과 지정)'}</div>
|
||||||
|
<div className="candidate-flavor">{c.flavor}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{interventions.some((iv) => iv.type === '절차') && (
|
||||||
|
<p className="council-deferred-note">절차 명분으로 이번 라운드 규약 결정이 보류됩니다.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button className="council-confirm-btn" onClick={confirm}>의회 결의 확정</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,12 +8,12 @@ import './DeskBar.css';
|
|||||||
// 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다."
|
// 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다."
|
||||||
// 플레이테스트 피드백 반영: 장수만으로는 부족해서 축×등급 분포와, 그게 어느 게이지를 얼마나
|
// 플레이테스트 피드백 반영: 장수만으로는 부족해서 축×등급 분포와, 그게 어느 게이지를 얼마나
|
||||||
// 올리고 있는지(▲+X%/결재)까지 항상 보이게 했다. 개별 카드 이름은 축을 클릭하면 팝업으로.
|
// 올리고 있는지(▲+X%/결재)까지 항상 보이게 했다. 개별 카드 이름은 축을 클릭하면 팝업으로.
|
||||||
export default function DeskBar({ deskCards, gauges, onOpenCombine, onOpenGuide }) {
|
export default function DeskBar({ deskCards, gauges, onOpenCombine, onOpenGuide, gaugeExtraFlat = 0, tokenExtraFlat = 0 }) {
|
||||||
const [detailAxis, setDetailAxis] = useState(null);
|
const [detailAxis, setDetailAxis] = useState(null);
|
||||||
const breakdown = gradeBreakdownByAxis(deskCards);
|
const breakdown = gradeBreakdownByAxis(deskCards);
|
||||||
const rates = gaugeRiseRateByAxis(deskCards);
|
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
|
||||||
const total = totalCount(deskCards);
|
const total = totalCount(deskCards);
|
||||||
const surcharge = tokenSurcharge(deskCards);
|
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="desk-bar">
|
<div className="desk-bar">
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
.shop-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-header h2 {
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-tokens {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-buy-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--color-token-budget);
|
||||||
|
color: #0a0a0a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-buy-btn:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reroll-btn,
|
||||||
|
.shop-continue-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reroll-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reroll-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-continue-btn {
|
||||||
|
background: var(--color-token-info);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import SymbolBadge from './SymbolBadge';
|
||||||
|
import { purgePrice, REROLL_COST, canAfford } from '../engine/shop';
|
||||||
|
import './ShopScreen.css';
|
||||||
|
|
||||||
|
// 상점 (§8.3) — 라운드 종료 후 의회 진입 전 정산 단계. 자산 구매·소모품·전례 매입은
|
||||||
|
// 각각 §10·§12·§11(3차 범위)이 있어야 성립해서 아직 없다. 지금 성립하는 품목은
|
||||||
|
// "오염 카드 직접 처리" 하나뿐이다 — 족보가 안 맞을 때의 탈출구, 명분·실적은 안 준다.
|
||||||
|
export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, onReroll, rerolled, onContinue }) {
|
||||||
|
const rerollAffordable = canAfford(tokens, REROLL_COST);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-screen">
|
||||||
|
<div className="shop-header">
|
||||||
|
<h2>정산 — 상점</h2>
|
||||||
|
<p className="shop-hint">
|
||||||
|
족보가 안 맞는 오염 카드를 토큰으로 직접 처리할 수 있습니다 (명분·실적은 없음).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shop-tokens">
|
||||||
|
예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shop-grid">
|
||||||
|
{display.length === 0 && <p className="shop-empty">진열할 오염 카드가 없습니다.</p>}
|
||||||
|
{display.map((card) => {
|
||||||
|
const price = purgePrice(card, deskSurcharge);
|
||||||
|
const affordable = canAfford(tokens, price);
|
||||||
|
return (
|
||||||
|
<div key={card.id} className="shop-item">
|
||||||
|
<SymbolBadge axis={card.axis} grade={card.grade} />
|
||||||
|
<span className="shop-item-name">{card.name || '미상 서류'}</span>
|
||||||
|
<button
|
||||||
|
className="shop-buy-btn"
|
||||||
|
disabled={!affordable}
|
||||||
|
onClick={() => onPurge(card.id)}
|
||||||
|
>
|
||||||
|
소각 · 예산{price.budget}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shop-actions">
|
||||||
|
<button className="shop-reroll-btn" disabled={rerolled || !rerollAffordable} onClick={onReroll}>
|
||||||
|
{rerolled ? '리롤 완료' : `진열 다시 뽑기 (예산 ${REROLL_COST.budget})`}
|
||||||
|
</button>
|
||||||
|
<button className="shop-continue-btn" onClick={onContinue}>의회로 이동 →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
|
||||||
|
// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
|
||||||
|
// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
|
||||||
|
// 뽑는 절차는 "가중 무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
|
||||||
|
// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
|
||||||
|
|
||||||
|
/* ── §9.3 실적 감사 ── */
|
||||||
|
|
||||||
|
// 요구치 곡선은 가안: 1R 25, 라운드당 +20.
|
||||||
|
export const requiredScore = (round) => 25 + (round - 1) * 20;
|
||||||
|
|
||||||
|
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 토큰 납입×5 + 명분 납입×20.
|
||||||
|
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
|
||||||
|
combineScore + tokensPaid * 5 + justificationsPaid * 20;
|
||||||
|
|
||||||
|
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만 ── */
|
||||||
|
|
||||||
|
export const PROTOCOL_TYPE_A = [
|
||||||
|
{
|
||||||
|
id: 'protocol_flush_ban',
|
||||||
|
name: '동일 부서 일괄 처리 금지 규약',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'flush_axis' },
|
||||||
|
flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_straight_ban',
|
||||||
|
name: '연번 상신 금지 규약',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'straight' },
|
||||||
|
flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_straightflush_ban',
|
||||||
|
name: '전결 처리 금지 규약',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'straight_flush' },
|
||||||
|
flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_fourkind_ban',
|
||||||
|
name: '전면 개편 금지 규약',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'four_kind' },
|
||||||
|
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_min_grade',
|
||||||
|
name: '기밀 등급 심사 강화 규약',
|
||||||
|
effect: { kind: 'min_grade', minGrade: 2 },
|
||||||
|
flavor: '일반 문서 단독으로는 더 이상 합본 상신이 승인되지 않습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROTOCOL_TYPE_C = [
|
||||||
|
{
|
||||||
|
id: 'penalty_gauge_accel',
|
||||||
|
name: '미결 서류 관리 규정',
|
||||||
|
effect: { kind: 'gauge_surcharge', amount: 1.0 },
|
||||||
|
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'penalty_token_surcharge',
|
||||||
|
name: '긴급 재정 감사 대응',
|
||||||
|
effect: { kind: 'token_surcharge', amount: 2 },
|
||||||
|
flavor: '감사 대응 인력 편성 비용이 모든 토큰 지출에 얹힙니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pick = (arr, n) => {
|
||||||
|
const pool = [...arr];
|
||||||
|
const out = [];
|
||||||
|
while (out.length < n && pool.length > 0) {
|
||||||
|
out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위). */
|
||||||
|
export const rollProtocolCandidates = () => pick(PROTOCOL_TYPE_A, 3);
|
||||||
|
|
||||||
|
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 가중 무작위로 대신한다. */
|
||||||
|
export const resolveProtocolVote = (candidates) => {
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
|
||||||
|
|
||||||
|
/* ── §7.2 명분 개입 — 규약 후보에 개입한다. 실제 13인 표결이 없어 "1표 뒤집기"(혼합 명분)는
|
||||||
|
* 문자 그대로 구현할 표가 없다. 대신 혼합 명분은 "후보 중 하나를 직접 지정 통과"로 대체했다 —
|
||||||
|
* 표 조작의 최종 효과(원하는 규약을 통과시킨다)는 같지만, 이건 §9.4~9.6의 실제 위원회가
|
||||||
|
* 생기면 반드시 다시 설계해야 할 자리표시자다. */
|
||||||
|
export const JUSTIFICATION_INTERVENTIONS = {
|
||||||
|
절차: { label: '규약 이월 (이번 라운드 규약 보류)', needsTarget: false },
|
||||||
|
여론: { label: '후보 1개 강제 탈락', needsTarget: true },
|
||||||
|
위기: { label: '누적 징벌 규약 1개 조기 해제', needsTarget: 'penalty' },
|
||||||
|
내부: { label: '이번 라운드 실적 요구치 하향 (-20%)', needsTarget: false },
|
||||||
|
혼합: { label: '후보 1개 직접 통과 지정', needsTarget: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 명분 개입을 규약 후보에 반영한다 (§7.2 규약 개입).
|
||||||
|
* interventions: [{ type: '절차'|'여론'|'위기'|'내부'|'혼합', targetCandidateId?, targetPenaltyId? }]
|
||||||
|
* @returns {{ candidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear }}
|
||||||
|
*/
|
||||||
|
export function applyInterventions(candidates, interventions) {
|
||||||
|
let pool = [...candidates];
|
||||||
|
let forcedWinnerId = null;
|
||||||
|
let deferVote = false;
|
||||||
|
let requiredScoreMultiplier = 1;
|
||||||
|
let penaltyIdToClear = null;
|
||||||
|
|
||||||
|
interventions.forEach((iv) => {
|
||||||
|
if (iv.type === '절차') deferVote = true;
|
||||||
|
if (iv.type === '여론' && iv.targetCandidateId) {
|
||||||
|
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
||||||
|
}
|
||||||
|
if (iv.type === '내부') requiredScoreMultiplier *= 0.8;
|
||||||
|
if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
|
||||||
|
// 혼합 명분: 실제 표 뒤집기가 아니라 "직접 통과 지정"으로 대체한 자리표시자 — 위 §7.2 주석 참고.
|
||||||
|
if (iv.type === '혼합' && iv.targetCandidateId) forcedWinnerId = iv.targetCandidateId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { candidates: pool, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 합본 판정에 규약 효과를 얹는다 ── */
|
||||||
|
|
||||||
|
/** 활성 유형 A 규약(들)을 반영해 detectHands 결과를 거른다. */
|
||||||
|
export function filterHandsByProtocols(matches, selection, activeProtocols) {
|
||||||
|
let allowed = matches;
|
||||||
|
activeProtocols.forEach((p) => {
|
||||||
|
if (p.effect.kind === 'disable_hand') {
|
||||||
|
allowed = allowed.filter((id) => id !== p.effect.patternId);
|
||||||
|
}
|
||||||
|
if (p.effect.kind === 'min_grade') {
|
||||||
|
const violatesMinGrade = selection.some((c) => c.grade < p.effect.minGrade);
|
||||||
|
if (violatesMinGrade) allowed = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활성 징벌(유형 C)이 게이지 상승률에 더하는 가산분 합계. */
|
||||||
|
export function penaltyGaugeSurcharge(penalties) {
|
||||||
|
return penalties.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활성 징벌(유형 C)이 토큰 지출 가산에 더하는 합계. */
|
||||||
|
export function penaltyTokenSurcharge(penalties) {
|
||||||
|
return penalties.reduce((sum, p) => sum + (p.effect.kind === 'token_surcharge' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
@@ -64,19 +64,20 @@ export function gaugeRiseRate(pressure) {
|
|||||||
return tier ? tier.rate : 13.0;
|
return tier ? tier.rate : 13.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다. */
|
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
|
||||||
export function gaugeRiseRateByAxis(deskCards) {
|
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
|
||||||
|
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
|
||||||
const out = {};
|
const out = {};
|
||||||
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)); });
|
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat; });
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
|
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
|
||||||
export function applyPassiveGaugeRise(gauges, deskCards) {
|
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
||||||
const next = { ...gauges };
|
const next = { ...gauges };
|
||||||
AXES.forEach((axis) => {
|
AXES.forEach((axis) => {
|
||||||
const gaugeKey = axisGaugeKey(axis);
|
const gaugeKey = axisGaugeKey(axis);
|
||||||
const rate = gaugeRiseRate(axisPressure(deskCards, axis));
|
const rate = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat;
|
||||||
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
|
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@@ -96,11 +97,12 @@ const SURCHARGE_TIERS = [
|
|||||||
{ max: Infinity, surcharge: 4 },
|
{ max: Infinity, surcharge: 4 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액. */
|
/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액.
|
||||||
export function tokenSurcharge(deskCards) {
|
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyTokenSurcharge). */
|
||||||
|
export function tokenSurcharge(deskCards, extraFlat = 0) {
|
||||||
const total = totalCount(deskCards);
|
const total = totalCount(deskCards);
|
||||||
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
||||||
return tier ? tier.surcharge : 4;
|
return (tier ? tier.surcharge : 4) + extraFlat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 상점 엔진 — ProjectSS_코어시스템_기획서.md §8.3.
|
||||||
|
// 자산 구매·소모품·전례 매입은 각각 §10·§12·§11(3차 범위)이 있어야 성립하는데 아직 없다.
|
||||||
|
// 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구,
|
||||||
|
// 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다.
|
||||||
|
// 진열 규칙(무작위 4~6개, 1회 리롤 가능·토큰 소모)만 그대로 §8.3을 따른다.
|
||||||
|
|
||||||
|
export const SHOP_DISPLAY_MIN = 4;
|
||||||
|
export const SHOP_DISPLAY_MAX = 6;
|
||||||
|
export const REROLL_COST = { budget: 3 };
|
||||||
|
|
||||||
|
/** 오염 카드 1장을 직접 처리(소각)하는 가격 — 등급이 높을수록, 책상이 꽉 찰수록 비싸진다. */
|
||||||
|
export function purgePrice(card, currentSurcharge) {
|
||||||
|
return { budget: card.grade * 3 + currentSurcharge };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 책상 카드 중 무작위로 4~6장(또는 그 미만이면 전부)을 진열한다. */
|
||||||
|
export function rollShopDisplay(deskCards) {
|
||||||
|
const n = Math.min(deskCards.length, SHOP_DISPLAY_MIN + Math.floor(Math.random() * (SHOP_DISPLAY_MAX - SHOP_DISPLAY_MIN + 1)));
|
||||||
|
const pool = [...deskCards];
|
||||||
|
const out = [];
|
||||||
|
while (out.length < n && pool.length > 0) {
|
||||||
|
out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAfford(tokens, price) {
|
||||||
|
return Object.entries(price).every(([k, v]) => (tokens[k] || 0) >= v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function payTokens(tokens, price) {
|
||||||
|
const next = { ...tokens };
|
||||||
|
Object.entries(price).forEach(([k, v]) => { next[k] = (next[k] || 0) - v; });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user