feat: 도적 공용 카드 효과 구현 #89
File diff suppressed because one or more lines are too long
@@ -59,6 +59,7 @@
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"damage": 8,
|
||||
"firstCardDamageBonus": 2,
|
||||
"vuln": 2,
|
||||
"desc": "피해 8, 취약 2",
|
||||
"image": "fe83c7635b0e49ed83d75a2833adb53e",
|
||||
@@ -527,7 +528,8 @@
|
||||
"damage": 3,
|
||||
"hits": 4,
|
||||
"sly": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d",
|
||||
"randomTargetEachHit": true
|
||||
},
|
||||
"Prepared": {
|
||||
"name": "예비",
|
||||
@@ -536,7 +538,7 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 1,
|
||||
"blockPerDamageDealtThisTurn": 1,
|
||||
"discard": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
@@ -601,7 +603,9 @@
|
||||
"rarity": "normal",
|
||||
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
|
||||
"draw": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
"image": "0946f69d84464df29b24b94c744c868d",
|
||||
"affectsAllEnemies": true,
|
||||
"enemyStrengthLossThisTurn": 6
|
||||
},
|
||||
"CloakAndDagger": {
|
||||
"name": "망토와 단검",
|
||||
@@ -786,7 +790,10 @@
|
||||
"rarity": "unique",
|
||||
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
|
||||
"vuln": 2,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
"image": "0946f69d84464df29b24b94c744c868d",
|
||||
"affectsAllEnemies": true,
|
||||
"removeEnemyBlock": true,
|
||||
"removeEnemyArtifact": true
|
||||
},
|
||||
"HiddenDaggers": {
|
||||
"name": "숨겨진 단검",
|
||||
@@ -829,7 +836,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
|
||||
"block": 7,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"turnHandSlyCount": 1
|
||||
},
|
||||
"Mirage": {
|
||||
"name": "신기루",
|
||||
@@ -859,7 +867,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
|
||||
"poison": 9,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
"image": "19361e72087946b1888684185b40d935",
|
||||
"poisonIfTargetPoisoned": true
|
||||
},
|
||||
"Blur": {
|
||||
"name": "흐릿함",
|
||||
@@ -891,7 +900,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
|
||||
"addShiv": 3,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d",
|
||||
"combatCostReductionOnPlay": 1
|
||||
},
|
||||
"BouncingFlask": {
|
||||
"name": "탄성 플라스크",
|
||||
@@ -975,12 +985,10 @@
|
||||
"kind": "Power",
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "중독을 3번 부여할 때마다, 모든 적에게 피해를 11 줍니다.",
|
||||
"aoe": true,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 11,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
"desc": "독이 3번 부여될 때마다 모든 적에게 11 피해를 줍니다.",
|
||||
"image": "19361e72087946b1888684185b40d935",
|
||||
"poisonApplicationBurstEvery": 3,
|
||||
"poisonApplicationBurstDamage": 11
|
||||
},
|
||||
"NoxiousFumes": {
|
||||
"name": "유독 가스",
|
||||
@@ -1059,7 +1067,8 @@
|
||||
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
|
||||
"aoe": true,
|
||||
"damage": 10,
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2",
|
||||
"repeatOnKill": true
|
||||
},
|
||||
"TheHunt": {
|
||||
"name": "사냥",
|
||||
@@ -1228,10 +1237,9 @@
|
||||
"kind": "Power",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "중독이 1번 추가로 발동합니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
"desc": "적 턴 시작 시 독이 한 번 더 틱합니다.",
|
||||
"image": "19361e72087946b1888684185b40d935",
|
||||
"extraPoisonTicks": 1
|
||||
},
|
||||
"Envenom": {
|
||||
"name": "독 바르기",
|
||||
@@ -1249,10 +1257,9 @@
|
||||
"kind": "Power",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "스킬 카드를 사용 시, 그 카드가 교활을 얻습니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
"desc": "사용한 스킬 카드는 교활해집니다.",
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"skillSlyOnPlay": true
|
||||
},
|
||||
"Tracking": {
|
||||
"name": "추적",
|
||||
|
||||
@@ -4,7 +4,7 @@ Current status of bandit cards and shared effect hooks.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -13,67 +13,10 @@ Shared hooks already in use:
|
||||
- `turnStartDraw`, `turnStartDiscard`
|
||||
- `nextTurnBlock`, `nextTurnDraw`, `nextTurnKeepBlock`, `nextTurnAttackMultiplier`, `nextTurnCopies`, `nextTurnSelectHandCard`
|
||||
- `damagePerOtherHandCard`, `damagePerAttackPlayedThisTurn`, `damagePerDiscardedThisTurn`, `damagePerSkillInHand`, `otherHandAtLeast`, `bonusHitsWhenOtherHandAtLeast`
|
||||
- `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `nextSkillCostZero`, `skillCostReductionThisTurn`
|
||||
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets`
|
||||
|
||||
## 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.
|
||||
- `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `blockPerDamageDealtThisTurn`, `nextSkillCostZero`, `skillCostReductionThisTurn`
|
||||
- `firstCardDamageBonus`
|
||||
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets`, `skillSlyOnPlay`, `extraPoisonTicks`, `poisonApplicationBurstEvery`, `poisonApplicationBurstDamage`
|
||||
|
||||
## Open questions
|
||||
|
||||
- `MasterPlanner`
|
||||
- `Accelerant`
|
||||
- `Outbreak`
|
||||
|
||||
These three still need a confirmed rule interpretation before we lock them in.
|
||||
None at the moment.
|
||||
|
||||
@@ -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
|
||||
- `cardPlayedDamage`: 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
|
||||
- `blockPerDamageDealtThisTurn`: gain block equal to damage dealt this turn
|
||||
- `shivDamageBonus`: bonus damage for all Shivs
|
||||
- `firstShivDamageBonus`: bonus damage for the first Shiv each turn
|
||||
- `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
|
||||
|
||||
@@ -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
|
||||
- `drawSkillBlock`: gain block for each Skill 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
|
||||
- `gainEnergy`: gain energy immediately
|
||||
- `strength`: gain Strength
|
||||
- `dex`: gain Dexterity
|
||||
- `thorns`: gain Thorns
|
||||
- `selfVuln`: apply Vulnerable to self
|
||||
- `extraPoisonTicks`: add extra poison ticks at enemy turn start
|
||||
|
||||
## Status
|
||||
|
||||
@@ -45,6 +56,19 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
- `poison`: apply Poison
|
||||
- `poisonHits`: apply poison multiple times
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ export function calcAttack(base, str, weak, vulnOnTarget) {
|
||||
return dmg;
|
||||
}
|
||||
|
||||
export function calcEnemyAttack(base, str, weak, vulnOnTarget, strengthLoss = 0) {
|
||||
return calcAttack(base, Math.max(0, str - strengthLoss), weak, vulnOnTarget);
|
||||
}
|
||||
|
||||
// 방어 우선 차감 후 hp 적용 → { hp, block }
|
||||
export function applyDamage(hp, block, amount) {
|
||||
let dmg = amount;
|
||||
@@ -100,12 +104,16 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
||||
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
|
||||
effectiveCost = Math.max(0, effectiveCost - ctx.combatCardCostReduction[x.id]);
|
||||
}
|
||||
return card.useAllEnergy === true ? true : effectiveCost <= energy;
|
||||
});
|
||||
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
||||
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
||||
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
||||
const effectiveCost = (card) => {
|
||||
const effectiveCost = (x) => {
|
||||
const card = cards[x.id];
|
||||
let cost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
||||
else if (card.useAllEnergy === true) cost = 1;
|
||||
@@ -113,10 +121,13 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
if (ctx.nextSkillCostZero === true) cost = 0;
|
||||
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
|
||||
cost = Math.max(0, cost - ctx.combatCardCostReduction[x.id]);
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(x), 1);
|
||||
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
||||
if (powers.length) return powers[0].i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
@@ -155,25 +166,96 @@ export function simulateCombat(data, rng, stats) {
|
||||
let nextSkillCostZero = false;
|
||||
let nextSkillRepeatCount = 0;
|
||||
let skillCostReductionThisTurn = 0;
|
||||
const combatCardCostReduction = {};
|
||||
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
|
||||
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
|
||||
let nextTurnAddCards = [];
|
||||
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
|
||||
let turnCardsPlayedThisTurn = 0;
|
||||
let damageDealtThisTurn = 0;
|
||||
let shivFirstDamageBonusUsed = false;
|
||||
let drawDamageThisTurn = 0;
|
||||
let drawPoisonThisTurn = 0;
|
||||
let shivAoeThisCombat = false;
|
||||
const skillSlyOnPlayCards = new Set();
|
||||
const turnSkillSlyCards = new Set();
|
||||
let poisonApplicationsThisCombat = 0;
|
||||
let enemyStrengthLossThisTurn = 0;
|
||||
let cardsDrawnThisCombat = 0;
|
||||
let bonusRewardScreens = 0;
|
||||
let activeKillReward = 0;
|
||||
let energy = 0;
|
||||
const powers = [];
|
||||
const mob = monsters.map((m) => ({
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: m.str || 0, weak: 0, vuln: 0, poison: 0, artifact: m.artifact || 0,
|
||||
intents: m.intents, intentIdx: 0, alive: true,
|
||||
}));
|
||||
let turns = 0;
|
||||
|
||||
const aliveMonsters = () => mob.filter((m) => m.alive);
|
||||
const countAliveMonsters = () => aliveMonsters().length;
|
||||
const randomAliveMonster = () => {
|
||||
const alive = aliveMonsters();
|
||||
if (!alive.length) return null;
|
||||
return alive[Math.floor(rng() * alive.length)];
|
||||
};
|
||||
const removeEnemyBlock = (target) => {
|
||||
if (target) target.block = 0;
|
||||
};
|
||||
const removeEnemyArtifact = (target) => {
|
||||
if (target) target.artifact = 0;
|
||||
};
|
||||
const applyMonsterWeak = (target, amount) => {
|
||||
if (!target || !amount || amount <= 0) return;
|
||||
if (target.artifact > 0) { target.artifact--; return; }
|
||||
target.weak += amount;
|
||||
};
|
||||
const applyMonsterVuln = (target, amount) => {
|
||||
if (!target || !amount || amount <= 0) return;
|
||||
if (target.artifact > 0) { target.artifact--; return; }
|
||||
target.vuln += amount;
|
||||
};
|
||||
const applyPoisonToMonster = (target, amount) => {
|
||||
if (!target || !target.alive || !amount || amount <= 0) return;
|
||||
if (target.artifact > 0) { target.artifact--; return; }
|
||||
target.poison += amount;
|
||||
poisonApplicationsThisCombat += 1;
|
||||
const burstEvery = powerFieldTotal('poisonApplicationBurstEvery');
|
||||
const burstDamage = powerFieldTotal('poisonApplicationBurstDamage');
|
||||
if (burstEvery > 0 && burstDamage > 0 && poisonApplicationsThisCombat % burstEvery === 0) {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
const r = applyDamage(m.hp, m.block, burstDamage);
|
||||
m.hp = r.hp; m.block = r.block;
|
||||
if (burstDamage > 0) damageDealtThisTurn += burstDamage;
|
||||
if (m.hp <= 0) m.alive = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const dealDamageToMonster = (target, amount, pierce = false) => {
|
||||
if (!target || !target.alive) return false;
|
||||
let dmg = amount;
|
||||
const effectiveStr = Math.max(0, target.str - enemyStrengthLossThisTurn);
|
||||
dmg = calcAttack(dmg, effectiveStr, target.weak, 0);
|
||||
if (target.vuln > 0) dmg = Math.floor(dmg * 1.5);
|
||||
if (target.block > 0 && !pierce) {
|
||||
const absorbed = Math.min(target.block, dmg);
|
||||
target.block -= absorbed;
|
||||
dmg -= absorbed;
|
||||
}
|
||||
target.hp -= dmg;
|
||||
if (dmg > 0) {
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (attackPoison > 0) applyPoisonToMonster(target, attackPoison);
|
||||
}
|
||||
if (target.hp <= 0) {
|
||||
target.hp = 0;
|
||||
target.alive = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function draw(n) {
|
||||
const drawn = [];
|
||||
if (drawDisabledThisTurn === true) return drawn;
|
||||
@@ -195,8 +277,11 @@ export function simulateCombat(data, rng, stats) {
|
||||
m.block -= absorbed;
|
||||
dmg -= absorbed;
|
||||
}
|
||||
if (drawPoison > 0) m.poison += drawPoison;
|
||||
if (dmg > 0) m.hp -= dmg;
|
||||
if (drawPoison > 0) applyPoisonToMonster(m, drawPoison);
|
||||
if (dmg > 0) {
|
||||
m.hp -= dmg;
|
||||
damageDealtThisTurn += dmg;
|
||||
}
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; }
|
||||
}
|
||||
}
|
||||
@@ -257,6 +342,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
|
||||
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
|
||||
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
|
||||
if (c.class === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus;
|
||||
if (c.class === 'shiv') {
|
||||
if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus');
|
||||
if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus');
|
||||
@@ -314,6 +400,19 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.drawDamage && c.kind !== 'Power') drawDamageThisTurn += c.drawDamage;
|
||||
if (c.drawPoison && c.kind !== 'Power') drawPoisonThisTurn += c.drawPoison;
|
||||
if (c.shivAoe === true && c.kind !== 'Power') shivAoeThisCombat = true;
|
||||
if (c.skillSlyOnPlay === true && c.kind === 'Skill') skillSlyOnPlayCards.add(id);
|
||||
if (c.turnHandSlyCount && c.turnHandSlyCount > 0) {
|
||||
let picked = 0;
|
||||
for (const hid of hand) {
|
||||
if (hid === id) continue;
|
||||
const hc = cards[hid];
|
||||
if (hc?.kind === 'Skill' && !turnSkillSlyCards.has(hid) && !skillSlyOnPlayCards.has(hid) && hc.sly !== true) {
|
||||
turnSkillSlyCards.add(hid);
|
||||
picked++;
|
||||
if (picked >= c.turnHandSlyCount) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const xEnergy = costSpent || 0;
|
||||
if (c.kind === 'Attack') {
|
||||
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
|
||||
@@ -321,49 +420,70 @@ export function simulateCombat(data, rng, stats) {
|
||||
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
|
||||
? c.bonusHitsWhenOtherHandAtLeast : 0;
|
||||
const hitN = (c.hits || 1) + bonusHits;
|
||||
const preview = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
const target = chooseTarget(alive, preview);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
let totalNv = 0;
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
dmg = totalNv;
|
||||
let useAoe = c.aoe === true;
|
||||
if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true;
|
||||
if (useAoe === true) {
|
||||
for (const m2 of aliveList()) {
|
||||
let d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
if (m2.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) {
|
||||
d2 = Math.floor(d2 * c.attackDamageVsWeakMultiplier);
|
||||
}
|
||||
const r2 = applyDamage(m2.hp, m2.block, d2);
|
||||
m2.hp = r2.hp; m2.block = r2.block;
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison;
|
||||
if (m2.hp <= 0) {
|
||||
m2.alive = false;
|
||||
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
const perHit = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
const dealToTarget = (target, amount) => {
|
||||
if (!target || !target.alive) return { killed: false, dealt: 0 };
|
||||
let dealt = amount;
|
||||
if (target.vuln > 0) dealt = Math.floor(dealt * 1.5);
|
||||
if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) {
|
||||
dmg = Math.floor(dmg * c.attackDamageVsWeakMultiplier);
|
||||
dealt = Math.floor(dealt * c.attackDamageVsWeakMultiplier);
|
||||
}
|
||||
if (c.pierce === true) {
|
||||
target.hp -= dmg;
|
||||
target.hp -= dealt;
|
||||
if (target.hp < 0) target.hp = 0;
|
||||
} else {
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
const r = applyDamage(target.hp, target.block, dealt);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
}
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (dmg > 0 && attackPoison > 0) target.poison += attackPoison;
|
||||
if (dealt > 0 && attackPoison > 0) applyPoisonToMonster(target, attackPoison);
|
||||
let killed = false;
|
||||
if (target.hp <= 0) {
|
||||
target.alive = false;
|
||||
killed = true;
|
||||
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
|
||||
}
|
||||
return { killed, dealt };
|
||||
};
|
||||
const resolveAttackRound = () => {
|
||||
let roundKilled = false;
|
||||
let roundDamage = 0;
|
||||
if (useAoe === true) {
|
||||
for (const m2 of aliveList()) {
|
||||
const r2 = dealToTarget(m2, perHit);
|
||||
roundDamage += r2.dealt;
|
||||
if (r2.killed) roundKilled = true;
|
||||
}
|
||||
} else if (c.randomTargetEachHit === true) {
|
||||
for (let h = 0; h < hitN; h++) {
|
||||
const target = randomAliveMonster();
|
||||
if (!target) break;
|
||||
const r = dealToTarget(target, perHit);
|
||||
roundDamage += r.dealt;
|
||||
if (r.killed) roundKilled = true;
|
||||
}
|
||||
} else {
|
||||
const preview = perHit;
|
||||
const target = chooseTarget(aliveList(), preview);
|
||||
if (target) {
|
||||
if (c.weak) applyMonsterWeak(target, c.weak);
|
||||
if (c.vuln) applyMonsterVuln(target, c.vuln);
|
||||
const totalNv = perHit * hitN;
|
||||
const r = dealToTarget(target, totalNv);
|
||||
roundDamage += r.dealt;
|
||||
if (r.killed) roundKilled = true;
|
||||
}
|
||||
}
|
||||
dmg += roundDamage;
|
||||
damageDealtThisTurn += roundDamage;
|
||||
return roundKilled;
|
||||
};
|
||||
let roundKilled = false;
|
||||
do {
|
||||
roundKilled = resolveAttackRound();
|
||||
} while (c.repeatOnKill === true && roundKilled === true && countAliveMonsters() > 0);
|
||||
}
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
} else if (c.kind === 'Power') {
|
||||
@@ -371,17 +491,28 @@ export function simulateCombat(data, rng, stats) {
|
||||
} else {
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy;
|
||||
if ((weakAmount || c.vuln || c.poison) && alive.length) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (weakAmount) target.weak += weakAmount;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
const vulnAmount = c.vuln || 0;
|
||||
if ((weakAmount || vulnAmount || c.poison || c.removeEnemyBlock || c.removeEnemyArtifact || c.enemyStrengthLossThisTurn) && alive.length) {
|
||||
const targets = c.affectsAllEnemies === true ? aliveList() : [chooseTarget(alive, 0)];
|
||||
if (c.enemyStrengthLossThisTurn && c.enemyStrengthLossThisTurn > 0) {
|
||||
enemyStrengthLossThisTurn += c.enemyStrengthLossThisTurn;
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (!target || !target.alive) continue;
|
||||
if (c.removeEnemyBlock === true) removeEnemyBlock(target);
|
||||
if (c.removeEnemyArtifact === true) removeEnemyArtifact(target);
|
||||
if (weakAmount) applyMonsterWeak(target, weakAmount);
|
||||
if (vulnAmount) applyMonsterVuln(target, vulnAmount);
|
||||
if (c.poison) {
|
||||
if (c.poisonIfTargetPoisoned !== true || target.poison > 0) {
|
||||
const poisonHits = c.poisonHits || 1;
|
||||
for (let i = 0; i < poisonHits; i++) {
|
||||
const target2 = c.poisonRandomTargets === true
|
||||
? alive[Math.floor(rng() * alive.length)]
|
||||
: target;
|
||||
if (target2) target2.poison += c.poison;
|
||||
if (target2) applyPoisonToMonster(target2, c.poison);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
|
||||
@@ -398,6 +529,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
activeKillReward = c.rewardOnKill || 0;
|
||||
if (c.intangible) pIntangible += c.intangible;
|
||||
queueNextTurnEffects(c);
|
||||
turnCardsPlayedThisTurn++;
|
||||
let drawnCards = [];
|
||||
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
|
||||
if (c.drawUntilHandSize) {
|
||||
@@ -415,6 +547,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
dmg += c.cardPlayedDamage;
|
||||
damageDealtThisTurn += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
@@ -425,15 +558,20 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
dmg += c.cardPlayedRandomDamage;
|
||||
damageDealtThisTurn += c.cardPlayedRandomDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') {
|
||||
blockGained += Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn);
|
||||
}
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||
}
|
||||
function triggerSly(id) {
|
||||
const c = cards[id];
|
||||
if (!c?.sly) return;
|
||||
if (!c) return;
|
||||
if (!c.sly && !skillSlyOnPlayCards.has(id) && !turnSkillSlyCards.has(id)) return;
|
||||
resolveCardEffects(id, c, 0, false);
|
||||
}
|
||||
function discardHandCard(idx, trigger = true) {
|
||||
@@ -464,6 +602,8 @@ export function simulateCombat(data, rng, stats) {
|
||||
drawDamageThisTurn = 0;
|
||||
drawPoisonThisTurn = 0;
|
||||
shivAoeThisCombat = false;
|
||||
turnSkillSlyCards.clear();
|
||||
enemyStrengthLossThisTurn = 0;
|
||||
blockGainMultiplier = 1;
|
||||
handCostZeroThisTurn = false;
|
||||
drawDisabledThisTurn = false;
|
||||
@@ -483,7 +623,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
||||
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
||||
else if (pc.powerEffect === 'poisonPerTurn') {
|
||||
for (const m of mob) if (m.alive) m.poison += pc.value;
|
||||
for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value);
|
||||
} else if (pc.powerEffect === 'damagePerTurn') {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
@@ -509,60 +649,25 @@ export function simulateCombat(data, rng, stats) {
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn });
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn, combatCardCostReduction });
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
let dmg = 0;
|
||||
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
|
||||
const skillRepeat = c.kind === 'Skill' ? nextSkillRepeatCount : 0;
|
||||
const baseCost = c.cost || 0;
|
||||
const combatReduction = combatCardCostReduction[id] || 0;
|
||||
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
|
||||
energy -= cost;
|
||||
resolveCardEffects(id, c, cost);
|
||||
const finalCost = Math.max(0, cost - combatReduction);
|
||||
energy -= finalCost;
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
if (c.cardPlayedDamage && alive.length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
dmg += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedRandomDamage && alive.length) {
|
||||
const pool = aliveList();
|
||||
if (pool.length) {
|
||||
const target = pool[Math.floor(rng() * pool.length)];
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
dmg += c.cardPlayedRandomDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skillRepeat > 0) {
|
||||
nextSkillRepeatCount = Math.max(0, nextSkillRepeatCount - skillRepeat);
|
||||
for (let r = 0; r < skillRepeat; r++) {
|
||||
resolveCardEffects(id, c, cost);
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
if (c.cardPlayedDamage && alive.length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
dmg += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedRandomDamage && alive.length) {
|
||||
const pool = aliveList();
|
||||
if (pool.length) {
|
||||
const target = pool[Math.floor(rng() * pool.length)];
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
dmg += c.cardPlayedRandomDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||
@@ -571,6 +676,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
queueSelectedReserve(c);
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
||||
else if (c.kind !== 'Power') discard.push(id);
|
||||
if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) {
|
||||
combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay;
|
||||
}
|
||||
applyDiscardEffects(c);
|
||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens };
|
||||
}
|
||||
@@ -600,17 +708,20 @@ export function simulateCombat(data, rng, stats) {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
|
||||
if (m.poison > 0) {
|
||||
const poisonTicks = 1 + Math.max(0, powerFieldTotal('extraPoisonTicks'));
|
||||
for (let tick = 0; tick < poisonTicks; tick++) {
|
||||
if (m.poison <= 0) break;
|
||||
m.hp -= m.poison;
|
||||
m.poison--;
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; break; }
|
||||
}
|
||||
if (!m.alive) continue;
|
||||
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
||||
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
|
||||
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
|
||||
if (it) {
|
||||
if (it.kind === 'Attack') {
|
||||
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
|
||||
const atk = calcAttack(it.value, Math.max(0, m.str - enemyStrengthLossThisTurn), m.weak, pVuln);
|
||||
const beforeHp = pHp;
|
||||
let incoming = atk;
|
||||
if (pIntangible > 0 && incoming > 1) incoming = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll,
|
||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, calcEnemyAttack, rarityForRoll,
|
||||
} from './sim-balance.mjs';
|
||||
|
||||
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
|
||||
@@ -758,6 +758,14 @@ test("chooseAction: useAllEnergy cards remain playable at zero energy", () => {
|
||||
assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0);
|
||||
});
|
||||
|
||||
test("chooseAction: combatCardCostReduction discounts the same card across combat", () => {
|
||||
const cards = {
|
||||
Sleeve: { name: "UpMySleeve", cost: 2, kind: "Skill" },
|
||||
};
|
||||
assert.equal(chooseAction(["Sleeve"], cards, 1, { combatCardCostReduction: { Sleeve: 1 } }), 0);
|
||||
assert.equal(chooseAction(["Sleeve"], cards, 1, {}), -1);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
@@ -821,10 +829,181 @@ test("simulateCombat: attackPoison power applies poison on attack damage", () =>
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedDamage hits the target whenever a card is played", () => {
|
||||
test("simulateCombat: skillSlyOnPlay makes later discards of the same skill trigger sly effects", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true },
|
||||
},
|
||||
starterDeck: ["MasterPlanner", "MasterPlanner"],
|
||||
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const withSly = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true, skillSlyOnPlay: true },
|
||||
},
|
||||
}, () => 0.999999);
|
||||
const withoutSly = simulateCombat(shared, () => 0.999999);
|
||||
assert.equal(withSly.win, true);
|
||||
assert.equal(withSly.turns, 1);
|
||||
assert.ok(withoutSly.turns > withSly.turns);
|
||||
});
|
||||
|
||||
test("simulateCombat: randomTargetEachHit can spread hits across alive enemies", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4, randomTargetEachHit: true },
|
||||
},
|
||||
starterDeck: ["Ricochet"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const makeRng = () => {
|
||||
const seq = [0, 0.999999, 0, 0.999999];
|
||||
let i = 0;
|
||||
return () => seq[i++ % seq.length];
|
||||
};
|
||||
const withRicochet = simulateCombat(shared, makeRng());
|
||||
const withoutRicochet = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4 },
|
||||
},
|
||||
}, makeRng());
|
||||
assert.equal(withRicochet.win, true);
|
||||
assert.equal(withRicochet.turns, 1);
|
||||
assert.equal(withoutRicochet.turns, 2);
|
||||
});
|
||||
|
||||
test("calcEnemyAttack: enemyStrengthLossThisTurn reduces enemy attack damage", () => {
|
||||
assert.equal(calcEnemyAttack(10, 6, 0, 0, 6), 10);
|
||||
assert.equal(calcEnemyAttack(10, 6, 0, 0, 0), 16);
|
||||
});
|
||||
|
||||
test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10, repeatOnKill: true },
|
||||
},
|
||||
starterDeck: ["EchoingSlash"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 20, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const withRepeat = simulateCombat(shared, () => 0.999999);
|
||||
const withoutRepeat = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10 },
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withRepeat.win, true);
|
||||
assert.equal(withRepeat.turns, 1);
|
||||
assert.equal(withoutRepeat.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: poisonIfTargetPoisoned only applies poison to already poisoned enemies", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9, poisonIfTargetPoisoned: true },
|
||||
},
|
||||
starterDeck: ["Bubble"],
|
||||
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const withBubble = simulateCombat(shared, () => 0.999999);
|
||||
const withoutBubble = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9 },
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withBubble.draw, true);
|
||||
assert.equal(withBubble.turns, 100);
|
||||
assert.equal(withoutBubble.win, true);
|
||||
assert.equal(withoutBubble.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: turnHandSlyCount marks a skill in hand as sly for the turn", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7, turnHandSlyCount: 1 },
|
||||
Shield: { name: "Shield", cost: 0, kind: "Skill", unplayable: true, block: 7 },
|
||||
Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true },
|
||||
},
|
||||
starterDeck: ["Gamble", "Shield", "HandTrick"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 10 }] }],
|
||||
};
|
||||
const withHandTrick = simulateCombat(shared, () => 0.999999);
|
||||
const withoutHandTrick = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7 },
|
||||
Shield: shared.cards.Shield,
|
||||
Gamble: shared.cards.Gamble,
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withHandTrick.playerHpRemaining, 80);
|
||||
assert.equal(withoutHandTrick.playerHpRemaining, 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: extraPoisonTicks adds an extra poison tick at enemy turn start", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Accelerant: { name: "Accelerant", cost: 1, kind: "Power", extraPoisonTicks: 1 },
|
||||
Poison: { name: "Poison", cost: 1, kind: "Skill", poison: 2 },
|
||||
},
|
||||
starterDeck: ["Accelerant", "Poison"],
|
||||
monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const withTick = simulateCombat(shared, () => 0.999999);
|
||||
const withoutTick = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Accelerant: { name: "Accelerant", cost: 1, kind: "Power" },
|
||||
Poison: shared.cards.Poison,
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withTick.win, true);
|
||||
assert.equal(withTick.turns, 1);
|
||||
assert.equal(withoutTick.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: poisonApplicationBurstEvery bursts after every third poison application", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
Outbreak: { name: "Outbreak", cost: 1, kind: "Power", poisonApplicationBurstEvery: 3, poisonApplicationBurstDamage: 11 },
|
||||
Poison1: { name: "Poison1", cost: 0, kind: "Skill", poison: 1 },
|
||||
Poison2: { name: "Poison2", cost: 0, kind: "Skill", poison: 1 },
|
||||
Poison3: { name: "Poison3", cost: 0, kind: "Skill", poison: 1 },
|
||||
},
|
||||
starterDeck: ["Outbreak", "Poison1", "Poison2", "Poison3"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const withBurst = simulateCombat(shared, () => 0.999999);
|
||||
const withoutBurst = simulateCombat({
|
||||
...shared,
|
||||
cards: {
|
||||
Outbreak: { name: "Outbreak", cost: 1, kind: "Power" },
|
||||
Poison1: shared.cards.Poison1,
|
||||
Poison2: shared.cards.Poison2,
|
||||
Poison3: shared.cards.Poison3,
|
||||
},
|
||||
}, () => 0.999999);
|
||||
assert.equal(withBurst.win, true);
|
||||
assert.equal(withBurst.turns, 1);
|
||||
assert.ok(withoutBurst.turns > withBurst.turns);
|
||||
});
|
||||
|
||||
test("simulateCombat: firstCardDamageBonus applies on the first card played this turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, cardPlayedDamage: 2 },
|
||||
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, firstCardDamageBonus: 2 },
|
||||
},
|
||||
starterDeck: ["Strangle"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
@@ -833,6 +1012,19 @@ test("simulateCombat: cardPlayedDamage hits the target whenever a card is played
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: blockPerDamageDealtThisTurn grants block from damage dealt this turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Mirage: { name: "Mirage", cost: 1, kind: "Skill", blockPerDamageDealtThisTurn: 1, block: 0 },
|
||||
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 4 },
|
||||
},
|
||||
starterDeck: ["Strike", "Mirage"],
|
||||
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
|
||||
@@ -59,6 +59,9 @@ end
|
||||
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
|
||||
cost = math.max(0, cost - self.SkillCostReductionThisTurn)
|
||||
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
|
||||
skillRepeat = self.NextSkillRepeatCount
|
||||
end
|
||||
@@ -103,6 +106,12 @@ end
|
||||
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
|
||||
self.ActiveKillReward = 0
|
||||
end
|
||||
if c.combatCostReductionOnPlay ~= nil and c.combatCostReductionOnPlay > 0 then
|
||||
if self.CombatCardCostReduction == nil then
|
||||
self.CombatCardCostReduction = {}
|
||||
end
|
||||
self.CombatCardCostReduction[cardId] = (self.CombatCardCostReduction[cardId] or 0) + c.combatCostReductionOnPlay
|
||||
end
|
||||
table.remove(self.Hand, slot)
|
||||
if c.exhaust == true then
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
@@ -283,7 +292,7 @@ m.hp = m.hp - dmg
|
||||
if dmg > 0 then
|
||||
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||
if poison ~= nil and poison > 0 then
|
||||
m.poison = (m.poison or 0) + poison
|
||||
self:ApplyPoisonToMonster(m, poison)
|
||||
end
|
||||
end
|
||||
self:MonsterHitMotion(m.slot)
|
||||
@@ -345,6 +354,62 @@ end
|
||||
return killed`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
], 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]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
self:DealDamageToTarget(damage, pierce)
|
||||
@@ -426,7 +491,7 @@ _TimerService:SetTimerOnce(function()
|
||||
if dmg > 0 then
|
||||
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||
if poison ~= nil and poison > 0 then
|
||||
m.poison = (m.poison or 0) + poison
|
||||
self:ApplyPoisonToMonster(m, poison)
|
||||
end
|
||||
end
|
||||
self:ShowDmgPop(i, dmg)
|
||||
@@ -518,6 +583,12 @@ local m = self.Monsters[idx]
|
||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(idx)
|
||||
self:SetEntityEnabled(base .. "/ActFrame", true)
|
||||
_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
|
||||
m.hp = m.hp - m.poison
|
||||
self:ShowDmgPop(idx, m.poison)
|
||||
@@ -532,12 +603,17 @@ _TimerService:SetTimerOnce(function()
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
m.block = 0
|
||||
local intent = m.intents[m.intentIdx]
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
self:MonsterLunge(idx)
|
||||
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
|
||||
atk = math.floor(atk * 0.75)
|
||||
end
|
||||
|
||||
@@ -228,6 +228,8 @@ self.RetainSelectActive = false
|
||||
self.ReserveSelectActive = false
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.TurnCardsPlayedThisTurn = 0
|
||||
self.DamageDealtThisTurn = 0
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.SkillCostReductionThisTurn = 0
|
||||
@@ -252,6 +254,9 @@ self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self.DrawDamageThisTurn = 0
|
||||
self.DrawPoisonThisTurn = 0
|
||||
self.ShivAoeThisCombat = false
|
||||
self.SkillSlyOnPlayCards = self.SkillSlyOnPlayCards or {}
|
||||
self.TurnSkillSlyCards = {}
|
||||
self.EnemyStrengthLossThisTurn = 0
|
||||
self.HandCostZeroThisTurn = false
|
||||
self.DrawDisabledThisTurn = false
|
||||
local powerTurnDraw = 0
|
||||
@@ -271,7 +276,7 @@ if self.PlayerPowers ~= nil then
|
||||
for j = 1, #self.Monsters do
|
||||
local tm = self.Monsters[j]
|
||||
if tm ~= nil and tm.alive == true then
|
||||
tm.poison = (tm.poison or 0) + pc.value
|
||||
self:ApplyPoisonToMonster(tm, pc.value)
|
||||
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
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self.TurnSkillSlyCards = {}
|
||||
self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]),
|
||||
method('DrawCards', `local drawnSlots = {}
|
||||
local drawnCards = {}
|
||||
|
||||
@@ -311,6 +311,9 @@ end
|
||||
if c.damagePerCardDrawnThisCombat ~= nil then
|
||||
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
|
||||
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 self:HasPowerField("shivDamageBonus") == true then
|
||||
base2 = base2 + self:AddPowerFieldTotal("shivDamageBonus")
|
||||
@@ -411,6 +414,33 @@ end
|
||||
if c.shivAoe == true and c.kind ~= "Power" then
|
||||
self.ShivAoeThisCombat = true
|
||||
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 weakAmount = c.weak 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
|
||||
self.ShivFirstDamageBonusUsed = true
|
||||
end
|
||||
if useAoe == true then
|
||||
self:PlayAoeFx(c.fx or c.image, total)
|
||||
else
|
||||
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true)
|
||||
local function countAliveMonsters()
|
||||
local n = 0
|
||||
if self.Monsters ~= nil then
|
||||
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
|
||||
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
|
||||
self:AddCardBlock(c.block)
|
||||
end
|
||||
@@ -490,20 +569,59 @@ end
|
||||
if c.intangible ~= nil and c.intangible > 0 then
|
||||
self.PlayerIntangible = (self.PlayerIntangible or 0) + c.intangible
|
||||
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)
|
||||
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]
|
||||
if tm == nil or tm.alive ~= true then
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end
|
||||
end
|
||||
end
|
||||
if tm ~= nil and tm.alive == true then
|
||||
if weakAmount ~= nil and weakAmount > 0 then tm.weak = tm.weak + weakAmount end
|
||||
local targets = {}
|
||||
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 c.poisonIfTargetPoisoned ~= true or (target.poison ~= nil and target.poison > 0) then
|
||||
local poisonHits = c.poisonHits or 1
|
||||
for pi = 1, poisonHits do
|
||||
local target = tm
|
||||
local target2 = target
|
||||
if c.poisonRandomTargets == true and self.Monsters ~= nil then
|
||||
local alive = {}
|
||||
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
|
||||
if #alive > 0 then
|
||||
target = alive[math.random(#alive)]
|
||||
target2 = alive[math.random(#alive)]
|
||||
end
|
||||
end
|
||||
if target ~= nil and target.alive == true then
|
||||
target.poison = (target.poison or 0) + poisonAmount
|
||||
if target2 ~= nil and target2.alive == true then
|
||||
self:ApplyPoisonToMonster(target2, poisonAmount)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
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
|
||||
tm.weak = tm.weak + 1
|
||||
target.weak = target.weak + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -573,10 +697,11 @@ if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison >
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
if drawPoison ~= nil and drawPoison > 0 then
|
||||
m2.poison = (m2.poison or 0) + drawPoison
|
||||
self:ApplyPoisonToMonster(m2, drawPoison)
|
||||
end
|
||||
if dmg > 0 then
|
||||
m2.hp = m2.hp - dmg
|
||||
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
|
||||
end
|
||||
self:ShowDmgPop(mi, dmg)
|
||||
self:MonsterHitMotion(mi)
|
||||
@@ -599,9 +724,16 @@ end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
|
||||
]),
|
||||
method('TriggerSly', `local c = self.Cards[cardId]
|
||||
if c == nil or c.sly ~= true then
|
||||
if c == nil then
|
||||
return
|
||||
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:ResolveCardEffects(cardId, 0, c, true, 0)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
|
||||
method('DiscardHandCard', `if self.Hand == nil then
|
||||
|
||||
@@ -74,11 +74,16 @@ self.DrawDisabledThisTurn = false
|
||||
self.NextSkillCostZero = false
|
||||
self.NextSkillRepeatCount = 0
|
||||
self.SkillCostReductionThisTurn = 0
|
||||
self.CombatCardCostReduction = {}
|
||||
self.SkillSlyOnPlayCards = {}
|
||||
self.TurnSkillSlyCards = {}
|
||||
self.ShivFirstDamageBonusUsed = false
|
||||
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self.DrawDamageThisTurn = 0
|
||||
self.DrawPoisonThisTurn = 0
|
||||
self.ShivAoeThisCombat = false
|
||||
self.PoisonApplicationsThisCombat = 0
|
||||
self.EnemyStrengthLossThisTurn = 0
|
||||
self.PlayerStr = 0
|
||||
self.PlayerDex = 0
|
||||
self.PlayerThorns = 0
|
||||
@@ -91,6 +96,8 @@ self.PlayerPowers = {}
|
||||
self.FightAttackCount = 0
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.TurnCardsPlayedThisTurn = 0
|
||||
self.DamageDealtThisTurn = 0
|
||||
self.DmgPopSeq = 0
|
||||
self.FirstHpLossDone = false
|
||||
self.ClayBlockNext = 0
|
||||
@@ -233,7 +240,7 @@ for i = 1, n do
|
||||
local startIdx = 1
|
||||
if #intents > 0 then startIdx = math.random(1, #intents) end
|
||||
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,
|
||||
intents = intents, intentIdx = startIdx, alive = true, slot = i }
|
||||
self:ReviveMonsterEntity(item.entity)
|
||||
|
||||
@@ -166,6 +166,7 @@ function luaCardsTable(cards) {
|
||||
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
|
||||
if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`);
|
||||
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.intangible != null) fields.push(`intangible = ${c.intangible}`);
|
||||
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.block != null) fields.push(`block = ${c.block}`);
|
||||
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.dex != null) fields.push(`dex = ${c.dex}`);
|
||||
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.poisonHits != null) fields.push(`poisonHits = ${c.poisonHits}`);
|
||||
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.xWeakPerEnergy != null) fields.push(`xWeakPerEnergy = ${c.xWeakPerEnergy}`);
|
||||
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.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
|
||||
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.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
|
||||
if (c.sly === true) fields.push('sly = true');
|
||||
|
||||
Reference in New Issue
Block a user