diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 36b82562..75b97c7f 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -175,13 +175,14 @@ export const worldRouter = router({ const turns = generalIds.length ? await ctx.db.generalTurn.findMany({ where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }) : []; - const turnMap = new Map(); + const turnMap = new Map>(); for (const turn of turns) { const list = turnMap.get(turn.generalId) ?? []; - list[turn.turnIdx] = turn.actionCode; + list[turn.turnIdx] = { action: turn.actionCode, args: turn.arg }; turnMap.set(turn.generalId, list); } const nationMap = new Map(nations.map((item) => [item.id, item])); diff --git a/app/game-api/test/worldCurrentCityRouter.test.ts b/app/game-api/test/worldCurrentCityRouter.test.ts new file mode 100644 index 00000000..36b84234 --- /dev/null +++ b/app/game-api/test/worldCurrentCityRouter.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +vi.mock('../src/maps/mapLayout.js', () => ({ + loadMapLayout: vi.fn(async () => ({ + mapName: 'che', + cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 0, y: 0, path: [] }], + regionMap: { 1: '하북' }, + levelMap: { 8: '특' }, + })), +})); + +vi.mock('@sammo-ts/game-engine/scenario/unitSetLoader.js', () => ({ + loadUnitSetDefinitionByName: vi.fn(async () => ({ crewTypes: [{ id: 1, name: '보병' }] })), +})); + +const now = new Date('2026-01-01T01:02:00Z'); +const general = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'u1', + 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: 900, + dedication: 100, + officerLevel: 1, + gold: 1000, + rice: 2000, + crew: 300, + crewTypeId: 1, + train: 90, + atmos: 90, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { defence_train: 80 }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const token = (): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), + sessionId: 'session-1', + user: { id: 'u1', username: 'u1', displayName: '장수', roles: [] }, + sanctions: {}, +}); + +const fixture = (authenticated = true) => { + const actor = general(); + const npc = general({ id: 2, userId: null, name: 'NPC', npcState: 2 }); + const city = { + id: 1, + name: '업', + nationId: 1, + level: 8, + region: 1, + population: 150_000, + populationMax: 620_500, + agriculture: 1_000, + agricultureMax: 12_500, + commerce: 1_000, + commerceMax: 11_300, + security: 1_000, + securityMax: 10_000, + trust: 80, + trade: 100, + defence: 5_000, + defenceMax: 11_700, + wall: 5_000, + wallMax: 12_200, + }; + const db = { + general: { + findFirst: vi.fn(async () => actor), + findMany: vi.fn(async ({ where }: { where: Record }) => { + if ('cityId' in where) return [actor, npc]; + if ('officerLevel' in where) return []; + if ('nationId' in where) return [actor, npc]; + return []; + }), + }, + nation: { + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#008000', level: 1, meta: {} })), + findMany: vi.fn(async () => [{ id: 1, name: '위', color: '#008000', level: 1, meta: {} }]), + }, + city: { findMany: vi.fn(async () => [city]) }, + worldState: { + findFirst: vi.fn(async () => ({ config: {}, meta: { turntime: '2026-01-01 10:02:00' } })), + }, + generalTurn: { + findMany: vi.fn(async () => [ + { + generalId: 1, + turnIdx: 0, + actionCode: 'che_징병', + arg: { crewType: 1, amount: 300 }, + }, + ]), + }, + }; + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: authenticated ? token() : null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'secret', + }; + return { caller: appRouter.createCaller(context), db }; +}; + +describe('world current-city command projection', () => { + it('returns the first five own-user turns as canonical action and args while redacting NPC turns', async () => { + const { caller, db } = fixture(); + + const result = await caller.world.getCurrentCity(); + + expect(result.generals.find((entry) => entry.id === 1)?.turns).toEqual([ + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + ]); + expect(result.generals.find((entry) => entry.id === 2)?.turns).toEqual([]); + expect(db.generalTurn.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { generalId: { in: [1] }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, + }) + ); + }); + + it('keeps authentication and input validation in front of the city read model', async () => { + await expect(fixture(false).caller.world.getCurrentCity()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(fixture().caller.world.getCurrentCity({ cityId: 0 })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); +}); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index e90843f6..2270d1ce 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -210,7 +210,44 @@ const install = async ( } if (operation === 'general.me') return response(generalContext); if (operation === 'world.getMap') return response(mapFixture); - if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'turns.getCommandTable') + return response({ + general: [ + { + category: '군사', + values: [ + { + key: 'che_징병', + name: '징병', + reqArg: true, + status: 'needsInput', + possible: true, + inputFields: [], + }, + { + key: 'che_화계', + name: '화계', + reqArg: true, + status: 'needsInput', + possible: true, + inputFields: [], + }, + ], + }, + ], + nation: [], + inputOptions: { + cities: [{ value: 1, label: '업 (아국)' }], + nations: [], + generals: [], + crewTypes: [{ value: 1, label: '보병' }], + armTypes: [], + nationTypes: [], + colors: [], + items: {}, + recruitment: null, + }, + }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: [], revision: 0 }); } @@ -373,7 +410,12 @@ const install = async ( crew: 500, train: 90, atmos: 90, - turns: denseCurrentCity ? ['징병', '훈련'] : ['징병'], + turns: denseCurrentCity + ? [ + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + { action: 'che_화계', args: { destCityId: 1 } }, + ] + : [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }], }, ...(denseCurrentCity ? Array.from({ length: 12 }, (_, index) => ({ @@ -1082,8 +1124,10 @@ test('current-city wraps dense general names and only shrinks reserved turns', a const rows = page.locator('.generals tbody tr'); const reservedTurns = rows.nth(0).locator('.turns'); const npcTurns = rows.nth(1).locator('.turns'); - await expect(reservedTurns).toContainText('1 : 징병'); - await expect(reservedTurns).toContainText('2 : 훈련'); + await expect(reservedTurns).toContainText('1 : 【보병】 300명 징병'); + await expect(reservedTurns).toContainText('2 : 【업】에 화계실행'); + await expect(reservedTurns.locator('.turn-line').nth(0)).toHaveAttribute('title', '【보병】 300명 징병'); + await expect(reservedTurns.locator('.turn-line').nth(1)).toHaveAttribute('title', '【업】에 화계실행'); await expect(reservedTurns).toHaveClass(/turns--reserved/); await expect(npcTurns).toHaveText('NPC 장수'); await expect(npcTurns).not.toHaveClass(/turns--reserved/); diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue index f3251a8f..01ea4136 100644 --- a/app/game-frontend/src/views/CurrentCityView.vue +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -1,6 +1,8 @@