Merge pull request '워리어 카드 풀 및 공용 전투 효과 구현' (#109) from codex/warrior-card-effects-fixes into main

Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
2026-07-04 01:40:00 +09:00
13 changed files with 1870 additions and 189 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -6,6 +6,9 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
## Damage ## Damage
- `damage`: base attack 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 - `damagePerOtherHandCard`: bonus damage per other card in hand
- `damagePerAttackPlayedThisTurn`: bonus damage per attack played this turn - `damagePerAttackPlayedThisTurn`: bonus damage per attack played this turn
- `damagePerDiscardedThisTurn`: bonus damage per card discarded 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 - `damagePerTurn`: damage applied at turn start
- `cardPlayedDamage`: damage when the card is played - `cardPlayedDamage`: damage when the card is played
- `cardPlayedRandomDamage`: random 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 - `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 - `randomTargetEachHit`: choose a random alive enemy for each hit
- `repeatOnKill`: repeat the attack when it kills at least one enemy - `repeatOnKill`: repeat the attack when it kills at least one enemy
- `firstCardDamageBonus`: bonus damage for the first card played this turn - `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 - `drawUntilHandSize`: draw until hand reaches a target size
- `drawSkillBlock`: gain block for each Skill drawn - `drawSkillBlock`: gain block for each Skill drawn
- `drawPoison`: apply poison when a card is 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 - `handCostZeroThisTurn`: make hand cards cost 0 this turn
- `drawDisabledThisTurn`: disable draw for the rest of the turn - `drawDisabledThisTurn`: disable draw for the rest of the turn
- `heal`: heal immediately - `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: "poisonPerTurn"`
- `powerEffect: "damagePerTurn"` - `powerEffect: "damagePerTurn"`
- `powerEffect: "retainOne"` - `powerEffect: "retainOne"`
- `powerEffect: "keepBlock"`
- `turnStartShiv`: create Shivs at turn start - `turnStartShiv`: create Shivs at turn start
- `turnStartDraw`: draw cards at turn start - `turnStartDraw`: draw cards at turn start
- `turnStartDiscard`: discard cards at turn start - `turnStartDiscard`: discard cards at turn start

View File

@@ -99,7 +99,7 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
const card = cards[x.id]; const card = cards[x.id];
if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false; if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false;
let effectiveCost = card.cost || 0; 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.useAllEnergy === true) effectiveCost = 1;
else if (card.kind === 'Skill') { else if (card.kind === 'Skill') {
if (ctx.nextSkillCostZero === true) effectiveCost = 0; if (ctx.nextSkillCostZero === true) effectiveCost = 0;
@@ -116,7 +116,7 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
const effectiveCost = (x) => { const effectiveCost = (x) => {
const card = cards[x.id]; const card = cards[x.id];
let cost = card.cost || 0; 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.useAllEnergy === true) cost = 1;
else if (card.kind === 'Skill') { else if (card.kind === 'Skill') {
if (ctx.nextSkillCostZero === true) cost = 0; if (ctx.nextSkillCostZero === true) cost = 0;
@@ -127,7 +127,48 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
} }
return cost; 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 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]; const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
if ((ctx.incomingDamage || 0) > (ctx.currentBlock || 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 (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 (skills.length) return bestBy(skills, blkEff).i;
if (attacks.length) return bestBy(attacks, dmgEff).i;
return -1; return -1;
} }
@@ -167,13 +212,14 @@ function bump(s, cost, dmg, blk) {
// 반환: { win, turns, playerHpRemaining, draw? } // 반환: { win, turns, playerHpRemaining, draw? }
export function simulateCombat(data, rng, stats) { export function simulateCombat(data, rng, stats) {
const { cards, starterDeck, monsters } = data; 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); const startingPlayerHp = Math.min(data.playerHp ?? playerMaxHp, playerMaxHp);
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: startingPlayerHp }; if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: startingPlayerHp };
let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards); let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards);
let discard = []; let discard = [];
const exhaust = []; const exhaust = [];
let hand = []; let hand = [];
const zeroCostCardIdsThisTurn = new Set();
let pHp = startingPlayerHp, pBlock = data.playerStartBlock || 0; 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 pStr = data.playerStrength || 0, pDex = 0, pThorns = data.playerThorns || 0, pWeak = 0, pVuln = 0, pIntangible = 0;
let blockGainMultiplier = 1; let blockGainMultiplier = 1;
@@ -215,6 +261,14 @@ export function simulateCombat(data, rng, stats) {
if (!alive.length) return null; if (!alive.length) return null;
return alive[Math.floor(rng() * alive.length)]; 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) => { const expectedIncomingDamage = () => mob.filter((m) => m.alive).reduce((total, m) => {
if (!m.intents || m.intents.length === 0) return total; if (!m.intents || m.intents.length === 0) return total;
const expected = m.intents.reduce((sum, intent) => { const expected = m.intents.reduce((sum, intent) => {
@@ -315,10 +369,26 @@ export function simulateCombat(data, rng, stats) {
if (hand.length >= 10) { if (hand.length >= 10) {
discard.push(card); discard.push(card);
triggerSly(card); triggerSly(card);
} else hand.push(card); } else {
hand.push(card);
triggerDrawNameMatchAutoPlay(card);
}
} }
return drawn; 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) { function addCardsToHand(id, n) {
for (let k = 0; k < n; k++) { for (let k = 0; k < n; k++) {
if (hand.length >= 10) discard.push(id); if (hand.length >= 10) discard.push(id);
@@ -380,8 +450,23 @@ export function simulateCombat(data, rng, stats) {
} }
return n; 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) { function attackBaseForCard(id, c) {
let base = c.damage || 0; 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); const otherHand = Math.max(0, hand.length - 1);
if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard; if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard;
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn; if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
@@ -433,6 +518,52 @@ export function simulateCombat(data, rng, stats) {
} }
return total; 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) { function resolveCardEffects(id, c, costSpent, recordStats = true) {
const alive = aliveList(); const alive = aliveList();
let dmg = 0; let dmg = 0;
@@ -461,7 +592,7 @@ export function simulateCombat(data, rng, stats) {
} }
const xEnergy = costSpent || 0; const xEnergy = costSpent || 0;
if (c.kind === 'Attack') { 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 baseDamage = c.xDamagePerEnergy ? xEnergy * c.xDamagePerEnergy : attackBaseForCard(id, c);
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast) const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
? c.bonusHitsWhenOtherHandAtLeast : 0; ? c.bonusHitsWhenOtherHandAtLeast : 0;
@@ -493,6 +624,10 @@ export function simulateCombat(data, rng, stats) {
target.alive = false; target.alive = false;
killed = true; killed = true;
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill; if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
if (c.maxHpOnKill) {
playerMaxHp += c.maxHpOnKill;
pHp += c.maxHpOnKill;
}
} }
return { killed, dealt }; return { killed, dealt };
}; };
@@ -536,7 +671,7 @@ export function simulateCombat(data, rng, stats) {
} }
if (c.block) blockGained = addBlock(c.block); if (c.block) blockGained = addBlock(c.block);
} else if (c.kind === 'Power') { } else if (c.kind === 'Power') {
if (recordStats) powers.push(id); powers.push(id);
} else { } else {
if (c.block) blockGained = addBlock(c.block); if (c.block) blockGained = addBlock(c.block);
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy; 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); 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) { if (c.cardPlayedDamage && alive.length) {
const target = chooseTarget(aliveList(), 0); const target = chooseTarget(aliveList(), 0);
if (target && target.alive) { if (target && target.alive) {
@@ -639,7 +796,48 @@ export function simulateCombat(data, rng, stats) {
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded); if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
if (c.drawPerDiscarded) draw(discarded * c.drawPerDiscarded); 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) { while (turns < MAX_TURNS) {
turns++; turns++;
turnAttackCardsPlayed = 0; turnAttackCardsPlayed = 0;
@@ -655,7 +853,9 @@ export function simulateCombat(data, rng, stats) {
drawDisabledThisTurn = false; drawDisabledThisTurn = false;
skillCostReductionThisTurn = 0; skillCostReductionThisTurn = 0;
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워) // 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
zeroCostCardIdsThisTurn.clear();
if (nextTurnKeepBlock === true) nextTurnKeepBlock = false; if (nextTurnKeepBlock === true) nextTurnKeepBlock = false;
else if (powers.some((pid) => cards[pid]?.powerEffect === 'keepBlock')) {}
else pBlock = 0; else pBlock = 0;
turnAttackMultiplier = nextTurnAttackMultiplier; turnAttackMultiplier = nextTurnAttackMultiplier;
nextTurnAttackMultiplier = 1; nextTurnAttackMultiplier = 1;
@@ -668,6 +868,7 @@ export function simulateCombat(data, rng, stats) {
if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value; if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value; else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value; else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
else if (pc.powerEffect === 'keepBlock') {}
else if (pc.powerEffect === 'poisonPerTurn') { else if (pc.powerEffect === 'poisonPerTurn') {
for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value); for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value);
} else if (pc.powerEffect === 'damagePerTurn') { } else if (pc.powerEffect === 'damagePerTurn') {
@@ -701,8 +902,14 @@ export function simulateCombat(data, rng, stats) {
skillCostReductionThisTurn, skillCostReductionThisTurn,
handCostZeroThisTurn, handCostZeroThisTurn,
combatCardCostReduction, combatCardCostReduction,
zeroCostCardIdsThisTurn,
incomingDamage: data.smartPlayer === true ? expectedIncomingDamage() : 0, incomingDamage: data.smartPlayer === true ? expectedIncomingDamage() : 0,
currentBlock: pBlock, currentBlock: pBlock,
turnAttackCardsPlayed,
cardsDrawnThisCombat,
drawPileCards: drawPile,
discardCards: discard,
exhaustCards: exhaust,
}); });
if (idx < 0) break; if (idx < 0) break;
const id = hand[idx], c = cards[id]; 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 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); const finalCost = c.useAllEnergy === true ? cost : Math.max(0, cost - combatReduction);
energy -= finalCost; energy -= finalCost;
hand.splice(idx, 1);
resolveCardEffects(id, c, finalCost); resolveCardEffects(id, c, finalCost);
if (c.kind === 'Attack' && (data.healOnAttack || 0) > 0) { if (c.kind === 'Attack' && (data.healOnAttack || 0) > 0) {
pHp = Math.min(playerMaxHp, pHp + data.healOnAttack); pHp = Math.min(playerMaxHp, pHp + data.healOnAttack);
@@ -729,9 +937,11 @@ export function simulateCombat(data, rng, stats) {
} }
if (c.kind === 'Attack') turnAttackCardsPlayed++; if (c.kind === 'Attack') turnAttackCardsPlayed++;
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false; if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
hand.splice(idx, 1);
queueSelectedReserve(c); 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); else if (c.kind !== 'Power') discard.push(id);
if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) { if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) {
combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay; combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay;

View File

@@ -649,9 +649,10 @@ test("simulateCombat: damagePerAttackPlayedThisTurn scales Finisher", () => {
starterDeck: ["Hit", "Finisher"], starterDeck: ["Hit", "Finisher"],
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }], 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.win, true);
assert.equal(r.turns, 2); assert.ok((stats.Finisher?.damage || 0) >= 6);
}); });
test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => { test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => {
@@ -666,9 +667,11 @@ test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applie
starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"], starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"],
monsters: [{ name: "Dummy", maxHp: 21, intents: [{ kind: "Attack", value: 0 }] }], 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.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", () => { test("simulateCombat: damagePerDiscardedThisTurn and bonusHitsWhenOtherHandAtLeast work", () => {
@@ -1099,6 +1102,90 @@ test("simulateCombat: blockPerDamageDealtThisTurn grants block from damage dealt
assert.equal(r.win, true); 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", () => { test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
const data = { const data = {
cards: { cards: {
@@ -1124,6 +1211,95 @@ test("simulateCombat: rewardOnKill grants an extra reward screen when an attack
assert.equal(r.bonusRewardScreens, 1); 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", () => { test("simulateCombat: intangible cards reduce incoming damage and persist across turns", () => {
const data = { const data = {
cards: { cards: {

View File

@@ -49,6 +49,8 @@ local skillFree = false
local skillRepeat = 0 local skillRepeat = 0
if self.HandCostZeroThisTurn == true then if self.HandCostZeroThisTurn == true then
cost = 0 cost = 0
elseif self.ZeroCostCardIdsThisTurn ~= nil and self.ZeroCostCardIdsThisTurn[cardId] == true then
cost = 0
elseif c.useAllEnergy == true then elseif c.useAllEnergy == true then
cost = self.Energy cost = self.Energy
end end
@@ -71,6 +73,8 @@ if self.Energy < cost then
end end
self.Energy = self.Energy - cost self.Energy = self.Energy - cost
self.ActiveKillReward = c.rewardOnKill or 0 self.ActiveKillReward = c.rewardOnKill or 0
self.ActiveKillMaxHpGain = c.maxHpOnKill or 0
table.remove(self.Hand, slot)
self:ResolveCardEffects(cardId, slot, c, false, cost) self:ResolveCardEffects(cardId, slot, c, false, cost)
local function applyCardPlayHooks() local function applyCardPlayHooks()
if self:HasPowerField("cardPlayedBlock") == true then if self:HasPowerField("cardPlayedBlock") == true then
@@ -106,16 +110,19 @@ end
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
self.ActiveKillReward = 0 self.ActiveKillReward = 0
end end
if self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain <= 0 then
self.ActiveKillMaxHpGain = 0
end
if c.combatCostReductionOnPlay ~= nil and c.combatCostReductionOnPlay > 0 then if c.combatCostReductionOnPlay ~= nil and c.combatCostReductionOnPlay > 0 then
if self.CombatCardCostReduction == nil then if self.CombatCardCostReduction == nil then
self.CombatCardCostReduction = {} self.CombatCardCostReduction = {}
end end
self.CombatCardCostReduction[cardId] = (self.CombatCardCostReduction[cardId] or 0) + c.combatCostReductionOnPlay self.CombatCardCostReduction[cardId] = (self.CombatCardCostReduction[cardId] or 0) + c.combatCostReductionOnPlay
end end
table.remove(self.Hand, slot)
if c.exhaust == true then if c.exhaust == true then
if self.ExhaustPile == nil then self.ExhaustPile = {} end if self.ExhaustPile == nil then self.ExhaustPile = {} end
table.insert(self.ExhaustPile, cardId) table.insert(self.ExhaustPile, cardId)
self:TriggerExhaustEffects(1)
elseif c.kind ~= "Power" then elseif c.kind ~= "Power" then
table.insert(self.DiscardPile, cardId) table.insert(self.DiscardPile, cardId)
end end
@@ -300,6 +307,13 @@ local killed = false
if m.hp <= 0 then if m.hp <= 0 then
m.hp = 0 m.hp = 0
self:KillMonster(m.slot) 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 killed = true
end end
return killed`, [ return killed`, [
@@ -411,6 +425,11 @@ end
if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward) self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
end 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:RenderCombat()
self:CheckCombatEnd() self:CheckCombatEnd()
return killCount > 0`, [ return killCount > 0`, [
@@ -450,10 +469,8 @@ _TimerService:SetTimerOnce(function()
shown = math.floor(shown * self.ActiveAttackDamageVsWeakMultiplier) shown = math.floor(shown * self.ActiveAttackDamageVsWeakMultiplier)
end end
local killed = self:DealDamageToTarget(damage, pierce) 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.ActiveKillReward = 0
self.ActiveKillMaxHpGain = 0
self.ActiveAttackDamageVsWeakMultiplier = 1 self.ActiveAttackDamageVsWeakMultiplier = 1
self:ShowDmgPop(targetIndex, shown) self:ShowDmgPop(targetIndex, shown)
self:RenderCombat() self:RenderCombat()
@@ -510,10 +527,8 @@ _TimerService:SetTimerOnce(function()
end end
end 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.ActiveKillReward = 0
self.ActiveKillMaxHpGain = 0
self.ActiveAttackDamageVsWeakMultiplier = 1 self.ActiveAttackDamageVsWeakMultiplier = 1
self:RenderCombat() self:RenderCombat()
self:CheckCombatEnd() self:CheckCombatEnd()

View File

@@ -243,6 +243,7 @@ self.BlockGainMultiplier = 1
self:ApplyRelics("turnStart") self:ApplyRelics("turnStart")
if self.NextTurnKeepBlock == true then if self.NextTurnKeepBlock == true then
self.NextTurnKeepBlock = false self.NextTurnKeepBlock = false
elseif self:HasPowerEffect("keepBlock") == true then
else else
self.PlayerBlock = 0 self.PlayerBlock = 0
end end
@@ -258,6 +259,7 @@ self.ActiveAttackDamageVsWeakMultiplier = 1
self.DrawDamageThisTurn = 0 self.DrawDamageThisTurn = 0
self.DrawPoisonThisTurn = 0 self.DrawPoisonThisTurn = 0
self.ShivAoeThisCombat = false self.ShivAoeThisCombat = false
self.ZeroCostCardIdsThisTurn = {}
self.SkillSlyOnPlayCards = self.SkillSlyOnPlayCards or {} self.SkillSlyOnPlayCards = self.SkillSlyOnPlayCards or {}
self.TurnSkillSlyCards = {} self.TurnSkillSlyCards = {}
self.EnemyStrengthLossThisTurn = 0 self.EnemyStrengthLossThisTurn = 0
@@ -275,6 +277,7 @@ if self.PlayerPowers ~= nil then
self.Energy = self.Energy + pc.value self.Energy = self.Energy + pc.value
elseif pc.powerEffect == "blockPerTurn" then elseif pc.powerEffect == "blockPerTurn" then
self.PlayerBlock = self.PlayerBlock + pc.value self.PlayerBlock = self.PlayerBlock + pc.value
elseif pc.powerEffect == "keepBlock" then
elseif pc.powerEffect == "poisonPerTurn" then elseif pc.powerEffect == "poisonPerTurn" then
if self.Monsters ~= nil then if self.Monsters ~= nil then
for j = 1, #self.Monsters do for j = 1, #self.Monsters do
@@ -481,8 +484,11 @@ for i = 1, amount do
\t\tself:TriggerSly(cardId) \t\tself:TriggerSly(cardId)
\telse \telse
\t\ttable.insert(self.Hand, cardId) \t\ttable.insert(self.Hand, cardId)
\t\tdrewAny = true \t\tlocal autoPlayed = self:TriggerDrawnCardAutoPlay(cardId)
\t\ttable.insert(drawnSlots, #self.Hand) \t\tif autoPlayed ~= true then
\t\t\tdrewAny = true
\t\t\ttable.insert(drawnSlots, #self.Hand)
\t\tend
\tend \tend
end end
self:RenderPiles() self:RenderPiles()
@@ -495,8 +501,9 @@ if animate == true and #drawnSlots > 0 then
\t\tlocal slot = drawnSlots[i] \t\tlocal slot = drawnSlots[i]
\t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045) \t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045)
\tend \tend
end
return drawnCards return drawnCards
end`, [ `, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' }, { Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
], 0, 'any'), ], 0, 'any'),

View File

@@ -326,7 +326,33 @@ for i = 1, #self.Hand do
end end
end end
return n`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'), 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 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 local otherHand = 0
if self.Hand ~= nil then if self.Hand ~= nil then
otherHand = #self.Hand - 1 otherHand = #self.Hand - 1
@@ -365,6 +391,198 @@ return base2`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }, { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
], 0, 'number'), ], 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 method('CalcPlayerAttack', `local base2 = base
self.FightAttackCount = self.FightAttackCount + 1 self.FightAttackCount = self.FightAttackCount + 1
if self.FightAttackCount == 1 and self:HasRelic("akabeko") then 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 weakAmount = weakAmount + xEnergy * c.xWeakPerEnergy
end end
if c.kind == "Attack" then 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() self:PlayerAttackMotion()
local baseDmg = self:AttackBaseForCard(slot, c) local baseDmg = self:AttackBaseForCard(slot, c)
self.ActiveAttackDamageVsWeakMultiplier = c.attackDamageVsWeakMultiplier or 1 self.ActiveAttackDamageVsWeakMultiplier = c.attackDamageVsWeakMultiplier or 1
@@ -719,6 +937,23 @@ if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
end end
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
self:AddCardsToHand("Shiv", c.addShiv) 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`, [ end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }, { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },

View File

@@ -95,6 +95,7 @@ self.PlayerVuln = 0
self.PlayerIntangible = 0 self.PlayerIntangible = 0
self.BonusRewardScreens = 0 self.BonusRewardScreens = 0
self.ActiveKillReward = 0 self.ActiveKillReward = 0
self.ActiveKillMaxHpGain = 0
self.PlayerPowers = {} self.PlayerPowers = {}
self.FightAttackCount = 0 self.FightAttackCount = 0
self.TurnAttackCardsPlayed = 0 self.TurnAttackCardsPlayed = 0
@@ -118,6 +119,7 @@ self.TurnAttackMultiplier = 1
self.NextTurnSelectPrompt = "" self.NextTurnSelectPrompt = ""
self.NextTurnSelectCopies = 0 self.NextTurnSelectCopies = 0
self.NextTurnAddCards = {} self.NextTurnAddCards = {}
self.ZeroCostCardIdsThisTurn = {}
self.CombatOver = false self.CombatOver = false
self.DiscardPile = {} self.DiscardPile = {}
self.ExhaustPile = {} self.ExhaustPile = {}

View File

@@ -142,6 +142,7 @@ function writeCodeblocks() {
prop('number', 'PoisonApplicationsThisCombat', '0'), prop('number', 'PoisonApplicationsThisCombat', '0'),
prop('number', 'EnemyStrengthLossThisTurn', '0'), prop('number', 'EnemyStrengthLossThisTurn', '0'),
prop('number', 'ActiveKillReward', '0'), prop('number', 'ActiveKillReward', '0'),
prop('number', 'ActiveKillMaxHpGain', '0'),
prop('number', 'BonusRewardScreens', '0'), prop('number', 'BonusRewardScreens', '0'),
prop('number', 'FightAttackCount', '0'), prop('number', 'FightAttackCount', '0'),
prop('number', 'TurnAttackCardsPlayed', '0'), prop('number', 'TurnAttackCardsPlayed', '0'),
@@ -173,6 +174,7 @@ function writeCodeblocks() {
prop('boolean', 'NextSkillCostZero', 'false'), prop('boolean', 'NextSkillCostZero', 'false'),
prop('number', 'NextSkillRepeatCount', '0'), prop('number', 'NextSkillRepeatCount', '0'),
prop('any', 'NextTurnAddCards'), prop('any', 'NextTurnAddCards'),
prop('any', 'ZeroCostCardIdsThisTurn'),
], [ ], [
...bootMethods, ...bootMethods,
...screensMethods, ...screensMethods,

View File

@@ -19,18 +19,18 @@ for (const cls of Object.keys(CLASSES)) {
// 전직 옵션 // 전직 옵션
const JOBS = { const JOBS = {
warrior: [ warrior: [
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack', tier: 2, parent: 'warrior' }, { id: 'fighter', name: '파이터', desc: '연속 공격 계열\n이중 타격 · 난타\n악마의 형상', starter: 'TwinStrike', tier: 2, parent: 'warrior' },
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge', tier: 2, parent: 'warrior' }, { id: 'page', name: '페이지', desc: '방어·운영 계열\n전투의 북소리 · 무적\n바리케이드', starter: 'DrumOfBattle', tier: 2, parent: 'warrior' },
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce', tier: 2, parent: 'warrior' }, { id: 'spearman', name: '스피어맨', desc: '광역·장기전 계열\n대화재 · 소용돌이\n불의 심장', starter: 'Conflagration', tier: 2, parent: 'warrior' },
], ],
fighter: [ 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: [ 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: [ 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: [ magician: [
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow', tier: 2, parent: '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 lines = Object.entries(cards).map(([id, c]) => {
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`]; 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.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.damagePerOtherHandCard != null) fields.push(`damagePerOtherHandCard = ${c.damagePerOtherHandCard}`);
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`); if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`); 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.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`);
if (c.firstCardDamageBonus != null) fields.push(`firstCardDamageBonus = ${c.firstCardDamageBonus}`); if (c.firstCardDamageBonus != null) fields.push(`firstCardDamageBonus = ${c.firstCardDamageBonus}`);
if (c.rewardOnKill != null) fields.push(`rewardOnKill = ${c.rewardOnKill}`); 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.intangible != null) fields.push(`intangible = ${c.intangible}`);
if (c.endTurnDexLoss != null) fields.push(`endTurnDexLoss = ${c.endTurnDexLoss}`); if (c.endTurnDexLoss != null) fields.push(`endTurnDexLoss = ${c.endTurnDexLoss}`);
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`); 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.dex != null) fields.push(`dex = ${c.dex}`);
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`); if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
if (c.cardPlayedBlock != null) fields.push(`cardPlayedBlock = ${c.cardPlayedBlock}`); 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.weak != null) fields.push(`weak = ${c.weak}`);
if (c.vuln != null) fields.push(`vuln = ${c.vuln}`); if (c.vuln != null) fields.push(`vuln = ${c.vuln}`);
if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`); 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.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
if (c.drawDamage != null) fields.push(`drawDamage = ${c.drawDamage}`); if (c.drawDamage != null) fields.push(`drawDamage = ${c.drawDamage}`);
if (c.drawPoison != null) fields.push(`drawPoison = ${c.drawPoison}`); 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.heal != null) fields.push(`heal = ${c.heal}`);
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`); if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
if (c.poison != null) fields.push(`poison = ${c.poison}`); if (c.poison != null) fields.push(`poison = ${c.poison}`);

View File

@@ -17,6 +17,7 @@ const POWER_FIELDS = [
'shivDamageBonus', 'firstShivDamageBonus', 'shivRetain', 'shivAoe', 'shivDamageBonus', 'firstShivDamageBonus', 'shivRetain', 'shivAoe',
'attackPoison', 'drawDamage', 'drawPoison', 'attackDamageVsWeakMultiplier', 'attackPoison', 'drawDamage', 'drawPoison', 'attackDamageVsWeakMultiplier',
'cardPlayedBlock', 'cardPlayedDamage', 'cardPlayedRandomDamage', 'cardPlayedBlock', 'cardPlayedDamage', 'cardPlayedRandomDamage',
'drawOnExhaust', 'drawNameMatchAutoPlay',
'extraPoisonTicks', 'poisonApplicationBurstEvery', 'poisonApplicationBurstDamage', 'extraPoisonTicks', 'poisonApplicationBurstEvery', 'poisonApplicationBurstDamage',
'skillSlyOnPlay', 'endTurnDexLoss', 'skillSlyOnPlay', 'endTurnDexLoss',
]; ];
@@ -28,7 +29,7 @@ for (const [id, c] of Object.entries(cards)) {
issues.push(`${id}(${c.name}): 미지원 kind="${c.kind}"`); issues.push(`${id}(${c.name}): 미지원 kind="${c.kind}"`);
continue; 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)`); issues.push(`${id}(${c.name}): kind=Attack인데 damage 없음 → 몬스터 드롭 라우팅 불가(방어/유틸이면 kind=Skill)`);
} }
if (c.kind === 'Power' && !POWER_FIELDS.some((f) => c[f] != null)) { if (c.kind === 'Power' && !POWER_FIELDS.some((f) => c[f] != null)) {