feat: 도적 공용 효과 정리
This commit is contained in:
@@ -54,6 +54,10 @@ export function calcAttack(base, str, weak, vulnOnTarget) {
|
||||
return dmg;
|
||||
}
|
||||
|
||||
export function calcEnemyAttack(base, str, weak, vulnOnTarget, strengthLoss = 0) {
|
||||
return calcAttack(base, Math.max(0, str - strengthLoss), weak, vulnOnTarget);
|
||||
}
|
||||
|
||||
// 방어 우선 차감 후 hp 적용 → { hp, block }
|
||||
export function applyDamage(hp, block, amount) {
|
||||
let dmg = amount;
|
||||
@@ -100,12 +104,16 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
||||
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
|
||||
effectiveCost = Math.max(0, effectiveCost - ctx.combatCardCostReduction[x.id]);
|
||||
}
|
||||
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) => {
|
||||
const effectiveCost = (x) => {
|
||||
const card = cards[x.id];
|
||||
let cost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
||||
else if (card.useAllEnergy === true) cost = 1;
|
||||
@@ -113,10 +121,13 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
if (ctx.nextSkillCostZero === true) cost = 0;
|
||||
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
|
||||
cost = Math.max(0, cost - ctx.combatCardCostReduction[x.id]);
|
||||
}
|
||||
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 dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(x), 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;
|
||||
@@ -155,25 +166,96 @@ export function simulateCombat(data, rng, stats) {
|
||||
let nextSkillCostZero = false;
|
||||
let nextSkillRepeatCount = 0;
|
||||
let skillCostReductionThisTurn = 0;
|
||||
const combatCardCostReduction = {};
|
||||
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
|
||||
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
|
||||
let nextTurnAddCards = [];
|
||||
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
|
||||
let turnCardsPlayedThisTurn = 0;
|
||||
let damageDealtThisTurn = 0;
|
||||
let shivFirstDamageBonusUsed = false;
|
||||
let drawDamageThisTurn = 0;
|
||||
let drawPoisonThisTurn = 0;
|
||||
let shivAoeThisCombat = false;
|
||||
const skillSlyOnPlayCards = new Set();
|
||||
const turnSkillSlyCards = new Set();
|
||||
let poisonApplicationsThisCombat = 0;
|
||||
let enemyStrengthLossThisTurn = 0;
|
||||
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,
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: m.str || 0, weak: 0, vuln: 0, poison: 0, artifact: m.artifact || 0,
|
||||
intents: m.intents, intentIdx: 0, alive: true,
|
||||
}));
|
||||
let turns = 0;
|
||||
|
||||
const aliveMonsters = () => mob.filter((m) => m.alive);
|
||||
const countAliveMonsters = () => aliveMonsters().length;
|
||||
const randomAliveMonster = () => {
|
||||
const alive = aliveMonsters();
|
||||
if (!alive.length) return null;
|
||||
return alive[Math.floor(rng() * alive.length)];
|
||||
};
|
||||
const removeEnemyBlock = (target) => {
|
||||
if (target) target.block = 0;
|
||||
};
|
||||
const removeEnemyArtifact = (target) => {
|
||||
if (target) target.artifact = 0;
|
||||
};
|
||||
const applyMonsterWeak = (target, amount) => {
|
||||
if (!target || !amount || amount <= 0) return;
|
||||
if (target.artifact > 0) { target.artifact--; return; }
|
||||
target.weak += amount;
|
||||
};
|
||||
const applyMonsterVuln = (target, amount) => {
|
||||
if (!target || !amount || amount <= 0) return;
|
||||
if (target.artifact > 0) { target.artifact--; return; }
|
||||
target.vuln += amount;
|
||||
};
|
||||
const applyPoisonToMonster = (target, amount) => {
|
||||
if (!target || !target.alive || !amount || amount <= 0) return;
|
||||
if (target.artifact > 0) { target.artifact--; return; }
|
||||
target.poison += amount;
|
||||
poisonApplicationsThisCombat += 1;
|
||||
const burstEvery = powerFieldTotal('poisonApplicationBurstEvery');
|
||||
const burstDamage = powerFieldTotal('poisonApplicationBurstDamage');
|
||||
if (burstEvery > 0 && burstDamage > 0 && poisonApplicationsThisCombat % burstEvery === 0) {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
const r = applyDamage(m.hp, m.block, burstDamage);
|
||||
m.hp = r.hp; m.block = r.block;
|
||||
if (burstDamage > 0) damageDealtThisTurn += burstDamage;
|
||||
if (m.hp <= 0) m.alive = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const dealDamageToMonster = (target, amount, pierce = false) => {
|
||||
if (!target || !target.alive) return false;
|
||||
let dmg = amount;
|
||||
const effectiveStr = Math.max(0, target.str - enemyStrengthLossThisTurn);
|
||||
dmg = calcAttack(dmg, effectiveStr, target.weak, 0);
|
||||
if (target.vuln > 0) dmg = Math.floor(dmg * 1.5);
|
||||
if (target.block > 0 && !pierce) {
|
||||
const absorbed = Math.min(target.block, dmg);
|
||||
target.block -= absorbed;
|
||||
dmg -= absorbed;
|
||||
}
|
||||
target.hp -= dmg;
|
||||
if (dmg > 0) {
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (attackPoison > 0) applyPoisonToMonster(target, attackPoison);
|
||||
}
|
||||
if (target.hp <= 0) {
|
||||
target.hp = 0;
|
||||
target.alive = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function draw(n) {
|
||||
const drawn = [];
|
||||
if (drawDisabledThisTurn === true) return drawn;
|
||||
@@ -195,8 +277,11 @@ export function simulateCombat(data, rng, stats) {
|
||||
m.block -= absorbed;
|
||||
dmg -= absorbed;
|
||||
}
|
||||
if (drawPoison > 0) m.poison += drawPoison;
|
||||
if (dmg > 0) m.hp -= dmg;
|
||||
if (drawPoison > 0) applyPoisonToMonster(m, drawPoison);
|
||||
if (dmg > 0) {
|
||||
m.hp -= dmg;
|
||||
damageDealtThisTurn += dmg;
|
||||
}
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; }
|
||||
}
|
||||
}
|
||||
@@ -257,6 +342,7 @@ 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 === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus;
|
||||
if (c.class === 'shiv') {
|
||||
if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus');
|
||||
if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus');
|
||||
@@ -314,6 +400,19 @@ export function simulateCombat(data, rng, stats) {
|
||||
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;
|
||||
if (c.skillSlyOnPlay === true && c.kind === 'Skill') skillSlyOnPlayCards.add(id);
|
||||
if (c.turnHandSlyCount && c.turnHandSlyCount > 0) {
|
||||
let picked = 0;
|
||||
for (const hid of hand) {
|
||||
if (hid === id) continue;
|
||||
const hc = cards[hid];
|
||||
if (hc?.kind === 'Skill' && !turnSkillSlyCards.has(hid) && !skillSlyOnPlayCards.has(hid) && hc.sly !== true) {
|
||||
turnSkillSlyCards.add(hid);
|
||||
picked++;
|
||||
if (picked >= c.turnHandSlyCount) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const xEnergy = costSpent || 0;
|
||||
if (c.kind === 'Attack') {
|
||||
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
|
||||
@@ -321,49 +420,70 @@ export function simulateCombat(data, rng, stats) {
|
||||
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;
|
||||
const perHit = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
const dealToTarget = (target, amount) => {
|
||||
if (!target || !target.alive) return { killed: false, dealt: 0 };
|
||||
let dealt = amount;
|
||||
if (target.vuln > 0) dealt = Math.floor(dealt * 1.5);
|
||||
if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) {
|
||||
dmg = Math.floor(dmg * c.attackDamageVsWeakMultiplier);
|
||||
dealt = Math.floor(dealt * c.attackDamageVsWeakMultiplier);
|
||||
}
|
||||
if (c.pierce === true) {
|
||||
target.hp -= dmg;
|
||||
target.hp -= dealt;
|
||||
if (target.hp < 0) target.hp = 0;
|
||||
} else {
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
const r = applyDamage(target.hp, target.block, dealt);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
}
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (dmg > 0 && attackPoison > 0) target.poison += attackPoison;
|
||||
if (dealt > 0 && attackPoison > 0) applyPoisonToMonster(target, attackPoison);
|
||||
let killed = false;
|
||||
if (target.hp <= 0) {
|
||||
target.alive = false;
|
||||
killed = true;
|
||||
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
|
||||
}
|
||||
}
|
||||
return { killed, dealt };
|
||||
};
|
||||
const resolveAttackRound = () => {
|
||||
let roundKilled = false;
|
||||
let roundDamage = 0;
|
||||
if (useAoe === true) {
|
||||
for (const m2 of aliveList()) {
|
||||
const r2 = dealToTarget(m2, perHit);
|
||||
roundDamage += r2.dealt;
|
||||
if (r2.killed) roundKilled = true;
|
||||
}
|
||||
} else if (c.randomTargetEachHit === true) {
|
||||
for (let h = 0; h < hitN; h++) {
|
||||
const target = randomAliveMonster();
|
||||
if (!target) break;
|
||||
const r = dealToTarget(target, perHit);
|
||||
roundDamage += r.dealt;
|
||||
if (r.killed) roundKilled = true;
|
||||
}
|
||||
} else {
|
||||
const preview = perHit;
|
||||
const target = chooseTarget(aliveList(), preview);
|
||||
if (target) {
|
||||
if (c.weak) applyMonsterWeak(target, c.weak);
|
||||
if (c.vuln) applyMonsterVuln(target, c.vuln);
|
||||
const totalNv = perHit * hitN;
|
||||
const r = dealToTarget(target, totalNv);
|
||||
roundDamage += r.dealt;
|
||||
if (r.killed) roundKilled = true;
|
||||
}
|
||||
}
|
||||
dmg += roundDamage;
|
||||
damageDealtThisTurn += roundDamage;
|
||||
return roundKilled;
|
||||
};
|
||||
let roundKilled = false;
|
||||
do {
|
||||
roundKilled = resolveAttackRound();
|
||||
} while (c.repeatOnKill === true && roundKilled === true && countAliveMonsters() > 0);
|
||||
}
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
} else if (c.kind === 'Power') {
|
||||
@@ -371,19 +491,30 @@ export function simulateCombat(data, rng, stats) {
|
||||
} 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;
|
||||
const vulnAmount = c.vuln || 0;
|
||||
if ((weakAmount || vulnAmount || c.poison || c.removeEnemyBlock || c.removeEnemyArtifact || c.enemyStrengthLossThisTurn) && alive.length) {
|
||||
const targets = c.affectsAllEnemies === true ? aliveList() : [chooseTarget(alive, 0)];
|
||||
if (c.enemyStrengthLossThisTurn && c.enemyStrengthLossThisTurn > 0) {
|
||||
enemyStrengthLossThisTurn += c.enemyStrengthLossThisTurn;
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (!target || !target.alive) continue;
|
||||
if (c.removeEnemyBlock === true) removeEnemyBlock(target);
|
||||
if (c.removeEnemyArtifact === true) removeEnemyArtifact(target);
|
||||
if (weakAmount) applyMonsterWeak(target, weakAmount);
|
||||
if (vulnAmount) applyMonsterVuln(target, vulnAmount);
|
||||
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.poisonIfTargetPoisoned !== true || target.poison > 0) {
|
||||
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) applyPoisonToMonster(target2, c.poison);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
|
||||
shivFirstDamageBonusUsed = true;
|
||||
}
|
||||
@@ -398,6 +529,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
activeKillReward = c.rewardOnKill || 0;
|
||||
if (c.intangible) pIntangible += c.intangible;
|
||||
queueNextTurnEffects(c);
|
||||
turnCardsPlayedThisTurn++;
|
||||
let drawnCards = [];
|
||||
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
|
||||
if (c.drawUntilHandSize) {
|
||||
@@ -415,6 +547,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
dmg += c.cardPlayedDamage;
|
||||
damageDealtThisTurn += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
@@ -425,15 +558,20 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
dmg += c.cardPlayedRandomDamage;
|
||||
damageDealtThisTurn += c.cardPlayedRandomDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') {
|
||||
blockGained += Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn);
|
||||
}
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||
}
|
||||
function triggerSly(id) {
|
||||
const c = cards[id];
|
||||
if (!c?.sly) return;
|
||||
if (!c) return;
|
||||
if (!c.sly && !skillSlyOnPlayCards.has(id) && !turnSkillSlyCards.has(id)) return;
|
||||
resolveCardEffects(id, c, 0, false);
|
||||
}
|
||||
function discardHandCard(idx, trigger = true) {
|
||||
@@ -464,6 +602,8 @@ export function simulateCombat(data, rng, stats) {
|
||||
drawDamageThisTurn = 0;
|
||||
drawPoisonThisTurn = 0;
|
||||
shivAoeThisCombat = false;
|
||||
turnSkillSlyCards.clear();
|
||||
enemyStrengthLossThisTurn = 0;
|
||||
blockGainMultiplier = 1;
|
||||
handCostZeroThisTurn = false;
|
||||
drawDisabledThisTurn = false;
|
||||
@@ -483,7 +623,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
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;
|
||||
for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value);
|
||||
} else if (pc.powerEffect === 'damagePerTurn') {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
@@ -509,60 +649,25 @@ export function simulateCombat(data, rng, stats) {
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn });
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn, combatCardCostReduction });
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
let dmg = 0;
|
||||
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
|
||||
const skillRepeat = c.kind === 'Skill' ? nextSkillRepeatCount : 0;
|
||||
const baseCost = c.cost || 0;
|
||||
const combatReduction = combatCardCostReduction[id] || 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 finalCost = Math.max(0, cost - combatReduction);
|
||||
energy -= finalCost;
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
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);
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
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++;
|
||||
@@ -571,6 +676,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
queueSelectedReserve(c);
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
||||
else if (c.kind !== 'Power') discard.push(id);
|
||||
if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) {
|
||||
combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay;
|
||||
}
|
||||
applyDiscardEffects(c);
|
||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens };
|
||||
}
|
||||
@@ -600,17 +708,20 @@ export function simulateCombat(data, rng, stats) {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
|
||||
if (m.poison > 0) {
|
||||
const poisonTicks = 1 + Math.max(0, powerFieldTotal('extraPoisonTicks'));
|
||||
for (let tick = 0; tick < poisonTicks; tick++) {
|
||||
if (m.poison <= 0) break;
|
||||
m.hp -= m.poison;
|
||||
m.poison--;
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; break; }
|
||||
}
|
||||
if (!m.alive) 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 atk = calcAttack(it.value, Math.max(0, m.str - enemyStrengthLossThisTurn), m.weak, pVuln);
|
||||
const beforeHp = pHp;
|
||||
let incoming = atk;
|
||||
if (pIntangible > 0 && incoming > 1) incoming = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll,
|
||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, calcEnemyAttack, rarityForRoll,
|
||||
} from './sim-balance.mjs';
|
||||
|
||||
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
|
||||
@@ -758,6 +758,14 @@ test("chooseAction: useAllEnergy cards remain playable at zero energy", () => {
|
||||
assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0);
|
||||
});
|
||||
|
||||
test("chooseAction: combatCardCostReduction discounts the same card across combat", () => {
|
||||
const cards = {
|
||||
Sleeve: { name: "UpMySleeve", cost: 2, kind: "Skill" },
|
||||
};
|
||||
assert.equal(chooseAction(["Sleeve"], cards, 1, { combatCardCostReduction: { Sleeve: 1 } }), 0);
|
||||
assert.equal(chooseAction(["Sleeve"], cards, 1, {}), -1);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
@@ -821,10 +829,181 @@ test("simulateCombat: attackPoison power applies poison on attack damage", () =>
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedDamage hits the target whenever a card is played", () => {
|
||||
test("simulateCombat: skillSlyOnPlay makes later discards of the same skill trigger sly effects", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true },
|
||||
},
|
||||
starterDeck: ["MasterPlanner", "MasterPlanner"],
|
||||
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const withSly = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true, skillSlyOnPlay: true },
|
||||
},
|
||||
}, () => 0.999999);
|
||||
const withoutSly = simulateCombat(shared, () => 0.999999);
|
||||
assert.equal(withSly.win, true);
|
||||
assert.equal(withSly.turns, 1);
|
||||
assert.ok(withoutSly.turns > withSly.turns);
|
||||
});
|
||||
|
||||
test("simulateCombat: randomTargetEachHit can spread hits across alive enemies", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4, randomTargetEachHit: true },
|
||||
},
|
||||
starterDeck: ["Ricochet"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const makeRng = () => {
|
||||
const seq = [0, 0.999999, 0, 0.999999];
|
||||
let i = 0;
|
||||
return () => seq[i++ % seq.length];
|
||||
};
|
||||
const withRicochet = simulateCombat(shared, makeRng());
|
||||
const withoutRicochet = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4 },
|
||||
},
|
||||
}, makeRng());
|
||||
assert.equal(withRicochet.win, true);
|
||||
assert.equal(withRicochet.turns, 1);
|
||||
assert.equal(withoutRicochet.turns, 2);
|
||||
});
|
||||
|
||||
test("calcEnemyAttack: enemyStrengthLossThisTurn reduces enemy attack damage", () => {
|
||||
assert.equal(calcEnemyAttack(10, 6, 0, 0, 6), 10);
|
||||
assert.equal(calcEnemyAttack(10, 6, 0, 0, 0), 16);
|
||||
});
|
||||
|
||||
test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10, repeatOnKill: true },
|
||||
},
|
||||
starterDeck: ["EchoingSlash"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 20, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const withRepeat = simulateCombat(shared, () => 0.999999);
|
||||
const withoutRepeat = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10 },
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withRepeat.win, true);
|
||||
assert.equal(withRepeat.turns, 1);
|
||||
assert.equal(withoutRepeat.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: poisonIfTargetPoisoned only applies poison to already poisoned enemies", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9, poisonIfTargetPoisoned: true },
|
||||
},
|
||||
starterDeck: ["Bubble"],
|
||||
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const withBubble = simulateCombat(shared, () => 0.999999);
|
||||
const withoutBubble = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9 },
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withBubble.draw, true);
|
||||
assert.equal(withBubble.turns, 100);
|
||||
assert.equal(withoutBubble.win, true);
|
||||
assert.equal(withoutBubble.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: turnHandSlyCount marks a skill in hand as sly for the turn", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7, turnHandSlyCount: 1 },
|
||||
Shield: { name: "Shield", cost: 0, kind: "Skill", unplayable: true, block: 7 },
|
||||
Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true },
|
||||
},
|
||||
starterDeck: ["Gamble", "Shield", "HandTrick"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 10 }] }],
|
||||
};
|
||||
const withHandTrick = simulateCombat(shared, () => 0.999999);
|
||||
const withoutHandTrick = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7 },
|
||||
Shield: shared.cards.Shield,
|
||||
Gamble: shared.cards.Gamble,
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withHandTrick.playerHpRemaining, 80);
|
||||
assert.equal(withoutHandTrick.playerHpRemaining, 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: extraPoisonTicks adds an extra poison tick at enemy turn start", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Accelerant: { name: "Accelerant", cost: 1, kind: "Power", extraPoisonTicks: 1 },
|
||||
Poison: { name: "Poison", cost: 1, kind: "Skill", poison: 2 },
|
||||
},
|
||||
starterDeck: ["Accelerant", "Poison"],
|
||||
monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const withTick = simulateCombat(shared, () => 0.999999);
|
||||
const withoutTick = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Accelerant: { name: "Accelerant", cost: 1, kind: "Power" },
|
||||
Poison: shared.cards.Poison,
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withTick.win, true);
|
||||
assert.equal(withTick.turns, 1);
|
||||
assert.equal(withoutTick.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: poisonApplicationBurstEvery bursts after every third poison application", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Outbreak: { name: "Outbreak", cost: 1, kind: "Power", poisonApplicationBurstEvery: 3, poisonApplicationBurstDamage: 11 },
|
||||
Poison1: { name: "Poison1", cost: 0, kind: "Skill", poison: 1 },
|
||||
Poison2: { name: "Poison2", cost: 0, kind: "Skill", poison: 1 },
|
||||
Poison3: { name: "Poison3", cost: 0, kind: "Skill", poison: 1 },
|
||||
},
|
||||
starterDeck: ["Outbreak", "Poison1", "Poison2", "Poison3"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const withBurst = simulateCombat(shared, () => 0.999999);
|
||||
const withoutBurst = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Outbreak: { name: "Outbreak", cost: 1, kind: "Power" },
|
||||
Poison1: shared.cards.Poison1,
|
||||
Poison2: shared.cards.Poison2,
|
||||
Poison3: shared.cards.Poison3,
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withBurst.win, true);
|
||||
assert.equal(withBurst.turns, 1);
|
||||
assert.ok(withoutBurst.turns > withBurst.turns);
|
||||
});
|
||||
|
||||
test("simulateCombat: firstCardDamageBonus applies on the first card played this turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, cardPlayedDamage: 2 },
|
||||
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, firstCardDamageBonus: 2 },
|
||||
},
|
||||
starterDeck: ["Strangle"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
@@ -833,6 +1012,19 @@ test("simulateCombat: cardPlayedDamage hits the target whenever a card is played
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: blockPerDamageDealtThisTurn grants block from damage dealt this turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Mirage: { name: "Mirage", cost: 1, kind: "Skill", blockPerDamageDealtThisTurn: 1, block: 0 },
|
||||
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 4 },
|
||||
},
|
||||
starterDeck: ["Strike", "Mirage"],
|
||||
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
|
||||
Reference in New Issue
Block a user