Phase 2~4 그레이박스 시스템 완성 및 내러티브 태그 재설계
1. Phase 2 완료 — 자원 경제 & 덱빌딩
- 파산 문책 시스템: 토큰 마이너스 상태로 결재 시 파산 카운터 증가,
의회에서 단계별 경고 → 미개선 시 다음 의회에서 해임 게임오버
- 오염 카드 등급 확률: 엔트로피/내부저항/대중공황/위험계수 단계에 비례한
S/A/B/C 가중치로 의회 종료 시 오염 카드 자동 주입
(신화/조직저항/사회공황/이사회압박 16종 카드 신규 추가)
- 자산 효과 4종 구현: 성질 변환·시한부 격리·물리적 영구 소각·결재권 강제 행사
(자산 슬롯 클릭으로 발동)
2. Phase 3 완료 — 정치 시스템 & 스토리 페이징
- 글로벌 지부장 12개 파벌이 각자 다른 공식으로 우호도를 계산하는 엔진 추가.
게임 시작 시 5명 무작위 선발, 동맹/파국 성향 임원은 국장의 개입(매수/감사)에 면역
- 내러티브 태그 누적치에 따라 페이즈1(일상/위기) → 페이즈2(작전/극비) 카드 풀이
실시간으로 전환되도록 구현
- 버그 수정: 위험 수용 계수(risk)가 직접 게임오버를 발동시키지만 EndingScreen에
대응 엔딩이 없어 "미확인 종말" 빈 폴백으로 빠지던 문제 (risk는 탄핵 기준치로만 사용하도록 수정)
- 버그 수정: CouncilScreen의 임원 초기화 effect가 매수 액션의 risk 상승으로
재실행되며 방금 강제 전환한 투표 결과를 덮어쓰던 문제 (마운트 시 1회만 초기화하도록 수정)
3. Phase 4 완료 — 멀티 엔딩 & 연대기
- 우선순위 기반 엔딩 판별기(endings.js) 신규: 의회 정치 엔딩 > 내러티브 연쇄 엔딩 >
변주된 붕괴 엔딩(자원 태그 3종 + 토큰 3종) > 기본 붕괴 엔딩, 총 15종 엔딩 카탈로그
- 극단적 파라미터 조기 엔딩: 단일 결재로 파라미터가 급격히 5단계에 도달하면
"돌이킬 수 없는 순간" 즉시 발동
- 엔딩 도감(EndingCodex) 신규 컴포넌트: 해금/미해금(???) 표시,
localStorage(ss_endings_v2)에 엔딩 고유 id 기준으로 영구 저장
- 연대기 팝업에 타임라인(세로선 + 턴별 점) 시각화 추가
4. 내러티브 태그 시스템 재설계
- 기존에는 결재 카드 한 장당 무조건 +1 되던 카운터였으나, "항상 얻는 보상이 아니다"라는
기획 의도에 맞춰 선별된 카드(22/63장)의 좌/우 선택지에만 짧은 키워드를 부여하도록 변경
(cards.csv에 left_narrative/right_narrative 컬럼 추가)
- 연대기 팝업 상단과 엔딩 화면에 수집한 키워드를 칩(chip) 형태로 표시
- 위 재설계로 태그 획득이 희소해진 만큼, 페이즈 전환 문턱을 4/8 → 2/4로 하향 조정
5. 기타
- 로컬 Node.js LTS 설치 및 Playwright 기반 실기 플레이 검증 체계 확립
- ROADMAP.md/CLAUDE.md Phase 상태 갱신 (Phase 0~4 그레이박스 구간 완료,
다음은 외부 플레이어 그레이박스 테스트)
This commit is contained in:
@@ -1,28 +1,38 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './CouncilScreen.css';
|
||||
|
||||
export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, onResolve }) {
|
||||
const getBankruptcyTier = (counter) => {
|
||||
if (counter >= 8) return { label: '최종 경고', text: '이사회 최종 경고: 다음에도 장부가 마이너스라면, 당신의 자리는 없습니다.' };
|
||||
if (counter >= 4) return { label: '공식 문책', text: '인사위원회가 소환장을 보냈습니다. 법인카드는 잠시 압수하겠습니다.' };
|
||||
return { label: '가벼운 경고', text: '회계팀이 슬쩍 눈치를 주기 시작했습니다. 영수증 좀 잘 챙기시죠.' };
|
||||
};
|
||||
|
||||
export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, bankruptcyCounter, bankruptcyWarned, globalFactions = [], onBribe, onResolve }) {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [agenda, setAgenda] = useState('');
|
||||
const [impeachment, setImpeachment] = useState(false);
|
||||
const [actionTaken, setActionTaken] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const isNegative = tokens.budget < 0 || tokens.personnel < 0 || tokens.info < 0;
|
||||
const bankruptcyTermination = isNegative && bankruptcyWarned;
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize 13 members (8 board, 5 global)
|
||||
// Their vote depends on risk parameter and randomness
|
||||
const newMembers = Array.from({ length: 13 }).map((_, i) => {
|
||||
const type = i < 8 ? 'board' : 'global';
|
||||
// High risk = more likely to vote against
|
||||
// 상임 이사회 8명: 위험 수용 계수 기반 무작위 (성향 태그 세부 연동은 Phase 3 범위 밖)
|
||||
const boardMembers = Array.from({ length: 8 }).map((_, i) => {
|
||||
const rand = Math.random() * 100;
|
||||
let vote = '미정';
|
||||
if (rand < params.risk * 20) {
|
||||
vote = '반대';
|
||||
} else if (rand > 50) {
|
||||
vote = '찬성';
|
||||
}
|
||||
return { id: i, type, vote };
|
||||
if (rand < params.risk * 20) vote = '반대';
|
||||
else if (rand > 50) vote = '찬성';
|
||||
return { id: i, type: 'board', vote, immune: false, label: '상임 이사회' };
|
||||
});
|
||||
|
||||
// 글로벌 지부장 5명: 파벌별 개별 우호도 계산 결과를 그대로 투표에 반영
|
||||
const globalMembers = globalFactions.map((f, i) => ({
|
||||
id: 8 + i, type: 'global', vote: f.vote, immune: f.immune, label: `${f.name} (${f.tier})`,
|
||||
}));
|
||||
|
||||
const newMembers = [...boardMembers, ...globalMembers];
|
||||
setMembers(newMembers);
|
||||
|
||||
const againstCount = newMembers.filter(m => m.vote === '반대').length;
|
||||
@@ -33,7 +43,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정';
|
||||
setAgenda(randAgenda);
|
||||
}
|
||||
}, [params.risk]);
|
||||
// 의회는 마운트 시 1회만 초기화한다 (params.risk를 deps에 넣으면 매수 액션의 risk 상승이
|
||||
// 이 effect를 재실행시켜 방금 강제 전환한 투표를 덮어써버리는 버그가 있었다)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleAction = (type) => {
|
||||
if (actionTaken) return;
|
||||
@@ -42,10 +55,11 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
if (type === 'bribe' && tokens.budget >= 30) {
|
||||
setTokens(prev => ({ ...prev, budget: prev.budget - 30 }));
|
||||
setParams(prev => ({ ...prev, risk: Math.min(5, prev.risk + 1) }));
|
||||
// Force 2 against to support
|
||||
onBribe && onBribe();
|
||||
// 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
|
||||
let changed = 0;
|
||||
for (let m of newMembers) {
|
||||
if (m.vote === '반대' && changed < 2) {
|
||||
if (m.vote === '반대' && !m.immune && changed < 2) {
|
||||
m.vote = '찬성';
|
||||
changed++;
|
||||
}
|
||||
@@ -60,10 +74,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
} else if (type === 'audit' && tokens.personnel >= 30) {
|
||||
setTokens(prev => ({ ...prev, personnel: prev.personnel - 30 }));
|
||||
setParams(prev => ({ ...prev, resistance: Math.min(5, prev.resistance + 1) }));
|
||||
// Force 3 against to abstain
|
||||
// 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
|
||||
let changed = 0;
|
||||
for (let m of newMembers) {
|
||||
if (m.vote === '반대' && changed < 3) {
|
||||
if (m.vote === '반대' && !m.immune && changed < 3) {
|
||||
m.vote = '미정';
|
||||
changed++;
|
||||
}
|
||||
@@ -78,16 +92,16 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
|
||||
const finalize = () => {
|
||||
if (result) {
|
||||
onResolve({ impeachment: false });
|
||||
onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
return;
|
||||
}
|
||||
|
||||
const againstCount = members.filter(m => m.vote === '반대').length;
|
||||
if (impeachment) {
|
||||
if (againstCount >= 9) {
|
||||
onResolve({ impeachment: true });
|
||||
onResolve({ impeachment: true, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
} else {
|
||||
onResolve({ impeachment: false });
|
||||
onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
}
|
||||
} else {
|
||||
const supportCount = members.filter(m => m.vote === '찬성').length;
|
||||
@@ -98,10 +112,23 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
setTags(prev => ({ ...prev, wealth: Math.max(0, prev.wealth - 1) }));
|
||||
}
|
||||
}
|
||||
onResolve({ impeachment: false });
|
||||
onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
}
|
||||
};
|
||||
|
||||
if (bankruptcyTermination) {
|
||||
return (
|
||||
<div className="council-screen bankruptcy-termination">
|
||||
<h2>🚨 재정 파탄 특별 청문회</h2>
|
||||
<p>지난 경고에도 불구하고 장부는 여전히 마이너스입니다. 이사회는 더 이상 변명을 듣지 않기로 했습니다.</p>
|
||||
<p className="bankruptcy-quote">"숫자는 거짓말을 하지 않습니다. 당신의 자리도 이제 안녕입니다."</p>
|
||||
<button className="fat-btn" onClick={() => onResolve({ impeachment: false, bankruptcy: true, bankruptcyWarning: true })}>해임 통보 수령</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tier = getBankruptcyTier(bankruptcyCounter);
|
||||
|
||||
return (
|
||||
<div className="council-screen">
|
||||
<div className="council-header">
|
||||
@@ -113,11 +140,14 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
</div>
|
||||
</div>
|
||||
{impeachment && <div className="warning-blink">[🚨 정치적 탄핵안 상정!]</div>}
|
||||
{isNegative && !bankruptcyWarned && (
|
||||
<div className="bankruptcy-warning">⚠️ [{tier.label}] {tier.text}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="members-grid">
|
||||
{members.map(m => (
|
||||
<div key={m.id} className={`member-icon ${m.type}`}>
|
||||
<div key={m.id} className={`member-icon ${m.type}`} title={m.label}>
|
||||
<span className="member-avatar">👤</span>
|
||||
<span className="member-badge">
|
||||
{m.vote === '찬성' ? '✅' : m.vote === '반대' ? '❌' : '❓'}
|
||||
|
||||
Reference in New Issue
Block a user