Merge remote-tracking branch 'origin/main' into fix/command-map-detail-images-20260813
This commit is contained in:
@@ -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 plainLegacyInfo = (value: string): string =>
|
||||
value
|
||||
.replace(/<br\s*\/?>/giu, ' · ')
|
||||
@@ -141,7 +154,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({
|
||||
@@ -158,10 +175,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' },
|
||||
@@ -171,9 +185,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]));
|
||||
@@ -184,7 +199,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) {
|
||||
const cost = item.cost ?? 0;
|
||||
const currentSecurity = city?.security ?? 0;
|
||||
@@ -239,6 +254,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,
|
||||
}),
|
||||
context: {
|
||||
actorGold: general.gold,
|
||||
actorRice: general.rice,
|
||||
|
||||
@@ -18,6 +18,38 @@ export interface TurnCommandOption {
|
||||
description?: 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' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
||||
|
||||
@@ -44,6 +76,7 @@ export interface TurnCommandInputOptions {
|
||||
nationTypes: TurnCommandOption[];
|
||||
colors: TurnCommandOption[];
|
||||
items: Record<string, TurnCommandOption[]>;
|
||||
recruitment: TurnCommandRecruitmentInfo | null;
|
||||
context?: {
|
||||
actorGold: number;
|
||||
actorRice: number;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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: ['강력하지만 기술이 필요합니다.'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,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: ['강력하지만 기술이 필요합니다.'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {
|
||||
actorGold: 1000,
|
||||
actorRice: 1000,
|
||||
@@ -88,6 +133,11 @@ const commandTable = {
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '내정',
|
||||
values: [
|
||||
{
|
||||
key: 'che_징병',
|
||||
name: '징병',
|
||||
@@ -95,13 +145,18 @@ const commandTable = {
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
{
|
||||
key: 'crewType',
|
||||
label: '병종',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'crewTypes',
|
||||
},
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
@@ -178,8 +233,46 @@ const generalContext = {
|
||||
dedication: 0,
|
||||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||
},
|
||||
city: { id: 1, name: '업', level: 8, region: 1, population: 1000, populationMax: 2000 },
|
||||
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
|
||||
city: {
|
||||
id: 1,
|
||||
name: '업',
|
||||
level: 8,
|
||||
levelName: '특',
|
||||
region: 1,
|
||||
regionName: '하북',
|
||||
nationId: 1,
|
||||
nationName: '아국',
|
||||
population: 1000,
|
||||
populationMax: 2000,
|
||||
agriculture: 100,
|
||||
agricultureMax: 200,
|
||||
commerce: 100,
|
||||
commerceMax: 200,
|
||||
security: 100,
|
||||
securityMax: 200,
|
||||
trust: 70,
|
||||
trade: 100,
|
||||
defence: 100,
|
||||
defenceMax: 200,
|
||||
wall: 100,
|
||||
wallMax: 200,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
},
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '아국',
|
||||
color: '#008000',
|
||||
level: 1,
|
||||
levelName: '호족',
|
||||
gold: 5000,
|
||||
rice: 6000,
|
||||
tech: 100,
|
||||
typeCode: 'che_중립',
|
||||
typeName: '중립',
|
||||
capitalCityId: 1,
|
||||
capitalCityName: '업',
|
||||
},
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
@@ -207,6 +300,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);
|
||||
@@ -235,16 +329,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')
|
||||
if (name === 'dashboard.getContextBundleDelta') {
|
||||
const initial = !dashboardLoaded;
|
||||
dashboardLoaded = true;
|
||||
return response({
|
||||
context: { kind: 'snapshot', revision: 'context-v1', data: generalContext },
|
||||
commandTable: { kind: 'snapshot', revision: 'commands-v1', data: commandTable },
|
||||
boardAccess: {
|
||||
kind: 'snapshot',
|
||||
revision: 'board-v1',
|
||||
data: { permission: 0, canMeeting: false, canSecret: false },
|
||||
},
|
||||
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({
|
||||
@@ -454,6 +565,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('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
@@ -471,19 +690,6 @@ test('uses the map to choose a nation target in the chief command window', async
|
||||
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('leaves the separately scoped recruitment argument window unchanged', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /징병/ }).click();
|
||||
await expect(picker.getByTestId('command-argument-form')).toBeVisible();
|
||||
await expect(picker.getByTestId('command-argument-guidance')).toHaveCount(0);
|
||||
await expect(picker.getByTestId('command-argument-map')).toHaveCount(0);
|
||||
expect((await picker.boundingBox())?.width).toBeLessThan(300);
|
||||
});
|
||||
|
||||
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
@@ -530,7 +736,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') => {
|
||||
|
||||
@@ -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>
|
||||
@@ -4,6 +4,7 @@ import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
||||
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
||||
import { commandArgumentPresentation } from './commandArgumentPresentation';
|
||||
import DragSelect from './DragSelect.vue';
|
||||
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
|
||||
import {
|
||||
amplifyPattern,
|
||||
CommandStorage,
|
||||
@@ -132,6 +133,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());
|
||||
@@ -599,6 +603,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 }"
|
||||
>
|
||||
@@ -623,8 +628,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"
|
||||
@@ -950,6 +967,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) {
|
||||
.argument-expanded:not(.compact) .command-picker {
|
||||
right: 0;
|
||||
@@ -971,6 +1002,14 @@ const clickOutsideMenu = (event: Event) => {
|
||||
max-height: calc(100vh - 104px);
|
||||
overflow: auto;
|
||||
}
|
||||
.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 {
|
||||
@@ -1016,9 +1055,36 @@ const clickOutsideMenu = (event: Event) => {
|
||||
margin-top: -330px;
|
||||
overflow: visible;
|
||||
}
|
||||
.mobile.compact .command-picker.recruitment-picker {
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
left: 0;
|
||||
width: 500px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 82px);
|
||||
margin-top: 0;
|
||||
overflow: auto;
|
||||
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>
|
||||
|
||||
@@ -58,6 +58,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[];
|
||||
@@ -70,6 +100,7 @@ export type CommandTable = {
|
||||
nationTypes: CommandOption[];
|
||||
colors: CommandOption[];
|
||||
items: Record<string, CommandOption[]>;
|
||||
recruitment: RecruitmentInfo | null;
|
||||
context?: CommandInputContext;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import type { GatewayApiContext } from './context.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
||||
import { orderGatewayProfiles } from './profileOrder.js';
|
||||
import { orderGatewayProfiles, resolveGatewayProfileKoreanName } from './profileOrder.js';
|
||||
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||
|
||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||
@@ -1585,7 +1585,7 @@ export const adminRouter = router({
|
||||
instanceKey: profile.instanceKey,
|
||||
currentScenario: profile.currentScenario,
|
||||
meta: {
|
||||
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}),
|
||||
korName: resolveGatewayProfileKoreanName(profile.profile, profile.meta.korName),
|
||||
},
|
||||
}));
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
GatewayProfileRepository,
|
||||
GatewayProfileStatus,
|
||||
} from '../orchestrator/profileRepository.js';
|
||||
import { orderGatewayProfiles } from '../profileOrder.js';
|
||||
import { orderGatewayProfiles, resolveGatewayProfileKoreanName } from '../profileOrder.js';
|
||||
|
||||
export type LobbyMapSnapshot = {
|
||||
updatedAt: string | null;
|
||||
@@ -102,7 +102,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||
korName: resolveGatewayProfileKoreanName(row.profile, meta.korName),
|
||||
color: (meta.color as string | undefined) ?? '#ffffff',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const;
|
||||
|
||||
export const GATEWAY_PROFILE_KOREAN_NAMES = {
|
||||
che: '체',
|
||||
kwe: '퀘',
|
||||
pwe: '풰',
|
||||
twe: '퉤',
|
||||
nya: '냐',
|
||||
pya: '퍄',
|
||||
hwe: '훼',
|
||||
} as const satisfies Record<(typeof GATEWAY_PROFILE_ORDER)[number], string>;
|
||||
|
||||
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
|
||||
const gatewayProfileKoreanNames = new Map<string, string>(Object.entries(GATEWAY_PROFILE_KOREAN_NAMES));
|
||||
|
||||
export const resolveGatewayProfileKoreanName = (profile: string, configuredName?: unknown): string => {
|
||||
if (typeof configuredName === 'string' && configuredName.trim()) {
|
||||
return configuredName.trim();
|
||||
}
|
||||
return gatewayProfileKoreanNames.get(profile) ?? profile;
|
||||
};
|
||||
|
||||
export const compareGatewayProfiles = (
|
||||
left: { profile: string; instanceKey: string },
|
||||
|
||||
@@ -366,7 +366,7 @@ describe('admin profile navigation API', () => {
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
meta: {},
|
||||
meta: { korName: '체' },
|
||||
},
|
||||
]);
|
||||
expect(harness.getRuntimeStateListCount()).toBe(0);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GATEWAY_PROFILE_ORDER, orderGatewayProfiles } from '../src/profileOrder.js';
|
||||
import {
|
||||
GATEWAY_PROFILE_KOREAN_NAMES,
|
||||
GATEWAY_PROFILE_ORDER,
|
||||
orderGatewayProfiles,
|
||||
resolveGatewayProfileKoreanName,
|
||||
} from '../src/profileOrder.js';
|
||||
|
||||
describe('orderGatewayProfiles', () => {
|
||||
it('uses the public server order instead of alphabetical profile order', () => {
|
||||
@@ -29,3 +34,25 @@ describe('orderGatewayProfiles', () => {
|
||||
expect(profiles[0]?.profile).toBe('zeta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGatewayProfileKoreanName', () => {
|
||||
it('uses the canonical Korean labels for every public profile', () => {
|
||||
expect(GATEWAY_PROFILE_ORDER.map((profile) => resolveGatewayProfileKoreanName(profile))).toEqual(
|
||||
GATEWAY_PROFILE_ORDER.map((profile) => GATEWAY_PROFILE_KOREAN_NAMES[profile])
|
||||
);
|
||||
expect(GATEWAY_PROFILE_ORDER.map((profile) => `${resolveGatewayProfileKoreanName(profile)}섭`)).toEqual([
|
||||
'체섭',
|
||||
'퀘섭',
|
||||
'풰섭',
|
||||
'퉤섭',
|
||||
'냐섭',
|
||||
'퍄섭',
|
||||
'훼섭',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves configured and unknown profile names', () => {
|
||||
expect(resolveGatewayProfileKoreanName('che', ' 천하서버 ')).toBe('천하서버');
|
||||
expect(resolveGatewayProfileKoreanName('custom')).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user