feat(combat): 몬스터 랜덤 구성·랜덤 행동·덱 오염(AddCard)·map01 로스터 (P14-3)

- 구성 랜덤화: BuildMonsters를 그룹별 수집 후 노드 타입별 추첨
  (일반 1~3 / 엘리트 1+일반0~2 / 보스 1, MAX_MONSTERS=4 내)
- 행동 랜덤화: EnemyActStep 순차(round-robin) → 정의된 intent 중 math.random 선택
  (스폰 시 시작 intent도 랜덤, sim-balance.mjs 미러 동기화)
- StS2식 덱 오염: AddCard intent 신규 — 저주 카드(Wound/Burn)를 버린 더미에 추가
  · Wound=사용불가 사석, Burn=사용불가+턴종료 피해2(EndPlayerTurn 처리)
  · PlayCard unplayable 가드, CardPool class 필터로 보상/상점 자동 제외
  · luaIntentsArray(card/count)·luaCardsTable(unplayable/curse/endTurnDamage) 직렬화
- map01 인카운터: 일반 5종(주황/초록/빨강달팽이/파랑/돼지) + 엘리트1 + 보스1, 우측 포메이션
  · enemies.json red_snail/stump 신규, blue_mushroom/mushmom에 AddCard intent
  · gen-map-encounters 레이아웃 맵별 분기 + 풀 인덱싱 일반화
- 막 배율 0.6→0.45(5막 기준 완화). sim 테스트 35/35 통과(신규 3 포함). 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 00:33:49 +09:00
parent 8fa1079548
commit 8f58a90746
13 changed files with 754 additions and 402 deletions

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,8 @@
},
"classToFrame": {
"warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior",
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician"
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician",
"curse": "bandit"
},
"rewardWeights": { "normal": 70, "unique": 25, "legend": 5 }
}

View File

@@ -335,6 +335,27 @@
"desc": "피해 8",
"image": "0265e103b4904f178b1c2bdcd54d5975",
"rarity": "unique"
},
"Wound": {
"name": "상처",
"cost": 0,
"kind": "Status",
"desc": "사용할 수 없다. 손패를 막는 저주.",
"class": "curse",
"rarity": "normal",
"unplayable": true,
"curse": true
},
"Burn": {
"name": "화상",
"cost": 0,
"kind": "Status",
"desc": "사용 불가. 손패에 있으면 턴 종료 시 피해 2.",
"class": "curse",
"rarity": "normal",
"unplayable": true,
"curse": true,
"endTurnDamage": 2
}
},
"starterDecks": {

View File

@@ -46,7 +46,8 @@
"intents": [
{ "kind": "Attack", "value": 4 },
{ "kind": "Attack", "value": 4 },
{ "kind": "Attack", "value": 10 }
{ "kind": "Attack", "value": 10 },
{ "kind": "AddCard", "card": "Wound", "count": 1 }
]
},
"pig": {
@@ -67,6 +68,24 @@
{ "kind": "Attack", "value": 9 }
]
},
"red_snail": {
"name": "빨간 달팽이",
"maxHp": 14,
"intents": [
{ "kind": "Attack", "value": 5 },
{ "kind": "Defend", "value": 6 },
{ "kind": "Attack", "value": 7 }
]
},
"stump": {
"name": "나무토막",
"maxHp": 19,
"intents": [
{ "kind": "Defend", "value": 5 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Attack", "value": 6 }
]
},
"mushmom": {
"name": "머쉬맘",
"maxHp": 75,
@@ -75,7 +94,8 @@
{ "kind": "Debuff", "effect": "weak", "value": 2 },
{ "kind": "Attack", "value": 16 },
{ "kind": "Attack", "value": 9 },
{ "kind": "Defend", "value": 6 }
{ "kind": "Defend", "value": 6 },
{ "kind": "AddCard", "card": "Burn", "count": 1 }
]
},
"modified_snail": {

File diff suppressed because it is too large Load Diff

View File

@@ -6508,7 +6508,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "green_mushroom",
"EnemyId": "pig",
"Group": "combat"
}
],
@@ -6660,7 +6660,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "pig",
"EnemyId": "green_mushroom",
"Group": "combat"
}
],
@@ -6812,7 +6812,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "blue_mushroom",
"EnemyId": "stump",
"Group": "combat"
}
],

View File

@@ -6508,7 +6508,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "green_mushroom",
"EnemyId": "pig",
"Group": "combat"
}
],
@@ -6660,7 +6660,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "blue_mushroom",
"EnemyId": "red_snail",
"Group": "combat"
}
],

View File

@@ -6508,7 +6508,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "pig",
"EnemyId": "blue_mushroom",
"Group": "combat"
}
],
@@ -6660,7 +6660,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "blue_mushroom",
"EnemyId": "stump",
"Group": "combat"
}
],
@@ -6812,7 +6812,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom",
"EnemyId": "green_mushroom",
"Group": "combat"
}
],

View File

@@ -6508,7 +6508,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "pig",
"EnemyId": "blue_mushroom",
"Group": "combat"
}
],
@@ -6660,7 +6660,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom",
"EnemyId": "green_mushroom",
"Group": "combat"
}
],
@@ -6812,7 +6812,7 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "green_mushroom",
"EnemyId": "pig",
"Group": "combat"
}
],

View File

@@ -74,7 +74,7 @@ export function loadData() {
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
export function chooseAction(hand, cards, energy) {
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy);
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
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');
@@ -202,7 +202,12 @@ export function simulateCombat(data, rng, stats) {
if (c.draw) draw(c.draw);
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
}
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
let burn = 0;
for (const hid of hand) { const hc = cards[hid]; if (hc && hc.endTurnDamage) burn += hc.endTurnDamage; }
if (burn > 0) { pHp -= burn; if (pHp < 0) pHp = 0; }
discard.push(...hand); hand = [];
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
if (pWeak > 0) pWeak--;
if (pVuln > 0) pVuln--;
@@ -215,7 +220,8 @@ export function simulateCombat(data, rng, stats) {
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
}
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
const it = m.intents[m.intentIdx];
// 정의된 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);
@@ -224,9 +230,12 @@ export function simulateCombat(data, rng, stats) {
else if (it.kind === 'Debuff') {
if (it.effect === 'weak') pWeak += it.value;
else if (it.effect === 'vuln') pVuln += it.value;
} else if (it.kind === 'AddCard') {
// StS2식 덱 오염 — 저주 카드를 버린 더미에 추가 (Lua 동기화)
const cnt = it.count || 1;
for (let k = 0; k < cnt; k++) discard.push(it.card);
}
}
m.intentIdx = (m.intentIdx + 1) % m.intents.length;
// 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후)
if (m.weak > 0) m.weak--;
if (m.vuln > 0) m.vuln--;

View File

@@ -345,3 +345,33 @@ test('simulateCombat: draw — 카드 드로로 손패 보충', () => {
assert.ok(r.turns <= 2, `seed ${s}: ${r.turns}`);
}
});
test('chooseAction: unplayable(저주) 카드는 건너뜀', () => {
const cards = { Strike: { cost: 1, kind: 'Attack', damage: 6 }, Wound: { cost: 0, kind: 'Status', unplayable: true } };
assert.equal(chooseAction(['Wound', 'Strike'], cards, 3), 1); // Strike 선택
assert.equal(chooseAction(['Wound'], cards, 3), -1); // 낼 카드 없음
});
test('simulateCombat: AddCard intent가 저주를 덱에 추가(오염)', () => {
const data = {
cards: { Hit: { name: '히트', cost: 1, kind: 'Attack', damage: 1 }, Wound: { name: '상처', cost: 0, kind: 'Status', unplayable: true } },
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '오염자', maxHp: 9999, intents: [{ kind: 'AddCard', card: 'Wound', count: 1 }] }],
};
// 적은 공격 안 하고 매 턴 저주만 추가 → 플레이어 무피해(승리 불가, 9999hp) → 무승부, 사망 아님
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, false);
assert.equal(r.draw, true);
});
test('simulateCombat: endTurnDamage(화상)이 턴 종료 시 누적 피해', () => {
const data = {
cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 }, Burn: { name: '화상', cost: 0, kind: 'Status', unplayable: true, endTurnDamage: 2 } },
starterDeck: ['Burn', 'Skip', 'Skip', 'Skip', 'Skip'],
monsters: [{ name: '무공격', maxHp: 9999, intents: [{ kind: 'Defend', value: 0 }] }],
};
// 적은 방어만(무피해). 손패의 Burn이 매 턴 -2 → 80hp 잠식 → MAX_TURNS 전 사망 → win false(draw 아님)
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, false);
assert.notEqual(r.draw, true);
});

View File

@@ -90,8 +90,10 @@ function luaPotionsTable(potions) {
function luaIntentsArray(intents) {
return '{ ' + intents.map((it) => {
const fields = [`kind = ${luaStr(it.kind)}`, `value = ${it.value}`];
const fields = [`kind = ${luaStr(it.kind)}`, `value = ${it.value != null ? it.value : 0}`];
if (it.effect != null) fields.push(`effect = ${luaStr(it.effect)}`);
if (it.card != null) fields.push(`card = ${luaStr(it.card)}`);
if (it.count != null) fields.push(`count = ${it.count}`);
return `{ ${fields.join(', ')} }`;
}).join(', ') + ' }';
}
@@ -131,6 +133,9 @@ function luaCardsTable(cards) {
if (c.heal != null) fields.push(`heal = ${c.heal}`);
if (c.poison != null) fields.push(`poison = ${c.poison}`);
if (c.aoe === true) fields.push('aoe = true');
if (c.unplayable === true) fields.push('unplayable = true');
if (c.curse === true) fields.push('curse = true');
if (c.endTurnDamage != null) fields.push(`endTurnDamage = ${c.endTurnDamage}`);
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
return `\t${id} = { ${fields.join(', ')} },`;
});
@@ -2761,37 +2766,64 @@ for i = 1, #reg do
reg[i].entity:SetVisible(false)
end
end
local list = {}
local byGroup = {}
for i = 1, #reg do
local r = reg[i]
if r.entity ~= nil and isvalid(r.entity) and r.group == g and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) then
if r.entity ~= nil and isvalid(r.entity) and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) then
local gg = r.group
if gg == nil or gg == "" then gg = "combat" end
if byGroup[gg] == nil then byGroup[gg] = {} end
local x = 0
if r.entity.TransformComponent ~= nil then
x = r.entity.TransformComponent.WorldPosition.x
end
table.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x })
table.insert(byGroup[gg], { entity = r.entity, enemyId = r.enemyId, x = x })
end
end
table.sort(list, function(a, b) return a.x < b.x end)
-- 노드 타입별 랜덤 구성: 일반 1~3 / 엘리트 1+일반0~2 / 보스 1
local chosen = {}
local function takeFrom(key, k)
local src = byGroup[key] or {}
local pool = {}
for i = 1, #src do pool[i] = src[i] end
self:Shuffle(pool)
local taken = 0
for i = 1, #pool do
if taken >= k then break end
table.insert(chosen, pool[i])
taken = taken + 1
end
end
if g == "boss" then
takeFrom("boss", 1)
elseif g == "elite" then
takeFrom("elite", 1)
takeFrom("combat", math.random(0, 2))
else
takeFrom("combat", math.random(1, 3))
end
if #chosen == 0 then takeFrom(g, 1) end
if #chosen == 0 then takeFrom("combat", 1) end
table.sort(chosen, function(a, b) return a.x < b.x end)
local mult = 1 + (self.Floor - 1) * 0.45
if g == "elite" or g == "boss" then
mult = mult + self:AscEliteBonus()
end
local n = #list
local n = #chosen
if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end
for i = 1, n do
local item = list[i]
local item = chosen[i]
local e = self.Enemies[item.enemyId]
if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end
local intents = {}
for k = 1, #e.intents do
local v = e.intents[k].value
local v = e.intents[k].value or 0
if e.intents[k].kind == "Attack" then
v = math.floor(v * mult * self:AscAtkMult())
elseif e.intents[k].kind ~= "Debuff" then
v = math.floor(v * mult)
end
intents[k] = { kind = e.intents[k].kind, value = v, effect = e.intents[k].effect }
intents[k] = { kind = e.intents[k].kind, value = v, effect = e.intents[k].effect, card = e.intents[k].card, count = e.intents[k].count }
end
local maxHp = math.floor(e.maxHp * mult * self:AscHpMult())
local hitClip = nil
@@ -2802,10 +2834,12 @@ for i = 1, n do
standClip = item.entity.StateAnimationComponent.ActionSheet["stand"]
end)
end
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,
hitClip = hitClip, standClip = standClip, motionBusy = false,
intents = intents, intentIdx = 1, alive = true, slot = i }
intents = intents, intentIdx = startIdx, alive = true, slot = i }
self:ReviveMonsterEntity(item.entity)
self:PositionMonsterSlot(i)
end
@@ -3026,6 +3060,17 @@ self:RenderCombat()`),
method('EndPlayerTurn', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
local burn = 0
for bi = 1, #self.Hand do
\tlocal hc = self.Cards[self.Hand[bi]]
\tif hc ~= nil and hc.endTurnDamage ~= nil then burn = burn + hc.endTurnDamage end
end
if burn > 0 then
\tself.PlayerHp = self.PlayerHp - burn
\tif self.PlayerHp < 0 then self.PlayerHp = 0 end
\tself:ShowPlayerDmgPop(burn)
\tself:RenderCombat()
end
for i = 1, #self.Hand do
\ttable.insert(self.DiscardPile, self.Hand[i])
end
@@ -3279,6 +3324,10 @@ local c = self.Cards[cardId]
if c == nil then
return
end
if c.unplayable == true then
self:Toast("사용할 수 없는 카드입니다")
return
end
if self.Energy < c.cost then
self:Toast("에너지가 부족합니다")
return
@@ -3623,11 +3672,20 @@ _TimerService:SetTimerOnce(function()
elseif intent.effect == "vuln" then
self.PlayerVuln = self.PlayerVuln + intent.value
end
elseif intent.kind == "AddCard" then
local cnt = intent.count or 1
for ci = 1, cnt do
table.insert(self.DiscardPile, intent.card)
end
self:RenderPiles()
local cn = intent.card
local cc = self.Cards[intent.card]
if cc ~= nil then cn = cc.name end
self:Toast(m.name .. ": " .. cn .. " 추가!")
end
end
m.intentIdx = m.intentIdx + 1
if m.intentIdx > #m.intents then
m.intentIdx = 1
if #m.intents > 0 then
m.intentIdx = math.random(1, #m.intents)
end
if m.weak > 0 then m.weak = m.weak - 1 end
if m.vuln > 0 then m.vuln = m.vuln - 1 end
@@ -3828,6 +3886,8 @@ return table.concat(parts, " ")`, [
elseif intent.kind == "Debuff" then
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
else t = "취약 " .. tostring(intent.value) .. " 부여" end
elseif intent.kind == "AddCard" then
t = "저주 카드 추가"
end
end
self:SetText(base .. "/Intent", t)
@@ -3838,6 +3898,8 @@ return table.concat(parts, " ")`, [
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
elseif intent.kind == "Debuff" then
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
elseif intent.kind == "AddCard" then
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
else
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
end

View File

@@ -2,15 +2,24 @@ import { readFileSync, writeFileSync } from 'node:fs';
// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성.
// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론).
const MAP_NUMBERS = [2, 3, 4, 5];
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom'];
const MAP_NUMBERS = [1, 2, 3, 4, 5];
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump'];
const ELITE_POOL = ['mushmom', 'modified_snail'];
const BOSS_POOL = ['king_slime', 'slime_boss'];
const LAYOUT = [
// map01: StS2식 일반 5종 + 엘리트 1 + 보스 1(보스 노드용, 화면 우측 포메이션).
// 그 외 맵: 일반 3 + 엘리트 2 + 보스 1. 전투 시 BuildMonsters가 노드 타입별로 1~3마리 랜덤 추첨.
const LAYOUT_MAP01 = [
{ group: 'combat', x: 2.6 }, { group: 'combat', x: 3.6 }, { group: 'combat', x: 4.6 },
{ group: 'combat', x: 5.6 }, { group: 'combat', x: 6.6 },
{ group: 'elite', x: 4.6 },
{ group: 'boss', x: 4.6 },
];
const LAYOUT_DEFAULT = [
{ group: 'combat', x: 2.3 }, { group: 'combat', x: 3.8 }, { group: 'combat', x: 5.2 },
{ group: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 },
{ group: 'boss', x: 4.0 },
];
const layoutFor = (nn) => (nn === 1 ? LAYOUT_MAP01 : LAYOUT_DEFAULT);
const MONSTER_VARIANTS = [
{ sprite: '96e955c1bf27415e84f96deea200a8f1', stand: '96e955c1bf27415e84f96deea200a8f1', hit: 'aec9504d5dc24aceb5646b79d30abad4', die: '65a2bfb039614f2e9e4ccc354340153d' },
{ sprite: 'f86992ba9c41487c8480fcb893fcbda6', stand: 'f86992ba9c41487c8480fcb893fcbda6', hit: 'd305b942b1704c8084548108ff3b7a6b', die: '5a563e5fd98c4132b61057dc6bb8aaf2' },
@@ -54,13 +63,17 @@ function patchMap(nn) {
const template = monsters[0];
map.ContentProto.Entities = ents.filter((e) => !isMonster(e));
const rand = rng(nn * 7919 + 17);
const combatIds = pickN(rand, COMBAT_POOL, 3);
const eliteIds = pickN(rand, ELITE_POOL, 2);
const layout = layoutFor(nn);
const nCombat = layout.filter((s) => s.group === 'combat').length;
const nElite = layout.filter((s) => s.group === 'elite').length;
const combatIds = pickN(rand, COMBAT_POOL, nCombat);
const eliteIds = pickN(rand, ELITE_POOL, nElite);
const bossId = pick(rand, BOSS_POOL);
const variants = pickN(rand, MONSTER_VARIANTS, 6);
LAYOUT.forEach((slot, idx) => {
const variants = pickN(rand, MONSTER_VARIANTS, layout.length);
let ci = 0, ei = 0;
layout.forEach((slot, idx) => {
const m = JSON.parse(JSON.stringify(template));
const enemyId = slot.group === 'combat' ? combatIds[idx] : slot.group === 'elite' ? eliteIds[idx - 3] : bossId;
const enemyId = slot.group === 'combat' ? combatIds[ci++] : slot.group === 'elite' ? eliteIds[ei++] : bossId;
const name = `${slot.group}_${idx + 1}`;
m.id = encGuid(nn, idx);
m.path = `/maps/map${tag}/${name}`;