// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로. // ⚠️ 전투 규칙은 tools/deck/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것. // (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현) import { readFileSync } from 'node:fs'; export const PLAYER_HP = 80; // 데이터 미포함 placeholder (codeblock과 일치) export const ENERGY = 3; export const HAND_SIZE = 5; export const MAX_TURNS = 100; export function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export function shuffle(arr, rng) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function prepareCombatDrawPile(deck, cards) { const rest = []; const innate = []; for (const id of deck) { if (cards[id]?.innate === true) innate.push(id); else rest.push(id); } return rest.concat(innate); } // 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화. // floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1)) // 보상 카드 등급 추첨 (Lua OfferReward 미러) — roll ∈ 1..100, normal 70 / unique 25 / legend 5 export function rarityForRoll(roll) { if (roll > 95) return 'legend'; if (roll > 70) return 'unique'; return 'normal'; } export function calcAttack(base, str, weak, vulnOnTarget) { let dmg = base + str; if (weak > 0) dmg = Math.floor(dmg * 0.75); if (vulnOnTarget > 0) dmg = Math.floor(dmg * 1.5); if (dmg < 0) dmg = 0; return dmg; } // 방어 우선 차감 후 hp 적용 → { hp, block } export function applyDamage(hp, block, amount) { let dmg = amount; if (block > 0) { const absorbed = Math.min(block, dmg); block -= absorbed; dmg -= absorbed; } hp -= dmg; if (hp < 0) hp = 0; return { hp, block }; } export function loadData() { const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8')); const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8')); const ids = enemiesData.simEncounter || [enemiesData.activeEnemy]; const monsters = ids.map((id) => { const e = enemiesData.enemies[id]; if (!e) throw new Error(`simEncounter 적 없음: ${id}`); return { name: e.name, maxHp: e.maxHp, intents: e.intents }; }); // 시뮬 기본 덱은 전사 시작 덱 (클래스별 시뮬은 starterDeck 직접 주입으로 가능) return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters }; } function canPlayCardNow(card, ctx = {}) { if (!card) return false; if (card.playableWhenDrawPileEmpty === true && (ctx.drawPileCount || 0) > 0) return false; return true; } // 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐 // 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님). // 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬. export function chooseAction(hand, cards, energy, ctx = {}) { const entries = hand.map((id, i) => ({ id, i })).filter((x) => { const card = cards[x.id]; if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false; let effectiveCost = card.cost || 0; if (ctx.handCostZeroThisTurn === true) effectiveCost = 0; else if (card.useAllEnergy === true) effectiveCost = 1; else if (card.kind === 'Skill') { if (ctx.nextSkillCostZero === true) effectiveCost = 0; else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0)); } return card.useAllEnergy === true ? true : effectiveCost <= energy; }); const powers = entries.filter((x) => cards[x.id].kind === 'Power'); const attacks = entries.filter((x) => cards[x.id].kind === 'Attack'); const skills = entries.filter((x) => cards[x.id].kind === 'Skill'); const effectiveCost = (card) => { let cost = card.cost || 0; if (ctx.handCostZeroThisTurn === true) cost = 0; else if (card.useAllEnergy === true) cost = 1; else if (card.kind === 'Skill') { if (ctx.nextSkillCostZero === true) cost = 0; else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0)); } return cost; }; const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(cards[x.id]), 1); const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(cards[x.id]), 1); const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0]; if (powers.length) return powers[0].i; if (attacks.length) return bestBy(attacks, dmgEff).i; if (skills.length) return bestBy(skills, blkEff).i; return -1; } // 공격 타겟 선택: 이번 타격으로 처치 가능한 최소 유효체력, 없으면 유효체력 최소. export function chooseTarget(aliveMonsters, plannedDamage) { const eff = (m) => m.hp + m.block; const killable = aliveMonsters.filter((m) => eff(m) <= plannedDamage); const pool = killable.length ? killable : aliveMonsters; return pool.slice().sort((a, b) => eff(a) - eff(b) || pool.indexOf(a) - pool.indexOf(b))[0]; } function bump(s, cost, dmg, blk) { s = s || { plays: 0, energy: 0, damage: 0, block: 0 }; s.plays++; s.energy += cost; s.damage += dmg; s.block += blk; return s; } // 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적. // 반환: { win, turns, playerHpRemaining, draw? } export function simulateCombat(data, rng, stats) { const { cards, starterDeck, monsters } = data; if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP }; let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards); let discard = []; const exhaust = []; let hand = []; let pHp = PLAYER_HP, pBlock = 0; let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0, pIntangible = 0; let blockGainMultiplier = 1; let handCostZeroThisTurn = false; let drawDisabledThisTurn = false; let nextSkillCostZero = false; let nextSkillRepeatCount = 0; let skillCostReductionThisTurn = 0; let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false; let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1; let nextTurnAddCards = []; let turnAttackCardsPlayed = 0, turnDiscardedCards = 0; let shivFirstDamageBonusUsed = false; let drawDamageThisTurn = 0; let drawPoisonThisTurn = 0; let shivAoeThisCombat = false; let cardsDrawnThisCombat = 0; let bonusRewardScreens = 0; let activeKillReward = 0; let energy = 0; const powers = []; const mob = monsters.map((m) => ({ name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0, intents: m.intents, intentIdx: 0, alive: true, })); let turns = 0; function draw(n) { const drawn = []; if (drawDisabledThisTurn === true) return drawn; for (let k = 0; k < n; k++) { if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; } if (drawPile.length === 0) break; const card = drawPile.pop(); drawn.push(card); cardsDrawnThisCombat++; const drawDamage = powerFieldTotal('drawDamage') + drawDamageThisTurn; const drawPoison = powerFieldTotal('drawPoison') + drawPoisonThisTurn; if ((drawDamage > 0 || drawPoison > 0) && mob.some((m) => m.alive)) { for (const m of mob) { if (!m.alive) continue; let dmg = drawDamage; if (m.vuln > 0) dmg = Math.floor(dmg * 1.5); if (m.block > 0) { const absorbed = Math.min(m.block, dmg); m.block -= absorbed; dmg -= absorbed; } if (drawPoison > 0) m.poison += drawPoison; if (dmg > 0) m.hp -= dmg; if (m.hp <= 0) { m.hp = 0; m.alive = false; } } } // 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화) if (hand.length >= 10) { discard.push(card); triggerSly(card); } else hand.push(card); } return drawn; } function addCardsToHand(id, n) { for (let k = 0; k < n; k++) { if (hand.length >= 10) discard.push(id); else hand.push(id); } } function addBlock(base) { let amount = base || 0; if (amount > 0) amount += pDex; if (blockGainMultiplier > 1) amount *= blockGainMultiplier; if (amount < 0) amount = 0; pBlock += amount; return amount; } function discardForTurnStart(n) { const cnt = Math.min(n, hand.length); for (let i = 0; i < cnt; i++) { const idx = hand .map((id, k) => ({ id, k, card: cards[id] })) .sort((a, b) => { const ac = a.card?.cost || 0; const bc = b.card?.cost || 0; if (ac !== bc) return ac - bc; const ad = a.card?.damage || 0; const bd = b.card?.damage || 0; if (ad !== bd) return ad - bd; return a.k - b.k; })[0]?.k; if (idx == null) break; discardHandCard(idx, true); } } function countOtherHandSkills(currentId) { let n = 0; let skippedSelf = false; for (const id of hand) { if (!skippedSelf && id === currentId) { skippedSelf = true; continue; } if (cards[id]?.kind === 'Skill') n++; } return n; } function attackBaseForCard(id, c) { let base = c.damage || 0; const otherHand = Math.max(0, hand.length - 1); if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard; if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn; if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn; if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand; if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat; if (c.class === 'shiv') { if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus'); if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus'); } if (base < 0) base = 0; return base; } function queueNextTurnAddCard(id, n) { if (!id || !n || n <= 0) return; const entry = nextTurnAddCards.find((x) => x.cardId === id); if (entry) entry.amount += n; else nextTurnAddCards.push({ cardId: id, amount: n }); } function queueNextTurnEffects(c) { if (!c) return; if (c.nextTurnBlock) nextTurnBlock += c.nextTurnBlock; if (c.nextTurnDraw) nextTurnDraw += c.nextTurnDraw; if (c.nextTurnKeepBlock === true) nextTurnKeepBlock = true; if (c.nextTurnAttackMultiplier && c.nextTurnAttackMultiplier > 0) nextTurnAttackMultiplier *= c.nextTurnAttackMultiplier; } function queueSelectedReserve(c) { if (!c?.nextTurnSelectHandCard || !c.nextTurnCopies || hand.length === 0) return; const choice = hand .map((id, i) => ({ id, i, card: cards[id] })) .sort((a, b) => { const ak = a.card?.kind === 'Attack' ? 3 : a.card?.kind === 'Skill' ? 2 : 1; const bk = b.card?.kind === 'Attack' ? 3 : b.card?.kind === 'Skill' ? 2 : 1; if (bk !== ak) return bk - ak; const ad = a.card?.damage || 0; const bd = b.card?.damage || 0; if (bd !== ad) return bd - ad; return a.i - b.i; })[0]; if (choice?.id) queueNextTurnAddCard(choice.id, c.nextTurnCopies); } const aliveList = () => mob.filter((m) => m.alive); function powerFieldTotal(field) { let total = 0; for (const pid of powers) { const pc = cards[pid]; if (pc?.[field] != null) total += pc[field]; } return total; } function resolveCardEffects(id, c, costSpent, recordStats = true) { const alive = aliveList(); let dmg = 0; let blockGained = 0; if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier; if (c.nextSkillCostZero === true) nextSkillCostZero = true; if (c.nextSkillRepeatCount && c.nextSkillRepeatCount > 0) nextSkillRepeatCount += c.nextSkillRepeatCount; if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn; if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true; if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true; if (c.drawDamage && c.kind !== 'Power') drawDamageThisTurn += c.drawDamage; if (c.drawPoison && c.kind !== 'Power') drawPoisonThisTurn += c.drawPoison; if (c.shivAoe === true && c.kind !== 'Power') shivAoeThisCombat = true; const xEnergy = costSpent || 0; if (c.kind === 'Attack') { if (alive.length && (c.damage || c.xDamagePerEnergy)) { const baseDamage = c.xDamagePerEnergy ? xEnergy * c.xDamagePerEnergy : attackBaseForCard(id, c); const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast) ? c.bonusHitsWhenOtherHandAtLeast : 0; const hitN = (c.hits || 1) + bonusHits; const preview = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier; const target = chooseTarget(alive, preview); if (c.weak) target.weak += c.weak; if (c.vuln) target.vuln += c.vuln; let totalNv = 0; for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier; dmg = totalNv; let useAoe = c.aoe === true; if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true; if (useAoe === true) { for (const m2 of aliveList()) { let d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv; if (m2.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) { d2 = Math.floor(d2 * c.attackDamageVsWeakMultiplier); } const r2 = applyDamage(m2.hp, m2.block, d2); m2.hp = r2.hp; m2.block = r2.block; const attackPoison = powerFieldTotal('attackPoison'); if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison; if (m2.hp <= 0) { m2.alive = false; if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill; } } } else { dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv; if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) { dmg = Math.floor(dmg * c.attackDamageVsWeakMultiplier); } if (c.pierce === true) { target.hp -= dmg; if (target.hp < 0) target.hp = 0; } else { const r = applyDamage(target.hp, target.block, dmg); target.hp = r.hp; target.block = r.block; } const attackPoison = powerFieldTotal('attackPoison'); if (dmg > 0 && attackPoison > 0) target.poison += attackPoison; if (target.hp <= 0) { target.alive = false; if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill; } } } if (c.block) blockGained = addBlock(c.block); } else if (c.kind === 'Power') { if (recordStats) powers.push(id); } else { if (c.block) blockGained = addBlock(c.block); const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy; if ((weakAmount || c.vuln || c.poison) && alive.length) { const target = chooseTarget(alive, 0); if (weakAmount) target.weak += weakAmount; if (c.vuln) target.vuln += c.vuln; if (c.poison) { const poisonHits = c.poisonHits || 1; for (let i = 0; i < poisonHits; i++) { const target2 = c.poisonRandomTargets === true ? alive[Math.floor(rng() * alive.length)] : target; if (target2) target2.poison += c.poison; } } if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) { shivFirstDamageBonusUsed = true; } } } if (c.strength) pStr += c.strength; if (c.dex) pDex += c.dex; if (c.thorns) pThorns += c.thorns; if (c.selfVuln) pVuln += c.selfVuln; if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP); if (c.gainEnergy) energy += c.gainEnergy; activeKillReward = c.rewardOnKill || 0; if (c.intangible) pIntangible += c.intangible; queueNextTurnEffects(c); let drawnCards = []; if (c.draw) drawnCards = drawnCards.concat(draw(c.draw)); if (c.drawUntilHandSize) { const need = c.drawUntilHandSize - Math.max(0, hand.length - 1); if (need > 0) drawnCards = drawnCards.concat(draw(need)); } if (c.drawSkillBlock && c.drawSkillBlock > 0) { for (const drawnId of drawnCards) { if (cards[drawnId]?.kind === 'Skill') blockGained += addBlock(c.drawSkillBlock); } } if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv); if (c.cardPlayedDamage && alive.length) { const target = chooseTarget(aliveList(), 0); if (target && target.alive) { target.hp -= c.cardPlayedDamage; dmg += c.cardPlayedDamage; if (target.hp <= 0) target.alive = false; } } if (c.cardPlayedRandomDamage && alive.length) { const pool = aliveList(); if (pool.length) { const target = pool[Math.floor(rng() * pool.length)]; if (target) { target.hp -= c.cardPlayedRandomDamage; dmg += c.cardPlayedRandomDamage; if (target.hp <= 0) target.alive = false; } } } if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained); } function triggerSly(id) { const c = cards[id]; if (!c?.sly) return; resolveCardEffects(id, c, 0, false); } function discardHandCard(idx, trigger = true) { const [id] = hand.splice(idx, 1); if (!id) return; discard.push(id); turnDiscardedCards++; if (trigger) triggerSly(id); } function applyDiscardEffects(c) { let discarded = 0; if (c.discardAll) { while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; } } else if (c.discard) { const n = Math.min(c.discard, hand.length); for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; } } if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv); if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded); if (c.drawPerDiscarded) draw(discarded * c.drawPerDiscarded); } while (turns < MAX_TURNS) { turns++; turnAttackCardsPlayed = 0; turnDiscardedCards = 0; shivFirstDamageBonusUsed = false; drawDamageThisTurn = 0; drawPoisonThisTurn = 0; shivAoeThisCombat = false; blockGainMultiplier = 1; handCostZeroThisTurn = false; drawDisabledThisTurn = false; skillCostReductionThisTurn = 0; // 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워) if (nextTurnKeepBlock === true) nextTurnKeepBlock = false; else pBlock = 0; turnAttackMultiplier = nextTurnAttackMultiplier; nextTurnAttackMultiplier = 1; let energyBonus = 0; let powerTurnDraw = 0; let powerTurnDiscard = 0; for (const pid of powers) { const pc = cards[pid]; if (!pc) continue; if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value; else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value; else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value; else if (pc.powerEffect === 'poisonPerTurn') { for (const m of mob) if (m.alive) m.poison += pc.value; } else if (pc.powerEffect === 'damagePerTurn') { for (const m of mob) { if (!m.alive) continue; const r = applyDamage(m.hp, m.block, pc.value || 0); m.hp = r.hp; m.block = r.block; if (m.hp <= 0) m.alive = false; } } if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv); if (pc.turnStartDraw) powerTurnDraw += pc.turnStartDraw; if (pc.turnStartDiscard) powerTurnDiscard += pc.turnStartDiscard; } if (nextTurnBlock > 0) { addBlock(nextTurnBlock); nextTurnBlock = 0; } if (nextTurnAddCards.length) { for (const entry of nextTurnAddCards) addCardsToHand(entry.cardId, entry.amount); nextTurnAddCards = []; } energy = ENERGY + energyBonus; const drawBonus = nextTurnDraw + powerTurnDraw; nextTurnDraw = 0; draw(HAND_SIZE + drawBonus); if (powerTurnDiscard > 0) discardForTurnStart(powerTurnDiscard); while (true) { const alive = aliveList(); if (alive.length === 0) break; const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn }); if (idx < 0) break; const id = hand[idx], c = cards[id]; const skillFree = c.kind === 'Skill' && nextSkillCostZero === true; const skillRepeat = c.kind === 'Skill' ? nextSkillRepeatCount : 0; const baseCost = c.cost || 0; const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost))); energy -= cost; resolveCardEffects(id, c, cost); const playedBlock = powerFieldTotal('cardPlayedBlock'); if (playedBlock > 0) addBlock(playedBlock); if (c.cardPlayedDamage && alive.length) { const target = chooseTarget(aliveList(), 0); if (target && target.alive) { target.hp -= c.cardPlayedDamage; dmg += c.cardPlayedDamage; if (target.hp <= 0) target.alive = false; } } if (c.cardPlayedRandomDamage && alive.length) { const pool = aliveList(); if (pool.length) { const target = pool[Math.floor(rng() * pool.length)]; if (target) { target.hp -= c.cardPlayedRandomDamage; dmg += c.cardPlayedRandomDamage; if (target.hp <= 0) target.alive = false; } } } if (skillRepeat > 0) { nextSkillRepeatCount = Math.max(0, nextSkillRepeatCount - skillRepeat); for (let r = 0; r < skillRepeat; r++) { resolveCardEffects(id, c, cost); if (playedBlock > 0) addBlock(playedBlock); if (c.cardPlayedDamage && alive.length) { const target = chooseTarget(aliveList(), 0); if (target && target.alive) { target.hp -= c.cardPlayedDamage; dmg += c.cardPlayedDamage; if (target.hp <= 0) target.alive = false; } } if (c.cardPlayedRandomDamage && alive.length) { const pool = aliveList(); if (pool.length) { const target = pool[Math.floor(rng() * pool.length)]; if (target) { target.hp -= c.cardPlayedRandomDamage; dmg += c.cardPlayedRandomDamage; if (target.hp <= 0) target.alive = false; } } } } } if (c.kind === 'Attack') turnAttackCardsPlayed++; if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false; hand.splice(idx, 1); queueSelectedReserve(c); if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id); else if (c.kind !== 'Power') discard.push(id); applyDiscardEffects(c); if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens }; } // 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화) let burn = 0; for (const hid of hand) { const hc = cards[hid]; if (hc && hc.endTurnDamage) burn += hc.endTurnDamage; } if (burn > 0) { pHp -= burn; if (pHp < 0) pHp = 0; } const kept = []; for (const hid of hand) { const hc = cards[hid]; if (hc?.retain === true || (hc?.class === 'shiv' && powerFieldTotal('shivRetain') > 0)) kept.push(hid); else discard.push(hid); } hand = kept; for (const pid of powers) { const pc = cards[pid]; if (pc?.endTurnDexLoss) { pDex -= pc.endTurnDexLoss; if (pDex < 0) pDex = 0; } } if (pIntangible > 0) pIntangible--; if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; // 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전) if (pWeak > 0) pWeak--; if (pVuln > 0) pVuln--; for (const m of mob) { if (!m.alive) continue; // 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략 if (m.poison > 0) { m.hp -= m.poison; m.poison--; if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; } } m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월) // 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤) const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null; if (it) { if (it.kind === 'Attack') { const atk = calcAttack(it.value, m.str, m.weak, pVuln); const beforeHp = pHp; let incoming = atk; if (pIntangible > 0 && incoming > 1) incoming = 1; const r = applyDamage(pHp, pBlock, incoming); pHp = r.hp; pBlock = r.block; if (beforeHp > pHp && pThorns > 0) { m.hp -= pThorns; if (m.hp <= 0) m.alive = false; } } else if (it.kind === 'Defend') { m.block += it.value; } else if (it.kind === 'Debuff') { if (it.effect === 'weak') pWeak += it.value; else if (it.effect === 'vuln') pVuln += it.value; } else if (it.kind === 'AddCard') { // StS2식 덱 오염 — 저주 카드를 버린 더미에 추가 (Lua 동기화) const cnt = it.count || 1; for (let k = 0; k < cnt; k++) discard.push(it.card); } } // 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후) if (m.weak > 0) m.weak--; if (m.vuln > 0) m.vuln--; if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; } // 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화) if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens }; } return { win: false, turns, playerHpRemaining: pHp, draw: true, bonusRewardScreens }; } function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; } function median(a) { if (!a.length) return 0; const s = a.slice().sort((x, y) => x - y), m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; } export function runBatch(N, seed) { const data = loadData(); const rng = mulberry32(seed); const cardStats = {}; let wins = 0, draws = 0; const turnsArr = [], hpArr = []; for (let i = 0; i < N; i++) { const r = simulateCombat(data, rng, cardStats); if (r.draw) draws++; if (r.win) { wins++; hpArr.push(r.playerHpRemaining); } turnsArr.push(r.turns); } return { N, wins, draws, losses: N - wins - draws, winRate: wins / N, avgTurns: mean(turnsArr), medianTurns: median(turnsArr), avgHpOnWin: mean(hpArr), cardStats, cards: data.cards, monsters: data.monsters, seed, }; } export function formatReport(r) { const L = []; L.push(`=== 밸런스 시뮬레이션 (인카운터: ${r.monsters.map((m) => `${m.name}(${m.maxHp})`).join(', ')}) ===`); L.push(`시뮬 ${r.N}회 (seed=${r.seed})`); L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`); L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`); L.push(`승리 시 평균 잔여 HP: ${r.avgHpOnWin.toFixed(1)} / ${PLAYER_HP}`); if (r.draws) L.push(`⚠️ 무승부 ${r.draws}건 (턴 상한 ${MAX_TURNS} 초과)`); L.push(''); L.push('카드별:'); const rows = Object.entries(r.cardStats).map(([id, s]) => { const kind = r.cards[id].kind; const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy; return { id, name: r.cards[id].name, kind, plays: s.plays, eff }; }); for (const kind of ['Attack', 'Skill', 'Power']) { const kr = rows.filter((x) => x.kind === kind); if (!kr.length) continue; const med = median(kr.map((x) => x.eff)); const unit = kind === 'Attack' ? '뎀/E' : kind === 'Power' ? '(지속)' : '블록/E'; for (const x of kr) { const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : ''; L.push(` ${x.name}(${x.id}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`); } } const sorted = rows.slice().sort((a, b) => b.plays - a.plays); if (sorted.length) L.push(`최다 사용: ${sorted[0].name} / 최소 사용: ${sorted[sorted.length - 1].name}`); return L.join('\n'); } function main() { const args = process.argv.slice(2); let N = 2000, seed = 1; for (let i = 0; i < args.length; i++) { if (args[i] === '--seed') seed = parseInt(args[++i], 10); else if (/^\d+$/.test(args[i])) N = parseInt(args[i], 10); } console.log(formatReport(runBatch(N, seed))); } if (process.argv[1] && process.argv[1].endsWith('sim-balance.mjs')) main();