From 2017da001b19e40e00890a3f8e9e475467655e08 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:04:01 +0000 Subject: [PATCH 1/2] Add legacy-compatible in-game information pages --- app/game-api/src/maps/mapLayout.ts | 32 +- .../router/nation/endpoints/getNationInfo.ts | 127 ++++ app/game-api/src/router/nation/index.ts | 3 +- app/game-api/src/router/world/index.ts | 211 +++++- app/game-api/test/inGameInfoRouter.test.ts | 185 +++++ app/game-frontend/e2e/inGameInfo.spec.ts | 258 +++++++ app/game-frontend/e2e/playwright.config.mjs | 2 +- app/game-frontend/src/router/index.ts | 21 + .../src/views/CurrentCityView.vue | 240 ++++++ .../src/views/GlobalInfoView.vue | 243 ++++++ app/game-frontend/src/views/MainView.vue | 3 + .../src/views/NationCitiesView.vue | 707 ++++-------------- .../src/views/NationInfoView.vue | 177 +++++ 13 files changed, 1637 insertions(+), 572 deletions(-) create mode 100644 app/game-api/src/router/nation/endpoints/getNationInfo.ts create mode 100644 app/game-api/test/inGameInfoRouter.test.ts create mode 100644 app/game-frontend/e2e/inGameInfo.spec.ts create mode 100644 app/game-frontend/src/views/CurrentCityView.vue create mode 100644 app/game-frontend/src/views/GlobalInfoView.vue create mode 100644 app/game-frontend/src/views/NationInfoView.vue diff --git a/app/game-api/src/maps/mapLayout.ts b/app/game-api/src/maps/mapLayout.ts index c33bdda..a867703 100644 --- a/app/game-api/src/maps/mapLayout.ts +++ b/app/game-api/src/maps/mapLayout.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { loadMapDefinitionByName } from './mapDefinition.js'; export interface MapLayoutCity { id: number; @@ -30,8 +31,7 @@ const LEGACY_CITY_CONST = path.resolve(process.cwd(), 'legacy/hwe/sammo/CityCons const layoutCache = new Map(); -const stripComments = (value: string): string => - value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); +const stripComments = (value: string): string => value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); const extractPhpArray = (source: string, marker: string): string | null => { const idx = source.indexOf(marker); @@ -196,11 +196,7 @@ const parseCityConstFile = async (filePath: string): Promise => const resolveScenarioFile = async (scenario: string): Promise => { const normalized = scenario.replace(/\.json$/i, ''); - const candidates = [ - `${normalized}.json`, - `scenario_${normalized}.json`, - 'default.json', - ]; + const candidates = [`${normalized}.json`, `scenario_${normalized}.json`, 'default.json']; for (const candidate of candidates) { const fullPath = path.join(LEGACY_SCENARIO_ROOT, candidate); @@ -272,15 +268,15 @@ const normalizeInitCity = ( typeof levelLabel === 'number' ? levelLabel : typeof levelLabel === 'string' - ? levelMap.nameToId[levelLabel] ?? Number(levelLabel) - : 0; + ? (levelMap.nameToId[levelLabel] ?? Number(levelLabel)) + : 0; const regionValue = typeof regionLabel === 'number' ? regionLabel : typeof regionLabel === 'string' - ? regionMap.nameToId[regionLabel] ?? Number(regionLabel) - : 0; + ? (regionMap.nameToId[regionLabel] ?? Number(regionLabel)) + : 0; const pathNames = Array.isArray(path) ? (path as string[]) : []; const pathIds = pathNames @@ -324,7 +320,19 @@ export const loadMapLayout = async (scenario: string): Promise => { const levelMap = buildLookupMap(levelMapRaw); const initCity = map.initCity ?? base.initCity ?? []; - const cityList = normalizeInitCity(initCity, levelMap, regionMap); + let cityList = normalizeInitCity(initCity, levelMap, regionMap); + if (cityList.length === 0) { + const resourceMap = await loadMapDefinitionByName(mapName); + cityList = resourceMap.cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + region: city.region, + x: city.position.x, + y: city.position.y, + path: [...city.connections], + })); + } const layout: MapLayout = { mapName, diff --git a/app/game-api/src/router/nation/endpoints/getNationInfo.ts b/app/game-api/src/router/nation/endpoints/getNationInfo.ts new file mode 100644 index 0000000..bc3aa93 --- /dev/null +++ b/app/game-api/src/router/nation/endpoints/getNationInfo.ts @@ -0,0 +1,127 @@ +import { TRPCError } from '@trpc/server'; + +import { asRecord } from '@sammo-ts/common'; +import { LogCategory, LogScope } from '@sammo-ts/infra'; +import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome } from '@sammo-ts/logic'; + +import { authedProcedure } from '../../../trpc.js'; +import { getMyGeneral } from '../../shared/general.js'; +import { + assertNationAccess, + buildNationIncomeContext, + resolveNationBill, + resolveNationRate, + resolveOfficerCity, + toIncomeCity, +} from '../shared.js'; + +export const getNationInfo = authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const [nation, cities, generals, history] = await Promise.all([ + ctx.db.nation.findUnique({ where: { id: me.nationId } }), + ctx.db.city.findMany({ where: { nationId: me.nationId }, orderBy: { id: 'asc' } }), + ctx.db.general.findMany({ where: { nationId: me.nationId } }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: me.nationId, + }, + select: { id: true, year: true, month: true, text: true }, + orderBy: { id: 'asc' }, + }), + ]); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + + const officerCntByCity = new Map(); + for (const general of generals) { + const officerCity = resolveOfficerCity(asRecord(general.meta)); + if ( + general.officerLevel >= 2 && + general.officerLevel <= 4 && + officerCity > 0 && + general.cityId === officerCity + ) { + officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1); + } + } + + const incomeContext = await buildNationIncomeContext(nation); + const incomeCities = cities.map(toIncomeCity); + const rate = resolveNationRate(nation); + const bill = resolveNationBill(asRecord(nation.meta)); + const goldCity = getGoldIncome( + incomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const goldWar = getWarGoldIncome(incomeContext, incomeCities); + const riceCity = getRiceIncome( + incomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const riceWall = getWallIncome( + incomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const outcome = getOutcome( + bill, + generals.filter((general) => general.npcState !== 5) + ); + const population = cities.reduce((sum, city) => sum + city.population, 0); + const populationMax = cities.reduce((sum, city) => sum + city.populationMax, 0); + const crewGenerals = generals.filter((general) => general.npcState !== 5); + const crew = crewGenerals.reduce((sum, general) => sum + general.crew, 0); + const crewMax = crewGenerals.reduce((sum, general) => sum + general.leadership * 100, 0); + const meta = asRecord(nation.meta); + + return { + nation: { + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + power: typeof meta.power === 'number' ? meta.power : 0, + gold: nation.gold, + rice: nation.rice, + tech: Math.floor(nation.tech), + rate, + bill, + capitalCityId: nation.capitalCityId ?? 0, + generalCount: generals.length, + }, + population: { current: population, max: populationMax }, + crew: { current: crew, max: crewMax }, + income: { + goldCity, + goldWar, + goldTotal: goldCity + goldWar, + riceCity, + riceWall, + riceTotal: riceCity + riceWall, + outcome, + }, + budget: { + gold: nation.gold + goldCity + goldWar - outcome, + rice: nation.rice + riceCity + riceWall - outcome, + }, + cities: cities.map((city) => ({ + id: city.id, + name: city.name, + capital: city.id === nation.capitalCityId, + })), + history, + }; +}); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index c2c2d35..e810b96 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -6,6 +6,7 @@ import { getChiefCenter } from './endpoints/getChiefCenter.js'; import { getCityOverview } from './endpoints/getCityOverview.js'; import { getGeneralList } from './endpoints/getGeneralList.js'; import { getGeneralLog } from './endpoints/getGeneralLog.js'; +import { getNationInfo } from './endpoints/getNationInfo.js'; import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js'; import { getStratFinan } from './endpoints/getStratFinan.js'; import { kick } from './endpoints/kick.js'; @@ -18,6 +19,7 @@ import { setScoutMsg } from './endpoints/setScoutMsg.js'; import { setSecretLimit } from './endpoints/setSecretLimit.js'; export const nationRouter = router({ + getNationInfo, getGeneralList, getCityOverview, getPersonnelInfo, @@ -36,4 +38,3 @@ export const nationRouter = router({ kick, appoint, }); - diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 7d4540d..5719247 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -1,15 +1,37 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { - type WorldStateRow, - zWorldStateConfig, - zWorldStateMeta, -} from '../../context.js'; +import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { procedure, router } from '../../trpc.js'; +import { authedProcedure } from '../../trpc.js'; +import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; -import { getOwnedGeneral } from '../shared/general.js'; +import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; + +const isWorldAdmin = (roles: readonly string[]): boolean => + roles.some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser'); + +const numberRecord = (value: unknown): Record => { + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .map(([key, item]) => [Number(key), typeof item === 'number' ? item : Number.NaN] as const) + .filter(([key, item]) => Number.isFinite(key) && Number.isFinite(item)) + ); +}; + +const officerCity = (meta: unknown): number => { + const value = asRecord(meta); + const raw = value.officerCity ?? value.officer_city; + return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; +}; + +const defenceTrain = (meta: unknown): number => { + const value = asRecord(meta); + const raw = value.defenceTrain ?? value.defence_train; + return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; +}; const toWorldStateSnapshot = (row: WorldStateRow) => ({ scenarioCode: row.scenarioCode, @@ -22,6 +44,183 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({ }); export const worldRouter = router({ + getGlobalInfo: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + const [nations, cities, diplomacy, map] = await Promise.all([ + ctx.db.nation.findMany({ where: { level: { gt: 0 } } }), + ctx.db.city.findMany({ orderBy: { id: 'asc' } }), + ctx.db.diplomacy.findMany({ where: { isDead: false, isShowing: true } }), + loadWorldMap(ctx, { generalId: me.id, neutralView: false, showMe: true }), + ]); + if (!map) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + const nationRows = nations + .map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId ?? 0, + level: nation.level, + power: typeof asRecord(nation.meta).power === 'number' ? Number(asRecord(nation.meta).power) : 0, + cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name), + })) + .sort((left, right) => right.power - left.power || left.id - right.id); + const matrix: Record> = {}; + for (const nation of nationRows) { + matrix[nation.id] = {}; + for (const other of nationRows) matrix[nation.id]![other.id] = 2; + } + for (const relation of diplomacy) { + if (!matrix[relation.srcNationId]) continue; + const related = relation.srcNationId === me.nationId || relation.destNationId === me.nationId; + matrix[relation.srcNationId]![relation.destNationId] = related + ? relation.stateCode + : [3, 4, 5, 6, 7].includes(relation.stateCode) + ? 2 + : relation.stateCode; + } + const conflict = cities.flatMap((city) => { + const raw = numberRecord(city.conflict); + const entries = Object.entries(raw); + if (entries.length < 2) return []; + const sum = entries.reduce((total, [, value]) => total + value, 0); + if (sum <= 0) return []; + return [ + { + cityId: city.id, + cityName: city.name, + nations: Object.fromEntries( + entries.map(([id, value]) => [id, Math.round((value * 1000) / sum) / 10]) + ), + }, + ]; + }); + return { myNationId: me.nationId, nations: nationRows, diplomacy: matrix, conflict, map }; + }), + getCurrentCity: authedProcedure + .input(z.object({ cityId: z.number().int().positive().optional() }).optional()) + .query(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const admin = isWorldAdmin(ctx.auth?.user.roles ?? []); + const [cities, nation, nationGenerals, nations, world, layout] = await Promise.all([ + ctx.db.city.findMany({ orderBy: { id: 'asc' } }), + me.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: me.nationId } }) : null, + me.nationId > 0 + ? ctx.db.general.findMany({ where: { nationId: me.nationId }, select: { cityId: true } }) + : [], + ctx.db.nation.findMany(), + ctx.db.worldState.findFirst(), + loadMapLayout(ctx.profile.scenario), + ]); + const cityById = new Map(cities.map((city) => [city.id, city])); + const requested = input?.cityId && cityById.has(input.cityId) ? input.cityId : me.cityId; + const selected = cityById.get(requested); + if (!selected) throw new TRPCError({ code: 'NOT_FOUND', message: 'City not found' }); + const spy = numberRecord(asRecord(nation?.meta).spyList ?? asRecord(nation?.meta).spy); + const selectable = new Set([me.cityId]); + if (me.officerLevel > 0 && me.nationId > 0) { + cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id)); + nationGenerals.forEach((general) => selectable.add(general.cityId)); + Object.keys(spy).forEach((id) => selectable.add(Number(id))); + } + if (admin) cities.forEach((city) => selectable.add(city.id)); + const full = admin || selectable.has(selected.id); + const ownCities = new Set( + cities.filter((city) => city.nationId === me.nationId && me.nationId > 0).map((city) => city.id) + ); + const layoutCity = layout.cityList.find((city) => city.id === selected.id); + const detailed = full || Boolean(layoutCity?.path.some((id) => ownCities.has(id))); + const generals = detailed + ? await ctx.db.general.findMany({ where: { cityId: selected.id }, orderBy: { turnTime: 'asc' } }) + : []; + const generalIds = generals + .filter((general) => general.nationId === me.nationId && general.npcState <= 1) + .map((general) => general.id); + const turns = generalIds.length + ? await ctx.db.generalTurn.findMany({ + where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }) + : []; + const turnMap = new Map(); + for (const turn of turns) { + const list = turnMap.get(turn.generalId) ?? []; + list[turn.turnIdx] = turn.actionCode; + turnMap.set(turn.generalId, list); + } + const nationMap = new Map(nations.map((item) => [item.id, item])); + const officers = await ctx.db.general.findMany({ + where: { officerLevel: { in: [2, 3, 4] } }, + select: { name: true, officerLevel: true, meta: true }, + }); + const selectedOfficers = Object.fromEntries( + officers + .filter((item) => officerCity(item.meta) === selected.id) + .map((item) => [item.officerLevel, item.name]) + ); + const redact = (value: T): T | null => (full ? value : null); + const mappedGenerals = generals.map((general) => { + const ours = admin || (me.nationId > 0 && general.nationId === me.nationId); + return { + id: general.id, + name: general.name, + npcState: general.npcState, + picture: general.picture, + imageServer: general.imageServer, + nationId: general.nationId, + nationName: nationMap.get(general.nationId)?.name ?? '재야', + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + injury: general.injury, + officerLevel: general.officerLevel, + defenceTrain: ours ? defenceTrain(general.meta) : null, + crewTypeId: ours ? general.crewTypeId : null, + crew: ours || full ? general.crew : null, + train: ours ? general.train : null, + atmos: ours ? general.atmos : null, + turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [], + }; + }); + return { + me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin }, + options: [...selectable] + .map((id) => cityById.get(id)) + .filter((city): city is NonNullable => Boolean(city)) + .map((city) => ({ id: city.id, name: city.name, nationId: city.nationId })), + visibility: { full, detailed }, + city: { + id: selected.id, + name: selected.name, + nationId: selected.nationId, + level: selected.level, + region: selected.region, + population: redact(selected.population), + populationMax: selected.populationMax, + agriculture: redact(selected.agriculture), + agricultureMax: selected.agricultureMax, + commerce: redact(selected.commerce), + commerceMax: selected.commerceMax, + security: redact(selected.security), + securityMax: selected.securityMax, + trust: redact(selected.trust), + trade: selected.trade, + defence: full || selected.nationId === 0 ? selected.defence : null, + defenceMax: selected.defenceMax, + wall: full || selected.nationId === 0 ? selected.wall : null, + wallMax: selected.wallMax, + officers: { + 4: selectedOfficers[4] ?? '-', + 3: selectedOfficers[3] ?? '-', + 2: selectedOfficers[2] ?? '-', + }, + }, + generals: mappedGenerals, + lastExecute: + typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '', + }; + }), getState: procedure.query(async ({ ctx }) => { const state = await ctx.db.worldState.findFirst(); return state ? toWorldStateSnapshot(state) : null; diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts new file mode 100644 index 0000000..8a788cd --- /dev/null +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const now = new Date('2026-01-01T00:00:00Z'); +const general = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'user-1', + name: '아군', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 500, + 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: { defence_train: 80 }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); +const auth = (roles: string[] = []): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86400000).toISOString(), + sessionId: 'session', + user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles }, + sanctions: {}, +}); +const city = (id: number, nationId: number) => ({ + id, + name: `도시${id}`, + level: 6, + nationId, + supplyState: 1, + frontState: 0, + population: 1000, + populationMax: 2000, + agriculture: 10, + agricultureMax: 20, + commerce: 11, + commerceMax: 21, + security: 12, + securityMax: 22, + trust: 50, + trade: 100, + defence: 13, + defenceMax: 23, + wall: 14, + wallMax: 24, + region: 2, + conflict: {}, + meta: {}, +}); + +const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record } = {}) => { + const me = options.me ?? general(); + const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)]; + const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 }); + const db = { + general: { + findFirst: vi.fn(async () => me), + findMany: vi.fn(async (args: { where?: Record; select?: Record }) => { + if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }]; + if (args.where?.cityId === 2) return [foreign]; + if (args.where?.cityId === 3) return [foreign]; + if (args.where?.officerLevel) return []; + return []; + }), + }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '아국', + color: '#008000', + level: 1, + capitalCityId: 1, + meta: options.nationMeta ?? {}, + })), + findMany: vi.fn(async () => [ + { id: 1, name: '아국', color: '#008000', level: 1, capitalCityId: 1, meta: { power: 100 } }, + { id: 2, name: '적국', color: '#800000', level: 1, capitalCityId: 2, meta: { power: 90 } }, + ]), + }, + city: { findMany: vi.fn(async () => cities) }, + worldState: { findFirst: vi.fn(async () => ({ meta: { turntime: '2026-01-01' } })) }, + generalTurn: { findMany: vi.fn(async () => []) }, + diplomacy: { findMany: vi.fn(async () => []) }, + }; + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + const accessTokenStore = new RedisAccessTokenStore(redis, 'che:default'); + return { + 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(options.roles), + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'secret', + } satisfies GameApiContext; +}; + +describe('in-game information permissions', () => { + it('does not expose nation-only pages to a wandering general', async () => { + const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) })); + await expect(caller.nation.getNationInfo()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + await expect(caller.nation.getCityOverview()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + + it('lets a wandering general select only the current city', async () => { + const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) })); + const result = await caller.world.getCurrentCity({ cityId: 2 }); + expect(result.city.id).toBe(2); + expect(result.options.map((entry) => entry.id)).toEqual([1]); + expect(result.visibility.full).toBe(false); + expect(result.city.population).toBeNull(); + expect(result.generals).toEqual([]); + }); + + it('keeps adjacent foreign detail redacted and never reveals military fields', async () => { + const result = await appRouter + .createCaller(context({ me: general({ cityId: 80 }) })) + .world.getCurrentCity({ cityId: 2 }); + expect(result.visibility).toEqual({ full: false, detailed: true }); + expect(result.city.agriculture).toBeNull(); + expect(result.city.defence).toBeNull(); + expect(result.generals[0]).toMatchObject({ crew: null, train: null, atmos: null, crewTypeId: null }); + }); + + it('allows a spied city in full but still redacts foreign-general private details', async () => { + const result = await appRouter + .createCaller(context({ nationMeta: { spy: { 2: 2 } } })) + .world.getCurrentCity({ cityId: 2 }); + expect(result.visibility.full).toBe(true); + expect(result.city.population).toBe(1000); + expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null }); + }); + + it('allows administrative roles to inspect all city and general fields', async () => { + const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 }); + expect(result.options).toHaveLength(4); + expect(result.visibility.full).toBe(true); + expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); + }); +}); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts new file mode 100644 index 0000000..6b1de47 --- /dev/null +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -0,0 +1,258 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const city = { + id: 1, + name: '업', + level: 8, + region: 1, + population: 150000, + populationMax: 620500, + agriculture: 1000, + agricultureMax: 12500, + commerce: 1000, + commerceMax: 11300, + security: 1000, + securityMax: 10000, + trust: 80, + trade: 100, + defence: 5000, + defenceMax: 11700, + wall: 5000, + wallMax: 12200, + supplyState: 1, + frontState: 0, + incomes: { gold: 1000, rice: 900, wall: 800 }, + officers: { 2: null, 3: null, 4: { id: 1, name: '태수', npcState: 0, officerLevel: 4, cityId: 1, cityName: '업' } }, +}; +const map = { + result: true, + version: 0, + startYear: 180, + year: 200, + month: 1, + cityList: [[1, 8, 0, 1, 1, 1]], + nationList: [[1, '아국', '#008000', 1]], + spyList: {}, + shownByGeneralList: [], + myCity: 1, + myNation: 1, +}; +const layout = { + mapName: 'che', + cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 345, y: 130, path: [] }], + regionMap: { 1: '하북' }, + levelMap: { 8: '특' }, +}; + +const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_info'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/game/**', (route) => + route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) + ); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getNationInfo') + return response({ + nation: { + id: 1, + name: '아국', + color: '#008000', + level: 1, + power: 1234, + gold: 10000, + rice: 9000, + tech: 100, + rate: 20, + bill: 100, + capitalCityId: 1, + generalCount: 2, + }, + population: { current: 150000, max: 620500 }, + crew: { current: 500, max: 7000 }, + income: { + goldCity: 1000, + goldWar: 200, + goldTotal: 1200, + riceCity: 900, + riceWall: 800, + riceTotal: 1700, + outcome: 300, + }, + budget: { gold: 10900, rice: 10400 }, + cities: [{ id: 1, name: '업', capital: true }], + history: [{ id: 1, year: 200, month: 1, text: '건국했습니다.' }], + }); + if (operation === 'nation.getCityOverview') + return response({ + me: { id: 1, officerLevel: 1 }, + nation: { + id: 1, + name: '아국', + color: '#008000', + level: 1, + typeCode: 'che_중립', + capitalCityId: 1, + rate: 20, + }, + chiefStatMin: 65, + cities: [city], + generals: [ + { + id: 1, + name: '장수', + npcState: 0, + officerLevel: 1, + cityId: 1, + officerCity: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + }, + ], + }); + if (operation === 'world.getGlobalInfo') + return response({ + myNationId: 1, + nations: [ + { + id: 1, + name: '아국', + color: '#008000', + capitalCityId: 1, + level: 1, + power: 1234, + cities: ['업'], + }, + { + id: 2, + name: '적국', + color: '#800000', + capitalCityId: 2, + level: 1, + power: 1000, + cities: ['허창'], + }, + ], + diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } }, + conflict: [], + map, + }); + if (operation === 'world.getMapLayout') return response(layout); + if (operation === 'world.getCurrentCity') + return response({ + me: { + id: 1, + nationId: mode === 'wanderer' ? 0 : 1, + officerLevel: mode === 'wanderer' ? 0 : 1, + admin: mode === 'admin', + }, + options: [{ id: 1, name: '업', nationId: 1 }], + visibility: { full: mode !== 'wanderer', detailed: mode !== 'wanderer' }, + city: { + id: 1, + name: '업', + nationId: 1, + level: 8, + region: 1, + population: mode === 'wanderer' ? null : 150000, + populationMax: 620500, + agriculture: mode === 'wanderer' ? null : 1000, + agricultureMax: 12500, + commerce: mode === 'wanderer' ? null : 1000, + commerceMax: 11300, + security: mode === 'wanderer' ? null : 1000, + securityMax: 10000, + trust: mode === 'wanderer' ? null : 80, + trade: 100, + defence: mode === 'wanderer' ? null : 5000, + defenceMax: 11700, + wall: mode === 'wanderer' ? null : 5000, + wallMax: 12200, + officers: { 2: '-', 3: '-', 4: '태수' }, + }, + generals: + mode === 'wanderer' + ? [] + : [ + { + id: 1, + name: '장수', + npcState: 0, + picture: null, + imageServer: 0, + nationId: 1, + nationName: '아국', + leadership: 70, + strength: 60, + intelligence: 50, + injury: 0, + officerLevel: 1, + defenceTrain: 80, + crewTypeId: 1, + crew: 500, + train: 90, + atmos: 90, + turns: ['징병'], + }, + ], + lastExecute: '2026-07-26', + }); + return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; +const go = async (page: Page, path: string) => { + const ready = page.waitForResponse((r) => r.url().includes('/trpc/lobby.info')); + await page.goto(path); + await ready; +}; + +test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + for (const [path, selector] of [ + ['nation/info', '.legacy-info-page'], + ['nation/cities', '.nation-cities-page'], + ['global-info', '.global-page'], + ['current-city', '.city-page'], + ] as const) { + await go(page, path); + await expect(page.locator(selector)).toBeVisible(); + const box = await page.locator(selector).evaluate((el) => { + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return { x: r.x, width: r.width, fontSize: s.fontSize, fontFamily: s.fontFamily }; + }); + expect(box.width).toBe(1000); + expect(box.x).toBe(100); + expect(box.fontSize).toBe('14px'); + expect(box.fontFamily).toContain('Pretendard'); + expect( + await page + .locator('table') + .first() + .evaluate((el) => getComputedStyle(el).borderCollapse) + ).toBe('collapse'); + } +}); + +test('current-city hides values and general rows for a wandering user', async ({ page }) => { + await install(page, 'wanderer'); + await go(page, 'current-city'); + await expect(page.locator('.stats')).toContainText('?/620,500'); + await expect(page.locator('.generals')).toHaveCount(0); +}); + +test('current-city exposes own general details to a member and admin fixture', async ({ page }) => { + await install(page, 'admin'); + await go(page, 'current-city'); + await expect(page.locator('.generals')).toContainText('장수'); + await expect(page.locator('.generals')).toContainText('90'); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index eddbfbd..4fd93c5 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../. export default defineConfig({ testDir: '.', - testMatch: 'troop.spec.ts', + testMatch: ['troop.spec.ts', 'inGameInfo.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 d82fe60..ee29b09 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -6,6 +6,9 @@ import JoinView from '../views/JoinView.vue'; import InheritView from '../views/InheritView.vue'; import AuctionView from '../views/AuctionView.vue'; import NationCitiesView from '../views/NationCitiesView.vue'; +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 NationPersonnelView from '../views/NationPersonnelView.vue'; import NationStratFinanView from '../views/NationStratFinanView.vue'; @@ -83,6 +86,12 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/nation/info', + name: 'nation-info', + component: NationInfoView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, { path: '/nation/cities', name: 'nation-cities', @@ -92,6 +101,18 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/global-info', + name: 'global-info', + component: GlobalInfoView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, + { + path: '/current-city', + name: 'current-city', + component: CurrentCityView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, { path: '/nation/affairs', name: 'nation-affairs', diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue new file mode 100644 index 0000000..a3d068b --- /dev/null +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/app/game-frontend/src/views/GlobalInfoView.vue b/app/game-frontend/src/views/GlobalInfoView.vue new file mode 100644 index 0000000..6abd86c --- /dev/null +++ b/app/game-frontend/src/views/GlobalInfoView.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index a1284f0..5c4bf7b 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -91,7 +91,10 @@ watch(

{{ statusLine }}

+ 세력 정보 세력 도시 + 중원 정보 + 현재 도시 세력 장수 인사부 부대 편성 diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index bf0d509..ff781cb 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -1,582 +1,185 @@ diff --git a/app/game-frontend/src/views/NationInfoView.vue b/app/game-frontend/src/views/NationInfoView.vue new file mode 100644 index 0000000..aab5fa5 --- /dev/null +++ b/app/game-frontend/src/views/NationInfoView.vue @@ -0,0 +1,177 @@ + + + + + From 63c80fb49ed2082e89698db3a17bc6faf16c5199 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:06:39 +0000 Subject: [PATCH 2/2] Stabilize in-game information E2E navigation --- app/game-frontend/e2e/inGameInfo.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 6b1de47..98e9421 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -209,9 +209,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb }); }; const go = async (page: Page, path: string) => { - const ready = page.waitForResponse((r) => r.url().includes('/trpc/lobby.info')); await page.goto(path); - await ready; }; test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {