Add shared bandit effect hooks
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
|
||||
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
|
||||
// ⚠️ 전투 규칙은 tools/deck/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
|
||||
// (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현)
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -159,6 +159,10 @@ export function simulateCombat(data, rng, stats) {
|
||||
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;
|
||||
@@ -179,6 +183,23 @@ export function simulateCombat(data, rng, stats) {
|
||||
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);
|
||||
@@ -236,6 +257,10 @@ export function simulateCombat(data, rng, stats) {
|
||||
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;
|
||||
}
|
||||
@@ -286,6 +311,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
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)) {
|
||||
@@ -300,9 +328,14 @@ export function simulateCombat(data, rng, stats) {
|
||||
let totalNv = 0;
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
dmg = totalNv;
|
||||
if (c.aoe === true) {
|
||||
let useAoe = c.aoe === true;
|
||||
if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true;
|
||||
if (useAoe === true) {
|
||||
for (const m2 of aliveList()) {
|
||||
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
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');
|
||||
@@ -314,6 +347,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
} 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;
|
||||
@@ -339,7 +375,18 @@ export function simulateCombat(data, rng, stats) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (weakAmount) target.weak += weakAmount;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
if (c.poison) target.poison += c.poison;
|
||||
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;
|
||||
@@ -413,6 +460,10 @@ export function simulateCombat(data, rng, stats) {
|
||||
turns++;
|
||||
turnAttackCardsPlayed = 0;
|
||||
turnDiscardedCards = 0;
|
||||
shivFirstDamageBonusUsed = false;
|
||||
drawDamageThisTurn = 0;
|
||||
drawPoisonThisTurn = 0;
|
||||
shivAoeThisCombat = false;
|
||||
blockGainMultiplier = 1;
|
||||
handCostZeroThisTurn = false;
|
||||
drawDisabledThisTurn = false;
|
||||
@@ -530,7 +581,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
const kept = [];
|
||||
for (const hid of hand) {
|
||||
const hc = cards[hid];
|
||||
if (hc?.retain === true) kept.push(hid);
|
||||
if (hc?.retain === true || (hc?.class === 'shiv' && powerFieldTotal('shivRetain') > 0)) kept.push(hid);
|
||||
else discard.push(hid);
|
||||
}
|
||||
hand = kept;
|
||||
|
||||
@@ -914,3 +914,38 @@ test("simulateCombat: damagePerCardDrawnThisCombat scales murder", () => {
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(stats.Murder.damage > 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: shiv damage bonuses stack and first Shiv bonus applies once per turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 },
|
||||
PhantomBlades: { name: "PhantomBlades", cost: 1, kind: "Power", firstShivDamageBonus: 3 },
|
||||
Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Accuracy", "PhantomBlades", "Shiv"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: shivAoe makes Shivs hit all enemies", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
FanOfKnives: { name: "FanOfKnives", cost: 2, kind: "Skill", addShiv: 2, shivAoe: true },
|
||||
Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 },
|
||||
Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 },
|
||||
Pass: { name: "Pass", cost: 99, kind: "Skill" },
|
||||
},
|
||||
starterDeck: ["Accuracy", "FanOfKnives", "Pass"],
|
||||
monsters: [
|
||||
{ name: "A", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "B", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "C", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user