feat: restore Ref recruitment command details

This commit is contained in:
2026-08-13 16:20:43 +00:00
parent 94b5b7813c
commit 317a055434
12 changed files with 1375 additions and 58 deletions
+35 -10
View File
@@ -1,11 +1,17 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic';
import { loadActionModuleBundle } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
import { buildTurnCommandTable, evaluateReservedTurnPermission } from '../../turns/commandTable.js';
import {
buildRecruitmentCommandInfo,
buildTurnCommandTable,
evaluateReservedTurnPermission,
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
@@ -89,6 +95,13 @@ const getReservationWorldState = async (ctx: GameApiContext): Promise<WorldState
return worldState;
};
const resolveMapName = (worldState: WorldStateRow, fallback: string): string => {
const config = asRecord(worldState.config);
const environment = asRecord(config.environment ?? config.map);
const mapName = environment.mapName;
return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback;
};
const assertReservedTurnPermission = async (
worldState: WorldStateRow,
general: GeneralRow,
@@ -123,7 +136,11 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
});
}
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] =
const environmentPromise = buildBattleSimEnvironment(worldState, ctx.profile.id);
const moduleBundlePromise = environmentPromise.then((environment) =>
loadActionModuleBundle(environment.unitSet, environment.scenarioEffect)
);
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, moduleBundle, map] =
await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
@@ -140,10 +157,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
where: { nationId: general.nationId },
})
: Promise.resolve(null),
ctx.db.city.findMany({
select: { id: true, name: true, nationId: true },
orderBy: { id: 'asc' },
}),
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
ctx.db.nation.findMany({
select: { id: true, name: true, color: true },
orderBy: { id: 'asc' },
@@ -153,9 +167,10 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
select: { id: true, name: true, nationId: true, cityId: true },
orderBy: { id: 'asc' },
}),
buildBattleSimEnvironment(worldState, ctx.profile.id),
environmentPromise,
loadBattleSimTraitOptions(),
loadItemModules([...ITEM_KEYS]),
moduleBundlePromise,
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
]);
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
@@ -166,7 +181,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
book: [{ value: 'None', label: '판매/해제' }],
item: [{ value: 'None', label: '판매/해제' }],
};
for (const item of itemModules) {
for (const item of moduleBundle.itemModules) {
if (item.buyable) {
items[item.slot].push({ value: item.key, label: item.name });
}
@@ -201,6 +216,16 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
color,
})),
items,
recruitment: buildRecruitmentCommandInfo({
worldState,
general,
city,
nation,
cities,
map,
unitSet: environment.unitSet,
generalActionModules: moduleBundle.general,
}),
};
return buildTurnCommandTable({
+33
View File
@@ -17,6 +17,38 @@ export interface TurnCommandOption {
color?: string;
}
export interface TurnCommandRecruitmentCrewType {
id: number;
armType: number;
name: string;
available: boolean;
special: boolean;
attack: number;
defence: number;
speed: number;
avoid: number;
baseCost: number;
baseRice: number;
info: string[];
}
export interface TurnCommandRecruitmentGroup {
armType: number;
armName: string;
values: TurnCommandRecruitmentCrewType[];
}
export interface TurnCommandRecruitmentInfo {
techLevel: number;
leadership: number;
fullLeadership: number;
currentCrewTypeId: number;
currentCrewTypeName: string;
crew: number;
gold: number;
groups: TurnCommandRecruitmentGroup[];
}
export type TurnCommandOptionSource =
| 'cities'
| 'nations'
@@ -50,6 +82,7 @@ export interface TurnCommandInputOptions {
nationTypes: TurnCommandOption[];
colors: TurnCommandOption[];
items: Record<string, TurnCommandOption[]>;
recruitment: TurnCommandRecruitmentInfo | null;
}
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
+82
View File
@@ -7,15 +7,20 @@ import type {
GeneralItemSlots,
GeneralActionDefinition,
GeneralTurnCommandSpec,
MapDefinition,
Nation,
NationTurnCommandSpec,
RequirementKey,
StateView,
TurnCommandEnv,
TriggerValue,
UnitSetDefinition,
} from '@sammo-ts/logic';
import { evaluateConstraints } from '@sammo-ts/logic';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js';
import { asRecord, isRecord } from '@sammo-ts/common';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
@@ -24,6 +29,7 @@ import {
loadTurnCommandSpecs,
type TurnCommandInputField,
type TurnCommandInputOptions,
type TurnCommandRecruitmentInfo,
} from './commandInput.js';
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
@@ -343,6 +349,81 @@ const mapNationRow = (row: NationRow): Nation => ({
},
});
export const buildRecruitmentCommandInfo = (options: {
worldState: WorldStateRow;
general: GeneralRow;
city: CityRow | null;
nation: NationRow | null;
cities: CityRow[];
map: MapDefinition;
unitSet: UnitSetDefinition;
generalActionModules?: ReadonlyArray<GeneralActionModule | null | undefined>;
}): TurnCommandRecruitmentInfo => {
const general = mapGeneralRow(options.general);
const city = options.city ? mapCityRow(options.city) : undefined;
const nation = options.nation ? mapNationRow(options.nation) : null;
const cities = options.cities.map(mapCityRow);
const context = city ? { general, city, nation } : { general, nation };
const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], {});
const tech = options.nation?.tech ?? 0;
const techAbility = getTechAbility(tech);
const constraintEnv = buildConstraintEnv(options.worldState);
const startYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined;
const availabilityContext = {
general,
nation,
map: options.map,
cities,
currentYear: options.worldState.currentYear,
...(startYear === undefined ? {} : { startYear }),
};
const crewTypes = options.unitSet.crewTypes ?? [];
const armTypes = Object.entries(options.unitSet.armTypes ?? {})
.map(([armType, armName]) => ({ armType: Number(armType), armName }))
.filter((entry) => Number.isFinite(entry.armType))
.sort((left, right) => left.armType - right.armType);
const groups = armTypes.map(({ armType, armName }) => ({
armType,
armName,
values: crewTypes
.filter((crewType) => crewType.armType === armType)
.map((crewType) => {
const displayCost = command.getDisplayUnitCost(context, crewType);
const requiredTech = crewType.requirements.find((requirement) => requirement.type === 'ReqTech');
return {
id: crewType.id,
armType,
name: crewType.name,
available: isCrewTypeAvailable(options.unitSet, crewType.id, availabilityContext),
special:
requiredTech?.type === 'ReqTech' &&
typeof requiredTech.tech === 'number' &&
requiredTech.tech > 0,
attack: crewType.attack + techAbility,
defence: crewType.defence + techAbility,
speed: crewType.speed,
avoid: crewType.avoid,
baseCost: displayCost.gold,
baseRice: displayCost.rice,
info: [...crewType.info],
};
}),
}));
const currentCrewTypeName = crewTypes.find((crewType) => crewType.id === general.crewTypeId)?.name ?? '-';
return {
techLevel: getTechLevel(tech),
leadership: command.resolveLeadership(context),
fullLeadership: command.resolveFullLeadership(context),
currentCrewTypeId: general.crewTypeId,
currentCrewTypeName,
crew: general.crew,
gold: general.gold,
groups,
};
};
const buildStateView = (
general: General,
city: City | null,
@@ -528,6 +609,7 @@ export const buildTurnCommandTable = async (options: {
nationTypes: [],
colors: [],
items: {},
recruitment: null,
},
};
};
+106 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
import { buildTurnCommandTable } from '../src/turns/commandTable.js';
import type { GeneralActionModule, MapDefinition, UnitSetDefinition } from '@sammo-ts/logic';
import { buildRecruitmentCommandInfo, buildTurnCommandTable } from '../src/turns/commandTable.js';
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
({
@@ -136,4 +137,108 @@ describe('buildTurnCommandTable', () => {
reason: '랜덤 임관만 가능합니다',
});
});
it('projects Ref recruitment availability, combat values, descriptions, and adjusted costs', () => {
const general = buildGeneral();
general.injury = 3;
general.gold = 12_345;
const nation = buildNation();
nation.tech = 1000;
const unitSet = {
id: 'test',
name: 'test',
defaultCrewTypeId: 1100,
armTypes: { 1: '보병' },
crewTypes: [
{
id: 1100,
armType: 1,
name: '보병',
attack: 100,
defence: 150,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 9,
rice: 9,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: ['표준적인 보병입니다.'],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
{
id: 1101,
armType: 1,
name: '정예병',
attack: 150,
defence: 200,
speed: 8,
avoid: 20,
magicCoef: 0,
cost: 12,
rice: 10,
requirements: [{ type: 'ReqTech', tech: 2000 }],
attackCoef: {},
defenceCoef: {},
info: ['강력하지만 기술이 필요합니다.'],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
} satisfies UnitSetDefinition;
const map = {
id: 'test',
name: 'test',
cities: [{ id: 1, name: 'TestCity', region: 1 }],
} as unknown as MapDefinition;
const costDiscount: GeneralActionModule = {
onCalcDomestic: (_context, _turnType, varType, value) => (varType === 'cost' ? value * 0.9 : value),
};
const info = buildRecruitmentCommandInfo({
worldState: buildWorldState(),
general,
city: buildCity(),
nation,
cities: [buildCity()],
map,
unitSet,
generalActionModules: [costDiscount],
});
expect(info).toMatchObject({
techLevel: 1,
fullLeadership: 70,
currentCrewTypeId: 1100,
currentCrewTypeName: '보병',
crew: 100,
gold: 12_345,
});
expect(info.leadership).toBeLessThan(info.fullLeadership);
expect(info.groups).toHaveLength(1);
expect(info.groups[0]?.values[0]).toMatchObject({
name: '보병',
available: true,
special: false,
attack: 125,
defence: 175,
speed: 7,
avoid: 10,
info: ['표준적인 보병입니다.'],
});
expect(info.groups[0]?.values[0]?.baseCost).toBeCloseTo(9 * 1.15 * 0.9, 10);
expect(info.groups[0]?.values[0]?.baseRice).toBeCloseTo(9 * 1.15, 10);
expect(info.groups[0]?.values[1]).toMatchObject({
name: '정예병',
available: false,
special: true,
attack: 175,
defence: 225,
info: ['강력하지만 기술이 필요합니다.'],
});
});
});
+209 -2
View File
@@ -30,6 +30,51 @@ const inputOptions = {
nationTypes: [{ value: 'che_중립', label: '중립' }],
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
items: { horse: [{ value: 'None', label: '판매/해제' }] },
recruitment: {
techLevel: 1,
leadership: 68,
fullLeadership: 70,
currentCrewTypeId: 1100,
currentCrewTypeName: '보병',
crew: 500,
gold: 12_345,
groups: [
{
armType: 1,
armName: '보병',
values: [
{
id: 1100,
armType: 1,
name: '보병',
available: true,
special: false,
attack: 125,
defence: 175,
speed: 7,
avoid: 10,
baseCost: 10.35,
baseRice: 10.35,
info: ['표준적인 보병입니다.', '보병은 방어특화입니다.'],
},
{
id: 1101,
armType: 1,
name: '정예병',
available: false,
special: true,
attack: 175,
defence: 225,
speed: 8,
avoid: 20,
baseCost: 13.8,
baseRice: 11.5,
info: ['강력하지만 기술이 필요합니다.'],
},
],
},
],
},
};
const commandTable = {
general: [
@@ -55,6 +100,33 @@ const commandTable = {
},
],
},
{
category: '내정',
values: [
{
key: 'che_징병',
name: '징병',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'crewType', label: '병종', kind: 'select', required: true, optionSource: 'crewTypes' },
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
],
},
{
key: 'che_모병',
name: '모병',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'crewType', label: '병종', kind: 'select', required: true, optionSource: 'crewTypes' },
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
],
},
],
},
],
nation: [
{
@@ -134,6 +206,7 @@ const install = async (page: Page, rejectGeneral = false) => {
const nationTurns = turns(12);
let generalRevision = 0;
let nationRevision = 0;
let dashboardLoaded = false;
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_commands');
localStorage.setItem('sammo-game-profile', profile);
@@ -143,6 +216,33 @@ const install = async (page: Page, rejectGeneral = false) => {
const names = operations(route);
const body = route.request().postDataJSON();
const results = names.map((name) => {
if (name === 'dashboard.getContextBundleDelta') {
const initial = !dashboardLoaded;
dashboardLoaded = true;
return response({
context: initial
? {
kind: 'snapshot',
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
data: generalContext,
}
: { kind: 'unchanged', revision: 'AAAAAAAAAAAAAAAAAAAAAA' },
commandTable: initial
? {
kind: 'snapshot',
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
data: commandTable,
}
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
boardAccess: initial
? {
kind: 'snapshot',
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
data: { permission: 4, canMeeting: true, canSecret: true },
}
: { kind: 'unchanged', revision: 'CCCCCCCCCCCCCCCCCCCCCC' },
});
}
if (name === 'general.me') return response(generalContext);
if (name === 'world.getMapLayout')
return response({
@@ -285,6 +385,114 @@ test('enters general and nation command arguments and sends exact values', async
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
});
test('shows Ref recruitment details and preserves the 1000px desktop and 500px mobile information layouts', async ({
page,
}, testInfo) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
let picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click();
let form = picker.getByTestId('recruitment-command-form');
await expect(form).toContainText('현재 기술력 : 1등급');
await expect(form).toContainText('공격');
await expect(form).toContainText('방어');
await expect(form).toContainText('기동');
await expect(form).toContainText('회피');
await expect(form).toContainText('가격');
await expect(form).toContainText('군량');
await expect(form).toContainText('표준적인 보병입니다.');
await expect(form.getByRole('button', { name: '정예병 선택 불가', exact: true })).toHaveCount(0);
await form.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
const unavailable = form.getByRole('button', { name: '정예병 선택 불가', exact: true });
await expect(unavailable).toBeVisible();
await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)');
const desktopGeometry = await form.evaluate(async (element) => {
const row = element.querySelector('.crew-row');
const image = row?.querySelector('.crew-image');
const info = row?.querySelector('.crew-info');
const backgroundImage = image ? getComputedStyle(image).backgroundImage : '';
const imageUrl = backgroundImage.match(/^url\(["']?(.*?)["']?\)$/)?.[1];
const naturalSize = imageUrl
? await new Promise<{ width: number; height: number } | null>((resolve) => {
const probe = new Image();
probe.onload = () => resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
probe.onerror = () => resolve(null);
probe.src = imageUrl;
})
: null;
return {
formWidth: element.getBoundingClientRect().width,
rowHeight: row?.getBoundingClientRect().height ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
imageNaturalSize: naturalSize,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
};
});
expect(desktopGeometry.formWidth).toBeCloseTo(986, 0);
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(desktopGeometry.infoWidth).toBeCloseTo(250, 0);
expect(desktopGeometry.imageNaturalSize).toEqual({ width: 128, height: 128 });
expect(desktopGeometry.scrollWidth).toBe(desktopGeometry.clientWidth);
const infantry = form.getByRole('button', { name: '보병 선택 가능', exact: true });
await infantry.getByRole('button', { name: '절반', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('징병');
expect(JSON.stringify(requests)).toContain('"crewType":1100');
expect(JSON.stringify(requests)).toContain('"amount":3500');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click();
form = picker.getByTestId('recruitment-command-form');
await page.evaluate(() => window.scrollTo(0, 0));
const mobileGeometry = await form.evaluate((element) => {
const row = element.querySelector('.crew-row');
const image = row?.querySelector('.crew-image');
const info = row?.querySelector('.crew-info');
const selectedPanel = element.querySelector('.mobile-selected-panel');
return {
formLeft: element.getBoundingClientRect().left,
formWidth: element.getBoundingClientRect().width,
rowWidth: row?.getBoundingClientRect().width ?? 0,
rowHeight: row?.getBoundingClientRect().height ?? 0,
imageWidth: image?.getBoundingClientRect().width ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileGeometry.formLeft).toBe(0);
expect(mobileGeometry.formWidth).toBe(500);
expect(mobileGeometry.rowWidth).toBe(500);
expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(mobileGeometry.rowHeight).toBeLessThanOrEqual(66);
expect(mobileGeometry.imageWidth).toBe(64);
expect(mobileGeometry.infoWidth).toBe(270);
expect(mobileGeometry.selectedDisplay).toBe('grid');
expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth);
expect(mobileGeometry.documentScrollWidth).toBeGreaterThanOrEqual(500);
await page.screenshot({ path: testInfo.outputPath('recruitment-mobile.png') });
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '모병', exact: true }).click();
const mercenaryForm = picker.getByTestId('recruitment-command-form');
await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.');
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
});
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
await install(page, true);
await page.goto('/');
@@ -302,7 +510,6 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await page.goto('/');
const editor = page.locator('[data-command-scope="general"]');
if ((await editor.count()) === 0) await page.reload();
await expect(editor).toBeVisible();
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
const drag = async (first: number, last: number, selector = '.index-column > button') => {
@@ -333,7 +540,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await expect.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:clipboard'))).not.toBeNull();
await editor.locator('details.range-menu > summary').click();
await editor.getByRole('button', { name: '모든턴', exact: true }).click();
await expect(editor.locator('.index-column > button.selected')).toHaveCount(14);
await expect(editor.locator('.index-column > button.selected')).toHaveCount(15);
await editor.locator('details.selected-menu > summary').click();
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계');
@@ -0,0 +1,524 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
import type { RecruitmentCrewType, RecruitmentInfo } from './types';
const props = defineProps<{
commandKey: 'che_징병' | 'che_모병';
info: RecruitmentInfo;
}>();
const emit = defineEmits<{
(event: 'update:args', args: Record<string, unknown>): void;
(event: 'update:valid', valid: boolean): void;
(event: 'submit'): void;
}>();
const selectedCrewTypeId = ref(0);
const amount = ref(1);
const showUnavailable = ref<Record<number, boolean>>({});
const commandName = computed(() => (props.commandKey === 'che_모병' ? '모병' : '징병'));
const goldCoefficient = computed(() => (props.commandKey === 'che_모병' ? 2 : 1));
const crewTypes = computed(() => props.info.groups.flatMap((group) => group.values));
const selectedCrewType = computed(
() => crewTypes.value.find((crewType) => crewType.id === selectedCrewTypeId.value) ?? crewTypes.value[0] ?? null
);
const valid = computed(
() => Boolean(selectedCrewType.value?.available) && Number.isFinite(amount.value) && amount.value >= 1
);
const estimatedGold = computed(() =>
selectedCrewType.value ? Math.ceil(amount.value * selectedCrewType.value.baseCost * goldCoefficient.value) : 0
);
const filledAmount = (crewType: RecruitmentCrewType | null): number => {
if (crewType?.id === props.info.currentCrewTypeId) {
return Math.max(1, props.info.fullLeadership - Math.floor(props.info.crew / 100));
}
return Math.max(1, props.info.fullLeadership);
};
const initialize = () => {
showUnavailable.value = Object.fromEntries(props.info.groups.map((group) => [group.armType, false]));
const current = crewTypes.value.find((crewType) => crewType.id === props.info.currentCrewTypeId);
selectedCrewTypeId.value =
(current ?? crewTypes.value.find((crewType) => crewType.available) ?? crewTypes.value[0])?.id ?? 0;
amount.value = filledAmount(selectedCrewType.value);
};
const selectCrewType = (crewType: RecruitmentCrewType) => {
selectedCrewTypeId.value = crewType.id;
amount.value = filledAmount(crewType);
};
const setHalf = () => {
amount.value = Math.max(1, Math.ceil(props.info.fullLeadership * 0.5));
};
const setFilled = () => {
amount.value = filledAmount(selectedCrewType.value);
};
const setFull = () => {
amount.value = Math.max(1, Math.floor(props.info.fullLeadership * 1.2));
};
const updateAmount = (event: Event) => {
const value = Number((event.currentTarget as HTMLInputElement).value);
amount.value = Number.isFinite(value) ? value : 0;
};
const submit = async (crewType?: RecruitmentCrewType) => {
if (crewType) selectCrewType(crewType);
await nextTick();
if (valid.value) emit('submit');
};
const imageUrl = (crewTypeId: number): string => `${configuredGameAssetUrl()}/crewtype${crewTypeId}.png`;
const availabilityClass = (crewType: RecruitmentCrewType): string =>
crewType.available ? (crewType.special ? 'special' : 'available') : 'unavailable';
const displayDecimal = (value: number): string => value.toFixed(1);
watch(() => [props.commandKey, props.info] as const, initialize, { immediate: true, deep: true });
watch(
[selectedCrewTypeId, amount, valid],
() => {
emit('update:args', {
crewType: selectedCrewTypeId.value,
amount: Math.max(0, Math.trunc(amount.value * 100)),
});
emit('update:valid', valid.value);
},
{ immediate: true }
);
</script>
<template>
<section class="recruitment-command-form" data-testid="recruitment-command-form">
<div class="recruitment-intro legacy-bg0">
병사를 모집합니다.
<template v-if="props.commandKey === 'che_징병'"> 훈련과 사기치는 낮지만 가격이 저렴합니다. </template>
<template v-else>훈련과 사기치는 높지만 자금이 많이 듭니다.</template>
<br />가능한 수보다 많게 입력하면 가능한 최대 병사를 모집합니다.<br />
이미 병사가 있는 경우 추가 {{ commandName }}되며, 병종이 다르면 기존 병사는 소집해제됩니다.<br />
현재 {{ commandName }} 가능한 기본 병종은 <span class="legend available">녹색</span>, 특수 병종은
<span class="legend special">초록색</span>, 불가능한 병종은
<span class="legend unavailable">빨간색</span>으로 표시됩니다.
</div>
<div class="recruitment-list-front">
<p v-if="props.commandKey === 'che_모병'" class="mercenary-notice">모병은 가격 2배의 자금이 소요됩니다.</p>
<div class="recruitment-status legacy-bg2">
<span>현재 기술력 : {{ props.info.techLevel }}등급</span>
<span
>현재 통솔 :
<strong :class="{ injured: props.info.leadership < props.info.fullLeadership }">
{{ props.info.leadership }}
</strong></span
>
<span>최대 통솔 : {{ props.info.fullLeadership }}</span>
<span>현재 병종 : {{ props.info.currentCrewTypeName }}</span>
<span>현재 병사 : {{ props.info.crew.toLocaleString() }}</span>
<span>현재 자금 : {{ props.info.gold.toLocaleString() }}</span>
</div>
<div v-if="selectedCrewType" class="mobile-selected-panel legacy-bg0">
<div
class="crew-image"
:style="{ backgroundImage: `url(${JSON.stringify(imageUrl(selectedCrewType.id))})` }"
/>
<button
type="button"
class="crew-name"
:class="availabilityClass(selectedCrewType)"
:title="selectedCrewType.available ? '현재 선택 가능' : '현재 선택 불가'"
>
{{ selectedCrewType.name }}<small>{{ selectedCrewType.available ? '가능' : '불가' }}</small>
</button>
<div class="amount-panel">
<div class="quick-buttons">
<button type="button" @click="setHalf">절반</button>
<button type="button" @click="setFilled">채우기</button>
<button type="button" @click="setFull">가득</button>
</div>
<label>
<span>병력</span>
<input :value="amount" type="number" min="1" step="1" @input="updateAmount" />
<span>00</span><output>{{ estimatedGold.toLocaleString() }}</output>
</label>
</div>
<button type="button" class="submit-recruit" :disabled="!valid" @click="submit()">
{{ commandName }}
</button>
</div>
<div class="crew-grid crew-header legacy-bg1" aria-hidden="true">
<span class="crew-image">사진</span><span class="crew-name">병종</span><span class="attack">공격</span
><span class="defence">방어</span><span class="speed">기동</span><span class="avoid">회피</span
><span class="cost">가격</span><span class="rice">군량</span><span class="amount-panel">병사 </span
><span class="crew-action">행동</span><span class="crew-info">특징</span>
</div>
</div>
<div class="crew-list">
<section v-for="group in props.info.groups" :key="group.armType" class="crew-group">
<header>
<strong>{{ group.armName }} 계열</strong>
<button
type="button"
:class="{ active: showUnavailable[group.armType] }"
@click="showUnavailable[group.armType] = !showUnavailable[group.armType]"
>
{{
showUnavailable[group.armType]
? '선택 할 수 있는 병종만 보기'
: '선택 할 수 없는 병종도 보기'
}}
</button>
</header>
<div
v-for="crewType in group.values.filter(
(entry) => showUnavailable[group.armType] || entry.available
)"
:key="crewType.id"
class="crew-grid crew-row"
:class="{ selected: crewType.id === selectedCrewTypeId }"
role="button"
tabindex="0"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '선택 불가'}`"
@click="selectCrewType(crewType)"
@keydown.enter="selectCrewType(crewType)"
>
<span
class="crew-image"
:style="{ backgroundImage: `url(${JSON.stringify(imageUrl(crewType.id))})` }"
/>
<span class="crew-name" :class="availabilityClass(crewType)"
>{{ crewType.name }}<small>{{ crewType.available ? '가능' : '불가' }}</small></span
>
<span class="attack"><small>공격</small>{{ crewType.attack }}</span>
<span class="defence"><small>방어</small>{{ crewType.defence }}</span>
<span class="speed"><small>기동</small>{{ crewType.speed }}</span>
<span class="avoid"><small>회피</small>{{ crewType.avoid }}</span>
<span class="cost"><small>가격</small>{{ displayDecimal(crewType.baseCost) }}</span>
<span class="rice"><small>군량</small>{{ displayDecimal(crewType.baseRice) }}</span>
<span class="amount-panel" @click.stop>
<span class="quick-buttons">
<button
type="button"
@click="
selectCrewType(crewType);
setHalf();
"
>
절반
</button>
<button
type="button"
@click="
selectCrewType(crewType);
setFilled();
"
>
채우기
</button>
<button
type="button"
@click="
selectCrewType(crewType);
setFull();
"
>
가득
</button>
</span>
<label>
<span>병력</span>
<input
:value="crewType.id === selectedCrewTypeId ? amount : filledAmount(crewType)"
type="number"
min="1"
step="1"
@focus="selectCrewType(crewType)"
@input="
selectCrewType(crewType);
updateAmount($event);
"
/>
<span>00명</span>
</label>
</span>
<span class="crew-action" @click.stop>
<button type="button" :disabled="!crewType.available" @click="submit(crewType)">
{{ commandName }}
</button>
</span>
<span class="crew-info"
><span v-for="line in crewType.info" :key="line">{{ line }}</span></span
>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.recruitment-command-form {
box-sizing: border-box;
width: 100%;
min-width: 0;
color: #fff;
background: #1d1d1d;
font: 14px/1.25 var(--sammo-font-sans);
}
.recruitment-intro {
padding: 6px;
}
.legend,
.crew-name {
color: #fff;
}
.available {
background: green !important;
}
.special {
background: limegreen !important;
}
.unavailable {
background: #c90000 !important;
}
.mercenary-notice {
margin: 0;
padding: 4px;
text-align: center;
}
.recruitment-status {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
text-align: center;
}
.recruitment-status > span {
padding: 4px 2px;
}
.injured {
color: red;
}
.crew-grid {
display: grid;
grid-template-columns: 64px 90px repeat(6, minmax(42px, 1fr)) 210px 72px 250px;
align-items: stretch;
text-align: center;
}
.crew-header {
min-height: 30px;
align-items: center;
}
.crew-header > span,
.crew-row > span {
min-width: 0;
display: grid;
place-items: center;
}
.crew-group > header {
min-height: 38px;
display: grid;
grid-template-columns: 1fr 205px;
align-items: center;
border-bottom: 1px solid #777;
}
.crew-group > header strong {
padding: 0 12px;
font-size: 1.3em;
}
.crew-group > header button {
min-height: 34px;
border: 0;
background: #444;
color: #fff;
}
.crew-group > header button.active {
background: #d89a00;
color: #111;
}
.crew-row {
width: 100%;
min-height: 64px;
border: 0;
border-bottom: 1px solid #777;
padding: 0;
background: #242424;
color: #fff;
font: inherit;
cursor: pointer;
}
.crew-row:hover,
.crew-row:focus-visible,
.crew-row.selected {
outline: 2px solid #5bc0de;
outline-offset: -2px;
}
.crew-image {
min-height: 64px;
background: #222 no-repeat center;
background-size: 64px;
outline: 1px solid gray;
}
.crew-name {
height: 100%;
border: 0;
font: inherit;
}
.crew-name small {
display: block;
font-size: 0.72em;
}
.crew-row small {
display: none;
}
.amount-panel {
align-content: center;
padding: 2px 5px;
}
.quick-buttons {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.quick-buttons button,
.crew-action button,
.submit-recruit {
min-height: 28px;
border: 1px solid #777;
background: #444;
color: #fff;
}
.amount-panel label {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
}
.amount-panel input {
min-width: 0;
height: 28px;
box-sizing: border-box;
text-align: right;
}
.crew-action button,
.submit-recruit {
width: 100%;
height: 100%;
background: #375a7f;
}
.crew-action button:disabled,
.submit-recruit:disabled {
opacity: 0.45;
}
.crew-info {
padding: 4px 8px;
text-align: left;
}
.crew-info > span {
display: block;
}
.mobile-selected-panel {
display: none;
}
@media (max-width: 600px) {
.recruitment-command-form {
width: 500px;
}
.recruitment-list-front {
position: sticky;
z-index: 5;
top: 0;
background: #1d1d1d;
}
.recruitment-status {
grid-template-columns: repeat(3, 1fr);
}
.mobile-selected-panel {
display: grid;
grid-template-columns: 64px 76px 270px 90px;
min-height: 64px;
align-items: stretch;
}
.mobile-selected-panel .crew-name {
display: grid;
place-items: center;
}
.mobile-selected-panel .amount-panel label {
grid-template-columns: auto minmax(0, 1fr) auto 85px;
}
.mobile-selected-panel output {
box-sizing: border-box;
min-width: 0;
padding: 4px;
background: #ddd;
color: #303030;
text-align: right;
}
.crew-grid {
grid-template:
'image name attack defence speed info' 32px
'image name avoid cost rice info' 32px /
64px 76px 30px 30px 30px 270px;
}
.crew-grid .crew-image {
grid-area: image;
}
.crew-grid .crew-name {
grid-area: name;
}
.crew-grid .attack {
grid-area: attack;
}
.crew-grid .defence {
grid-area: defence;
}
.crew-grid .speed {
grid-area: speed;
}
.crew-grid .avoid {
grid-area: avoid;
}
.crew-grid .cost {
grid-area: cost;
}
.crew-grid .rice {
grid-area: rice;
}
.crew-grid .crew-info {
grid-area: info;
}
.crew-grid .amount-panel,
.crew-grid .crew-action {
display: none;
}
.crew-row small {
display: block;
font-size: 0.62em;
line-height: 1;
}
.crew-row .crew-name small {
font-size: 0.72em;
}
.crew-header .attack,
.crew-header .defence,
.crew-header .speed,
.crew-header .avoid,
.crew-header .cost,
.crew-header .rice {
font-size: 0.78em;
}
.crew-group > header {
grid-template-columns: 7fr 5fr;
}
.crew-group > header button {
font-size: 0.82em;
}
.crew-info {
overflow: hidden;
font-size: 0.9em;
}
}
</style>
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue';
import DragSelect from './DragSelect.vue';
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
import {
amplifyPattern,
CommandStorage,
@@ -113,6 +114,9 @@ const displayRows = computed(() =>
props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount)
);
const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`);
const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
);
const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action;
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
const pattern = () => extractPattern(props.rows, selectedIndices());
@@ -560,6 +564,7 @@ const clickOutsideMenu = (event: Event) => {
<div
v-if="pickerOpen"
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
>
@@ -584,8 +589,20 @@ const clickOutsideMenu = (event: Event) => {
>현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.</small
>
</div>
<RecruitmentCommandForm
v-if="
isRecruitmentCommand &&
props.commandTable?.inputOptions.recruitment &&
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
"
:command-key="selectedCommand.key"
:info="props.commandTable.inputOptions.recruitment"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
@submit="submitCommand"
/>
<CommandArgumentForm
v-if="selectedCommand.reqArg && props.commandTable"
v-else-if="selectedCommand.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
@@ -901,6 +918,20 @@ const clickOutsideMenu = (event: Event) => {
max-height: 344px;
}
.reserved-command-editor .command-picker.recruitment-picker {
position: fixed;
z-index: 1100;
top: 76px;
right: auto;
bottom: auto;
left: 50%;
width: 1000px;
height: auto;
max-height: calc(100vh - 82px);
overflow: auto;
transform: translateX(-50%);
}
@media (min-width: 1025px) {
.compact:not(.mobile) .command-picker {
position: fixed;
@@ -910,6 +941,14 @@ const clickOutsideMenu = (event: Event) => {
left: calc(50% - 476px);
width: 238px;
}
.compact:not(.mobile) .command-picker.recruitment-picker {
top: 76px;
left: 50%;
width: 1000px;
max-height: calc(100vh - 82px);
overflow: auto;
transform: translateX(-50%);
}
}
.mobile.compact .editor-layout {
@@ -945,9 +984,33 @@ const clickOutsideMenu = (event: Event) => {
width: 370px;
height: 327px;
}
.mobile.compact .command-picker.recruitment-picker {
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
transform: none;
}
.mobile.compact .advanced-actions {
right: 0;
bottom: 0;
left: 109px;
}
@media (max-width: 600px) {
.reserved-command-editor .command-picker.recruitment-picker,
.reserved-command-editor.compact .command-picker.recruitment-picker {
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
overflow: auto;
padding: 0;
border-right: 0;
border-left: 0;
transform: none;
}
}
</style>
@@ -26,6 +26,36 @@ export type CommandAvailability = {
export type CommandGroup = { category: string; values: CommandAvailability[] };
export type RecruitmentCrewType = {
id: number;
armType: number;
name: string;
available: boolean;
special: boolean;
attack: number;
defence: number;
speed: number;
avoid: number;
baseCost: number;
baseRice: number;
info: string[];
};
export type RecruitmentInfo = {
techLevel: number;
leadership: number;
fullLeadership: number;
currentCrewTypeId: number;
currentCrewTypeName: string;
crew: number;
gold: number;
groups: Array<{
armType: number;
armName: string;
values: RecruitmentCrewType[];
}>;
};
export type CommandTable = {
general: CommandGroup[];
nation: CommandGroup[];
@@ -38,6 +68,7 @@ export type CommandTable = {
nationTypes: CommandOption[];
colors: CommandOption[];
items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null;
};
};
@@ -8,7 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
import type { CommandPatternEntry } from '../components/command/types';
import type { CommandPatternEntry, CommandTable } from '../components/command/types';
type ChiefTurn = {
index: number;
@@ -43,49 +43,6 @@ type ChiefCenterResponse = {
chiefs: ChiefEntry[];
};
type CommandAvailability = {
key: string;
name: string;
reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
reason?: string;
inputFields: Array<{
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
required: boolean;
min?: number;
max?: number;
step?: number;
constValue?: string | number;
options?: Array<{ value: string | number; label: string; color?: string }>;
optionSource?:
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
}>;
};
type CommandGroup = {
category: string;
values: CommandAvailability[];
};
type CommandTable = {
general: CommandGroup[];
nation: CommandGroup[];
inputOptions: {
cities: Array<{ value: string | number; label: string; color?: string }>;
nations: Array<{ value: string | number; label: string; color?: string }>;
generals: Array<{ value: string | number; label: string; color?: string }>;
crewTypes: Array<{ value: string | number; label: string; color?: string }>;
armTypes: Array<{ value: string | number; label: string; color?: string }>;
nationTypes: Array<{ value: string | number; label: string; color?: string }>;
colors: Array<{ value: string | number; label: string; color?: string }>;
items: Record<string, Array<{ value: string | number; label: string; color?: string }>>;
};
};
const chiefApi = trpc as unknown as {
nation: {
getChiefCenter: {
+16
View File
@@ -167,6 +167,22 @@ and is never written to the artifact. The matching core fixture is
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
징병·모병의 Ref 화면은 다음 collector로 1000/500px DOM, 이미지 natural size,
불가능 병종 toggle과 hover/focus를 수집합니다. 기본 모드는 현재 Ref session을
사용합니다. 비교 계정이 없는 환경에서는 `REF_STATIC_FIXTURE=1`로 Ref가 빌드한
실제 `v_processing.js`/CSS에 고정 `procRes`만 주입하며, 이 결과는 live PHP/DB
export 검증과 구분합니다. artifact 디렉터리는 Git 밖의 경로를 사용합니다.
```sh
REF_PARITY_PASSWORD_FILE=/path/to/ignored/password \
REF_PARITY_ARTIFACT_DIR=/tmp/ref-recruitment \
node tools/frontend-legacy-parity/reference-recruitment.mjs
REF_STATIC_FIXTURE=1 \
REF_PARITY_ARTIFACT_DIR=/tmp/ref-recruitment-static \
node tools/frontend-legacy-parity/reference-recruitment.mjs
```
For a review run that also writes full-page screenshots, create an ignored
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
suite. The ordinary CI run does not write screenshots after successful tests.
@@ -235,6 +235,25 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
return finalizeLegacyStat(this.pipeline.onCalcStat(context, 'leadership', base));
}
resolveFullLeadership(context: RecruitCalcContext<TriggerState>): number {
return finalizeLegacyStat(this.pipeline.onCalcStat(context, 'leadership', context.general.stats.leadership));
}
getDisplayUnitCost(
context: RecruitCalcContext<TriggerState>,
crewType: { armType: number; cost: number; rice: number }
): { gold: number; rice: number } {
const techCost = getTechCost(readNationTech(context.nation ?? null));
return {
gold: this.pipeline.onCalcDomestic(context, ACTION_NAME, 'cost', crewType.cost * techCost, {
armType: crewType.armType,
}),
rice: this.pipeline.onCalcDomestic(context, ACTION_NAME, 'rice', crewType.rice * techCost, {
armType: crewType.armType,
}),
};
}
resolveCrewPlan(
context: RecruitCalcContext<TriggerState>,
crewTypeId: number,
@@ -0,0 +1,255 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_PARITY_BASE_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_PARITY_USER ?? 'refuser1';
const passwordFile =
process.env.REF_PARITY_PASSWORD_FILE ??
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
const artifactDir = process.env.REF_PARITY_ARTIFACT_DIR;
const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1';
const useStaticFixture = process.env.REF_STATIC_FIXTURE === '1';
const password = useStaticFixture ? null : (await readFile(passwordFile, 'utf8')).trim();
const browser = await chromium.launch({ headless: true });
const staticCrewTypes = [
{
id: 1100,
armType: 1,
name: '보병',
reqTech: 0,
reqYear: 0,
notAvailable: false,
attack: 125,
defence: 175,
speed: 7,
avoid: 10,
baseCost: 10.35,
baseRice: 10.35,
img: 'https://sam-image.hided.net/game/crewtype1100.png',
info: ['표준적인 보병입니다.', '보병은 방어특화입니다.'],
},
{
id: 1101,
armType: 1,
name: '정예병',
reqTech: 1000,
reqYear: 0,
notAvailable: true,
attack: 175,
defence: 225,
speed: 8,
avoid: 20,
baseCost: 13.8,
baseRice: 11.5,
img: 'https://sam-image.hided.net/game/crewtype1101.png',
info: ['강력하지만 기술이 필요합니다.'],
},
];
const loadStaticReference = async (page, command) => {
const commandName = command === 'che_모병' ? '모병' : '징병';
const procRes = {
relYear: 20,
year: 200,
tech: 1000,
techLevel: 1,
startYear: 180,
goldCoeff: command === 'che_모병' ? 2 : 1,
leadership: 68,
fullLeadership: 70,
armCrewTypes: [{ armType: 1, armName: '보병', values: staticCrewTypes }],
currentCrewType: 1100,
crew: 500,
gold: 12_345,
};
const assetBase = new URL('dist_js/hwe_dynamic/vue/', baseUrl).toString();
const rootAssetBase = new URL('', baseUrl).toString();
await page.setContent(
`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=500">` +
`<link rel="stylesheet" href="${rootAssetBase}d_shared/common.css">` +
`<link rel="stylesheet" href="${assetBase}vendors.css">` +
`<link rel="stylesheet" href="${assetBase}common_ts.css">` +
`<link rel="stylesheet" href="${assetBase}bootstrap.css">` +
`<link rel="stylesheet" href="${assetBase}v_processing.css">` +
`<script>var staticValues=${JSON.stringify({
serverNick: 'hwe',
serverID: 'hwe',
commandName,
turnList: [0],
currentCity: 1,
currentNation: 1,
entryInfo: ['General', command],
mapName: 'che',
unitSet: 'che',
})};var procRes=${JSON.stringify(procRes)};var entryInfo=['General',${JSON.stringify(command)}];</script>` +
`</head><body><div id="app"></div></body></html>`,
{ waitUntil: 'networkidle' }
);
for (const url of [
`${rootAssetBase}d_shared/common_path.js`,
`${rootAssetBase}hwe/d_shared/base_map.js`,
`${assetBase}vendors.js`,
`${assetBase}common_ts.js`,
`${assetBase}bootstrap.js`,
`${assetBase}v_processing.js`,
]) {
await page.addScriptTag({ url });
}
};
if (artifactDir) await mkdir(artifactDir, { recursive: true });
try {
const context = await browser.newContext({
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
});
const page = await context.newPage();
const diagnostics = [];
page.on('pageerror', (error) => diagnostics.push(`pageerror: ${error.stack ?? error.message}`));
page.on('console', (message) => {
if (message.type() === 'error') diagnostics.push(`console: ${message.text()}`);
});
if (!useStaticFixture) {
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const loginResult = await login.json();
if (!login.ok() || loginResult.result !== true) {
throw new Error(`reference login failed: HTTP ${login.status()}`);
}
await page.goto(new URL('hwe/index.php', baseUrl).toString(), { waitUntil: 'networkidle' });
if (!(await page.locator('.reservedCommandZone').isVisible()) && allowGeneralCreate) {
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), { waitUntil: 'networkidle' });
const create = page.getByRole('button', { name: '장수 생성', exact: true });
await create.waitFor({ state: 'visible', timeout: 30_000 });
page.once('dialog', (dialog) => dialog.accept());
await create.click();
await page.locator('.reservedCommandZone').waitFor({ state: 'visible', timeout: 60_000 });
}
} else {
await page.goto(new URL('d_shared/common.css', baseUrl).toString(), { waitUntil: 'networkidle' });
}
for (const command of ['che_징병', 'che_모병']) {
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
if (useStaticFixture) {
await loadStaticReference(page, command);
} else {
const url = new URL('hwe/v_processing.php', baseUrl);
url.searchParams.set('command', command);
url.searchParams.set('turnList', '0');
await page.goto(url.toString(), { waitUntil: 'networkidle' });
}
try {
await page.locator('.crewTypeList').waitFor({ state: 'visible', timeout: 5_000 });
} catch {
throw new Error(
`reference recruitment page unavailable: ${page.url()} (${await page.title()}) | ${diagnostics
.slice(-5)
.join(' | ')}`
);
}
const toggle = page.getByRole('button', { name: '선택 할 수 없는 병종도 보기' }).first();
const unavailableBefore = await page
.locator('.crewTypeItem')
.evaluateAll(
(rows) =>
rows.filter(
(row) =>
getComputedStyle(row.querySelector('.crewTypeName')).backgroundColor ===
'rgb(255, 0, 0)'
).length
);
await toggle.hover();
const toggleHover = await toggle.evaluate((element) => {
const style = getComputedStyle(element);
return { backgroundColor: style.backgroundColor, borderColor: style.borderColor, color: style.color };
});
await toggle.focus();
const toggleFocus = await toggle.evaluate((element) => {
const style = getComputedStyle(element);
return { outline: style.outline, boxShadow: style.boxShadow };
});
await toggle.click();
const measurement = await page.evaluate(async () => {
const rect = (element) => element.getBoundingClientRect().toJSON();
const list = document.querySelector('.crewTypeList');
const status = document.querySelector('.listFront .bg2');
const header = document.querySelector('.listHeader');
const firstRow = document.querySelector('.crewTypeItem');
const image = firstRow?.querySelector('.crewTypeImg');
const info = firstRow?.querySelector('.crewTypeInfo');
const selectedPanel = document.querySelector('.miniCrewPanel');
if (
!(list instanceof HTMLElement) ||
!(status instanceof HTMLElement) ||
!(header instanceof HTMLElement)
) {
throw new Error('missing recruitment layout');
}
const backgroundImage = image instanceof HTMLElement ? getComputedStyle(image).backgroundImage : '';
const imageUrl = backgroundImage.match(/^url\(["']?(.*?)["']?\)$/)?.[1];
const naturalSize = imageUrl
? await new Promise((resolve) => {
const probe = new Image();
probe.onload = () => resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
probe.onerror = () => resolve(null);
probe.src = imageUrl;
})
: null;
return {
document: {
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
},
list: rect(list),
status: rect(status),
statusCells: Array.from(status.children, rect),
header: rect(header),
gridTemplateColumns: getComputedStyle(header).gridTemplateColumns,
firstRow: firstRow instanceof HTMLElement ? rect(firstRow) : null,
image: image instanceof HTMLElement ? rect(image) : null,
imageNaturalSize: naturalSize,
info: info instanceof HTMLElement ? rect(info) : null,
selectedPanel:
selectedPanel instanceof HTMLElement
? { rect: rect(selectedPanel), display: getComputedStyle(selectedPanel).display }
: null,
statusText: status.textContent?.replace(/\s+/g, ' ').trim(),
firstRowText: firstRow?.textContent?.replace(/\s+/g, ' ').trim(),
unavailableAfter: Array.from(document.querySelectorAll('.crewTypeItem')).filter(
(row) =>
getComputedStyle(row.querySelector('.crewTypeName')).backgroundColor === 'rgb(255, 0, 0)'
).length,
};
});
if (artifactDir) {
await page.screenshot({ path: join(artifactDir, `${command}-${viewport.name}.png`), fullPage: true });
}
console.log(
JSON.stringify({ command, viewport, unavailableBefore, toggleHover, toggleFocus, measurement })
);
}
}
} finally {
await browser.close();
}