밴딧 공용 효과와 문서 정리
This commit is contained in:
@@ -27,6 +27,16 @@ export function shuffle(arr, rng) {
|
||||
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
|
||||
@@ -70,11 +80,17 @@ export function loadData() {
|
||||
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) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
|
||||
export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable && canPlayCardNow(cards[x.id], ctx));
|
||||
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');
|
||||
@@ -106,12 +122,17 @@ function bump(s, cost, dmg, blk) {
|
||||
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 = shuffle(starterDeck, rng);
|
||||
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;
|
||||
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
|
||||
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
|
||||
let nextTurnAddCards = [];
|
||||
let turnAttackCardsPlayed = 0, turnDiscardedCards = 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,
|
||||
@@ -137,19 +158,103 @@ export function simulateCombat(data, rng, stats) {
|
||||
else hand.push(id);
|
||||
}
|
||||
}
|
||||
function addBlock(base) {
|
||||
let amount = base || 0;
|
||||
if (amount > 0) amount += pDex;
|
||||
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 (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.kind === 'Attack') {
|
||||
if (alive.length && c.damage) {
|
||||
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
|
||||
const baseDamage = 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;
|
||||
const hitN = c.hits || 1;
|
||||
let totalNv = 0;
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
dmg = totalNv;
|
||||
if (c.aoe === true) {
|
||||
for (const m2 of aliveList()) {
|
||||
@@ -170,11 +275,11 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
} else if (c.kind === 'Power') {
|
||||
if (recordStats) powers.push(id);
|
||||
} else {
|
||||
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
if ((c.weak || c.vuln || c.poison) && alive.length) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
@@ -187,7 +292,13 @@ export function simulateCombat(data, rng, stats) {
|
||||
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;
|
||||
queueNextTurnEffects(c);
|
||||
if (c.draw) draw(c.draw);
|
||||
if (c.drawUntilHandSize) {
|
||||
const need = c.drawUntilHandSize - Math.max(0, hand.length - 1);
|
||||
if (need > 0) draw(need);
|
||||
}
|
||||
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||
}
|
||||
@@ -200,6 +311,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
const [id] = hand.splice(idx, 1);
|
||||
if (!id) return;
|
||||
discard.push(id);
|
||||
turnDiscardedCards++;
|
||||
if (trigger) triggerSly(id);
|
||||
}
|
||||
function applyDiscardEffects(c) {
|
||||
@@ -212,13 +324,21 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
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;
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
||||
pBlock = 0;
|
||||
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;
|
||||
@@ -226,17 +346,32 @@ export function simulateCombat(data, rng, stats) {
|
||||
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
||||
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
||||
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
|
||||
if (pc.turnStartDraw) powerTurnDraw += pc.turnStartDraw;
|
||||
if (pc.turnStartDiscard) powerTurnDiscard += pc.turnStartDiscard;
|
||||
}
|
||||
let energy = ENERGY + energyBonus; draw(HAND_SIZE);
|
||||
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);
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length });
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
energy -= c.cost;
|
||||
resolveCardEffects(id, c, c.cost);
|
||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user