도적 카드 공통 효과 훅 정리 #88

Merged
maple merged 1 commits from codex/bandit-shared-hooks-pr into main 2026-06-22 17:50:39 +09:00
11 changed files with 343 additions and 158 deletions

File diff suppressed because one or more lines are too long

View File

@@ -900,7 +900,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "무작위 적에게 중독을 3만큼 3번 부여합니다.",
"poison": 9,
"poison": 3,
"poisonHits": 3,
"poisonRandomTargets": true,
"image": "19361e72087946b1888684185b40d935"
},
"Reflex": {
@@ -999,8 +1001,7 @@
"class": "bandit",
"rarity": "unique",
"desc": "표창의 피해량이 4 증가합니다.",
"powerEffect": "strengthPerTurn",
"value": 1,
"shivDamageBonus": 4,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"PhantomBlades": {
@@ -1010,8 +1011,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
"powerEffect": "strengthPerTurn",
"value": 1,
"shivRetain": true,
"firstShivDamageBonus": 9,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Speedster": {
@@ -1022,8 +1023,7 @@
"rarity": "unique",
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 2 줍니다.",
"aoe": true,
"powerEffect": "damagePerTurn",
"value": 2,
"drawDamage": 2,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"GrandFinale": {
@@ -1144,7 +1144,7 @@
"class": "bandit",
"rarity": "legend",
"desc": "이번 턴에 카드를 뽑을 때마다, 모든 적에게 중독을 2 부여합니다.",
"poison": 2,
"drawPoison": 2,
"image": "19361e72087946b1888684185b40d935"
},
"BladeOfInk": {
@@ -1261,8 +1261,7 @@
"class": "bandit",
"rarity": "legend",
"desc": "약화 상태의 적이 공격 카드로 받는 피해가 2배가 됩니다.",
"powerEffect": "strengthPerTurn",
"value": 1,
"attackDamageVsWeakMultiplier": 2,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"FanOfKnives": {
@@ -1272,9 +1271,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
"powerEffect": "strengthPerTurn",
"value": 1,
"addShiv": 4,
"shivAoe": true,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"SerpentForm": {

View File

@@ -1,100 +1,79 @@
# Bandit Card Audit
`bandit` 카드의 구현 상태를 카드별로 정리한 문서입니다.
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`
## 구현됨
Shared hooks already in use:
`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`
- `poison`, `innate`, `playableWhenDrawPileEmpty`
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne`
- `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
- `poison`, `innate`, `playableWhenDrawPileEmpty` 구현됨
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne` 구현됨
- `turnStartDraw`, `turnStartDiscard` 구현됨
- `nextTurnBlock`, `nextTurnDraw`, `nextTurnKeepBlock`, `nextTurnAttackMultiplier`, `nextTurnCopies`, `nextTurnSelectHandCard` 구현됨
- `damagePerOtherHandCard`, `damagePerAttackPlayedThisTurn`, `damagePerDiscardedThisTurn`, `damagePerSkillInHand`, `otherHandAtLeast`, `bonusHitsWhenOtherHandAtLeast` 구현됨
- `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `nextSkillCostZero`, `skillCostReductionThisTurn` 구현됨
`Ricochet`: random split attacks need fuller targeting rules.
## 부분구현
`Anticipate`: next-turn enemy intent preview plus block still needs exact behavior review.
`Ricochet`: 무작위 적 4회 타격이 아니라 일반 분산 공격으로만 처리됨
`PiercingWail`: enemy attack reduction is implemented as a shared Weak/Vulnerable style effect, but needs final balance review.
`Anticipate`: 이번 턴 동안 민첩 2가 아니라 전투 전체 민첩 증가
`Expose`: weak/vulnerable interaction is only partially modeled.
`PiercingWail`: 이번 턴 적 공격 감소가 아니라 공용 약화/취약 계열만 적용
`BubbleBubble`: still missing the condition for poison dragon form ownership.
`Expose`: 방어도/인공물 제거는 없고 취약만 적용됨
`Skewer`: X-cost attack.
`BubbleBubble`: 적이 독을 보유한 경우라는 조건이 아직 없음
`Outbreak`: every 3 poison applications deal 11 to all enemies.
`BouncingFlask`: 무작위 적 3번 분산 대신 단일 독 9 처리
`Strangle`: extra damage the first time a card is used.
## 미구현
`EscapePlan`: gain block when discarded.
`Skewer`: X코스트 연타 공격
`HandTrick`: one card becomes sly and grants block when discarded.
`Outbreak`: 독 3번 부여 시 전체 피해 트리거
`Mirage`: gain block equal to total damage dealt this turn.
`Strangle`: 이번 턴 카드 사용마다 추가 피해
`UpMySleeve`: create Shiv plus reduce discard cost.
`EscapePlan`: 드로우한 카드가 스킬이면 방어도 3
`NoxiousFumes`: poison all enemies at turn start.
`HandTrick`: 손패의 스킬 카드 하나에 교활 부여
`EchoingSlash`: repeat on kill.
`Mirage`: 모든 적의 독 총합만큼 방어 획득
`TheHunt`: kill reward is implemented.
`UpMySleeve`: 표창 생성 + 비용 감소
`Murder`: damage scales with cards drawn this combat.
`NoxiousFumes`: 턴 시작 전체 적 독 부여 파워
`Malaise`: X-cost weak/vulnerable reduction.
`Accuracy`: 표창 피해 증가 파워
`Pinpoint`: per-turn discard-based attack damage.
`PhantomBlades`: 표창 보존 + 첫 표창 강화
`BladeOfInk`: turn-based Shiv creation.
`Speedster`: 드로우할 때마다 전체 피해
`KnifeTrap`: playable only when draw pile is empty.
`EchoingSlash`: 처치 시 반복
`BulletTime`: hand cost zero plus draw disabled.
`TheHunt`: 처치 조건 보상
`Accelerant`: poison trigger timing is not finalized.
`Murder`: 이번 전투 동안 뽑은 카드 수 비례 피해
`Envenom`: attack poison on hit.
`Malaise`: X코스트 약화/피해 감소
`MasterPlanner`: skill discard interaction still needs a clear rules decision.
`Pinpoint`: 이번 턴 스킬 비용 감소
`SerpentForm`: damage on card play.
`CorrosiveWave`: 드로우할 때마다 독
`WraithForm`: intangible plus turn-end block loss.
`BladeOfInk`: 전용 표창 생성
## Open questions
`KnifeTrap`: 소멸된 표창 전부 사용
`BulletTime`: 드로우 금지 + 손패 무료 사용
`Accelerant`: 추가 독 발동
`Envenom`: 공격 적중 시 독 부여
`MasterPlanner`: 스킬 사용 시 교활 부여
`Tracking`: 약화된 적이 공격 피해를 2배로 받음
`FanOfKnives`: 표창이 모든 적 대상
`SerpentForm`: 카드 사용할 때마다 무작위 적에게 피해
`WraithForm`: 불가침 2 + 턴 종료 시 민첩 감소
## 다음 축
- 조건부 피해
- 카드 사용 트리거
- 비용/X코스트
- 드로우 연동 파워
- `MasterPlanner`
- `Accelerant`
- `Outbreak`
These three still need a confirmed rule interpretation before we lock them in.

View File

@@ -1,88 +1,102 @@
# Card Effect Fields
`data/cards.json`의 카드 효과를 공용 데이터 필드로 표현하는 기준 문서입니다.
This file tracks the shared data fields used by `data/cards.json`.
The goal is to keep card behavior reusable instead of hardcoding one-off card names.
## 피해 수치
## Damage
- `damage`: 기본 피해
- `damagePerOtherHandCard`: 손패의 다른 카드 수만큼 피해 증감
- `damagePerAttackPlayedThisTurn`: 이번 턴에 사용한 공격 카드 수만큼 피해 증감
- `damagePerDiscardedThisTurn`: 이번 턴에 버린 카드 수만큼 피해 증감
- `damagePerSkillInHand`: 손패의 스킬 카드 수만큼 피해 증감
- `otherHandAtLeast`: 손패의 다른 카드가 이 수 이상일 때 조건 충족
- `bonusHitsWhenOtherHandAtLeast`: 조건 충족 시 추가 적중 수
- `damage`: base attack damage
- `damagePerOtherHandCard`: bonus damage per other card in hand
- `damagePerAttackPlayedThisTurn`: bonus damage per attack played this turn
- `damagePerDiscardedThisTurn`: bonus damage per card discarded this turn
- `damagePerSkillInHand`: bonus damage per skill card in hand
- `damagePerCardDrawnThisCombat`: bonus damage per card drawn this combat
- `damagePerTurn`: damage applied at turn start
- `cardPlayedDamage`: damage when the card is played
- `cardPlayedRandomDamage`: random damage when the card is played
- `drawDamage`: damage dealt when a card is drawn
- `shivDamageBonus`: bonus damage for all Shivs
- `firstShivDamageBonus`: bonus damage for the first Shiv each turn
- `attackDamageVsWeakMultiplier`: multiplier when the attack hits Weak targets
## 방어/상태
## Block and utility
- `block`: 방어도 획득
- `cardPlayedBlock`: 카드를 사용할 때마다 방어도 획득
- `blockGainMultiplier`: 이번 턴 동안 얻는 방어도 배수
- `hits`: 다단히트 횟수
- `aoe`: 모든 적 대상
- `pierce`: 방어도 무시
- `draw`: 즉시 드로우
- `drawUntilHandSize`: 손패가 지정 장수에 도달할 때까지 드로우
- `heal`: 즉시 회복
- `gainEnergy`: 즉시 에너지 획득
- `strength`: 힘 획득
- `dex`: 민첩 획득
- `thorns`: 가시 획득
- `selfVuln`: 자신에게 취약 부여
- `block`: gain block
- `cardPlayedBlock`: gain block whenever a card is played
- `blockGainMultiplier`: multiplier for block gained this turn
- `hits`: multi-hit count
- `aoe`: hit all enemies
- `pierce`: ignore block
- `draw`: draw cards immediately
- `drawUntilHandSize`: draw until hand reaches a target size
- `drawSkillBlock`: gain block for each Skill drawn
- `drawPoison`: apply poison when a card is drawn
- `heal`: heal immediately
- `gainEnergy`: gain energy immediately
- `strength`: gain Strength
- `dex`: gain Dexterity
- `thorns`: gain Thorns
- `selfVuln`: apply Vulnerable to self
## 상태이상
## Status
- `weak`: 약화 부여
- `vuln`: 취약 부여
- `poison`: 중독 부여
- `weak`: apply Weak
- `vuln`: apply Vulnerable
- `poison`: apply Poison
- `poisonHits`: apply poison multiple times
- `poisonRandomTargets`: spread poison applications across random alive enemies
`poison`은 적 턴 시작 시 피해를 주고 1 감소합니다.
`poison` deals damage at enemy turn start and then decreases by 1.
## 드로우/버리기
## Shivs and discard
- `discard`: 손패에서 지정 장수 버리기
- `discardAll`: 손패 전부 버리기
- `drawPerDiscarded`: 버린 카드 1장당 추가 드로우
- `addShiv`: 표창 생성
- `addShivPerDiscard`: 버린 장수만큼 표창 생성
- `sly`: 버려질 때 교활 발동
- `retain`: 턴 종료 시 해당 카드 보존
- `discard`: discard a chosen number of cards from hand
- `discardAll`: discard the whole hand
- `drawPerDiscarded`: draw one extra card per discarded card
- `addShiv`: create Shiv cards
- `addShivPerDiscard`: create one Shiv per discarded card
- `shivRetain`: Shiv cards are retained at end of turn
- `shivAoe`: Shiv cards hit all enemies for the turn
- `sly`: trigger on discard
- `retain`: keep the card at end of turn
## 파워/턴 효과
## Powers and turn effects
- `powerEffect: "strengthPerTurn"`
- `powerEffect: "energyPerTurn"`
- `powerEffect: "blockPerTurn"`
- `powerEffect: "poisonPerTurn"`
- `powerEffect: "damagePerTurn"`
- `powerEffect: "retainOne"`
- `turnStartShiv`: 턴 시작 시 표창 생성
- `turnStartDraw`: 턴 시작 시 추가 드로우
- `turnStartDiscard`: 턴 시작 시 카드 버리기
- `turnStartShiv`: create Shivs at turn start
- `turnStartDraw`: draw cards at turn start
- `turnStartDiscard`: discard cards at turn start
## 다음 턴 예약
## Next turn planning
- `nextTurnBlock`: 다음 턴 시작 시 방어도 획득
- `nextTurnDraw`: 다음 턴 시작 시 추가 드로우
- `nextTurnKeepBlock`: 다음 턴 시작 시 기존 방어도 유지
- `nextTurnAttackMultiplier`: 다음 턴 공격 피해 배수
- `nextTurnCopies`: 다음 턴에 손패에서 가져올 복사본 수
- `nextTurnSelectHandCard`: 현재 손패에서 카드 1장 선택
- `nextTurnSelectPrompt`: 선택 UI 문구
- `nextSkillRepeatCount`: 다음 스킬 카드의 효과를 추가 횟수만큼 다시 적용
- `nextSkillCostZero`: 다음 스킬 카드 비용을 0으로 만듦
- `skillCostReductionThisTurn`: 이번 턴 스킬 카드 비용을 일정량 감소
- `nextTurnBlock`: gain block next turn
- `nextTurnDraw`: draw extra cards next turn
- `nextTurnKeepBlock`: keep block next turn
- `nextTurnAttackMultiplier`: attack multiplier next turn
- `nextTurnCopies`: copy a chosen card next turn
- `nextTurnSelectHandCard`: choose a card from the current hand for next turn copies
- `nextTurnSelectPrompt`: prompt text for selection UI
- `nextSkillRepeatCount`: repeat the next Skill's effect
- `nextSkillCostZero`: make the next Skill cost 0
- `skillCostReductionThisTurn`: reduce Skill costs this turn
## 기타
## Misc
- `innate`: 전투 시작 시 첫 손패에 우선 진입
- `playableWhenDrawPileEmpty`: 뽑을 카드 더미가 비었을 때만 사용 가능
- `exhaust`: 사용 후 소멸
- `unplayable`: 사용 불가
- `curse`: 저주 카드
- `token`: 토큰 카드
- `endTurnDamage`: 턴 종료 시 손패에 있으면 피해
- `innate`: place the card in the opening hand
- `playableWhenDrawPileEmpty`: only playable when the draw pile is empty
- `exhaust`: exhaust after use
- `unplayable`: cannot be played
- `curse`: curse card
- `token`: token card
- `endTurnDamage`: damage if the card remains in hand at end of turn
## 사용 원칙
- 카드 전용 분기보다 공용 필드를 먼저 쓴다.
- 같은 효과는 같은 필드로 재사용한다.
- 새 카드가 같은 패턴이면 먼저 공용 필드를 추가한다.
## Rules
- Prefer shared fields over card-specific branches.
- Reuse the same field name for the same behavior.
- Add a new shared field before adding more special-case card logic.

View File

@@ -1,4 +1,4 @@
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
// ⚠️ 전투 규칙은 tools/deck/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
// (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현)
import { readFileSync } from 'node:fs';
@@ -159,6 +159,10 @@ export function simulateCombat(data, rng, stats) {
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
let nextTurnAddCards = [];
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
let shivFirstDamageBonusUsed = false;
let drawDamageThisTurn = 0;
let drawPoisonThisTurn = 0;
let shivAoeThisCombat = false;
let cardsDrawnThisCombat = 0;
let bonusRewardScreens = 0;
let activeKillReward = 0;
@@ -179,6 +183,23 @@ export function simulateCombat(data, rng, stats) {
const card = drawPile.pop();
drawn.push(card);
cardsDrawnThisCombat++;
const drawDamage = powerFieldTotal('drawDamage') + drawDamageThisTurn;
const drawPoison = powerFieldTotal('drawPoison') + drawPoisonThisTurn;
if ((drawDamage > 0 || drawPoison > 0) && mob.some((m) => m.alive)) {
for (const m of mob) {
if (!m.alive) continue;
let dmg = drawDamage;
if (m.vuln > 0) dmg = Math.floor(dmg * 1.5);
if (m.block > 0) {
const absorbed = Math.min(m.block, dmg);
m.block -= absorbed;
dmg -= absorbed;
}
if (drawPoison > 0) m.poison += drawPoison;
if (dmg > 0) m.hp -= dmg;
if (m.hp <= 0) { m.hp = 0; m.alive = false; }
}
}
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
if (hand.length >= 10) {
discard.push(card);
@@ -236,6 +257,10 @@ 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 === 'shiv') {
if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus');
if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus');
}
if (base < 0) base = 0;
return base;
}
@@ -286,6 +311,9 @@ export function simulateCombat(data, rng, stats) {
if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn;
if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true;
if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true;
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;
const xEnergy = costSpent || 0;
if (c.kind === 'Attack') {
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
@@ -300,9 +328,14 @@ export function simulateCombat(data, rng, stats) {
let totalNv = 0;
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
dmg = totalNv;
if (c.aoe === true) {
let useAoe = c.aoe === true;
if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true;
if (useAoe === true) {
for (const m2 of aliveList()) {
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
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');
@@ -314,6 +347,9 @@ export function simulateCombat(data, rng, stats) {
}
} else {
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) {
dmg = Math.floor(dmg * c.attackDamageVsWeakMultiplier);
}
if (c.pierce === true) {
target.hp -= dmg;
if (target.hp < 0) target.hp = 0;
@@ -339,7 +375,18 @@ export function simulateCombat(data, rng, stats) {
const target = chooseTarget(alive, 0);
if (weakAmount) target.weak += weakAmount;
if (c.vuln) target.vuln += c.vuln;
if (c.poison) target.poison += c.poison;
if (c.poison) {
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 (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
shivFirstDamageBonusUsed = true;
}
}
}
if (c.strength) pStr += c.strength;
@@ -413,6 +460,10 @@ export function simulateCombat(data, rng, stats) {
turns++;
turnAttackCardsPlayed = 0;
turnDiscardedCards = 0;
shivFirstDamageBonusUsed = false;
drawDamageThisTurn = 0;
drawPoisonThisTurn = 0;
shivAoeThisCombat = false;
blockGainMultiplier = 1;
handCostZeroThisTurn = false;
drawDisabledThisTurn = false;
@@ -530,7 +581,7 @@ export function simulateCombat(data, rng, stats) {
const kept = [];
for (const hid of hand) {
const hc = cards[hid];
if (hc?.retain === true) kept.push(hid);
if (hc?.retain === true || (hc?.class === 'shiv' && powerFieldTotal('shivRetain') > 0)) kept.push(hid);
else discard.push(hid);
}
hand = kept;

View File

@@ -914,3 +914,38 @@ test("simulateCombat: damagePerCardDrawnThisCombat scales murder", () => {
assert.equal(r.win, true);
assert.ok(stats.Murder.damage > 1);
});
test("simulateCombat: shiv damage bonuses stack and first Shiv bonus applies once per turn", () => {
const data = {
cards: {
Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 },
PhantomBlades: { name: "PhantomBlades", cost: 1, kind: "Power", firstShivDamageBonus: 3 },
Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 },
},
starterDeck: ["Accuracy", "PhantomBlades", "Shiv"],
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test("simulateCombat: shivAoe makes Shivs hit all enemies", () => {
const data = {
cards: {
FanOfKnives: { name: "FanOfKnives", cost: 2, kind: "Skill", addShiv: 2, shivAoe: true },
Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 },
Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 },
Pass: { name: "Pass", cost: 99, kind: "Skill" },
},
starterDeck: ["Accuracy", "FanOfKnives", "Pass"],
monsters: [
{ name: "A", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
{ name: "B", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
{ name: "C", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});

View File

@@ -271,6 +271,9 @@ local dmg = amount
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.weak > 0 and self.ActiveAttackDamageVsWeakMultiplier ~= nil and self.ActiveAttackDamageVsWeakMultiplier > 1 then
dmg = math.floor(dmg * self.ActiveAttackDamageVsWeakMultiplier)
end
if m.block > 0 and pierce ~= true then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
@@ -345,6 +348,7 @@ return killed`, [
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)
self.ActiveAttackDamageVsWeakMultiplier = 1
self:RenderCombat()
self:CheckCombatEnd()
return
@@ -370,11 +374,15 @@ _TimerService:SetTimerOnce(function()
if mt ~= nil and mt.alive == true and mt.vuln > 0 then
shown = math.floor(damage * 1.5)
end
if mt ~= nil and mt.alive == true and mt.weak > 0 and self.ActiveAttackDamageVsWeakMultiplier ~= nil and self.ActiveAttackDamageVsWeakMultiplier > 1 then
shown = math.floor(shown * self.ActiveAttackDamageVsWeakMultiplier)
end
local killed = self:DealDamageToTarget(damage, pierce)
if killed == true and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + self.ActiveKillReward
end
self.ActiveKillReward = 0
self.ActiveAttackDamageVsWeakMultiplier = 1
self:ShowDmgPop(targetIndex, shown)
self:RenderCombat()
self:CheckCombatEnd()
@@ -406,6 +414,9 @@ _TimerService:SetTimerOnce(function()
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.weak > 0 and self.ActiveAttackDamageVsWeakMultiplier ~= nil and self.ActiveAttackDamageVsWeakMultiplier > 1 then
dmg = math.floor(dmg * self.ActiveAttackDamageVsWeakMultiplier)
end
if m.block > 0 then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
@@ -431,6 +442,7 @@ _TimerService:SetTimerOnce(function()
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
end
self.ActiveKillReward = 0
self.ActiveAttackDamageVsWeakMultiplier = 1
self:RenderCombat()
self:CheckCombatEnd()
end, 0.35)`, [

View File

@@ -247,6 +247,11 @@ end
self.TurnAttackMultiplier = self.NextTurnAttackMultiplier or 1
self.NextTurnAttackMultiplier = 1
self.CardsDrawnThisCombat = self.CardsDrawnThisCombat or 0
self.ShivFirstDamageBonusUsed = false
self.ActiveAttackDamageVsWeakMultiplier = 1
self.DrawDamageThisTurn = 0
self.DrawPoisonThisTurn = 0
self.ShivAoeThisCombat = false
self.HandCostZeroThisTurn = false
self.DrawDisabledThisTurn = false
local powerTurnDraw = 0
@@ -419,7 +424,7 @@ local kept = {}
for i = 1, #self.Hand do
\tlocal cardId = self.Hand[i]
\tlocal c = self.Cards[cardId]
\tif c ~= nil and (c.retain == true or i == retainSlot) then
\tif c ~= nil and (c.retain == true or (c.class == "shiv" and self:HasPowerField("shivRetain") == true) or i == retainSlot) then
\t\ttable.insert(kept, cardId)
\telse
\t\ttable.insert(self.DiscardPile, cardId)

View File

@@ -311,6 +311,14 @@ end
if c.damagePerCardDrawnThisCombat ~= nil then
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
end
if c.class == "shiv" then
if self:HasPowerField("shivDamageBonus") == true then
base2 = base2 + self:AddPowerFieldTotal("shivDamageBonus")
end
if self.ShivFirstDamageBonusUsed ~= true and self:HasPowerField("firstShivDamageBonus") == true then
base2 = base2 + self:AddPowerFieldTotal("firstShivDamageBonus")
end
end
if base2 < 0 then
base2 = 0
end
@@ -394,6 +402,15 @@ end
if c.drawDisabledThisTurn == true then
self.DrawDisabledThisTurn = true
end
if c.drawDamage ~= nil and c.drawDamage > 0 and c.kind ~= "Power" then
self.DrawDamageThisTurn = (self.DrawDamageThisTurn or 0) + c.drawDamage
end
if c.drawPoison ~= nil and c.drawPoison > 0 and c.kind ~= "Power" then
self.DrawPoisonThisTurn = (self.DrawPoisonThisTurn or 0) + c.drawPoison
end
if c.shivAoe == true and c.kind ~= "Power" then
self.ShivAoeThisCombat = true
end
local xEnergy = energySpent or 0
local weakAmount = c.weak or 0
local vulnAmount = c.vuln or 0
@@ -405,6 +422,7 @@ if c.kind == "Attack" then
if c.damage ~= nil or c.xDamagePerEnergy ~= nil then
self:PlayerAttackMotion()
local baseDmg = self:AttackBaseForCard(slot, c)
self.ActiveAttackDamageVsWeakMultiplier = c.attackDamageVsWeakMultiplier or 1
if c.xDamagePerEnergy ~= nil and c.xDamagePerEnergy > 0 then
baseDmg = xEnergy * c.xDamagePerEnergy
end
@@ -423,7 +441,14 @@ if c.kind == "Attack" then
for h = 1, hitN do
total = total + self:CalcPlayerAttack(baseDmg)
end
if c.aoe == true then
local useAoe = c.aoe == true
if c.class == "shiv" and (self.ShivAoeThisCombat == true or self:HasPowerField("shivAoe") == true) then
useAoe = true
end
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)
@@ -475,7 +500,27 @@ if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil
end
if tm ~= nil and tm.alive == true then
if weakAmount ~= nil and weakAmount > 0 then tm.weak = tm.weak + weakAmount end
if poisonAmount ~= nil and poisonAmount > 0 then tm.poison = (tm.poison or 0) + poisonAmount end
if poisonAmount ~= nil and poisonAmount > 0 then
local poisonHits = c.poisonHits or 1
for pi = 1, poisonHits do
local target = tm
if c.poisonRandomTargets == true and self.Monsters ~= nil then
local alive = {}
for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then
table.insert(alive, om)
end
end
if #alive > 0 then
target = alive[math.random(#alive)]
end
end
if target ~= nil and target.alive == true then
target.poison = (target.poison or 0) + poisonAmount
end
end
end
if vulnAmount ~= nil and vulnAmount > 0 then
tm.vuln = tm.vuln + vulnAmount
if self:HasRelic("championBelt") then
@@ -512,6 +557,38 @@ if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
end
end
end
local drawDamage = self:AddPowerFieldTotal("drawDamage") + (self.DrawDamageThisTurn or 0)
local drawPoison = self:AddPowerFieldTotal("drawPoison") + (self.DrawPoisonThisTurn or 0)
if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison > 0) then
for mi = 1, #self.Monsters do
local m2 = self.Monsters[mi]
if m2 ~= nil and m2.alive == true then
local dmg = drawDamage or 0
if m2.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m2.block > 0 then
local absorbed = math.min(m2.block, dmg)
m2.block = m2.block - absorbed
dmg = dmg - absorbed
end
if drawPoison ~= nil and drawPoison > 0 then
m2.poison = (m2.poison or 0) + drawPoison
end
if dmg > 0 then
m2.hp = m2.hp - dmg
end
self:ShowDmgPop(mi, dmg)
self:MonsterHitMotion(mi)
if m2.hp <= 0 then
m2.hp = 0
self:KillMonster(m2.slot)
end
end
end
self:RenderCombat()
self:CheckCombatEnd()
end
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
self:AddCardsToHand("Shiv", c.addShiv)
end`, [

View File

@@ -74,6 +74,11 @@ self.DrawDisabledThisTurn = false
self.NextSkillCostZero = false
self.NextSkillRepeatCount = 0
self.SkillCostReductionThisTurn = 0
self.ShivFirstDamageBonusUsed = false
self.ActiveAttackDamageVsWeakMultiplier = 1
self.DrawDamageThisTurn = 0
self.DrawPoisonThisTurn = 0
self.ShivAoeThisCombat = false
self.PlayerStr = 0
self.PlayerDex = 0
self.PlayerThorns = 0

View File

@@ -192,6 +192,8 @@ function luaCardsTable(cards) {
if (c.draw != null) fields.push(`draw = ${c.draw}`);
if (c.drawUntilHandSize != null) fields.push(`drawUntilHandSize = ${c.drawUntilHandSize}`);
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
if (c.drawDamage != null) fields.push(`drawDamage = ${c.drawDamage}`);
if (c.drawPoison != null) fields.push(`drawPoison = ${c.drawPoison}`);
if (c.heal != null) fields.push(`heal = ${c.heal}`);
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
if (c.poison != null) fields.push(`poison = ${c.poison}`);
@@ -206,6 +208,13 @@ function luaCardsTable(cards) {
if (c.drawDisabledThisTurn === true) fields.push('drawDisabledThisTurn = true');
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
if (c.useAllEnergy === true) fields.push('useAllEnergy = true');
if (c.shivDamageBonus != null) fields.push(`shivDamageBonus = ${c.shivDamageBonus}`);
if (c.firstShivDamageBonus != null) fields.push(`firstShivDamageBonus = ${c.firstShivDamageBonus}`);
if (c.shivRetain === true) fields.push('shivRetain = true');
if (c.shivAoe === true) fields.push('shivAoe = true');
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.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}`);