diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 382cf12..994f986 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -3,10 +3,10 @@ import { existsSync } from 'node:fs'; import { readFile, writeFile } from 'node:fs/promises'; import { chromium } from 'playwright'; -const targetUrl = process.env.QA_URL ?? 'http://localhost:5173/'; +const defaultQaPort = process.env.QA_PORT ?? '41737'; +const targetUrl = process.env.QA_URL ?? `http://127.0.0.1:${defaultQaPort}/`; const headless = process.env.QA_HEADLESS !== '0'; const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80); -const pnpmCommand = process.env.PNPM_CMD ?? 'pnpm'; const cumulativeMode = process.env.QA_CUMULATIVE === '1'; const campaignStorageKey = 'heros-web:campaign-state'; const campaignSlotStorageKey = 'heros-web:campaign-state:slot-1'; @@ -1478,9 +1478,13 @@ async function ensureLocalServer(url) { return undefined; } - const child = spawn(pnpmCommand, ['dev'], { + const viteArgs = localViteServerArgs(url); + if (!viteArgs) { + throw new Error(`QA target is not reachable and cannot be started locally: ${url}`); + } + + const child = spawn(process.execPath, viteArgs, { cwd: process.cwd(), - shell: true, stdio: 'ignore', windowsHide: true }); @@ -1496,6 +1500,28 @@ async function ensureLocalServer(url) { throw new Error(`Could not start dev server at ${url}`); } +function localViteServerArgs(url) { + let parsed; + try { + parsed = new URL(url); + } catch { + return undefined; + } + + const localHosts = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]', '::1']); + if (!localHosts.has(parsed.hostname) || !parsed.port) { + return undefined; + } + + const viteCliPath = 'node_modules/vite/bin/vite.js'; + if (!existsSync(viteCliPath)) { + return undefined; + } + + const host = parsed.hostname === 'localhost' || parsed.hostname === '0.0.0.0' ? '127.0.0.1' : parsed.hostname; + return [viteCliPath, '--host', host, '--port', parsed.port, '--strictPort']; +} + async function isServerReachable(url) { try { const response = await fetch(url); diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 7839fd8..c1921a4 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -1091,10 +1091,55 @@ export const eighthBattleScenario: BattleScenarioDefinition = { victoryConditionLabel: '고순 격파', defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ - '여포군의 척후와 습격병이 소패 보급로를 흔들고 있습니다. 고순을 격파하면 서주의 첫 방위선을 지킬 수 있습니다.', - '기병은 길을 타고 빠르게 파고들고, 궁병은 강 건너와 언덕에서 사거리를 잡습니다. 마을과 창고 지형을 이용해 버티십시오.', + '여포군의 척후와 습격병이 소패 보급로의 창고를 노리고 있습니다. 고순을 격파하면 서주의 첫 방위선을 지킬 수 있습니다.', + '간옹은 병법서와 책략으로 길목 압박을 늦추고, 미축은 창고 주변 보급과 회복을 맡기 좋습니다. 마을과 창고 지형을 중심으로 전열을 잡으십시오.', '18턴 이내 승리하면 서주 보급망과 새 합류 무장의 명성이 크게 오릅니다.' ], + tacticalGuide: { + summary: '중앙 창고 마을을 먼저 안정시키고, 관우가 길목을 버티는 동안 간옹과 미축이 후방에서 회복과 책략 보조를 이어갑니다.', + route: '아군 진영 → 중앙 창고 마을 → 동쪽 길목 → 고순 본대', + focus: '창고 습격병을 먼저 정리해 미축의 보급 위치를 확보하고, 간옹은 화계와 격려로 기병의 접근을 늦추십시오.', + roles: ['관우: 보급로 전열', '장비: 남쪽 길 압박', '간옹: 책략/격려 보조', '미축: 창고 보급/회복', '유비: 후방 지휘'], + events: { + 'village-approach': { + title: '창고 마을 접근', + lines: ['소패 창고 주변에 습격병이 붙어 있습니다.', '미축을 후방에 두고 간옹의 책략으로 길목 적을 약화시키면 보급선을 안정시키기 쉽습니다.'] + }, + 'enemy-posture': { + title: '보급로 압박', + lines: ['여포군 기병은 동쪽 길을 타고 빠르게 들어오고, 궁병은 언덕에서 전열을 끊으려 합니다.', '관우는 길목을 잡고 장비는 남쪽 압박, 간옹과 미축은 뒤에서 회복과 책략을 이어가십시오.'] + }, + 'cavalry-approach': { + title: '기병 돌입', + lines: ['여포군 기병이 보급로 안쪽으로 파고듭니다.', '장비로 측면을 끊고 간옹의 격려 또는 화계로 돌파 속도를 늦추십시오.'] + } + } + }, + sortie: { + sortieLimit: 5, + requiredUnits: ['liu-bei'], + recommendedUnits: [ + { unitId: 'guan-yu', role: 'front', reason: '보급로 앞 길목을 버티며 고순 본대와 기병 압박을 받아냅니다.' }, + { unitId: 'jian-yong', role: 'support', reason: '병법서와 잘 맞는 책략 보조로 화계, 응급, 격려를 모두 활용할 수 있습니다.' }, + { unitId: 'mi-zhu', role: 'reserve', reason: '군량 주머니와 회복품 운용으로 창고 주변 장기전을 안정시킵니다.' }, + { unitId: 'zhang-fei', role: 'flank', reason: '남쪽 길로 들어오는 기병과 습격병을 빠르게 압박합니다.' }, + { unitId: 'liu-bei', role: 'support', reason: '후방에서 생존을 지키며 새 합류 무장과 전열의 균형을 잡습니다.' } + ], + recommendedClasses: [ + { label: '보급로 전열', classKeys: ['infantry'], reason: '마을과 길목 방어 보정을 살려 기병과 보병의 첫 압박을 받아냅니다.' }, + { label: '책략 보조', classKeys: ['strategist'], reason: '화계와 격려로 원거리 궁병과 기병 돌입을 늦추는 역할이 중요합니다.' }, + { label: '창고 보급', classKeys: ['quartermaster'], reason: '회복품과 마을 보정을 묶어 보급로 장기전을 안정시킵니다.' }, + { label: '측면 압박', classKeys: ['spearman'], reason: '남쪽 길의 기병 진입과 창고 습격병을 끊어냅니다.' } + ], + deploymentSlots: [ + { role: 'front', unitId: 'guan-yu', x: 3, y: 17, label: '보급로 전열' }, + { role: 'support', unitId: 'jian-yong', x: 3, y: 18, label: '책략 보조' }, + { role: 'reserve', unitId: 'mi-zhu', x: 1, y: 18, label: '창고 보급' }, + { role: 'flank', unitId: 'zhang-fei', x: 2, y: 18, label: '남쪽 압박' }, + { role: 'support', unitId: 'liu-bei', x: 2, y: 17, label: '후방 지휘' } + ], + note: '소패 보급로는 새로 합류한 간옹과 미축을 시험하기 좋은 전장입니다. 관우가 길목을 막고, 간옹은 책략, 미축은 보급을 맡는 전열이 기본입니다.' + }, map: eighthBattleMap, units: eighthBattleUnits, bonds: eighthBattleBonds, @@ -1122,7 +1167,9 @@ export const eighthBattleScenario: BattleScenarioDefinition = { kind: 'secure-terrain', label: '소패 보급촌 확보', rewardGold: 360, - terrain: 'village' + terrain: 'village', + targetTile: { x: 12, y: 10, radius: 5 }, + threatRadius: 0 }, { id: 'quick', @@ -1134,6 +1181,14 @@ export const eighthBattleScenario: BattleScenarioDefinition = { ], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], itemRewards: ['콩 3', '상처약 2', '탁주 1', '서주 보급망 +1'], + campaignReward: { + supplies: ['콩 3', '상처약 2', '탁주 1'], + equipment: ['군량 주머니 1'], + reputation: ['서주 보급망 +1'], + unlockBattleId: 'ninth-battle-xuzhou-gate-night-raid', + unlockLabel: '서주 성문 야습 개방', + note: '소패 보급로를 지켜 미축의 보급 운용과 간옹의 책략 지원이 서주 방위 편성에 자리 잡습니다.' + }, victoryPages: eighthBattleVictoryPages, nextCampScene: 'CampScene' }; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 28836f7..62631a2 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -282,7 +282,7 @@ const sortieFlows: Record = { afterBattleId: seventhBattleScenario.id, eyebrow: '다음 전장', title: eighthBattleScenario.title, - description: '도겸에게 서주를 맡은 뒤 첫 과제는 소패 보급로를 지키는 일입니다. 여포군의 그림자가 드리우기 전에 새로 합류한 장수들과 방위선을 세워야 합니다.', + description: '도겸에게 서주를 맡은 뒤 첫 과제는 소패 보급로를 지키는 일입니다. 간옹은 전황 판단을, 미축은 창고 보급을 맡아 새 방위선의 빈틈을 메워야 합니다.', rewardHint: `예상 보상: ${eighthBattleScenario.title} 개방`, nextBattleId: eighthBattleScenario.id, campaignStep: 'eighth-battle', @@ -292,7 +292,7 @@ const sortieFlows: Record = { afterBattleId: eighthBattleScenario.id, eyebrow: '다음 전장', title: ninthBattleScenario.title, - description: '여포를 의탁시킨 뒤 서주 성문 안팎의 불안이 터집니다. 조표의 야습을 막아 성문을 지켜내야 합니다.', + description: '소패 보급로를 지켰지만 서주 성문 안팎의 불안은 곧 터집니다. 보급을 정돈한 미축과 책략 보조를 익힌 간옹을 다음 방위 편성에 넣을지 판단해야 합니다.', rewardHint: `예상 보상: ${ninthBattleScenario.title} 개방`, nextBattleId: ninthBattleScenario.id, campaignStep: 'ninth-battle', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 2b23501..4dc081b 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1731,14 +1731,14 @@ const enemyAiByUnitId: Record = { 'xuzhou-guard-b': 'guard', 'xuzhou-archer-c': 'hold', 'xuzhou-leader-xiahou-dun': 'guard', - 'xiaopei-raider-a': 'aggressive', - 'xiaopei-raider-b': 'aggressive', + 'xiaopei-raider-a': 'guard', + 'xiaopei-raider-b': 'guard', 'xiaopei-infantry-a': 'guard', 'xiaopei-infantry-b': 'guard', 'xiaopei-archer-a': 'hold', 'xiaopei-archer-b': 'hold', - 'xiaopei-guard-a': 'aggressive', - 'xiaopei-guard-b': 'aggressive', + 'xiaopei-guard-a': 'guard', + 'xiaopei-guard-b': 'guard', 'xiaopei-cavalry-a': 'aggressive', 'xiaopei-cavalry-b': 'aggressive', 'xiaopei-leader-gao-shun': 'guard', diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index cff6234..bde93cd 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -651,11 +651,13 @@ const sortieRulesByBattleId: Partial