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

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}`;