diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index c481325..d7ae046 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -19,6 +19,7 @@ import { auctionRouter } from './router/auction/index.js'; 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'; export const appRouter = router({ health: healthRouter, @@ -40,6 +41,7 @@ export const appRouter = router({ tournament: tournamentRouter, board: boardRouter, diplomacy: diplomacyRouter, + yearbook: yearbookRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts new file mode 100644 index 0000000..1e6bae5 --- /dev/null +++ b/app/game-api/src/router/yearbook/index.ts @@ -0,0 +1,288 @@ +import { createHash } from 'crypto'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { asRecord, isRecord } from '@sammo-ts/common'; +import { LogCategory, LogScope } from '@sammo-ts/infra'; + +import type { GameApiContext } from '../../context.js'; +import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js'; +import { procedure, router } from '../../trpc.js'; + +type YearbookNation = { + id: number; + name: string; + color: string; + level: number; + power: number; + cities: string[]; +}; + +const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; + +const computeHash = (payload: unknown): string => + createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + +const parseYearbookNations = (value: unknown): YearbookNation[] => { + if (!Array.isArray(value)) { + return []; + } + const output: YearbookNation[] = []; + for (const item of value) { + if (!isRecord(item)) { + continue; + } + const id = typeof item.id === 'number' ? item.id : null; + const name = typeof item.name === 'string' ? item.name : null; + const color = typeof item.color === 'string' ? item.color : null; + const level = typeof item.level === 'number' ? item.level : null; + const power = typeof item.power === 'number' ? item.power : null; + const cities = Array.isArray(item.cities) + ? item.cities.filter((city): city is string => typeof city === 'string') + : null; + if (id === null || name === null || color === null || level === null || power === null || !cities) { + continue; + } + output.push({ id, name, color, level, power, cities }); + } + return output; +}; + +const buildNationSnapshot = async (ctx: GameApiContext) => { + const [nationRows, cityRows, generalRows] = await Promise.all([ + ctx.db.nation.findMany({ + select: { + id: true, + name: true, + color: true, + level: true, + gold: true, + rice: true, + tech: true, + }, + orderBy: { id: 'asc' }, + }), + ctx.db.city.findMany({ + select: { + id: true, + name: true, + nationId: true, + population: true, + agriculture: true, + commerce: true, + security: true, + defence: true, + wall: true, + populationMax: true, + agricultureMax: true, + commerceMax: true, + securityMax: true, + defenceMax: true, + wallMax: true, + }, + }), + ctx.db.general.findMany({ + select: { + nationId: true, + npcState: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + gold: true, + rice: true, + }, + }), + ]); + + const cityStatsByNation = new Map(); + const cityNamesByNation = new Map(); + + for (const city of cityRows) { + const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; + const valueSum = + city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence; + const maxSum = + city.populationMax + + city.agricultureMax + + city.commerceMax + + city.securityMax + + city.wallMax + + city.defenceMax; + entry.popSum += city.population; + entry.valueSum += valueSum; + entry.maxSum += maxSum; + cityStatsByNation.set(city.nationId, entry); + + const cityNames = cityNamesByNation.get(city.nationId) ?? []; + cityNames.push(city.name); + cityNamesByNation.set(city.nationId, cityNames); + } + + const generalStatsByNation = new Map(); + for (const general of generalRows) { + const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + entry.goldRice += general.gold + general.rice; + const leadership = general.leadership; + const strength = general.strength; + const intel = general.intel; + const npcMultiplier = general.npcState < 2 ? 1.2 : 1; + const leaderCore = leadership >= 40 ? leadership : 0; + entry.statPower += npcMultiplier * leaderCore * 2 + (Math.sqrt(intel * strength) * 2 + leadership / 2) / 2; + entry.expDed += general.experience + general.dedication; + generalStatsByNation.set(general.nationId, entry); + } + + return nationRows.map((nation) => { + const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; + const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100); + const tech = nation.tech ?? 0; + const cityPower = + nation.level > 0 && cityStats.maxSum > 0 + ? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100) + : 0; + const expDed = Math.round(generalStats.expDed / 100); + const power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10); + + return { + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + power, + cities: cityNamesByNation.get(nation.id) ?? [], + }; + }); +}; + +const buildLogs = async (ctx: GameApiContext, year: number, month: number) => { + const [historyLogs, actionLogs] = await Promise.all([ + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + year, + month, + }, + orderBy: { id: 'desc' }, + }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + year, + month, + }, + orderBy: { id: 'desc' }, + }), + ]); + + const globalHistory = historyLogs.map((entry) => entry.text); + const globalAction = actionLogs.map((entry) => entry.text); + + return { + globalHistory: globalHistory.length ? globalHistory : [`●${month}월: 기록 없음`], + globalAction: globalAction.length ? globalAction : [`●${month}월: 기록 없음`], + }; +}; + +export const yearbookRouter = router({ + getRange: procedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + + const firstRow = await ctx.db.yearbookHistory.findFirst({ + where: { profileName: ctx.profile.name }, + select: { year: true, month: true }, + orderBy: [{ year: 'asc' }, { month: 'asc' }], + }); + + const lastRow = await ctx.db.yearbookHistory.findFirst({ + where: { profileName: ctx.profile.name }, + select: { year: true, month: true }, + orderBy: [{ year: 'desc' }, { month: 'desc' }], + }); + + const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth); + const fallbackYearMonth = currentYearMonth - 1; + const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth; + const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth; + + return { + firstYearMonth, + lastYearMonth, + currentYearMonth, + }; + }), + getHistory: procedure + .input( + z.object({ + year: z.number().int(), + month: z.number().int().min(1).max(12), + hash: z.string().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + + const isCurrent = + worldState.currentYear === input.year && worldState.currentMonth === input.month; + + const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month); + + if (isCurrent) { + const map = await loadPublicMap(ctx, false); + if (!map) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World map is not available.' }); + } + const nations = await buildNationSnapshot(ctx); + const data = { + year: input.year, + month: input.month, + map, + nations, + globalHistory, + globalAction, + }; + const hash = computeHash(data); + if (input.hash && input.hash === hash) { + return { notModified: true, hash }; + } + return { notModified: false, hash, data }; + } + + const row = await ctx.db.yearbookHistory.findFirst({ + where: { + profileName: ctx.profile.name, + year: input.year, + month: input.month, + }, + }); + if (!row) { + throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' }); + } + + const map = asRecord(row.map) as BaseMapResult; + const nations = parseYearbookNations(row.nations); + const data = { + year: input.year, + month: input.month, + map, + nations, + globalHistory, + globalAction, + }; + const hash = computeHash({ map, nations, globalHistory, globalAction }); + if (input.hash && input.hash === hash) { + return { notModified: true, hash }; + } + return { notModified: false, hash, data }; + }), +}); \ No newline at end of file diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 4360e6c..6e2bf59 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -31,6 +31,7 @@ import { createAuctionFinalizer } from '../auction/finalizer.js'; import { createAuctionBidder } from '../auction/bidder.js'; import { createTournamentRewardFinalizer } from '../tournament/finalizer.js'; import { createTournamentAutoStartHandler } from './tournamentAutoStart.js'; +import { createYearbookHandler } from './yearbookHandler.js'; export interface TurnDaemonRuntimeOptions { profile: string; @@ -133,11 +134,17 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) getWorldConfig: () => snapshot.worldConfig ?? null, getTickSeconds: () => worldRef?.getState().tickSeconds ?? null, }); + const yearbookHandler = createYearbookHandler({ + databaseUrl: options.databaseUrl, + profileName: options.profileName ?? options.profile, + getWorld: () => worldRef, + }); const calendarHandler = composeCalendarHandlers( options.calendarHandler ?? unification?.handler, incomeHandler, frontStateHandler, - tournamentAutoStartHandler + tournamentAutoStartHandler, + yearbookHandler.handler ); const worldOptions: InMemoryTurnWorldOptions = { schedule, @@ -319,6 +326,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) if (unification) { await unification.close(); } + await yearbookHandler.close(); if (redisConnector) { await redisConnector.disconnect(); } diff --git a/app/game-engine/src/turn/yearbookHandler.ts b/app/game-engine/src/turn/yearbookHandler.ts new file mode 100644 index 0000000..46017f3 --- /dev/null +++ b/app/game-engine/src/turn/yearbookHandler.ts @@ -0,0 +1,197 @@ +import { createHash } from 'crypto'; +import { asNumber, asRecord } from '@sammo-ts/common'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; + +import type { TurnCalendarHandler } from './inMemoryWorld.js'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +const MAP_VERSION = 0 as const; + +type MapCityCompact = [number, number, number, number, number, number]; +type MapNationCompact = [number, string, string, number]; + +type YearbookMap = { + result: true; + version: number; + startYear: number; + year: number; + month: number; + cityList: MapCityCompact[]; + nationList: MapNationCompact[]; +}; + +type YearbookNation = { + id: number; + name: string; + color: string; + level: number; + power: number; + cities: string[]; +}; + +const readState = (meta: Record): number => { + const raw = meta.state; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.floor(raw); + } + return 0; +}; + +const resolveStartYear = (meta: Record): number => { + const scenarioMeta = asRecord(meta.scenarioMeta); + const startYear = scenarioMeta.startYear; + if (typeof startYear === 'number' && Number.isFinite(startYear)) { + return startYear; + } + return 0; +}; + +const buildMapSnapshot = (world: InMemoryTurnWorld, year: number, month: number): YearbookMap => { + const state = world.getState(); + const cityList: MapCityCompact[] = world.listCities().map((city) => { + const meta = asRecord(city.meta); + const stateValue = city.state ?? readState(meta); + const region = asNumber(meta.region, 0); + const supplyFlag = city.supplyState > 0 ? 1 : 0; + return [city.id, city.level, stateValue, city.nationId, region, supplyFlag]; + }); + + const nationList: MapNationCompact[] = world.listNations().map((nation) => [ + nation.id, + nation.name, + nation.color, + nation.capitalCityId ?? 0, + ]); + + return { + result: true, + version: MAP_VERSION, + startYear: resolveStartYear(asRecord(state.meta)), + year, + month, + cityList, + nationList, + }; +}; + +const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => { + const cities = world.listCities(); + const generals = world.listGenerals(); + const nations = world.listNations(); + + const cityStatsByNation = new Map(); + const cityNamesByNation = new Map(); + + for (const city of cities) { + const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; + const valueSum = + city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence; + const maxSum = + city.populationMax + + city.agricultureMax + + city.commerceMax + + city.securityMax + + city.wallMax + + city.defenceMax; + entry.popSum += city.population; + entry.valueSum += valueSum; + entry.maxSum += maxSum; + cityStatsByNation.set(city.nationId, entry); + + const cityNames = cityNamesByNation.get(city.nationId) ?? []; + cityNames.push(city.name); + cityNamesByNation.set(city.nationId, cityNames); + } + + const generalStatsByNation = new Map(); + for (const general of generals) { + const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + entry.goldRice += general.gold + general.rice; + const leadership = general.stats.leadership; + const strength = general.stats.strength; + const intel = general.stats.intelligence; + const npcMultiplier = general.npcState < 2 ? 1.2 : 1; + const leaderCore = leadership >= 40 ? leadership : 0; + entry.statPower += npcMultiplier * leaderCore * 2 + (Math.sqrt(intel * strength) * 2 + leadership / 2) / 2; + entry.expDed += general.experience + general.dedication; + generalStatsByNation.set(general.nationId, entry); + } + + return nations.map((nation) => { + const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; + const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100); + const tech = asNumber(asRecord(nation.meta).tech, 0); + const cityPower = + nation.level > 0 && cityStats.maxSum > 0 + ? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100) + : 0; + const expDed = Math.round(generalStats.expDed / 100); + const power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10); + + return { + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + power, + cities: cityNamesByNation.get(nation.id) ?? [], + }; + }); +}; + +const buildHash = (map: YearbookMap, nations: YearbookNation[]): string => + createHash('sha256').update(JSON.stringify({ map, nations })).digest('hex'); + +export const createYearbookHandler = (options: { + databaseUrl: string; + profileName: string; + getWorld: () => InMemoryTurnWorld | null; +}): { handler: TurnCalendarHandler; close: () => Promise } => { + const connector = createGamePostgresConnector({ url: options.databaseUrl }); + const ready = connector.connect(); + + const handler: TurnCalendarHandler = { + onMonthChanged: (context) => { + const world = options.getWorld(); + if (!world) { + return; + } + void (async () => { + await ready; + const map = buildMapSnapshot(world, context.previousYear, context.previousMonth); + const nations = buildNationSnapshot(world); + const hash = buildHash(map, nations); + + await connector.prisma.yearbookHistory.upsert({ + where: { + profileName_year_month: { + profileName: options.profileName, + year: context.previousYear, + month: context.previousMonth, + }, + }, + update: { + map, + nations, + hash, + }, + create: { + profileName: options.profileName, + year: context.previousYear, + month: context.previousMonth, + map, + nations, + hash, + }, + }); + })(); + }, + }; + + const close = async () => { + await connector.disconnect(); + }; + + return { handler, close }; +}; \ No newline at end of file diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 00c4a1f..4f988ae 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -213,6 +213,21 @@ model DiplomacyLetter { @@map("diplomacy_letter") } +model YearbookHistory { + id Int @id @default(autoincrement()) + profileName String @map("profile_name") + year Int + month Int + map Json + nations Json + hash String @default("") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([profileName, year, month]) + @@index([profileName, year, month]) + @@map("yearbook_history") +} + model Event { id Int @id @default(autoincrement()) targetCode String @map("target_code") diff --git a/packages/infra/prisma/migrations/20260128002000_add_yearbook_history/migration.sql b/packages/infra/prisma/migrations/20260128002000_add_yearbook_history/migration.sql new file mode 100644 index 0000000..e360da3 --- /dev/null +++ b/packages/infra/prisma/migrations/20260128002000_add_yearbook_history/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "yearbook_history" ( + "id" SERIAL PRIMARY KEY, + "profile_name" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "map" JSONB NOT NULL, + "nations" JSONB NOT NULL, + "hash" TEXT NOT NULL DEFAULT '', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX "yearbook_history_profile_year_month_key" ON "yearbook_history"("profile_name", "year", "month"); +CREATE INDEX "yearbook_history_profile_year_month_idx" ON "yearbook_history"("profile_name", "year", "month"); \ No newline at end of file diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 3b8d2df..e5d883c 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -10,6 +10,7 @@ export interface DatabaseClient { nation: GamePrisma.NationDelegate; diplomacy: GamePrisma.DiplomacyDelegate; diplomacyLetter: GamePrisma.DiplomacyLetterDelegate; + yearbookHistory: GamePrisma.YearbookHistoryDelegate; generalTurn: GamePrisma.GeneralTurnDelegate; nationTurn: GamePrisma.NationTurnDelegate; troop: GamePrisma.TroopDelegate;