자산(엔진) 시스템 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:
kyoung5seo
2026-07-23 22:59:30 +09:00
co-authored by Claude Sonnet 5
parent 31558edcb2
commit db1051c08c
14 changed files with 1397 additions and 279 deletions
+211 -160
View File
@@ -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>
)}