diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index 044e157..e7a0f59 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -1,10 +1,13 @@ import { TRPCError } from '@trpc/server'; +import { asRecord } from '@sammo-ts/common'; import type { GameApiContext } from '../../context.js'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadPublicMap } from '../../maps/worldMap.js'; import { procedure, router } from '../../trpc.js'; +import { loadTraitNames } from '../nation/shared.js'; +import { z } from 'zod'; type WorldTrendSnapshot = { year: number; @@ -37,6 +40,8 @@ type NationCountRow = { count: number; }; +type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; + const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -151,6 +156,52 @@ const loadCachedNationList = async (ctx: GameApiContext): Promise (value && value !== 'None' ? value : null); + +const readFiniteMetaNumber = (meta: Record, key: string): number => { + const value = meta[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +}; + +const compareString = (left: string, right: string): number => { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +}; + +const sortNpcList = (rows: T[], sort: NpcListSort): T[] => + rows.sort((left, right) => { + switch (sort) { + case 2: + return left.nationId - right.nationId; + case 3: + return right.statTotal - left.statTotal; + case 4: + return right.leadership - left.leadership; + case 5: + return right.strength - left.strength; + case 6: + return right.intelligence - left.intelligence; + case 7: + return right.experience - left.experience; + case 8: + return right.dedication - left.dedication; + case 1: + default: + return compareString(left.name, right.name); + } + }); + export const publicRouter = router({ getMapLayout: procedure.query(async ({ ctx }) => { return loadMapLayout(ctx.profile.scenario); @@ -208,4 +259,109 @@ export const publicRouter = router({ intelligence: general.intel, })); }), + getNpcList: procedure + .input( + z + .object({ + sort: z.number().int().min(1).max(8).catch(1).optional(), + }) + .optional() + ) + .query(async ({ ctx, input }) => { + const sort = (input?.sort ?? 1) as NpcListSort; + const [generals, nations] = await Promise.all([ + ctx.db.general.findMany({ + where: { npcState: { gt: 0 } }, + select: { + id: true, + name: true, + npcState: true, + nationId: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + personalCode: true, + specialCode: true, + special2Code: true, + meta: true, + }, + orderBy: { id: 'asc' }, + }), + ctx.db.nation.findMany({ + select: { id: true, name: true }, + }), + ]); + + const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode)); + const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode)); + const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code)); + const [personalityMap, domesticMap, warMap] = await Promise.all([ + loadTraitNames(personalityKeys, 'personality'), + loadTraitNames(domesticKeys, 'domestic'), + loadTraitNames(warKeys, 'war'), + ]); + const nationMap = new Map(nations.map((nation) => [nation.id, nation.name])); + + // Legacy select_pool rows preceded possessed NPC rows before its stable-value sort. + const pool = generals.filter((general) => general.npcState >= 2); + const possessed = generals.filter((general) => general.npcState === 1); + const rows = [...pool, ...possessed].map((general) => { + const meta = asRecord(general.meta); + const personalityKey = normalizeTraitKey(general.personalCode); + const domesticKey = normalizeTraitKey(general.specialCode); + const warKey = normalizeTraitKey(general.special2Code); + const ownerName = + general.npcState === 1 + ? typeof meta.owner_name === 'string' + ? meta.owner_name + : typeof meta.ownerName === 'string' + ? meta.ownerName + : '' + : ''; + + return { + id: general.id, + name: general.name, + npcState: general.npcState, + ownerName, + level: readFiniteMetaNumber(meta, 'explevel'), + nationId: general.nationId, + nationName: nationMap.get(general.nationId) ?? '-', + personality: personalityKey + ? { + key: personalityKey, + name: personalityMap.get(personalityKey)?.name ?? personalityKey, + info: personalityMap.get(personalityKey)?.info ?? '', + } + : null, + specialDomestic: domesticKey + ? { + key: domesticKey, + name: domesticMap.get(domesticKey)?.name ?? domesticKey, + info: domesticMap.get(domesticKey)?.info ?? '', + } + : null, + specialWar: warKey + ? { + key: warKey, + name: warMap.get(warKey)?.name ?? warKey, + info: warMap.get(warKey)?.info ?? '', + } + : null, + statTotal: general.leadership + general.strength + general.intel, + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + experience: general.experience, + dedication: general.dedication, + }; + }); + + return { + sort, + generals: sortNpcList(rows, sort), + }; + }), }); diff --git a/app/game-api/test/publicNpcList.test.ts b/app/game-api/test/publicNpcList.test.ts new file mode 100644 index 0000000..d17400f --- /dev/null +++ b/app/game-api/test/publicNpcList.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const buildContext = (): GameApiContext => { + const generalRows = [ + { + id: 10, + name: '관우', + npcState: 1, + nationId: 1, + leadership: 90, + strength: 95, + intel: 75, + experience: 800, + dedication: 700, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + meta: { owner_name: '악령 관우', explevel: 4 }, + }, + { + id: 20, + name: '조운', + npcState: 2, + nationId: 0, + leadership: 90, + strength: 95, + intel: 75, + experience: 900, + dedication: 600, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + meta: { owner_name: '노출 금지', explevel: 5 }, + }, + ]; + const db = { + general: { + findMany: async (args: { where: { npcState: { gt: number } } }) => { + expect(args.where).toEqual({ npcState: { gt: 0 } }); + return generalRows; + }, + }, + nation: { + findMany: async () => [{ id: 1, name: '촉' }], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: null as GameSessionTokenPayload | null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('public.getNpcList', () => { + it('returns only the legacy-compatible public DTO without user identifiers', async () => { + const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 1 }); + + expect(result.generals).toEqual([ + { + id: 10, + name: '관우', + npcState: 1, + ownerName: '악령 관우', + level: 4, + nationId: 1, + nationName: '촉', + personality: null, + specialDomestic: null, + specialWar: null, + statTotal: 260, + leadership: 90, + strength: 95, + intelligence: 75, + experience: 800, + dedication: 700, + }, + { + id: 20, + name: '조운', + npcState: 2, + ownerName: '', + level: 5, + nationId: 0, + nationName: '-', + personality: null, + specialDomestic: null, + specialWar: null, + statTotal: 260, + leadership: 90, + strength: 95, + intelligence: 75, + experience: 900, + dedication: 600, + }, + ]); + expect(JSON.stringify(result)).not.toContain('노출 금지'); + expect(JSON.stringify(result)).not.toContain('userId'); + }); + + it('keeps pool rows before possessed NPCs when the selected value is tied', async () => { + const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 3 }); + + expect(result.generals.map((general) => general.id)).toEqual([20, 10]); + }); + + it('falls an invalid legacy sort value back to name order', async () => { + const caller = appRouter.createCaller(buildContext()); + const result = await caller.public.getNpcList({ sort: 99 } as unknown as { sort: 1 }); + + expect(result.sort).toBe(1); + expect(result.generals.map((general) => general.name)).toEqual(['관우', '조운']); + }); +}); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index d2c47ab..d82fe60 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -28,6 +28,8 @@ import DynastyDetailView from '../views/DynastyDetailView.vue'; import SurveyView from '../views/SurveyView.vue'; 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 { useSessionStore } from '../stores/session'; const routes = [ @@ -220,6 +222,20 @@ const routes = [ requiresAuth: true, }, }, + { + path: '/nation-betting', + name: 'nation-betting', + component: NationBettingView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, + { + path: '/npc-list', + name: 'npc-list', + component: NpcListView, + }, { path: '/my-page', name: 'my-page', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index b77a91a..76ecf94 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -103,6 +103,8 @@ watch( 명예의 전당 왕조일람 연감 + 천통국 베팅 + 빙의일람 게시판 전투 시뮬레이터 내 정보 diff --git a/app/game-frontend/src/views/NationBettingView.vue b/app/game-frontend/src/views/NationBettingView.vue new file mode 100644 index 0000000..c776908 --- /dev/null +++ b/app/game-frontend/src/views/NationBettingView.vue @@ -0,0 +1,640 @@ + + + + + diff --git a/app/game-frontend/src/views/NpcListView.vue b/app/game-frontend/src/views/NpcListView.vue new file mode 100644 index 0000000..3d7aa23 --- /dev/null +++ b/app/game-frontend/src/views/NpcListView.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 7a8f154..6c3684a 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -107,6 +107,7 @@ onMounted(() => { 로그인 장수 생성/빙의 메인으로 + 빙의일람