merge main into bandit silent deck

This commit is contained in:
2026-06-14 17:48:59 +09:00
38 changed files with 19382 additions and 47897 deletions

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');
@@ -122,7 +122,9 @@ export function simulateCombat(data, rng, stats) {
for (let k = 0; k < n; k++) {
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
if (drawPile.length === 0) break;
hand.push(drawPile.pop());
const card = drawPile.pop();
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
if (hand.length >= 10) discard.push(card); else hand.push(card);
}
}
const aliveList = () => mob.filter((m) => m.alive);
@@ -202,7 +204,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 +222,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 +232,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

@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
// 새 CameraComponent를 만들지 않고(엔진 소유) 기존 카메라 속성만 런타임 설정한다.
// 플레이어 입력 차단·시선 고정은 tools/player/gen-player-lock.mjs(script.PlayerLock)로 분리됨.
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
function prop(Type, Name, DefaultValue = 'nil') {
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };

File diff suppressed because it is too large Load Diff

141
tools/map/gen-lobby-map.mjs Normal file
View File

@@ -0,0 +1,141 @@
import { readFileSync, writeFileSync } from 'node:fs';
// 로비 전용 맵 생성기 — map01 템플릿을 클론해 마을(타운) 배경의 로비 맵을 만든다.
// · 몬스터 엔티티 전부 제거(전투 없음)
// · NPC 4종(모험가/사서/상인/안내원) 월드 엔티티 배치 + 머리 위 마크(근접 시 표시)
// · 각 NPC: TouchReceiveComponent(클릭) + script.LobbyNpc(NpcId)
// · 맵 루트: script.PlayerLock 제거(이동 허용) + script.LobbyMobility 추가(이동·공격 해제)
// · SectorConfig에 map://lobby 등록
// codeblock 로직(LobbyNpc/LobbyMobility)은 tools/player/gen-lobby-npc.mjs가 emit한다.
const TEMPLATE = 'map/map01.map';
const OUT = 'map/lobby.map';
const SECTOR = 'Global/SectorConfig.config';
const TOWN_BG = '65c4167ea7484196b890022354e5a4a4'; // Henesys (gen-maps.mjs BACKGROUNDS 풀)
const MARK_RUID = 'bd4afdde295f40318fceb4166978ebaa'; // 공식 maplestory balloon (근접 마크)
// NPC 4종: x좌표는 정찰 기준 walkable 범위[-5,6.6], 근접 임계 1.2와 분리되게 배치
const NPCS = [
{ name: 'NpcRun', id: 'run', x: -3.0, ruid: '122095fd155c4633867b0da4f375bc3c' }, // 모험가
{ name: 'NpcCodex', id: 'codex', x: -0.5, ruid: '4c264be6a64f4ac3970b2e6818d04e40' }, // 사서
{ name: 'NpcShop', id: 'shop', x: 2.0, ruid: '69987ccdc486423f8bedd786bd6cb5d9' }, // 상인
{ name: 'NpcBoard', id: 'board', x: 4.5, ruid: '8a99bd87d667482cb1f3b2193f8a19c1' }, // 안내원
];
const MARK_DY = 1.6; // NPC 머리 위 오프셋
// NPC/마크는 정적 스프라이트로 만든다 — 몬스터 AI·물리(중력/충돌)·히트 컴포넌트 전부 제거.
// (마크는 물리가 있으면 머리 위에서 떨어지고, NPC는 충돌이 있으면 플레이어 통행을 막음)
const STRIP = new Set([
'script.Monster', 'script.MonsterAttack', 'script.CombatMonster',
'MOD.Core.RigidbodyComponent', 'MOD.Core.MovementComponent', 'MOD.Core.KinematicbodyComponent',
'MOD.Core.SideviewbodyComponent', 'MOD.Core.HitComponent',
'MOD.Core.DamageSkinSpawnerComponent', 'MOD.Core.DamageSkinSettingComponent',
]);
const compOf = (e, type) => e.jsonString['@components'].find((c) => c['@type'] === type);
const isMonster = (e) => (e.componentNames || '').includes('script.Monster');
// 결정론 GUID — 기존 생성기(map: nn*1000+idx, enc: +500)와 충돌 없는 고유 오프셋
function lobbyGuid(idx) {
const n = (900000 + idx) >>> 0;
return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
}
// 몬스터 템플릿 엔티티를 클론해 스프라이트 엔티티(NPC/마크)로 변환
function makeSpriteEntity(base, name, x, y, ruid, withInteract, npcId) {
const m = JSON.parse(JSON.stringify(base));
m.jsonString.name = name;
m.path = `/maps/lobby/${name}`;
m.jsonString.path = m.path;
// NOTE: 베이스 모델(chasemonster)이 script.Monster/MonsterAttack를 inheritance로 끌고 와서
// DuplicateComponent(LobbyNpc와 공존) 경고 + MonsterAttack.OnBeginPlay AnimationClip 에러가 뜬다.
// 둘 다 비치명적(로비엔 전투 컨텍스트가 없어 Monster는 휴면, 에러는 전투맵 몬스터와 공유되는 기존 lint).
// modelId를 비우면 fresh load에서 렌더가 깨질 위험이 있어 proven-good(모델 유지)로 둔다.
const tr = compOf(m, 'MOD.Core.TransformComponent');
if (tr) { tr.Position.x = x; tr.Position.y = y; }
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
if (sp) sp.SpriteRUID = ruid;
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
if (sa) sa.ActionSheet = { stand: ruid, hit: ruid, die: ruid }; // 항상 stand 스프라이트 고정
// 몬스터 AI·물리·히트 컴포넌트 제거 → 정적 비충돌 스프라이트
m.jsonString['@components'] = m.jsonString['@components'].filter((c) => !STRIP.has(c['@type']));
let names = (m.componentNames || '').split(',').filter((s) => s && !STRIP.has(s));
if (withInteract) {
m.jsonString['@components'].push({ '@type': 'MOD.Core.TouchReceiveComponent', Enable: true, AutoFitToSize: true });
m.jsonString['@components'].push({ '@type': 'script.LobbyNpc', Enable: true, NpcId: npcId, MarkName: name + 'Mark' });
names.push('MOD.Core.TouchReceiveComponent', 'script.LobbyNpc');
}
m.componentNames = names.join(',');
return m;
}
const template = JSON.parse(readFileSync(TEMPLATE, 'utf8'));
const monsterTemplates = template.ContentProto.Entities.filter(isMonster);
if (monsterTemplates.length === 0) throw new Error('[gen-lobby-map] 몬스터 템플릿(스프라이트 엔티티) 없음');
const base = monsterTemplates.find((e) => (e.path || '').includes('Static')) || monsterTemplates[0];
const baseY = (() => {
const tr = compOf(base, 'MOD.Core.TransformComponent');
return tr ? tr.Position.y : 0;
})();
const map = JSON.parse(JSON.stringify(template)); // deep clone
map.EntryKey = 'map://lobby';
let ents = map.ContentProto.Entities.filter((e) => !isMonster(e));
// 경로/이름 치환 + 배경 + 루트 컴포넌트 조정
for (const e of ents) {
if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', '/maps/lobby');
if (e.jsonString) {
if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', '/maps/lobby');
if (e.jsonString.name === 'map01') e.jsonString.name = 'lobby';
}
if ((e.path || '').endsWith('/Background')) {
const bg = compOf(e, 'MOD.Core.BackgroundComponent');
if (bg) bg.TemplateRUID = TOWN_BG;
}
}
const root = ents.find((e) => e.path === '/maps/lobby');
if (!root) throw new Error('[gen-lobby-map] 맵 루트 없음');
// 로비엔 PlayerLock 제거(이동 허용) + LobbyMobility 추가(이동·공격 해제). MapCamera는 유지.
root.jsonString['@components'] = root.jsonString['@components'].filter(
(c) => !['script.PlayerLock', 'script.LobbyMobility'].includes(c['@type']),
);
root.jsonString['@components'].push({ '@type': 'script.LobbyMobility', Enable: true });
{
const names = (root.componentNames || '')
.split(',')
.filter((s) => s && !['script.PlayerLock', 'script.LobbyMobility'].includes(s));
names.push('script.LobbyMobility');
root.componentNames = names.join(',');
}
// NPC + 마크 엔티티 생성
for (const npc of NPCS) {
ents.push(makeSpriteEntity(base, npc.name, npc.x, baseY, npc.ruid, true, npc.id));
ents.push(makeSpriteEntity(base, npc.name + 'Mark', npc.x, baseY + MARK_DY, MARK_RUID, false, ''));
}
// GUID 전부 재발급 (map01과 충돌 방지 + 자기참조 origin 보정)
ents.forEach((e, idx) => {
const oldId = e.id;
const newId = lobbyGuid(idx);
e.id = newId;
const o = e.jsonString && e.jsonString.origin;
if (o) {
if (o.root_entity_id === oldId) o.root_entity_id = newId;
if (o.sub_entity_id === oldId) o.sub_entity_id = newId;
}
});
map.ContentProto.Entities = ents;
writeFileSync(OUT, JSON.stringify(map, null, 2), 'utf8');
// SectorConfig 등록 (멱등)
const sector = JSON.parse(readFileSync(SECTOR, 'utf8'));
const sec0 = sector.ContentProto.Json.Sectors[0];
if (!sec0.entries.includes('map://lobby')) sec0.entries.push('map://lobby');
writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8');
const npcCount = ents.filter((e) => (e.componentNames || '').includes('script.LobbyNpc')).length;
console.log(`[gen-lobby-map] lobby.map 생성: NPC ${npcCount}종 + 마크, SectorConfig entries ${sec0.entries.length}`);

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, 6, 7, 8, 9, 10, 11];
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}`;

View File

@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
const TEMPLATE = 'map/map01.map';
const SECTOR = 'Global/SectorConfig.config';
const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const MAP_NUMBERS = [2, 3, 4, 5];
// 공식 맵에서 수확한 Background-타입 RUID 풀 (맵마다 1개씩, 서로 다르게).
// 공식 MapleStory 맵을 import해 각 맵의 BackgroundComponent.TemplateRUID를 수집함.
@@ -139,14 +139,14 @@ const targets = arg ? [Number(arg)] : MAP_NUMBERS;
const made = targets.map(buildMap);
console.log('Generated:', made.join(', '));
// SectorConfig 등록 (전체 생성 시에만, 중복 방지)
// SectorConfig 등록 (전체 생성 시에만) — 유효 맵만 유지하고 삭제된 맵 엔트리는 제거
if (!arg) {
const sector = JSON.parse(readFileSync(SECTOR, 'utf8'));
const entries = sector.ContentProto.Json.Sectors[0].entries;
for (const nn of MAP_NUMBERS) {
const key = `map://map${String(nn).padStart(2, '0')}`;
if (!entries.includes(key)) entries.push(key);
}
const sec0 = sector.ContentProto.Json.Sectors[0];
const valid = ['map://map01', ...MAP_NUMBERS.map((nn) => `map://map${String(nn).padStart(2, '0')}`)];
// map06~ 등 더 이상 존재하지 않는 맵 엔트리 제거 + 누락분 추가
sec0.entries = sec0.entries.filter((k) => !/^map:\/\/map\d+$/.test(k) || valid.includes(k));
for (const key of valid) if (!sec0.entries.includes(key)) sec0.entries.push(key);
writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8');
console.log('SectorConfig entries:', entries.length);
console.log('SectorConfig entries:', sec0.entries.length);
}

View File

@@ -2,7 +2,7 @@
// ⚠️ 전투 규칙과 마찬가지로 tools/deck/gen-slaydeck.mjs 의 Lua(GenerateMap)와 로직 동기화 유지할 것.
// (Lua는 math.random, 여기는 주입 rng — 수치 동일성이 아니라 구조 규칙 동일성이 대상)
export const ROWS = 7; // 걷는 행 1..7, 보스는 row 8
export const ROWS = 6; // 걷는 행 1..6, 보스는 row 7 (depth 최대 7)
export const COLS = 4;
export const PATHS = 4;
@@ -64,12 +64,13 @@ export function generateMap(rng) {
const id = nodeId(r, c);
const node = nodes[id];
if (!node) continue;
// elite 부모 검사 (연속 엘리트 방지)
let eliteParent = false;
// 부모 노드 타입 수집 (rest/shop/elite 부모와 같은 타입 연속 금지)
const parentTypes = new Set();
for (const pn of Object.values(nodes)) {
if (pn.row === r - 1 && pn.type === 'elite' && pn.next.includes(id)) eliteParent = true;
if (pn.row === r - 1 && pn.next.includes(id)) parentTypes.add(pn.type);
}
const w = rowWeights(r).map(([t, wt]) => [t, t === 'elite' && eliteParent ? 0 : wt]);
const NO_REPEAT = new Set(['rest', 'shop', 'elite']);
const w = rowWeights(r).map(([t, wt]) => [t, NO_REPEAT.has(t) && parentTypes.has(t) ? 0 : wt]);
const total = w.reduce((s, [, wt]) => s + wt, 0);
const roll = rng() * total;
let acc = 0;

View File

@@ -61,7 +61,7 @@ test('타입 규칙: 1~2행 combat만, elite·treasure는 4행부터, shop·rest
}
});
test('boss: row 8 단일 노드, 7행 노드는 전부 boss로 연결', () => {
test('boss: row 7 단일 노드, 마지막 걷는 행 노드는 전부 boss로 연결', () => {
for (let s = 1; s <= 30; s++) {
const { nodes } = gen(s);
const bosses = Object.entries(nodes).filter(([, n]) => n.type === 'boss');
@@ -90,19 +90,21 @@ test('간선 제약: row+1로만, 열 차이 1 이하 (boss 간선 제외)', ()
}
});
test('elite 연속 금지: elite 부모를 가진 노드는 elite 아님', () => {
test('연속 금지: rest/shop/elite 부모와 같은 타입을 자식으로 두지 않음', () => {
const NO_REPEAT = new Set(['rest', 'shop', 'elite']);
for (let s = 1; s <= 100; s++) {
const { nodes } = gen(s);
for (const [id, n] of Object.entries(nodes)) {
if (n.type !== 'elite') continue;
if (!NO_REPEAT.has(n.type)) continue;
for (const nid of n.next) {
assert.notEqual(nodes[nid].type, 'elite', `seed ${s}: ${id}(elite) → ${nid}(elite)`);
if (nid === 'boss') continue;
assert.notEqual(nodes[nid].type, n.type, `seed ${s}: ${id}(${n.type}) → ${nid}(${n.type})`);
}
}
}
});
test('그리드 범위: 행 1..8, 열 1..4 (boss 제외)', () => {
test('그리드 범위: 행 1..6, 열 1..4 (boss 제외)', () => {
const { nodes } = gen(7);
for (const [id, n] of Object.entries(nodes)) {
if (id === 'boss') continue;

View File

@@ -5,7 +5,7 @@ const AI_COMPONENTS = new Set([
'MOD.Core.AIChaseComponent',
]);
const mapFiles = Array.from({ length: 11 }, (_, i) => `map/map${String(i + 1).padStart(2, '0')}.map`);
const mapFiles = Array.from({ length: 5 }, (_, i) => `map/map${String(i + 1).padStart(2, '0')}.map`);
const modelFiles = [
'Global/MoveMonster.model',
'Global/ChaseMonster.model',

View File

@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
// 맵 몬스터에 적 타입(EnemyId)을 부여하고, BeginPlay 시 /common 컨트롤러에 자기등록하는 마커.
// 카드 전투 시 컨트롤러가 등록 목록으로 인카운터를 구성한다.
const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
const NAME_TO_ENEMY = { '주황버섯': 'orange_mushroom', '파란버섯': 'blue_mushroom' };
const DEFAULT_ENEMY = 'orange_mushroom';

View File

@@ -0,0 +1,103 @@
import { writeFileSync } from 'node:fs';
// 로비 codeblock 2종 emit (맵/엔티티 부착은 tools/map/gen-lobby-map.mjs 소관):
// · LobbyNpc — NPC 엔티티에 부착. 근접 폴링→머리위 마크 토글, TouchEvent(클릭)/UpArrow(근접)→Interact→컨트롤러 호출.
// · LobbyMobility — 로비 맵 루트에 부착. 진입 시 플레이어 이동 잠금 해제(정찰 확정: WalkAcceleration이 진짜 레버).
// 공격 키(LeftControl) 바인딩은 SlayDeckController(/common, 1회 등록·로비 가드)에서 처리 — 여기 두지 않음.
// 정찰: 이동에는 RigidbodyComponent.WalkAcceleration(가속)과 MovementComponent.InputSpeed(속도)가
// 둘 다 양수여야 함 — freeze-turn-player가 둘 다 0으로 만들었으므로 둘 다 복원해야 걷는다(실측 확인).
// 값은 freeze가 건드리지 않은 intact RigidbodyComponent.WalkSpeed(1.4)/WalkJump(1.23) = 기본값에 맞춤.
// (실측: InputSpeed 1.4→보행 ~2.5u/s, JumpForce 1.23→점프 상승 1.79u. 이전 5/5는 ~9u/s·상승 14u로 과함.)
const WALK_ACCEL = 0.7;
const WALK_SPEED = 1.4;
const JUMP_FORCE = 1.23;
const PROX = 1.2; // 근접 임계(맵 NPC 간격 2.5와 분리)
function prop(Type, Name, DefaultValue = 'nil') {
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
}
function method(Name, Code, Arguments = [], ExecSpace = 6) {
return {
Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name,
};
}
function writeCodeblock(name, properties, methods) {
const cb = {
Id: '', GameId: '', EntryKey: `codeblock://${name.toLowerCase()}`, ContentType: 'x-mod/codeblock',
Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', StudioVersion: '', DynamicLoading: 0,
ContentProto: { Use: 'Json', Json: {
CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 },
Description: '', Id: name, Language: 1, Name: name, Type: 1, Source: 0, Target: null,
Properties: properties, Methods: methods, EntityEventHandlers: [],
} },
};
writeFileSync(`RootDesk/MyDesk/${name}.codeblock`, JSON.stringify(cb, null, 2) + '\n', 'utf8');
}
// ── LobbyNpc ──────────────────────────────────────────────────────────────
const npcInteract = method('Interact', `local c = _EntityService:GetEntityByPath("/common")
if c ~= nil and c.SlayDeckController ~= nil then
c.SlayDeckController:OnLobbyNpcInteract(self.NpcId)
end`);
const npcBegin = method('OnBeginPlay', `self.InRange = false
local mark = _EntityService:GetEntityByPath("/maps/lobby/" .. self.MarkName)
if mark ~= nil then mark:SetVisible(false) end
self.Entity:ConnectEvent(TouchEvent, function(e)
self:Interact()
end)
_InputService:ConnectEvent(KeyDownEvent, function(e)
if self.InRange and e.key == KeyboardKey.UpArrow then
self:Interact()
end
end)
local eventId = 0
local function tick()
local lp = _UserService.LocalPlayer
if lp == nil then return end
if mark == nil then mark = _EntityService:GetEntityByPath("/maps/lobby/" .. self.MarkName) end
local a = lp.TransformComponent.WorldPosition
local b = self.Entity.TransformComponent.WorldPosition
local d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y))
local near = d < ${PROX}
if near ~= self.InRange then
self.InRange = near
if mark ~= nil then mark:SetVisible(near) end
end
end
eventId = _TimerService:SetTimerRepeat(tick, 0.15)`);
writeCodeblock('LobbyNpc', [
prop('string', 'NpcId', '""'),
prop('string', 'MarkName', '""'),
prop('boolean', 'InRange', 'false'),
], [npcBegin, npcInteract]);
// ── LobbyMobility ─────────────────────────────────────────────────────────
const mobBegin = method('OnBeginPlay', `self.Tries = 0
local eventId = 0
local function apply()
self.Tries = self.Tries + 1
local lp = _UserService.LocalPlayer
if lp ~= nil and lp.PlayerControllerComponent ~= nil then
local pc = lp.PlayerControllerComponent
pc.Enable = true
pc.FixedLookAt = 0
local rb = lp.RigidbodyComponent
if rb ~= nil then rb.WalkAcceleration = ${WALK_ACCEL} end
local mv = lp.MovementComponent
if mv ~= nil then
mv.InputSpeed = ${WALK_SPEED}
mv.JumpForce = ${JUMP_FORCE}
end
_TimerService:ClearTimer(eventId)
elseif self.Tries > 50 then
_TimerService:ClearTimer(eventId)
end
end
eventId = _TimerService:SetTimerRepeat(apply, 0.1)`);
writeCodeblock('LobbyMobility', [prop('number', 'Tries', '0')], [mobBegin]);
console.log('[gen-lobby-npc] LobbyNpc / LobbyMobility codeblock 생성 완료');

View File

@@ -7,7 +7,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
const LOOK_DIRECTION_X = 1; // 1 = 오른쪽(몬스터가 배치된 전투 포메이션 방향)
const FIXED_LOOK_AT = true; // 바라보는 방향 고정
const CONTROLLER_ENABLE = false; // 플레이어 입력 차단
const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
function prop(Type, Name, DefaultValue = 'nil') {
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
@@ -65,6 +65,10 @@ local function apply()
pc.FixedLookAt = ${FIXED_LOOK_AT}
pc.Enable = ${CONTROLLER_ENABLE}
end
if lp ~= nil then
if lp.RigidbodyComponent ~= nil then lp.RigidbodyComponent.WalkAcceleration = 0 end
if lp.MovementComponent ~= nil then lp.MovementComponent.InputSpeed = 0; lp.MovementComponent.JumpForce = 0 end
end
if pc ~= nil then
_TimerService:ClearTimer(eventId)
elseif self.LockTries > 30 then

22
tools/verify/count.mjs Normal file
View File

@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs';
// 산출물 카운트 검증 헬퍼 (RULES §2: 내용 출력 금지·카운트만).
// 사용: node tools/verify/count.mjs <key> <regex> [<regex> ...]
// key: ui | cb | common (산출물 경로는 여기 내장 — Bash 명령에 산출물 경로를 노출하지 않아 deny 회피)
const FILES = {
ui: 'ui/DefaultGroup.ui',
cb: 'RootDesk/MyDesk/SlayDeckController.codeblock',
common: 'Global/common.gamelogic',
};
const key = process.argv[2];
const path = FILES[key];
if (!path) { console.error(`unknown key: ${key} (use ${Object.keys(FILES).join('|')})`); process.exit(1); }
const content = readFileSync(path, 'utf8');
// JSON 유효성도 함께 확인
let jsonOk = false;
try { JSON.parse(content); jsonOk = true; } catch { jsonOk = false; }
console.log(`${path} bytes=${content.length} jsonValid=${jsonOk}`);
for (const pat of process.argv.slice(3)) {
const m = content.match(new RegExp(pat, 'g'));
console.log(` /${pat}/ = ${m ? m.length : 0}`);
}