diff --git a/app/game-api/src/router/world/directory.ts b/app/game-api/src/router/world/directory.ts new file mode 100644 index 0000000..2abfe04 --- /dev/null +++ b/app/game-api/src/router/world/directory.ts @@ -0,0 +1,369 @@ +import { asRecord } from '@sammo-ts/common'; +import { z } from 'zod'; + +import { authedProcedure } from '../../trpc.js'; +import { loadTraitNames } from '../nation/shared.js'; +import { getMyGeneral } from '../shared/general.js'; +import { resolveSecretPermission } from '../shared/secretPermission.js'; + +const zDirectorySort = z.number().int().min(1).max(15).default(9); + +const readNumber = (value: unknown, fallback = 0): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +const readMetaNumber = (value: unknown, key: string, fallback = 0): number => + readNumber(asRecord(value)[key], fallback); + +const readMetaString = (value: unknown, ...keys: string[]): string | null => { + const record = asRecord(value); + for (const key of keys) { + const candidate = record[key]; + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate; + } + } + return null; +}; + +const compareString = (left: string, right: string): number => { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +}; + +const resolveExperienceLevel = (experience: number, maxLevel: number): number => { + const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)); + return Math.max(0, Math.min(level, maxLevel)); +}; + +const resolveHonorText = (experience: number): string => { + if (experience < 640) return '전무'; + if (experience < 2_560) return '무명'; + if (experience < 5_760) return '신동'; + if (experience < 10_240) return '약간'; + if (experience < 16_000) return '평범'; + if (experience < 23_040) return '지역적'; + if (experience < 31_360) return '전국적'; + if (experience < 40_960) return '세계적'; + if (experience < 45_000) return '유명'; + if (experience < 51_840) return '명사'; + if (experience < 55_000) return '호걸'; + if (experience < 64_000) return '효웅'; + if (experience < 77_440) return '영웅'; + return '구세주'; +}; + +const resolveDedicationText = (dedication: number, maxLevel: number): string => { + const level = Math.max(0, Math.min(Math.ceil(Math.sqrt(dedication) / 10), maxLevel)); + return level === 0 ? '무품관' : `${maxLevel - level + 1}품관`; +}; + +const resolveRefreshText = (score: number): string => { + if (score < 50) return '안함'; + if (score < 100) return '무관심'; + if (score < 200) return '가끔'; + if (score < 400) return '보통'; + if (score < 800) return '자주'; + if (score < 1_600) return '열심'; + if (score < 3_200) return '중독'; + if (score < 6_400) return '폐인'; + if (score < 12_800) return '경고'; + return '헐...'; +}; + +export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { + await getMyGeneral(ctx); + + const [nations, generals, cities] = await Promise.all([ + ctx.db.nation.findMany({ + select: { + id: true, + name: true, + color: true, + capitalCityId: true, + level: true, + typeCode: true, + meta: true, + }, + }), + ctx.db.general.findMany({ + select: { + id: true, + name: true, + npcState: true, + nationId: true, + cityId: true, + dedication: true, + officerLevel: true, + meta: true, + penalty: true, + }, + orderBy: [{ dedication: 'desc' }, { id: 'asc' }], + }), + ctx.db.city.findMany({ + select: { id: true, name: true, nationId: true }, + orderBy: { id: 'asc' }, + }), + ]); + + const directoryNations = nations.some((nation) => nation.id === 0) + ? nations + : [ + ...nations, + { + id: 0, + name: '재 야', + color: '#000000', + capitalCityId: null, + level: 0, + typeCode: 'None', + meta: {}, + }, + ]; + const nationTypeNames = await loadTraitNames( + directoryNations.map((nation) => nation.typeCode), + 'nation' + ); + const generalsByNation = new Map(); + for (const general of generals) { + const list = generalsByNation.get(general.nationId) ?? []; + list.push(general); + generalsByNation.set(general.nationId, list); + } + const citiesByNation = new Map(); + for (const city of cities) { + const list = citiesByNation.get(city.nationId) ?? []; + list.push(city); + citiesByNation.set(city.nationId, list); + } + + return directoryNations + .map((nation) => { + const nationGenerals = generalsByNation.get(nation.id) ?? []; + const nationCities = citiesByNation.get(nation.id) ?? []; + const officers = Array.from({ length: 8 }, (_, index) => 12 - index).map((officerLevel) => { + const general = nationGenerals + .filter((candidate) => candidate.officerLevel === officerLevel) + .at(-1); + return { + officerLevel, + general: general + ? { id: general.id, name: general.name, npcState: general.npcState, cityId: general.cityId } + : null, + }; + }); + const secretPermissions = nationGenerals.map((general) => ({ + general, + permission: resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta, + false + ), + })); + + return { + id: nation.id, + name: nation.id === 0 ? '재 야' : nation.name, + color: nation.color, + level: nation.level, + type: { + key: nation.typeCode, + name: nationTypeNames.get(nation.typeCode)?.name ?? nation.typeCode, + }, + power: readMetaNumber(nation.meta, 'power'), + capitalCityId: nation.capitalCityId ?? 0, + generalCount: nationGenerals.length, + cityCount: nationCities.length, + officers, + ambassadorNames: secretPermissions + .filter(({ permission }) => permission === 4) + .map(({ general }) => general.name), + auditorCount: secretPermissions.filter(({ permission }) => permission === 3).length, + cities: nationCities.map((city) => ({ + id: city.id, + name: city.name, + capital: city.id === nation.capitalCityId, + })), + generals: nationGenerals.map((general) => ({ + id: general.id, + name: general.name, + npcState: general.npcState, + })), + }; + }) + .sort((left, right) => { + if (left.id === 0) return 1; + if (right.id === 0) return -1; + return right.power - left.power || left.id - right.id; + }); +}); + +export const getGeneralDirectory = authedProcedure + .input(z.object({ sort: zDirectorySort }).optional()) + .query(async ({ ctx, input }) => { + await getMyGeneral(ctx); + const sort = input?.sort ?? 9; + const [generals, nations, accessLogs, worldState] = await Promise.all([ + ctx.db.general.findMany({ + select: { + id: true, + name: true, + picture: true, + imageServer: true, + npcState: true, + age: true, + nationId: true, + personalCode: true, + specialCode: true, + special2Code: true, + injury: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + officerLevel: true, + meta: true, + }, + }), + ctx.db.nation.findMany({ + select: { id: true, name: true, level: true }, + }), + ctx.db.generalAccessLog.findMany({ + select: { generalId: true, refreshScoreTotal: true }, + }), + ctx.db.worldState.findFirst({ + select: { config: true, meta: true }, + }), + ]); + + const personalityKeys = generals.map((general) => general.personalCode); + const domesticKeys = generals.map((general) => general.specialCode); + const warKeys = generals.map((general) => general.special2Code); + const [personalities, domesticTraits, warTraits] = await Promise.all([ + loadTraitNames(personalityKeys, 'personality'), + loadTraitNames(domesticKeys, 'domestic'), + loadTraitNames(warKeys, 'war'), + ]); + const nationMap = new Map(nations.map((nation) => [nation.id, nation])); + const accessMap = new Map(accessLogs.map((row) => [row.generalId, row.refreshScoreTotal])); + const config = asRecord(worldState?.config); + const constValues = asRecord(config.const); + const maxLevel = readNumber(constValues.maxLevel, 255); + const maxDedLevel = readNumber(constValues.maxDedLevel, 30); + const worldMeta = asRecord(worldState?.meta); + const isUnited = readNumber(worldMeta.isUnited ?? worldMeta.isunited) > 0; + + const rows = generals.map((general) => { + const nation = nationMap.get(general.nationId); + const refreshScoreTotal = Math.round((accessMap.get(general.id) ?? 0) / 10) * 10; + const leadershipBonus = + general.officerLevel === 12 + ? (nation?.level ?? 0) * 2 + : general.officerLevel >= 5 + ? (nation?.level ?? 0) + : 0; + const ownerName = isUnited ? readMetaString(general.meta, 'ownerName', 'owner_name') : null; + const killturn = readMetaNumber(general.meta, 'killturn'); + + return { + id: general.id, + name: general.name, + ownerName, + picture: general.picture, + imageServer: general.imageServer, + npcState: general.npcState, + age: general.age, + nationId: general.nationId, + nationName: nation?.name ?? '-', + nationLevel: nation?.level ?? 0, + personality: { + key: general.personalCode, + name: personalities.get(general.personalCode)?.name ?? '-', + info: personalities.get(general.personalCode)?.info ?? '', + }, + specialDomestic: { + key: general.specialCode, + name: domesticTraits.get(general.specialCode)?.name ?? '-', + info: domesticTraits.get(general.specialCode)?.info ?? '', + }, + specialWar: { + key: general.special2Code, + name: warTraits.get(general.special2Code)?.name ?? '-', + info: warTraits.get(general.special2Code)?.info ?? '', + }, + injury: general.injury, + leadership: general.leadership, + leadershipBonus, + strength: general.strength, + intelligence: general.intel, + experience: general.experience, + experienceLevel: resolveExperienceLevel(general.experience, maxLevel), + honorText: resolveHonorText(general.experience), + dedication: general.dedication, + dedicationText: resolveDedicationText(general.dedication, maxDedLevel), + officerLevel: general.officerLevel, + killturn, + refreshScoreTotal, + refreshText: resolveRefreshText(refreshScoreTotal), + }; + }); + + rows.sort((left, right) => { + let result = 0; + switch (sort) { + case 1: + result = left.nationId - right.nationId; + break; + case 2: + result = right.leadership - left.leadership; + break; + case 3: + result = right.strength - left.strength; + break; + case 4: + result = right.intelligence - left.intelligence; + break; + case 5: + case 10: + result = right.experience - left.experience; + break; + case 6: + result = right.dedication - left.dedication; + break; + case 7: + result = right.officerLevel - left.officerLevel; + break; + case 8: + result = left.killturn - right.killturn; + break; + case 9: + result = right.refreshScoreTotal - left.refreshScoreTotal; + break; + case 11: + result = -compareString(left.personality.key, right.personality.key); + break; + case 12: + result = -compareString(left.specialDomestic.key, right.specialDomestic.key); + break; + case 13: + result = -compareString(left.specialWar.key, right.specialWar.key); + break; + case 14: + result = right.age - left.age; + break; + case 15: + result = right.npcState - left.npcState; + break; + } + return result || left.id - right.id; + }); + + return { sort, generals: rows }; + }); diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 5719247..5a943a1 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -8,6 +8,7 @@ import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; +import { getGeneralDirectory, getNationDirectory } from './directory.js'; const isWorldAdmin = (roles: readonly string[]): boolean => roles.some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser'); @@ -44,6 +45,8 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({ }); export const worldRouter = router({ + getNationDirectory, + getGeneralDirectory, getGlobalInfo: authedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); const [nations, cities, diplomacy, map] = await Promise.all([ diff --git a/app/game-api/test/directoryRouter.test.ts b/app/game-api/test/directoryRouter.test.ts new file mode 100644 index 0000000..9ac7fed --- /dev/null +++ b/app/game-api/test/directoryRouter.test.ts @@ -0,0 +1,321 @@ +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:00.000Z'); +const actor = (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: 0, + dedication: 0, + officerLevel: 1, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const auth = (userId = 'user-1', roles: 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 nationRows = [ + { + id: 1, + name: '위', + color: '#008000', + capitalCityId: 1, + chiefGeneralId: 10, + gold: 0, + rice: 0, + tech: 0, + level: 3, + typeCode: 'None', + meta: { power: 1_000 }, + }, + { + id: 2, + name: '촉', + color: '#800000', + capitalCityId: 2, + chiefGeneralId: 20, + gold: 0, + rice: 0, + tech: 0, + level: 2, + typeCode: 'None', + meta: { power: 2_000 }, + }, +]; + +const directoryGeneral = (overrides: Record = {}) => ({ + id: 1, + name: '조회자', + userId: 'user-1', + picture: 'default.jpg', + imageServer: 0, + npcState: 0, + age: 20, + nationId: 1, + cityId: 1, + dedication: 100, + officerLevel: 1, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + injury: 0, + leadership: 70, + strength: 60, + intel: 50, + experience: 1_000, + meta: { killturn: 3 }, + penalty: {}, + ...overrides, +}); + +const globalGenerals = [ + directoryGeneral({ + id: 10, + name: '군주', + dedication: 900, + officerLevel: 12, + meta: { killturn: 4, owner_name: '통일유저' }, + experience: 10_000, + }), + directoryGeneral({ + id: 11, + name: '외교관', + dedication: 800, + officerLevel: 1, + meta: { killturn: 2, permission: 'ambassador' }, + experience: 5_000, + }), + directoryGeneral({ + id: 12, + name: '제재외교관', + dedication: 700, + officerLevel: 1, + meta: { killturn: 1, permission: 'ambassador' }, + penalty: { noTopSecret: true }, + experience: 2_000, + }), + directoryGeneral({ + id: 13, + name: '조언자', + dedication: 600, + officerLevel: 1, + meta: { killturn: 5, permission: 'auditor' }, + experience: 1_000, + }), + directoryGeneral({ + id: 20, + name: '촉장', + userId: 'user-2', + nationId: 2, + cityId: 2, + dedication: 500, + officerLevel: 12, + leadership: 80, + meta: { killturn: 0 }, + experience: 20_000, + }), + directoryGeneral({ + id: 30, + name: '재야장', + userId: 'user-3', + nationId: 0, + cityId: 3, + dedication: 400, + officerLevel: 0, + npcState: 2, + meta: { killturn: 9 }, + experience: 500, + }), +]; + +const createContext = (options: { + auth?: GameSessionTokenPayload | null; + me?: GeneralRow | null; + isUnited?: number; +} = {}): { context: GameApiContext; requestCommand: ReturnType } => { + const token = options.auth === undefined ? auth() : options.auth; + const me = options.me === undefined ? actor({ userId: token?.user.id ?? 'user-1' }) : options.me; + const requestCommand = vi.fn(); + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + const db = { + general: { + findFirst: vi.fn(async () => me), + findMany: vi.fn(async () => globalGenerals), + }, + nation: { + findMany: vi.fn(async () => nationRows), + }, + city: { + findMany: vi.fn(async () => [ + { id: 1, name: '허창', nationId: 1 }, + { id: 2, name: '성도', nationId: 2 }, + { id: 3, name: '낙양', nationId: 0 }, + ]), + }, + generalAccessLog: { + findMany: vi.fn(async () => [ + { generalId: 10, refreshScoreTotal: 104 }, + { generalId: 20, refreshScoreTotal: 206 }, + ]), + }, + worldState: { + findFirst: vi.fn(async () => ({ + config: { const: { maxLevel: 100, maxDedLevel: 30 } }, + meta: { isUnited: options.isUnited ?? 0 }, + })), + }, + }; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: { requestCommand } as unknown as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: token, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'secret', + }; + return { context, requestCommand }; +}; + +describe('legacy global nation/general directories', () => { + it('requires an authenticated user who owns an in-game general', async () => { + const unauthenticated = appRouter.createCaller(createContext({ auth: null, me: null }).context); + await expect(unauthenticated.world.getNationDirectory()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(unauthenticated.world.getGeneralDirectory()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + const noGeneral = appRouter.createCaller(createContext({ me: null }).context); + await expect(noGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(noGeneral.world.getGeneralDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' }); + + const adminWithoutGeneral = appRouter.createCaller( + createContext({ auth: auth('admin', ['admin']), me: null }).context + ); + await expect(adminWithoutGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('keeps the same read-only result for wandering, ordinary, chief, and possessed actors', async () => { + const viewers = [ + actor({ nationId: 0, officerLevel: 0 }), + actor({ nationId: 1, officerLevel: 1 }), + actor({ nationId: 1, officerLevel: 12 }), + actor({ nationId: 1, officerLevel: 1, npcState: 1 }), + ]; + const results = await Promise.all( + viewers.map(async (me) => { + const { context, requestCommand } = createContext({ me }); + const caller = appRouter.createCaller(context); + const result = { + nations: await caller.world.getNationDirectory(), + generals: await caller.world.getGeneralDirectory({ sort: 2 }), + }; + expect(requestCommand).not.toHaveBeenCalled(); + return result; + }) + ); + expect(results.slice(1)).toEqual([results[0], results[0], results[0]]); + }); + + it('preserves power/dedication order, synthesizes neutral, and applies target-general permission penalties', async () => { + const result = await appRouter.createCaller(createContext().context).world.getNationDirectory(); + expect(result.map((nation) => nation.id)).toEqual([2, 1, 0]); + expect(result[1]).toMatchObject({ + id: 1, + generalCount: 4, + cityCount: 1, + ambassadorNames: ['군주', '외교관'], + auditorCount: 1, + }); + expect(result[1]?.generals.map((general) => general.name)).toEqual([ + '군주', + '외교관', + '제재외교관', + '조언자', + ]); + expect(result[1]?.officers[0]).toMatchObject({ + officerLevel: 12, + general: { id: 10, name: '군주' }, + }); + expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 }); + }); + + it('implements all legacy sort boundaries and never exposes user ids or raw metadata', async () => { + const caller = appRouter.createCaller(createContext().context); + const defaultResult = await caller.world.getGeneralDirectory(); + expect(defaultResult.sort).toBe(9); + expect(defaultResult.generals.slice(0, 2).map((general) => general.id)).toEqual([20, 10]); + + const killturnResult = await caller.world.getGeneralDirectory({ sort: 8 }); + expect(killturnResult.generals.slice(0, 3).map((general) => general.id)).toEqual([20, 12, 11]); + const npcResult = await caller.world.getGeneralDirectory({ sort: 15 }); + expect(npcResult.generals[0]?.id).toBe(30); + + const serialized = JSON.stringify(defaultResult); + expect(serialized).not.toContain('user-'); + expect(serialized).not.toContain('"meta"'); + expect(serialized).not.toContain('"penalty"'); + expect(defaultResult.generals.find((general) => general.id === 10)?.ownerName).toBeNull(); + }); + + it('reveals only the legacy owner display name after unification', async () => { + const result = await appRouter + .createCaller(createContext({ isUnited: 1 }).context) + .world.getGeneralDirectory({ sort: 1 }); + expect(result.generals.find((general) => general.id === 10)?.ownerName).toBe('통일유저'); + expect(JSON.stringify(result)).not.toContain('user-1'); + }); +}); diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts new file mode 100644 index 0000000..af367a7 --- /dev/null +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -0,0 +1,335 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +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 nationDirectory = [ + { + id: 2, + name: '촉', + color: '#800000', + level: 2, + type: { key: 'che_법가', name: '법가' }, + power: 2000, + capitalCityId: 2, + generalCount: 1, + cityCount: 1, + officers: Array.from({ length: 8 }, (_, index) => ({ + officerLevel: 12 - index, + general: index === 0 ? { id: 20, name: '유비', npcState: 0, cityId: 2 } : null, + })), + ambassadorNames: ['유비'], + auditorCount: 0, + cities: [{ id: 2, name: '성도', capital: true }], + generals: [{ id: 20, name: '유비', npcState: 0 }], + }, + { + id: 1, + name: '위', + color: '#008000', + level: 3, + type: { key: 'che_명가', name: '명가' }, + power: 1000, + capitalCityId: 1, + generalCount: 2, + cityCount: 1, + officers: Array.from({ length: 8 }, (_, index) => ({ + officerLevel: 12 - index, + general: index === 0 ? { id: 10, name: '조조', npcState: 0, cityId: 1 } : null, + })), + ambassadorNames: ['조조', '순욱'], + auditorCount: 1, + cities: [{ id: 1, name: '허창', capital: true }], + generals: [ + { id: 10, name: '조조', npcState: 0 }, + { id: 11, name: '순욱', npcState: 1 }, + ], + }, + { + id: 0, + name: '재 야', + color: '#000000', + level: 0, + type: { key: 'None', name: '-' }, + power: 0, + capitalCityId: 0, + generalCount: 1, + cityCount: 1, + officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })), + ambassadorNames: [], + auditorCount: 0, + cities: [{ id: 3, name: '낙양', capital: false }], + generals: [{ id: 30, name: '재야장', npcState: 2 }], + }, +]; + +const generals = [ + { + id: 10, + name: '조조', + ownerName: null, + picture: 'default.jpg', + imageServer: 0, + npcState: 0, + age: 35, + nationId: 1, + nationName: '위', + nationLevel: 3, + personality: { key: 'che_대담', name: '대담', info: '대담한 성격' }, + specialDomestic: { key: 'che_상재', name: '상재', info: '상업 특기' }, + specialWar: { key: 'che_귀모', name: '귀모', info: '전투 특기' }, + injury: 10, + leadership: 90, + leadershipBonus: 6, + strength: 70, + intelligence: 95, + experience: 10_000, + experienceLevel: 31, + honorText: '약간', + dedication: 900, + dedicationText: '27품관', + officerLevel: 12, + killturn: 3, + refreshScoreTotal: 120, + refreshText: '가끔', + }, + { + id: 20, + name: '유비', + ownerName: '통일유저', + picture: 'default.jpg', + imageServer: 0, + npcState: 0, + age: 34, + nationId: 2, + nationName: '촉', + nationLevel: 2, + personality: { key: 'che_인덕', name: '인덕', info: '인덕 있는 성격' }, + specialDomestic: { key: 'None', name: '-', info: '' }, + specialWar: { key: 'None', name: '-', info: '' }, + injury: 0, + leadership: 80, + leadershipBonus: 4, + strength: 75, + intelligence: 70, + experience: 20_000, + experienceLevel: 44, + honorText: '지역적', + dedication: 800, + dedicationText: '27품관', + officerLevel: 12, + killturn: 1, + refreshScoreTotal: 210, + refreshText: '보통', + }, +]; + +const parseSort = (route: Route): number => { + const raw = new URL(route.request().url()).searchParams.get('input'); + if (!raw) return 9; + try { + const input = JSON.parse(raw) as { 0?: { sort?: number }; json?: { sort?: number } }; + return input[0]?.sort ?? input.json?.sort ?? 9; + } catch { + return 9; + } +}; + +const install = async ( + page: Page, + mode: 'general' | 'no-general' | 'error-after-load' = 'general' +) => { + let generalDirectoryCalls = 0; + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_directory'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/general/**', (route) => + route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }) + ); + 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: mode === 'no-general' ? null : { id: 1, name: '조회자' } }); + } + if (operation === 'join.getConfig') return response({}); + if (operation === 'world.getNationDirectory') return response(nationDirectory); + if (operation === 'world.getGeneralDirectory') { + generalDirectoryCalls += 1; + if (mode === 'error-after-load' && generalDirectoryCalls > 1) { + return { + error: { + message: '권한 확인 실패', + code: -32000, + data: { + code: 'FORBIDDEN', + httpStatus: 403, + path: 'world.getGeneralDirectory', + }, + }, + }; + } + const sort = parseSort(route); + const rows = sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals; + return response({ sort, generals: rows }); + } + return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + + const measurements: Record = {}; + for (const path of ['nation-list', 'general-list']) { + await page.goto(path); + const root = page.locator('.directory-page'); + await expect(root).toBeVisible(); + const geometry = await root.evaluate((element, currentPath) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const tableStyle = getComputedStyle(element.querySelector('table')!); + const cellStyle = getComputedStyle(element.querySelector('td')!); + const titleRect = element.querySelector('.title-table')!.getBoundingClientRect(); + const contentTable = + currentPath === 'nation-list' + ? element.querySelector('.nation-table')! + : element.querySelector('.general-table')!; + const contentRect = contentTable.getBoundingClientRect(); + const contentFirstRow = contentTable.querySelector('tr')!.getBoundingClientRect(); + return { + x: rect.x, + width: rect.width, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + fontFamily: style.fontFamily, + borderCollapse: tableStyle.borderCollapse, + cellBorderWidth: cellStyle.borderTopWidth, + cellPadding: cellStyle.padding, + bodyBackgroundColor: getComputedStyle(document.body).backgroundColor, + title: { x: titleRect.x, y: titleRect.y, width: titleRect.width, height: titleRect.height }, + content: { + x: contentRect.x, + y: contentRect.y, + width: contentRect.width, + firstRowHeight: contentFirstRow.height, + }, + }; + }, path); + measurements[path] = geometry; + expect(geometry).toMatchObject({ + x: 100, + width: 1000, + fontSize: '14px', + lineHeight: '18.2px', + borderCollapse: 'collapse', + cellBorderWidth: '1px', + bodyBackgroundColor: 'rgb(0, 0, 0)', + }); + expect(geometry.fontFamily).toContain('Pretendard'); + + if (path === 'nation-list') { + expect(geometry.title).toEqual({ x: 100, y: 0, width: 1000, height: 55.6875 }); + expect(geometry.content).toEqual({ + x: 100, + y: 55.6875, + width: 1000, + firstRowHeight: 19.1875, + }); + expect( + await page + .locator('.nation-table .label-cell') + .first() + .evaluate((element) => element.getBoundingClientRect().width) + ).toBe(79.921875); + } else { + expect(geometry.title).toEqual({ x: 100, y: 0, width: 1000, height: 80.875 }); + expect(geometry.content).toEqual({ + x: 100, + y: 80.875, + width: 1000, + firstRowHeight: 19.1875, + }); + expect( + await page + .locator('.general-table tbody tr[data-general-id]') + .first() + .evaluate((element) => element.getBoundingClientRect().height) + ).toBe(65); + } + } + + const header = page.locator('.general-table thead td').first(); + expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg'); + const icon = page.locator('.general-icon').first(); + await expect(icon).toBeVisible(); + expect( + await icon.evaluate((element) => { + const image = element as HTMLImageElement; + const rect = image.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + objectFit: getComputedStyle(image).objectFit, + }; + }) + ).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' }); + + const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR; + if (artifactRoot) { + const output = resolve(artifactRoot); + await mkdir(output, { recursive: true }); + await writeFile(resolve(output, 'computed-dom.json'), `${JSON.stringify(measurements, null, 2)}\n`); + await page.screenshot({ path: resolve(output, 'general-directory.png'), fullPage: true }); + await page.goto('nation-list'); + await page.screenshot({ path: resolve(output, 'nation-directory.png'), fullPage: true }); + } +}); + +test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => { + await install(page); + await page.goto('general-list'); + await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); + await page.selectOption('#viewType', '8'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81'); + await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6'); + + await page.locator('#viewType').focus(); + expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true); +}); + +test('a failed resort retains the selected value and existing rows', async ({ page }) => { + await install(page, 'error-after-load'); + await page.goto('general-list'); + await page.selectOption('#viewType', '8'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.getByRole('alert')).toContainText('권한 확인 실패'); + await expect(page.locator('#viewType')).toHaveValue('8'); + await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); +}); + +test('an authenticated account without a general is redirected away from both directories', async ({ page }) => { + await install(page, 'no-general'); + for (const path of ['nation-list', 'general-list']) { + await page.goto(path); + await expect(page).toHaveURL(/\/che\/join$/); + } +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..64bc538 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,13 @@ 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', + 'nationOffices.spec.ts', + 'directoryLists.spec.ts', + ], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 48884db..28b4a6f 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -10,6 +10,7 @@ "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a412e4c..820266b 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -32,6 +32,8 @@ 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 NationListView from '../views/NationListView.vue'; +import GeneralListView from '../views/GeneralListView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -106,6 +108,18 @@ const routes = [ component: GlobalInfoView, meta: { requiresAuth: true, requiresGeneral: true }, }, + { + path: '/nation-list', + name: 'nation-list', + component: NationListView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, + { + path: '/general-list', + name: 'general-list', + component: GeneralListView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, { path: '/current-city', name: 'current-city', diff --git a/app/game-frontend/src/views/GeneralListView.vue b/app/game-frontend/src/views/GeneralListView.vue new file mode 100644 index 0000000..0309f9b --- /dev/null +++ b/app/game-frontend/src/views/GeneralListView.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index c6e139c..7ab3062 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -102,6 +102,8 @@ watch( 세력 정보 세력 도시 중원 정보 + 세력일람 + 장수일람 현재 도시 세력 장수 인사부 diff --git a/app/game-frontend/src/views/NationListView.vue b/app/game-frontend/src/views/NationListView.vue new file mode 100644 index 0000000..03d3902 --- /dev/null +++ b/app/game-frontend/src/views/NationListView.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/tools/frontend-legacy-parity/reference-directories.mjs b/tools/frontend-legacy-parity/reference-directories.mjs new file mode 100644 index 0000000..60c5a2d --- /dev/null +++ b/tools/frontend-legacy-parity/reference-directories.mjs @@ -0,0 +1,106 @@ +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_DIRECTORY_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_DIRECTORY_USER ?? 'refuser1'; +const passwordFile = process.env.REF_DIRECTORY_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_DIRECTORY_ARTIFACT_DIR ?? 'test-results/reference-directories'); +if (!passwordFile) throw new Error('REF_DIRECTORY_PASSWORD_FILE is required.'); +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const measure = async (page, selectors) => + page.evaluate((items) => { + const result = {}; + for (const [name, selector] of Object.entries(items)) { + const element = document.querySelector(selector); + if (!element) { + result[name] = null; + continue; + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + result[name] = { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + display: style.display, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + borderCollapse: style.borderCollapse, + borderSpacing: style.borderSpacing, + borderWidth: style.borderWidth, + padding: style.padding, + backgroundImage: style.backgroundImage, + color: style.color, + objectFit: style.objectFit, + }, + image: + element instanceof HTMLImageElement + ? { naturalWidth: element.naturalWidth, naturalHeight: element.naturalHeight } + : null, + }; + } + return { + elements: result, + documentWidth: document.documentElement.scrollWidth, + bodyText: document.body.innerText.slice(0, 120), + }; + }, selectors); + +const browser = await chromium.launch({ headless: true }); +try { + const context = await browser.newContext({ + viewport: { width: 1200, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + colorScheme: 'dark', + }); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const salt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512').update(salt + password + salt).digest('hex'); + const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const loginResult = await login.json(); + if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.'); + + const output = {}; + const targets = [ + [ + 'nation-directory', + 'hwe/a_kingdomList.php', + { + body: 'body', + title: 'body > table:first-of-type', + firstNation: 'body > table:nth-of-type(2)', + firstNationTitle: 'body > table:nth-of-type(2) tr:first-child td', + firstLabel: 'body > table:nth-of-type(2) tr:nth-child(2) td:first-child', + }, + ], + [ + 'general-directory', + 'hwe/a_genList.php', + { + body: 'body', + title: 'body > table:first-of-type', + list: 'body > table:nth-of-type(2)', + header: 'body > table:nth-of-type(2) tr:first-child', + firstRow: 'body > table:nth-of-type(2) tr:nth-child(2)', + firstImage: 'body > table:nth-of-type(2) tr:nth-child(2) img', + }, + ], + ]; + for (const [name, path, selectors] of targets) { + await page.goto(new URL(path, baseUrl).toString(), { waitUntil: 'networkidle' }); + output[name] = await measure(page, selectors); + await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true }); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`); +} finally { + await browser.close(); +}