From 47f03abeb47c5279cff56acc9267e9377d1abaf1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:30:35 +0000 Subject: [PATCH] feat(frontend): finish battle simulator workflows --- app/game-frontend/e2e/battleSimulator.spec.ts | 320 ++++++++++++++++++ .../e2e/battleSimulatorRef.spec.ts | 73 ++++ app/game-frontend/e2e/playwright.config.mjs | 2 + app/game-frontend/package.json | 1 + .../components/battle/BattleGeneralCard.vue | 55 ++- app/game-frontend/src/router/index.ts | 1 - .../src/views/BattleSimulatorView.vue | 239 ++++++++++--- app/game-frontend/src/views/JoinView.vue | 23 +- docs/frontend-legacy-parity.md | 18 +- 9 files changed, 636 insertions(+), 96 deletions(-) create mode 100644 app/game-frontend/e2e/battleSimulator.spec.ts create mode 100644 app/game-frontend/e2e/battleSimulatorRef.spec.ts diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts new file mode 100644 index 0000000..bf0760f --- /dev/null +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -0,0 +1,320 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readImage = async (relative: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relative)); + } catch { + // Main checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Reference image not found: ${relative}`); +}; + +const simulatorOptions = { + world: { startYear: 190, currentYear: 205, currentMonth: 8 }, + config: { + maxTrainByWar: 120, + maxAtmosByWar: 120, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + }, + unitSet: { + defaultCrewTypeId: 100, + crewTypes: [ + { id: 100, name: '보병', armType: 1 }, + { id: 200, name: '궁병', armType: 2 }, + ], + }, + nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], + warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], + personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], + items: { horse: [], weapon: [], book: [], item: [] }, + nationLevels: [ + { level: 0, name: '방랑군' }, + { level: 1, name: '소국' }, + ], + cityLevels: [ + { level: 1, name: '소도시' }, + { level: 5, name: '대도시' }, + ], + dexLevels: [ + { level: 0, label: 'F', value: 0 }, + { level: 1, label: 'E', value: 1000 }, + ], +}; + +const generalMe = { + general: { + id: 7, + name: '유비', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: '22.jpg', + imageServer: 0, + officerLevel: 12, + stats: { leadership: 85, strength: 72, intelligence: 78 }, + gold: 1000, + rice: 8765, + crew: 4321, + train: 99, + atmos: 98, + injury: 0, + experience: 900, + dedication: 100, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: { id: 1, level: 1, defence: 2222, wall: 3333 }, + nation: { id: 1, level: 1, tech: 4500, typeCode: 'che_중립', capitalCityId: 1 }, + settings: {}, + penalties: {}, +}; + +const importedGeneral = { + general: { + no: 7, + name: '유비', + officer_level: 12, + explevel: 30, + leadership: 85, + strength: 72, + intel: 78, + horse: null, + weapon: null, + book: null, + item: null, + injury: 0, + rice: 8765, + personal: 'che_대담', + special2: 'che_필살', + crew: 4321, + crewtype: 100, + atmos: 98, + train: 99, + dex1: 1000, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + defence_train: 90, + warnum: 12, + killnum: 7, + killcrew: 3456, + }, +}; + +const simulationResult = { + result: true, + reason: 'success', + datetime: '205-08', + avgWar: 5, + phase: 13, + killed: 1234, + maxKilled: 1400, + minKilled: 1100, + dead: 432, + maxDead: 500, + minDead: 400, + attackerRice: 321, + defenderRice: 654, + attackerSkills: { 필살: 2 }, + defendersSkills: [{ 회피: 1 }], + lastWarLog: { + generalHistoryLog: '', + generalActionLog: '', + generalBattleResultLog: '유비가 모의전에서 승리했습니다.', + generalBattleDetailLog: '필살 발동, 피해 1,234', + nationalHistoryLog: '', + globalHistoryLog: '', + globalActionLog: '', + }, +}; + +type Fixture = { + hasGeneral: boolean; + failNextSimulation?: boolean; + queueFirst?: boolean; + pollingCount: number; + requests: string[]; +}; + +const installImages = async (page: Page) => { + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readImage(`game/${filename}`), + }); + }); + } +}; + +const installApi = async (page: Page, fixture: Fixture) => { + await installImages(page); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + fixture.requests.push(operation); + if (operation === 'lobby.info') { + return response({ + year: 205, + month: 8, + myGeneral: fixture.hasGeneral ? { name: '유비', picture: '22.jpg' } : null, + }); + } + if (operation === 'battle.getSimulatorContext') return response(simulatorOptions); + if (operation === 'general.me') return response(fixture.hasGeneral ? generalMe : null); + if (operation === 'battle.getGeneralList') { + return response({ + myNationId: 1, + myGeneralId: 7, + nations: [{ id: 1, name: '촉', color: '#8fbc8f' }], + generalsByNation: { 1: [{ id: 7, name: '유비', npcState: 0 }] }, + }); + } + if (operation === 'battle.getGeneralDetail') return response(importedGeneral); + if (operation === 'battle.simulate') { + if (fixture.failNextSimulation) { + fixture.failNextSimulation = false; + return errorResponse(operation, '시뮬레이터 입력 오류'); + } + if (fixture.queueFirst) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult }); + } + if (operation === 'battle.getSimulation') { + fixture.pollingCount += 1; + if (fixture.pollingCount === 1) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ + status: 'completed', + jobId: 'job-playwright', + payload: simulationResult, + }); + } + return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoSimulator = async (page: Page) => { + await page.goto('battle-simulator'); + await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible(); + await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible(); + await expect(page.getByText('출병자 설정')).toBeVisible(); +}; + +test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => { + const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] }; + await installApi(page, fixture); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoSimulator(page); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + const noticeRect = await notice.boundingBox(); + expect(noticeRect?.width).toBeGreaterThan(900); + expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex'); + + await page.getByRole('button', { name: '독립 기본값' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190'); + await expect(page.getByLabel('월')).toHaveValue('1'); + + await page.getByRole('button', { name: '현재 게임 환경 적용' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('205'); + await expect(page.getByLabel('월')).toHaveValue('8'); + + await page.getByRole('button', { name: '내 장수를 출병자로' }).click(); + await expect(page.getByLabel('이름').first()).toHaveValue('유비'); + await expect(page.getByLabel('병사').first()).toHaveValue('4321'); + + const battleButton = page.getByRole('button', { name: '전투', exact: true }); + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await page.getByLabel('시드').fill('playwright-fixed-seed'); + await battleButton.click(); + + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('5', { exact: true })).toBeVisible(); + expect(fixture.pollingCount).toBe(2); + expect(fixture.requests).toContain('battle.getSimulation'); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('keeps simulation available without a game general and preserves input after an API error', async ({ page }) => { + const fixture: Fixture = { + hasGeneral: false, + failNextSimulation: true, + pollingCount: 0, + requests: [], + }; + await installApi(page, fixture); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoSimulator(page); + + await expect(page).toHaveURL(/battle-simulator/); + await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled(); + + await page.getByLabel('시드').fill('keep-this-seed'); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); + await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); + + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-mobile.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/battleSimulatorRef.spec.ts b/app/game-frontend/e2e/battleSimulatorRef.spec.ts new file mode 100644 index 0000000..b11b48a --- /dev/null +++ b/app/game-frontend/e2e/battleSimulatorRef.spec.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test } from '@playwright/test'; + +const refBaseUrl = process.env.REF_BATTLE_SIM_URL; +const refPasswordFile = process.env.REF_USER_PASSWORD_FILE; +const refUsername = process.env.REF_USER_ID ?? 'refuser1'; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; +const refTest = refBaseUrl && refPasswordFile ? test : test.skip; + +refTest('runs the legacy simulator in the same Chromium and captures its rendered contract', async ({ page }) => { + test.setTimeout(120_000); + if (!refBaseUrl || !refPasswordFile) { + throw new Error('REF_BATTLE_SIM_URL and REF_USER_PASSWORD_FILE are required'); + } + const password = (await readFile(refPasswordFile, 'utf8')).trim(); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(refBaseUrl, { waitUntil: 'networkidle' }); + await page.locator('#username').fill(refUsername); + await page.locator('#password').fill(password); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const loginResponse = await page + .context() + .request.post(new URL('api.php?path=Login/LoginByID', refBaseUrl).toString(), { + data: { username: refUsername, password: passwordHash }, + }); + expect(loginResponse.status()).toBe(200); + await expect(loginResponse.json()).resolves.toMatchObject({ result: true }); + + await page.goto(new URL('hwe/battle_simulator.php', refBaseUrl).toString(), { + waitUntil: 'networkidle', + }); + const battleButton = page.locator('.btn-begin_battle'); + await expect(battleButton).toBeVisible(); + const container = page.locator('#container'); + const rect = await container.boundingBox(); + expect(rect?.width).toBeGreaterThanOrEqual(995); + expect(rect?.width).toBeLessThanOrEqual(1005); + + // A login with no game general leaves the legacy nation selects without a + // selected option. Choose the first legal independent value before running. + await page.locator('.form_nation_type').evaluateAll((elements) => { + for (const element of elements) { + const select = element as HTMLSelectElement; + select.selectedIndex = 0; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + await expect(page.locator('.form_nation_type').first()).not.toHaveValue(''); + + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + const simulationResponse = page.waitForResponse( + (response) => response.url().includes('/j_simulate_battle.php') && response.status() === 200, + { timeout: 90_000 } + ); + await battleButton.click(); + await simulationResponse; + await expect(page.locator('#generalBattleResultLog')).not.toBeEmpty(); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-ref-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 94135ac..5ef6863 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -16,6 +16,8 @@ export default defineConfig({ 'nationOffices.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', + 'battleSimulator.spec.ts', + 'battleSimulatorRef.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index a60d1d3..5d3a6bd 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -11,6 +11,7 @@ "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 8f60d54..7a84f23 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -7,6 +7,7 @@ interface Props { options: BattleSimOptions; mode: 'attacker' | 'defender'; title: string; + canImportServer: boolean; } const props = defineProps(); @@ -59,7 +60,19 @@ const officerLevelOptions = [
No {{ general.no }}
- + @@ -187,21 +200,11 @@ const officerLevelOptions = [
@@ -391,6 +379,11 @@ const officerLevelOptions = [ color: #f0b6b6; } +.action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .form-block { display: flex; flex-direction: column; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 63d0a16..c4dd81d 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -197,7 +197,6 @@ const routes = [ component: BattleSimulatorView, meta: { requiresAuth: true, - requiresGeneral: true, }, }, { diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index 9b6dec7..e8ced0d 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -26,6 +26,7 @@ type BattleExport = { type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport }; type GeneralListResponse = Awaited>; +type GeneralMeResponse = Awaited>; const loading = ref(true); const error = ref(null); @@ -72,6 +73,7 @@ const importTarget = ref(null); const generalList = ref(null); const generalListLoading = ref(false); const selectedGeneralId = ref(null); +const gameDefaults = ref(null); let generalIdSeed = 0; @@ -250,6 +252,7 @@ const initializeDefaults = async () => { try { const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]); options.value = context; + gameDefaults.value = me; year.value = context.world.currentYear; month.value = context.world.currentMonth; repeatCnt.value = 1; @@ -283,6 +286,47 @@ const initializeDefaults = async () => { } }; +const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id); + +const applyGameEnvironment = () => { + if (!options.value) { + return; + } + const me = gameDefaults.value; + const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + year.value = options.value.world.currentYear; + month.value = options.value.world.currentMonth; + attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; + defenderNation.type = attackerNation.type; + attackerNation.level = me?.nation?.level ?? 0; + defenderNation.level = attackerNation.level; + attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1; + defenderNation.tech = attackerNation.tech; + attackerCity.level = me?.city?.level ?? 5; + defenderCity.level = attackerCity.level; + defenderCity.def = me?.city?.defence ?? 1000; + defenderCity.wall = me?.city?.wall ?? 1000; + attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id; + defenderNation.isCapital = attackerNation.isCapital; + error.value = null; +}; + +const applyIndependentEnvironment = () => { + if (!options.value) { + return; + } + const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + year.value = options.value.world.startYear; + month.value = 1; + seed.value = ''; + repeatCnt.value = 1; + Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false }); + Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false }); + attackerCity.level = 5; + Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 }); + error.value = null; +}; + onMounted(() => { void initializeDefaults(); }); @@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => { } isSimulating.value = true; + error.value = null; + if (action === 'battle') { + battleResult.value = null; + } statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.'; try { const payload = buildBattlePayload(action); const response = await trpc.battle.simulate.mutate(payload); const result = - 'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId); + 'payload' in response && response.payload + ? response.payload + : await waitForSimulationResult(response.jobId); if (!result.result) { error.value = result.reason || 'battle_failed'; @@ -786,6 +836,10 @@ const loadGeneralList = async () => { }; const openImportModal = async (target: GeneralDraft) => { + if (!hasGameGeneral.value) { + error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.'; + return; + } importTarget.value = target; importOpen.value = true; if (!generalList.value) { @@ -801,47 +855,62 @@ const closeImportModal = () => { importTarget.value = null; }; +const applyServerGeneral = async (target: GeneralDraft, generalId: number) => { + const response = await trpc.battle.getGeneralDetail.query({ generalId }); + applyGeneralExport(target, { + no: response.general.no, + name: response.general.name, + officerLevel: response.general.officer_level, + expLevel: response.general.explevel, + leadership: response.general.leadership, + strength: response.general.strength, + intel: response.general.intel, + horse: response.general.horse, + weapon: response.general.weapon, + book: response.general.book, + item: response.general.item, + injury: response.general.injury, + rice: response.general.rice, + personal: response.general.personal, + special2: response.general.special2, + crew: response.general.crew, + crewtype: response.general.crewtype, + atmos: response.general.atmos, + train: response.general.train, + dex1: response.general.dex1, + dex2: response.general.dex2, + dex3: response.general.dex3, + dex4: response.general.dex4, + dex5: response.general.dex5, + defenceTrain: response.general.defence_train, + warnum: response.general.warnum, + killnum: response.general.killnum, + killcrew: response.general.killcrew, + inheritBuff: createInheritBuff(), + }); + target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id); +}; + +const applyMyGeneralToAttacker = async () => { + const generalId = gameDefaults.value?.general?.id; + if (!attackerGeneral.value || !generalId) { + error.value = '불러올 내 장수가 없습니다.'; + return; + } + try { + error.value = null; + await applyServerGeneral(attackerGeneral.value, generalId); + } catch (err) { + error.value = resolveErrorMessage(err); + } +}; + const confirmImport = async () => { if (!importTarget.value || !selectedGeneralId.value) { return; } try { - const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value }); - applyGeneralExport(importTarget.value, { - no: response.general.no, - name: response.general.name, - officerLevel: response.general.officer_level, - expLevel: response.general.explevel, - leadership: response.general.leadership, - strength: response.general.strength, - intel: response.general.intel, - horse: response.general.horse, - weapon: response.general.weapon, - book: response.general.book, - item: response.general.item, - injury: response.general.injury, - rice: response.general.rice, - personal: response.general.personal, - special2: response.general.special2, - crew: response.general.crew, - crewtype: response.general.crewtype, - atmos: response.general.atmos, - train: response.general.train, - dex1: response.general.dex1, - dex2: response.general.dex2, - dex3: response.general.dex3, - dex4: response.general.dex4, - dex5: response.general.dex5, - defenceTrain: response.general.defence_train, - warnum: response.general.warnum, - killnum: response.general.killnum, - killcrew: response.general.killcrew, - inheritBuff: createInheritBuff(), - }); - importTarget.value.no = - importTarget.value === attackerGeneral.value - ? 1 - : resolveGeneralNo(response.general.no, importTarget.value.id); + await applyServerGeneral(importTarget.value, selectedGeneralId.value); } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -882,19 +951,21 @@ const summaryRows = computed(() => { { label: '전투 페이즈', value: formatNumber(battleResult.value.phase) }, { label: '준 피해', - value: battleResult.value.minKilled !== battleResult.value.maxKilled - ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( - battleResult.value.maxKilled - )})` - : formatNumber(battleResult.value.killed), + value: + battleResult.value.minKilled !== battleResult.value.maxKilled + ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( + battleResult.value.maxKilled + )})` + : formatNumber(battleResult.value.killed), }, { label: '받은 피해', - value: battleResult.value.minDead !== battleResult.value.maxDead - ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( - battleResult.value.maxDead - )})` - : formatNumber(battleResult.value.dead), + value: + battleResult.value.minDead !== battleResult.value.maxDead + ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( + battleResult.value.maxDead + )})` + : formatNumber(battleResult.value.dead), }, { label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) }, { label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) }, @@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
+
+
+ 게임 상태와 분리된 모의 계산 +

+ 현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 턴·DB·장수 상태를 변경하지 + 않습니다. +

+
+
+ + + +
+
+
{{ error }}
{{ statusMessage }}
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); :options="options!" mode="attacker" title="출병자 설정" + :can-import-server="hasGameGeneral" @import="openImportModal(attackerGeneral!)" @save="saveGeneral(attackerGeneral!)" @load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })" @@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); :options="options!" mode="defender" :title="`수비자 설정 ${index + 1}`" + :can-import-server="hasGameGeneral" @import="openImportModal(defender)" @save="saveGeneral(defender)" @load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })" @@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);