feat: 워리어 카드와 공용 전투 효과 구현
This commit is contained in:
@@ -99,7 +99,7 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
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;
|
||||
if (ctx.handCostZeroThisTurn === true || ctx.zeroCostCardIdsThisTurn?.has(x.id) === true) effectiveCost = 0;
|
||||
else if (card.useAllEnergy === true) effectiveCost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
||||
@@ -116,7 +116,7 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const effectiveCost = (x) => {
|
||||
const card = cards[x.id];
|
||||
let cost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
||||
if (ctx.handCostZeroThisTurn === true || ctx.zeroCostCardIdsThisTurn?.has(x.id) === true) cost = 0;
|
||||
else if (card.useAllEnergy === true) cost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) cost = 0;
|
||||
@@ -127,7 +127,48 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
|
||||
const 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;
|
||||
};
|
||||
const countOwnedNameMatches = (match) => {
|
||||
if (!match) return 0;
|
||||
let n = 0;
|
||||
for (const id of hand) {
|
||||
const name = cards[id]?.name || '';
|
||||
if (name.includes(match)) n++;
|
||||
}
|
||||
for (const pile of [ctx.drawPileCards || [], ctx.discardCards || [], ctx.exhaustCards || []]) {
|
||||
for (const id of pile) {
|
||||
const name = cards[id]?.name || '';
|
||||
if (name.includes(match)) n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const attackBaseEstimate = (x) => {
|
||||
const card = cards[x.id];
|
||||
let base = card.damage || 0;
|
||||
base += (ctx.currentBlock || 0) * (card.damageFromCurrentBlock || 0);
|
||||
if (card.damageNameMatch && card.damagePerOwnedNameMatch) {
|
||||
base += countOwnedNameMatches(card.damageNameMatch) * card.damagePerOwnedNameMatch;
|
||||
}
|
||||
base += Math.max(0, hand.length - 1) * (card.damagePerOtherHandCard || 0);
|
||||
base += (ctx.turnAttackCardsPlayed || 0) * (card.damagePerAttackPlayedThisTurn || 0);
|
||||
base += countOtherHandSkills(x.id) * (card.damagePerSkillInHand || 0);
|
||||
base += (ctx.cardsDrawnThisCombat || 0) * (card.damagePerCardDrawnThisCombat || 0);
|
||||
if (base < 0) base = 0;
|
||||
return base;
|
||||
};
|
||||
const dmgEff = (x) => attackBaseEstimate(x) / 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 ((ctx.incomingDamage || 0) > (ctx.currentBlock || 0)) {
|
||||
@@ -144,8 +185,12 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
}
|
||||
}
|
||||
if (powers.length) return powers[0].i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
if (attacks.length) {
|
||||
const bestAttack = bestBy(attacks, dmgEff);
|
||||
if (bestAttack && dmgEff(bestAttack) > 0) return bestAttack.i;
|
||||
}
|
||||
if (skills.length) return bestBy(skills, blkEff).i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -167,13 +212,14 @@ function bump(s, cost, dmg, blk) {
|
||||
// 반환: { win, turns, playerHpRemaining, draw? }
|
||||
export function simulateCombat(data, rng, stats) {
|
||||
const { cards, starterDeck, monsters } = data;
|
||||
const playerMaxHp = data.playerMaxHp || PLAYER_HP;
|
||||
let playerMaxHp = data.playerMaxHp || PLAYER_HP;
|
||||
const startingPlayerHp = Math.min(data.playerHp ?? playerMaxHp, playerMaxHp);
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: startingPlayerHp };
|
||||
let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards);
|
||||
let discard = [];
|
||||
const exhaust = [];
|
||||
let hand = [];
|
||||
const zeroCostCardIdsThisTurn = new Set();
|
||||
let pHp = startingPlayerHp, pBlock = data.playerStartBlock || 0;
|
||||
let pStr = data.playerStrength || 0, pDex = 0, pThorns = data.playerThorns || 0, pWeak = 0, pVuln = 0, pIntangible = 0;
|
||||
let blockGainMultiplier = 1;
|
||||
@@ -215,6 +261,14 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (!alive.length) return null;
|
||||
return alive[Math.floor(rng() * alive.length)];
|
||||
};
|
||||
const randomCardPool = (sourceCard) => Object.entries(cards)
|
||||
.filter(([, rc]) => {
|
||||
if (!rc || rc.token === true || rc.curse === true || rc.unplayable === true) return false;
|
||||
if (sourceCard.addRandomCardKind && rc.kind !== sourceCard.addRandomCardKind) return false;
|
||||
if (sourceCard.addRandomCardSameClass === true && rc.class !== sourceCard.class) return false;
|
||||
return true;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
const expectedIncomingDamage = () => mob.filter((m) => m.alive).reduce((total, m) => {
|
||||
if (!m.intents || m.intents.length === 0) return total;
|
||||
const expected = m.intents.reduce((sum, intent) => {
|
||||
@@ -315,10 +369,26 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (hand.length >= 10) {
|
||||
discard.push(card);
|
||||
triggerSly(card);
|
||||
} else hand.push(card);
|
||||
} else {
|
||||
hand.push(card);
|
||||
triggerDrawNameMatchAutoPlay(card);
|
||||
}
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
function triggerDrawNameMatchAutoPlay(drawnId) {
|
||||
const drawnCard = cards[drawnId];
|
||||
const drawnName = drawnCard?.name || '';
|
||||
if (!drawnName) return;
|
||||
for (const pid of powers) {
|
||||
const pc = cards[pid];
|
||||
if (!pc?.drawNameMatchAutoPlay || !drawnName.includes(pc.drawNameMatchAutoPlay)) continue;
|
||||
const idx = hand.indexOf(drawnId);
|
||||
if (idx < 0) continue;
|
||||
hand.splice(idx, 1);
|
||||
autoPlayCardFromEffect(drawnId, 0);
|
||||
}
|
||||
}
|
||||
function addCardsToHand(id, n) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (hand.length >= 10) discard.push(id);
|
||||
@@ -380,8 +450,23 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function countOwnedNameMatches(match) {
|
||||
if (!match) return 0;
|
||||
let n = 0;
|
||||
for (const pile of [hand, drawPile, discard, exhaust]) {
|
||||
for (const id of pile) {
|
||||
const name = cards[id]?.name || '';
|
||||
if (name.includes(match)) n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function attackBaseForCard(id, c) {
|
||||
let base = c.damage || 0;
|
||||
if (c.damageNameMatch && c.damagePerOwnedNameMatch) {
|
||||
base += countOwnedNameMatches(c.damageNameMatch) * c.damagePerOwnedNameMatch;
|
||||
}
|
||||
if (c.damageFromCurrentBlock) base += pBlock * c.damageFromCurrentBlock;
|
||||
const otherHand = Math.max(0, hand.length - 1);
|
||||
if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard;
|
||||
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
|
||||
@@ -433,6 +518,52 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function triggerExhaust(count = 1) {
|
||||
const drawOnExhaust = powerFieldTotal('drawOnExhaust');
|
||||
if (drawOnExhaust > 0 && count > 0) draw(drawOnExhaust * count);
|
||||
}
|
||||
function addRandomCardsFromEffect(sourceCard, count) {
|
||||
if (!count || count <= 0) return [];
|
||||
const pool = randomCardPool(sourceCard);
|
||||
if (!pool.length) return [];
|
||||
const added = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = pool[Math.floor(rng() * pool.length)];
|
||||
if (!id) continue;
|
||||
addCardsToHand(id, 1);
|
||||
if (sourceCard.addedCardsCostZeroThisTurn === true) zeroCostCardIdsThisTurn.add(id);
|
||||
added.push(id);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
function exhaustHandNonAttackEffects(c) {
|
||||
if (c.exhaustHandNonAttack !== true || hand.length === 0) return 0;
|
||||
let exhaustedCount = 0;
|
||||
for (let i = hand.length - 1; i >= 0; i--) {
|
||||
const id = hand[i];
|
||||
const hc = cards[id];
|
||||
if (hc?.kind === 'Attack') continue;
|
||||
hand.splice(i, 1);
|
||||
exhaust.push(id);
|
||||
exhaustedCount++;
|
||||
}
|
||||
if (exhaustedCount > 0) {
|
||||
if (c.blockPerExhaustedCard) addBlock(exhaustedCount * c.blockPerExhaustedCard);
|
||||
triggerExhaust(exhaustedCount);
|
||||
}
|
||||
return exhaustedCount;
|
||||
}
|
||||
function exhaustHandAllEffects(c) {
|
||||
if (c.exhaustHandAll !== true || hand.length === 0) return 0;
|
||||
let exhaustedCount = 0;
|
||||
while (hand.length > 0) {
|
||||
const id = hand.pop();
|
||||
exhaust.push(id);
|
||||
exhaustedCount++;
|
||||
}
|
||||
if (exhaustedCount > 0) triggerExhaust(exhaustedCount);
|
||||
return exhaustedCount;
|
||||
}
|
||||
function resolveCardEffects(id, c, costSpent, recordStats = true) {
|
||||
const alive = aliveList();
|
||||
let dmg = 0;
|
||||
@@ -461,7 +592,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
const xEnergy = costSpent || 0;
|
||||
if (c.kind === 'Attack') {
|
||||
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
|
||||
if (alive.length && (c.damage != null || c.xDamagePerEnergy != null || c.damageFromCurrentBlock != null)) {
|
||||
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;
|
||||
@@ -493,6 +624,10 @@ export function simulateCombat(data, rng, stats) {
|
||||
target.alive = false;
|
||||
killed = true;
|
||||
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
|
||||
if (c.maxHpOnKill) {
|
||||
playerMaxHp += c.maxHpOnKill;
|
||||
pHp += c.maxHpOnKill;
|
||||
}
|
||||
}
|
||||
return { killed, dealt };
|
||||
};
|
||||
@@ -536,7 +671,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
} else if (c.kind === 'Power') {
|
||||
if (recordStats) powers.push(id);
|
||||
powers.push(id);
|
||||
} else {
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy;
|
||||
@@ -588,6 +723,28 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
}
|
||||
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
||||
const exhaustedCount = exhaustHandNonAttackEffects(c);
|
||||
const exhaustedAllCount = exhaustHandAllEffects(c);
|
||||
const totalExhausted = exhaustedCount + exhaustedAllCount;
|
||||
if (exhaustedCount > 0 && c.blockPerExhaustedCard) {
|
||||
blockGained += exhaustedCount * c.blockPerExhaustedCard;
|
||||
}
|
||||
if (c.drawPerExhausted && totalExhausted > 0) {
|
||||
draw(totalExhausted * c.drawPerExhausted);
|
||||
}
|
||||
if (c.addRandomCardCount) addRandomCardsFromEffect(c, c.addRandomCardCount);
|
||||
if (c.addRandomCardPerExhausted) {
|
||||
if (totalExhausted > 0) addRandomCardsFromEffect(c, totalExhausted * c.addRandomCardPerExhausted);
|
||||
}
|
||||
const topPlayCount = (c.playTopDrawPileCount || 0) + ((c.playTopDrawPileCountPerEnergy || 0) * xEnergy);
|
||||
if (topPlayCount > 0) {
|
||||
for (let i = 0; i < topPlayCount; i++) {
|
||||
if (drawPile.length <= 0) break;
|
||||
const topId = drawPile.pop();
|
||||
if (!topId) break;
|
||||
autoPlayCardFromEffect(topId, 0);
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedDamage && alive.length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
@@ -639,7 +796,48 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
||||
if (c.drawPerDiscarded) draw(discarded * c.drawPerDiscarded);
|
||||
}
|
||||
|
||||
function autoPlayCardFromEffect(id, energySpent = 0) {
|
||||
const c = cards[id];
|
||||
if (!c) return false;
|
||||
const skillFree = c.kind === 'Skill' && c.useAllEnergy !== true && nextSkillCostZero === true;
|
||||
const skillRepeat = c.kind === 'Skill' && (nextSkillRepeatCount || 0) > 0 ? nextSkillRepeatCount : 0;
|
||||
activeKillReward = c.rewardOnKill || 0;
|
||||
resolveCardEffects(id, c, energySpent, false);
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
if (c.cardPlayedDamage && aliveList().length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
damageDealtThisTurn += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedRandomDamage && aliveList().length) {
|
||||
const target = randomAliveMonster();
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
damageDealtThisTurn += 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, energySpent, false);
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
}
|
||||
}
|
||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) {
|
||||
exhaust.push(id);
|
||||
triggerExhaust(1);
|
||||
} else if (c.kind !== 'Power') {
|
||||
discard.push(id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
while (turns < MAX_TURNS) {
|
||||
turns++;
|
||||
turnAttackCardsPlayed = 0;
|
||||
@@ -655,7 +853,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
drawDisabledThisTurn = false;
|
||||
skillCostReductionThisTurn = 0;
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
||||
zeroCostCardIdsThisTurn.clear();
|
||||
if (nextTurnKeepBlock === true) nextTurnKeepBlock = false;
|
||||
else if (powers.some((pid) => cards[pid]?.powerEffect === 'keepBlock')) {}
|
||||
else pBlock = 0;
|
||||
turnAttackMultiplier = nextTurnAttackMultiplier;
|
||||
nextTurnAttackMultiplier = 1;
|
||||
@@ -668,6 +868,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
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 === 'keepBlock') {}
|
||||
else if (pc.powerEffect === 'poisonPerTurn') {
|
||||
for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value);
|
||||
} else if (pc.powerEffect === 'damagePerTurn') {
|
||||
@@ -701,8 +902,14 @@ export function simulateCombat(data, rng, stats) {
|
||||
skillCostReductionThisTurn,
|
||||
handCostZeroThisTurn,
|
||||
combatCardCostReduction,
|
||||
zeroCostCardIdsThisTurn,
|
||||
incomingDamage: data.smartPlayer === true ? expectedIncomingDamage() : 0,
|
||||
currentBlock: pBlock,
|
||||
turnAttackCardsPlayed,
|
||||
cardsDrawnThisCombat,
|
||||
drawPileCards: drawPile,
|
||||
discardCards: discard,
|
||||
exhaustCards: exhaust,
|
||||
});
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
@@ -714,6 +921,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
|
||||
const finalCost = c.useAllEnergy === true ? cost : Math.max(0, cost - combatReduction);
|
||||
energy -= finalCost;
|
||||
hand.splice(idx, 1);
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
if (c.kind === 'Attack' && (data.healOnAttack || 0) > 0) {
|
||||
pHp = Math.min(playerMaxHp, pHp + data.healOnAttack);
|
||||
@@ -729,9 +937,11 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
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);
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) {
|
||||
exhaust.push(id);
|
||||
triggerExhaust(1);
|
||||
}
|
||||
else if (c.kind !== 'Power') discard.push(id);
|
||||
if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) {
|
||||
combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay;
|
||||
|
||||
Reference in New Issue
Block a user