From 9fff671c2fe19b94ddc25a79324dcb4e6a8f985c Mon Sep 17 00:00:00 2001 From: Hide_D Date: Thu, 29 Jan 2026 18:18:35 +0000 Subject: [PATCH] feat: add ranking and hall of fame features - Implemented new routes for Best General and Hall of Fame views in the frontend. - Created BestGeneralView and HallOfFameView components to display rankings based on game data. - Added new database models for RankData and HallOfFame to store ranking information. - Introduced ranking types and hall of fame types in common types for better type safety. - Developed ranking router to handle fetching of best general and hall of fame data. - Updated database schema with migrations to include new tables for rank data and hall of fame. - Enhanced the main view with links to the new ranking features. --- app/game-api/src/router.ts | 2 + app/game-api/src/router/ranking/index.ts | 362 ++++++++++++++++++ .../src/scenario/scenarioSeeder.ts | 41 ++ app/game-engine/src/turn/databaseHooks.ts | 99 +++++ .../src/turn/unificationHandler.ts | 221 ++++++++++- app/game-frontend/src/router/index.ts | 15 + .../src/views/BestGeneralView.vue | 130 +++++++ .../src/views/HallOfFameView.vue | 155 ++++++++ app/game-frontend/src/views/MainView.vue | 2 + app/gateway-api/src/adminRouter.ts | 13 +- .../src/orchestrator/gatewayOrchestrator.ts | 11 + packages/common/src/index.ts | 1 + packages/common/src/ranking/types.ts | 74 ++++ packages/infra/prisma/game.prisma | 47 +++ .../migration.sql | 43 +++ packages/infra/src/db.ts | 3 + 16 files changed, 1217 insertions(+), 2 deletions(-) create mode 100644 app/game-api/src/router/ranking/index.ts create mode 100644 app/game-frontend/src/views/BestGeneralView.vue create mode 100644 app/game-frontend/src/views/HallOfFameView.vue create mode 100644 packages/common/src/ranking/types.ts create mode 100644 packages/infra/prisma/migrations/20260129000000_add_rank_hall_tables/migration.sql diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index d7ae046..750f033 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -20,6 +20,7 @@ import { tournamentRouter } from './router/tournament/index.js'; import { boardRouter } from './router/board/index.js'; import { diplomacyRouter } from './router/diplomacy/index.js'; import { yearbookRouter } from './router/yearbook/index.js'; +import { rankingRouter } from './router/ranking/index.js'; export const appRouter = router({ health: healthRouter, @@ -42,6 +43,7 @@ export const appRouter = router({ board: boardRouter, diplomacy: diplomacyRouter, yearbook: yearbookRouter, + ranking: rankingRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts new file mode 100644 index 0000000..e73f240 --- /dev/null +++ b/app/game-api/src/router/ranking/index.ts @@ -0,0 +1,362 @@ +import { z } from 'zod'; + +import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; +import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js'; + +import { procedure, router } from '../../trpc.js'; + +const DEFAULT_BG_COLOR = '#2b2b2b'; +const DEFAULT_FG_COLOR = '#ffffff'; + +const readMetaNumber = (value: unknown): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return 0; +}; + +const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`; + +const itemLoader = new ItemLoader(); +let cachedUniqueItems: Promise< + Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }> +> | null = null; + +const loadUniqueItems = () => { + if (!cachedUniqueItems) { + cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) => + modules + .filter((module) => module.unique && !module.buyable) + .map((module) => ({ + key: module.key, + name: module.name, + slot: module.slot, + unique: module.unique, + buyable: module.buyable, + info: module.info, + })) + ); + } + return cachedUniqueItems; +}; + +export const rankingRouter = router({ + getBestGeneral: procedure + .input( + z + .object({ + view: z.enum(['user', 'npc']).optional(), + }) + .optional() + ) + .query(async ({ ctx, input }) => { + const worldState = await ctx.db.worldState.findFirst({ + select: { meta: true }, + }); + const meta = asRecord(worldState?.meta); + const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; + + const view = input?.view ?? 'user'; + const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 }; + + const [nations, generals] = await Promise.all([ + ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), + ctx.db.general.findMany({ + where: { npcState: npcFilter }, + select: { + id: true, + name: true, + nationId: true, + userId: true, + picture: true, + imageServer: true, + experience: true, + dedication: true, + horseCode: true, + weaponCode: true, + bookCode: true, + itemCode: true, + }, + }), + ]); + + const nationMap = new Map(nations.map((nation) => [nation.id, nation])); + const generalIds = generals.map((general) => general.id); + const rankRows = await ctx.db.rankData.findMany({ + where: { generalId: { in: generalIds } }, + select: { generalId: true, type: true, value: true }, + }); + const rankMap = new Map>(); + for (const row of rankRows) { + const entry = rankMap.get(row.generalId) ?? {}; + entry[row.type] = row.value; + rankMap.set(row.generalId, entry); + } + + const types: Array<[string, 'int' | 'percent', (general: typeof generals[number], ranks: Record) => number]> = [ + ['명 성', 'int', (g) => g.experience], + ['계 급', 'int', (g) => g.dedication], + ['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0], + ['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0], + ['승 리', 'int', (_g, r) => r.killnum ?? 0], + ['승 률', 'percent', (_g, r) => { + const warnum = r.warnum ?? 0; + if (warnum < 10) { + return 0; + } + return (r.killnum ?? 0) / Math.max(1, warnum); + }], + ['점 령', 'int', (_g, r) => r.occupied ?? 0], + ['사 살', 'int', (_g, r) => r.killcrew ?? 0], + ['살 상 률', 'percent', (_g, r) => { + const warnum = r.warnum ?? 0; + if (warnum < 10) { + return 0; + } + return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 0); + }], + ['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0], + ['대 인 살 상 률', 'percent', (_g, r) => { + const warnum = r.warnum ?? 0; + if (warnum < 10) { + return 0; + } + return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0); + }], + ['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0], + ['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0], + ['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0], + ['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0], + ['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0], + ['전 력 전 승 률', 'percent', (_g, r) => { + const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0); + if (total < 50) { + return 0; + } + return (r.ttw ?? 0) / Math.max(1, total); + }], + ['통 솔 전 승 률', 'percent', (_g, r) => { + const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0); + if (total < 50) { + return 0; + } + return (r.tlw ?? 0) / Math.max(1, total); + }], + ['일 기 토 승 률', 'percent', (_g, r) => { + const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0); + if (total < 50) { + return 0; + } + return (r.tsw ?? 0) / Math.max(1, total); + }], + ['설 전 승 률', 'percent', (_g, r) => { + const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0); + if (total < 50) { + return 0; + } + return (r.tiw ?? 0) / Math.max(1, total); + }], + ['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0], + ['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0], + ['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0], + ['베 팅 수 익 률', 'percent', (_g, r) => { + const betgold = r.betgold ?? 0; + if (betgold < 1000) { + return 0; + } + return (r.betwingold ?? 0) / Math.max(1, betgold); + }], + ['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0], + ['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0], + ]; + + const sections = types.map(([title, valueType, valueFn]) => { + const entries = generals + .map((general) => { + const ranks = rankMap.get(general.id) ?? {}; + const value = valueFn(general, ranks); + const nation = nationMap.get(general.nationId) ?? null; + let display = { + id: general.id, + name: general.name, + ownerName: general.userId ?? null, + nationName: nation?.name ?? '재야', + bgColor: nation?.color ?? DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, + value, + printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'), + }; + + if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) { + display = { + ...display, + name: '???', + ownerName: null, + nationName: '???', + bgColor: DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: null, + imageServer: 0, + }; + } + return display; + }) + .filter((entry) => entry.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 10); + + return { title, valueType, entries }; + }); + + const uniqueItems = await loadUniqueItems(); + const itemEntries = uniqueItems.map((item) => { + const owners = generals.filter((general) => { + if (item.slot === 'horse') { + return general.horseCode === item.key; + } + if (item.slot === 'weapon') { + return general.weaponCode === item.key; + } + if (item.slot === 'book') { + return general.bookCode === item.key; + } + return general.itemCode === item.key; + }); + + const displayOwners = owners.length + ? owners.map((general) => { + const nation = nationMap.get(general.nationId) ?? null; + return { + id: general.id, + name: general.name, + nationName: nation?.name ?? '재야', + bgColor: nation?.color ?? DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + }; + }) + : [ + { + id: 0, + name: '미발견', + nationName: '-', + bgColor: DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + }, + ]; + + return { + title: item.name, + slot: item.slot, + owners: displayOwners, + }; + }); + + return { + isUnited, + sections, + uniqueItems: itemEntries, + }; + }), + getHallOfFameOptions: procedure.query(async ({ ctx }) => { + const rows = await ctx.db.gameHistory.findMany({ + select: { season: true, scenario: true, scenarioName: true }, + orderBy: [{ season: 'desc' }, { scenario: 'asc' }], + }); + const seasonMap = new Map }>(); + for (const row of rows) { + const entry = seasonMap.get(row.season) ?? { season: row.season, scenarios: [] }; + const scenario = entry.scenarios.find((item) => item.id === row.scenario); + if (scenario) { + scenario.count += 1; + } else { + entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 }); + } + seasonMap.set(row.season, entry); + } + return Array.from(seasonMap.values()); + }), + getHallOfFame: procedure + .input( + z.object({ + season: z.number().int(), + scenario: z.number().int().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const baseWhere = { + season: input.season, + ...(input.scenario !== undefined ? { scenario: input.scenario } : {}), + }; + + const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [ + { key: 'experience', title: '명 성', type: 'int' }, + { key: 'dedication', title: '계 급', type: 'int' }, + { key: 'firenum', title: '계 략 성 공', type: 'int' }, + { key: 'warnum', title: '전 투 횟 수', type: 'int' }, + { key: 'killnum', title: '승 리', type: 'int' }, + { key: 'winrate', title: '승 률', type: 'percent' }, + { key: 'occupied', title: '점 령', type: 'int' }, + { key: 'killcrew', title: '사 살', type: 'int' }, + { key: 'killrate', title: '살 상 률', type: 'percent' }, + { key: 'killcrew_person', title: '대 인 사 살', type: 'int' }, + { key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' }, + { key: 'dex1', title: '보 병 숙 련 도', type: 'int' }, + { key: 'dex2', title: '궁 병 숙 련 도', type: 'int' }, + { key: 'dex3', title: '기 병 숙 련 도', type: 'int' }, + { key: 'dex4', title: '귀 병 숙 련 도', type: 'int' }, + { key: 'dex5', title: '차 병 숙 련 도', type: 'int' }, + { key: 'ttrate', title: '전 력 전 승 률', type: 'percent' }, + { key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' }, + { key: 'tsrate', title: '일 기 토 승 률', type: 'percent' }, + { key: 'tirate', title: '설 전 승 률', type: 'percent' }, + { key: 'betgold', title: '베 팅 투 자 액', type: 'int' }, + { key: 'betwin', title: '베 팅 당 첨', type: 'int' }, + { key: 'betwingold', title: '베 팅 수 익 금', type: 'int' }, + { key: 'betrate', title: '베 팅 수 익 률', type: 'percent' }, + ]; + const allowedTypes = new Set(HALL_OF_FAME_TYPES); + + const sections = await Promise.all( + types.map(async (type) => { + if (!allowedTypes.has(type.key)) { + return { title: type.title, valueType: type.type, entries: [] }; + } + const rows = await ctx.db.hallOfFame.findMany({ + where: { ...baseWhere, type: type.key }, + orderBy: { value: 'desc' }, + take: 10, + }); + const entries = rows.map((row) => { + const aux = asRecord(row.aux); + return { + generalId: row.generalNo, + name: String(aux.name ?? ''), + nationName: String(aux.nationName ?? ''), + bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), + fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), + picture: typeof aux.picture === 'string' ? aux.picture : null, + imageServer: readMetaNumber(aux.imgsvr), + value: row.value, + printValue: type.type === 'percent' ? percentText(row.value) : Math.floor(row.value).toLocaleString('ko-KR'), + serverName: String(aux.serverName ?? ''), + serverIdx: readMetaNumber(aux.serverIdx), + scenarioName: String(aux.scenarioName ?? ''), + startTime: typeof aux.startTime === 'string' ? aux.startTime : null, + unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null, + }; + }); + return { title: type.title, valueType: type.type, entries }; + }) + ); + + return { sections }; + }), +}); diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 5c81868..f2f4c78 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -35,6 +35,7 @@ export interface ScenarioInstallOptions { autorunUser?: ScenarioAutorunOptions | null; preopenAt?: Date | null; season?: number; + serverId?: string; } export interface ScenarioSeedOptions { @@ -258,6 +259,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom if (typeof install?.season === 'number' && Number.isFinite(install.season)) { worldMeta.season = Math.floor(install.season); } + if (typeof install?.serverId === 'string' && install.serverId.trim()) { + worldMeta.serverId = install.serverId.trim(); + } const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]; if (typeof integrationSeed === 'string' && integrationSeed.trim().length > 0) { @@ -304,6 +308,43 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom }, }); + if (typeof worldMeta.serverId === 'string' && worldMeta.serverId) { + await prisma.gameHistory.upsert({ + where: { serverId: worldMeta.serverId }, + create: { + serverId: worldMeta.serverId, + date: now, + winnerNation: null, + map: scenario.config.environment.mapName ?? null, + season: + typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season) + ? Math.floor(worldMeta.season) + : 1, + scenario: options.scenarioId, + scenarioName: String(seed.scenarioMeta?.title ?? ''), + env: asJson({ + config: scenarioConfig, + meta: worldMeta, + }), + }, + update: { + date: now, + winnerNation: null, + map: scenario.config.environment.mapName ?? null, + season: + typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season) + ? Math.floor(worldMeta.season) + : 1, + scenario: options.scenarioId, + scenarioName: String(seed.scenarioMeta?.title ?? ''), + env: asJson({ + config: scenarioConfig, + meta: worldMeta, + }), + }, + }); + } + if (seed.nations.length > 0) { await prisma.nation.createMany({ data: seed.nations.map((nation) => ({ diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 34c20b6..bc095fd 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -13,6 +13,7 @@ import { type TurnEngineWorldStateUpdateInput, } from '@sammo-ts/infra'; import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic'; +import { asRecord, type RankDataType } from '@sammo-ts/common'; import type { TurnDaemonHooks } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; @@ -33,6 +34,79 @@ const readMetaNumber = (meta: Record, key: string): number | nu return typeof value === 'number' && Number.isFinite(value) ? value : null; }; +const readRankMetaNumber = (meta: Record, key: string): number => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return 0; +}; + +const buildRankRows = ( + general: ReturnType['generals'][number] +): Array<{ generalId: number; nationId: number; type: string; value: number }> => { + const meta = asRecord(general.meta); + const readMeta = (key: string) => readRankMetaNumber(meta, key); + const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`); + + const entries: Array<[RankDataType, number]> = [ + ['experience', Math.floor(general.experience)], + ['dedication', Math.floor(general.dedication)], + ['firenum', readMeta('firenum')], + ['warnum', readRank('warnum')], + ['killnum', readRank('killnum')], + ['deathnum', readRank('deathnum')], + ['occupied', readRank('occupied')], + ['killcrew', readRank('killcrew')], + ['deathcrew', readRank('deathcrew')], + ['killcrew_person', readRank('killcrew_person')], + ['deathcrew_person', readRank('deathcrew_person')], + ['dex1', readMeta('dex1')], + ['dex2', readMeta('dex2')], + ['dex3', readMeta('dex3')], + ['dex4', readMeta('dex4')], + ['dex5', readMeta('dex5')], + ['ttw', readMeta('ttw')], + ['ttd', readMeta('ttd')], + ['ttl', readMeta('ttl')], + ['ttg', readMeta('ttg')], + ['ttp', readMeta('ttp')], + ['tlw', readMeta('tlw')], + ['tld', readMeta('tld')], + ['tll', readMeta('tll')], + ['tlg', readMeta('tlg')], + ['tlp', readMeta('tlp')], + ['tsw', readMeta('tsw')], + ['tsd', readMeta('tsd')], + ['tsl', readMeta('tsl')], + ['tsg', readMeta('tsg')], + ['tsp', readMeta('tsp')], + ['tiw', readMeta('tiw')], + ['tid', readMeta('tid')], + ['til', readMeta('til')], + ['tig', readMeta('tig')], + ['tip', readMeta('tip')], + ['betgold', readMeta('betgold')], + ['betwin', readMeta('betwin')], + ['betwingold', readMeta('betwingold')], + ['inherit_earned', readMeta('inherit_earned')], + ['inherit_spent', readMeta('inherit_spent')], + ]; + + return entries.map(([type, value]) => ({ + generalId: general.id, + nationId: general.nationId, + type, + value, + })); +}; + const buildGeneralUpdate = ( general: ReturnType['generals'][number] ): TurnEngineGeneralUpdateInput => ({ @@ -308,6 +382,9 @@ export const createDatabaseTurnHooks = async ( await prisma.general.deleteMany({ where: { id: { in: deletedGenerals } }, }); + await prisma.rankData.deleteMany({ + where: { generalId: { in: deletedGenerals } }, + }); } if (deletedNations.length > 0) { @@ -377,6 +454,28 @@ export const createDatabaseTurnHooks = async ( ), ]); + const rankTargets = [...createdGenerals, ...generals]; + if (rankTargets.length > 0) { + const rankRows = rankTargets.flatMap(buildRankRows); + await Promise.all( + rankRows.map((row) => + prisma.rankData.upsert({ + where: { + generalId_type: { + generalId: row.generalId, + type: row.type, + }, + }, + update: { + nationId: row.nationId, + value: row.value, + }, + create: row, + }) + ) + ); + } + if (logs.length > 0) { const logContext = { year: state.currentYear, diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index bc4757d..cc815d4 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -1,5 +1,5 @@ import { createGamePostgresConnector } from '@sammo-ts/infra'; -import { asRecord } from '@sammo-ts/common'; +import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; import type { LogEntryDraft } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; @@ -16,6 +16,27 @@ const readMetaNumber = (meta: Record, key: string): number => { return value; }; +const readMetaNumberOrNull = (meta: Record, key: string): number | null => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return null; +}; + +const computeHallRate = (numerator: number, denominator: number): number => { + if (denominator <= 0) { + return 0; + } + return numerator / denominator; +}; + const computeDexPoint = (meta: Record): number => { let total = 0; for (const [key, value] of Object.entries(meta)) { @@ -162,6 +183,203 @@ export const createUnificationHandler = (options: { } }; + const settleHallOfFame = async (winnerNationId: number): Promise => { + const world = options.getWorld(); + if (!world) { + return; + } + await ready; + const prisma = connector.prisma; + const state = world.getState(); + const meta = asRecord(state.meta); + + const serverId = + typeof meta.serverId === 'string' && meta.serverId.trim() + ? meta.serverId.trim() + : options.profileName; + const season = readMetaNumberOrNull(meta, 'season') ?? 1; + const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0; + const scenarioName = + typeof asRecord(meta.scenarioMeta).title === 'string' + ? String(asRecord(meta.scenarioMeta).title) + : ''; + const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; + const unitedTime = new Date().toISOString(); + + const [serverCount, nationRows, generalRows, rankRows] = await Promise.all([ + prisma.gameHistory.count(), + prisma.nation.findMany({ select: { id: true, name: true, color: true } }), + prisma.general.findMany({ + where: { npcState: { lt: 2 } }, + select: { + id: true, + userId: true, + nationId: true, + name: true, + picture: true, + imageServer: true, + experience: true, + dedication: true, + }, + }), + prisma.rankData.findMany({ + where: { generalId: { gt: 0 } }, + select: { generalId: true, type: true, value: true }, + }), + ]); + + const nationMap = new Map(); + for (const nation of nationRows) { + nationMap.set(nation.id, { name: nation.name, color: nation.color }); + } + + const rankMap = new Map>(); + for (const row of rankRows) { + const entry = rankMap.get(row.generalId) ?? {}; + entry[row.type] = row.value; + rankMap.set(row.generalId, entry); + } + + const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => { + if (type === 'experience' || type === 'dedication' || type.startsWith('dex')) { + return [type, 'natural']; + } + if (type.endsWith('rate')) { + return [type, 'calc']; + } + return [type, 'rank']; + }); + + for (const general of generalRows) { + const ranks = rankMap.get(general.id) ?? {}; + const warnum = ranks.warnum ?? 0; + const killnum = ranks.killnum ?? 0; + const killcrew = ranks.killcrew ?? 0; + const deathcrew = ranks.deathcrew ?? 0; + const killcrewPerson = ranks.killcrew_person ?? 0; + const deathcrewPerson = ranks.deathcrew_person ?? 0; + const ttw = ranks.ttw ?? 0; + const ttd = ranks.ttd ?? 0; + const ttl = ranks.ttl ?? 0; + const tlw = ranks.tlw ?? 0; + const tld = ranks.tld ?? 0; + const tll = ranks.tll ?? 0; + const tsw = ranks.tsw ?? 0; + const tsd = ranks.tsd ?? 0; + const tsl = ranks.tsl ?? 0; + const tiw = ranks.tiw ?? 0; + const tid = ranks.tid ?? 0; + const til = ranks.til ?? 0; + const betGold = ranks.betgold ?? 0; + const betWinGold = ranks.betwingold ?? 0; + + const ttTotal = ttw + ttd + ttl; + const tlTotal = tlw + tld + tll; + const tsTotal = tsw + tsd + tsl; + const tiTotal = tiw + tid + til; + + const calcValues: Record = { + winrate: computeHallRate(killnum, warnum), + killrate: computeHallRate(killcrew, Math.max(1, deathcrew)), + killrate_person: computeHallRate(killcrewPerson, Math.max(1, deathcrewPerson)), + ttrate: computeHallRate(ttw, Math.max(1, ttTotal)), + tlrate: computeHallRate(tlw, Math.max(1, tlTotal)), + tsrate: computeHallRate(tsw, Math.max(1, tsTotal)), + tirate: computeHallRate(tiw, Math.max(1, tiTotal)), + betrate: computeHallRate(betWinGold, Math.max(1, betGold)), + }; + + const nation = nationMap.get(general.nationId) ?? { name: '재야', color: '#000000' }; + const aux = { + name: general.name, + nationName: nation.name, + bgColor: nation.color, + fgColor: nation.color, + picture: general.picture, + imgsvr: general.imageServer, + startTime, + unitedTime, + ownerName: general.userId ?? null, + serverID: serverId, + serverIdx: serverCount, + serverName: options.profileName, + scenarioName, + }; + + for (const [typeName, valueType] of hallTypes) { + let value = 0; + if (valueType === 'natural') { + value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0; + } else if (valueType === 'rank') { + value = ranks[typeName] ?? 0; + } else { + value = calcValues[typeName] ?? 0; + } + + if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) { + continue; + } + if (typeName === 'ttrate' && ttTotal < 50) { + continue; + } + if (typeName === 'tlrate' && tlTotal < 50) { + continue; + } + if (typeName === 'tsrate' && tsTotal < 50) { + continue; + } + if (typeName === 'tirate' && tiTotal < 50) { + continue; + } + if (typeName === 'betrate' && betGold < 1000) { + continue; + } + if (value <= 0) { + continue; + } + + const existing = await prisma.hallOfFame.findUnique({ + where: { + serverId_type_generalNo: { + serverId, + type: typeName, + generalNo: general.id, + }, + }, + }); + if (!existing) { + await prisma.hallOfFame.create({ + data: { + serverId, + season, + scenario, + generalNo: general.id, + type: typeName, + value, + owner: general.userId ?? null, + aux, + }, + }); + continue; + } + if (value > existing.value) { + await prisma.hallOfFame.update({ + where: { id: existing.id }, + data: { value, aux }, + }); + } + } + } + + await prisma.gameHistory.update({ + where: { serverId }, + data: { + winnerNation: winnerNationId, + date: new Date(), + }, + }); + }; + const handler: TurnCalendarHandler = { onMonthChanged: (context) => { const world = options.getWorld(); @@ -188,6 +406,7 @@ export const createUnificationHandler = (options: { world.updateWorldMeta({ isUnited: 2 }); world.pushLog(buildUnificationLog(winner.name)); void settleInheritance(winner.id, context.currentYear, context.currentMonth); + void settleHallOfFame(winner.id); }, }; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index efc0bea..cbf6af9 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -20,6 +20,8 @@ import BoardView from '../views/BoardView.vue'; import NationAffairsView from '../views/NationAffairsView.vue'; import ScoutMessageView from '../views/ScoutMessageView.vue'; import DiplomacyView from '../views/DiplomacyView.vue'; +import BestGeneralView from '../views/BestGeneralView.vue'; +import HallOfFameView from '../views/HallOfFameView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -163,6 +165,19 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/best-general', + name: 'best-general', + component: BestGeneralView, + meta: { + requiresAuth: true, + }, + }, + { + path: '/hall-of-fame', + name: 'hall-of-fame', + component: HallOfFameView, + }, { path: '/my-page', name: 'my-page', diff --git a/app/game-frontend/src/views/BestGeneralView.vue b/app/game-frontend/src/views/BestGeneralView.vue new file mode 100644 index 0000000..63e2add --- /dev/null +++ b/app/game-frontend/src/views/BestGeneralView.vue @@ -0,0 +1,130 @@ + + + diff --git a/app/game-frontend/src/views/HallOfFameView.vue b/app/game-frontend/src/views/HallOfFameView.vue new file mode 100644 index 0000000..49e22e7 --- /dev/null +++ b/app/game-frontend/src/views/HallOfFameView.vue @@ -0,0 +1,155 @@ + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index d8c7fb6..54449b9 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -98,6 +98,8 @@ watch( 외교부 사령부 감찰부 + 명장일람 + 명예의 전당 게시판 전투 시뮬레이터 내 정보 diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 86b9bd1..8f5b98b 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -321,6 +321,14 @@ const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): Use const buildAdminPassword = (): string => randomBytes(6).toString('hex'); +const buildServerId = (profileName: string, now: Date): string => { + const year = String(now.getFullYear()).slice(-2); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const suffix = randomBytes(2).toString('hex'); + return `${profileName}_${year}${month}${day}_${suffix}`; +}; + // 프로필 메타를 안전하게 읽고, 패치를 병합한다. const readMetaObject = (value: unknown): Record => { if (!value || typeof value !== 'object') { @@ -881,11 +889,13 @@ export const adminRouter = router({ } const season = nextSeasonIdx ?? baseSeason ?? 1; + const seedNow = new Date(); + const serverId = buildServerId(updatedProfile.profileName, seedNow); await seedProfileDatabase({ databaseUrl, scenarioId: input.install.scenarioId, tickSeconds: input.install.turnTermMinutes * 60, - now: new Date(), + now: seedNow, installOptions: { turnTermMinutes: input.install.turnTermMinutes, sync: input.install.sync, @@ -897,6 +907,7 @@ export const adminRouter = router({ tournamentTrig: input.install.tournamentTrig, joinMode: input.install.joinMode, season, + serverId, autorunUser: input.install.autorunUser ? { limitMinutes: input.install.autorunUser.limitMinutes, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index ecc5d86..348acab 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { randomBytes } from 'node:crypto'; import { type ScenarioInstallOptions } from '@sammo-ts/game-engine'; import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; @@ -113,6 +114,14 @@ interface GatewayAdminActionResult { const normalizeMeta = (value: unknown): Record => (isRecord(value) ? value : {}); +const buildServerId = (profileName: string, now: Date): string => { + const year = String(now.getFullYear()).slice(-2); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const suffix = randomBytes(2).toString('hex'); + return `${profileName}_${year}${month}${day}_${suffix}`; +}; + const readMetaNumber = (meta: Record, key: string): number | null => { const raw = meta[key]; if (typeof raw === 'number' && Number.isFinite(raw)) { @@ -663,6 +672,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } const workspace = await this.workspaceManager.prepare(commitSha); const resourceRoot = path.join(workspace.root, 'resources'); + const serverId = buildServerId(profile.profileName, seedTime); await seedProfileDatabase({ databaseUrl: seedInfo.databaseUrl, scenarioId: seedInfo.scenarioId, @@ -671,6 +681,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { installOptions: { ...(installOptions ?? {}), season, + serverId, }, scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') }, mapOptions: { mapRoot: path.join(resourceRoot, 'map') }, diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f661d45..53991be 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -15,3 +15,4 @@ export * from './tournament/autoStart.js'; export * from './turnDaemon/types.js'; export * from './realtime/keys.js'; export * from './realtime/types.js'; +export * from './ranking/types.js'; diff --git a/packages/common/src/ranking/types.ts b/packages/common/src/ranking/types.ts new file mode 100644 index 0000000..d50ae6a --- /dev/null +++ b/packages/common/src/ranking/types.ts @@ -0,0 +1,74 @@ +export const RANK_DATA_TYPES = [ + 'experience', + 'dedication', + 'firenum', + 'warnum', + 'killnum', + 'deathnum', + 'occupied', + 'killcrew', + 'deathcrew', + 'killcrew_person', + 'deathcrew_person', + 'dex1', + 'dex2', + 'dex3', + 'dex4', + 'dex5', + 'ttw', + 'ttd', + 'ttl', + 'ttg', + 'ttp', + 'tlw', + 'tld', + 'tll', + 'tlg', + 'tlp', + 'tsw', + 'tsd', + 'tsl', + 'tsg', + 'tsp', + 'tiw', + 'tid', + 'til', + 'tig', + 'tip', + 'betwin', + 'betgold', + 'betwingold', + 'inherit_earned', + 'inherit_spent', +] as const; + +export type RankDataType = (typeof RANK_DATA_TYPES)[number]; + +export const HALL_OF_FAME_TYPES = [ + 'experience', + 'dedication', + 'firenum', + 'warnum', + 'killnum', + 'winrate', + 'occupied', + 'killcrew', + 'killrate', + 'killcrew_person', + 'killrate_person', + 'dex1', + 'dex2', + 'dex3', + 'dex4', + 'dex5', + 'ttrate', + 'tlrate', + 'tsrate', + 'tirate', + 'betgold', + 'betwin', + 'betwingold', + 'betrate', +] as const; + +export type HallOfFameType = (typeof HALL_OF_FAME_TYPES)[number]; diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 4f988ae..c992a0d 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -146,6 +146,53 @@ model General { @@map("general") } +model RankData { + id Int @id @default(autoincrement()) + nationId Int @default(0) @map("nation_id") + generalId Int @map("general_id") + type String @db.VarChar(20) + value Int @default(0) + + @@unique([generalId, type]) + @@index([type, value], name: "by_type") + @@index([nationId, type, value], name: "by_nation") + @@map("rank_data") +} + +model HallOfFame { + id Int @id @default(autoincrement()) + serverId String @map("server_id") + season Int + scenario Int + generalNo Int @map("general_no") + type String @db.VarChar(20) + value Float + owner String? @map("owner") + aux Json @default(dbgenerated("'{}'::jsonb")) + + @@unique([serverId, type, generalNo]) + @@unique([owner, serverId, type], name: "hall_owner") + @@index([serverId, type, value], name: "server_show") + @@index([season, scenario, type, value], name: "scenario") + @@map("hall") +} + +model GameHistory { + id Int @id @default(autoincrement()) + serverId String @map("server_id") + date DateTime + winnerNation Int? @map("winner_nation") + map String? @map("map") + season Int + scenario Int + scenarioName String @map("scenario_name") + env Json @default(dbgenerated("'{}'::jsonb")) + + @@unique([serverId]) + @@index([date]) + @@map("ng_games") +} + model Troop { troopLeaderId Int @id @map("troop_leader") nationId Int @map("nation") diff --git a/packages/infra/prisma/migrations/20260129000000_add_rank_hall_tables/migration.sql b/packages/infra/prisma/migrations/20260129000000_add_rank_hall_tables/migration.sql new file mode 100644 index 0000000..7cbe373 --- /dev/null +++ b/packages/infra/prisma/migrations/20260129000000_add_rank_hall_tables/migration.sql @@ -0,0 +1,43 @@ +CREATE TABLE "rank_data" ( + "id" SERIAL PRIMARY KEY, + "nation_id" INTEGER NOT NULL DEFAULT 0, + "general_id" INTEGER NOT NULL, + "type" VARCHAR(20) NOT NULL, + "value" INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX "rank_data_by_general" ON "rank_data"("general_id", "type"); +CREATE INDEX "rank_data_by_type" ON "rank_data"("type", "value"); +CREATE INDEX "rank_data_by_nation" ON "rank_data"("nation_id", "type", "value"); + +CREATE TABLE "hall" ( + "id" SERIAL PRIMARY KEY, + "server_id" TEXT NOT NULL, + "season" INTEGER NOT NULL, + "scenario" INTEGER NOT NULL, + "general_no" INTEGER NOT NULL, + "type" VARCHAR(20) NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "owner" TEXT, + "aux" JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE UNIQUE INDEX "hall_server_general" ON "hall"("server_id", "type", "general_no"); +CREATE UNIQUE INDEX "hall_owner" ON "hall"("owner", "server_id", "type"); +CREATE INDEX "hall_server_show" ON "hall"("server_id", "type", "value"); +CREATE INDEX "hall_scenario" ON "hall"("season", "scenario", "type", "value"); + +CREATE TABLE "ng_games" ( + "id" SERIAL PRIMARY KEY, + "server_id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "winner_nation" INTEGER, + "map" TEXT, + "season" INTEGER NOT NULL, + "scenario" INTEGER NOT NULL, + "scenario_name" TEXT NOT NULL, + "env" JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE UNIQUE INDEX "ng_games_server_id" ON "ng_games"("server_id"); +CREATE INDEX "ng_games_date" ON "ng_games"("date"); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index e5d883c..9cb9099 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -11,6 +11,9 @@ export interface DatabaseClient { diplomacy: GamePrisma.DiplomacyDelegate; diplomacyLetter: GamePrisma.DiplomacyLetterDelegate; yearbookHistory: GamePrisma.YearbookHistoryDelegate; + rankData: GamePrisma.RankDataDelegate; + hallOfFame: GamePrisma.HallOfFameDelegate; + gameHistory: GamePrisma.GameHistoryDelegate; generalTurn: GamePrisma.GeneralTurnDelegate; nationTurn: GamePrisma.NationTurnDelegate; troop: GamePrisma.TroopDelegate;