diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index cb1fa847..3005f784 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -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 { + 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({ diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 75bc1844..e25e3eab 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -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; + recruitment: TurnCommandRecruitmentInfo | null; } // 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다. diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 8ad62068..05457ba9 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -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; +}): 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, }, }; }; diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 2239ab74..185e1bed 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -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: ['강력하지만 기술이 필요합니다.'], + }); + }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index db860ef3..3d6868cf 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -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('화계'); diff --git a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue new file mode 100644 index 00000000..e271bee5 --- /dev/null +++ b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue @@ -0,0 +1,524 @@ + + + + + diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 44f462de..df202aef 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -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) => {
@@ -584,8 +589,20 @@ const clickOutsideMenu = (event: Event) => { >현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.
+ { 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; + } +} diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index d01f78d4..52aae8cd 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -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; + recruitment: RecruitmentInfo | null; }; }; diff --git a/app/game-frontend/src/views/ChiefCenterView.vue b/app/game-frontend/src/views/ChiefCenterView.vue index e1d6fd01..1c47a5ec 100644 --- a/app/game-frontend/src/views/ChiefCenterView.vue +++ b/app/game-frontend/src/views/ChiefCenterView.vue @@ -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>; - }; -}; - const chiefApi = trpc as unknown as { nation: { getChiefCenter: { diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index a0df34cc..55bf333b 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -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. diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 65a93e6e..e9af5e64 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -235,6 +235,25 @@ export class CommandResolver): number { + return finalizeLegacyStat(this.pipeline.onCalcStat(context, 'leadership', context.general.stats.leadership)); + } + + getDisplayUnitCost( + context: RecruitCalcContext, + 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, crewTypeId: number, diff --git a/tools/frontend-legacy-parity/reference-recruitment.mjs b/tools/frontend-legacy-parity/reference-recruitment.mjs new file mode 100644 index 00000000..e8828370 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-recruitment.mjs @@ -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( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
`, + { 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(); +}