diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 45a8306..dc09885 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -45,6 +45,7 @@ export const zWorldStateMeta = z.object({ turntime: z.string().optional(), otherTextInfo: z.string().optional(), isUnited: z.number().optional(), + isunited: z.number().optional(), autorun_user: z .object({ limit_minutes: z.number().optional(), diff --git a/app/game-api/src/router/dynasty/index.ts b/app/game-api/src/router/dynasty/index.ts index 2bc2e0c..bf96f0b 100644 --- a/app/game-api/src/router/dynasty/index.ts +++ b/app/game-api/src/router/dynasty/index.ts @@ -25,6 +25,50 @@ const parseDisplayArray = (value: unknown): Array => ) : []; +const parseArchiveRecord = (value: unknown): Record => { + if (typeof value === 'string') { + try { + return asRecord(JSON.parse(value)); + } catch { + return {}; + } + } + return asRecord(value); +}; + +const firstFiniteNumber = (...values: unknown[]): number | null => { + for (const value of values) { + const parsed = typeof value === 'string' ? Number(value) : value; + if (typeof parsed === 'number' && Number.isFinite(parsed)) return parsed; + } + return null; +}; + +const firstDisplayArray = (...values: unknown[]): Array => { + for (const value of values) { + const parsed = parseDisplayArray(value); + if (parsed.length > 0 || Array.isArray(value)) return parsed; + } + return []; +}; + +const normalizeOldNationData = (value: unknown) => { + const data = asRecord(value); + const aux = parseArchiveRecord(data.aux); + const meta = asRecord(data.meta); + const legacyMaxPower = asRecord(meta.max_power); + const typeCode = typeof data.type === 'string' ? data.type : typeof data.typeCode === 'string' ? data.typeCode : ''; + + return { + data, + typeCode, + tech: firstFiniteNumber(data.tech, meta.tech), + maxPower: firstFiniteNumber(data.maxPower, aux.maxPower, legacyMaxPower.maxPower, data.power), + maxCrew: firstFiniteNumber(data.maxCrew, aux.maxCrew, legacyMaxPower.maxCrew), + maxCities: firstDisplayArray(data.maxCities, aux.maxCities, legacyMaxPower.maxCities), + }; +}; + const formatNationType = (typeCode: string): string => { const separator = typeCode.indexOf('_'); return separator < 0 ? typeCode : typeCode.slice(separator + 1); @@ -94,31 +138,27 @@ export const dynastyRouter = router({ const serverId = emperor.serverId ?? ''; const oldNationRows = await ctx.db.oldNation.findMany({ where: { serverId }, - orderBy: { date: 'desc' }, + orderBy: [{ date: 'desc' }, { id: 'desc' }], }); const nationEntries = oldNationRows .map((row) => { - const data = asRecord(row.data); + const normalized = normalizeOldNationData(row.data); + const { data } = normalized; const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0); - const typeCode = typeof data.type === 'string' ? data.type : ''; return { + archiveId: row.id, nation: nationId, isWinner: winnerNationId !== null && nationId === winnerNationId, name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상', color: typeof data.color === 'string' ? data.color : '#000000', - type: typeCode, - typeName: formatNationType(typeCode), + type: normalized.typeCode, + typeName: formatNationType(normalized.typeCode), level: typeof data.level === 'number' ? data.level : null, - tech: typeof data.tech === 'number' ? data.tech : null, - maxPower: - typeof data.maxPower === 'number' - ? data.maxPower - : typeof data.power === 'number' - ? data.power - : null, - maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null, - maxCities: parseDisplayArray(data.maxCities), + tech: normalized.tech, + maxPower: normalized.maxPower, + maxCrew: normalized.maxCrew, + maxCities: normalized.maxCities, generals: parseNumberArray(data.generals), history: parseTextArray(data.history), date: row.date.toISOString(), diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index bde6e37..e6653e7 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -52,7 +52,7 @@ export const lobbyRouter = router({ opentime: worldState.meta.opentime ?? '', turntime: worldState.meta.turntime ?? '', otherTextInfo: worldState.meta.otherTextInfo ?? '', - isUnited: worldState.meta.isUnited ?? 0, + isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), npcPossessionEnabled: worldState.config.npcMode === 1, myGeneral, diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index b60c2a7..d252303 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -88,7 +88,7 @@ const loadWorldTrendSnapshot = async (ctx: GameApiContext): Promise } => { +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(); @@ -280,12 +282,7 @@ describe('legacy global nation/general directories', () => { ambassadorNames: ['군주', '외교관'], auditorCount: 1, }); - expect(result[1]?.generals.map((general) => general.name)).toEqual([ - '군주', - '외교관', - '제재외교관', - '조언자', - ]); + expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']); expect(result[1]?.officers[0]).toMatchObject({ officerLevel: 12, general: { id: 10, name: '군주' }, @@ -311,9 +308,9 @@ describe('legacy global nation/general directories', () => { expect(defaultResult.generals.find((general) => general.id === 10)?.ownerName).toBeNull(); }); - it('reveals only the legacy owner display name after unification', async () => { + it.each([1, 2, 3])('reveals only the legacy owner display name in united state %i', async (isUnited) => { const result = await appRouter - .createCaller(createContext({ isUnited: 1 }).context) + .createCaller(createContext({ isUnited }).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-api/test/dynastyRouter.test.ts b/app/game-api/test/dynastyRouter.test.ts index 608671f..aa833c9 100644 --- a/app/game-api/test/dynastyRouter.test.ts +++ b/app/game-api/test/dynastyRouter.test.ts @@ -69,12 +69,11 @@ const oldNation = { nation: 1, name: '촉', color: '#FF0000', - type: 'che_병가', + typeCode: 'che_병가', level: 7, tech: 4000, - power: 34434, - maxCrew: 120000, - maxCities: ['성도', '한중'], + power: 12_345, + aux: { maxPower: 34_434, maxCrew: 120_000, maxCities: ['성도', '한중'] }, generals: [11, 12], history: ['유비가 황제로 즉위'], owner: 'not-returned', @@ -82,6 +81,28 @@ const oldNation = { date: new Date('2026-07-25T12:00:00.000Z'), }; +const deletedOldNation = { + id: 4, + serverId: emperor.serverId, + nation: 2, + data: { + nation: 2, + name: '위', + color: '#0000FF', + type: 'che_법가', + level: 5, + power: 8_000, + generals: [13], + history: [], + meta: { + tech: 3_000, + max_power: { maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] }, + privateNote: 'not-returned', + }, + }, + date: new Date('2026-07-24T12:00:00.000Z'), +}; + const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({ version: 1, profile: profile.name, @@ -97,7 +118,10 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload sanctions: {}, }); -const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => { +const buildContext = ( + auth: GameSessionTokenPayload | null, + oldNations: Array> = [oldNation, deletedOldNation] +): GameApiContext => { const db = { worldState: { findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), @@ -108,12 +132,13 @@ const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => { }, oldNation: { findMany: async ({ where }: { where: { serverId: string } }) => - where.serverId === emperor.serverId ? [oldNation] : [], + where.serverId === emperor.serverId ? oldNations : [], }, oldGeneral: { findMany: async () => [ { generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, + { generalNo: 13, name: '조조', lastYearMonth: 21003 }, ], }, }; @@ -204,16 +229,30 @@ describe('dynasty public read model', () => { ); expect(result.nations).toEqual([ expect.objectContaining({ + archiveId: 3, name: '촉', type: 'che_병가', typeName: '병가', levelName: '황제', maxPower: 34434, + maxCrew: 120000, + maxCities: ['성도', '한중'], generalsFull: [ { generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, ], }), + expect.objectContaining({ + archiveId: 4, + name: '위', + type: 'che_법가', + typeName: '법가', + tech: 3000, + maxPower: 20000, + maxCrew: 80000, + maxCities: ['허창'], + generalsFull: [{ generalNo: 13, name: '조조', lastYearMonth: 21003 }], + }), ]); expect(JSON.stringify(result)).not.toContain('privateNote'); expect(JSON.stringify(result)).not.toContain('not-returned'); @@ -221,6 +260,29 @@ describe('dynasty public read model', () => { expect(JSON.stringify(result)).not.toContain('"data"'); }); + it('reads legacy JSON-string aux values without exposing malformed archive internals', async () => { + const stringAuxNation = { + ...oldNation, + id: 5, + data: { + ...oldNation.data, + aux: JSON.stringify({ maxPower: 45_000, maxCrew: 130_000, maxCities: ['낙양'] }), + owner: 'not-returned', + }, + }; + const result = await appRouter + .createCaller(buildContext(null, [stringAuxNation])) + .dynasty.getDetail({ emperorId: emperor.id }); + + expect(result.nations[0]).toMatchObject({ + archiveId: 5, + maxPower: 45_000, + maxCrew: 130_000, + maxCities: ['낙양'], + }); + expect(JSON.stringify(result)).not.toContain('not-returned'); + }); + it('rejects invalid and missing record identifiers without querying another scope', async () => { const caller = appRouter.createCaller(buildContext(null)); diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts new file mode 100644 index 0000000..3ba1b6f --- /dev/null +++ b/app/game-api/test/lobbyRouter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DatabaseClient, GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const buildContext = (meta: Record): GameApiContext => + ({ + auth: null, + db: { + worldState: { + findFirst: vi.fn(async () => ({ + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3_600, + config: {}, + meta, + updatedAt: new Date('2026-07-31T00:00:00.000Z'), + })), + }, + general: { + count: vi.fn(async () => 0), + }, + nation: { + count: vi.fn(async () => 0), + }, + } as unknown as DatabaseClient, + }) as GameApiContext; + +describe('lobby season state', () => { + it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => { + const result = await appRouter + .createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited })) + .lobby.info(); + + expect(result.isUnited).toBe(isunited); + }); +}); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 4145a54..fe26274 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -35,6 +35,7 @@ import { calculateNationBettingRewards } from '../betting/nationBettingSettlemen import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import { buildPersistedRankRows } from './rankData.js'; import { persistUnificationFinalization } from './unificationPersistence.js'; +import { buildOldNationArchiveData } from './oldNationArchive.js'; import { persistYearbookSnapshot } from './yearbookPersistence.js'; export interface DatabaseTurnHooks { @@ -696,40 +697,22 @@ export const createDatabaseTurnHooks = async ( }, }, update: { - data: { - nation: snapshot.nation.id, - name: snapshot.nation.name, - color: snapshot.nation.color, - type: snapshot.nation.typeCode, - level: snapshot.nation.level, - gold: snapshot.nation.gold, - rice: snapshot.nation.rice, - power: snapshot.nation.power, - capitalCityId: snapshot.nation.capitalCityId, - generals: snapshot.generalIds, + data: buildOldNationArchiveData({ + nation: snapshot.nation, + generalIds: snapshot.generalIds, history: historyMap.get(snapshot.nation.id) ?? [], - meta: snapshot.nation.meta ?? {}, - }, + }) as InputJsonValue, date: snapshot.removedAt, }, create: { serverId, nation: snapshot.nation.id, sourceId: 0, - data: { - nation: snapshot.nation.id, - name: snapshot.nation.name, - color: snapshot.nation.color, - type: snapshot.nation.typeCode, - level: snapshot.nation.level, - gold: snapshot.nation.gold, - rice: snapshot.nation.rice, - power: snapshot.nation.power, - capitalCityId: snapshot.nation.capitalCityId, - generals: snapshot.generalIds, + data: buildOldNationArchiveData({ + nation: snapshot.nation, + generalIds: snapshot.generalIds, history: historyMap.get(snapshot.nation.id) ?? [], - meta: snapshot.nation.meta ?? {}, - }, + }) as InputJsonValue, date: snapshot.removedAt, }, }) diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index 68bfbbe..5a454bf 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -155,7 +155,7 @@ export const createRaiseInvaderHandler = (options: { } npcEachCount = Math.max(10, toInteger(npcEachCount)); - world.updateWorldMeta({ isunited: 1 }); + world.updateWorldMeta({ isunited: 1, isUnited: 1 }); const totalGeneralCount = npcEachCount * invaderCities.length + world.listGenerals().length; const maxGeneralsPerMinute = options.maxGeneralsPerMinute ?? readNumber(world.getState().meta.maxGeneralsPerMinute, 1_000); @@ -531,6 +531,7 @@ export const createInvaderEndingHandler = (options: { } world.updateWorldMeta({ isunited: 3, + isUnited: 3, refreshLimit: readNumber(meta.refreshLimit) * 100, }); world.removeEvent(environment.currentEventID); diff --git a/app/game-engine/src/turn/oldNationArchive.ts b/app/game-engine/src/turn/oldNationArchive.ts new file mode 100644 index 0000000..6a96a93 --- /dev/null +++ b/app/game-engine/src/turn/oldNationArchive.ts @@ -0,0 +1,38 @@ +import { asRecord } from '@sammo-ts/common'; +import type { Nation } from '@sammo-ts/logic'; + +const readNumber = (value: unknown): number => { + const parsed = typeof value === 'string' ? Number(value) : value; + return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : 0; +}; + +const readTextArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; + +export const buildOldNationArchiveData = (options: { + nation: Nation; + generalIds: readonly number[]; + history: readonly string[]; +}): Record => { + const { nation } = options; + const meta = asRecord(nation.meta); + const maxPower = asRecord(meta.max_power); + const aux = { + ...asRecord(meta.aux), + ...maxPower, + }; + const maxCities = readTextArray(maxPower.maxCities); + + return { + ...nation, + nation: nation.id, + type: nation.typeCode, + tech: readNumber(meta.tech), + maxPower: readNumber(maxPower.maxPower), + maxCrew: readNumber(maxPower.maxCrew), + maxCities, + aux, + generals: [...options.generalIds], + history: [...options.history], + }; +}; diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 05fc5bf..fa0b877 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -4,6 +4,7 @@ import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageReco import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js'; +import { buildOldNationArchiveData } from './oldNationArchive.js'; import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js'; const UNIFIER_POINT = 2000; @@ -490,16 +491,13 @@ export const persistUnificationFinalization = async ( const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0); const winnerMeta = asRecord(winner.meta); const winnerData = { - ...winner, - tech: readInteger(winnerMeta.tech), - aux: { - ...asRecord(winnerMeta.aux), - ...asRecord(winnerMeta.max_power), - }, + ...buildOldNationArchiveData({ + nation: winner, + generalIds: winnerGenerals.map((general) => general.id), + history: nationHistory, + }), msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''), scout_msg: String(winnerMeta.scout_msg ?? ''), - generals: winnerGenerals.map((general) => general.id), - history: nationHistory, generationKey: input.generationKey, }; await transaction.oldNation.upsert({ diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 12f8f28..ae4e8ad 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -255,6 +255,7 @@ describe('invader monthly actions', () => { ]); expect(harness.world.getState().meta).toMatchObject({ isunited: 1, + isUnited: 1, block_change_scout: false, lastNationId: 10, }); @@ -363,7 +364,7 @@ describe('invader monthly actions', () => { await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); - expect(world.getState().meta).toMatchObject({ isunited: 3, refreshLimit: 300 }); + expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 }); expect(world.listEvents()).toHaveLength(0); expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ '【이벤트】이민족을 모두 소탕했습니다!', diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index 3732691..3001ec2 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -443,7 +443,7 @@ integration('RaiseInvader database persistence', () => { }) ).toBe(3); expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ - meta: expect.objectContaining({ isunited: 1, block_change_scout: false }), + meta: expect.objectContaining({ isunited: 1, isUnited: 1, block_change_scout: false }), }); if (referenceTrace) { expect(referenceTrace.phases.afterRaise).toMatchObject({ @@ -451,6 +451,7 @@ integration('RaiseInvader database persistence', () => { diplomacyCountsPerNation: [2], diplomacyStates: ['1:24'], isunited: 1, + isUnited: 1, blockChangeScout: false, }); } @@ -539,7 +540,7 @@ integration('RaiseInvader database persistence', () => { expect(await db.event.findUnique({ where: { id: createdEventIds[1]! } })).toBeNull(); expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ - meta: expect.objectContaining({ isunited: 3, refreshLimit: 300 }), + meta: expect.objectContaining({ isunited: 3, isUnited: 3, refreshLimit: 300 }), }); expect( await db.logEntry.findMany({ @@ -556,6 +557,7 @@ integration('RaiseInvader database persistence', () => { result: 'Deleted', endingEventPresent: false, isunited: 3, + isUnited: 3, refreshLimit: 300, logs: [ '●200년 4월:【이벤트】이민족을 모두 소탕했습니다!', diff --git a/app/game-engine/test/oldNationArchive.test.ts b/app/game-engine/test/oldNationArchive.test.ts new file mode 100644 index 0000000..b739e6d --- /dev/null +++ b/app/game-engine/test/oldNationArchive.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import type { Nation } from '@sammo-ts/logic'; + +import { buildOldNationArchiveData } from '../src/turn/oldNationArchive.js'; + +describe('old nation archive data', () => { + it('writes one canonical public shape for winner and deleted-nation readers', () => { + const nation: Nation = { + id: 2, + name: '위', + color: '#0000ff', + capitalCityId: 3, + chiefGeneralId: 7, + gold: 1_000, + rice: 2_000, + power: 8_000, + level: 5, + typeCode: 'che_법가', + meta: { + tech: 3_000, + aux: { legacy: 'preserved' }, + max_power: { maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] }, + }, + }; + + expect(buildOldNationArchiveData({ nation, generalIds: [7, 8], history: ['위가 멸망'] })).toMatchObject({ + nation: 2, + name: '위', + type: 'che_법가', + typeCode: 'che_법가', + tech: 3_000, + power: 8_000, + maxPower: 20_000, + maxCrew: 80_000, + maxCities: ['허창'], + aux: { legacy: 'preserved', maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] }, + generals: [7, 8], + history: ['위가 멸망'], + }); + }); +}); diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index 6799cd9..a3ed85c 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -330,6 +330,26 @@ integration('unification finalization transaction', () => { expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1); expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1); expect(await db.oldNation.count({ where: { serverId } })).toBe(2); + expect( + ( + await db.oldNation.findUniqueOrThrow({ + where: { + serverId_nation_sourceId: { serverId, nation: fixtureId, sourceId: 0 }, + }, + }) + ).data + ).toMatchObject({ + nation: fixtureId, + name: '원자통일국', + type: 'che_중립', + typeCode: 'che_중립', + tech: 123, + maxPower: 3_500, + maxCrew: 400, + maxCities: ['원자도시'], + aux: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] }, + generals: [fixtureId], + }); expect(await db.emperor.count({ where: { serverId } })).toBe(1); expect( (await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })) diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index 6438e23..6e9f18f 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -210,7 +210,7 @@ onMounted(loadDetail); diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index aca9589..3592e7a 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -1,6 +1,14 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); +const artifactRoot = process.env.GATEWAY_STATUS_ARTIFACT_DIR; + +if (artifactRoot) { + mkdirSync(artifactRoot, { recursive: true }); +} const operationNames = (route: Route): string[] => { const url = new URL(route.request().url()); @@ -19,6 +27,7 @@ const fulfillTrpc = async (route: Route, results: unknown[]): Promise => { }; type LobbyFixtureOptions = { + authenticated?: boolean; canCreateGeneral?: boolean; myGeneral?: { name: string; @@ -29,10 +38,16 @@ type LobbyFixtureOptions = { npcPossessionEnabled?: boolean; userCnt?: number; maxUserCnt?: number; + nationCnt?: number; + isUnited?: number; + starttime?: string; + opentime?: string; + turntime?: string; }; const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => { const { + authenticated = true, canCreateGeneral = true, myGeneral = { name: '선택장수', @@ -43,22 +58,33 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => npcPossessionEnabled = false, userCnt = 1, maxUserCnt = 500, + nationCnt = 0, + isUnited = 0, + starttime = '2026-07-30 00:00:00', + opentime = '2026-07-30 00:00:00', + turntime = '2026-07-30 00:05:00', } = options; const gameOperations: Array<{ operation: string; authorization: string | undefined }> = []; - await page.addInitScript(() => { - window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session'); - }); + if (authenticated) { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session'); + }); + } await page.route('**/gateway/api/trpc/**', async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'me') { - return response({ - id: 'lobby-user', - username: 'lobby-user', - displayName: '로비사용자', - roles: ['user'], - kakaoVerified: true, - createdAt: '2026-07-30T00:00:00.000Z', - }); + return response( + authenticated + ? { + id: 'lobby-user', + username: 'lobby-user', + displayName: '로비사용자', + roles: ['user'], + kakaoVerified: true, + createdAt: '2026-07-30T00:00:00.000Z', + } + : null + ); } if (operation === 'lobby.notice') { return response(''); @@ -119,14 +145,14 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => userCnt, maxUserCnt, npcCnt: 0, - nationCnt: 0, + nationCnt, turnTerm: 5, fictionMode: '가상', - starttime: '2026-07-30 00:00:00', - opentime: '2026-07-30 00:00:00', - turntime: '2026-07-30 00:05:00', + starttime, + opentime, + turntime, otherTextInfo: '', - isUnited: 0, + isUnited, selectionPoolEnabled, npcPossessionEnabled, myGeneral, @@ -231,3 +257,99 @@ test('shows registration closed instead of acquisition actions at the Ref capaci await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0); await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0); }); + +for (const season of [ + { + name: 'competition', + isUnited: 0, + opentime: '2000-01-01 00:00:00', + label: '<4국 경쟁중>', + period: '2026-07-30 00:00:00 ~', + }, + { + name: 'preopen', + isUnited: 0, + opentime: '2099-01-01 00:00:00', + label: '-가오픈 중-', + period: '2026-07-30 00:00:00 ~', + }, + { + name: 'event running', + isUnited: 1, + opentime: '2099-01-01 00:00:00', + label: '§이벤트 진행중§', + period: '2026-07-30 00:00:00 ~', + }, + { + name: 'united', + isUnited: 2, + opentime: '2099-01-01 00:00:00', + label: '§천하통일§', + period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00', + }, + { + name: 'event finished', + isUnited: 3, + opentime: '2099-01-01 00:00:00', + label: '§이벤트 종료§', + period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00', + }, +] as const) { + test(`renders the Ref ${season.name} season status without changing entry actions`, async ({ page }) => { + await installFixture(page, { + isUnited: season.isUnited, + opentime: season.opentime, + nationCnt: 4, + }); + + await page.goto('lobby'); + const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' }); + const status = row.getByText(season.label, { exact: true }); + await expect(status).toBeVisible(); + await expect(row.locator('td').first().locator('[title]')).toHaveAttribute('title', season.period); + await expect(row.getByRole('button', { name: '입장' })).toBeVisible(); + + if (artifactRoot) { + const [viewport, rowGeometry, statusGeometry] = await Promise.all([ + page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + })), + row.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + status.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + color: style.color, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + textAlign: style.textAlign, + }; + }), + ]); + const geometry = { viewport, row: rowGeometry, status: statusGeometry }; + const slug = season.name.replaceAll(' ', '-'); + writeFileSync(resolve(artifactRoot, `gateway-${slug}.json`), `${JSON.stringify(geometry, null, 2)}\n`); + await page.screenshot({ path: resolve(artifactRoot, `gateway-${slug}.png`), fullPage: true }); + } + }); +} + +test('renders the same Ref season status on the public gateway page', async ({ page }) => { + await installFixture(page, { authenticated: false, isUnited: 3, nationCnt: 4 }); + + await page.goto(''); + const status = page.locator('.season-status'); + await expect(status).toHaveText('§이벤트 종료§'); + await expect(status).toHaveAttribute('title', '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00'); + await expect(page.getByRole('button', { name: '현황 새로고침' })).toBeEnabled(); +}); diff --git a/app/gateway-frontend/src/utils/serverSeasonStatus.ts b/app/gateway-frontend/src/utils/serverSeasonStatus.ts new file mode 100644 index 0000000..451a6cb --- /dev/null +++ b/app/gateway-frontend/src/utils/serverSeasonStatus.ts @@ -0,0 +1,32 @@ +export interface ServerSeasonStatusInput { + isUnited: number; + nationCnt: number; + opentime: string; + starttime: string; + turntime: string; +} + +export interface ServerSeasonStatus { + code: 'COMPETING' | 'PREOPEN' | 'EVENT_RUNNING' | 'UNITED' | 'EVENT_FINISHED'; + label: string; + period: string; +} + +export const resolveServerSeasonStatus = (info: ServerSeasonStatusInput, now = new Date()): ServerSeasonStatus => { + const finishedPeriod = `${info.starttime}\n~ ${info.turntime}`; + if (info.isUnited === 3) { + return { code: 'EVENT_FINISHED', label: '§이벤트 종료§', period: finishedPeriod }; + } + if (info.isUnited === 1) { + return { code: 'EVENT_RUNNING', label: '§이벤트 진행중§', period: `${info.starttime} ~` }; + } + if (info.isUnited === 2) { + return { code: 'UNITED', label: '§천하통일§', period: finishedPeriod }; + } + + const openAt = new Date(info.opentime); + if (Number.isFinite(openAt.getTime()) && openAt.getTime() > now.getTime()) { + return { code: 'PREOPEN', label: '-가오픈 중-', period: `${info.starttime} ~` }; + } + return { code: 'COMPETING', label: `<${info.nationCnt}국 경쟁중>`, period: `${info.starttime} ~` }; +}; diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 1aa8e3f..7fcb4d0 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -10,6 +10,7 @@ import { createGameTrpc, type GameRouter } from '../utils/gameTrpc'; import { trpc } from '../utils/trpc'; import { formatLog } from '../utils/formatLog'; import { sealPassword } from '../utils/passwordEnvelope'; +import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus'; type GatewayOutput = inferRouterOutputs; type GameOutput = inferRouterOutputs; @@ -37,6 +38,7 @@ const dateText = computed(() => { } return `西紀 ${info.value.year}年 ${info.value.month}月`; }); +const seasonStatus = computed(() => (info.value ? resolveServerSeasonStatus(info.value) : null)); const loadPublicStatus = async (): Promise => { statusLoading.value = true; @@ -153,9 +155,7 @@ const handlePasswordReset = async (): Promise => { - + @@ -174,7 +174,10 @@ const handlePasswordReset = async (): Promise => {
  • 서버: {{ profile?.korName }} / 시나리오: {{ profile?.scenario }}
  • -
  • 유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · {{ info.nationCnt }}국 경쟁중
  • +
  • + 유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · + {{ seasonStatus?.label }} +
  • {{ info.turnTerm }}분 턴 서버
diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 1af4a12..6618434 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -8,6 +8,7 @@ import MapPreview from '../components/MapPreview.vue'; import { trpc } from '../utils/trpc'; import { createGameTrpc } from '../utils/gameTrpc'; import type { GameRouter } from '../utils/gameTrpc'; +import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus'; type GatewayRouterOutput = inferRouterOutputs; type GameRouterOutput = inferRouterOutputs; @@ -43,6 +44,7 @@ const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gat const formatGraceEndsAt = (value: string | null | undefined): string => value ? new Date(value).toLocaleString('ko-KR') : ''; +const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); const encodeLegacyIconPath = (value: string): string => value .split('/') @@ -273,15 +275,18 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { :style="{ color: profile.color }" class="text-lg font-bold cursor-help" :title=" - profileDetails[profile.profileName]?.starttime - ? `시작일: ${profileDetails[profile.profileName]?.starttime}` + profileDetails[profile.profileName] + ? serverSeasonStatus(profileDetails[profile.profileName]!).period : '' " > {{ profile.korName }}섭
-
- <{{ profileDetails[profile.profileName]?.nationCnt }}국 경쟁중> +
+ {{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}