자산(엔진) 시스템 v1 + 오염 카드 압박 강화
자산 시스템 (docs/작업지시서/자산시스템_작업지시서.md 반영, 이전 세션 작업분 커밋): - 발라트로형 조커 레이어 엔진 빌딩 도입. assets.json 8종(트리거→효과→성장 스키마) + engine/assetEngine.js(이벤트 버스, 연쇄 깊이 상한 10)로 기존 수동 클릭형 자산 4종 전면 대체 - AssetInfoPopup 추가 — 자산 슬롯 클릭 시 트리거/효과/스택/충전 상태 표시, 발동 로그 피드 노출 오염 카드 압박 강화 (그레이박스 위기감 피드백 반영): - engine/pollution.js 신설 — 오염 주입 규칙(타입 매핑·등급 추첨·물량)을 단일 모듈로 분리해 사전 예고(SwipeCard) · 실제 주입(GameScreen) · 사후 알림이 같은 계산을 공유하도록 함 - 위험 파라미터를 올리는 결재는 정례회의를 기다리지 않고 그 자리에서 덱에 오염 카드를 주입 - UI 3종: 스와이프 시 오염 유입 사전 경고, 결재 후 유입 토스트, 상단 덱 오염 상시 게이지 - currentCardIndex를 매 결재마다 덱 길이로 정규화(주입/소각으로 덱 길이가 바뀌므로 필요) 기타: - docs/architecture_design.md를 실제 구현(plain JS/CSS, useState, engine/ 순수 함수 경계) 기준으로 전면 재작성 — 기존 문서는 채택되지 않은 초기 제안(Tailwind/Zustand/TS)이었음 - playtest.bat 추가 — 더블클릭으로 dev 서버 실행 (ASCII 전용, chcp로 인한 배치 파싱 오류 회피) - CLAUDE.md 상태 갱신 및 실기 플레이 결과 기록 실기 플레이로 정상 동작 확인. 자산 획득 경로(극비 카드 스와이프)는 플레이 중 한 번도 발생하지 않아 원인 미조사 상태로 남김 — CLAUDE.md에 후속 확인 항목으로 기록. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
92
client/src/components/AssetInfoPopup.css
Normal file
92
client/src/components/AssetInfoPopup.css
Normal file
@@ -0,0 +1,92 @@
|
||||
.asset-popup-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.asset-popup {
|
||||
background: var(--color-surface);
|
||||
width: 85%;
|
||||
max-width: 420px;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.asset-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.asset-popup-grade {
|
||||
font-family: var(--font-typewriter);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.grade-C { color: #9ca3af; }
|
||||
.grade-B { color: #4ade80; }
|
||||
.grade-A { color: #60a5fa; }
|
||||
.grade-S { color: #fbbf24; }
|
||||
|
||||
.asset-popup-archetype {
|
||||
font-size: 13px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.asset-popup-header .close-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.asset-popup-name {
|
||||
margin: 0 0 8px 0;
|
||||
font-family: var(--font-doc-title);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.asset-popup-desc {
|
||||
font-size: 14px;
|
||||
color: #d4d4d8;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.asset-popup-stat {
|
||||
font-size: 13px;
|
||||
color: var(--color-token-info);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.asset-popup-activate {
|
||||
width: 100%;
|
||||
background: var(--color-resistance-critical);
|
||||
color: #05201c;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.asset-popup-flavor {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
43
client/src/components/AssetInfoPopup.jsx
Normal file
43
client/src/components/AssetInfoPopup.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { ARCHETYPE_META } from '../engine/assetEngine';
|
||||
import './AssetInfoPopup.css';
|
||||
|
||||
export default function AssetInfoPopup({ instance, def, onActivate, onClose }) {
|
||||
if (!instance || !def) return null;
|
||||
const meta = ARCHETYPE_META[def.archetype];
|
||||
const isManualCharge = def.trigger.type === 'manual_charge';
|
||||
const ready = isManualCharge && instance.charge >= 1;
|
||||
|
||||
return (
|
||||
<div className="asset-popup-overlay" onClick={onClose}>
|
||||
<div className="asset-popup" onClick={e => e.stopPropagation()}>
|
||||
<div className="asset-popup-header">
|
||||
<span className={`asset-popup-grade grade-${def.grade}`}>{def.grade}</span>
|
||||
<span className="asset-popup-archetype">{meta.icon} {meta.label}</span>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<h3 className="asset-popup-name">{def.name}</h3>
|
||||
<p className="asset-popup-desc">{def.description}</p>
|
||||
|
||||
{def.growth && (
|
||||
<div className="asset-popup-stat">
|
||||
현재 숙련 스택: <strong>{instance.stack || 0}</strong> (매 {def.growth.per}회마다 추가 발동)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isManualCharge && (
|
||||
<div className="asset-popup-stat">
|
||||
{ready ? '충전 완료 — 발동 가능' : `충전 대기 중 (${def.trigger.params.chargeEvery}건마다 충전)`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ready && (
|
||||
<button className="asset-popup-activate" onClick={onActivate}>발동</button>
|
||||
)}
|
||||
|
||||
<p className="asset-popup-flavor">{def.flavor}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,30 @@
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 덱 오염도 — 결재를 이어갈수록 덱이 얼마나 썩었는지 상시 노출 */
|
||||
.deck-pollution {
|
||||
font-size: 11px;
|
||||
color: #84cc16;
|
||||
opacity: 0.75;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.deck-pollution.critical {
|
||||
color: #f87171;
|
||||
opacity: 1;
|
||||
font-weight: 700;
|
||||
animation: pulse-pollution 1.6s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse-pollution {
|
||||
0% { text-shadow: none; }
|
||||
100% { text-shadow: 0 0 8px currentColor; }
|
||||
}
|
||||
|
||||
.parameters {
|
||||
@@ -191,6 +215,46 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.asset-slot.grade-S { border-color: #fbbf24; }
|
||||
.asset-slot.grade-A { border-color: #60a5fa; }
|
||||
.asset-slot.grade-B { border-color: #4ade80; }
|
||||
.asset-slot.grade-C { border-color: #9ca3af; }
|
||||
|
||||
.asset-slot.ready {
|
||||
box-shadow: 0 0 10px rgba(251, 191, 36, 0.7);
|
||||
animation: shake 1.2s infinite;
|
||||
}
|
||||
|
||||
.asset-slot-empty-hint {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* 자산 발동 로그 피드 — 발라트로 조커 점멸에 해당하는 최소 구현(텍스트 로그) */
|
||||
.asset-log-feed {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 190px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 60%;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.asset-log-line {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 11px;
|
||||
color: #d4d4d8;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-left: 2px solid var(--color-risk-critical);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Game Over */
|
||||
.game-over {
|
||||
display: flex;
|
||||
@@ -234,11 +298,61 @@
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
.tag-notification {
|
||||
/* 태그 획득 / 오염 유입 토스트는 같은 열에 쌓아 서로 겹치지 않게 한다 */
|
||||
.notification-stack {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.pollution-notification {
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 12px;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: 0 4px 20px rgba(139, 0, 0, 0.5);
|
||||
animation: notiFadeOut 2.6s forwards;
|
||||
}
|
||||
|
||||
.pollution-noti-icon {
|
||||
font-size: 26px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.pollution-noti-info h4 {
|
||||
margin: 0;
|
||||
color: #f87171;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.pollution-noti-info p {
|
||||
margin: 4px 0 0 0;
|
||||
color: #e4e4e7;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-typewriter);
|
||||
}
|
||||
|
||||
@keyframes notiFadeOut {
|
||||
0% { opacity: 0; transform: translateY(-16px); }
|
||||
10% { opacity: 1; transform: translateY(0); }
|
||||
85% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-16px); }
|
||||
}
|
||||
|
||||
.tag-notification {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border: 1px solid #fbbf24;
|
||||
border-radius: 12px;
|
||||
@@ -247,8 +361,7 @@
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4);
|
||||
z-index: 100;
|
||||
animation: slideDownFadeOut 3s forwards;
|
||||
animation: notiFadeOut 3s forwards;
|
||||
}
|
||||
|
||||
.tag-noti-icon {
|
||||
@@ -268,9 +381,3 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@keyframes slideDownFadeOut {
|
||||
0% { opacity: 0; transform: translate(-50%, -20px); }
|
||||
10% { opacity: 1; transform: translate(-50%, 0); }
|
||||
80% { opacity: 1; transform: translate(-50%, 0); }
|
||||
100% { opacity: 0; transform: translate(-50%, -20px); }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Papa from 'papaparse';
|
||||
import cardsCsvRaw from '../data/cards.csv?raw';
|
||||
import narrativeTagsData from '../data/narrative_tags.json';
|
||||
@@ -7,50 +7,28 @@ import SwipeCard from './SwipeCard';
|
||||
import CouncilScreen from './CouncilScreen';
|
||||
import EndingScreen from './EndingScreen';
|
||||
import ChroniclePopup from './ChroniclePopup';
|
||||
import AssetInfoPopup from './AssetInfoPopup';
|
||||
import * as AssetEngine from '../engine/assetEngine';
|
||||
import {
|
||||
CONTAM_TYPES,
|
||||
CONTAM_TYPE_ICON,
|
||||
previewPollution,
|
||||
buildPollutionCards,
|
||||
buildCouncilPollutionCards,
|
||||
insertIntoDeck,
|
||||
describePollution,
|
||||
} from '../engine/pollution';
|
||||
import { CHAIRMAN_AXES, isNarrativeChainComplete, resolveEnding, getEndingById } from '../data/endings';
|
||||
import './GameScreen.css';
|
||||
|
||||
// 오염 카드 타입 <-> 파라미터 매핑 (파산/오염 등급 시스템)
|
||||
const CONTAM_TYPE_BY_PARAM = {
|
||||
entropy: '신화',
|
||||
resistance: '조직저항',
|
||||
panic: '사회공황',
|
||||
risk: '이사회압박',
|
||||
};
|
||||
const CONTAM_TYPES = new Set(Object.values(CONTAM_TYPE_BY_PARAM));
|
||||
|
||||
// 파라미터 단계(1~5)가 높을수록 높은 등급(S/A) 확률이 커지는 가중치 테이블
|
||||
const GRADE_WEIGHTS = {
|
||||
1: { C: 70, B: 22, A: 6, S: 2 },
|
||||
2: { C: 50, B: 28, A: 15, S: 7 },
|
||||
3: { C: 32, B: 30, A: 23, S: 15 },
|
||||
4: { C: 18, B: 24, A: 32, S: 26 },
|
||||
5: { C: 8, B: 17, A: 33, S: 42 },
|
||||
};
|
||||
|
||||
const pickGrade = (level) => {
|
||||
const weights = GRADE_WEIGHTS[Math.min(5, Math.max(1, level))];
|
||||
const total = Object.values(weights).reduce((a, b) => a + b, 0);
|
||||
let r = Math.random() * total;
|
||||
for (const [grade, w] of Object.entries(weights)) {
|
||||
if (r < w) return grade;
|
||||
r -= w;
|
||||
}
|
||||
return 'C';
|
||||
};
|
||||
// 오염 카드 주입 규칙(타입 매핑 · 등급 추첨 · 물량)은 engine/pollution.js에 모여 있다.
|
||||
// 스와이프 예고(SwipeCard)와 실제 주입이 같은 계산을 쓰게 하기 위해 여기서 다시 정의하지 말 것.
|
||||
|
||||
// 내러티브 태그는 narrative_tags.json 풀(10종)에서 "처음 획득하는 태그"만 진행도에 반영된다.
|
||||
// 문턱은 풀 크기 대비 낮게 잡는다 (기존 4/8은 실제 플레이에서 길게 느껴진다는 피드백 반영).
|
||||
const PHASE2_TAG_THRESHOLD = 3;
|
||||
const PHASE3_TAG_THRESHOLD = 6;
|
||||
|
||||
const ASSET_META = {
|
||||
transform: { icon: '🧪', label: '성질 변환' },
|
||||
quarantine: { icon: '⏳', label: '시한부 격리' },
|
||||
purge: { icon: '🔥', label: '물리적 영구 소각' },
|
||||
mulligan: { icon: '🛡️', label: '결재권 강제 행사' },
|
||||
};
|
||||
|
||||
// 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산)
|
||||
const GLOBAL_FACTIONS = [
|
||||
{ id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 },
|
||||
@@ -106,23 +84,15 @@ const buildCardFromRow = (row) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const transformCard = (card) => {
|
||||
const flip = (stats) => ({ ...stats, res: -stats.res, ent: -stats.ent, pan: -stats.pan, rsk: -stats.rsk });
|
||||
return {
|
||||
...card,
|
||||
transformed: true,
|
||||
text: card.transformed ? card.text : `${card.text} (순화됨)`,
|
||||
left: { ...card.left, stats: flip(card.left.stats) },
|
||||
right: { ...card.right, stats: flip(card.right.stats) },
|
||||
};
|
||||
};
|
||||
|
||||
export function GameScreen() {
|
||||
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 });
|
||||
const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
|
||||
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
|
||||
const [acquiredTags, setAcquiredTags] = useState([]);
|
||||
const [newTagNotification, setNewTagNotification] = useState(null);
|
||||
// 결재 직후 "오염 카드 N건이 덱에 유입됨"을 알리는 토스트. 연속 스와이프 시 타이머를 갱신한다.
|
||||
const [pollutionNotification, setPollutionNotification] = useState(null);
|
||||
const pollutionTimerRef = useRef(null);
|
||||
const [phase, setPhase] = useState(1);
|
||||
const [gameOver, setGameOver] = useState(false);
|
||||
const [cards, setCards] = useState([]);
|
||||
@@ -135,7 +105,11 @@ export function GameScreen() {
|
||||
const [showCouncil, setShowCouncil] = useState(false);
|
||||
const [chronicle, setChronicle] = useState([]);
|
||||
const [showChronicle, setShowChronicle] = useState(false);
|
||||
const [assets, setAssets] = useState([]);
|
||||
|
||||
// 자산(엔진) 시스템 — 작업지시서 기준 트리거/효과 엔진
|
||||
const [ownedAssets, setOwnedAssets] = useState([]);
|
||||
const [assetLog, setAssetLog] = useState([]);
|
||||
const [assetPopup, setAssetPopup] = useState(null);
|
||||
|
||||
// 라운드 구조: 라운드가 증가할수록 정례회의까지 남은 결재 건수가 줄어든다 (시간 압박 시각화)
|
||||
const [roundCount, setRoundCount] = useState(1);
|
||||
@@ -226,51 +200,93 @@ export function GameScreen() {
|
||||
}
|
||||
}, [turns]);
|
||||
|
||||
const applySide = (side, nullifyDanger) => {
|
||||
const s = side.stats;
|
||||
setParams(p => ({
|
||||
resistance: Math.max(0, p.resistance + (nullifyDanger ? 0 : s.res)),
|
||||
entropy: Math.max(0, p.entropy + (nullifyDanger ? 0 : s.ent)),
|
||||
panic: Math.max(0, p.panic + (nullifyDanger ? 0 : s.pan)),
|
||||
risk: Math.max(0, p.risk + (nullifyDanger ? 0 : s.rsk)),
|
||||
}));
|
||||
setTokens(t => ({
|
||||
budget: t.budget + s.bud,
|
||||
personnel: t.personnel + s.per,
|
||||
info: t.info + s.inf,
|
||||
}));
|
||||
setTags(tg => ({
|
||||
wealth: tg.wealth + s.wea,
|
||||
force: tg.force + s.for,
|
||||
surveillance: tg.surveillance + s.sur,
|
||||
}));
|
||||
// 자산 엔진 world 스냅샷 생성/반영 — 현재 state를 복사해 엔진에 넘기고, 반환된 결과를 다시 state로 반영한다.
|
||||
// 호출 지점 하나당 반드시 buildAssetWorld/commitAssetWorld를 한 번씩만 쓴다: 같은 이벤트 처리 안에서
|
||||
// 여러 번 짝지어 부르면 나중 커밋이 아직 리렌더되지 않은(오래된) 클로저 state로 앞의 변경을 덮어써 버린다.
|
||||
const buildAssetWorld = () => ({
|
||||
cards: [...cards],
|
||||
tokens: { ...tokens },
|
||||
tags: { ...tags },
|
||||
params: { ...params },
|
||||
owned: ownedAssets.map(o => ({ ...o })),
|
||||
quarantine: quarantineZone.map(q => ({ ...q })),
|
||||
logs: [],
|
||||
purgeGain: 0,
|
||||
});
|
||||
|
||||
const commitAssetWorld = (world) => {
|
||||
setCards(world.cards);
|
||||
setTokens(world.tokens);
|
||||
setTags(world.tags);
|
||||
setParams(world.params);
|
||||
setOwnedAssets(world.owned);
|
||||
setQuarantineZone(world.quarantine);
|
||||
if (world.purgeGain) setPurgeCount(c => c + world.purgeGain);
|
||||
if (world.logs.length) setAssetLog(prev => [...prev, ...world.logs].slice(-8));
|
||||
};
|
||||
|
||||
// 오염 유입 토스트 — 빠르게 연속 스와이프해도 마지막 알림 기준으로 표시 시간이 다시 채워진다.
|
||||
const notifyPollution = (count, entries) => {
|
||||
if (pollutionTimerRef.current) clearTimeout(pollutionTimerRef.current);
|
||||
setPollutionNotification({ id: Date.now(), count, summary: describePollution(entries) });
|
||||
pollutionTimerRef.current = setTimeout(() => setPollutionNotification(null), 2600);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(pollutionTimerRef.current), []);
|
||||
|
||||
const handleSwipe = (direction) => {
|
||||
if (cards.length === 0) return;
|
||||
const card = cards[currentCardIndex % cards.length];
|
||||
|
||||
let outcomeText = '';
|
||||
const cardIdx = currentCardIndex % cards.length;
|
||||
const card = cards[cardIdx];
|
||||
const side = direction === 'left' ? card.left : direction === 'right' ? card.right : null;
|
||||
|
||||
const world = buildAssetWorld();
|
||||
let outcomeText = '';
|
||||
let isNewTag = false;
|
||||
let gainedTag = null;
|
||||
|
||||
if (side) {
|
||||
const shouldNullify = card.contaminated && mulliganShields > 0;
|
||||
applySide(side, shouldNullify);
|
||||
// 자산 엔진: 좌 스와이프 격리 도박(임시 격리 컨테이너) — 성공하면 이 카드는 정상 결과 대신 격리된다.
|
||||
let quarantinedByAsset = false;
|
||||
if (direction === 'left' && card.contaminated) {
|
||||
AssetEngine.onSwipeLeft(world, cardIdx, turns);
|
||||
quarantinedByAsset = world.quarantinedCard;
|
||||
}
|
||||
|
||||
// 자산 엔진: 오염 카드 드로우 트리거(신화 환전/조직저항 자동 무효화 등)
|
||||
let negateFree = false;
|
||||
if (!quarantinedByAsset && card.contaminated) {
|
||||
AssetEngine.onPollutionDraw(world, card.type);
|
||||
negateFree = world.negateFree;
|
||||
}
|
||||
|
||||
if (quarantinedByAsset) {
|
||||
setChronicle(prev => [...prev, {
|
||||
turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
|
||||
decision: '임시 격리 컨테이너로 격리 (정례회의 전까지 미회수 시 실패)', tag: null,
|
||||
}]);
|
||||
} else if (side) {
|
||||
const shouldNullify = card.contaminated && (negateFree || mulliganShields > 0);
|
||||
const s = side.stats;
|
||||
world.params.resistance = Math.max(0, world.params.resistance + (shouldNullify ? 0 : s.res));
|
||||
world.params.entropy = Math.max(0, world.params.entropy + (shouldNullify ? 0 : s.ent));
|
||||
world.params.panic = Math.max(0, world.params.panic + (shouldNullify ? 0 : s.pan));
|
||||
world.params.risk = Math.max(0, world.params.risk + (shouldNullify ? 0 : s.rsk));
|
||||
world.tokens.budget += s.bud;
|
||||
world.tokens.personnel += s.per;
|
||||
world.tokens.info += s.inf;
|
||||
world.tags.wealth += s.wea;
|
||||
world.tags.force += s.for;
|
||||
world.tags.surveillance += s.sur;
|
||||
outcomeText = side.text;
|
||||
|
||||
// 파산 문책: 결재 결과 토큰이 마이너스가 되면 파산 카운터 증가
|
||||
const nextBudget = tokens.budget + side.stats.bud;
|
||||
const nextPersonnel = tokens.personnel + side.stats.per;
|
||||
const nextInfo = tokens.info + side.stats.inf;
|
||||
if (nextBudget < 0 || nextPersonnel < 0 || nextInfo < 0) {
|
||||
if (world.tokens.budget < 0 || world.tokens.personnel < 0 || world.tokens.info < 0) {
|
||||
setBankruptcyCount(c => c + 1);
|
||||
}
|
||||
|
||||
// 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
|
||||
if (!shouldNullify) {
|
||||
const dangerDeltas = { resistance: side.stats.res, entropy: side.stats.ent, panic: side.stats.pan };
|
||||
const dangerDeltas = { resistance: s.res, entropy: s.ent, panic: s.pan };
|
||||
let maxParam = null, maxVal = 0;
|
||||
Object.entries(dangerDeltas).forEach(([p, v]) => {
|
||||
if (v > maxVal) { maxVal = v; maxParam = p; }
|
||||
@@ -297,32 +313,59 @@ export function GameScreen() {
|
||||
}
|
||||
|
||||
if (shouldNullify) {
|
||||
setMulliganShields(s => s - 1);
|
||||
outcomeText += ' (방어막으로 페널티 무효화)';
|
||||
if (negateFree) {
|
||||
outcomeText += ' (책임 소재 불명 처리로 자동 무효화)';
|
||||
} else {
|
||||
setMulliganShields(s2 => s2 - 1);
|
||||
outcomeText += ' (방어막으로 페널티 무효화)';
|
||||
}
|
||||
}
|
||||
|
||||
// 위험 파라미터를 올린 결재는 그 자리에서 덱에 오염 카드를 주입한다 (정례회의까지 기다리지 않는다).
|
||||
// 페널티가 무효화된 경우 파라미터도 오르지 않았으므로 오염도 유입되지 않는다 — 방어막의 값어치.
|
||||
if (!shouldNullify) {
|
||||
const { total, entries } = previewPollution(s);
|
||||
if (total > 0) {
|
||||
const injected = buildPollutionCards(entries, world.params, contaminationTemplates);
|
||||
if (injected.length > 0) {
|
||||
world.cards = insertIntoDeck(world.cards, cardIdx, injected);
|
||||
outcomeText += ` → 오염 카드 ${injected.length}건 유입`;
|
||||
notifyPollution(injected.length, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (direction === 'up') {
|
||||
outcomeText = '보류 / 버리기';
|
||||
}
|
||||
|
||||
if (outcomeText) {
|
||||
if (!quarantinedByAsset && outcomeText) {
|
||||
setChronicle(prev => [...prev, {
|
||||
turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
|
||||
decision: outcomeText, tag: isNewTag ? gainedTag : null,
|
||||
}]);
|
||||
}
|
||||
|
||||
// 극비 프로젝트 성공 시 자산 획득 (4종 중 랜덤)
|
||||
if (card.type === '극비' && direction !== 'up') {
|
||||
if (assets.length < 4) {
|
||||
const types = Object.keys(ASSET_META);
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
setAssets(prev => [...prev, { id: Date.now(), type, ...ASSET_META[type] }]);
|
||||
// 극비 프로젝트 성공 시 자산 획득 (아직 보유하지 않은 자산 중 무작위 1종)
|
||||
if (!quarantinedByAsset && card.type === '극비' && direction !== 'up') {
|
||||
const ownedIds = new Set(world.owned.map(a => a.defId));
|
||||
const pool = AssetEngine.ASSET_CATALOG.filter(a => !ownedIds.has(a.id));
|
||||
if (pool.length > 0) {
|
||||
const def = pool[Math.floor(Math.random() * pool.length)];
|
||||
world.owned.push(AssetEngine.createAssetInstance(def.id, turns + 1));
|
||||
setAssetsAcquiredCount(c => c + 1);
|
||||
world.logs.push(`『${def.name}』(${def.grade}급) 획득`);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentCardIndex(idx => idx + 1);
|
||||
setTurns(t => t + 1);
|
||||
// 자산 엔진: 결재 N건마다 발동(문서 파쇄실) + 충전식 자산 충전 확인
|
||||
const nextTurns = turns + 1;
|
||||
AssetEngine.onApprovalTick(world, nextTurns);
|
||||
commitAssetWorld(world);
|
||||
|
||||
// 덱은 인덱스를 순환하는 풀이다. 주입/소각으로 길이가 바뀌므로 다음 인덱스는 항상 최종 덱 길이로
|
||||
// 정규화해 범위 안에 둔다 — 그래야 "현재 위치 기준 2~4장 뒤 삽입"이 의도대로 동작한다.
|
||||
setCurrentCardIndex(world.cards.length > 0 ? (cardIdx + 1) % world.cards.length : 0);
|
||||
setTurns(nextTurns);
|
||||
|
||||
const newRemaining = approvalsRemaining - 1;
|
||||
setApprovalsRemaining(newRemaining);
|
||||
@@ -331,33 +374,28 @@ export function GameScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 그림자 의회 종료 후 파라미터 단계에 비례한 등급 가중치로 오염 카드 주입
|
||||
// 그림자 의회 종료 후 파라미터 단계에 비례한 등급/물량으로 오염 카드 주입
|
||||
const injectContaminationCards = () => {
|
||||
const newCards = [];
|
||||
Object.entries(CONTAM_TYPE_BY_PARAM).forEach(([paramKey, cardType]) => {
|
||||
const level = params[paramKey];
|
||||
if (level < 1) return;
|
||||
const grade = pickGrade(level);
|
||||
const pool = contaminationTemplates.filter(t => t.type === cardType && t.grade === grade);
|
||||
if (pool.length === 0) return;
|
||||
const template = pool[Math.floor(Math.random() * pool.length)];
|
||||
newCards.push({ ...template, id: `${template.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` });
|
||||
});
|
||||
const newCards = buildCouncilPollutionCards(params, contaminationTemplates);
|
||||
if (newCards.length === 0) return;
|
||||
|
||||
if (newCards.length > 0) {
|
||||
setCards(prev => {
|
||||
const insertAt = Math.min(prev.length, currentCardIndex + 1);
|
||||
const next = [...prev];
|
||||
next.splice(insertAt, 0, ...newCards);
|
||||
return next;
|
||||
});
|
||||
setChronicle(prev => [...prev, { turn: turns, cardType: '오염', keyword: null, text: `정례회의 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.`, decision: '', tag: null }]);
|
||||
}
|
||||
setCards(prev => insertIntoDeck(prev, currentCardIndex, newCards));
|
||||
setChronicle(prev => [...prev, { turn: turns, cardType: '오염', keyword: null, text: `정례회의 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.`, decision: '', tag: null }]);
|
||||
const byType = newCards.reduce((acc, c) => ({ ...acc, [c.type]: (acc[c.type] || 0) + 1 }), {});
|
||||
notifyPollution(
|
||||
newCards.length,
|
||||
Object.entries(byType).map(([type, count]) => ({ icon: CONTAM_TYPE_ICON[type], type, count })),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCouncilResolve = (result) => {
|
||||
setShowCouncil(false);
|
||||
|
||||
// 자산 엔진: 의회 종료 시 상시 자산 리스크 정산 + 격리 실패 판정(심연 보관 조례 개입 포함)
|
||||
const councilWorld = buildAssetWorld();
|
||||
AssetEngine.onCouncilEnd(councilWorld);
|
||||
commitAssetWorld(councilWorld);
|
||||
|
||||
if (result.bankrupted) {
|
||||
setFinalEnding(getEndingById('bankruptcy'));
|
||||
setGameOver(true);
|
||||
@@ -395,42 +433,16 @@ export function GameScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 자산 효과 4종 발동
|
||||
const activateAsset = (asset) => {
|
||||
if (asset.type === 'mulligan') {
|
||||
setMulliganShields(s => s + 1);
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '결재권 강제 행사 방어막을 전개했습니다. 다음 오염 카드의 페널티가 무효화됩니다.', decision: '', tag: null }]);
|
||||
return;
|
||||
}
|
||||
// 자산 슬롯 클릭 — 항상 정보 팝업을 연다 (Part C UX 블로커 해결). defId만 저장해 최신 스택/충전 값을 반영한다.
|
||||
const handleAssetSlotClick = (inst) => setAssetPopup(inst.defId);
|
||||
|
||||
const contaminatedIdx = [];
|
||||
cards.forEach((c, i) => { if (c.contaminated) contaminatedIdx.push(i); });
|
||||
|
||||
if (contaminatedIdx.length === 0) {
|
||||
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `${asset.label}을(를) 발동했지만 대상이 될 오염 카드가 없습니다.`, decision: '', tag: null }]);
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.type === 'purge') {
|
||||
const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
|
||||
setCards(prev => prev.filter((_, i) => i !== target));
|
||||
setPurgeCount(c => c + 1);
|
||||
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '오염 카드를 물리적으로 영구 소각했습니다.', decision: '', tag: null }]);
|
||||
} else if (asset.type === 'quarantine') {
|
||||
const targetSet = new Set(contaminatedIdx.slice(0, 2));
|
||||
const quarantined = cards.filter((c, i) => targetSet.has(i));
|
||||
setCards(prev => prev.filter((_, i) => !targetSet.has(i)));
|
||||
setQuarantineZone(prev => [...prev, ...quarantined.map(c => ({ ...c, returnTurn: turns + 5 }))]);
|
||||
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `오염 카드 ${quarantined.length}건을 시한부 격리했습니다. (5턴 후 덱으로 복귀)`, decision: '', tag: null }]);
|
||||
} else if (asset.type === 'transform') {
|
||||
const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
|
||||
setCards(prev => prev.map((c, i) => (i === target ? transformCard(c) : c)));
|
||||
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '오염 카드의 성질을 영구적으로 순화했습니다. (독을 약으로)', decision: '', tag: null }]);
|
||||
}
|
||||
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
// 충전식 자산(manual_charge) 수동 발동 — 팝업 내부의 '발동' 버튼에서 호출
|
||||
const handleAssetActivate = (defId) => {
|
||||
const world = buildAssetWorld();
|
||||
AssetEngine.activateManualAsset(world, defId, turns);
|
||||
commitAssetWorld(world);
|
||||
if (world.negateChargeGranted) setMulliganShields(s => s + 1);
|
||||
setAssetPopup(null);
|
||||
};
|
||||
|
||||
const renderDots = (value, max = 5) => {
|
||||
@@ -454,7 +466,7 @@ export function GameScreen() {
|
||||
// 글로벌 지부장 12개 파벌 중 이번 회차 선발된 5명의 우호도를 각자 다른 조건으로 개별 계산
|
||||
const affinityCtx = {
|
||||
tags, tokens, params,
|
||||
assetsCount: assets.length,
|
||||
assetsCount: ownedAssets.length,
|
||||
mythicCount: cards.filter(c => c.type === '신화').length,
|
||||
tagCount: acquiredTags.length, assetsAcquiredCount, purgeCount, bribeCount,
|
||||
};
|
||||
@@ -485,6 +497,10 @@ export function GameScreen() {
|
||||
const phaseClass = phase === 1 ? 'phase-1' : phase === 2 ? 'phase-2' : 'phase-3';
|
||||
const nextTagThreshold = phase === 1 ? PHASE2_TAG_THRESHOLD : phase === 2 ? PHASE3_TAG_THRESHOLD : null;
|
||||
|
||||
// 덱 오염도 — 결재를 이어갈수록 덱이 얼마나 썩었는지 상시 노출해 압박을 준다.
|
||||
const pollutedCount = cards.filter(c => c.contaminated).length;
|
||||
const pollutionRatio = cards.length > 0 ? pollutedCount / cards.length : 0;
|
||||
|
||||
return (
|
||||
<div className={`game-screen ${phaseClass}`}>
|
||||
{/* Top Bar */}
|
||||
@@ -493,7 +509,10 @@ export function GameScreen() {
|
||||
<BookOpen size={16} /> <span>{acquiredTags.length}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
|
||||
</div>
|
||||
<div className="round-info">
|
||||
제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건
|
||||
<div>제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건</div>
|
||||
<div className={`deck-pollution ${pollutionRatio >= 0.4 ? 'critical' : ''}`}>
|
||||
☣ 덱 오염 {pollutedCount}/{cards.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="parameters">
|
||||
<div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
|
||||
@@ -538,30 +557,62 @@ export function GameScreen() {
|
||||
<div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
|
||||
</div>
|
||||
<div className="asset-slots">
|
||||
{[0, 1, 2, 3].map(i => {
|
||||
const asset = assets[i];
|
||||
{ownedAssets.length === 0 && <div className="asset-slot-empty-hint">보유 자산 없음</div>}
|
||||
{ownedAssets.map((inst) => {
|
||||
const def = AssetEngine.getAssetDef(inst.defId);
|
||||
const meta = AssetEngine.ARCHETYPE_META[def.archetype];
|
||||
const ready = def.trigger.type === 'manual_charge' && inst.charge >= 1;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={`asset-slot ${asset ? 'filled' : ''}`}
|
||||
onClick={() => asset && activateAsset(asset)}
|
||||
disabled={!asset}
|
||||
title={asset ? asset.label : '빈 슬롯'}
|
||||
key={inst.defId}
|
||||
className={`asset-slot filled grade-${def.grade} ${ready ? 'ready' : ''}`}
|
||||
onClick={() => handleAssetSlotClick(inst)}
|
||||
title={def.name}
|
||||
>
|
||||
{asset ? <span className="asset-icon">{asset.icon}</span> : ''}
|
||||
<span className="asset-icon">{meta.icon}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newTagNotification && (
|
||||
<div className="tag-notification">
|
||||
<div className="tag-noti-icon">{newTagNotification.icon}</div>
|
||||
<div className="tag-noti-info">
|
||||
<h4>새로운 내러티브 획득!</h4>
|
||||
<p>[{newTagNotification.name}]</p>
|
||||
</div>
|
||||
{assetLog.length > 0 && (
|
||||
<div className="asset-log-feed">
|
||||
{assetLog.slice(-4).map((line, i) => (
|
||||
<div key={i} className="asset-log-line">{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assetPopup && (
|
||||
<AssetInfoPopup
|
||||
instance={ownedAssets.find(a => a.defId === assetPopup)}
|
||||
def={AssetEngine.getAssetDef(assetPopup)}
|
||||
onActivate={() => handleAssetActivate(assetPopup)}
|
||||
onClose={() => setAssetPopup(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(newTagNotification || pollutionNotification) && (
|
||||
<div className="notification-stack">
|
||||
{newTagNotification && (
|
||||
<div className="tag-notification">
|
||||
<div className="tag-noti-icon">{newTagNotification.icon}</div>
|
||||
<div className="tag-noti-info">
|
||||
<h4>새로운 내러티브 획득!</h4>
|
||||
<p>[{newTagNotification.name}]</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pollutionNotification && (
|
||||
<div className="pollution-notification" key={pollutionNotification.id}>
|
||||
<div className="pollution-noti-icon">☣</div>
|
||||
<div className="pollution-noti-info">
|
||||
<h4>오염 카드 {pollutionNotification.count}건이 덱에 유입되었습니다</h4>
|
||||
<p>{pollutionNotification.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -222,6 +222,43 @@
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* 오염 카드 사전 경고 — 이 선택이 덱을 얼마나 더럽히는지 스와이프 전에 보여준다 */
|
||||
.pollution-warning {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(20, 0, 0, 0.92);
|
||||
border: 2px solid #dc2626;
|
||||
color: #f87171;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--font-ui);
|
||||
box-shadow: 0 0 15px rgba(139, 0, 0, 0.6);
|
||||
animation: pulsePollutionWarning 1.5s infinite;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.pollution-warning-head {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.pollution-warning-types {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 11px;
|
||||
color: #e4e4e7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes pulsePollutionWarning {
|
||||
0% { transform: scale(1); box-shadow: 0 0 12px rgba(139, 0, 0, 0.5); }
|
||||
50% { transform: scale(1.04); box-shadow: 0 0 22px rgba(139, 0, 0, 0.9); }
|
||||
100% { transform: scale(1); box-shadow: 0 0 12px rgba(139, 0, 0, 0.5); }
|
||||
}
|
||||
|
||||
@keyframes pulseTag {
|
||||
0% { transform: scale(1); box-shadow: 0 0 15px rgba(251, 191, 36, 0.5); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 0 25px rgba(251, 191, 36, 0.8); }
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
|
||||
import { HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
|
||||
import { previewPollution, describePollution } from '../engine/pollution';
|
||||
import './SwipeCard.css';
|
||||
|
||||
// 이 선택이 덱에 오염 카드를 몇 장 밀어 넣는지 스와이프 전에 예고한다.
|
||||
// 계산은 반드시 engine/pollution.js의 previewPollution을 쓴다 — 실제 주입과 같은 기준이어야 한다.
|
||||
const renderPollutionWarning = (stats) => {
|
||||
const { total, entries } = previewPollution(stats);
|
||||
if (total === 0) return null;
|
||||
return (
|
||||
<div className="pollution-warning">
|
||||
<span className="pollution-warning-head">☣ 오염 카드 +{total}</span>
|
||||
<span className="pollution-warning-types">{describePollution(entries)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStats = (stats) => {
|
||||
if (!stats) return null;
|
||||
const items = [];
|
||||
@@ -117,6 +131,7 @@ export default function SwipeCard({ card, onSwipe, phase }) {
|
||||
<span className="intent-tag-name">{card.left.tag.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{renderPollutionWarning(card.left?.stats)}
|
||||
{renderStats(card.left?.stats)}
|
||||
</motion.div>
|
||||
<motion.div className="swipe-intent right-intent" style={{ opacity: rightOpacity }}>
|
||||
@@ -127,6 +142,7 @@ export default function SwipeCard({ card, onSwipe, phase }) {
|
||||
<span className="intent-tag-name">{card.right.tag.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{renderPollutionWarning(card.right?.stats)}
|
||||
{renderStats(card.right?.stats)}
|
||||
</motion.div>
|
||||
{card.type === '극비' && (
|
||||
|
||||
@@ -1,30 +1,106 @@
|
||||
[
|
||||
{
|
||||
"id": "asset_01",
|
||||
"name": "전임 국장의 만년필",
|
||||
"icon": "🖋️",
|
||||
"description": "피로 쓰여진 듯한 잉크가 나옵니다. 예산 관리에 소소한 도움을 줍니다.",
|
||||
"flavor": "\"예산 전용 동의서에 서명하기 아주 좋은 펜이군.\""
|
||||
"id": "asset_purge_001",
|
||||
"name": "문서 파쇄실",
|
||||
"grade": "C",
|
||||
"archetype": "purge",
|
||||
"trigger": { "type": "every_n_approvals", "params": { "n": 5 } },
|
||||
"effect": { "type": "purge_lowest", "params": { "count": 1 } },
|
||||
"growth": null,
|
||||
"risk": null,
|
||||
"faction_link": "scavengers",
|
||||
"description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.",
|
||||
"flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
|
||||
},
|
||||
{
|
||||
"id": "asset_02",
|
||||
"name": "비밀 스위스 은행 계좌",
|
||||
"icon": "🏦",
|
||||
"description": "마이너스 예산을 한 번 막아줄 수 있습니다.",
|
||||
"flavor": "\"우리 기구가 파산해도 내 노후는 안전해야지.\""
|
||||
"id": "asset_purge_002",
|
||||
"name": "소각 처리 대장",
|
||||
"grade": "A",
|
||||
"archetype": "purge",
|
||||
"trigger": { "type": "on_purge", "params": {} },
|
||||
"effect": null,
|
||||
"growth": { "stackOn": "on_purge", "per": 3, "bonus": { "effect": "purge_lowest", "count": 1 } },
|
||||
"risk": null,
|
||||
"faction_link": "scavengers",
|
||||
"description": "소각이 발생할 때마다 숙련도가 쌓인다. 숙련 3회마다 최하 등급 오염 카드 1장을 추가로 소각한다.",
|
||||
"flavor": "소각로 온도는 서류로 증명되지 않는다."
|
||||
},
|
||||
{
|
||||
"id": "asset_03",
|
||||
"name": "기억 소거 장치",
|
||||
"icon": "🔦",
|
||||
"description": "불리한 결재 건을 한 번 취소할 수 있습니다.",
|
||||
"flavor": "\"자, 여기 불빛을 보세요. 당신은 방금 아무것도 보지 못했습니다.\""
|
||||
"id": "asset_convert_001",
|
||||
"name": "폐기물 재활용 규정",
|
||||
"grade": "C",
|
||||
"archetype": "convert",
|
||||
"trigger": { "type": "on_pollution_draw", "params": { "category": "신화", "minGrade": null } },
|
||||
"effect": { "type": "convert_to_token", "params": { "tokenType": "budget", "amount": 2 } },
|
||||
"growth": null,
|
||||
"risk": null,
|
||||
"faction_link": "nexus_traders",
|
||||
"description": "신화 오염 카드가 드로우되면 예산 토큰 2를 회수한다. (페널티는 정상 적용 후)",
|
||||
"flavor": "괴수 사체도 감가상각 대상이다."
|
||||
},
|
||||
{
|
||||
"id": "asset_04",
|
||||
"name": "방탄 커피 머신",
|
||||
"icon": "☕",
|
||||
"description": "요원들의 불만을 약간 억제합니다.",
|
||||
"flavor": "\"아무리 세상이 멸망해도 모닝 커피는 포기 못하지.\""
|
||||
"id": "asset_convert_002",
|
||||
"name": "부산물 매각 계약",
|
||||
"grade": "B",
|
||||
"archetype": "convert",
|
||||
"trigger": { "type": "on_convert", "params": {} },
|
||||
"effect": { "type": "convert_multiplier", "params": { "multiplier": 2 } },
|
||||
"growth": null,
|
||||
"risk": { "param": "risk_tolerance", "amount": 5, "when": "council_end" },
|
||||
"faction_link": "nexus_traders",
|
||||
"description": "모든 환전 효과의 산출량을 2배로 늘린다. 의회가 열릴 때마다 위험 수용 계수가 소폭 상승한다.",
|
||||
"flavor": "이 계약서에 서명한 사람은 아무도 기억하지 못한다."
|
||||
},
|
||||
{
|
||||
"id": "asset_quarantine_001",
|
||||
"name": "임시 격리 컨테이너",
|
||||
"grade": "C",
|
||||
"archetype": "quarantine",
|
||||
"trigger": { "type": "on_swipe_left", "params": {} },
|
||||
"effect": { "type": "quarantine", "params": { "failCondition": "다음 의회까지 대중 공황 1단계 상승" } },
|
||||
"growth": null,
|
||||
"risk": null,
|
||||
"faction_link": "occult_collectors",
|
||||
"description": "좌로 스와이프한 오염 카드를 격리한다. 다음 의회 전까지 처리하지 못하면 대중 공황이 상승한다.",
|
||||
"flavor": "컨테이너 라벨: '절대 열지 말 것 (2차 통보)'."
|
||||
},
|
||||
{
|
||||
"id": "asset_quarantine_002",
|
||||
"name": "심연 보관 조례",
|
||||
"grade": "S",
|
||||
"archetype": "quarantine",
|
||||
"trigger": { "type": "on_quarantine_fail", "params": {} },
|
||||
"effect": { "type": "quarantine_fail_to_purge", "params": { "bonusTag": "surveillance" } },
|
||||
"growth": null,
|
||||
"risk": null,
|
||||
"faction_link": "occult_collectors",
|
||||
"description": "격리가 실패로 판정되면 그 즉시 대상을 영구 소각하고, 감시 태그 1을 획득한다. 격리 실패가 더 이상 페널티가 아니다.",
|
||||
"flavor": "실패란 서류상의 표현일 뿐이다."
|
||||
},
|
||||
{
|
||||
"id": "asset_skip_001",
|
||||
"name": "결재 반려 도장",
|
||||
"grade": "C",
|
||||
"archetype": "skip",
|
||||
"trigger": { "type": "manual_charge", "params": { "chargeEvery": 10 } },
|
||||
"effect": { "type": "negate_penalty", "params": { "category": null } },
|
||||
"growth": null,
|
||||
"risk": null,
|
||||
"faction_link": "field_agents_union",
|
||||
"description": "결재 10건마다 충전된다. 충전 완료 시 수동으로 발동해 다음 오염 카드의 페널티를 1회 무효화한다.",
|
||||
"flavor": "도장이 닳는 속도가 곧 국장의 임기다."
|
||||
},
|
||||
{
|
||||
"id": "asset_skip_002",
|
||||
"name": "책임 소재 불명 처리",
|
||||
"grade": "B",
|
||||
"archetype": "skip",
|
||||
"trigger": { "type": "on_pollution_draw", "params": { "category": "조직저항", "minGrade": null } },
|
||||
"effect": { "type": "negate_penalty", "params": { "category": "조직저항" } },
|
||||
"growth": null,
|
||||
"risk": { "param": "resistance", "amount": 3, "when": "on_trigger" },
|
||||
"faction_link": "field_agents_union",
|
||||
"description": "조직 저항 계열 오염 카드의 페널티를 자동으로 무효화한다. 다만 책임 소재가 사라질 때마다 내부 저항이 소폭 상승한다.",
|
||||
"flavor": "'담당자 부재'라는 담당자가 새로 생겼다."
|
||||
}
|
||||
]
|
||||
|
||||
226
client/src/engine/assetEngine.js
Normal file
226
client/src/engine/assetEngine.js
Normal file
@@ -0,0 +1,226 @@
|
||||
// 자산(엔진) 시스템 — 트리거 → 효과 → (선택) 성장 이벤트 엔진 (그레이박스 v1)
|
||||
// docs/작업지시서/자산시스템_작업지시서.md 기준.
|
||||
//
|
||||
// GameScreen은 매 훅 지점(결재, 오염 드로우, 좌 스와이프, 의회 종료)마다 현재 상태로
|
||||
// world 스냅샷을 만들어 이 모듈의 함수에 넘기고, 반환된 world를 다시 React state로 반영한다.
|
||||
// world = { cards, tokens, tags, params, owned, quarantine, logs, purgeGain }
|
||||
|
||||
import assetDefs from '../data/assets.json';
|
||||
|
||||
const byId = Object.fromEntries(assetDefs.map((a) => [a.id, a]));
|
||||
|
||||
export const ASSET_CATALOG = assetDefs;
|
||||
export const getAssetDef = (id) => byId[id];
|
||||
|
||||
export const ARCHETYPE_META = {
|
||||
purge: { icon: '🔥', label: '소각' },
|
||||
convert: { icon: '💱', label: '환전' },
|
||||
quarantine: { icon: '⏳', label: '격리' },
|
||||
skip: { icon: '🛡️', label: '회피' },
|
||||
};
|
||||
|
||||
const MAX_CHAIN_DEPTH = 10;
|
||||
const GRADE_RANK = { C: 0, B: 1, A: 2, S: 3 };
|
||||
const TOKEN_LABEL = { budget: '예산', personnel: '인력', info: '정보' };
|
||||
const TAG_LABEL = { wealth: '재력', force: '무력', surveillance: '감시' };
|
||||
|
||||
export const createAssetInstance = (defId, turns) => ({
|
||||
defId,
|
||||
stack: 0,
|
||||
charge: 0,
|
||||
lastChargeTurn: turns,
|
||||
acquiredTurn: turns,
|
||||
});
|
||||
|
||||
const log = (world, text) => world.logs.push(text);
|
||||
const hasAsset = (world, defId) => world.owned.some((o) => o.defId === defId);
|
||||
|
||||
const lowestGradeContaminatedIndex = (cards) => {
|
||||
let best = -1;
|
||||
let bestRank = Infinity;
|
||||
cards.forEach((c, i) => {
|
||||
if (!c.contaminated) return;
|
||||
const r = GRADE_RANK[c.grade] ?? 0;
|
||||
if (r < bestRank) { bestRank = r; best = i; }
|
||||
});
|
||||
return best;
|
||||
};
|
||||
|
||||
// 퍼센트 리스크(예: 5%, 3%)는 기존 0~5단계 파라미터 스케일(1단계 = 20%)로 환산한다.
|
||||
// '1단계' 같이 정수 단위로 명시된 페널티(격리 실패 등)는 그대로 정수로 더한다.
|
||||
const applyRisk = (world, risk, sourceName) => {
|
||||
if (!risk) return;
|
||||
const key = risk.param === 'risk_tolerance' ? 'risk' : risk.param;
|
||||
const amount = risk.amount / 20;
|
||||
world.params[key] = (world.params[key] || 0) + amount;
|
||||
log(world, `『${sourceName}』 부작용 — ${key} +${risk.amount}%`);
|
||||
};
|
||||
|
||||
const convertAmount = (world, amount) => {
|
||||
const doubled = world.owned.some((o) => getAssetDef(o.defId)?.effect?.type === 'convert_multiplier');
|
||||
return doubled ? amount * 2 : amount;
|
||||
};
|
||||
|
||||
const doPurge = (world, count, sourceName, depth) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = lowestGradeContaminatedIndex(world.cards);
|
||||
if (idx === -1) return;
|
||||
const [removed] = world.cards.splice(idx, 1);
|
||||
world.purgeGain = (world.purgeGain || 0) + 1;
|
||||
log(world, `『${sourceName}』 발동 — [${removed.grade}급 ${removed.type}] 소각됨`);
|
||||
fireEvent(world, 'on_purge', {}, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 결재 카드 스와이프마다 호출: every_n_approvals 자산 발동 + manual_charge 충전 확인
|
||||
export function onApprovalTick(world, turns) {
|
||||
world.owned.forEach((inst) => {
|
||||
const def = getAssetDef(inst.defId);
|
||||
if (!def) return;
|
||||
|
||||
if (def.trigger.type === 'every_n_approvals') {
|
||||
const n = def.trigger.params.n;
|
||||
if (turns > 0 && turns % n === 0) {
|
||||
doPurge(world, def.effect.params.count, def.name, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (def.trigger.type === 'manual_charge' && inst.charge < 1) {
|
||||
const base = inst.lastChargeTurn ?? inst.acquiredTurn ?? 0;
|
||||
if (turns - base >= def.trigger.params.chargeEvery) {
|
||||
inst.charge = 1;
|
||||
log(world, `『${def.name}』 충전 완료 — 슬롯을 눌러 발동할 수 있습니다.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return world;
|
||||
}
|
||||
|
||||
// 오염 카드가 드로우(결재 대상으로 등장)될 때 호출. world.negateFree === true면 이번 카드 페널티를 자동 무효화.
|
||||
export function onPollutionDraw(world, category) {
|
||||
world.negateFree = false;
|
||||
world.owned.forEach((inst) => {
|
||||
const def = getAssetDef(inst.defId);
|
||||
if (!def || def.trigger.type !== 'on_pollution_draw') return;
|
||||
if (def.trigger.params.category && def.trigger.params.category !== category) return;
|
||||
|
||||
if (def.effect.type === 'convert_to_token') {
|
||||
const amt = convertAmount(world, def.effect.params.amount);
|
||||
const tokenType = def.effect.params.tokenType;
|
||||
world.tokens[tokenType] = (world.tokens[tokenType] || 0) + amt;
|
||||
log(world, `『${def.name}』 발동 — ${TOKEN_LABEL[tokenType]} +${amt}`);
|
||||
} else if (def.effect.type === 'negate_penalty') {
|
||||
world.negateFree = true;
|
||||
log(world, `『${def.name}』 발동 — 이번 오염 카드 페널티 자동 무효화`);
|
||||
applyRisk(world, def.risk, def.name);
|
||||
}
|
||||
});
|
||||
return world;
|
||||
}
|
||||
|
||||
// 좌 스와이프 시 호출(cardIndex는 world.cards 내 위치). 반환된 world.quarantinedCard === true면
|
||||
// 이번 카드는 정상 결과 대신 격리 처리되어 world.cards에서 이미 제거된 상태다.
|
||||
export function onSwipeLeft(world, cardIndex, turns) {
|
||||
world.quarantinedCard = false;
|
||||
const card = world.cards[cardIndex];
|
||||
if (!card || !card.contaminated) return world;
|
||||
|
||||
const inst = world.owned.find((o) => o.defId === 'asset_quarantine_001');
|
||||
if (!inst) return world;
|
||||
const def = getAssetDef('asset_quarantine_001');
|
||||
|
||||
world.cards.splice(cardIndex, 1);
|
||||
world.quarantine.push({ ...card, returnTurn: turns + 5, viaAsset: def.id, failChecked: false });
|
||||
log(world, `『${def.name}』 발동 — [${card.grade}급 ${card.type}] 격리됨 (다음 의회 전까지 미회수 시 실패)`);
|
||||
world.quarantinedCard = true;
|
||||
return world;
|
||||
}
|
||||
|
||||
// 의회 종료 시 호출: 상시 자산 리스크 정산 + 격리 실패 판정(+심연 보관 조례 개입)
|
||||
export function onCouncilEnd(world) {
|
||||
world.owned.forEach((inst) => {
|
||||
const def = getAssetDef(inst.defId);
|
||||
if (def?.risk?.when === 'council_end') applyRisk(world, def.risk, def.name);
|
||||
});
|
||||
|
||||
const pending = world.quarantine.filter((c) => c.viaAsset === 'asset_quarantine_001' && !c.failChecked);
|
||||
pending.forEach((card) => {
|
||||
card.failChecked = true;
|
||||
if (hasAsset(world, 'asset_quarantine_002')) {
|
||||
world.quarantine = world.quarantine.filter((c) => c !== card);
|
||||
const def6 = getAssetDef('asset_quarantine_002');
|
||||
world.tags.surveillance = (world.tags.surveillance || 0) + 1;
|
||||
world.purgeGain = (world.purgeGain || 0) + 1;
|
||||
log(world, `『${def6.name}』 발동 — 격리 실패한 [${card.grade}급 ${card.type}]를 영구 소각하고 감시 태그 획득`);
|
||||
fireEvent(world, 'on_purge', {}, 1);
|
||||
} else {
|
||||
world.params.panic = (world.params.panic || 0) + 1;
|
||||
log(world, `격리 실패 — [${card.grade}급 ${card.type}] 처리 지연으로 대중 공황 1단계 상승`);
|
||||
}
|
||||
});
|
||||
|
||||
return world;
|
||||
}
|
||||
|
||||
// 충전된 manual_charge 자산을 수동 발동. 성공 시 world.negateChargeGranted === true.
|
||||
export function activateManualAsset(world, defId, turns) {
|
||||
world.negateChargeGranted = false;
|
||||
const inst = world.owned.find((o) => o.defId === defId);
|
||||
const def = getAssetDef(defId);
|
||||
if (!inst || !def || inst.charge < 1) return world;
|
||||
|
||||
inst.charge = 0;
|
||||
inst.lastChargeTurn = turns;
|
||||
world.negateChargeGranted = true;
|
||||
log(world, `『${def.name}』 발동 — 다음 오염 카드 페널티 무효화 충전을 사용했습니다.`);
|
||||
return world;
|
||||
}
|
||||
|
||||
// on_purge / on_convert 등 연쇄 이벤트 디스패치. depth는 무한 루프 방지용 상한(10)까지.
|
||||
function fireEvent(world, eventType, payload, depth) {
|
||||
if (depth > MAX_CHAIN_DEPTH) {
|
||||
log(world, '⚠️ 연쇄 발동이 상한(10회)에 도달해 중단되었습니다.');
|
||||
return world;
|
||||
}
|
||||
|
||||
world.owned.forEach((inst) => {
|
||||
const def = getAssetDef(inst.defId);
|
||||
if (!def || def.trigger.type !== eventType) return;
|
||||
|
||||
if (def.growth && def.growth.stackOn === eventType) {
|
||||
inst.stack = (inst.stack || 0) + 1;
|
||||
if (inst.stack % def.growth.per === 0) {
|
||||
log(world, `『${def.name}』 숙련 ${inst.stack}회 누적 — 추가 발동`);
|
||||
if (def.growth.bonus.effect === 'purge_lowest') {
|
||||
doPurge(world, def.growth.bonus.count, def.name, depth);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!def.effect) return;
|
||||
switch (def.effect.type) {
|
||||
case 'gain_token': {
|
||||
const tokenType = def.effect.params.tokenType;
|
||||
const amt = def.effect.params.amount;
|
||||
world.tokens[tokenType] = (world.tokens[tokenType] || 0) + amt;
|
||||
log(world, `『${def.name}』 발동 — ${TOKEN_LABEL[tokenType]} +${amt}`);
|
||||
break;
|
||||
}
|
||||
case 'gain_tag': {
|
||||
const tagType = def.effect.params.tagType;
|
||||
const amt = def.effect.params.amount;
|
||||
world.tags[tagType] = (world.tags[tagType] || 0) + amt;
|
||||
log(world, `『${def.name}』 발동 — ${TAG_LABEL[tagType]} 태그 +${amt}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// convert_multiplier 등 상시 패시브 효과는 소유 여부만으로 별도 계산부(convertAmount)에서 반영한다.
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return world;
|
||||
}
|
||||
|
||||
export { fireEvent };
|
||||
154
client/src/engine/pollution.js
Normal file
154
client/src/engine/pollution.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// 오염 카드 주입 엔진 — 결재 선택(스와이프)과 정례회의 종료 시 덱에 오염 카드를 밀어 넣는다.
|
||||
//
|
||||
// 설계 의도(그레이박스 튜닝):
|
||||
// 좌우 스와이프 외에 능동적 행동이 거의 없는 게임이라, "내 결정이 덱을 더럽힌다"는 압박이
|
||||
// 위기감의 핵심이다. 그래서 위험 파라미터를 올리는 선택은 정례회의를 기다리지 않고
|
||||
// 그 자리에서 덱에 오염 카드를 주입한다.
|
||||
//
|
||||
// SwipeCard의 사전 예고 · GameScreen의 사후 알림 · 실제 주입은 모두 previewPollution()을
|
||||
// 단일 기준으로 쓴다. 예고한 숫자와 실제 주입량이 어긋나면 플레이어가 정보를 신뢰하지 않게 되므로,
|
||||
// 이 함수를 우회해서 오염 카드를 만들지 말 것.
|
||||
|
||||
// 오염 카드 타입 <-> 파라미터 매핑 (파산/오염 등급 시스템)
|
||||
export const CONTAM_TYPE_BY_PARAM = {
|
||||
entropy: '신화',
|
||||
resistance: '조직저항',
|
||||
panic: '사회공황',
|
||||
risk: '이사회압박',
|
||||
};
|
||||
export const CONTAM_TYPES = new Set(Object.values(CONTAM_TYPE_BY_PARAM));
|
||||
|
||||
export const CONTAM_TYPE_ICON = {
|
||||
신화: '🌀',
|
||||
조직저항: '✊',
|
||||
사회공황: '😱',
|
||||
이사회압박: '⚠️',
|
||||
};
|
||||
|
||||
// cards.csv의 위험 스탯 키 -> 파라미터 키 (상단 파라미터 표시 순서와 동일하게 유지)
|
||||
const DANGER_STAT_TO_PARAM = {
|
||||
res: 'resistance',
|
||||
ent: 'entropy',
|
||||
pan: 'panic',
|
||||
rsk: 'risk',
|
||||
};
|
||||
|
||||
/* ── 밸런스 튜닝 상수 (오염 물량 조정은 여기만 고치면 된다) ───────────────── */
|
||||
|
||||
// 위험 파라미터 +1 당 주입되는 오염 카드 장수.
|
||||
// cards.csv의 위험 수치는 대부분 +1, 일부 +2, 드물게 +3이다(한 선택지 최대 합계 +5).
|
||||
export const POLLUTION_PER_DANGER_POINT = 1;
|
||||
|
||||
// 한 번의 결재로 주입되는 최대 장수 — 극단적인 카드 한 장이 덱을 통째로 망가뜨리지 않게 하는 상한.
|
||||
export const MAX_POLLUTION_PER_SWIPE = 3;
|
||||
|
||||
// 정례회의 종료 시 파라미터 단계별 주입량 (기존: 단계와 무관하게 무조건 1장).
|
||||
export const councilPollutionCount = (level) => Math.min(3, Math.ceil(level / 2));
|
||||
|
||||
// 주입된 카드가 실제로 등장하기까지 남겨두는 결재 건수 — 즉시 등장하면 예측 가능해지고,
|
||||
// 너무 멀면 인과가 안 느껴진다.
|
||||
const INJECT_OFFSET_MIN = 2;
|
||||
const INJECT_OFFSET_MAX = 4;
|
||||
|
||||
// 파라미터 단계(1~5)가 높을수록 높은 등급(S/A) 확률이 커지는 가중치 테이블
|
||||
const GRADE_WEIGHTS = {
|
||||
1: { C: 70, B: 22, A: 6, S: 2 },
|
||||
2: { C: 50, B: 28, A: 15, S: 7 },
|
||||
3: { C: 32, B: 30, A: 23, S: 15 },
|
||||
4: { C: 18, B: 24, A: 32, S: 26 },
|
||||
5: { C: 8, B: 17, A: 33, S: 42 },
|
||||
};
|
||||
|
||||
// 자산 부작용(applyRisk)이 파라미터를 소수점으로 올리므로 반드시 정수 단계로 반올림해서 조회한다.
|
||||
export const pickGrade = (level) => {
|
||||
const step = Math.min(5, Math.max(1, Math.round(level || 0)));
|
||||
const weights = GRADE_WEIGHTS[step];
|
||||
const total = Object.values(weights).reduce((a, b) => a + b, 0);
|
||||
let r = Math.random() * total;
|
||||
for (const [grade, w] of Object.entries(weights)) {
|
||||
if (r < w) return grade;
|
||||
r -= w;
|
||||
}
|
||||
return 'C';
|
||||
};
|
||||
|
||||
/**
|
||||
* 이 선택지가 덱에 몇 장의 오염 카드를 주입하는지 미리 계산한다.
|
||||
* 스와이프 전 예고(SwipeCard)와 실제 주입(GameScreen)이 같은 값을 쓰도록 하는 단일 기준.
|
||||
* @returns {{ total: number, entries: Array<{param, type, icon, count}> }}
|
||||
*/
|
||||
export function previewPollution(stats) {
|
||||
if (!stats) return { total: 0, entries: [] };
|
||||
|
||||
const entries = [];
|
||||
Object.entries(DANGER_STAT_TO_PARAM).forEach(([statKey, param]) => {
|
||||
const value = stats[statKey] || 0;
|
||||
if (value <= 0) return;
|
||||
const type = CONTAM_TYPE_BY_PARAM[param];
|
||||
entries.push({ param, type, icon: CONTAM_TYPE_ICON[type], count: value * POLLUTION_PER_DANGER_POINT });
|
||||
});
|
||||
|
||||
// 상한 초과분은 뒤쪽 항목부터 깎는다 (앞 = 저항/엔트로피 쪽을 우선 남긴다)
|
||||
let total = entries.reduce((sum, e) => sum + e.count, 0);
|
||||
for (let i = entries.length - 1; i >= 0 && total > MAX_POLLUTION_PER_SWIPE; i--) {
|
||||
const cut = Math.min(entries[i].count, total - MAX_POLLUTION_PER_SWIPE);
|
||||
entries[i].count -= cut;
|
||||
total -= cut;
|
||||
}
|
||||
|
||||
return { total, entries: entries.filter((e) => e.count > 0) };
|
||||
}
|
||||
|
||||
const makePollutionCard = (type, level, templates) => {
|
||||
const grade = pickGrade(level);
|
||||
// 등급별 초안이 아직 비어 있는 타입이 있을 수 있으므로 같은 타입 전체로 폴백한다.
|
||||
const pool = templates.filter((t) => t.type === type && t.grade === grade);
|
||||
const fallback = pool.length > 0 ? pool : templates.filter((t) => t.type === type);
|
||||
if (fallback.length === 0) return null;
|
||||
const template = fallback[Math.floor(Math.random() * fallback.length)];
|
||||
return { ...template, id: `${template.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` };
|
||||
};
|
||||
|
||||
/**
|
||||
* previewPollution()의 entries를 실제 카드 인스턴스로 만든다.
|
||||
* 등급은 "그 파라미터의 현재 단계"로 뽑으므로, 위험이 높을수록 더 흉악한 오염이 들어온다.
|
||||
*/
|
||||
export function buildPollutionCards(entries, params, templates) {
|
||||
const out = [];
|
||||
entries.forEach(({ param, type, count }) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = makePollutionCard(type, params[param], templates);
|
||||
if (card) out.push(card);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 정례회의 종료 시 주입분 — 파라미터 단계에 비례한 장수로 뽑는다. */
|
||||
export function buildCouncilPollutionCards(params, templates) {
|
||||
const entries = [];
|
||||
Object.entries(CONTAM_TYPE_BY_PARAM).forEach(([param, type]) => {
|
||||
const level = params[param] || 0;
|
||||
if (level < 1) return;
|
||||
entries.push({ param, type, icon: CONTAM_TYPE_ICON[type], count: councilPollutionCount(level) });
|
||||
});
|
||||
return buildPollutionCards(entries, params, templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 결재 위치 기준 2~4장 뒤에 오염 카드를 끼워 넣는다.
|
||||
* 덱은 인덱스를 순환(modulo)하며 도는 풀이므로, currentIdx는 항상 덱 범위 안의 값이어야 한다.
|
||||
*/
|
||||
export function insertIntoDeck(deck, currentIdx, newCards) {
|
||||
const next = [...deck];
|
||||
newCards.forEach((card) => {
|
||||
const span = INJECT_OFFSET_MAX - INJECT_OFFSET_MIN + 1;
|
||||
const offset = INJECT_OFFSET_MIN + Math.floor(Math.random() * span);
|
||||
next.splice(Math.min(next.length, currentIdx + offset), 0, card);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 요약 문자열: "🌀 신화 x2 · 😱 사회공황" */
|
||||
export const describePollution = (entries) =>
|
||||
entries.map((e) => `${e.icon} ${e.type}${e.count > 1 ? ` x${e.count}` : ''}`).join(' · ');
|
||||
Reference in New Issue
Block a user