feat(frontend): finish battle simulator workflows
This commit is contained in:
@@ -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<Buffer> => {
|
||||||
|
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: '<span>유비가 모의전에서 승리했습니다.</span>',
|
||||||
|
generalBattleDetailLog: '<span>필살 발동, 피해 1,234</span>',
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -16,6 +16,8 @@ export default defineConfig({
|
|||||||
'nationOffices.spec.ts',
|
'nationOffices.spec.ts',
|
||||||
'nationGeneralSecret.spec.ts',
|
'nationGeneralSecret.spec.ts',
|
||||||
'npcPolicy.spec.ts',
|
'npcPolicy.spec.ts',
|
||||||
|
'battleSimulator.spec.ts',
|
||||||
|
'battleSimulatorRef.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
"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: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: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": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "node -e \"console.log('test not configured')\"",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Props {
|
|||||||
options: BattleSimOptions;
|
options: BattleSimOptions;
|
||||||
mode: 'attacker' | 'defender';
|
mode: 'attacker' | 'defender';
|
||||||
title: string;
|
title: string;
|
||||||
|
canImportServer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -59,7 +60,19 @@ const officerLevelOptions = [
|
|||||||
<div class="general-subtitle">No {{ general.no }}</div>
|
<div class="general-subtitle">No {{ general.no }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="general-actions">
|
<div class="general-actions">
|
||||||
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
|
<button
|
||||||
|
class="action"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canImportServer"
|
||||||
|
:title="
|
||||||
|
canImportServer
|
||||||
|
? '게임 서버의 장수 정보를 불러옵니다.'
|
||||||
|
: '게임 장수를 보유해야 사용할 수 있습니다.'
|
||||||
|
"
|
||||||
|
@click="emit('import')"
|
||||||
|
>
|
||||||
|
서버에서 가져오기
|
||||||
|
</button>
|
||||||
<button class="action" type="button" @click="emit('save')">저장</button>
|
<button class="action" type="button" @click="emit('save')">저장</button>
|
||||||
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
||||||
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
||||||
@@ -187,21 +200,11 @@ const officerLevelOptions = [
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>훈련</span>
|
<span>훈련</span>
|
||||||
<input
|
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
|
||||||
v-model.number="general.train"
|
|
||||||
type="number"
|
|
||||||
min="40"
|
|
||||||
:max="options.config.maxTrainByWar"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>사기</span>
|
<span>사기</span>
|
||||||
<input
|
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
|
||||||
v-model.number="general.atmos"
|
|
||||||
type="number"
|
|
||||||
min="40"
|
|
||||||
:max="options.config.maxAtmosByWar"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>전특</span>
|
<span>전특</span>
|
||||||
@@ -308,30 +311,15 @@ const officerLevelOptions = [
|
|||||||
<div class="form-row buff-row">
|
<div class="form-row buff-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 회피</span>
|
<span>상대 회피</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warAvoidRatioOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 필살</span>
|
<span>상대 필살</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warCriticalRatioOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>상대 계략</span>
|
<span>상대 계략</span>
|
||||||
<input
|
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
|
||||||
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,6 +379,11 @@ const officerLevelOptions = [
|
|||||||
color: #f0b6b6;
|
color: #f0b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.form-block {
|
.form-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -197,7 +197,6 @@ const routes = [
|
|||||||
component: BattleSimulatorView,
|
component: BattleSimulatorView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresGeneral: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type BattleExport = {
|
|||||||
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
|
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
|
||||||
|
|
||||||
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
|
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
|
||||||
|
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
|
|||||||
const generalList = ref<GeneralListResponse | null>(null);
|
const generalList = ref<GeneralListResponse | null>(null);
|
||||||
const generalListLoading = ref(false);
|
const generalListLoading = ref(false);
|
||||||
const selectedGeneralId = ref<number | null>(null);
|
const selectedGeneralId = ref<number | null>(null);
|
||||||
|
const gameDefaults = ref<GeneralMeResponse>(null);
|
||||||
|
|
||||||
let generalIdSeed = 0;
|
let generalIdSeed = 0;
|
||||||
|
|
||||||
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
|
|||||||
try {
|
try {
|
||||||
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
|
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
|
||||||
options.value = context;
|
options.value = context;
|
||||||
|
gameDefaults.value = me;
|
||||||
year.value = context.world.currentYear;
|
year.value = context.world.currentYear;
|
||||||
month.value = context.world.currentMonth;
|
month.value = context.world.currentMonth;
|
||||||
repeatCnt.value = 1;
|
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(() => {
|
onMounted(() => {
|
||||||
void initializeDefaults();
|
void initializeDefaults();
|
||||||
});
|
});
|
||||||
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSimulating.value = true;
|
isSimulating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
if (action === 'battle') {
|
||||||
|
battleResult.value = null;
|
||||||
|
}
|
||||||
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = buildBattlePayload(action);
|
const payload = buildBattlePayload(action);
|
||||||
const response = await trpc.battle.simulate.mutate(payload);
|
const response = await trpc.battle.simulate.mutate(payload);
|
||||||
const result =
|
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) {
|
if (!result.result) {
|
||||||
error.value = result.reason || 'battle_failed';
|
error.value = result.reason || 'battle_failed';
|
||||||
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openImportModal = async (target: GeneralDraft) => {
|
const openImportModal = async (target: GeneralDraft) => {
|
||||||
|
if (!hasGameGeneral.value) {
|
||||||
|
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
importTarget.value = target;
|
importTarget.value = target;
|
||||||
importOpen.value = true;
|
importOpen.value = true;
|
||||||
if (!generalList.value) {
|
if (!generalList.value) {
|
||||||
@@ -801,47 +855,62 @@ const closeImportModal = () => {
|
|||||||
importTarget.value = null;
|
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 () => {
|
const confirmImport = async () => {
|
||||||
if (!importTarget.value || !selectedGeneralId.value) {
|
if (!importTarget.value || !selectedGeneralId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
|
await applyServerGeneral(importTarget.value, 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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
|
|||||||
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
||||||
{
|
{
|
||||||
label: '준 피해',
|
label: '준 피해',
|
||||||
value: battleResult.value.minKilled !== battleResult.value.maxKilled
|
value:
|
||||||
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
battleResult.value.minKilled !== battleResult.value.maxKilled
|
||||||
battleResult.value.maxKilled
|
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
||||||
)})`
|
battleResult.value.maxKilled
|
||||||
: formatNumber(battleResult.value.killed),
|
)})`
|
||||||
|
: formatNumber(battleResult.value.killed),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '받은 피해',
|
label: '받은 피해',
|
||||||
value: battleResult.value.minDead !== battleResult.value.maxDead
|
value:
|
||||||
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
battleResult.value.minDead !== battleResult.value.maxDead
|
||||||
battleResult.value.maxDead
|
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
||||||
)})`
|
battleResult.value.maxDead
|
||||||
: formatNumber(battleResult.value.dead),
|
)})`
|
||||||
|
: formatNumber(battleResult.value.dead),
|
||||||
},
|
},
|
||||||
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
|
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
|
||||||
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
|
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
|
||||||
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
|
||||||
|
<div>
|
||||||
|
<strong>게임 상태와 분리된 모의 계산</strong>
|
||||||
|
<p>
|
||||||
|
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 턴·DB·장수 상태를 변경하지
|
||||||
|
않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="notice-actions">
|
||||||
|
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
|
||||||
|
현재 게임 환경 적용
|
||||||
|
</button>
|
||||||
|
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
|
||||||
|
독립 기본값
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="!hasGameGeneral || !attackerGeneral"
|
||||||
|
@click="applyMyGeneralToAttacker"
|
||||||
|
>
|
||||||
|
내 장수를 출병자로
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
||||||
|
|
||||||
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
:options="options!"
|
:options="options!"
|
||||||
mode="attacker"
|
mode="attacker"
|
||||||
title="출병자 설정"
|
title="출병자 설정"
|
||||||
|
:can-import-server="hasGameGeneral"
|
||||||
@import="openImportModal(attackerGeneral!)"
|
@import="openImportModal(attackerGeneral!)"
|
||||||
@save="saveGeneral(attackerGeneral!)"
|
@save="saveGeneral(attackerGeneral!)"
|
||||||
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
||||||
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
:options="options!"
|
:options="options!"
|
||||||
mode="defender"
|
mode="defender"
|
||||||
:title="`수비자 설정 ${index + 1}`"
|
:title="`수비자 설정 ${index + 1}`"
|
||||||
|
:can-import-server="hasGameGeneral"
|
||||||
@import="openImportModal(defender)"
|
@import="openImportModal(defender)"
|
||||||
@save="saveGeneral(defender)"
|
@save="saveGeneral(defender)"
|
||||||
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
|
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
|
||||||
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="select-wrap">
|
<div v-else class="select-wrap">
|
||||||
<select v-model.number="selectedGeneralId">
|
<select v-model.number="selectedGeneralId">
|
||||||
<optgroup
|
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
|
||||||
v-for="group in generalGroups"
|
|
||||||
:key="group.nation.id"
|
|
||||||
:label="group.nation.name"
|
|
||||||
>
|
|
||||||
<option
|
<option
|
||||||
v-for="general in group.generals"
|
v-for="general in group.generals"
|
||||||
:key="general.id"
|
:key="general.id"
|
||||||
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
width: min(100%, 1000px);
|
||||||
|
margin: 0 auto;
|
||||||
padding-bottom: 30px;
|
padding-bottom: 30px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
|
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
|
||||||
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.independence-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(112, 170, 141, 0.45);
|
||||||
|
background: rgba(18, 52, 40, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.independence-notice strong {
|
||||||
|
color: #bfe2cd;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.independence-notice p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: rgba(221, 239, 228, 0.75);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1230,6 +1355,11 @@ button {
|
|||||||
background: rgba(16, 16, 16, 0.6);
|
background: rgba(16, 16, 16, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #f5b7b1;
|
color: #f5b7b1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -1369,5 +1499,10 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.independence-notice {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { RouterLink, useRouter } from 'vue-router';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -281,6 +281,7 @@ onMounted(() => {
|
|||||||
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="join-tabs">
|
<div class="join-tabs">
|
||||||
|
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
||||||
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,17 +465,13 @@ onMounted(() => {
|
|||||||
<section v-else class="join-grid">
|
<section v-else class="join-grid">
|
||||||
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
|
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
|
||||||
목록 새로고침
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
||||||
<div v-if="npcLoading && npcCandidates.length === 0">
|
<div v-if="npcLoading && npcCandidates.length === 0">
|
||||||
<SkeletonLines :lines="3" />
|
<SkeletonLines :lines="3" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="npcCandidates.length === 0" class="muted">
|
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
|
||||||
빙의 가능한 NPC가 없습니다.
|
|
||||||
</div>
|
|
||||||
<div v-else class="npc-list">
|
<div v-else class="npc-list">
|
||||||
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
||||||
<div class="npc-header">
|
<div class="npc-header">
|
||||||
@@ -490,9 +487,7 @@ onMounted(() => {
|
|||||||
<div>나이 {{ npc.age }}</div>
|
<div>나이 {{ npc.age }}</div>
|
||||||
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
|
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
|
||||||
빙의
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="npc-footer">
|
<div class="npc-footer">
|
||||||
@@ -542,6 +537,14 @@ onMounted(() => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.simulator-link {
|
||||||
|
border: 1px solid rgba(112, 170, 141, 0.55);
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #bfe2cd;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.join-tabs button.active {
|
.join-tabs button.active {
|
||||||
background: rgba(201, 164, 90, 0.2);
|
background: rgba(201, 164, 90, 0.2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ NPC list, including mutations and recoverable API failures.
|
|||||||
betting routes, including a recoverable failed bet.
|
betting routes, including a recoverable failed bet.
|
||||||
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
|
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
|
||||||
명예의 전당 computed DOM without embedding the reference password.
|
명예의 전당 computed DOM without embedding the reference password.
|
||||||
|
`battleSimulator.spec.ts` covers the authenticated simulator with and without
|
||||||
|
an owned general. `battleSimulatorRef.spec.ts` can additionally exercise the
|
||||||
|
live reference page when its URL, user, and ignored password file are supplied.
|
||||||
|
|
||||||
Run the suite from the core2026 repository root:
|
Run the suite from the core2026 repository root:
|
||||||
|
|
||||||
@@ -33,6 +36,16 @@ When another worktree occupies the default ports, set
|
|||||||
`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
|
`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
|
||||||
tournament and betting screenshots.
|
tournament and betting screenshots.
|
||||||
|
|
||||||
|
Run the focused simulator fixture with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter game-frontend test:e2e:battle-simulator
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional reference check uses `REF_BATTLE_SIM_URL`, `REF_USER_ID`, and
|
||||||
|
`REF_USER_PASSWORD_FILE`. The password is read inside the test and is never
|
||||||
|
written to screenshots or reports.
|
||||||
|
|
||||||
The suite starts both applications at their public prefixes:
|
The suite starts both applications at their public prefixes:
|
||||||
|
|
||||||
- gateway: `http://127.0.0.1:15100/gateway/`
|
- gateway: `http://127.0.0.1:15100/gateway/`
|
||||||
@@ -53,8 +66,8 @@ storage, route guards, and image loading.
|
|||||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||||
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
||||||
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
||||||
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
||||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
||||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||||
@@ -62,6 +75,7 @@ storage, route guards, and image loading.
|
|||||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||||
|
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, fixed-seed result/logs, retained input after API error |
|
||||||
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
|
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
|
||||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||||
|
|||||||
Reference in New Issue
Block a user