// 자산(엔진) 시스템 — 트리거 → 효과 → (선택) 성장 이벤트 엔진 (그레이박스 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 };