feat: 도적 공용 카드 효과 구현 #89

Merged
maple merged 1 commits from codex/bandit-shared-effects into main 2026-06-22 22:18:17 +09:00
11 changed files with 805 additions and 239 deletions

File diff suppressed because one or more lines are too long

View File

@@ -59,6 +59,7 @@
"cost": 2, "cost": 2,
"kind": "Attack", "kind": "Attack",
"damage": 8, "damage": 8,
"firstCardDamageBonus": 2,
"vuln": 2, "vuln": 2,
"desc": "피해 8, 취약 2", "desc": "피해 8, 취약 2",
"image": "fe83c7635b0e49ed83d75a2833adb53e", "image": "fe83c7635b0e49ed83d75a2833adb53e",
@@ -527,7 +528,8 @@
"damage": 3, "damage": 3,
"hits": 4, "hits": 4,
"sly": true, "sly": true,
"image": "1b0f2dc8abd0434990eee1befefcbe0d" "image": "1b0f2dc8abd0434990eee1befefcbe0d",
"randomTargetEachHit": true
}, },
"Prepared": { "Prepared": {
"name": "예비", "name": "예비",
@@ -536,7 +538,7 @@
"class": "bandit", "class": "bandit",
"rarity": "normal", "rarity": "normal",
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.", "desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
"draw": 1, "blockPerDamageDealtThisTurn": 1,
"discard": 1, "discard": 1,
"image": "c1e19219745e44c39ae6ac2f77e347d9" "image": "c1e19219745e44c39ae6ac2f77e347d9"
}, },
@@ -601,7 +603,9 @@
"rarity": "normal", "rarity": "normal",
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.", "desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
"draw": 1, "draw": 1,
"image": "0946f69d84464df29b24b94c744c868d" "image": "0946f69d84464df29b24b94c744c868d",
"affectsAllEnemies": true,
"enemyStrengthLossThisTurn": 6
}, },
"CloakAndDagger": { "CloakAndDagger": {
"name": "망토와 단검", "name": "망토와 단검",
@@ -786,7 +790,10 @@
"rarity": "unique", "rarity": "unique",
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.", "desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
"vuln": 2, "vuln": 2,
"image": "0946f69d84464df29b24b94c744c868d" "image": "0946f69d84464df29b24b94c744c868d",
"affectsAllEnemies": true,
"removeEnemyBlock": true,
"removeEnemyArtifact": true
}, },
"HiddenDaggers": { "HiddenDaggers": {
"name": "숨겨진 단검", "name": "숨겨진 단검",
@@ -829,7 +836,8 @@
"rarity": "unique", "rarity": "unique",
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.", "desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
"block": 7, "block": 7,
"image": "c1e19219745e44c39ae6ac2f77e347d9" "image": "c1e19219745e44c39ae6ac2f77e347d9",
"turnHandSlyCount": 1
}, },
"Mirage": { "Mirage": {
"name": "신기루", "name": "신기루",
@@ -859,7 +867,8 @@
"rarity": "unique", "rarity": "unique",
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.", "desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
"poison": 9, "poison": 9,
"image": "19361e72087946b1888684185b40d935" "image": "19361e72087946b1888684185b40d935",
"poisonIfTargetPoisoned": true
}, },
"Blur": { "Blur": {
"name": "흐릿함", "name": "흐릿함",
@@ -891,7 +900,8 @@
"rarity": "unique", "rarity": "unique",
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.", "desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
"addShiv": 3, "addShiv": 3,
"image": "1b0f2dc8abd0434990eee1befefcbe0d" "image": "1b0f2dc8abd0434990eee1befefcbe0d",
"combatCostReductionOnPlay": 1
}, },
"BouncingFlask": { "BouncingFlask": {
"name": "탄성 플라스크", "name": "탄성 플라스크",
@@ -975,12 +985,10 @@
"kind": "Power", "kind": "Power",
"class": "bandit", "class": "bandit",
"rarity": "unique", "rarity": "unique",
"desc": "중독을 3번 부여 때마다, 모든 적에게 피해를 11 줍니다.", "desc": "독이 3번 부여 때마다 모든 적에게 11 피해를 줍니다.",
"aoe": true, "image": "19361e72087946b1888684185b40d935",
"powerEffect": "strengthPerTurn", "poisonApplicationBurstEvery": 3,
"value": 1, "poisonApplicationBurstDamage": 11
"damage": 11,
"image": "19361e72087946b1888684185b40d935"
}, },
"NoxiousFumes": { "NoxiousFumes": {
"name": "유독 가스", "name": "유독 가스",
@@ -1059,7 +1067,8 @@
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.", "desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
"aoe": true, "aoe": true,
"damage": 10, "damage": 10,
"image": "dbdbb1b56ae54672ae68ac6882fff6a2" "image": "dbdbb1b56ae54672ae68ac6882fff6a2",
"repeatOnKill": true
}, },
"TheHunt": { "TheHunt": {
"name": "사냥", "name": "사냥",
@@ -1228,10 +1237,9 @@
"kind": "Power", "kind": "Power",
"class": "bandit", "class": "bandit",
"rarity": "legend", "rarity": "legend",
"desc": "중독이 1번 추가로 발동합니다.", "desc": "적 턴 시작 시 독이 한 번 더 틱합니다.",
"powerEffect": "strengthPerTurn", "image": "19361e72087946b1888684185b40d935",
"value": 1, "extraPoisonTicks": 1
"image": "19361e72087946b1888684185b40d935"
}, },
"Envenom": { "Envenom": {
"name": "독 바르기", "name": "독 바르기",
@@ -1249,10 +1257,9 @@
"kind": "Power", "kind": "Power",
"class": "bandit", "class": "bandit",
"rarity": "legend", "rarity": "legend",
"desc": "스킬 카드를 사용 시, 그 카드 교활을 얻습니다.", "desc": "사용한 스킬 카드 교활해집니다.",
"powerEffect": "strengthPerTurn", "image": "c1e19219745e44c39ae6ac2f77e347d9",
"value": 1, "skillSlyOnPlay": true
"image": "c1e19219745e44c39ae6ac2f77e347d9"
}, },
"Tracking": { "Tracking": {
"name": "추적", "name": "추적",

View File

@@ -4,7 +4,7 @@ Current status of bandit cards and shared effect hooks.
## Implemented ## Implemented
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `Burst`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `Pinpoint`, `BouncingFlask`, `Accuracy`, `PhantomBlades`, `Speedster`, `CorrosiveWave`, `Tracking`, `FanOfKnives` `Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `Burst`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `BouncingFlask`, `Accuracy`, `PhantomBlades`, `Speedster`, `CorrosiveWave`, `Tracking`, `FanOfKnives`, `Strangle`, `Mirage`, `Accelerant`, `MasterPlanner`, `Outbreak`, `EscapePlan`, `HandTrick`, `NoxiousFumes`, `Pinpoint`, `TheHunt`, `Murder`, `Malaise`, `BladeOfInk`, `KnifeTrap`, `BulletTime`, `Envenom`, `SerpentForm`, `WraithForm`, `Skewer`, `Ricochet`, `Anticipate`, `PiercingWail`, `Expose`, `UpMySleeve`, `EchoingSlash`, `BubbleBubble`
Shared hooks already in use: Shared hooks already in use:
@@ -13,67 +13,10 @@ Shared hooks already in use:
- `turnStartDraw`, `turnStartDiscard` - `turnStartDraw`, `turnStartDiscard`
- `nextTurnBlock`, `nextTurnDraw`, `nextTurnKeepBlock`, `nextTurnAttackMultiplier`, `nextTurnCopies`, `nextTurnSelectHandCard` - `nextTurnBlock`, `nextTurnDraw`, `nextTurnKeepBlock`, `nextTurnAttackMultiplier`, `nextTurnCopies`, `nextTurnSelectHandCard`
- `damagePerOtherHandCard`, `damagePerAttackPlayedThisTurn`, `damagePerDiscardedThisTurn`, `damagePerSkillInHand`, `otherHandAtLeast`, `bonusHitsWhenOtherHandAtLeast` - `damagePerOtherHandCard`, `damagePerAttackPlayedThisTurn`, `damagePerDiscardedThisTurn`, `damagePerSkillInHand`, `otherHandAtLeast`, `bonusHitsWhenOtherHandAtLeast`
- `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `nextSkillCostZero`, `skillCostReductionThisTurn` - `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `blockPerDamageDealtThisTurn`, `nextSkillCostZero`, `skillCostReductionThisTurn`
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets` - `firstCardDamageBonus`
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets`, `skillSlyOnPlay`, `extraPoisonTicks`, `poisonApplicationBurstEvery`, `poisonApplicationBurstDamage`
## Partial
`Ricochet`: random split attacks need fuller targeting rules.
`Anticipate`: next-turn enemy intent preview plus block still needs exact behavior review.
`PiercingWail`: enemy attack reduction is implemented as a shared Weak/Vulnerable style effect, but needs final balance review.
`Expose`: weak/vulnerable interaction is only partially modeled.
`BubbleBubble`: still missing the condition for poison dragon form ownership.
`Skewer`: X-cost attack.
`Outbreak`: every 3 poison applications deal 11 to all enemies.
`Strangle`: extra damage the first time a card is used.
`EscapePlan`: gain block when discarded.
`HandTrick`: one card becomes sly and grants block when discarded.
`Mirage`: gain block equal to total damage dealt this turn.
`UpMySleeve`: create Shiv plus reduce discard cost.
`NoxiousFumes`: poison all enemies at turn start.
`EchoingSlash`: repeat on kill.
`TheHunt`: kill reward is implemented.
`Murder`: damage scales with cards drawn this combat.
`Malaise`: X-cost weak/vulnerable reduction.
`Pinpoint`: per-turn discard-based attack damage.
`BladeOfInk`: turn-based Shiv creation.
`KnifeTrap`: playable only when draw pile is empty.
`BulletTime`: hand cost zero plus draw disabled.
`Accelerant`: poison trigger timing is not finalized.
`Envenom`: attack poison on hit.
`MasterPlanner`: skill discard interaction still needs a clear rules decision.
`SerpentForm`: damage on card play.
`WraithForm`: intangible plus turn-end block loss.
## Open questions ## Open questions
- `MasterPlanner` None at the moment.
- `Accelerant`
- `Outbreak`
These three still need a confirmed rule interpretation before we lock them in.

View File

@@ -14,10 +14,18 @@ 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
- `rewardOnKill`: gain bonus reward screens when the card kills
- `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
- `drawDamage`: damage dealt when a card is drawn - `drawDamage`: damage dealt when a card is drawn
- `blockPerDamageDealtThisTurn`: gain block equal to damage dealt this turn
- `shivDamageBonus`: bonus damage for all Shivs - `shivDamageBonus`: bonus damage for all Shivs
- `firstShivDamageBonus`: bonus damage for the first Shiv each turn - `firstShivDamageBonus`: bonus damage for the first Shiv each turn
- `attackDamageVsWeakMultiplier`: multiplier when the attack hits Weak targets - `attackDamageVsWeakMultiplier`: multiplier when the attack hits Weak targets
- `useAllEnergy`: treat the card as spending all available energy
- `xDamagePerEnergy`: scale attack damage by energy spent
- `xWeakPerEnergy`: scale Weak applied by energy spent
## Block and utility ## Block and utility
@@ -31,12 +39,15 @@ 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
- `handCostZeroThisTurn`: make hand cards cost 0 this turn
- `drawDisabledThisTurn`: disable draw for the rest of the turn
- `heal`: heal immediately - `heal`: heal immediately
- `gainEnergy`: gain energy immediately - `gainEnergy`: gain energy immediately
- `strength`: gain Strength - `strength`: gain Strength
- `dex`: gain Dexterity - `dex`: gain Dexterity
- `thorns`: gain Thorns - `thorns`: gain Thorns
- `selfVuln`: apply Vulnerable to self - `selfVuln`: apply Vulnerable to self
- `extraPoisonTicks`: add extra poison ticks at enemy turn start
## Status ## Status
@@ -45,6 +56,19 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
- `poison`: apply Poison - `poison`: apply Poison
- `poisonHits`: apply poison multiple times - `poisonHits`: apply poison multiple times
- `poisonRandomTargets`: spread poison applications across random alive enemies - `poisonRandomTargets`: spread poison applications across random alive enemies
- `poisonIfTargetPoisoned`: apply poison only if the target is already poisoned
- `poisonApplicationBurstEvery`: trigger a burst every N poison applications
- `poisonApplicationBurstDamage`: burst damage when the poison application threshold is reached
- `skillSlyOnPlay`: make a played Skill card count as sly when it is later discarded
- `turnHandSlyCount`: mark up to N other Skill cards in hand as sly for this turn
- `attackPoison`: apply poison when attack damage is dealt
- `intangible`: reduce incoming damage to 1 for the duration
- `endTurnDexLoss`: lose Dexterity at end of turn
- `combatCostReductionOnPlay`: reduce this card's cost each time it is played this combat
- `enemyStrengthLossThisTurn`: reduce enemy Strength for the rest of the turn
- `affectsAllEnemies`: apply the card's debuffs to every alive enemy
- `removeEnemyBlock`: clear enemy block when the card resolves
- `removeEnemyArtifact`: consume enemy Artifact when the card resolves
`poison` deals damage at enemy turn start and then decreases by 1. `poison` deals damage at enemy turn start and then decreases by 1.

View File

@@ -54,6 +54,10 @@ export function calcAttack(base, str, weak, vulnOnTarget) {
return dmg; return dmg;
} }
export function calcEnemyAttack(base, str, weak, vulnOnTarget, strengthLoss = 0) {
return calcAttack(base, Math.max(0, str - strengthLoss), weak, vulnOnTarget);
}
// 방어 우선 차감 후 hp 적용 → { hp, block } // 방어 우선 차감 후 hp 적용 → { hp, block }
export function applyDamage(hp, block, amount) { export function applyDamage(hp, block, amount) {
let dmg = amount; let dmg = amount;
@@ -100,12 +104,16 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
if (ctx.nextSkillCostZero === true) effectiveCost = 0; if (ctx.nextSkillCostZero === true) effectiveCost = 0;
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0)); else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
} }
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
effectiveCost = Math.max(0, effectiveCost - ctx.combatCardCostReduction[x.id]);
}
return card.useAllEnergy === true ? true : effectiveCost <= energy; return card.useAllEnergy === true ? true : effectiveCost <= energy;
}); });
const powers = entries.filter((x) => cards[x.id].kind === 'Power'); const powers = entries.filter((x) => cards[x.id].kind === 'Power');
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack'); const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
const skills = entries.filter((x) => cards[x.id].kind === 'Skill'); const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
const effectiveCost = (card) => { const effectiveCost = (x) => {
const card = cards[x.id];
let cost = card.cost || 0; let cost = card.cost || 0;
if (ctx.handCostZeroThisTurn === true) cost = 0; if (ctx.handCostZeroThisTurn === true) cost = 0;
else if (card.useAllEnergy === true) cost = 1; else if (card.useAllEnergy === true) cost = 1;
@@ -113,10 +121,13 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
if (ctx.nextSkillCostZero === true) cost = 0; if (ctx.nextSkillCostZero === true) cost = 0;
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0)); else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
} }
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
cost = Math.max(0, cost - ctx.combatCardCostReduction[x.id]);
}
return cost; return cost;
}; };
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(cards[x.id]), 1); const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(cards[x.id]), 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 (powers.length) return powers[0].i; if (powers.length) return powers[0].i;
if (attacks.length) return bestBy(attacks, dmgEff).i; if (attacks.length) return bestBy(attacks, dmgEff).i;
@@ -155,25 +166,96 @@ export function simulateCombat(data, rng, stats) {
let nextSkillCostZero = false; let nextSkillCostZero = false;
let nextSkillRepeatCount = 0; let nextSkillRepeatCount = 0;
let skillCostReductionThisTurn = 0; let skillCostReductionThisTurn = 0;
const combatCardCostReduction = {};
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false; let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1; let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
let nextTurnAddCards = []; let nextTurnAddCards = [];
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0; let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
let turnCardsPlayedThisTurn = 0;
let damageDealtThisTurn = 0;
let shivFirstDamageBonusUsed = false; let shivFirstDamageBonusUsed = false;
let drawDamageThisTurn = 0; let drawDamageThisTurn = 0;
let drawPoisonThisTurn = 0; let drawPoisonThisTurn = 0;
let shivAoeThisCombat = false; let shivAoeThisCombat = false;
const skillSlyOnPlayCards = new Set();
const turnSkillSlyCards = new Set();
let poisonApplicationsThisCombat = 0;
let enemyStrengthLossThisTurn = 0;
let cardsDrawnThisCombat = 0; let cardsDrawnThisCombat = 0;
let bonusRewardScreens = 0; let bonusRewardScreens = 0;
let activeKillReward = 0; let activeKillReward = 0;
let energy = 0; let energy = 0;
const powers = []; const powers = [];
const mob = monsters.map((m) => ({ const mob = monsters.map((m) => ({
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0, name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: m.str || 0, weak: 0, vuln: 0, poison: 0, artifact: m.artifact || 0,
intents: m.intents, intentIdx: 0, alive: true, intents: m.intents, intentIdx: 0, alive: true,
})); }));
let turns = 0; let turns = 0;
const aliveMonsters = () => mob.filter((m) => m.alive);
const countAliveMonsters = () => aliveMonsters().length;
const randomAliveMonster = () => {
const alive = aliveMonsters();
if (!alive.length) return null;
return alive[Math.floor(rng() * alive.length)];
};
const removeEnemyBlock = (target) => {
if (target) target.block = 0;
};
const removeEnemyArtifact = (target) => {
if (target) target.artifact = 0;
};
const applyMonsterWeak = (target, amount) => {
if (!target || !amount || amount <= 0) return;
if (target.artifact > 0) { target.artifact--; return; }
target.weak += amount;
};
const applyMonsterVuln = (target, amount) => {
if (!target || !amount || amount <= 0) return;
if (target.artifact > 0) { target.artifact--; return; }
target.vuln += amount;
};
const applyPoisonToMonster = (target, amount) => {
if (!target || !target.alive || !amount || amount <= 0) return;
if (target.artifact > 0) { target.artifact--; return; }
target.poison += amount;
poisonApplicationsThisCombat += 1;
const burstEvery = powerFieldTotal('poisonApplicationBurstEvery');
const burstDamage = powerFieldTotal('poisonApplicationBurstDamage');
if (burstEvery > 0 && burstDamage > 0 && poisonApplicationsThisCombat % burstEvery === 0) {
for (const m of mob) {
if (!m.alive) continue;
const r = applyDamage(m.hp, m.block, burstDamage);
m.hp = r.hp; m.block = r.block;
if (burstDamage > 0) damageDealtThisTurn += burstDamage;
if (m.hp <= 0) m.alive = false;
}
}
};
const dealDamageToMonster = (target, amount, pierce = false) => {
if (!target || !target.alive) return false;
let dmg = amount;
const effectiveStr = Math.max(0, target.str - enemyStrengthLossThisTurn);
dmg = calcAttack(dmg, effectiveStr, target.weak, 0);
if (target.vuln > 0) dmg = Math.floor(dmg * 1.5);
if (target.block > 0 && !pierce) {
const absorbed = Math.min(target.block, dmg);
target.block -= absorbed;
dmg -= absorbed;
}
target.hp -= dmg;
if (dmg > 0) {
const attackPoison = powerFieldTotal('attackPoison');
if (attackPoison > 0) applyPoisonToMonster(target, attackPoison);
}
if (target.hp <= 0) {
target.hp = 0;
target.alive = false;
return true;
}
return false;
};
function draw(n) { function draw(n) {
const drawn = []; const drawn = [];
if (drawDisabledThisTurn === true) return drawn; if (drawDisabledThisTurn === true) return drawn;
@@ -195,8 +277,11 @@ export function simulateCombat(data, rng, stats) {
m.block -= absorbed; m.block -= absorbed;
dmg -= absorbed; dmg -= absorbed;
} }
if (drawPoison > 0) m.poison += drawPoison; if (drawPoison > 0) applyPoisonToMonster(m, drawPoison);
if (dmg > 0) m.hp -= dmg; if (dmg > 0) {
m.hp -= dmg;
damageDealtThisTurn += dmg;
}
if (m.hp <= 0) { m.hp = 0; m.alive = false; } if (m.hp <= 0) { m.hp = 0; m.alive = false; }
} }
} }
@@ -257,6 +342,7 @@ export function simulateCombat(data, rng, stats) {
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn; if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand; if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat; if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
if (c.class === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus;
if (c.class === 'shiv') { if (c.class === 'shiv') {
if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus'); if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus');
if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus'); if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus');
@@ -314,6 +400,19 @@ export function simulateCombat(data, rng, stats) {
if (c.drawDamage && c.kind !== 'Power') drawDamageThisTurn += c.drawDamage; if (c.drawDamage && c.kind !== 'Power') drawDamageThisTurn += c.drawDamage;
if (c.drawPoison && c.kind !== 'Power') drawPoisonThisTurn += c.drawPoison; if (c.drawPoison && c.kind !== 'Power') drawPoisonThisTurn += c.drawPoison;
if (c.shivAoe === true && c.kind !== 'Power') shivAoeThisCombat = true; if (c.shivAoe === true && c.kind !== 'Power') shivAoeThisCombat = true;
if (c.skillSlyOnPlay === true && c.kind === 'Skill') skillSlyOnPlayCards.add(id);
if (c.turnHandSlyCount && c.turnHandSlyCount > 0) {
let picked = 0;
for (const hid of hand) {
if (hid === id) continue;
const hc = cards[hid];
if (hc?.kind === 'Skill' && !turnSkillSlyCards.has(hid) && !skillSlyOnPlayCards.has(hid) && hc.sly !== true) {
turnSkillSlyCards.add(hid);
picked++;
if (picked >= c.turnHandSlyCount) break;
}
}
}
const xEnergy = costSpent || 0; const xEnergy = costSpent || 0;
if (c.kind === 'Attack') { if (c.kind === 'Attack') {
if (alive.length && (c.damage || c.xDamagePerEnergy)) { if (alive.length && (c.damage || c.xDamagePerEnergy)) {
@@ -321,49 +420,70 @@ export function simulateCombat(data, rng, stats) {
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast) const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
? c.bonusHitsWhenOtherHandAtLeast : 0; ? c.bonusHitsWhenOtherHandAtLeast : 0;
const hitN = (c.hits || 1) + bonusHits; const hitN = (c.hits || 1) + bonusHits;
const preview = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
const target = chooseTarget(alive, preview);
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
let totalNv = 0;
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
dmg = totalNv;
let useAoe = c.aoe === true; let useAoe = c.aoe === true;
if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true; if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true;
if (useAoe === true) { const perHit = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
for (const m2 of aliveList()) { const dealToTarget = (target, amount) => {
let d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv; if (!target || !target.alive) return { killed: false, dealt: 0 };
if (m2.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) { let dealt = amount;
d2 = Math.floor(d2 * c.attackDamageVsWeakMultiplier); if (target.vuln > 0) dealt = Math.floor(dealt * 1.5);
}
const r2 = applyDamage(m2.hp, m2.block, d2);
m2.hp = r2.hp; m2.block = r2.block;
const attackPoison = powerFieldTotal('attackPoison');
if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison;
if (m2.hp <= 0) {
m2.alive = false;
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
}
}
} else {
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) { if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) {
dmg = Math.floor(dmg * c.attackDamageVsWeakMultiplier); dealt = Math.floor(dealt * c.attackDamageVsWeakMultiplier);
} }
if (c.pierce === true) { if (c.pierce === true) {
target.hp -= dmg; target.hp -= dealt;
if (target.hp < 0) target.hp = 0; if (target.hp < 0) target.hp = 0;
} else { } else {
const r = applyDamage(target.hp, target.block, dmg); const r = applyDamage(target.hp, target.block, dealt);
target.hp = r.hp; target.block = r.block; target.hp = r.hp; target.block = r.block;
} }
const attackPoison = powerFieldTotal('attackPoison'); const attackPoison = powerFieldTotal('attackPoison');
if (dmg > 0 && attackPoison > 0) target.poison += attackPoison; if (dealt > 0 && attackPoison > 0) applyPoisonToMonster(target, attackPoison);
let killed = false;
if (target.hp <= 0) { if (target.hp <= 0) {
target.alive = false; target.alive = false;
killed = true;
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill; if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
} }
return { killed, dealt };
};
const resolveAttackRound = () => {
let roundKilled = false;
let roundDamage = 0;
if (useAoe === true) {
for (const m2 of aliveList()) {
const r2 = dealToTarget(m2, perHit);
roundDamage += r2.dealt;
if (r2.killed) roundKilled = true;
} }
} else if (c.randomTargetEachHit === true) {
for (let h = 0; h < hitN; h++) {
const target = randomAliveMonster();
if (!target) break;
const r = dealToTarget(target, perHit);
roundDamage += r.dealt;
if (r.killed) roundKilled = true;
}
} else {
const preview = perHit;
const target = chooseTarget(aliveList(), preview);
if (target) {
if (c.weak) applyMonsterWeak(target, c.weak);
if (c.vuln) applyMonsterVuln(target, c.vuln);
const totalNv = perHit * hitN;
const r = dealToTarget(target, totalNv);
roundDamage += r.dealt;
if (r.killed) roundKilled = true;
}
}
dmg += roundDamage;
damageDealtThisTurn += roundDamage;
return roundKilled;
};
let roundKilled = false;
do {
roundKilled = resolveAttackRound();
} while (c.repeatOnKill === true && roundKilled === true && countAliveMonsters() > 0);
} }
if (c.block) blockGained = addBlock(c.block); if (c.block) blockGained = addBlock(c.block);
} else if (c.kind === 'Power') { } else if (c.kind === 'Power') {
@@ -371,17 +491,28 @@ export function simulateCombat(data, rng, stats) {
} 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;
if ((weakAmount || c.vuln || c.poison) && alive.length) { const vulnAmount = c.vuln || 0;
const target = chooseTarget(alive, 0); if ((weakAmount || vulnAmount || c.poison || c.removeEnemyBlock || c.removeEnemyArtifact || c.enemyStrengthLossThisTurn) && alive.length) {
if (weakAmount) target.weak += weakAmount; const targets = c.affectsAllEnemies === true ? aliveList() : [chooseTarget(alive, 0)];
if (c.vuln) target.vuln += c.vuln; if (c.enemyStrengthLossThisTurn && c.enemyStrengthLossThisTurn > 0) {
enemyStrengthLossThisTurn += c.enemyStrengthLossThisTurn;
}
for (const target of targets) {
if (!target || !target.alive) continue;
if (c.removeEnemyBlock === true) removeEnemyBlock(target);
if (c.removeEnemyArtifact === true) removeEnemyArtifact(target);
if (weakAmount) applyMonsterWeak(target, weakAmount);
if (vulnAmount) applyMonsterVuln(target, vulnAmount);
if (c.poison) { if (c.poison) {
if (c.poisonIfTargetPoisoned !== true || target.poison > 0) {
const poisonHits = c.poisonHits || 1; const poisonHits = c.poisonHits || 1;
for (let i = 0; i < poisonHits; i++) { for (let i = 0; i < poisonHits; i++) {
const target2 = c.poisonRandomTargets === true const target2 = c.poisonRandomTargets === true
? alive[Math.floor(rng() * alive.length)] ? alive[Math.floor(rng() * alive.length)]
: target; : target;
if (target2) target2.poison += c.poison; if (target2) applyPoisonToMonster(target2, c.poison);
}
}
} }
} }
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) { if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
@@ -398,6 +529,7 @@ export function simulateCombat(data, rng, stats) {
activeKillReward = c.rewardOnKill || 0; activeKillReward = c.rewardOnKill || 0;
if (c.intangible) pIntangible += c.intangible; if (c.intangible) pIntangible += c.intangible;
queueNextTurnEffects(c); queueNextTurnEffects(c);
turnCardsPlayedThisTurn++;
let drawnCards = []; let drawnCards = [];
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw)); if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
if (c.drawUntilHandSize) { if (c.drawUntilHandSize) {
@@ -415,6 +547,7 @@ export function simulateCombat(data, rng, stats) {
if (target && target.alive) { if (target && target.alive) {
target.hp -= c.cardPlayedDamage; target.hp -= c.cardPlayedDamage;
dmg += c.cardPlayedDamage; dmg += c.cardPlayedDamage;
damageDealtThisTurn += c.cardPlayedDamage;
if (target.hp <= 0) target.alive = false; if (target.hp <= 0) target.alive = false;
} }
} }
@@ -425,15 +558,20 @@ export function simulateCombat(data, rng, stats) {
if (target) { if (target) {
target.hp -= c.cardPlayedRandomDamage; target.hp -= c.cardPlayedRandomDamage;
dmg += c.cardPlayedRandomDamage; dmg += c.cardPlayedRandomDamage;
damageDealtThisTurn += c.cardPlayedRandomDamage;
if (target.hp <= 0) target.alive = false; if (target.hp <= 0) target.alive = false;
} }
} }
} }
if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') {
blockGained += Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn);
}
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained); if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
} }
function triggerSly(id) { function triggerSly(id) {
const c = cards[id]; const c = cards[id];
if (!c?.sly) return; if (!c) return;
if (!c.sly && !skillSlyOnPlayCards.has(id) && !turnSkillSlyCards.has(id)) return;
resolveCardEffects(id, c, 0, false); resolveCardEffects(id, c, 0, false);
} }
function discardHandCard(idx, trigger = true) { function discardHandCard(idx, trigger = true) {
@@ -464,6 +602,8 @@ export function simulateCombat(data, rng, stats) {
drawDamageThisTurn = 0; drawDamageThisTurn = 0;
drawPoisonThisTurn = 0; drawPoisonThisTurn = 0;
shivAoeThisCombat = false; shivAoeThisCombat = false;
turnSkillSlyCards.clear();
enemyStrengthLossThisTurn = 0;
blockGainMultiplier = 1; blockGainMultiplier = 1;
handCostZeroThisTurn = false; handCostZeroThisTurn = false;
drawDisabledThisTurn = false; drawDisabledThisTurn = false;
@@ -483,7 +623,7 @@ export function simulateCombat(data, rng, stats) {
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value; else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value; else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
else if (pc.powerEffect === 'poisonPerTurn') { else if (pc.powerEffect === 'poisonPerTurn') {
for (const m of mob) if (m.alive) m.poison += pc.value; for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value);
} else if (pc.powerEffect === 'damagePerTurn') { } else if (pc.powerEffect === 'damagePerTurn') {
for (const m of mob) { for (const m of mob) {
if (!m.alive) continue; if (!m.alive) continue;
@@ -509,60 +649,25 @@ export function simulateCombat(data, rng, stats) {
while (true) { while (true) {
const alive = aliveList(); const alive = aliveList();
if (alive.length === 0) break; if (alive.length === 0) break;
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn }); const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn, combatCardCostReduction });
if (idx < 0) break; if (idx < 0) break;
const id = hand[idx], c = cards[id]; const id = hand[idx], c = cards[id];
let dmg = 0;
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true; const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
const skillRepeat = c.kind === 'Skill' ? nextSkillRepeatCount : 0; const skillRepeat = c.kind === 'Skill' ? nextSkillRepeatCount : 0;
const baseCost = c.cost || 0; const baseCost = c.cost || 0;
const combatReduction = combatCardCostReduction[id] || 0;
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost))); const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
energy -= cost; const finalCost = Math.max(0, cost - combatReduction);
resolveCardEffects(id, c, cost); energy -= finalCost;
resolveCardEffects(id, c, finalCost);
const playedBlock = powerFieldTotal('cardPlayedBlock'); const playedBlock = powerFieldTotal('cardPlayedBlock');
if (playedBlock > 0) addBlock(playedBlock); if (playedBlock > 0) addBlock(playedBlock);
if (c.cardPlayedDamage && alive.length) {
const target = chooseTarget(aliveList(), 0);
if (target && target.alive) {
target.hp -= c.cardPlayedDamage;
dmg += c.cardPlayedDamage;
if (target.hp <= 0) target.alive = false;
}
}
if (c.cardPlayedRandomDamage && alive.length) {
const pool = aliveList();
if (pool.length) {
const target = pool[Math.floor(rng() * pool.length)];
if (target) {
target.hp -= c.cardPlayedRandomDamage;
dmg += c.cardPlayedRandomDamage;
if (target.hp <= 0) target.alive = false;
}
}
}
if (skillRepeat > 0) { if (skillRepeat > 0) {
nextSkillRepeatCount = Math.max(0, nextSkillRepeatCount - skillRepeat); nextSkillRepeatCount = Math.max(0, nextSkillRepeatCount - skillRepeat);
for (let r = 0; r < skillRepeat; r++) { for (let r = 0; r < skillRepeat; r++) {
resolveCardEffects(id, c, cost); resolveCardEffects(id, c, finalCost);
if (playedBlock > 0) addBlock(playedBlock); if (playedBlock > 0) addBlock(playedBlock);
if (c.cardPlayedDamage && alive.length) {
const target = chooseTarget(aliveList(), 0);
if (target && target.alive) {
target.hp -= c.cardPlayedDamage;
dmg += c.cardPlayedDamage;
if (target.hp <= 0) target.alive = false;
}
}
if (c.cardPlayedRandomDamage && alive.length) {
const pool = aliveList();
if (pool.length) {
const target = pool[Math.floor(rng() * pool.length)];
if (target) {
target.hp -= c.cardPlayedRandomDamage;
dmg += c.cardPlayedRandomDamage;
if (target.hp <= 0) target.alive = false;
}
}
}
} }
} }
if (c.kind === 'Attack') turnAttackCardsPlayed++; if (c.kind === 'Attack') turnAttackCardsPlayed++;
@@ -571,6 +676,9 @@ export function simulateCombat(data, rng, stats) {
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);
else if (c.kind !== 'Power') discard.push(id); else if (c.kind !== 'Power') discard.push(id);
if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) {
combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay;
}
applyDiscardEffects(c); applyDiscardEffects(c);
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens }; if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens };
} }
@@ -600,17 +708,20 @@ export function simulateCombat(data, rng, stats) {
for (const m of mob) { for (const m of mob) {
if (!m.alive) continue; if (!m.alive) continue;
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략 // 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
if (m.poison > 0) { const poisonTicks = 1 + Math.max(0, powerFieldTotal('extraPoisonTicks'));
for (let tick = 0; tick < poisonTicks; tick++) {
if (m.poison <= 0) break;
m.hp -= m.poison; m.hp -= m.poison;
m.poison--; m.poison--;
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; } if (m.hp <= 0) { m.hp = 0; m.alive = false; break; }
} }
if (!m.alive) continue;
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월) m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤) // 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null; const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
if (it) { if (it) {
if (it.kind === 'Attack') { if (it.kind === 'Attack') {
const atk = calcAttack(it.value, m.str, m.weak, pVuln); const atk = calcAttack(it.value, Math.max(0, m.str - enemyStrengthLossThisTurn), m.weak, pVuln);
const beforeHp = pHp; const beforeHp = pHp;
let incoming = atk; let incoming = atk;
if (pIntangible > 0 && incoming > 1) incoming = 1; if (pIntangible > 0 && incoming > 1) incoming = 1;

View File

@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll, mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, calcEnemyAttack, rarityForRoll,
} from './sim-balance.mjs'; } from './sim-balance.mjs';
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => { test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
@@ -758,6 +758,14 @@ test("chooseAction: useAllEnergy cards remain playable at zero energy", () => {
assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0); assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0);
}); });
test("chooseAction: combatCardCostReduction discounts the same card across combat", () => {
const cards = {
Sleeve: { name: "UpMySleeve", cost: 2, kind: "Skill" },
};
assert.equal(chooseAction(["Sleeve"], cards, 1, { combatCardCostReduction: { Sleeve: 1 } }), 0);
assert.equal(chooseAction(["Sleeve"], cards, 1, {}), -1);
});
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => { test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
const data = { const data = {
cards: { cards: {
@@ -821,10 +829,181 @@ test("simulateCombat: attackPoison power applies poison on attack damage", () =>
assert.equal(r.turns, 1); assert.equal(r.turns, 1);
}); });
test("simulateCombat: cardPlayedDamage hits the target whenever a card is played", () => { test("simulateCombat: skillSlyOnPlay makes later discards of the same skill trigger sly effects", () => {
const shared = {
cards: {
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true },
},
starterDeck: ["MasterPlanner", "MasterPlanner"],
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
};
const withSly = simulateCombat({
...shared,
cards: {
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true, skillSlyOnPlay: true },
},
}, () => 0.999999);
const withoutSly = simulateCombat(shared, () => 0.999999);
assert.equal(withSly.win, true);
assert.equal(withSly.turns, 1);
assert.ok(withoutSly.turns > withSly.turns);
});
test("simulateCombat: randomTargetEachHit can spread hits across alive enemies", () => {
const shared = {
cards: {
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4, randomTargetEachHit: true },
},
starterDeck: ["Ricochet"],
monsters: [
{ name: "DummyA", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
{ name: "DummyB", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
],
};
const makeRng = () => {
const seq = [0, 0.999999, 0, 0.999999];
let i = 0;
return () => seq[i++ % seq.length];
};
const withRicochet = simulateCombat(shared, makeRng());
const withoutRicochet = simulateCombat({
...shared,
cards: {
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4 },
},
}, makeRng());
assert.equal(withRicochet.win, true);
assert.equal(withRicochet.turns, 1);
assert.equal(withoutRicochet.turns, 2);
});
test("calcEnemyAttack: enemyStrengthLossThisTurn reduces enemy attack damage", () => {
assert.equal(calcEnemyAttack(10, 6, 0, 0, 6), 10);
assert.equal(calcEnemyAttack(10, 6, 0, 0, 0), 16);
});
test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => {
const shared = {
cards: {
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10, repeatOnKill: true },
},
starterDeck: ["EchoingSlash"],
monsters: [
{ name: "DummyA", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] },
{ name: "DummyB", maxHp: 20, intents: [{ kind: "Attack", value: 0 }] },
],
};
const withRepeat = simulateCombat(shared, () => 0.999999);
const withoutRepeat = simulateCombat({
...shared,
cards: {
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10 },
},
}, () => 0.999999);
assert.equal(withRepeat.win, true);
assert.equal(withRepeat.turns, 1);
assert.equal(withoutRepeat.turns, 2);
});
test("simulateCombat: poisonIfTargetPoisoned only applies poison to already poisoned enemies", () => {
const shared = {
cards: {
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9, poisonIfTargetPoisoned: true },
},
starterDeck: ["Bubble"],
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
};
const withBubble = simulateCombat(shared, () => 0.999999);
const withoutBubble = simulateCombat({
...shared,
cards: {
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9 },
},
}, () => 0.999999);
assert.equal(withBubble.draw, true);
assert.equal(withBubble.turns, 100);
assert.equal(withoutBubble.win, true);
assert.equal(withoutBubble.turns, 1);
});
test("simulateCombat: turnHandSlyCount marks a skill in hand as sly for the turn", () => {
const shared = {
cards: {
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7, turnHandSlyCount: 1 },
Shield: { name: "Shield", cost: 0, kind: "Skill", unplayable: true, block: 7 },
Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true },
},
starterDeck: ["Gamble", "Shield", "HandTrick"],
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 10 }] }],
};
const withHandTrick = simulateCombat(shared, () => 0.999999);
const withoutHandTrick = simulateCombat({
...shared,
cards: {
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7 },
Shield: shared.cards.Shield,
Gamble: shared.cards.Gamble,
},
}, () => 0.999999);
assert.equal(withHandTrick.playerHpRemaining, 80);
assert.equal(withoutHandTrick.playerHpRemaining, 0);
});
test("simulateCombat: extraPoisonTicks adds an extra poison tick at enemy turn start", () => {
const shared = {
cards: {
Accelerant: { name: "Accelerant", cost: 1, kind: "Power", extraPoisonTicks: 1 },
Poison: { name: "Poison", cost: 1, kind: "Skill", poison: 2 },
},
starterDeck: ["Accelerant", "Poison"],
monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }],
};
const withTick = simulateCombat(shared, () => 0.999999);
const withoutTick = simulateCombat({
...shared,
cards: {
Accelerant: { name: "Accelerant", cost: 1, kind: "Power" },
Poison: shared.cards.Poison,
},
}, () => 0.999999);
assert.equal(withTick.win, true);
assert.equal(withTick.turns, 1);
assert.equal(withoutTick.turns, 2);
});
test("simulateCombat: poisonApplicationBurstEvery bursts after every third poison application", () => {
const shared = {
cards: {
Outbreak: { name: "Outbreak", cost: 1, kind: "Power", poisonApplicationBurstEvery: 3, poisonApplicationBurstDamage: 11 },
Poison1: { name: "Poison1", cost: 0, kind: "Skill", poison: 1 },
Poison2: { name: "Poison2", cost: 0, kind: "Skill", poison: 1 },
Poison3: { name: "Poison3", cost: 0, kind: "Skill", poison: 1 },
},
starterDeck: ["Outbreak", "Poison1", "Poison2", "Poison3"],
monsters: [
{ name: "DummyA", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
{ name: "DummyB", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
],
};
const withBurst = simulateCombat(shared, () => 0.999999);
const withoutBurst = simulateCombat({
...shared,
cards: {
Outbreak: { name: "Outbreak", cost: 1, kind: "Power" },
Poison1: shared.cards.Poison1,
Poison2: shared.cards.Poison2,
Poison3: shared.cards.Poison3,
},
}, () => 0.999999);
assert.equal(withBurst.win, true);
assert.equal(withBurst.turns, 1);
assert.ok(withoutBurst.turns > withBurst.turns);
});
test("simulateCombat: firstCardDamageBonus applies on the first card played this turn", () => {
const data = { const data = {
cards: { cards: {
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, cardPlayedDamage: 2 }, Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, firstCardDamageBonus: 2 },
}, },
starterDeck: ["Strangle"], starterDeck: ["Strangle"],
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }], monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
@@ -833,6 +1012,19 @@ test("simulateCombat: cardPlayedDamage hits the target whenever a card is played
assert.equal(r.win, true); assert.equal(r.win, true);
}); });
test("simulateCombat: blockPerDamageDealtThisTurn grants block from damage dealt this turn", () => {
const data = {
cards: {
Mirage: { name: "Mirage", cost: 1, kind: "Skill", blockPerDamageDealtThisTurn: 1, block: 0 },
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 4 },
},
starterDeck: ["Strike", "Mirage"],
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
});
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => { test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
const data = { const data = {
cards: { cards: {

View File

@@ -59,6 +59,9 @@ end
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
cost = math.max(0, cost - self.SkillCostReductionThisTurn) cost = math.max(0, cost - self.SkillCostReductionThisTurn)
end end
if self.CombatCardCostReduction ~= nil and self.CombatCardCostReduction[cardId] ~= nil then
cost = math.max(0, cost - self.CombatCardCostReduction[cardId])
end
if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then
skillRepeat = self.NextSkillRepeatCount skillRepeat = self.NextSkillRepeatCount
end end
@@ -103,6 +106,12 @@ 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 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) 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
@@ -283,7 +292,7 @@ m.hp = m.hp - dmg
if dmg > 0 then if dmg > 0 then
local poison = self:AddPowerFieldTotal("attackPoison") local poison = self:AddPowerFieldTotal("attackPoison")
if poison ~= nil and poison > 0 then if poison ~= nil and poison > 0 then
m.poison = (m.poison or 0) + poison self:ApplyPoisonToMonster(m, poison)
end end
end end
self:MonsterHitMotion(m.slot) self:MonsterHitMotion(m.slot)
@@ -345,6 +354,62 @@ end
return killed`, [ return killed`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
], 0, 'boolean'), ], 0, 'boolean'),
method('ApplyPoisonToMonster', `if target == nil or target.alive ~= true or amount == nil or amount <= 0 then
return
end
if target.artifact ~= nil and target.artifact > 0 then
target.artifact = target.artifact - 1
return
end
target.poison = (target.poison or 0) + amount
self.PoisonApplicationsThisCombat = (self.PoisonApplicationsThisCombat or 0) + 1
local burstEvery = self:AddPowerFieldTotal("poisonApplicationBurstEvery")
local burstDamage = self:AddPowerFieldTotal("poisonApplicationBurstDamage")
if burstEvery ~= nil and burstEvery > 0 and burstDamage ~= nil and burstDamage > 0 then
if (self.PoisonApplicationsThisCombat % burstEvery) == 0 then
self:DealDamageToAllMonsters(burstDamage)
end
end`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'target' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('DealDamageToAllMonsters', `if self.Monsters == nil then
return false
end
local killCount = 0
for i = 1, #self.Monsters do
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
local dmg = amount
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.block > 0 then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
dmg = dmg - absorbed
end
m.hp = m.hp - dmg
if dmg > 0 then
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
end
self:ShowDmgPop(i, dmg)
self:MonsterHitMotion(i)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
killCount = killCount + 1
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:RenderCombat()
self:CheckCombatEnd()
return killCount > 0`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
], 0, 'boolean'),
method('PlayAttackFx', `local m = self.Monsters[targetIndex] method('PlayAttackFx', `local m = self.Monsters[targetIndex]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
self:DealDamageToTarget(damage, pierce) self:DealDamageToTarget(damage, pierce)
@@ -426,7 +491,7 @@ _TimerService:SetTimerOnce(function()
if dmg > 0 then if dmg > 0 then
local poison = self:AddPowerFieldTotal("attackPoison") local poison = self:AddPowerFieldTotal("attackPoison")
if poison ~= nil and poison > 0 then if poison ~= nil and poison > 0 then
m.poison = (m.poison or 0) + poison self:ApplyPoisonToMonster(m, poison)
end end
end end
self:ShowDmgPop(i, dmg) self:ShowDmgPop(i, dmg)
@@ -518,6 +583,12 @@ local m = self.Monsters[idx]
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(idx) local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(idx)
self:SetEntityEnabled(base .. "/ActFrame", true) self:SetEntityEnabled(base .. "/ActFrame", true)
_TimerService:SetTimerOnce(function() _TimerService:SetTimerOnce(function()
local poisonTicks = 1
local bonusTicks = self:AddPowerFieldTotal("extraPoisonTicks")
if bonusTicks ~= nil and bonusTicks > 0 then
poisonTicks = poisonTicks + bonusTicks
end
for pt = 1, poisonTicks do
if m.poison ~= nil and m.poison > 0 then if m.poison ~= nil and m.poison > 0 then
m.hp = m.hp - m.poison m.hp = m.hp - m.poison
self:ShowDmgPop(idx, m.poison) self:ShowDmgPop(idx, m.poison)
@@ -532,12 +603,17 @@ _TimerService:SetTimerOnce(function()
return return
end end
end end
end
m.block = 0 m.block = 0
local intent = m.intents[m.intentIdx] local intent = m.intents[m.intentIdx]
if intent ~= nil then if intent ~= nil then
if intent.kind == "Attack" then if intent.kind == "Attack" then
self:MonsterLunge(idx) self:MonsterLunge(idx)
local atk = intent.value + m.str local atk = intent.value + m.str
if self.EnemyStrengthLossThisTurn ~= nil and self.EnemyStrengthLossThisTurn > 0 then
atk = atk - self.EnemyStrengthLossThisTurn
if atk < 0 then atk = 0 end
end
if m.weak > 0 then if m.weak > 0 then
atk = math.floor(atk * 0.75) atk = math.floor(atk * 0.75)
end end

View File

@@ -228,6 +228,8 @@ self.RetainSelectActive = false
self.ReserveSelectActive = false self.ReserveSelectActive = false
self.TurnAttackCardsPlayed = 0 self.TurnAttackCardsPlayed = 0
self.TurnDiscardedCards = 0 self.TurnDiscardedCards = 0
self.TurnCardsPlayedThisTurn = 0
self.DamageDealtThisTurn = 0
self.NextTurnSelectCopies = 0 self.NextTurnSelectCopies = 0
self.NextTurnSelectPrompt = "" self.NextTurnSelectPrompt = ""
self.SkillCostReductionThisTurn = 0 self.SkillCostReductionThisTurn = 0
@@ -252,6 +254,9 @@ self.ActiveAttackDamageVsWeakMultiplier = 1
self.DrawDamageThisTurn = 0 self.DrawDamageThisTurn = 0
self.DrawPoisonThisTurn = 0 self.DrawPoisonThisTurn = 0
self.ShivAoeThisCombat = false self.ShivAoeThisCombat = false
self.SkillSlyOnPlayCards = self.SkillSlyOnPlayCards or {}
self.TurnSkillSlyCards = {}
self.EnemyStrengthLossThisTurn = 0
self.HandCostZeroThisTurn = false self.HandCostZeroThisTurn = false
self.DrawDisabledThisTurn = false self.DrawDisabledThisTurn = false
local powerTurnDraw = 0 local powerTurnDraw = 0
@@ -271,7 +276,7 @@ if self.PlayerPowers ~= nil then
for j = 1, #self.Monsters do for j = 1, #self.Monsters do
local tm = self.Monsters[j] local tm = self.Monsters[j]
if tm ~= nil and tm.alive == true then if tm ~= nil and tm.alive == true then
tm.poison = (tm.poison or 0) + pc.value self:ApplyPoisonToMonster(tm, pc.value)
end end
end end
end end
@@ -448,6 +453,7 @@ if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
self:RenderHand(false) self:RenderHand(false)
self:RenderPiles() self:RenderPiles()
self.TurnSkillSlyCards = {}
self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]), self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]),
method('DrawCards', `local drawnSlots = {} method('DrawCards', `local drawnSlots = {}
local drawnCards = {} local drawnCards = {}

View File

@@ -311,6 +311,9 @@ end
if c.damagePerCardDrawnThisCombat ~= nil then if c.damagePerCardDrawnThisCombat ~= nil then
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
end end
if c.class == "Attack" and (self.TurnCardsPlayedThisTurn or 0) == 0 and c.firstCardDamageBonus ~= nil then
base2 = base2 + c.firstCardDamageBonus
end
if c.class == "shiv" then if c.class == "shiv" then
if self:HasPowerField("shivDamageBonus") == true then if self:HasPowerField("shivDamageBonus") == true then
base2 = base2 + self:AddPowerFieldTotal("shivDamageBonus") base2 = base2 + self:AddPowerFieldTotal("shivDamageBonus")
@@ -411,6 +414,33 @@ end
if c.shivAoe == true and c.kind ~= "Power" then if c.shivAoe == true and c.kind ~= "Power" then
self.ShivAoeThisCombat = true self.ShivAoeThisCombat = true
end end
if c.skillSlyOnPlay == true and c.kind == "Skill" then
if self.SkillSlyOnPlayCards == nil then
self.SkillSlyOnPlayCards = {}
end
self.SkillSlyOnPlayCards[cardId] = true
end
if c.turnHandSlyCount ~= nil and c.turnHandSlyCount > 0 then
if self.TurnSkillSlyCards == nil then
self.TurnSkillSlyCards = {}
end
local picked = 0
if self.Hand ~= nil then
for i = 1, #self.Hand do
local hid = self.Hand[i]
if hid ~= nil and hid ~= cardId then
local hc = self.Cards[hid]
if hc ~= nil and hc.kind == "Skill" and self.TurnSkillSlyCards[hid] ~= true and self.SkillSlyOnPlayCards[hid] ~= true and hc.sly ~= true then
self.TurnSkillSlyCards[hid] = true
picked = picked + 1
if picked >= c.turnHandSlyCount then
break
end
end
end
end
end
end
local xEnergy = energySpent or 0 local xEnergy = energySpent or 0
local weakAmount = c.weak or 0 local weakAmount = c.weak or 0
local vulnAmount = c.vuln or 0 local vulnAmount = c.vuln or 0
@@ -448,12 +478,61 @@ if c.kind == "Attack" then
if c.class == "shiv" and self.ShivFirstDamageBonusUsed ~= true and self:HasPowerField("firstShivDamageBonus") == true then if c.class == "shiv" and self.ShivFirstDamageBonusUsed ~= true and self:HasPowerField("firstShivDamageBonus") == true then
self.ShivFirstDamageBonusUsed = true self.ShivFirstDamageBonusUsed = true
end end
if useAoe == true then local function countAliveMonsters()
self:PlayAoeFx(c.fx or c.image, total) local n = 0
else if self.Monsters ~= nil then
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true) for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then n = n + 1 end
end end
end end
return n
end
local function randomAliveMonsterIndex()
local alive = {}
if self.Monsters ~= nil then
for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then
table.insert(alive, mi)
end
end
end
if #alive <= 0 then
return 0
end
return alive[math.random(1, #alive)]
end
local function resolveAttackRound()
local roundKilled = false
if useAoe == true then
local killed = self:DealDamageToAllMonsters(total)
if killed == true then roundKilled = true end
elseif c.randomTargetEachHit == true then
for h = 1, hitN do
local targetIdx = randomAliveMonsterIndex()
if targetIdx ~= nil and targetIdx > 0 then
local prev = self.TargetIndex
self.TargetIndex = targetIdx
local killed = self:DealDamageToTarget(total / hitN, c.pierce == true)
self.TargetIndex = prev
if killed == true then roundKilled = true end
end
end
else
local killed = self:DealDamageToTarget(total, c.pierce == true)
if killed == true then roundKilled = true end
end
return roundKilled
end
local totalDamage = 0
local roundKilled = false
repeat
roundKilled = resolveAttackRound()
totalDamage = totalDamage + total
until c.repeatOnKill ~= true or roundKilled ~= true or countAliveMonsters() <= 0
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + totalDamage
end
if c.block ~= nil then if c.block ~= nil then
self:AddCardBlock(c.block) self:AddCardBlock(c.block)
end end
@@ -490,20 +569,59 @@ end
if c.intangible ~= nil and c.intangible > 0 then if c.intangible ~= nil and c.intangible > 0 then
self.PlayerIntangible = (self.PlayerIntangible or 0) + c.intangible self.PlayerIntangible = (self.PlayerIntangible or 0) + c.intangible
end end
self.TurnCardsPlayedThisTurn = (self.TurnCardsPlayedThisTurn or 0) + 1
if c.blockPerDamageDealtThisTurn ~= nil and c.blockPerDamageDealtThisTurn > 0 then
self:AddCardBlock((self.DamageDealtThisTurn or 0) * c.blockPerDamageDealtThisTurn)
end
self:QueueNextTurnEffects(c) self:QueueNextTurnEffects(c)
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil then 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
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil or c.affectsAllEnemies == true or c.removeEnemyBlock == true or c.removeEnemyArtifact == true or (c.enemyStrengthLossThisTurn ~= nil and c.enemyStrengthLossThisTurn > 0) then
local tm = self.Monsters[self.TargetIndex] local tm = self.Monsters[self.TargetIndex]
if tm == nil or tm.alive ~= true then if tm == nil or tm.alive ~= true then
for i = 1, #self.Monsters do for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end
end end
end end
if tm ~= nil and tm.alive == true then local targets = {}
if weakAmount ~= nil and weakAmount > 0 then tm.weak = tm.weak + weakAmount end if c.affectsAllEnemies == true and self.Monsters ~= nil then
for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then
table.insert(targets, om)
end
end
elseif tm ~= nil and tm.alive == true then
table.insert(targets, tm)
end
if c.enemyStrengthLossThisTurn ~= nil and c.enemyStrengthLossThisTurn > 0 then
self.EnemyStrengthLossThisTurn = (self.EnemyStrengthLossThisTurn or 0) + c.enemyStrengthLossThisTurn
end
for ti = 1, #targets do
local target = targets[ti]
if target ~= nil and target.alive == true then
if c.removeEnemyBlock == true then
target.block = 0
end
if c.removeEnemyArtifact == true then
target.artifact = 0
end
if weakAmount ~= nil and weakAmount > 0 then
if target.artifact ~= nil and target.artifact > 0 then
target.artifact = target.artifact - 1
else
target.weak = target.weak + weakAmount
end
end
if poisonAmount ~= nil and poisonAmount > 0 then if poisonAmount ~= nil and poisonAmount > 0 then
if c.poisonIfTargetPoisoned ~= true or (target.poison ~= nil and target.poison > 0) then
local poisonHits = c.poisonHits or 1 local poisonHits = c.poisonHits or 1
for pi = 1, poisonHits do for pi = 1, poisonHits do
local target = tm local target2 = target
if c.poisonRandomTargets == true and self.Monsters ~= nil then if c.poisonRandomTargets == true and self.Monsters ~= nil then
local alive = {} local alive = {}
for mi = 1, #self.Monsters do for mi = 1, #self.Monsters do
@@ -513,18 +631,24 @@ if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil
end end
end end
if #alive > 0 then if #alive > 0 then
target = alive[math.random(#alive)] target2 = alive[math.random(#alive)]
end end
end end
if target ~= nil and target.alive == true then if target2 ~= nil and target2.alive == true then
target.poison = (target.poison or 0) + poisonAmount self:ApplyPoisonToMonster(target2, poisonAmount)
end
end end
end end
end end
if vulnAmount ~= nil and vulnAmount > 0 then if vulnAmount ~= nil and vulnAmount > 0 then
tm.vuln = tm.vuln + vulnAmount if target.artifact ~= nil and target.artifact > 0 then
target.artifact = target.artifact - 1
else
target.vuln = target.vuln + vulnAmount
if self:HasRelic("championBelt") then if self:HasRelic("championBelt") then
tm.weak = tm.weak + 1 target.weak = target.weak + 1
end
end
end end
end end
end end
@@ -573,10 +697,11 @@ if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison >
dmg = dmg - absorbed dmg = dmg - absorbed
end end
if drawPoison ~= nil and drawPoison > 0 then if drawPoison ~= nil and drawPoison > 0 then
m2.poison = (m2.poison or 0) + drawPoison self:ApplyPoisonToMonster(m2, drawPoison)
end end
if dmg > 0 then if dmg > 0 then
m2.hp = m2.hp - dmg m2.hp = m2.hp - dmg
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
end end
self:ShowDmgPop(mi, dmg) self:ShowDmgPop(mi, dmg)
self:MonsterHitMotion(mi) self:MonsterHitMotion(mi)
@@ -599,9 +724,16 @@ end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
]), ]),
method('TriggerSly', `local c = self.Cards[cardId] method('TriggerSly', `local c = self.Cards[cardId]
if c == nil or c.sly ~= true then if c == nil then
return return
end end
if c.sly ~= true then
local onPlay = self.SkillSlyOnPlayCards ~= nil and self.SkillSlyOnPlayCards[cardId] == true
local tempSly = self.TurnSkillSlyCards ~= nil and self.TurnSkillSlyCards[cardId] == true
if onPlay ~= true and tempSly ~= true then
return
end
end
self:Toast("교활 발동: " .. c.name) self:Toast("교활 발동: " .. c.name)
self:ResolveCardEffects(cardId, 0, c, true, 0)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]), self:ResolveCardEffects(cardId, 0, c, true, 0)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
method('DiscardHandCard', `if self.Hand == nil then method('DiscardHandCard', `if self.Hand == nil then

View File

@@ -74,11 +74,16 @@ self.DrawDisabledThisTurn = false
self.NextSkillCostZero = false self.NextSkillCostZero = false
self.NextSkillRepeatCount = 0 self.NextSkillRepeatCount = 0
self.SkillCostReductionThisTurn = 0 self.SkillCostReductionThisTurn = 0
self.CombatCardCostReduction = {}
self.SkillSlyOnPlayCards = {}
self.TurnSkillSlyCards = {}
self.ShivFirstDamageBonusUsed = false self.ShivFirstDamageBonusUsed = false
self.ActiveAttackDamageVsWeakMultiplier = 1 self.ActiveAttackDamageVsWeakMultiplier = 1
self.DrawDamageThisTurn = 0 self.DrawDamageThisTurn = 0
self.DrawPoisonThisTurn = 0 self.DrawPoisonThisTurn = 0
self.ShivAoeThisCombat = false self.ShivAoeThisCombat = false
self.PoisonApplicationsThisCombat = 0
self.EnemyStrengthLossThisTurn = 0
self.PlayerStr = 0 self.PlayerStr = 0
self.PlayerDex = 0 self.PlayerDex = 0
self.PlayerThorns = 0 self.PlayerThorns = 0
@@ -91,6 +96,8 @@ self.PlayerPowers = {}
self.FightAttackCount = 0 self.FightAttackCount = 0
self.TurnAttackCardsPlayed = 0 self.TurnAttackCardsPlayed = 0
self.TurnDiscardedCards = 0 self.TurnDiscardedCards = 0
self.TurnCardsPlayedThisTurn = 0
self.DamageDealtThisTurn = 0
self.DmgPopSeq = 0 self.DmgPopSeq = 0
self.FirstHpLossDone = false self.FirstHpLossDone = false
self.ClayBlockNext = 0 self.ClayBlockNext = 0
@@ -233,7 +240,7 @@ for i = 1, n do
local startIdx = 1 local startIdx = 1
if #intents > 0 then startIdx = math.random(1, #intents) end if #intents > 0 then startIdx = math.random(1, #intents) end
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name, self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, poison = 0, hp = maxHp, maxHp = maxHp, block = 0, str = e.str or 0, weak = 0, vuln = 0, poison = 0, artifact = e.artifact or 0,
hitClip = hitClip, standClip = standClip, motionBusy = false, hitClip = hitClip, standClip = standClip, motionBusy = false,
intents = intents, intentIdx = startIdx, alive = true, slot = i } intents = intents, intentIdx = startIdx, alive = true, slot = i }
self:ReviveMonsterEntity(item.entity) self:ReviveMonsterEntity(item.entity)

View File

@@ -166,6 +166,7 @@ function luaCardsTable(cards) {
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`); if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`); if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`);
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.rewardOnKill != null) fields.push(`rewardOnKill = ${c.rewardOnKill}`); if (c.rewardOnKill != null) fields.push(`rewardOnKill = ${c.rewardOnKill}`);
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}`);
@@ -175,6 +176,7 @@ function luaCardsTable(cards) {
if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`); if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`);
if (c.block != null) fields.push(`block = ${c.block}`); if (c.block != null) fields.push(`block = ${c.block}`);
if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`); if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`);
if (c.blockPerDamageDealtThisTurn != null) fields.push(`blockPerDamageDealtThisTurn = ${c.blockPerDamageDealtThisTurn}`);
if (c.strength != null) fields.push(`strength = ${c.strength}`); if (c.strength != null) fields.push(`strength = ${c.strength}`);
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}`);
@@ -215,6 +217,7 @@ function luaCardsTable(cards) {
if (c.attackDamageVsWeakMultiplier != null) fields.push(`attackDamageVsWeakMultiplier = ${c.attackDamageVsWeakMultiplier}`); if (c.attackDamageVsWeakMultiplier != null) fields.push(`attackDamageVsWeakMultiplier = ${c.attackDamageVsWeakMultiplier}`);
if (c.poisonHits != null) fields.push(`poisonHits = ${c.poisonHits}`); if (c.poisonHits != null) fields.push(`poisonHits = ${c.poisonHits}`);
if (c.poisonRandomTargets === true) fields.push('poisonRandomTargets = true'); if (c.poisonRandomTargets === true) fields.push('poisonRandomTargets = true');
if (c.poisonIfTargetPoisoned === true) fields.push('poisonIfTargetPoisoned = true');
if (c.xDamagePerEnergy != null) fields.push(`xDamagePerEnergy = ${c.xDamagePerEnergy}`); if (c.xDamagePerEnergy != null) fields.push(`xDamagePerEnergy = ${c.xDamagePerEnergy}`);
if (c.xWeakPerEnergy != null) fields.push(`xWeakPerEnergy = ${c.xWeakPerEnergy}`); if (c.xWeakPerEnergy != null) fields.push(`xWeakPerEnergy = ${c.xWeakPerEnergy}`);
if (c.nextTurnBlock != null) fields.push(`nextTurnBlock = ${c.nextTurnBlock}`); if (c.nextTurnBlock != null) fields.push(`nextTurnBlock = ${c.nextTurnBlock}`);
@@ -227,6 +230,18 @@ function luaCardsTable(cards) {
if (c.nextSkillRepeatCount != null) fields.push(`nextSkillRepeatCount = ${c.nextSkillRepeatCount}`); if (c.nextSkillRepeatCount != null) fields.push(`nextSkillRepeatCount = ${c.nextSkillRepeatCount}`);
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true'); if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`); if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
if (c.skillSlyOnPlay === true) fields.push('skillSlyOnPlay = true');
if (c.turnHandSlyCount != null) fields.push(`turnHandSlyCount = ${c.turnHandSlyCount}`);
if (c.combatCostReductionOnPlay != null) fields.push(`combatCostReductionOnPlay = ${c.combatCostReductionOnPlay}`);
if (c.randomTargetEachHit === true) fields.push('randomTargetEachHit = true');
if (c.repeatOnKill === true) fields.push('repeatOnKill = true');
if (c.affectsAllEnemies === true) fields.push('affectsAllEnemies = true');
if (c.removeEnemyBlock === true) fields.push('removeEnemyBlock = true');
if (c.removeEnemyArtifact === true) fields.push('removeEnemyArtifact = true');
if (c.enemyStrengthLossThisTurn != null) fields.push(`enemyStrengthLossThisTurn = ${c.enemyStrengthLossThisTurn}`);
if (c.extraPoisonTicks != null) fields.push(`extraPoisonTicks = ${c.extraPoisonTicks}`);
if (c.poisonApplicationBurstEvery != null) fields.push(`poisonApplicationBurstEvery = ${c.poisonApplicationBurstEvery}`);
if (c.poisonApplicationBurstDamage != null) fields.push(`poisonApplicationBurstDamage = ${c.poisonApplicationBurstDamage}`);
if (c.innate === true) fields.push('innate = true'); if (c.innate === true) fields.push('innate = true');
if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true'); if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
if (c.sly === true) fields.push('sly = true'); if (c.sly === true) fields.push('sly = true');