워리어 카드 풀 및 공용 전투 효과 구현 #109
File diff suppressed because one or more lines are too long
1042
data/cards.json
1042
data/cards.json
File diff suppressed because it is too large
Load Diff
BIN
data/cards.xlsx
BIN
data/cards.xlsx
Binary file not shown.
@@ -6,6 +6,9 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
## Damage
|
||||
|
||||
- `damage`: base attack damage
|
||||
- `damageFromCurrentBlock`: add current block times this value to attack damage
|
||||
- `damageNameMatch`: substring to match against owned card names
|
||||
- `damagePerOwnedNameMatch`: bonus damage per owned card whose name matches `damageNameMatch`
|
||||
- `damagePerOtherHandCard`: bonus damage per other card in hand
|
||||
- `damagePerAttackPlayedThisTurn`: bonus damage per attack played this turn
|
||||
- `damagePerDiscardedThisTurn`: bonus damage per card discarded this turn
|
||||
@@ -14,7 +17,10 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
- `damagePerTurn`: damage applied at turn start
|
||||
- `cardPlayedDamage`: damage when the card is played
|
||||
- `cardPlayedRandomDamage`: random damage when the card is played
|
||||
- `drawOnExhaust`: draw when a card is exhausted
|
||||
- `rewardOnKill`: gain bonus reward screens when the card kills
|
||||
- `maxHpOnKill`: gain max HP when the attack kills
|
||||
- `drawNameMatchAutoPlay`: auto-play drawn cards whose names contain this substring
|
||||
- `randomTargetEachHit`: choose a random alive enemy for each hit
|
||||
- `repeatOnKill`: repeat the attack when it kills at least one enemy
|
||||
- `firstCardDamageBonus`: bonus damage for the first card played this turn
|
||||
@@ -39,6 +45,17 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
- `drawUntilHandSize`: draw until hand reaches a target size
|
||||
- `drawSkillBlock`: gain block for each Skill drawn
|
||||
- `drawPoison`: apply poison when a card is drawn
|
||||
- `exhaustHandNonAttack`: exhaust every non-Attack card in hand
|
||||
- `exhaustHandAll`: exhaust every card in hand
|
||||
- `drawPerExhausted`: draw cards equal to exhausted cards
|
||||
- `blockPerExhaustedCard`: gain block for each card exhausted by the current effect
|
||||
- `addRandomCardCount`: add random cards to hand
|
||||
- `addRandomCardPerExhausted`: add random cards equal to exhausted cards
|
||||
- `addRandomCardKind`: filter random added cards by kind
|
||||
- `addRandomCardSameClass`: restrict random added cards to the source card class
|
||||
- `addedCardsCostZeroThisTurn`: cards added by this effect cost 0 this turn
|
||||
- `playTopDrawPileCount`: play cards from the top of the draw pile
|
||||
- `playTopDrawPileCountPerEnergy`: play cards from the top of the draw pile per energy spent
|
||||
- `handCostZeroThisTurn`: make hand cards cost 0 this turn
|
||||
- `drawDisabledThisTurn`: disable draw for the rest of the turn
|
||||
- `heal`: heal immediately
|
||||
@@ -92,6 +109,7 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
- `powerEffect: "poisonPerTurn"`
|
||||
- `powerEffect: "damagePerTurn"`
|
||||
- `powerEffect: "retainOne"`
|
||||
- `powerEffect: "keepBlock"`
|
||||
- `turnStartShiv`: create Shivs at turn start
|
||||
- `turnStartDraw`: draw cards at turn start
|
||||
- `turnStartDiscard`: discard cards at turn start
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -649,9 +649,10 @@ test("simulateCombat: damagePerAttackPlayedThisTurn scales Finisher", () => {
|
||||
starterDeck: ["Hit", "Finisher"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
assert.ok((stats.Finisher?.damage || 0) >= 6);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => {
|
||||
@@ -666,9 +667,11 @@ test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applie
|
||||
starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"],
|
||||
monsters: [{ name: "Dummy", maxHp: 21, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 5);
|
||||
assert.ok((stats.Precise?.damage || 0) >= 5);
|
||||
assert.ok((stats.Flechettes?.damage || 0) >= 10);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerDiscardedThisTurn and bonusHitsWhenOtherHandAtLeast work", () => {
|
||||
@@ -1099,6 +1102,90 @@ test("simulateCombat: blockPerDamageDealtThisTurn grants block from damage dealt
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: damageFromCurrentBlock uses current block as attack damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Guard: { name: "Guard", cost: 1, kind: "Skill", block: 5 },
|
||||
BodySlam: { name: "BodySlam", cost: 1, kind: "Attack", damageFromCurrentBlock: 1 },
|
||||
},
|
||||
starterDeck: ["Guard", "BodySlam"],
|
||||
monsters: [{ name: "Dummy", maxHp: 5, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerOwnedNameMatch counts matching owned cards across combat piles", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Strike1: { name: "타격", cost: 99, kind: "Attack", damage: 0 },
|
||||
Strike2: { name: "타격", cost: 99, kind: "Attack", damage: 0 },
|
||||
Perfected: { name: "완벽한 타격", cost: 0, kind: "Attack", damage: 6, damageNameMatch: "타격", damagePerOwnedNameMatch: 2 },
|
||||
},
|
||||
starterDeck: ["Strike1", "Strike2", "Perfected"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok((stats.Perfected?.damage || 0) >= 12);
|
||||
});
|
||||
|
||||
test("simulateCombat: exhaustHandNonAttack exhausts only non-attacks and grants block per exhausted card", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
SecondWind: { name: "기사회생", cost: 0, kind: "Skill", exhaustHandNonAttack: true, blockPerExhaustedCard: 5 },
|
||||
Guard1: { name: "수비1", cost: 99, kind: "Skill", block: 0 },
|
||||
Guard2: { name: "수비2", cost: 99, kind: "Skill", block: 0 },
|
||||
Hit: { name: "타격", cost: 99, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Guard1", "Guard2", "Hit", "SecondWind"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 10 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
simulateCombat(data, () => 0.999999, stats);
|
||||
assert.ok((stats.SecondWind?.block || 0) >= 10);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawOnExhaust draws when cards are exhausted", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Embrace: { name: "어둠의 포옹", cost: 0, kind: "Power", drawOnExhaust: 1 },
|
||||
Burn: { name: "소각", cost: 0, kind: "Skill", exhaust: true },
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 6 },
|
||||
},
|
||||
starterDeck: ["Embrace", "Burn", "Hit"],
|
||||
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: keepBlock power preserves block across turns", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Barricade: { name: "바리케이드", cost: 0, kind: "Power", powerEffect: "keepBlock", value: 0 },
|
||||
Guard: { name: "수비", cost: 0, kind: "Skill", block: 5 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Barricade", "Guard", "Pass"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("chooseAction: damageFromCurrentBlock values attack using current block", () => {
|
||||
const cards = {
|
||||
Guard: { name: "Guard", cost: 1, kind: "Skill", block: 5 },
|
||||
BodySlam: { name: "BodySlam", cost: 1, kind: "Attack", damageFromCurrentBlock: 1 },
|
||||
};
|
||||
assert.equal(chooseAction(["BodySlam", "Guard"], cards, 1, { currentBlock: 6 }), 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
@@ -1124,6 +1211,95 @@ test("simulateCombat: rewardOnKill grants an extra reward screen when an attack
|
||||
assert.equal(r.bonusRewardScreens, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: maxHpOnKill increases max hp and heals when attack kills", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Feed: { name: "포식", cost: 1, kind: "Attack", damage: 10, maxHpOnKill: 3 },
|
||||
},
|
||||
starterDeck: ["Feed"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
playerHp: 50,
|
||||
playerMaxHp: 80,
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.playerHpRemaining, 53);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawNameMatchAutoPlay auto-plays matching drawn cards", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Hellraiser: { name: "지옥검무", cost: 0, kind: "Power", drawNameMatchAutoPlay: "타격" },
|
||||
Strike: { name: "강타격", cost: 99, kind: "Attack", damage: 9 },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill" },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill" },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill" },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill" },
|
||||
},
|
||||
starterDeck: ["Hellraiser", "Pass1", "Pass2", "Pass3", "Pass4", "Strike"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: addRandomCardCount can add same-class attack as zero-cost this turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
InfernalBlade: {
|
||||
name: "지옥검",
|
||||
cost: 0,
|
||||
kind: "Skill",
|
||||
addRandomCardCount: 1,
|
||||
addRandomCardKind: "Attack",
|
||||
addRandomCardSameClass: true,
|
||||
addedCardsCostZeroThisTurn: true,
|
||||
class: "warrior",
|
||||
},
|
||||
BigHit: { name: "큰 일격", cost: 2, kind: "Attack", damage: 12, class: "warrior" },
|
||||
OffClass: { name: "외부 공격", cost: 0, kind: "Attack", damage: 1, class: "rogue" },
|
||||
},
|
||||
starterDeck: ["InfernalBlade"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawPerExhausted draws for each exhausted card", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Stoke: { name: "화력 증폭", cost: 0, kind: "Skill", exhaustHandAll: true, drawPerExhausted: 1 },
|
||||
Filler1: { name: "채우기1", cost: 99, kind: "Skill" },
|
||||
Filler2: { name: "채우기2", cost: 99, kind: "Skill" },
|
||||
Hit: { name: "일격", cost: 0, kind: "Attack", damage: 8 },
|
||||
},
|
||||
starterDeck: ["Stoke", "Filler1", "Filler2", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 8, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: playTopDrawPileCountPerEnergy auto-plays top draw pile cards", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Cascade: { name: "연쇄", cost: 0, kind: "Skill", useAllEnergy: true, playTopDrawPileCountPerEnergy: 1, innate: true },
|
||||
Filler1: { name: "준비1", cost: 99, kind: "Skill", innate: true },
|
||||
Filler2: { name: "준비2", cost: 99, kind: "Skill", innate: true },
|
||||
Filler3: { name: "준비3", cost: 99, kind: "Skill", innate: true },
|
||||
Filler4: { name: "준비4", cost: 99, kind: "Skill", innate: true },
|
||||
Hit1: { name: "타격1", cost: 99, kind: "Attack", damage: 6 },
|
||||
Hit2: { name: "타격2", cost: 99, kind: "Attack", damage: 6 },
|
||||
Hit3: { name: "타격3", cost: 99, kind: "Attack", damage: 6 },
|
||||
},
|
||||
starterDeck: ["Cascade", "Filler1", "Filler2", "Filler3", "Filler4", "Hit1", "Hit2", "Hit3"],
|
||||
monsters: [{ name: "Dummy", maxHp: 18, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: intangible cards reduce incoming damage and persist across turns", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
|
||||
@@ -38,7 +38,7 @@ if c == nil then
|
||||
return
|
||||
end
|
||||
if c.unplayable == true then
|
||||
self:Toast("사용할 수 없는 카드입니다")
|
||||
self:Toast("사용할 수 없는 카드입니다")
|
||||
return
|
||||
end
|
||||
if self:CanPlayCardNow(c) ~= true then
|
||||
@@ -49,6 +49,8 @@ local skillFree = false
|
||||
local skillRepeat = 0
|
||||
if self.HandCostZeroThisTurn == true then
|
||||
cost = 0
|
||||
elseif self.ZeroCostCardIdsThisTurn ~= nil and self.ZeroCostCardIdsThisTurn[cardId] == true then
|
||||
cost = 0
|
||||
elseif c.useAllEnergy == true then
|
||||
cost = self.Energy
|
||||
end
|
||||
@@ -71,6 +73,8 @@ if self.Energy < cost then
|
||||
end
|
||||
self.Energy = self.Energy - cost
|
||||
self.ActiveKillReward = c.rewardOnKill or 0
|
||||
self.ActiveKillMaxHpGain = c.maxHpOnKill or 0
|
||||
table.remove(self.Hand, slot)
|
||||
self:ResolveCardEffects(cardId, slot, c, false, cost)
|
||||
local function applyCardPlayHooks()
|
||||
if self:HasPowerField("cardPlayedBlock") == true then
|
||||
@@ -106,16 +110,19 @@ end
|
||||
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
|
||||
self.ActiveKillReward = 0
|
||||
end
|
||||
if self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain <= 0 then
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
end
|
||||
if c.combatCostReductionOnPlay ~= nil and c.combatCostReductionOnPlay > 0 then
|
||||
if self.CombatCardCostReduction == nil then
|
||||
self.CombatCardCostReduction = {}
|
||||
end
|
||||
self.CombatCardCostReduction[cardId] = (self.CombatCardCostReduction[cardId] or 0) + c.combatCostReductionOnPlay
|
||||
end
|
||||
table.remove(self.Hand, slot)
|
||||
if c.exhaust == true then
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId)
|
||||
self:TriggerExhaustEffects(1)
|
||||
elseif c.kind ~= "Power" then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
end
|
||||
@@ -300,6 +307,13 @@ local killed = false
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
if self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + self.ActiveKillReward
|
||||
end
|
||||
if self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain > 0 then
|
||||
self.PlayerMaxHp = self.PlayerMaxHp + self.ActiveKillMaxHpGain
|
||||
self.PlayerHp = self.PlayerHp + self.ActiveKillMaxHpGain
|
||||
end
|
||||
killed = true
|
||||
end
|
||||
return killed`, [
|
||||
@@ -411,6 +425,11 @@ end
|
||||
if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
|
||||
end
|
||||
if killCount > 0 and self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain > 0 then
|
||||
local gain = killCount * self.ActiveKillMaxHpGain
|
||||
self.PlayerMaxHp = self.PlayerMaxHp + gain
|
||||
self.PlayerHp = self.PlayerHp + gain
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
return killCount > 0`, [
|
||||
@@ -450,10 +469,8 @@ _TimerService:SetTimerOnce(function()
|
||||
shown = math.floor(shown * self.ActiveAttackDamageVsWeakMultiplier)
|
||||
end
|
||||
local killed = self:DealDamageToTarget(damage, pierce)
|
||||
if killed == true and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + self.ActiveKillReward
|
||||
end
|
||||
self.ActiveKillReward = 0
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self:ShowDmgPop(targetIndex, shown)
|
||||
self:RenderCombat()
|
||||
@@ -510,10 +527,8 @@ _TimerService:SetTimerOnce(function()
|
||||
end
|
||||
end
|
||||
end
|
||||
if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
|
||||
end
|
||||
self.ActiveKillReward = 0
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
|
||||
@@ -243,6 +243,7 @@ self.BlockGainMultiplier = 1
|
||||
self:ApplyRelics("turnStart")
|
||||
if self.NextTurnKeepBlock == true then
|
||||
self.NextTurnKeepBlock = false
|
||||
elseif self:HasPowerEffect("keepBlock") == true then
|
||||
else
|
||||
self.PlayerBlock = 0
|
||||
end
|
||||
@@ -258,6 +259,7 @@ self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self.DrawDamageThisTurn = 0
|
||||
self.DrawPoisonThisTurn = 0
|
||||
self.ShivAoeThisCombat = false
|
||||
self.ZeroCostCardIdsThisTurn = {}
|
||||
self.SkillSlyOnPlayCards = self.SkillSlyOnPlayCards or {}
|
||||
self.TurnSkillSlyCards = {}
|
||||
self.EnemyStrengthLossThisTurn = 0
|
||||
@@ -275,6 +277,7 @@ if self.PlayerPowers ~= nil then
|
||||
self.Energy = self.Energy + pc.value
|
||||
elseif pc.powerEffect == "blockPerTurn" then
|
||||
self.PlayerBlock = self.PlayerBlock + pc.value
|
||||
elseif pc.powerEffect == "keepBlock" then
|
||||
elseif pc.powerEffect == "poisonPerTurn" then
|
||||
if self.Monsters ~= nil then
|
||||
for j = 1, #self.Monsters do
|
||||
@@ -481,8 +484,11 @@ for i = 1, amount do
|
||||
\t\tself:TriggerSly(cardId)
|
||||
\telse
|
||||
\t\ttable.insert(self.Hand, cardId)
|
||||
\t\tdrewAny = true
|
||||
\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\t\tlocal autoPlayed = self:TriggerDrawnCardAutoPlay(cardId)
|
||||
\t\tif autoPlayed ~= true then
|
||||
\t\t\tdrewAny = true
|
||||
\t\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\t\tend
|
||||
\tend
|
||||
end
|
||||
self:RenderPiles()
|
||||
@@ -495,8 +501,9 @@ if animate == true and #drawnSlots > 0 then
|
||||
\t\tlocal slot = drawnSlots[i]
|
||||
\t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045)
|
||||
\tend
|
||||
end
|
||||
return drawnCards
|
||||
end`, [
|
||||
`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
|
||||
], 0, 'any'),
|
||||
|
||||
@@ -326,7 +326,33 @@ for i = 1, #self.Hand do
|
||||
end
|
||||
end
|
||||
return n`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
|
||||
method('CountOwnedNameMatches', `if match == nil or match == "" then
|
||||
return 0
|
||||
end
|
||||
local n = 0
|
||||
local function countPile(pile)
|
||||
if pile == nil then return end
|
||||
for i = 1, #pile do
|
||||
local c2 = self.Cards[pile[i]]
|
||||
local name = ""
|
||||
if c2 ~= nil and c2.name ~= nil then name = c2.name end
|
||||
if string.find(name, match, 1, true) ~= nil then
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
countPile(self.Hand)
|
||||
countPile(self.DrawPile)
|
||||
countPile(self.DiscardPile)
|
||||
countPile(self.ExhaustPile)
|
||||
return n`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'match' }], 0, 'number'),
|
||||
method('AttackBaseForCard', `local base2 = c.damage or 0
|
||||
if c.damageNameMatch ~= nil and c.damagePerOwnedNameMatch ~= nil then
|
||||
base2 = base2 + self:CountOwnedNameMatches(c.damageNameMatch) * c.damagePerOwnedNameMatch
|
||||
end
|
||||
if c.damageFromCurrentBlock ~= nil and c.damageFromCurrentBlock ~= 0 then
|
||||
base2 = base2 + (self.PlayerBlock or 0) * c.damageFromCurrentBlock
|
||||
end
|
||||
local otherHand = 0
|
||||
if self.Hand ~= nil then
|
||||
otherHand = #self.Hand - 1
|
||||
@@ -365,6 +391,198 @@ return base2`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
], 0, 'number'),
|
||||
method('TriggerExhaustEffects', `if count == nil or count <= 0 then
|
||||
return
|
||||
end
|
||||
local drawOnExhaust = self:AddPowerFieldTotal("drawOnExhaust")
|
||||
if drawOnExhaust ~= nil and drawOnExhaust > 0 then
|
||||
self:DrawCards(drawOnExhaust * count, true)
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'count' }]),
|
||||
method('MarkCardCostZeroThisTurn', `if cardId == nil or cardId == "" then
|
||||
return
|
||||
end
|
||||
if self.ZeroCostCardIdsThisTurn == nil then
|
||||
self.ZeroCostCardIdsThisTurn = {}
|
||||
end
|
||||
self.ZeroCostCardIdsThisTurn[cardId] = true`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
|
||||
method('AutoPlayCardId', `local c = self.Cards[cardId]
|
||||
if c == nil then
|
||||
return false
|
||||
end
|
||||
local spent = energySpent or 0
|
||||
local skillFree = false
|
||||
local skillRepeat = 0
|
||||
if c.kind == "Skill" and c.useAllEnergy ~= true and self.NextSkillCostZero == true then
|
||||
skillFree = true
|
||||
end
|
||||
if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then
|
||||
skillRepeat = self.NextSkillRepeatCount
|
||||
end
|
||||
self.ActiveKillReward = c.rewardOnKill or 0
|
||||
self.ActiveKillMaxHpGain = c.maxHpOnKill or 0
|
||||
self:ResolveCardEffects(cardId, 0, c, false, spent)
|
||||
local function applyCardPlayHooks()
|
||||
if self:HasPowerField("cardPlayedBlock") == true then
|
||||
self:AddCardBlock(self:AddPowerFieldTotal("cardPlayedBlock"))
|
||||
end
|
||||
if c.cardPlayedDamage ~= nil and c.cardPlayedDamage > 0 then
|
||||
self:DealDirectDamageToTarget(c.cardPlayedDamage)
|
||||
end
|
||||
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
|
||||
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
|
||||
end
|
||||
end
|
||||
applyCardPlayHooks()
|
||||
if skillRepeat > 0 then
|
||||
local remaining = (self.NextSkillRepeatCount or 0) - skillRepeat
|
||||
if remaining < 0 then remaining = 0 end
|
||||
self.NextSkillRepeatCount = remaining
|
||||
for i = 1, skillRepeat do
|
||||
self:ResolveCardEffects(cardId, 0, c, false, spent)
|
||||
applyCardPlayHooks()
|
||||
end
|
||||
end
|
||||
if c.kind == "Attack" then
|
||||
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
|
||||
end
|
||||
if skillFree == true and c.nextSkillCostZero ~= true then
|
||||
self.NextSkillCostZero = false
|
||||
end
|
||||
if c.exhaust == true then
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId)
|
||||
self:TriggerExhaustEffects(1)
|
||||
elseif c.kind ~= "Power" then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
end
|
||||
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
|
||||
self.ActiveKillReward = 0
|
||||
end
|
||||
if self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain <= 0 then
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
end
|
||||
return true`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
|
||||
], 0, 'boolean'),
|
||||
method('TriggerDrawnCardAutoPlay', `if cardId == nil or cardId == "" or self.Hand == nil or self.PlayerPowers == nil then
|
||||
return false
|
||||
end
|
||||
local c = self.Cards[cardId]
|
||||
if c == nil or c.name == nil or c.name == "" then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local powerCard = self.Cards[self.PlayerPowers[i]]
|
||||
if powerCard ~= nil and powerCard.drawNameMatchAutoPlay ~= nil and powerCard.drawNameMatchAutoPlay ~= "" then
|
||||
if string.find(c.name, powerCard.drawNameMatchAutoPlay, 1, true) ~= nil then
|
||||
local foundSlot = 0
|
||||
for hi = 1, #self.Hand do
|
||||
if self.Hand[hi] == cardId then
|
||||
foundSlot = hi
|
||||
break
|
||||
end
|
||||
end
|
||||
if foundSlot <= 0 then
|
||||
return false
|
||||
end
|
||||
table.remove(self.Hand, foundSlot)
|
||||
self:AutoPlayCardId(cardId, 0)
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }], 0, 'boolean'),
|
||||
method('PlayTopDrawPileCards', `if c == nil or self.DrawPile == nil then
|
||||
return 0
|
||||
end
|
||||
local count = c.playTopDrawPileCount or 0
|
||||
if c.playTopDrawPileCountPerEnergy ~= nil and c.playTopDrawPileCountPerEnergy > 0 then
|
||||
count = count + ((energySpent or 0) * c.playTopDrawPileCountPerEnergy)
|
||||
end
|
||||
if count <= 0 then
|
||||
return 0
|
||||
end
|
||||
local played = 0
|
||||
for i = 1, count do
|
||||
if #self.DrawPile <= 0 then
|
||||
break
|
||||
end
|
||||
local topCardId = table.remove(self.DrawPile)
|
||||
if topCardId ~= nil then
|
||||
self:AutoPlayCardId(topCardId, 0)
|
||||
played = played + 1
|
||||
end
|
||||
end
|
||||
return played`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
|
||||
], 0, 'number'),
|
||||
method('AddRandomCardsFromEffect', `if c == nil or count == nil or count <= 0 then
|
||||
return 0
|
||||
end
|
||||
local pool = {}
|
||||
for id, rc in pairs(self.Cards) do
|
||||
if rc ~= nil and rc.token ~= true and rc.curse ~= true and rc.unplayable ~= true then
|
||||
local ok = true
|
||||
if c.addRandomCardKind ~= nil and rc.kind ~= c.addRandomCardKind then ok = false end
|
||||
if c.addRandomCardSameClass == true and rc.class ~= c.class then ok = false end
|
||||
if ok == true then table.insert(pool, id) end
|
||||
end
|
||||
end
|
||||
if #pool <= 0 then
|
||||
return 0
|
||||
end
|
||||
local added = 0
|
||||
for i = 1, count do
|
||||
local cardId2 = pool[math.random(1, #pool)]
|
||||
if cardId2 ~= nil then
|
||||
self:AddCardsToHand(cardId2, 1)
|
||||
if c.addedCardsCostZeroThisTurn == true then
|
||||
self:MarkCardCostZeroThisTurn(cardId2)
|
||||
end
|
||||
added = added + 1
|
||||
end
|
||||
end
|
||||
return added`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'count' },
|
||||
], 0, 'number'),
|
||||
method('ExhaustHandNonAttack', `if c == nil or c.exhaustHandNonAttack ~= true or self.Hand == nil or #self.Hand <= 0 then
|
||||
return 0
|
||||
end
|
||||
local exhausted = 0
|
||||
for i = #self.Hand, 1, -1 do
|
||||
local cardId2 = self.Hand[i]
|
||||
local hc = self.Cards[cardId2]
|
||||
if hc == nil or hc.kind ~= "Attack" then
|
||||
table.remove(self.Hand, i)
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId2)
|
||||
exhausted = exhausted + 1
|
||||
end
|
||||
end
|
||||
if exhausted > 0 then
|
||||
if c.blockPerExhaustedCard ~= nil and c.blockPerExhaustedCard > 0 then
|
||||
self:AddCardBlock(exhausted * c.blockPerExhaustedCard)
|
||||
end
|
||||
self:TriggerExhaustEffects(exhausted)
|
||||
end
|
||||
return exhausted`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'number'),
|
||||
method('ExhaustHandAll', `if c == nil or c.exhaustHandAll ~= true or self.Hand == nil or #self.Hand <= 0 then
|
||||
return 0
|
||||
end
|
||||
local exhausted = 0
|
||||
while #self.Hand > 0 do
|
||||
local cardId2 = table.remove(self.Hand)
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId2)
|
||||
exhausted = exhausted + 1
|
||||
end
|
||||
if exhausted > 0 then
|
||||
self:TriggerExhaustEffects(exhausted)
|
||||
end
|
||||
return exhausted`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'number'),
|
||||
method('CalcPlayerAttack', `local base2 = base
|
||||
self.FightAttackCount = self.FightAttackCount + 1
|
||||
if self.FightAttackCount == 1 and self:HasRelic("akabeko") then
|
||||
@@ -485,7 +703,7 @@ if c.xWeakPerEnergy ~= nil and c.xWeakPerEnergy > 0 then
|
||||
weakAmount = weakAmount + xEnergy * c.xWeakPerEnergy
|
||||
end
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil or c.xDamagePerEnergy ~= nil then
|
||||
if c.damage ~= nil or c.xDamagePerEnergy ~= nil or c.damageFromCurrentBlock ~= nil then
|
||||
self:PlayerAttackMotion()
|
||||
local baseDmg = self:AttackBaseForCard(slot, c)
|
||||
self.ActiveAttackDamageVsWeakMultiplier = c.attackDamageVsWeakMultiplier or 1
|
||||
@@ -719,6 +937,23 @@ if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
|
||||
end
|
||||
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
|
||||
self:AddCardsToHand("Shiv", c.addShiv)
|
||||
end
|
||||
local exhaustedNonAttack = self:ExhaustHandNonAttack(c)
|
||||
local exhaustedAll = self:ExhaustHandAll(c)
|
||||
local totalExhausted = exhaustedNonAttack + exhaustedAll
|
||||
if c.drawPerExhausted ~= nil and c.drawPerExhausted > 0 and totalExhausted > 0 then
|
||||
self:DrawCards(totalExhausted * c.drawPerExhausted, true)
|
||||
end
|
||||
if c.addRandomCardCount ~= nil and c.addRandomCardCount > 0 then
|
||||
self:AddRandomCardsFromEffect(c, c.addRandomCardCount)
|
||||
end
|
||||
if c.addRandomCardPerExhausted ~= nil and c.addRandomCardPerExhausted > 0 then
|
||||
if totalExhausted > 0 then
|
||||
self:AddRandomCardsFromEffect(c, totalExhausted * c.addRandomCardPerExhausted)
|
||||
end
|
||||
end
|
||||
if (c.playTopDrawPileCount ~= nil and c.playTopDrawPileCount > 0) or (c.playTopDrawPileCountPerEnergy ~= nil and c.playTopDrawPileCountPerEnergy > 0) then
|
||||
self:PlayTopDrawPileCards(c, xEnergy)
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
|
||||
@@ -95,6 +95,7 @@ self.PlayerVuln = 0
|
||||
self.PlayerIntangible = 0
|
||||
self.BonusRewardScreens = 0
|
||||
self.ActiveKillReward = 0
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
self.PlayerPowers = {}
|
||||
self.FightAttackCount = 0
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
@@ -118,6 +119,7 @@ self.TurnAttackMultiplier = 1
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnAddCards = {}
|
||||
self.ZeroCostCardIdsThisTurn = {}
|
||||
self.CombatOver = false
|
||||
self.DiscardPile = {}
|
||||
self.ExhaustPile = {}
|
||||
|
||||
@@ -142,6 +142,7 @@ function writeCodeblocks() {
|
||||
prop('number', 'PoisonApplicationsThisCombat', '0'),
|
||||
prop('number', 'EnemyStrengthLossThisTurn', '0'),
|
||||
prop('number', 'ActiveKillReward', '0'),
|
||||
prop('number', 'ActiveKillMaxHpGain', '0'),
|
||||
prop('number', 'BonusRewardScreens', '0'),
|
||||
prop('number', 'FightAttackCount', '0'),
|
||||
prop('number', 'TurnAttackCardsPlayed', '0'),
|
||||
@@ -173,6 +174,7 @@ function writeCodeblocks() {
|
||||
prop('boolean', 'NextSkillCostZero', 'false'),
|
||||
prop('number', 'NextSkillRepeatCount', '0'),
|
||||
prop('any', 'NextTurnAddCards'),
|
||||
prop('any', 'ZeroCostCardIdsThisTurn'),
|
||||
], [
|
||||
...bootMethods,
|
||||
...screensMethods,
|
||||
|
||||
@@ -19,18 +19,18 @@ for (const cls of Object.keys(CLASSES)) {
|
||||
// 전직 옵션
|
||||
const JOBS = {
|
||||
warrior: [
|
||||
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack', tier: 2, parent: 'warrior' },
|
||||
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge', tier: 2, parent: 'warrior' },
|
||||
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce', tier: 2, parent: 'warrior' },
|
||||
{ id: 'fighter', name: '파이터', desc: '연속 공격 계열\n이중 타격 · 난타\n악마의 형상', starter: 'TwinStrike', tier: 2, parent: 'warrior' },
|
||||
{ id: 'page', name: '페이지', desc: '방어·운영 계열\n전투의 북소리 · 무적\n바리케이드', starter: 'DrumOfBattle', tier: 2, parent: 'warrior' },
|
||||
{ id: 'spearman', name: '스피어맨', desc: '광역·장기전 계열\n대화재 · 소용돌이\n불의 심장', starter: 'Conflagration', tier: 2, parent: 'warrior' },
|
||||
],
|
||||
fighter: [
|
||||
{ id: 'crusader', name: '크루세이더', desc: 'Fighter의 3차 전직\n콤보 압박과 화력 심화\n파이터 카드 계승', starter: '', tier: 3, parent: 'fighter' },
|
||||
{ id: 'crusader', name: '크루세이더', desc: '파이터의 3차 전직\n아이언클래드 공격 풀 계승\n전사 카드 사용', starter: '', tier: 3, parent: 'fighter' },
|
||||
],
|
||||
page: [
|
||||
{ id: 'knight', name: '나이트', desc: 'Page의 3차 전직\n방어와 차지 운영 심화\n페이지 카드 계승', starter: '', tier: 3, parent: 'page' },
|
||||
{ id: 'knight', name: '나이트', desc: '페이지의 3차 전직\n아이언클래드 운영 풀 계승\n전사 카드 사용', starter: '', tier: 3, parent: 'page' },
|
||||
],
|
||||
spearman: [
|
||||
{ id: 'berserker', name: '버서커', desc: 'Spearman의 3차 전직\n관통과 생존 운영 심화\n스피어맨 카드 계승', starter: '', tier: 3, parent: 'spearman' },
|
||||
{ id: 'berserker', name: '버서커', desc: '스피어맨의 3차 전직\n아이언클래드 장기전 풀 계승\n전사 카드 사용', starter: '', tier: 3, parent: 'spearman' },
|
||||
],
|
||||
magician: [
|
||||
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow', tier: 2, parent: 'magician' },
|
||||
@@ -224,6 +224,9 @@ function luaCardsTable(cards) {
|
||||
const lines = Object.entries(cards).map(([id, c]) => {
|
||||
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
|
||||
if (c.damage != null) fields.push(`damage = ${c.damage}`);
|
||||
if (c.damageFromCurrentBlock != null) fields.push(`damageFromCurrentBlock = ${c.damageFromCurrentBlock}`);
|
||||
if (c.damageNameMatch != null) fields.push(`damageNameMatch = ${luaStr(c.damageNameMatch)}`);
|
||||
if (c.damagePerOwnedNameMatch != null) fields.push(`damagePerOwnedNameMatch = ${c.damagePerOwnedNameMatch}`);
|
||||
if (c.damagePerOtherHandCard != null) fields.push(`damagePerOtherHandCard = ${c.damagePerOtherHandCard}`);
|
||||
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
|
||||
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
|
||||
@@ -234,6 +237,7 @@ function luaCardsTable(cards) {
|
||||
if (c.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`);
|
||||
if (c.firstCardDamageBonus != null) fields.push(`firstCardDamageBonus = ${c.firstCardDamageBonus}`);
|
||||
if (c.rewardOnKill != null) fields.push(`rewardOnKill = ${c.rewardOnKill}`);
|
||||
if (c.maxHpOnKill != null) fields.push(`maxHpOnKill = ${c.maxHpOnKill}`);
|
||||
if (c.intangible != null) fields.push(`intangible = ${c.intangible}`);
|
||||
if (c.endTurnDexLoss != null) fields.push(`endTurnDexLoss = ${c.endTurnDexLoss}`);
|
||||
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);
|
||||
@@ -247,6 +251,8 @@ function luaCardsTable(cards) {
|
||||
if (c.dex != null) fields.push(`dex = ${c.dex}`);
|
||||
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
|
||||
if (c.cardPlayedBlock != null) fields.push(`cardPlayedBlock = ${c.cardPlayedBlock}`);
|
||||
if (c.drawOnExhaust != null) fields.push(`drawOnExhaust = ${c.drawOnExhaust}`);
|
||||
if (c.drawNameMatchAutoPlay != null) fields.push(`drawNameMatchAutoPlay = ${luaStr(c.drawNameMatchAutoPlay)}`);
|
||||
if (c.weak != null) fields.push(`weak = ${c.weak}`);
|
||||
if (c.vuln != null) fields.push(`vuln = ${c.vuln}`);
|
||||
if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`);
|
||||
@@ -262,6 +268,17 @@ function luaCardsTable(cards) {
|
||||
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
|
||||
if (c.drawDamage != null) fields.push(`drawDamage = ${c.drawDamage}`);
|
||||
if (c.drawPoison != null) fields.push(`drawPoison = ${c.drawPoison}`);
|
||||
if (c.exhaustHandNonAttack === true) fields.push('exhaustHandNonAttack = true');
|
||||
if (c.exhaustHandAll === true) fields.push('exhaustHandAll = true');
|
||||
if (c.drawPerExhausted != null) fields.push(`drawPerExhausted = ${c.drawPerExhausted}`);
|
||||
if (c.blockPerExhaustedCard != null) fields.push(`blockPerExhaustedCard = ${c.blockPerExhaustedCard}`);
|
||||
if (c.addRandomCardCount != null) fields.push(`addRandomCardCount = ${c.addRandomCardCount}`);
|
||||
if (c.addRandomCardPerExhausted != null) fields.push(`addRandomCardPerExhausted = ${c.addRandomCardPerExhausted}`);
|
||||
if (c.addRandomCardKind != null) fields.push(`addRandomCardKind = ${luaStr(c.addRandomCardKind)}`);
|
||||
if (c.addRandomCardSameClass === true) fields.push('addRandomCardSameClass = true');
|
||||
if (c.addedCardsCostZeroThisTurn === true) fields.push('addedCardsCostZeroThisTurn = true');
|
||||
if (c.playTopDrawPileCount != null) fields.push(`playTopDrawPileCount = ${c.playTopDrawPileCount}`);
|
||||
if (c.playTopDrawPileCountPerEnergy != null) fields.push(`playTopDrawPileCountPerEnergy = ${c.playTopDrawPileCountPerEnergy}`);
|
||||
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
||||
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
|
||||
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
||||
|
||||
@@ -17,6 +17,7 @@ const POWER_FIELDS = [
|
||||
'shivDamageBonus', 'firstShivDamageBonus', 'shivRetain', 'shivAoe',
|
||||
'attackPoison', 'drawDamage', 'drawPoison', 'attackDamageVsWeakMultiplier',
|
||||
'cardPlayedBlock', 'cardPlayedDamage', 'cardPlayedRandomDamage',
|
||||
'drawOnExhaust', 'drawNameMatchAutoPlay',
|
||||
'extraPoisonTicks', 'poisonApplicationBurstEvery', 'poisonApplicationBurstDamage',
|
||||
'skillSlyOnPlay', 'endTurnDexLoss',
|
||||
];
|
||||
@@ -28,7 +29,7 @@ for (const [id, c] of Object.entries(cards)) {
|
||||
issues.push(`${id}(${c.name}): 미지원 kind="${c.kind}"`);
|
||||
continue;
|
||||
}
|
||||
if (c.kind === 'Attack' && c.damage == null && c.xDamagePerEnergy == null) {
|
||||
if (c.kind === 'Attack' && c.damage == null && c.xDamagePerEnergy == null && c.damageFromCurrentBlock == null) {
|
||||
issues.push(`${id}(${c.name}): kind=Attack인데 damage 없음 → 몬스터 드롭 라우팅 불가(방어/유틸이면 kind=Skill)`);
|
||||
}
|
||||
if (c.kind === 'Power' && !POWER_FIELDS.some((f) => c[f] != null)) {
|
||||
|
||||
Reference in New Issue
Block a user