From 0127fd501a36c47764ec538bb0f2e4998a9de0ae Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:20:32 +0000 Subject: [PATCH] Complete legacy-compatible in-game information menus --- app/game-api/src/router/general/index.ts | 39 +- .../router/nation/endpoints/getGeneralList.ts | 317 +++++- app/game-api/src/router/nation/index.ts | 3 +- app/game-api/src/router/public/index.ts | 117 ++ .../test/inGameMenuPermissions.test.ts | 257 +++++ .../test/nationGeneralSecretRouter.test.ts | 209 ++++ app/game-api/test/publicTraffic.test.ts | 115 ++ .../src/turn/worldCommandHandler.ts | 77 +- .../test/myInformationCommands.test.ts | 179 +++ app/game-frontend/e2e/inGameMenus.spec.ts | 314 +++++ app/game-frontend/e2e/playwright.config.mjs | 2 +- app/game-frontend/src/router/index.ts | 20 +- .../src/views/BattleCenterView.vue | 236 +++- app/game-frontend/src/views/MainView.vue | 7 +- app/game-frontend/src/views/MyPageView.vue | 1010 +++++++++-------- .../src/views/NationGeneralsView.vue | 538 ++++----- .../src/views/NationSecretView.vue | 250 ++++ app/game-frontend/src/views/PublicView.vue | 1 + app/game-frontend/src/views/TrafficView.vue | 343 ++++++ .../reference-ingame-menus.mjs | 175 +++ 20 files changed, 3269 insertions(+), 940 deletions(-) create mode 100644 app/game-api/test/inGameMenuPermissions.test.ts create mode 100644 app/game-api/test/nationGeneralSecretRouter.test.ts create mode 100644 app/game-api/test/publicTraffic.test.ts create mode 100644 app/game-engine/test/myInformationCommands.test.ts create mode 100644 app/game-frontend/e2e/inGameMenus.spec.ts create mode 100644 app/game-frontend/src/views/NationSecretView.vue create mode 100644 app/game-frontend/src/views/TrafficView.vue create mode 100644 tools/frontend-legacy-parity/reference-ingame-menus.mjs diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 97a0bf9..4405b25 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -37,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => { }; const resolveUserSettings = (meta: Record) => { - const settings = asRecord(meta.userSettings); - const mysetRaw = settings.myset; + // The legacy general columns are persisted at the top level of General.meta. + // Keep reading the short-lived nested shape for installations that ran the + // initial rewrite implementation before this compatibility fix. + const nestedSettings = asRecord(meta.userSettings); + const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key]; + const mysetRaw = readSetting('myset'); const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null; return { - tnmt: readNumber(settings.tnmt, 1), - defence_train: readNumber(settings.defence_train, 80), - use_treatment: readNumber(settings.use_treatment, 10), - use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1), + tnmt: readNumber(readSetting('tnmt'), 1), + defence_train: readNumber(readSetting('defence_train'), 80), + use_treatment: readNumber(readSetting('use_treatment'), 10), + use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1), myset, }; }; @@ -262,29 +266,6 @@ export const generalRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } - const metaRecord = asRecord(general.meta); - const prevSettings = asRecord(metaRecord.userSettings); - const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset) - ? prevSettings.myset - : null; - const nextSettings = { - ...prevSettings, - ...input, - } as Record; - if (typeof prevMyset === 'number') { - nextSettings.myset = Math.max(0, prevMyset - 1); - } - - await ctx.db.general.update({ - where: { id: general.id }, - data: { - meta: { - ...metaRecord, - userSettings: nextSettings, - }, - } as any, - }); - return { ok: true }; }), dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => { diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 8223e9b..171e452 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -1,84 +1,279 @@ import { TRPCError } from '@trpc/server'; +import { asRecord } from '@sammo-ts/common'; + +import type { GameApiContext } from '../../../context.js'; import { authedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, loadTraitNames, mapGeneralList, resolveChiefStatMin } from '../shared.js'; +import { + assertNationAccess, + loadTraitNames, + resolveNationPermission, + resolveOfficerCity, +} from '../shared.js'; -export const getGeneralList = authedProcedure.query(async ({ ctx }) => { - const general = await getMyGeneral(ctx); - assertNationAccess(general); +const MAX_DEDICATION_LEVEL = 10; - const [nation, cityRows, troopRows, generalRows, worldState] = await Promise.all([ - ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { - id: true, - name: true, - color: true, - level: true, - typeCode: true, - capitalCityId: true, - meta: true, - }, - }), - ctx.db.city.findMany({ select: { id: true, name: true } }), - ctx.db.troop.findMany({ select: { troopLeaderId: true, name: true } }), - ctx.db.general.findMany({ - where: { nationId: general.nationId }, - select: { - id: true, - name: true, - npcState: true, - nationId: true, - cityId: true, - troopId: true, - picture: true, - imageServer: true, - officerLevel: true, - leadership: true, - strength: true, - intel: true, - experience: true, - dedication: true, - injury: true, - gold: true, - rice: true, - crew: true, - personalCode: true, - specialCode: true, - special2Code: true, - meta: true, - penalty: true, - }, - orderBy: { id: 'asc' }, - }), - ctx.db.worldState.findFirst(), - ]); +const readNumber = (record: Record, keys: string[], fallback = 0): number => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return fallback; +}; +const experienceLevel = (experience: number): number => + Math.max(0, Math.min(100, experience < 1_000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))); + +const dedicationLevel = (dedication: number): number => + Math.max(0, Math.min(MAX_DEDICATION_LEVEL, Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10))); + +const dedicationLevelText = (level: number): string => + level === 0 ? '무품관' : `${MAX_DEDICATION_LEVEL - level + 1}품관`; + +const honorText = (experience: number): string => { + const levels: Array<[number, string]> = [ + [640, '전무'], + [2_560, '무명'], + [5_760, '신동'], + [10_240, '약간'], + [16_000, '평범'], + [23_040, '지역적'], + [31_360, '전국적'], + [40_960, '세계적'], + [45_000, '유명'], + [51_840, '명사'], + [55_000, '호걸'], + [64_000, '효웅'], + [77_440, '영웅'], + ]; + return levels.find(([limit]) => experience < limit)?.[1] ?? '구세주'; +}; + +const leadershipBonus = (officerLevel: number, nationLevel: number): number => { + if (officerLevel === 12) return nationLevel * 2; + if (officerLevel >= 5) return nationLevel; + return 0; +}; + +const woundedStat = (value: number, injury: number): number => + injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value; + +const defenceTrainText = (value: number): string => { + if (value === 999) return '×'; + if (value >= 90) return '☆'; + if (value >= 80) return '◎'; + if (value >= 60) return '○'; + return '△'; +}; + +const loadNationGeneralData = async (ctx: GameApiContext) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + typeCode: true, + capitalCityId: true, + meta: true, + }, + }); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } + const viewerPermission = resolveNationPermission(me, nation.meta, true); - const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name])); - const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); - const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap); - const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode); + const [cityRows, troopRows, generalRows] = await Promise.all([ + ctx.db.city.findMany({ select: { id: true, name: true } }), + ctx.db.troop.findMany({ + where: { nationId: me.nationId }, + select: { troopLeaderId: true, name: true }, + }), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + orderBy: [{ turnTime: 'asc' }, { id: 'asc' }], + }), + ]); + const generalIds = generalRows.map((general) => general.id); + const [accessRows, turnRows] = await Promise.all([ + ctx.db.generalAccessLog.findMany({ + where: { generalId: { in: generalIds } }, + select: { generalId: true, refreshScore: true, refreshScoreTotal: true }, + }), + viewerPermission >= 1 + ? ctx.db.generalTurn.findMany({ + where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }) + : Promise.resolve([]), + ]); + + const cityNames = new Map(cityRows.map((city) => [city.id, city.name])); + const troopNames = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); + const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row])); + const turnsByGeneral = new Map(); + for (const turn of turnRows) { + const turns = turnsByGeneral.get(turn.generalId) ?? []; + turns[turn.turnIdx] = turn.actionCode; + turnsByGeneral.set(turn.generalId, turns); + } + + const [personalityMap, domesticMap, warMap] = await Promise.all([ + loadTraitNames(generalRows.map((general) => general.personalCode), 'personality'), + loadTraitNames(generalRows.map((general) => general.specialCode), 'domestic'), + loadTraitNames(generalRows.map((general) => general.special2Code), 'war'), + ]); + + const generals = generalRows.map((general) => { + const meta = asRecord(general.meta); + const officerCity = resolveOfficerCity(meta); + const access = accessByGeneral.get(general.id); + const dedLevel = dedicationLevel(general.dedication); + const actualOfficerLevel = general.officerLevel; + const visibleOfficerLevel = + viewerPermission >= 1 || actualOfficerLevel >= 5 ? actualOfficerLevel : Math.min(1, actualOfficerLevel); + const bonus = leadershipBonus(actualOfficerLevel, nation.level); + const detail = + viewerPermission >= 1 + ? { + officerLevel: actualOfficerLevel, + officerCity, + officerCityName: officerCity > 0 ? (cityNames.get(officerCity) ?? null) : null, + cityId: general.cityId, + cityName: cityNames.get(general.cityId) ?? null, + troopId: general.troopId, + troopName: troopNames.get(general.troopId) ?? null, + defenceTrain: readNumber(meta, ['defenceTrain', 'defence_train'], 80), + crewTypeId: general.crewTypeId, + crew: general.crew, + train: general.train, + atmos: general.atmos, + experience: general.experience, + dedication: general.dedication, + turnTime: general.turnTime.toISOString(), + recentWarTime: general.recentWarTime?.toISOString() ?? null, + killTurn: readNumber(meta, ['killturn', 'killTurn']), + refreshScore: access?.refreshScore ?? 0, + reservedCommands: general.npcState < 2 ? (turnsByGeneral.get(general.id) ?? []) : [], + } + : null; + + return { + id: general.id, + name: general.name, + npcState: general.npcState, + picture: general.picture, + imageServer: general.imageServer, + injury: general.injury, + stats: { + leadership: woundedStat(general.leadership, general.injury), + strength: woundedStat(general.strength, general.injury), + intelligence: woundedStat(general.intel, general.injury), + }, + leadershipBonus: bonus, + officerLevel: visibleOfficerLevel, + experienceLevel: experienceLevel(general.experience), + honorText: honorText(general.experience), + dedicationLevel: dedLevel, + dedicationLevelText: dedicationLevelText(dedLevel), + bill: dedLevel * 200 + 400, + gold: general.gold, + rice: general.rice, + age: general.age, + belong: readNumber(meta, ['belong']), + refreshScoreTotal: access?.refreshScoreTotal ?? 0, + personality: general.personalCode === 'None' ? null : (personalityMap.get(general.personalCode) ?? null), + specialDomestic: + general.specialCode === 'None' ? null : (domesticMap.get(general.specialCode) ?? null), + specialWar: general.special2Code === 'None' ? null : (warMap.get(general.special2Code) ?? null), + detail, + }; + }); return { + me, nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level, typeCode: nation.typeCode, - type: { - key: nation.typeCode, - name: nationTrait?.name ?? nation.typeCode, - info: nationTrait?.info ?? '', - }, capitalCityId: nation.capitalCityId ?? 0, }, - chiefStatMin: resolveChiefStatMin(worldState), - generals: list, + viewerPermission, + generals, + }; +}; + +export const getGeneralList = authedProcedure.query(async ({ ctx }) => { + const data = await loadNationGeneralData(ctx); + return { + nation: data.nation, + viewer: { generalId: data.me.id, permission: data.viewerPermission }, + generals: data.generals, + }; +}); + +export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => { + const data = await loadNationGeneralData(ctx); + if (data.viewerPermission < 1) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', + }); + } + + const visibleGenerals = data.generals.filter((general) => general.npcState !== 5); + const summaryBase = visibleGenerals.reduce( + (summary, general) => { + const detail = general.detail; + if (!detail) return summary; + summary.gold += general.gold; + summary.rice += general.rice; + summary.crew += detail.crew; + if (detail.crew > 0) { + for (const threshold of [90, 80, 60] as const) { + if (detail.train >= threshold && detail.atmos >= threshold) { + summary.readiness[threshold].crew += detail.crew; + summary.readiness[threshold].generals += 1; + } + } + } + return summary; + }, + { + gold: 0, + rice: 0, + crew: 0, + readiness: { + 90: { crew: 0, generals: 0 }, + 80: { crew: 0, generals: 0 }, + 60: { crew: 0, generals: 0 }, + }, + } + ); + const generalCount = visibleGenerals.length; + + return { + nation: data.nation, + viewer: { generalId: data.me.id, permission: data.viewerPermission }, + summary: { + ...summaryBase, + generalCount, + averageGold: generalCount ? summaryBase.gold / generalCount : 0, + averageRice: generalCount ? summaryBase.rice / generalCount : 0, + }, + generals: data.generals.map((general) => ({ + ...general, + defenceTrainText: defenceTrainText(general.detail?.defenceTrain ?? 0), + })), }; }); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index e810b96..4fece4a 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -4,7 +4,7 @@ import { changePermission } from './endpoints/changePermission.js'; import { getBattleCenter } from './endpoints/getBattleCenter.js'; import { getChiefCenter } from './endpoints/getChiefCenter.js'; import { getCityOverview } from './endpoints/getCityOverview.js'; -import { getGeneralList } from './endpoints/getGeneralList.js'; +import { getGeneralList, getSecretGeneralList } from './endpoints/getGeneralList.js'; import { getGeneralLog } from './endpoints/getGeneralLog.js'; import { getNationInfo } from './endpoints/getNationInfo.js'; import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js'; @@ -21,6 +21,7 @@ import { setSecretLimit } from './endpoints/setSecretLimit.js'; export const nationRouter = router({ getNationInfo, getGeneralList, + getSecretGeneralList, getCityOverview, getPersonnelInfo, getStratFinan, diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index e7a0f59..f4ef4d8 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -42,6 +42,14 @@ type NationCountRow = { type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; +type TrafficHistoryItem = { + year: number; + month: number; + refresh: number; + online: number; + date: string; +}; + const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; +const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { + if (!Array.isArray(value)) { + return []; + } + + const result: TrafficHistoryItem[] = []; + for (const item of value) { + const row = asRecord(item); + const year = readFiniteMetaNumber(row, 'year'); + const month = readFiniteMetaNumber(row, 'month'); + const refresh = readFiniteMetaNumber(row, 'refresh'); + const online = readFiniteMetaNumber(row, 'online'); + const date = typeof row.date === 'string' ? row.date : ''; + if (year > 0 && month > 0 && date) { + result.push({ year, month, refresh, online, date }); + } + } + return result; +}; + const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -222,6 +250,95 @@ export const publicRouter = router({ getNationList: procedure.query(async ({ ctx }) => { return loadCachedNationList(ctx); }), + getTraffic: procedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + const meta = asRecord(worldState.meta); + const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; + const parsedOnlineSince = + typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date + ? new Date(rawOnlineSince) + : null; + const onlineSince = + parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) + ? parsedOnlineSince + : new Date(Date.now() - worldState.tickSeconds * 1_000); + const [accessTotal, currentOnline, topAccess] = await Promise.all([ + ctx.db.generalAccessLog.aggregate({ + _sum: { + refresh: true, + refreshScoreTotal: true, + }, + }), + ctx.db.generalAccessLog.count({ + where: { + lastRefresh: { + gte: onlineSince, + }, + }, + }), + ctx.db.generalAccessLog.findMany({ + orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], + take: 5, + select: { + generalId: true, + refresh: true, + refreshScoreTotal: true, + }, + }), + ]); + + const generalIds = topAccess.map((entry) => entry.generalId); + const generalRows = + generalIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: generalIds } }, + select: { id: true, name: true }, + }) + : []; + const generalName = new Map(generalRows.map((general) => [general.id, general.name])); + const totalRefresh = accessTotal._sum.refresh ?? 0; + const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; + const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); + const history = parseTrafficHistory(meta.recentTraffic); + history.push({ + year: worldState.currentYear, + month: worldState.currentMonth, + refresh: currentRefresh, + online: currentOnline, + date: new Date().toISOString(), + }); + + return { + history, + maxRefresh: Math.max( + 1, + readFiniteMetaNumber(meta, 'maxrefresh'), + ...history.map((entry) => entry.refresh) + ), + maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), + suspects: [ + { + generalId: null, + name: '접속자 총합', + refresh: totalRefresh, + refreshScoreTotal: totalRefreshScore, + }, + ...topAccess.map((entry) => ({ + generalId: entry.generalId, + name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, + refresh: entry.refresh, + refreshScoreTotal: entry.refreshScoreTotal, + })), + ], + }; + }), getGeneralList: procedure.query(async ({ ctx }) => { const [generals, nations] = await Promise.all([ ctx.db.general.findMany({ diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts new file mode 100644 index 0000000..ea2263c --- /dev/null +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const now = new Date('2026-01-01T00:00:00.000Z'); +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-7', + name: '검증장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 10, + dedication: 20, + officerLevel: 1, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 80, + atmos: 80, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { + belong: 1, + permission: 'normal', + myset: 3, + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), + sessionId: 'session-7', + user: { id: 'user-7', username: 'tester', displayName: 'Tester', roles: [] }, + sanctions: {}, +}; + +const createContext = (options: { + me?: GeneralRow; + targets?: GeneralRow[]; + nationMeta?: Record; + requestCommand?: ReturnType; +}) => { + const me = options.me ?? buildGeneral(); + const targets = options.targets ?? [me]; + const requestCommand = + options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id })); + const generalFindUnique = vi.fn( + async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null + ); + const db = { + general: { + findFirst: vi.fn(async () => me), + findUnique: generalFindUnique, + findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)), + update: vi.fn(), + }, + city: { findUnique: vi.fn(async () => null) }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#777777', + level: 3, + gold: 10_000, + rice: 20_000, + tech: 100, + typeCode: 'che_법가', + capitalCityId: 1, + meta: options.nationMeta ?? { secretlimit: 3 }, + })), + }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + })), + }, + logEntry: { + groupBy: vi.fn(async () => []), + findMany: vi.fn(async () => [{ id: 1, text: '기록' }]), + }, + }; + const redisClient = { get: async () => null, set: async () => null }; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, requestCommand }; +}; + +describe('in-game my information ownership', () => { + it('reads legacy top-level settings and dispatches only the session-owned general', async () => { + const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); + const fixture = createContext({ requestCommand }); + const caller = appRouter.createCaller(fixture.context); + + const me = await caller.general.me(); + expect(me?.settings).toEqual({ + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + myset: 3, + }); + + await caller.general.setMySetting({ tnmt: 1, defence_train: 999 }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'setMySetting', + generalId: 7, + settings: { tnmt: 1, defence_train: 999 }, + }); + expect(fixture.db.general.update).not.toHaveBeenCalled(); + }); + + it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => { + const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' }); + const fixture = createContext({ targets: [buildGeneral(), otherUser] }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.general.me()).resolves.toMatchObject({ + general: { id: 7, name: '검증장수' }, + }); + await expect(caller.general.getMyLog({ type: 'generalAction' })).resolves.toMatchObject({ + type: 'generalAction', + logs: [{ id: 1 }], + }); + + expect(fixture.db.general.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-7' }, + }) + ); + expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ generalId: 7 }), + }) + ); + }); +}); + +describe('battle-center general and user permissions', () => { + it('distinguishes an ordinary member, a tenured member, and an auditor', async () => { + const ordinary = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 1, permission: 'normal' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(ordinary.context).nation.getBattleCenter()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + + const tenured = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({ + me: { id: 7, permissionLevel: 1 }, + }); + + const auditor = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(auditor.context).nation.getBattleCenter()).resolves.toMatchObject({ + me: { id: 7, permissionLevel: 3 }, + }); + }); + + it('redacts another user action log while allowing own, NPC, chief, and non-private logs', async () => { + const me = buildGeneral({ meta: { belong: 3, permission: 'normal' } }); + const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저', npcState: 0 }); + const npc = buildGeneral({ id: 9, userId: null, name: 'NPC', npcState: 2 }); + const foreign = buildGeneral({ id: 10, userId: 'user-10', name: '타국', nationId: 2 }); + const memberFixture = createContext({ + me, + targets: [me, otherUser, npc, foreign], + nationMeta: { secretlimit: 3 }, + }); + const member = appRouter.createCaller(memberFixture.context); + + await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({ + generalId: me.id, + }); + await expect( + member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + member.nation.getGeneralLog({ generalId: otherUser.id, type: 'battleDetail' }) + ).resolves.toMatchObject({ generalId: otherUser.id }); + await expect(member.nation.getGeneralLog({ generalId: npc.id, type: 'generalAction' })).resolves.toMatchObject({ + generalId: npc.id, + }); + await expect( + member.nation.getGeneralLog({ generalId: foreign.id, type: 'battleDetail' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + const chiefFixture = createContext({ + me: buildGeneral({ officerLevel: 5 }), + targets: [buildGeneral({ officerLevel: 5 }), otherUser], + nationMeta: { secretlimit: 3 }, + }); + await expect( + appRouter + .createCaller(chiefFixture.context) + .nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' }) + ).resolves.toMatchObject({ generalId: otherUser.id }); + }); +}); diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts new file mode 100644 index 0000000..329ad5e --- /dev/null +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const now = new Date('2026-01-01T01:02:00.000Z'); +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'user-1', + name: '일반장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 900, + dedication: 100, + officerLevel: 1, + gold: 1_000, + rice: 2_000, + crew: 300, + crewTypeId: 1, + train: 90, + atmos: 90, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { belong: 1, defence_train: 80, killturn: 7 }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const auth = (userId: string): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), + sessionId: `session-${userId}`, + user: { id: userId, username: userId, displayName: userId, roles: [] }, + sanctions: {}, +}); + +const createContext = (options: { + sessionUserId?: string; + generals?: GeneralRow[]; + nationMeta?: Record; +}) => { + const sessionUserId = options.sessionUserId ?? 'user-1'; + const generals = options.generals ?? [buildGeneral()]; + const db = { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + generals.find((general) => general.userId === where.userId) + ), + findMany: vi.fn(async ({ where }: { where: { nationId: number } }) => + generals.filter((general) => general.nationId === where.nationId) + ), + }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_중립', + capitalCityId: 1, + meta: options.nationMeta ?? { secretlimit: 3 }, + })), + }, + city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) }, + troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) }, + generalAccessLog: { + findMany: vi.fn(async () => + generals.map((general) => ({ + generalId: general.id, + refreshScore: general.id, + refreshScoreTotal: general.id * 10, + })) + ), + }, + generalTurn: { + findMany: vi.fn(async () => [ + { generalId: 1, turnIdx: 0, actionCode: '징병' }, + { generalId: 1, turnIdx: 1, actionCode: '훈련' }, + ]), + }, + }; + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + return { + context: { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: auth(sessionUserId), + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + } satisfies GameApiContext, + db, + }; +}; + +describe('nation general and secret-office permissions', () => { + it('redacts confidential columns for an ordinary member and denies the secret office', async () => { + const fixture = createContext({}); + const caller = appRouter.createCaller(fixture.context); + + const list = await caller.nation.getGeneralList(); + expect(list.viewer).toEqual({ generalId: 1, permission: 0 }); + expect(list.generals[0]).toMatchObject({ + officerLevel: 1, + gold: 1_000, + rice: 2_000, + detail: null, + }); + expect(fixture.db.generalTurn.findMany).not.toHaveBeenCalled(); + await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('allows a tenured member, scopes rows to the actor nation, and returns legacy summary and turns', async () => { + const me = buildGeneral({ meta: { belong: 3, defence_train: 90, killturn: 7 } }); + const ally = buildGeneral({ + id: 2, + userId: 'user-2', + name: '아군', + troopId: 2, + gold: 3_000, + rice: 4_000, + crew: 200, + train: 80, + atmos: 80, + }); + const hiddenNpc = buildGeneral({ id: 3, userId: null, name: '가상', npcState: 5, gold: 99_999 }); + const foreign = buildGeneral({ id: 4, userId: 'foreign', nationId: 2, gold: 88_888 }); + const fixture = createContext({ generals: [me, ally, hiddenNpc, foreign] }); + + const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList(); + expect(result.viewer.permission).toBe(1); + expect(result.generals.map((general) => general.id)).toEqual([1, 2, 3]); + expect(result.generals[0]?.detail).toMatchObject({ + cityName: '업', + defenceTrain: 90, + killTurn: 7, + reservedCommands: ['징병', '훈련'], + }); + expect(result.generals[1]?.detail?.troopName).toBe('선봉대'); + expect(result.summary).toMatchObject({ + gold: 4_000, + rice: 6_000, + crew: 500, + generalCount: 2, + readiness: { + 90: { crew: 300, generals: 1 }, + 80: { crew: 500, generals: 2 }, + 60: { crew: 500, generals: 2 }, + }, + }); + expect(fixture.db.general.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { nationId: 1 } }) + ); + }); + + it('derives the acting general from the authenticated user and applies that general permission', async () => { + const first = buildGeneral({ userId: 'user-1', meta: { belong: 1 } }); + const second = buildGeneral({ id: 2, userId: 'user-2', officerLevel: 5, meta: { belong: 1 } }); + const fixture = createContext({ sessionUserId: 'user-2', generals: [first, second] }); + + const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList(); + expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); + expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-2' } }); + }); + + it('keeps penalty-based secret denial even for an otherwise qualified general', async () => { + const me = buildGeneral({ officerLevel: 5, penalty: { noChief: true } }); + const fixture = createContext({ generals: [me] }); + await expect(appRouter.createCaller(fixture.context).nation.getSecretGeneralList()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); +}); diff --git a/app/game-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts new file mode 100644 index 0000000..78008d8 --- /dev/null +++ b/app/game-api/test/publicTraffic.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const buildContext = (): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + id: 1, + currentYear: 185, + currentMonth: 3, + tickSeconds: 600, + config: {}, + meta: { + lastTurnTime: '2026-07-26T03:00:00.000Z', + refresh: 12, + maxrefresh: 30, + maxonline: 5, + recentTraffic: [ + { + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }, + ], + }, + }), + }, + generalAccessLog: { + aggregate: async () => ({ + _sum: { + refresh: 12, + refreshScoreTotal: 21, + }, + }), + count: async (args: { where: { lastRefresh: { gte: Date } } }) => { + expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); + return 2; + }, + findMany: async () => [ + { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, + ], + }, + general: { + findMany: async () => [ + { id: 7, name: '갑' }, + { id: 8, name: '을' }, + ], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('public.getTraffic', () => { + it('is public and returns only aggregate traffic plus allowlisted general names', async () => { + const result = await appRouter.createCaller(buildContext()).public.getTraffic(); + + expect(result.history).toHaveLength(2); + expect(result.history[0]).toEqual({ + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }); + expect(result.history[1]).toMatchObject({ + year: 185, + month: 3, + refresh: 12, + online: 2, + }); + expect(result.maxRefresh).toBe(30); + expect(result.maxOnline).toBe(5); + expect(result.suspects).toEqual([ + { generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 }, + { generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 }, + ]); + expect(JSON.stringify(result)).not.toContain('userId'); + }); +}); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index e358955..42efbb9 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -809,9 +809,35 @@ async function handleVacation( if (!general) { return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } + const autorunUser = asRecord(world.getState().meta.autorun_user); + if (autorunUser.limit_minutes) { + return { + type: 'vacation', + ok: false, + generalId: command.generalId, + reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', + }; + } + const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); + world.updateGeneral(general.id, { + meta: { + ...general.meta, + killturn: killturn * 3, + }, + }); return { type: 'vacation', ok: true, generalId: command.generalId }; } +const normalizeDefenceTrain = (value: number): number => { + if (value <= 40) { + return 40; + } + if (value <= 90) { + return Math.round(value / 10) * 10; + } + return 999; +}; + async function handleSetMySetting( ctx: CommandHandlerContext, command: Extract @@ -826,11 +852,48 @@ async function handleSetMySetting( reason: '장수 정보를 찾을 수 없습니다.', }; } + + const settings = command.settings; + const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80); + const nextDefenceTrain = + settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train); + const nextMeta = { ...general.meta }; + + if (settings.tnmt !== undefined) { + nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt; + } + if (settings.use_treatment !== undefined) { + nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment)); + } + if (settings.use_auto_nation_turn !== undefined) { + nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn; + } + + let nextTrain = general.train; + let nextAtmos = general.atmos; + if (nextDefenceTrain !== previousDefenceTrain) { + nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1; + nextMeta.defence_train = nextDefenceTrain; + if (nextDefenceTrain === 999) { + const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect; + const ignoresPenalty = + scenarioEffect === 'event_UnlimitedDefenceThresholdChange' || + scenarioEffect === 'event_StrongAttacker' || + scenarioEffect === 'event_MoreEffect'; + const constValues = asRecord(world.getScenarioConfig().const); + const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100); + const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100); + const trainDelta = ignoresPenalty ? 0 : -3; + const atmosDelta = ignoresPenalty ? 0 : -6; + nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta)); + nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta)); + } + } + world.updateGeneral(command.generalId, { - meta: { - ...general.meta, - ...command.settings, - }, + meta: nextMeta, + train: nextTrain, + atmos: nextAtmos, }); return { type: 'setMySetting', ok: true, generalId: command.generalId }; } @@ -844,10 +907,8 @@ async function handleDropItem( if (!general) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } - const slot = (['horse', 'weapon', 'book', 'item'] as const).find( - (candidate) => general.role.items[candidate] === command.itemType - ); - if (!slot) { + const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType); + if (!slot || !general.role.items[slot]) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' }; } const nextGeneral = { diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts new file mode 100644 index 0000000..86c72b8 --- /dev/null +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ + id: 7, + userId: 'user-7', + name: '테스트장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + turnTime: new Date('0185-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: 'che_명마', weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 12, + myset: 3, + defence_train: 80, + tnmt: 0, + use_treatment: 10, + use_auto_nation_turn: 1, + }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 90, + atmos: 90, + age: 20, + npcState: 0, + ...overrides, +}); + +const buildWorld = ( + general = buildGeneral(), + options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {} +) => { + const state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { + killturn: 24, + autorun_user: options.autorunLimit ? { limit_minutes: 60 } : {}, + }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: { maxTrainByWar: 100, maxAtmosByWar: 100 }, + environment: { + mapName: 'test', + unitSet: 'test', + ...(options.scenarioEffect !== undefined ? { scenarioEffect: options.scenarioEffect } : {}), + }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + return { world, handler: createTurnDaemonCommandHandler({ world }) }; +}; + +describe('my information world commands', () => { + it('normalizes legacy settings and charges myset only when defence mode changes', async () => { + const fixture = buildWorld(); + + await expect( + fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { + tnmt: 9, + defence_train: 94, + use_treatment: 200, + use_auto_nation_turn: 0, + }, + }) + ).resolves.toMatchObject({ ok: true }); + + expect(fixture.world.getGeneralById(7)).toMatchObject({ + train: 87, + atmos: 84, + meta: { + tnmt: 1, + defence_train: 999, + use_treatment: 100, + use_auto_nation_turn: 0, + myset: 2, + }, + }); + + await fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { tnmt: 0, defence_train: 999, use_treatment: 1 }, + }); + expect(fixture.world.getGeneralById(7)?.meta).toMatchObject({ + tnmt: 0, + use_treatment: 10, + myset: 2, + }); + }); + + it('preserves the event scenarios that waive the no-defence penalty', async () => { + const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' }); + await fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { defence_train: 999 }, + }); + expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 }); + }); + + it('applies vacation killturn and rejects it in automatic-turn mode', async () => { + const allowed = buildWorld(); + await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true }); + expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72); + + const blocked = buildWorld(buildGeneral(), { autorunLimit: true }); + await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ + ok: false, + reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', + }); + expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12); + }); + + it('drops only the authenticated command target slot and rejects an empty slot', async () => { + const fixture = buildWorld(); + await expect( + fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' }) + ).resolves.toMatchObject({ ok: false }); + await expect( + fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' }) + ).resolves.toMatchObject({ ok: true }); + expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull(); + }); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts new file mode 100644 index 0000000..ca33b24 --- /dev/null +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -0,0 +1,314 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR; +const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT; +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); + +const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => { + if (!parityArtifactDir) { + return; + } + await mkdir(parityArtifactDir, { recursive: true }); + await Promise.all([ + page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }), + writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), + ]); +}; + +type FixtureState = { + permission: 'head' | 'member'; + myset: number; + settingMutations: Array>; +}; + +const myGeneral = (state: FixtureState) => ({ + general: { + id: 7, + name: '검증장수', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: null, + imageServer: 0, + officerLevel: state.permission === 'head' ? 5 : 1, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + gold: 1_000, + rice: 2_000, + crew: 300, + train: 80, + atmos: 90, + injury: 0, + experience: 100, + dedication: 200, + items: { horse: 'che_명마', weapon: null, book: null, item: null }, + }, + city: { id: 1, name: '업', level: 8, nationId: 1 }, + nation: { id: 1, name: '위', color: '#777777', level: 3 }, + settings: { + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + myset: state.myset, + }, + penalties: {}, +}); + +const battleCenter = (state: FixtureState) => ({ + me: { + id: 7, + officerLevel: state.permission === 'head' ? 5 : 1, + permissionLevel: state.permission === 'head' ? 2 : 0, + }, + nation: { id: 1, name: '위', color: '#777777', level: 3 }, + currentYear: 185, + currentMonth: 1, + turnTermMinutes: 10, + generals: [ + { + id: 7, + name: '검증장수', + npcState: 0, + officerLevel: state.permission === 'head' ? 5 : 1, + cityId: 1, + turnTime: '2026-01-01 00:10:00', + recentWar: '2026-01-01 00:00:00', + warnum: 3, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experience: 100, + dedication: 200, + injury: 0, + gold: 1_000, + rice: 2_000, + crew: 300, + train: 80, + atmos: 90, + }, + { + id: 8, + name: '다른장수', + npcState: 2, + officerLevel: 1, + cityId: 1, + turnTime: '2026-01-01 00:20:00', + recentWar: null, + warnum: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + injury: 0, + gold: 500, + rice: 500, + crew: 100, + train: 60, + atmos: 60, + }, + ], +}); + +const install = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'menu-token'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/game/**', async (route) => { + const filename = basename(new URL(route.request().url()).pathname); + if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readFile(resolve(legacyImageRoot, filename)), + }); + return; + } + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response(myGeneral(state)); + if (operation === 'world.getState') + return response({ + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + config: { npcMode: 0, const: { availableInstantAction: {} } }, + meta: { + turntime: '2026-01-01T00:00:00.000Z', + opentime: '2025-12-01T00:00:00.000Z', + autorun_user: {}, + }, + }); + if (operation === 'general.getMyLog') + return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); + if (operation === 'general.setMySetting') { + const raw = route.request().postDataJSON() as { input?: { json?: Record } }; + state.settingMutations.push(raw.input?.json ?? {}); + state.myset = Math.max(0, state.myset - 1); + return response({ ok: true }); + } + if (operation === 'nation.getBattleCenter') { + if (state.permission === 'member') { + return { + error: { + message: '권한이 부족합니다.', + code: -32000, + data: { code: 'FORBIDDEN', httpStatus: 403, path: operation }, + }, + }; + } + return response(battleCenter(state)); + } + if (operation === 'nation.getGeneralLog') { + const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction') + ? 'generalAction' + : operation; + return response({ type, generalId: 7, logs: [{ id: 1, text: '감찰 기록' }] }); + } + return response({ ok: true }); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operations.length === 1 ? results[0] : results), + }); + }); +}; + +test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('my-page'); + await expect(page.locator('.title-row')).toContainText('내 정 보'); + await expect(page.locator('#set_my_setting')).toBeVisible(); + + const desktop = await page.locator('#container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const title = element.querySelector('.title-row')!.getBoundingClientRect(); + const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + const saveButton = element.querySelector('#set_my_setting')!; + const save = saveButton.getBoundingClientRect(); + const customCss = element.querySelector('#custom_css')!.getBoundingClientRect(); + const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns; + return { + width: rect.width, + minWidth: getComputedStyle(element).minWidth, + fontSize: getComputedStyle(element).fontSize, + columns, + titleHeight: title.height, + settingsOffset: settings.x - rect.x, + saveWidth: save.width, + saveHeight: save.height, + saveBackground: getComputedStyle(saveButton).backgroundColor, + customCssWidth: customCss.width, + customCssHeight: customCss.height, + backgroundImage: getComputedStyle(element).backgroundImage, + sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, + }; + }); + expect(desktop.width).toBe(1000); + expect(desktop.minWidth).toBe('500px'); + expect(desktop.fontSize).toBe('14px'); + expect(desktop.columns.split(' ')).toHaveLength(2); + expect(desktop.titleHeight).toBeCloseTo(54, 0); + expect(desktop.settingsOffset).toBeCloseTo(500, 0); + expect(desktop.saveWidth).toBe(160); + expect(desktop.saveHeight).toBe(30); + expect(desktop.saveBackground).toBe('rgb(34, 85, 0)'); + expect(desktop.customCssWidth).toBe(420); + expect(desktop.customCssHeight).toBe(150); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); + await persistParityArtifact(page, 'core-my-page-desktop', desktop); + + await page + .locator('select') + .filter({ has: page.locator('option[value="999"]') }) + .selectOption('999'); + await page.locator('#set_my_setting').click(); + await expect.poll(() => state.settingMutations.length).toBe(1); + expect(state.settingMutations[0]).not.toHaveProperty('generalId'); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.reload(); + const mobile = await page.locator('#container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + return { + width: rect.width, + scrollWidth: document.documentElement.scrollWidth, + columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, + settingsOffset: settings.x - rect.x, + settingsWidth: settings.width, + }; + }); + expect(mobile).toMatchObject({ + width: 500, + scrollWidth: 500, + columns: '500px', + settingsOffset: 0, + settingsWidth: 500, + }); + await persistParityArtifact(page, 'core-my-page-mobile', mobile); +}); + +test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { + const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + await install(page, head); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('battle-center'); + await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible(); + await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); + await page.getByRole('button', { name: '다음 ▶' }).click(); + await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); + const geometry = await page.locator('.battle-page').evaluate((element) => { + const selector = element.querySelector('.selector-row')!; + const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); + const logBlock = element.querySelector('.log-block')!.getBoundingClientRect(); + return { + width: element.getBoundingClientRect().width, + fontSize: getComputedStyle(element).fontSize, + selectorColumns: getComputedStyle(selector).gridTemplateColumns, + selectorHeight: selector.getBoundingClientRect().height, + controlWidths: controls.map((control) => control.width), + logBlockWidth: logBlock.width, + backgroundImage: getComputedStyle(element).backgroundImage, + generalBackgroundImage: getComputedStyle(element.querySelector('.battle-general-card')!) + .backgroundImage, + }; + }); + expect(geometry.width).toBe(1000); + expect(geometry.fontSize).toBe('14px'); + expect(geometry.selectorColumns.split(' ')).toHaveLength(4); + expect(geometry.selectorHeight).toBeCloseTo(36, 0); + expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0); + expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0); + expect(geometry.logBlockWidth).toBeCloseTo(500, 0); + expect(geometry.backgroundImage).toContain('back_walnut.jpg'); + expect(geometry.generalBackgroundImage).toContain('back_blue.jpg'); + await persistParityArtifact(page, 'core-battle-center-desktop', geometry); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({ + columns: getComputedStyle(element).gridTemplateColumns, + controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), + })); + expect(mobileGeometry.columns.split(' ')).toHaveLength(4); + expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0); + expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0); + await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); + + await page.unrouteAll({ behavior: 'wait' }); + const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] }; + await install(page, member); + await page.reload(); + await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..b6090fe 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], + testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a412e4c..90532bf 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue'; import GlobalInfoView from '../views/GlobalInfoView.vue'; import CurrentCityView from '../views/CurrentCityView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue'; +import NationSecretView from '../views/NationSecretView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue'; import NationStratFinanView from '../views/NationStratFinanView.vue'; import ChiefCenterView from '../views/ChiefCenterView.vue'; @@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue'; import TournamentView from '../views/TournamentView.vue'; import BettingView from '../views/BettingView.vue'; import MyPageView from '../views/MyPageView.vue'; -import MySettingsView from '../views/MySettingsView.vue'; import BoardView from '../views/BoardView.vue'; import DiplomacyView from '../views/DiplomacyView.vue'; import BestGeneralView from '../views/BestGeneralView.vue'; @@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue'; import YearbookView from '../views/YearbookView.vue'; import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; +import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -148,6 +149,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/nation/secret', + name: 'nation-secret', + component: NationSecretView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/nation/personnel', name: 'nation-personnel', @@ -251,6 +261,11 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/traffic', + name: 'traffic', + component: TrafficView, + }, { path: '/npc-list', name: 'npc-list', @@ -276,8 +291,7 @@ const routes = [ }, { path: '/my-settings', - name: 'my-settings', - component: MySettingsView, + redirect: '/my-page', meta: { requiresAuth: true, requiresGeneral: true, diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index 1d3f0e6..8b48183 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue'; -import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { trpc } from '../utils/trpc'; import { getNpcColor } from '../utils/npcColor'; import { formatLog } from '../utils/formatLog'; @@ -269,7 +268,26 @@ onMounted(() => { - + +
+
+ {{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }}) +
+
+ 통솔{{ selectedGeneral.stats.leadership }} 무력{{ selectedGeneral.stats.strength }} 지력{{ selectedGeneral.stats.intelligence }} 자금{{ selectedGeneral.gold }} 군량{{ selectedGeneral.rice }} 병력{{ selectedGeneral.crew }} 훈련{{ selectedGeneral.train }} 사기{{ selectedGeneral.atmos }} 부상{{ selectedGeneral.injury }} 경험{{ selectedGeneral.experience }} 공헌{{ selectedGeneral.dedication }} 전투{{ selectedGeneral.warnum }}회 +
+
최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}
최근 전투: {{ selectedGeneral.recentWar || '-' }}
@@ -286,12 +304,7 @@ onMounted(() => {
@@ -303,126 +316,237 @@ onMounted(() => { diff --git a/app/game-frontend/src/views/NationSecretView.vue b/app/game-frontend/src/views/NationSecretView.vue new file mode 100644 index 0000000..e622cbc --- /dev/null +++ b/app/game-frontend/src/views/NationSecretView.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 6c3684a..1624fcf 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -108,6 +108,7 @@ onMounted(() => { 장수 생성/빙의 메인으로 빙의일람 + 접속량정보 diff --git a/app/game-frontend/src/views/TrafficView.vue b/app/game-frontend/src/views/TrafficView.vue new file mode 100644 index 0000000..58b5f30 --- /dev/null +++ b/app/game-frontend/src/views/TrafficView.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/tools/frontend-legacy-parity/reference-ingame-menus.mjs b/tools/frontend-legacy-parity/reference-ingame-menus.mjs new file mode 100644 index 0000000..219dd75 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-ingame-menus.mjs @@ -0,0 +1,175 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_MENU_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_MENU_USER ?? 'refuser1'; +const passwordFile = process.env.REF_MENU_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_MENU_ARTIFACT_DIR ?? 'test-results/reference-ingame-menus'); + +if (!passwordFile) { + throw new Error('REF_MENU_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const login = async (context, page) => { + 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 response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const rectAndStyle = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderTopColor: style.borderTopColor, + borderTopWidth: style.borderTopWidth, + padding: style.padding, + margin: style.margin, + cursor: style.cursor, + }, + }; +}; + +const measure = async (page, selectors) => + page.evaluate( + ({ selectors, measureSource }) => { + const measureElement = new Function(`return (${measureSource})`)(); + const result = {}; + for (const [name, selector] of Object.entries(selectors)) { + const element = document.querySelector(selector); + result[name] = element ? measureElement(element) : null; + } + return { + elements: result, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }, + { selectors, measureSource: rectAndStyle.toString() } + ); + +const browser = await chromium.launch({ headless: true }); +try { + const output = {}; + for (const viewport of [ + { name: 'desktop', width: 1000, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + const consoleErrors = []; + const failedResources = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('response', (response) => { + if (response.status() >= 400) failedResources.push(`${response.status()} ${response.url()}`); + }); + await login(context, page); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + + await page.goto(new URL('hwe/b_myPage.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + await page.locator('#container').waitFor(); + const myPage = await measure(page, { + body: 'body', + container: '#container', + title: '#container > .row:first-child', + infoColumn: '#container > .row:nth-child(2) > .col:first-child', + settingsColumn: '#container > .row:nth-child(2) > .col:nth-child(2)', + saveButton: '#set_my_setting', + firstSelect: 'select', + customCss: '#custom_css', + firstLogTitle: '#generalActionPlate', + }); + await page.screenshot({ path: resolve(artifactRoot, `ref-my-page-${viewport.name}.png`), fullPage: true }); + + await page.goto(new URL('hwe/a_traffic.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + const traffic = await measure(page, { + body: 'body', + title: 'body > table:first-of-type', + chartLayout: 'body > table:nth-of-type(2)', + refreshChart: 'body > table:nth-of-type(2) > tbody > tr > td:first-child > table', + onlineChart: 'body > table:nth-of-type(2) > tbody > tr > td:nth-child(2) > table', + firstBigBar: '.big_bar', + suspectTable: 'body > table:nth-of-type(3)', + }); + await page.screenshot({ path: resolve(artifactRoot, `ref-traffic-${viewport.name}.png`), fullPage: true }); + + await page.goto(new URL('hwe/a_npcList.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + const npcList = await measure(page, { + body: 'body', + title: 'body > table:first-of-type', + sortSelect: 'select[name="type"]', + list: 'body > table:nth-of-type(2)', + header: 'body > table:nth-of-type(2) tr:first-child', + footer: 'body > table:nth-of-type(3)', + }); + await page.screenshot({ path: resolve(artifactRoot, `ref-npc-list-${viewport.name}.png`), fullPage: true }); + + await page.goto(new URL('hwe/v_battleCenter.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + try { + await page.locator('#container').waitFor({ timeout: 10_000 }); + } catch { + throw new Error( + `Reference battle center failed to mount: ${JSON.stringify({ + url: page.url(), + text: (await page.locator('body').innerText()).slice(0, 500), + html: (await page.content()).slice(-1_000), + consoleErrors, + failedResources, + })}` + ); + } + const battleCenter = await measure(page, { + body: 'body', + container: '#container', + topBar: '#container > :first-child', + selectorRow: '#container > .row:nth-child(2)', + previousButton: '#container > .row:nth-child(2) button:first-child', + firstSelect: '#container > .row:nth-child(2) select:first-of-type', + generalCard: '.header-cell', + firstLogHeader: '.header-cell:nth-of-type(1)', + }); + await page.screenshot({ + path: resolve(artifactRoot, `ref-battle-center-${viewport.name}.png`), + fullPage: true, + }); + output[viewport.name] = { myPage, traffic, npcList, battleCenter }; + await context.close(); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(output) })}\n`); +} finally { + await browser.close(); +}