feat(bandit): STS2 사일런트 카드풀 및 직업 탭 정리 #56

Merged
gahusb merged 14 commits from codex/class-tabbed-codex into main 2026-06-15 07:27:26 +09:00
5 changed files with 345 additions and 125 deletions
Showing only changes of commit 05a06644cf - Show all commits

File diff suppressed because one or more lines are too long

View File

@@ -396,7 +396,8 @@
"class": "bandit",
"rarity": "normal",
"desc": "방어도를 8 얻습니다. 카드를 1장 버립니다.",
"block": 8
"block": 8,
"discard": 1
},
"SilentDefend": {
"name": "수비",
@@ -435,7 +436,8 @@
"rarity": "normal",
"desc": "피해를 9 줍니다. 카드를 1장 뽑습니다. 카드를 1장 버립니다.",
"draw": 1,
"damage": 9
"damage": 9,
"discard": 1
},
"PoisonedStab": {
"name": "독 찌르기",
@@ -483,7 +485,8 @@
"rarity": "normal",
"desc": "교활. 모든 적에게 피해를 6 줍니다.",
"aoe": true,
"damage": 6
"damage": 6,
"sly": true
},
"Ricochet": {
"name": "도탄",
@@ -493,7 +496,8 @@
"rarity": "normal",
"desc": "교활. 무작위 적에게 피해를 3만큼 4번 줍니다.",
"damage": 3,
"hits": 4
"hits": 4,
"sly": true
},
"Prepared": {
"name": "예비",
@@ -502,7 +506,8 @@
"class": "bandit",
"rarity": "normal",
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
"draw": 1
"draw": 1,
"discard": 1
},
"Anticipate": {
"name": "예측",
@@ -596,7 +601,8 @@
"class": "bandit",
"rarity": "normal",
"desc": "교활. 방어도를 6 얻습니다.",
"block": 6
"block": 6,
"sly": true
},
"Skewer": {
"name": "꼬챙이",
@@ -725,7 +731,8 @@
"rarity": "unique",
"desc": "카드를 2장 버립니다. 단도를 2장 손으로 가져옵니다.",
"damage": 4,
"hits": 2
"hits": 2,
"discard": 2
},
"EscapePlan": {
"name": "탈출구",
@@ -744,7 +751,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "카드를 3장 뽑습니다. 카드를 1장 버립니다.",
"draw": 3
"draw": 3,
"discard": 1
},
"HandTrick": {
"name": "손기술",
@@ -827,7 +835,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "교활. 카드를 2장 뽑습니다.",
"draw": 2
"draw": 2,
"sly": true
},
"Haze": {
"name": "아지랑이",
@@ -836,7 +845,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "교활. 모든 적에게 중독을 4 부여합니다.",
"poison": 4
"poison": 4,
"sly": true
},
"Tactician": {
"name": "전략가",
@@ -846,7 +856,8 @@
"rarity": "unique",
"desc": "교활. 을 얻습니다.",
"powerEffect": "energyPerTurn",
"value": 1
"value": 1,
"sly": true
},
"WellLaidPlans": {
"name": "괜찮은 전략",
@@ -1008,7 +1019,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 단도를 손으로 가져옵니다.",
"draw": 1
"draw": 1,
"discardAll": true
},
"ShadowStep": {
"name": "그림자 걸음",
@@ -1017,7 +1029,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "손에 있는 모든 카드를 버립니다. 다음 턴에, 공격 카드의 피해량이 2배가 됩니다.",
"draw": 1
"draw": 1,
"discardAll": true
},
"Shadowmeld": {
"name": "그림자 은신",
@@ -1093,7 +1106,8 @@
"desc": "내 턴 시작 시, 카드를 1장 뽑고 카드를 1장 버립니다.",
"draw": 1,
"powerEffect": "energyPerTurn",
"value": 1
"value": 1,
"discard": 1
},
"Afterimage": {
"name": "잔상",
@@ -1178,7 +1192,8 @@
"rarity": "legend",
"desc": "교활. 민첩을 1 얻습니다. 가시를 4 얻습니다.",
"powerEffect": "blockPerTurn",
"value": 2
"value": 2,
"sly": true
},
"Suppress": {
"name": "진압",
@@ -1216,7 +1231,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "교활. 다른 플레이어가 적을 공격할 때마다, 방어도를 1 얻습니다.",
"block": 1
"block": 1,
"sly": true
}
},
"starterDecks": {

View File

@@ -124,10 +124,81 @@ export function simulateCombat(data, rng, stats) {
if (drawPile.length === 0) break;
const card = drawPile.pop();
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
if (hand.length >= 10) discard.push(card); else hand.push(card);
if (hand.length >= 10) {
discard.push(card);
triggerSly(card);
} else hand.push(card);
}
}
const aliveList = () => mob.filter((m) => m.alive);
function resolveCardEffects(id, c, costSpent, recordStats = true) {
const alive = aliveList();
let dmg = 0;
if (c.kind === 'Attack') {
if (alive.length && c.damage) {
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
const hitN = c.hits || 1;
let totalNv = 0;
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
dmg = totalNv;
if (c.aoe === true) {
for (const m2 of aliveList()) {
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
const r2 = applyDamage(m2.hp, m2.block, d2);
m2.hp = r2.hp; m2.block = r2.block;
if (m2.hp <= 0) m2.alive = false;
}
} else {
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
if (c.pierce === true) {
target.hp -= dmg;
if (target.hp < 0) target.hp = 0;
} else {
const r = applyDamage(target.hp, target.block, dmg);
target.hp = r.hp; target.block = r.block;
}
if (target.hp <= 0) target.alive = false;
}
}
if (c.block) pBlock += c.block;
} else if (c.kind === 'Power') {
if (c.powerEffect && recordStats) powers.push(id);
} else {
pBlock += c.block || 0;
if ((c.weak || c.vuln || c.poison) && alive.length) {
const target = chooseTarget(alive, 0);
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
if (c.poison) target.poison += c.poison;
}
}
if (c.strength) pStr += c.strength;
if (c.selfVuln) pVuln += c.selfVuln;
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
if (c.draw) draw(c.draw);
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, c.block || 0);
}
function triggerSly(id) {
const c = cards[id];
if (!c?.sly) return;
resolveCardEffects(id, c, 0, false);
}
function discardHandCard(idx, trigger = true) {
const [id] = hand.splice(idx, 1);
if (!id) return;
discard.push(id);
if (trigger) triggerSly(id);
}
function applyDiscardEffects(c) {
if (c.discardAll) {
while (hand.length) discardHandCard(hand.length - 1, true);
} else if (c.discard) {
const n = Math.min(c.discard, hand.length);
for (let i = 0; i < n; i++) discardHandCard(hand.length - 1, true);
}
}
while (turns < MAX_TURNS) {
turns++;
@@ -149,59 +220,10 @@ export function simulateCombat(data, rng, stats) {
if (idx < 0) break;
const id = hand[idx], c = cards[id];
energy -= c.cost;
if (c.kind === 'Attack') {
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
// 카드 디버프는 피해보다 먼저 적용 — Lua PlayCard(즉시 부여) + 지연 데미지(0.35s) 동기화
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
// 다단히트: 타격마다 힘·약화 적용 합산, 취약은 합산값에 1회 (Lua 동기화)
const hitN = c.hits || 1;
let totalNv = 0;
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
let dmg = totalNv; // 통계 보고용 (aoe는 1대상 기준)
if (c.aoe === true) {
// 전체 공격 — 대상마다 취약/방어 개별 적용 (Lua PlayAoeFx 동기화)
for (const m2 of aliveList()) {
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
const r2 = applyDamage(m2.hp, m2.block, d2);
m2.hp = r2.hp; m2.block = r2.block;
if (m2.hp <= 0) m2.alive = false;
}
} else {
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
if (c.pierce === true) {
target.hp -= dmg; // 방어 무시
if (target.hp < 0) target.hp = 0;
} else {
const r = applyDamage(target.hp, target.block, dmg);
target.hp = r.hp; target.block = r.block;
}
if (target.hp <= 0) target.alive = false;
}
if (c.block) pBlock += c.block;
if (c.strength) pStr += c.strength;
if (c.selfVuln) pVuln += c.selfVuln;
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
if (stats) stats[id] = bump(stats[id], c.cost, dmg, c.block || 0);
} else if (c.kind === 'Power') {
if (c.powerEffect) powers.push(id);
if (stats) stats[id] = bump(stats[id], c.cost, 0, 0);
} else {
pBlock += c.block || 0;
if (c.strength) pStr += c.strength;
if (c.selfVuln) pVuln += c.selfVuln;
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
if (c.weak || c.vuln || c.poison) {
const target = chooseTarget(alive, 0);
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
if (c.poison) target.poison += c.poison;
}
if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0);
}
resolveCardEffects(id, c, c.cost);
hand.splice(idx, 1);
if (c.kind !== 'Power') discard.push(id); // 파워는 소멸 — Lua 동기화
if (c.draw) draw(c.draw);
if (c.kind !== 'Power') discard.push(id);
applyDiscardEffects(c);
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
}
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)

View File

@@ -375,3 +375,18 @@ test('simulateCombat: endTurnDamage(화상)이 턴 종료 시 누적 피해', ()
assert.equal(r.win, false);
assert.notEqual(r.draw, true);
});
test("simulateCombat: sly discarded card resolves for free", () => {
const data = {
cards: {
Toss: { name: "Toss", cost: 1, kind: "Skill", discardAll: true },
SlyHit: { name: "SlyHit", cost: 99, kind: "Attack", damage: 10, sly: true },
Blank: { name: "Blank", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Toss", "SlyHit", "Blank", "Blank", "Blank"],
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Defend", value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});

View File

@@ -149,6 +149,9 @@ function luaCardsTable(cards) {
if (c.draw != null) fields.push(`draw = ${c.draw}`);
if (c.heal != null) fields.push(`heal = ${c.heal}`);
if (c.poison != null) fields.push(`poison = ${c.poison}`);
if (c.discard != null) fields.push(`discard = ${c.discard}`);
if (c.discardAll === true) fields.push('discardAll = true');
if (c.sly === true) fields.push('sly = true');
if (c.aoe === true) fields.push('aoe = true');
if (c.unplayable === true) fields.push('unplayable = true');
if (c.curse === true) fields.push('curse = true');
@@ -3690,6 +3693,7 @@ for i = 1, amount do
\tlocal cardId = table.remove(self.DrawPile)
\tif #self.Hand >= 10 then
\t\ttable.insert(self.DiscardPile, cardId)
\t\tself:TriggerSly(cardId)
\telse
\t\ttable.insert(self.Hand, cardId)
\t\tif #self.Hand <= 5 then
@@ -4139,6 +4143,106 @@ if dmg < 0 then
dmg = 0
end
return dmg`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
method('ResolveCardEffects', `if c == nil then
return
end
if c.kind == "Attack" then
if c.damage ~= nil then
self:PlayerAttackMotion()
local total = 0
local hitN = c.hits or 1
for h = 1, hitN do
total = total + self:CalcPlayerAttack(c.damage)
end
if c.aoe == true then
self:PlayAoeFx(c.fx or c.image, total)
else
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true)
end
end
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
if free ~= true then
self:ApplyRelics("cardPlayed")
end
elseif c.kind == "Skill" then
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
elseif c.kind == "Power" then
if c.powerEffect ~= nil and free ~= true then
table.insert(self.PlayerPowers, cardId)
end
end
if c.strength ~= nil then
self.PlayerStr = self.PlayerStr + c.strength
end
if c.selfVuln ~= nil then
self.PlayerVuln = self.PlayerVuln + c.selfVuln
end
if c.heal ~= nil then
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
end
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil 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 c.weak ~= nil then tm.weak = tm.weak + c.weak end
if c.poison ~= nil then tm.poison = (tm.poison or 0) + c.poison end
if c.vuln ~= nil then
tm.vuln = tm.vuln + c.vuln
if self:HasRelic("championBelt") then
tm.weak = tm.weak + 1
end
end
end
end
if c.draw ~= nil then
self:DrawCards(c.draw, true)
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'free' },
]),
method('TriggerSly', `local c = self.Cards[cardId]
if c == nil or c.sly ~= true then
return
end
self:Toast("교활 발동: " .. c.name)
self:ResolveCardEffects(cardId, c, true)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
method('DiscardHandCard', `if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
table.remove(self.Hand, slot)
table.insert(self.DiscardPile, cardId)
if triggerSly == true then
self:TriggerSly(cardId)
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'triggerSly' },
]),
method('DiscardCardEffects', `if c == nil or self.Hand == nil then
return
end
if c.discardAll == true then
while #self.Hand > 0 do
self:DiscardHandCard(#self.Hand, true)
end
elseif c.discard ~= nil then
local n = math.min(c.discard, #self.Hand)
for i = 1, n do
self:DiscardHandCard(#self.Hand, true)
end
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }]),
method('PlayCard', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
@@ -4162,62 +4266,12 @@ if self.Energy < c.cost then
return
end
self.Energy = self.Energy - c.cost
if c.kind == "Attack" then
if c.damage ~= nil then
self:PlayerAttackMotion()
local total = 0
local hitN = c.hits or 1
for h = 1, hitN do
total = total + self:CalcPlayerAttack(c.damage)
end
if c.aoe == true then
self:PlayAoeFx(c.fx or c.image, total)
else
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true)
end
end
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
self:ApplyRelics("cardPlayed")
elseif c.kind == "Skill" then
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
elseif c.kind == "Power" then
if c.powerEffect ~= nil then
table.insert(self.PlayerPowers, cardId)
end
end
if c.strength ~= nil then
self.PlayerStr = self.PlayerStr + c.strength
end
if c.selfVuln ~= nil then
self.PlayerVuln = self.PlayerVuln + c.selfVuln
end
if c.heal ~= nil then
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
end
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil then
local tm = self.Monsters[self.TargetIndex]
if tm ~= nil and tm.alive == true then
if c.weak ~= nil then tm.weak = tm.weak + c.weak end
if c.poison ~= nil then tm.poison = (tm.poison or 0) + c.poison end
if c.vuln ~= nil then
tm.vuln = tm.vuln + c.vuln
if self:HasRelic("championBelt") then
tm.weak = tm.weak + 1
end
end
end
end
self:ResolveCardEffects(cardId, c, false)
table.remove(self.Hand, slot)
if c.kind ~= "Power" then
table.insert(self.DiscardPile, cardId)
end
if c.draw ~= nil then
self:DrawCards(c.draw, true)
end
self:DiscardCardEffects(c)
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()