From 6fe6d544321c65b869d2fbd03125a506ab44fd7b Mon Sep 17 00:00:00 2001 From: Hide_D Date: Tue, 30 Dec 2025 04:45:07 +0000 Subject: [PATCH] feat: add GeneralTurn and NationTurn models with CRUD operations - Introduced GeneralTurn and NationTurn models in Prisma schema. - Implemented reserved turns management in the game API, including loading, setting, and shifting turns for generals and nations. - Created unit tests for reserved turns functionality to ensure correctness. - Developed reserved turn handler to process actions based on reserved turns. - Established in-memory storage for reserved turns with persistence to the database. --- app/game-api/src/context.ts | 50 ++ app/game-api/src/router.ts | 159 ++++ app/game-api/src/server.ts | 4 +- app/game-api/src/turns/reservedTurns.ts | 192 +++++ app/game-api/test/reservedTurns.test.ts | 127 +++ app/game-api/test/router.test.ts | 10 + app/game-engine/src/turn/databaseHooks.ts | 68 +- .../src/turn/inMemoryTurnProcessor.ts | 9 + app/game-engine/src/turn/inMemoryWorld.ts | 145 +++- .../src/turn/reservedTurnHandler.ts | 796 ++++++++++++++++++ app/game-engine/src/turn/reservedTurnStore.ts | 330 ++++++++ app/game-engine/src/turn/turnDaemon.ts | 49 +- packages/infra/prisma/schema.prisma | 25 + 13 files changed, 1949 insertions(+), 15 deletions(-) create mode 100644 app/game-api/src/turns/reservedTurns.ts create mode 100644 app/game-api/test/reservedTurns.test.ts create mode 100644 app/game-engine/src/turn/reservedTurnHandler.ts create mode 100644 app/game-engine/src/turn/reservedTurnStore.ts diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 1f42477..956f474 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -48,6 +48,23 @@ export interface GeneralRow { meta: unknown; } +export interface GeneralTurnRow { + id: number; + generalId: number; + turnIdx: number; + actionCode: string; + arg: unknown; +} + +export interface NationTurnRow { + id: number; + nationId: number; + officerLevel: number; + turnIdx: number; + actionCode: string; + arg: unknown; +} + export interface CityRow { id: number; name: string; @@ -95,6 +112,39 @@ export interface DatabaseClient { nation: { findUnique(args: { where: { id: number } }): Promise; }; + generalTurn: { + findMany(args: { + where: { generalId: number }; + orderBy?: { turnIdx: 'asc' | 'desc' }[]; + }): Promise; + deleteMany(args: { where: { generalId: number } }): Promise; + createMany(args: { + data: Array<{ + generalId: number; + turnIdx: number; + actionCode: string; + arg: unknown; + }>; + }): Promise; + }; + nationTurn: { + findMany(args: { + where: { nationId: number; officerLevel: number }; + orderBy?: { turnIdx: 'asc' | 'desc' }[]; + }): Promise; + deleteMany(args: { + where: { nationId: number; officerLevel: number }; + }): Promise; + createMany(args: { + data: Array<{ + nationId: number; + officerLevel: number; + turnIdx: number; + actionCode: string; + arg: unknown; + }>; + }): Promise; + }; } export interface GameApiContext { diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 66b8e49..091579d 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -4,6 +4,14 @@ import { z } from 'zod'; import type { WorldStateRow } from './context.js'; import { authedProcedure, procedure, router } from './trpc.js'; import { buildTurnCommandTable } from './turns/commandTable.js'; +import { + MAX_GENERAL_TURNS, + MAX_NATION_TURNS, + setGeneralTurn, + setNationTurn, + shiftGeneralTurns, + shiftNationTurns, +} from './turns/reservedTurns.js'; const zRunReason = z.enum(['schedule', 'manual', 'poke']); @@ -13,6 +21,15 @@ const zTurnRunBudget = z.object({ catchUpCap: z.number().int().positive(), }); +const buildShiftAmountSchema = (maxTurns: number) => + z.number() + .int() + .min(-(maxTurns - 1)) + .max(maxTurns - 1) + .refine((value) => value !== 0, { + message: 'Amount must be non-zero.', + }); + const toWorldStateSnapshot = (row: WorldStateRow) => ({ scenarioCode: row.scenarioCode, currentYear: row.currentYear, @@ -84,6 +101,148 @@ export const appRouter = router({ nation, }); }), + reserved: router({ + setGeneral: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + turnIndex: z.number() + .int() + .min(0) + .max(MAX_GENERAL_TURNS - 1), + action: z.string().min(1), + args: z.unknown().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + + const turns = await setGeneralTurn( + ctx.db, + input.generalId, + input.turnIndex, + input.action, + input.args + ); + return { ok: true, turns }; + }), + shiftGeneral: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + amount: buildShiftAmountSchema(MAX_GENERAL_TURNS), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + + const turns = await shiftGeneralTurns( + ctx.db, + input.generalId, + input.amount + ); + return { ok: true, turns }; + }), + setNation: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + turnIndex: z.number() + .int() + .min(0) + .max(MAX_NATION_TURNS - 1), + action: z.string().min(1), + args: z.unknown().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + if (general.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'General is not part of a nation.', + }); + } + if (general.officerLevel < 5) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'General is not an officer.', + }); + } + + const turns = await setNationTurn( + ctx.db, + general.nationId, + general.officerLevel, + input.turnIndex, + input.action, + input.args + ); + return { ok: true, turns }; + }), + shiftNation: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + amount: buildShiftAmountSchema(MAX_NATION_TURNS), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + if (general.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'General is not part of a nation.', + }); + } + if (general.officerLevel < 5) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'General is not an officer.', + }); + } + + const turns = await shiftNationTurns( + ctx.db, + general.nationId, + general.officerLevel, + input.amount + ); + return { ok: true, turns }; + }), + }), }), turnDaemon: router({ run: procedure diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index b66d996..a206321 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -9,7 +9,7 @@ import { } from '@sammo-ts/infra'; import { resolveGameApiConfigFromEnv } from './config.js'; -import { createGameApiContext } from './context.js'; +import { createGameApiContext, type DatabaseClient } from './context.js'; import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js'; import { RedisTurnDaemonTransport } from './daemon/redisTransport.js'; import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js'; @@ -75,7 +75,7 @@ export const createGameApiServer = async () => { const token = extractBearerToken(req.headers.authorization); const auth = token ? tokenVerifier.verify(token) : null; return createGameApiContext({ - db: postgres.prisma, + db: postgres.prisma as unknown as DatabaseClient, turnDaemon, profile: { id: config.profile, diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts new file mode 100644 index 0000000..5d15b59 --- /dev/null +++ b/app/game-api/src/turns/reservedTurns.ts @@ -0,0 +1,192 @@ +import type { + DatabaseClient, + GeneralTurnRow, + NationTurnRow, +} from '../context.js'; + +export const DEFAULT_TURN_ACTION = '휴식'; +export const MAX_GENERAL_TURNS = 30; +export const MAX_NATION_TURNS = 12; + +export interface ReservedTurnEntry { + action: string; + args: Record; +} + +export interface ReservedTurnView { + index: number; + action: string; + args: Record; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const normalizeAction = (action: string | null | undefined): string => + action && action.length > 0 ? action : DEFAULT_TURN_ACTION; + +const normalizeArgs = (args: unknown): Record => + isRecord(args) ? args : {}; + +const createDefaultEntry = (): ReservedTurnEntry => ({ + action: DEFAULT_TURN_ACTION, + args: {}, +}); + +const buildDefaultTurns = (length: number): ReservedTurnEntry[] => + Array.from({ length }, () => createDefaultEntry()); + +const applyShift = ( + turns: ReservedTurnEntry[], + amount: number +): ReservedTurnEntry[] => { + if (amount === 0) { + return turns.slice(); + } + if (amount > 0) { + const padding = Array.from({ length: amount }, () => createDefaultEntry()); + const sliced = turns.slice(0, Math.max(0, turns.length - amount)); + return padding.concat(sliced); + } + const shift = Math.min(turns.length, Math.abs(amount)); + const padding = Array.from({ length: shift }, () => createDefaultEntry()); + const sliced = turns.slice(shift); + return sliced.concat(padding); +}; + +const buildTurnListFromRows = ( + rows: Array, + maxTurns: number +): ReservedTurnEntry[] => { + const result = buildDefaultTurns(maxTurns); + for (const row of rows) { + if (row.turnIdx < 0 || row.turnIdx >= maxTurns) { + continue; + } + result[row.turnIdx] = { + action: normalizeAction(row.actionCode), + args: normalizeArgs(row.arg), + }; + } + return result; +}; + +const serializeTurnList = (turns: ReservedTurnEntry[]): ReservedTurnView[] => + turns.map((entry, index) => ({ + index, + action: entry.action, + args: entry.args, + })); + +const persistGeneralTurns = async ( + db: DatabaseClient, + generalId: number, + turns: ReservedTurnEntry[] +): Promise => { + await db.generalTurn.deleteMany({ where: { generalId } }); + await db.generalTurn.createMany({ + data: turns.map((entry, turnIdx) => ({ + generalId, + turnIdx, + actionCode: normalizeAction(entry.action), + arg: normalizeArgs(entry.args), + })), + }); +}; + +const persistNationTurns = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + turns: ReservedTurnEntry[] +): Promise => { + await db.nationTurn.deleteMany({ where: { nationId, officerLevel } }); + await db.nationTurn.createMany({ + data: turns.map((entry, turnIdx) => ({ + nationId, + officerLevel, + turnIdx, + actionCode: normalizeAction(entry.action), + arg: normalizeArgs(entry.args), + })), + }); +}; + +export const loadGeneralTurns = async ( + db: DatabaseClient, + generalId: number +): Promise => { + const rows = await db.generalTurn.findMany({ + where: { generalId }, + orderBy: [{ turnIdx: 'asc' }], + }); + return buildTurnListFromRows(rows, MAX_GENERAL_TURNS); +}; + +export const loadNationTurns = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number +): Promise => { + const rows = await db.nationTurn.findMany({ + where: { nationId, officerLevel }, + orderBy: [{ turnIdx: 'asc' }], + }); + return buildTurnListFromRows(rows, MAX_NATION_TURNS); +}; + +export const setGeneralTurn = async ( + db: DatabaseClient, + generalId: number, + turnIndex: number, + action: string, + args: unknown +): Promise => { + const turns = await loadGeneralTurns(db, generalId); + turns[turnIndex] = { + action: normalizeAction(action), + args: normalizeArgs(args), + }; + await persistGeneralTurns(db, generalId, turns); + return serializeTurnList(turns); +}; + +export const shiftGeneralTurns = async ( + db: DatabaseClient, + generalId: number, + amount: number +): Promise => { + const turns = await loadGeneralTurns(db, generalId); + const shifted = applyShift(turns, amount); + await persistGeneralTurns(db, generalId, shifted); + return serializeTurnList(shifted); +}; + +export const setNationTurn = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + turnIndex: number, + action: string, + args: unknown +): Promise => { + const turns = await loadNationTurns(db, nationId, officerLevel); + turns[turnIndex] = { + action: normalizeAction(action), + args: normalizeArgs(args), + }; + await persistNationTurns(db, nationId, officerLevel, turns); + return serializeTurnList(turns); +}; + +export const shiftNationTurns = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + amount: number +): Promise => { + const turns = await loadNationTurns(db, nationId, officerLevel); + const shifted = applyShift(turns, amount); + await persistNationTurns(db, nationId, officerLevel, shifted); + return serializeTurnList(shifted); +}; diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts new file mode 100644 index 0000000..0353b33 --- /dev/null +++ b/app/game-api/test/reservedTurns.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import type { + DatabaseClient, + GeneralTurnRow, + NationTurnRow, +} from '../src/context.js'; +import { + MAX_GENERAL_TURNS, + MAX_NATION_TURNS, + setGeneralTurn, + setNationTurn, + shiftGeneralTurns, + shiftNationTurns, +} from '../src/turns/reservedTurns.js'; + +const buildDb = () => { + const generalTurns = new Map(); + const nationTurns = new Map(); + + const db: DatabaseClient = { + worldState: { + findFirst: async () => null, + }, + general: { + findUnique: async () => null, + }, + city: { + findUnique: async () => null, + }, + nation: { + findUnique: async () => null, + }, + generalTurn: { + findMany: async ({ where }) => generalTurns.get(where.generalId) ?? [], + deleteMany: async ({ where }) => { + generalTurns.delete(where.generalId); + return {}; + }, + createMany: async ({ data }) => { + const rows = data.map((row, index) => ({ + id: index + 1, + generalId: row.generalId, + turnIdx: row.turnIdx, + actionCode: row.actionCode, + arg: row.arg, + })); + const generalId = data[0]?.generalId; + if (generalId !== undefined) { + generalTurns.set(generalId, rows); + } + return {}; + }, + }, + nationTurn: { + findMany: async ({ where }) => + nationTurns.get(`${where.nationId}:${where.officerLevel}`) ?? [], + deleteMany: async ({ where }) => { + nationTurns.delete(`${where.nationId}:${where.officerLevel}`); + return {}; + }, + createMany: async ({ data }) => { + const rows = data.map((row, index) => ({ + id: index + 1, + nationId: row.nationId, + officerLevel: row.officerLevel, + turnIdx: row.turnIdx, + actionCode: row.actionCode, + arg: row.arg, + })); + const nationId = data[0]?.nationId; + const officerLevel = data[0]?.officerLevel; + if (nationId !== undefined && officerLevel !== undefined) { + nationTurns.set(`${nationId}:${officerLevel}`, rows); + } + return {}; + }, + }, + }; + + return { db }; +}; + +describe('reservedTurns', () => { + it('sets and shifts general turns', async () => { + const { db } = buildDb(); + + const initial = await setGeneralTurn( + db, + 1, + 0, + 'che_화계', + { destCityId: 10 } + ); + + expect(initial).toHaveLength(MAX_GENERAL_TURNS); + expect(initial[0]?.action).toBe('che_화계'); + + const pushed = await shiftGeneralTurns(db, 1, 1); + expect(pushed[0]?.action).toBe('휴식'); + expect(pushed[1]?.action).toBe('che_화계'); + + const pulled = await shiftGeneralTurns(db, 1, -1); + expect(pulled[0]?.action).toBe('che_화계'); + expect(pulled[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식'); + }); + + it('sets and shifts nation turns', async () => { + const { db } = buildDb(); + + const initial = await setNationTurn( + db, + 2, + 5, + 0, + 'che_포상', + { isGold: true, amount: 200, destGeneralId: 7 } + ); + + expect(initial).toHaveLength(MAX_NATION_TURNS); + expect(initial[0]?.action).toBe('che_포상'); + + const pushed = await shiftNationTurns(db, 2, 5, 1); + expect(pushed[0]?.action).toBe('휴식'); + expect(pushed[1]?.action).toBe('che_포상'); + }); +}); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 8cd39db..934d511 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -28,6 +28,16 @@ const buildContext = (options?: { nation: { findUnique: async () => null, }, + generalTurn: { + findMany: async () => [], + deleteMany: async () => ({}), + createMany: async () => ({}), + }, + nationTurn: { + findMany: async () => [], + deleteMany: async () => ({}), + createMany: async () => ({}), + }, }; return { db, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 8878927..9bd7be4 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -5,6 +5,7 @@ import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic'; import type { TurnDaemonHooks } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -50,6 +51,40 @@ const buildGeneralUpdate = ( recentWarTime: general.recentWarTime ?? null, }); +const buildGeneralCreate = ( + general: ReturnType['generals'][number] +): Prisma.GeneralCreateManyInput => ({ + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: general.experience, + dedication: general.dedication, + officerLevel: general.officerLevel, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + age: general.age, + horseCode: toCode(general.role.items.horse), + weaponCode: toCode(general.role.items.weapon), + bookCode: toCode(general.role.items.book), + itemCode: toCode(general.role.items.item), + personalCode: toCode(general.role.personality), + specialCode: toCode(general.role.specialDomestic), + special2Code: toCode(general.role.specialWar), + meta: asJson(general.meta), + turnTime: general.turnTime, + recentWarTime: general.recentWarTime ?? null, +}); + const buildCityUpdate = ( city: ReturnType['cities'][number] ): Prisma.CityUpdateInput => ({ @@ -116,7 +151,8 @@ const buildLogCreateData = ( export const createDatabaseTurnHooks = async ( databaseUrl: string, - world: InMemoryTurnWorld + world: InMemoryTurnWorld, + options?: { reservedTurns?: InMemoryReservedTurnStore } ): Promise => { // 턴 처리 결과를 DB에 반영하는 훅을 만든다. const connector = createPostgresConnector({ url: databaseUrl }); @@ -125,7 +161,8 @@ export const createDatabaseTurnHooks = async ( const hooks: TurnDaemonHooks = { flushChanges: async () => { const state = world.getState(); - const { generals, cities, nations, logs } = world.consumeDirtyState(); + const { generals, cities, nations, logs, createdGenerals } = + world.consumeDirtyState(); await connector.prisma.worldState.update({ where: { id: state.id }, @@ -137,13 +174,25 @@ export const createDatabaseTurnHooks = async ( }, }); + const createdIds = new Set( + createdGenerals.map((general) => general.id) + ); + + if (createdGenerals.length > 0) { + await connector.prisma.general.createMany({ + data: createdGenerals.map(buildGeneralCreate), + }); + } + await Promise.all([ - ...generals.map((general) => - connector.prisma.general.update({ - where: { id: general.id }, - data: buildGeneralUpdate(general), - }) - ), + ...generals + .filter((general) => !createdIds.has(general.id)) + .map((general) => + connector.prisma.general.update({ + where: { id: general.id }, + data: buildGeneralUpdate(general), + }) + ), ...cities.map((city) => connector.prisma.city.update({ where: { id: city.id }, @@ -176,6 +225,9 @@ export const createDatabaseTurnHooks = async ( }); } } + if (options?.reservedTurns) { + await options.reservedTurns.flushChanges(); + } }, }; diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index d625577..2140a33 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -6,9 +6,11 @@ import type { } from '../lifecycle/types.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { TurnGeneral } from './types.js'; export interface InMemoryTurnProcessorOptions { tickMinutes?: number; + beforeExecuteGeneral?: (general: TurnGeneral) => Promise; } const resolveTickMinutes = ( @@ -26,10 +28,14 @@ export class InMemoryTurnProcessor implements TurnProcessor { // 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다. private readonly world: InMemoryTurnWorld; private readonly tickMinutes: number; + private readonly beforeExecuteGeneral?: ( + general: TurnGeneral + ) => Promise; constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) { this.world = world; this.tickMinutes = resolveTickMinutes(world, options.tickMinutes); + this.beforeExecuteGeneral = options.beforeExecuteGeneral; } async run( @@ -57,6 +63,9 @@ export class InMemoryTurnProcessor implements TurnProcessor { break; } const executedAt = new Date(general.turnTime.getTime()); + if (this.beforeExecuteGeneral) { + await this.beforeExecuteGeneral(general); + } this.world.executeGeneralTurn(general); processedGenerals += 1; nextCheckpoint = { diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index b3214ce..78dfc0d 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -18,6 +18,14 @@ export interface GeneralTurnResult { nation?: Nation | null; nextTurnAt?: Date; logs?: LogEntryDraft[]; + patches?: { + generals: Array<{ id: number; patch: Partial }>; + cities: Array<{ id: number; patch: Partial }>; + nations: Array<{ id: number; patch: Partial }>; + }; + created?: { + generals: TurnGeneral[]; + }; } export interface GeneralTurnHandler { @@ -74,6 +82,65 @@ const shouldProcessByCheckpoint = ( return general.id > checkpoint.generalId; }; +const mergeStats = ( + base: TurnGeneral['stats'], + patch: Partial +): TurnGeneral['stats'] => ({ + leadership: patch.leadership ?? base.leadership, + strength: patch.strength ?? base.strength, + intelligence: patch.intelligence ?? base.intelligence, +}); + +const mergeRole = ( + base: TurnGeneral['role'], + patch: Partial +): TurnGeneral['role'] => ({ + ...base, + ...patch, + items: { + ...base.items, + ...(patch.items ?? {}), + }, +}); + +const mergeTriggerState = ( + base: TurnGeneral['triggerState'], + patch: Partial +): TurnGeneral['triggerState'] => ({ + ...base, + ...patch, + flags: { ...base.flags, ...(patch.flags ?? {}) }, + counters: { ...base.counters, ...(patch.counters ?? {}) }, + modifiers: { ...base.modifiers, ...(patch.modifiers ?? {}) }, + meta: { ...base.meta, ...(patch.meta ?? {}) }, +}); + +const applyGeneralPatch = ( + base: TurnGeneral, + patch: Partial +): TurnGeneral => ({ + ...base, + ...patch, + stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats, + role: patch.role ? mergeRole(base.role, patch.role) : base.role, + triggerState: patch.triggerState + ? mergeTriggerState(base.triggerState, patch.triggerState) + : base.triggerState, + meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, +}); + +const applyCityPatch = (base: City, patch: Partial): City => ({ + ...base, + ...patch, + meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, +}); + +const applyNationPatch = (base: Nation, patch: Partial): Nation => ({ + ...base, + ...patch, + meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, +}); + export class InMemoryTurnWorld { // DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다. private readonly schedule: TurnSchedule; @@ -85,6 +152,7 @@ export class InMemoryTurnWorld { private readonly dirtyGeneralIds = new Set(); private readonly dirtyCityIds = new Set(); private readonly dirtyNationIds = new Set(); + private readonly createdGeneralIds = new Set(); private readonly logs: LogEntryDraft[] = []; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -118,6 +186,34 @@ export class InMemoryTurnWorld { return { ...this.state }; } + getGeneralById(id: number): TurnGeneral | null { + return this.generals.get(id) ?? null; + } + + getCityById(id: number): City | null { + return this.cities.get(id) ?? null; + } + + getNationById(id: number): Nation | null { + return this.nations.get(id) ?? null; + } + + listGenerals(): TurnGeneral[] { + return Array.from(this.generals.values()).map((general) => ({ + ...general, + })); + } + + listCities(): City[] { + return Array.from(this.cities.values()).map((city) => ({ ...city })); + } + + listNations(): Nation[] { + return Array.from(this.nations.values()).map((nation) => ({ + ...nation, + })); + } + setLastTurnTime(turnTime: Date): void { const meta = { ...this.state.meta, @@ -199,6 +295,48 @@ export class InMemoryTurnWorld { if (result.logs && result.logs.length > 0) { this.logs.push(...result.logs); } + if (result.patches) { + for (const patch of result.patches.generals) { + const target = this.generals.get(patch.id); + if (!target) { + continue; + } + this.generals.set( + patch.id, + applyGeneralPatch(target, patch.patch) + ); + this.dirtyGeneralIds.add(patch.id); + } + for (const patch of result.patches.cities) { + const target = this.cities.get(patch.id); + if (!target) { + continue; + } + this.cities.set(patch.id, applyCityPatch(target, patch.patch)); + this.dirtyCityIds.add(patch.id); + } + for (const patch of result.patches.nations) { + const target = this.nations.get(patch.id); + if (!target) { + continue; + } + this.nations.set( + patch.id, + applyNationPatch(target, patch.patch) + ); + this.dirtyNationIds.add(patch.id); + } + } + if (result.created) { + for (const createdGeneral of result.created.generals) { + if (this.generals.has(createdGeneral.id)) { + continue; + } + this.generals.set(createdGeneral.id, { ...createdGeneral }); + this.dirtyGeneralIds.add(createdGeneral.id); + this.createdGeneralIds.add(createdGeneral.id); + } + } return nextTurnAt; } @@ -243,10 +381,14 @@ export class InMemoryTurnWorld { cities: City[]; nations: Nation[]; logs: LogEntryDraft[]; + createdGenerals: TurnGeneral[]; } { const generals = Array.from(this.dirtyGeneralIds) .map((id) => this.generals.get(id)) .filter((general): general is TurnGeneral => Boolean(general)); + const createdGenerals = Array.from(this.createdGeneralIds) + .map((id) => this.generals.get(id)) + .filter((general): general is TurnGeneral => Boolean(general)); const cities = Array.from(this.dirtyCityIds) .map((id) => this.cities.get(id)) .filter((city): city is City => Boolean(city)); @@ -258,7 +400,8 @@ export class InMemoryTurnWorld { this.dirtyGeneralIds.clear(); this.dirtyCityIds.clear(); this.dirtyNationIds.clear(); + this.createdGeneralIds.clear(); - return { generals, cities, nations, logs }; + return { generals, cities, nations, logs, createdGenerals }; } } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts new file mode 100644 index 0000000..7a2c7c2 --- /dev/null +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -0,0 +1,796 @@ +import type { + City, + General, + GeneralActionDefinition, + GeneralActionResolveContext, + LogEntryDraft, + Nation, + ScenarioConfig, + ScenarioDiplomacy, + ScenarioMeta, +} from '@sammo-ts/logic'; +import { + AssignmentActionDefinition, + AwardActionDefinition, + CommerceInvestmentActionDefinition, + evaluateConstraints, + FireAttackActionDefinition, + NationRestActionDefinition, + resolveGeneralAction, + RestActionDefinition, + TalentScoutActionDefinition, + VolunteerRecruitActionDefinition, +} from '@sammo-ts/logic'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; +import { LiteHashDRBG } from '@sammo-ts/common'; + +import type { ConstraintContext, StateView } from '@sammo-ts/logic'; + +import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldState } from './types.js'; +import type { ReservedTurnEntry } from './reservedTurnStore.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; + +interface CommandEnv { + develCost: number; + sabotageDefaultProb: number; + sabotageProbCoefByStat: number; + sabotageDefenceCoefByGeneralCount: number; + sabotageDamageMin: number; + sabotageDamageMax: number; + openingPartYear: number; + maxGeneral: number; + defaultNpcGold: number; + defaultNpcRice: number; + defaultCrewTypeId: number; + defaultSpecialDomestic: string | null; + defaultSpecialWar: string | null; + initialNationGenLimit: number; + baseGold: number; + baseRice: number; + maxResourceActionAmount: number; +} + +interface WorldSummary { + totalGeneralCount: number; + totalNpcCount: number; + averageStats?: General['stats']; +} + +interface NationSummary { + averageStats?: General['stats']; + averageExperience?: number; + averageDedication?: number; +} + +const DEFAULT_GENERAL_GOLD = 1000; +const DEFAULT_GENERAL_RICE = 1000; +const DEFAULT_CREW_TYPE_ID = 1100; +const DEFAULT_ACTION = '휴식'; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const asRecord = (value: unknown): Record => + isRecord(value) ? value : {}; + +const normalizeCode = (value: string | null | undefined): string | null => { + if (!value || value === 'None') { + return null; + } + return value; +}; + +const resolveNumber = ( + source: Record, + keys: string[], + fallback: number +): number => { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return fallback; +}; + +const resolveOptionalString = ( + source: Record, + keys: string[] +): string | null => { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'string') { + return normalizeCode(value); + } + } + return null; +}; + +const buildCommandEnv = (config: ScenarioConfig): CommandEnv => { + const constValues = asRecord(config.const); + + return { + develCost: resolveNumber( + constValues, + ['develCost', 'develcost', 'develrate'], + 0 + ), + sabotageDefaultProb: resolveNumber( + constValues, + ['sabotageDefaultProb'], + 0 + ), + sabotageProbCoefByStat: resolveNumber( + constValues, + ['sabotageProbCoefByStat'], + 0 + ), + sabotageDefenceCoefByGeneralCount: resolveNumber( + constValues, + ['sabotageDefenceCoefByGeneralCount'], + 0 + ), + sabotageDamageMin: resolveNumber( + constValues, + ['sabotageDamageMin'], + 0 + ), + sabotageDamageMax: resolveNumber( + constValues, + ['sabotageDamageMax'], + 0 + ), + openingPartYear: resolveNumber( + constValues, + ['openingPartYear'], + 0 + ), + maxGeneral: resolveNumber( + constValues, + ['defaultMaxGeneral', 'maxGeneral'], + 0 + ), + defaultNpcGold: resolveNumber( + constValues, + ['defaultNpcGold', 'defaultGold'], + DEFAULT_GENERAL_GOLD + ), + defaultNpcRice: resolveNumber( + constValues, + ['defaultNpcRice', 'defaultRice'], + DEFAULT_GENERAL_RICE + ), + defaultCrewTypeId: resolveNumber( + constValues, + ['defaultCrewTypeId'], + DEFAULT_CREW_TYPE_ID + ), + defaultSpecialDomestic: resolveOptionalString( + constValues, + ['defaultSpecialDomestic'] + ), + defaultSpecialWar: resolveOptionalString( + constValues, + ['defaultSpecialWar'] + ), + initialNationGenLimit: resolveNumber( + constValues, + ['initialNationGenLimit'], + 0 + ), + baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), + baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), + maxResourceActionAmount: resolveNumber( + constValues, + ['maxResourceActionAmount'], + 0 + ), + }; +}; + +const resolveConstraintEnv = ( + world: TurnWorldState, + scenarioMeta?: ScenarioMeta +): Record => { + const startYear = + typeof scenarioMeta?.startYear === 'number' + ? scenarioMeta.startYear + : undefined; + const relYear = + typeof startYear === 'number' + ? world.currentYear - startYear + : undefined; + + return { + currentYear: world.currentYear, + currentMonth: world.currentMonth, + year: world.currentYear, + month: world.currentMonth, + startYear, + relYear, + }; +}; + +const buildDiplomacyMap = ( + diplomacy: ScenarioDiplomacy[] +): Map => { + const map = new Map(); + for (const row of diplomacy) { + map.set(`${row.fromNationId}:${row.toNationId}`, row.state); + } + return map; +}; + +const buildWorldSummary = ( + world: InMemoryTurnWorld | null +): WorldSummary => { + if (!world) { + return { totalGeneralCount: 0, totalNpcCount: 0 }; + } + const generals = world.listGenerals(); + if (generals.length === 0) { + return { totalGeneralCount: 0, totalNpcCount: 0 }; + } + const total = generals.length; + const npcCount = generals.filter((general) => general.npcState > 0).length; + const statSum = generals.reduce( + (acc, general) => ({ + leadership: acc.leadership + general.stats.leadership, + strength: acc.strength + general.stats.strength, + intelligence: acc.intelligence + general.stats.intelligence, + }), + { leadership: 0, strength: 0, intelligence: 0 } + ); + return { + totalGeneralCount: total, + totalNpcCount: npcCount, + averageStats: { + leadership: statSum.leadership / total, + strength: statSum.strength / total, + intelligence: statSum.intelligence / total, + }, + }; +}; + +const buildNationSummary = ( + world: InMemoryTurnWorld | null, + nationId: number +): NationSummary => { + if (!world || nationId <= 0) { + return {}; + } + const generals = world.listGenerals().filter( + (general) => general.nationId === nationId + ); + if (generals.length === 0) { + return {}; + } + const total = generals.length; + const statSum = generals.reduce( + (acc, general) => ({ + leadership: acc.leadership + general.stats.leadership, + strength: acc.strength + general.stats.strength, + intelligence: acc.intelligence + general.stats.intelligence, + }), + { leadership: 0, strength: 0, intelligence: 0 } + ); + const expSum = generals.reduce((acc, general) => acc + general.experience, 0); + const dedSum = generals.reduce((acc, general) => acc + general.dedication, 0); + return { + averageStats: { + leadership: statSum.leadership / total, + strength: statSum.strength / total, + intelligence: statSum.intelligence / total, + }, + averageExperience: expSum / total, + averageDedication: dedSum / total, + }; +}; + +const buildAverageNationGeneralCount = (world: InMemoryTurnWorld | null): number => { + if (!world) { + return 0; + } + const generals = world.listGenerals(); + const nations = world.listNations(); + if (nations.length === 0) { + return generals.length; + } + return generals.length / nations.length; +}; + +const resolveStartYear = ( + world: TurnWorldState, + scenarioMeta?: ScenarioMeta +): number => { + if (typeof scenarioMeta?.startYear === 'number') { + return scenarioMeta.startYear; + } + return world.currentYear; +}; + +const buildSeedBase = (world: TurnWorldState): string => { + const meta = asRecord(world.meta); + const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id; + return String(rawSeed); +}; + +const serializeSeed = (...values: Array): string => + values + .map((value) => + typeof value === 'string' + ? `str(${value.length},${value})` + : `int(${Math.floor(value)})` + ) + .join('|'); + +class DeterministicRandom { + constructor(private readonly rng: LiteHashDRBG) {} + + nextFloat(): number { + return this.rng.nextFloat1(); + } + + nextBool(probability: number): boolean { + if (probability >= 1) { + return true; + } + if (probability <= 0) { + return false; + } + return this.nextFloat() < probability; + } + + nextInt(minInclusive: number, maxExclusive: number): number { + const span = maxExclusive - minInclusive; + if (span <= 1) { + return minInclusive; + } + return minInclusive + this.rng.nextInt(span - 1); + } +} + +class WorldStateView implements StateView { + constructor( + private readonly world: InMemoryTurnWorld | null, + private readonly diplomacy: Map, + private readonly env: Record, + private readonly args: Record, + private readonly overrides?: { + general?: TurnGeneral; + city?: City; + nation?: Nation | null; + } + ) {} + + has(req: Parameters[0]): boolean { + return this.get(req) !== null; + } + + get(req: Parameters[0]): unknown | null { + if (!this.world) { + return null; + } + switch (req.kind) { + case 'general': + if (this.overrides?.general && this.overrides.general.id === req.id) { + return this.overrides.general; + } + return this.world.getGeneralById(req.id); + case 'destGeneral': + return this.world.getGeneralById(req.id); + case 'city': + if (this.overrides?.city && this.overrides.city.id === req.id) { + return this.overrides.city; + } + return this.world.getCityById(req.id); + case 'destCity': + return this.world.getCityById(req.id); + case 'nation': + if (this.overrides?.nation && this.overrides.nation.id === req.id) { + return this.overrides.nation; + } + return this.world.getNationById(req.id); + case 'destNation': + return this.world.getNationById(req.id); + case 'diplomacy': + return this.diplomacy.get( + `${req.srcNationId}:${req.destNationId}` + ) ?? null; + case 'arg': + return this.args[req.key] ?? null; + case 'env': + return this.env[req.key] ?? null; + default: + return null; + } + } +} + +const buildGeneralDefinitions = ( + env: CommandEnv +): Map => { + const definitions = new Map(); + definitions.set( + 'che_상업투자', + new CommerceInvestmentActionDefinition([], env) + ); + definitions.set('che_화계', new FireAttackActionDefinition([], env)); + definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env)); + definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env)); + definitions.set('휴식', new RestActionDefinition()); + return definitions; +}; + +const buildNationDefinitions = ( + env: CommandEnv +): Map => { + const definitions = new Map(); + const maxAmount = + env.maxResourceActionAmount > 0 + ? env.maxResourceActionAmount + : Math.max(env.baseGold, env.baseRice, 1000); + definitions.set('휴식', new NationRestActionDefinition()); + definitions.set( + 'che_포상', + new AwardActionDefinition({ + baseGold: env.baseGold, + baseRice: env.baseRice, + maxAmount, + }) + ); + definitions.set('che_발령', new AssignmentActionDefinition({})); + return definitions; +}; + +const extractArgsRecord = (value: unknown): Record => + isRecord(value) ? value : {}; + +const resolveTurnTermMinutes = (world: TurnWorldState): number => + Math.max(1, Math.round(world.tickSeconds / 60)); + +type ActionContextBase = { + general: TurnGeneral; + city?: City; + nation?: Nation | null; + rng: DeterministicRandom; +}; + +type ActionResolveContext = ActionContextBase & Record; + +const buildActionContext = ( + key: string, + base: ActionContextBase, + options: { + world: TurnWorldState; + scenarioMeta?: ScenarioMeta; + worldRef: InMemoryTurnWorld | null; + actionArgs: Record; + createGeneralId: () => number; + } +): ActionResolveContext | null => { + switch (key) { + case 'che_인재탐색': + return { + ...base, + currentYear: options.world.currentYear, + worldSummary: buildWorldSummary(options.worldRef), + createGeneralId: options.createGeneralId, + }; + case 'che_의병모집': { + const nationSummary = buildNationSummary( + options.worldRef, + (base.general as TurnGeneral).nationId + ); + return { + ...base, + currentYear: options.world.currentYear, + startYear: resolveStartYear(options.world, options.scenarioMeta), + averageNationGeneralCount: buildAverageNationGeneralCount( + options.worldRef + ), + nationAverageStats: nationSummary.averageStats, + nationAverageExperience: nationSummary.averageExperience, + nationAverageDedication: nationSummary.averageDedication, + createGeneralId: options.createGeneralId, + }; + } + case 'che_포상': { + const destGeneralId = options.actionArgs.destGeneralId; + if (typeof destGeneralId !== 'number') { + return null; + } + const destGeneral = options.worldRef?.getGeneralById(destGeneralId); + if (!destGeneral) { + return null; + } + return { + ...base, + destGeneral, + }; + } + case 'che_발령': { + const destGeneralId = options.actionArgs.destGeneralId; + const destCityId = options.actionArgs.destCityId; + if (typeof destGeneralId !== 'number' || typeof destCityId !== 'number') { + return null; + } + const destGeneral = options.worldRef?.getGeneralById(destGeneralId); + const destCity = options.worldRef?.getCityById(destCityId); + if (!destGeneral || !destCity) { + return null; + } + return { + ...base, + destGeneral, + destCity, + currentYear: options.world.currentYear, + currentMonth: options.world.currentMonth, + turnTermMinutes: resolveTurnTermMinutes(options.world), + generalTurnTime: (base.general as TurnGeneral).turnTime, + destGeneralTurnTime: destGeneral.turnTime, + }; + } + default: + return base; + } +}; + +const buildConstraintContext = ( + general: TurnGeneral, + city: City | undefined, + nation: Nation | null | undefined, + args: Record, + env: Record +): ConstraintContext => ({ + actorId: general.id, + cityId: city?.id, + nationId: nation?.id, + args, + env, + mode: 'full', +}); + +const createActionLog = (message: string): LogEntryDraft => ({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + text: message, +}); + +const resolveDefinition = ( + actionKey: string, + definitions: Map, + fallback: GeneralActionDefinition +): GeneralActionDefinition => definitions.get(actionKey) ?? fallback; + +export const createReservedTurnHandler = (options: { + reservedTurns: InMemoryReservedTurnStore; + scenarioConfig: ScenarioConfig; + scenarioMeta?: ScenarioMeta; + diplomacy: ScenarioDiplomacy[]; + getWorld: () => InMemoryTurnWorld | null; +}): GeneralTurnHandler => { + const env = buildCommandEnv(options.scenarioConfig); + const generalDefinitions = buildGeneralDefinitions(env); + const nationDefinitions = buildNationDefinitions(env); + const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!; + const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!; + const diplomacyMap = buildDiplomacyMap(options.diplomacy); + + let nextGeneralId: number | null = null; + const createGeneralId = (): number => { + if (nextGeneralId === null) { + const world = options.getWorld(); + const ids = world ? world.listGenerals().map((general) => general.id) : []; + nextGeneralId = ids.length > 0 ? Math.max(...ids) + 1 : 1; + } + const result = nextGeneralId; + nextGeneralId += 1; + return result; + }; + + return { + execute(context): GeneralTurnResult { + const worldRef = options.getWorld(); + const constraintEnv = resolveConstraintEnv( + context.world, + options.scenarioMeta + ); + const logs: LogEntryDraft[] = []; + const patches = { + generals: [] as Array<{ id: number; patch: Partial }>, + cities: [] as Array<{ id: number; patch: Partial }>, + nations: [] as Array<{ id: number; patch: Partial }>, + }; + const created: TurnGeneral[] = []; + + let currentGeneral = context.general; + let currentCity = context.city; + let currentNation = context.nation ?? null; + + const runAction = ( + definitionMap: Map, + fallbackDefinition: GeneralActionDefinition, + command: ReservedTurnEntry, + applyNextTurnAt: boolean + ): Date | undefined => { + const resolvedDefinition = resolveDefinition( + command.action, + definitionMap, + fallbackDefinition + ); + const rawArgs = extractArgsRecord(command.args); + let parsedArgs = resolvedDefinition.parseArgs(rawArgs); + let definition = resolvedDefinition; + let actionArgs = parsedArgs ?? {}; + let actionKey = definition.key; + + if (parsedArgs === null) { + definition = fallbackDefinition; + actionArgs = definition.parseArgs({}) ?? {}; + actionKey = definition.key; + logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); + } + + const constraintCtx = buildConstraintContext( + currentGeneral, + currentCity, + currentNation, + actionArgs as Record, + constraintEnv + ); + const view = new WorldStateView( + worldRef, + diplomacyMap, + constraintEnv, + actionArgs as Record, + { + general: currentGeneral, + city: currentCity, + nation: currentNation, + } + ); + const constraints = definition.buildConstraints( + constraintCtx, + actionArgs + ); + const result = evaluateConstraints(constraints, constraintCtx, view); + if (result.kind !== 'allow') { + definition = fallbackDefinition; + actionArgs = definition.parseArgs({}) ?? {}; + actionKey = definition.key; + const reason = + result.kind === 'deny' + ? result.reason + : '조건을 확인할 수 없습니다.'; + logs.push(createActionLog(reason)); + } + + const seedBase = buildSeedBase(context.world); + const buildRng = (key: string) => { + const rngSeed = serializeSeed( + seedBase, + key, + context.world.currentYear, + context.world.currentMonth, + currentGeneral.id + ); + return new DeterministicRandom(new LiteHashDRBG(rngSeed)); + }; + + const actionArgsRecord = extractArgsRecord(actionArgs); + let baseContext: ActionContextBase = { + general: currentGeneral, + city: currentCity, + nation: currentNation, + rng: buildRng(actionKey), + }; + let specificContext = buildActionContext(actionKey, baseContext, { + world: context.world, + scenarioMeta: options.scenarioMeta, + worldRef, + actionArgs: actionArgsRecord, + createGeneralId, + }); + if (!specificContext && actionKey !== fallbackDefinition.key) { + definition = fallbackDefinition; + actionArgs = definition.parseArgs({}) ?? {}; + actionKey = definition.key; + logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); + baseContext = { + general: currentGeneral, + city: currentCity, + nation: currentNation, + rng: buildRng(actionKey), + }; + specificContext = baseContext; + } + const actionContext = specificContext ?? baseContext; + + const resolution = resolveGeneralAction( + definition, + actionContext as GeneralActionResolveContext, + { + now: currentGeneral.turnTime, + schedule: context.schedule, + }, + actionArgs + ); + + currentGeneral = resolution.general as TurnGeneral; + currentCity = resolution.city ?? currentCity; + currentNation = resolution.nation ?? currentNation; + logs.push(...resolution.logs); + + if (resolution.patches) { + patches.generals.push( + ...(resolution.patches.generals as Array<{ + id: number; + patch: Partial; + }>) + ); + patches.cities.push( + ...(resolution.patches.cities as Array<{ + id: number; + patch: Partial; + }>) + ); + patches.nations.push( + ...(resolution.patches.nations as Array<{ + id: number; + patch: Partial; + }>) + ); + } + + if (resolution.created?.generals) { + created.push(...(resolution.created.generals as TurnGeneral[])); + } + + return applyNextTurnAt ? resolution.nextTurnAt : undefined; + }; + + if (currentNation && currentGeneral.officerLevel >= 5) { + const nationCommand = options.reservedTurns.getNationTurn( + currentNation.id, + currentGeneral.officerLevel, + 0 + ); + runAction(nationDefinitions, nationFallback, nationCommand, false); + options.reservedTurns.shiftNationTurns( + currentNation.id, + currentGeneral.officerLevel, + -1 + ); + } + + const generalCommand = options.reservedTurns.getGeneralTurn( + currentGeneral.id, + 0 + ); + const nextTurnAt = runAction( + generalDefinitions, + generalFallback, + generalCommand, + true + ); + options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); + + const result: GeneralTurnResult = { + general: currentGeneral, + city: currentCity, + nation: currentNation, + nextTurnAt, + logs, + patches, + created: created.length > 0 ? { generals: created } : undefined, + }; + + return result; + }, + }; +}; diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts new file mode 100644 index 0000000..71129ab --- /dev/null +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -0,0 +1,330 @@ +import type { Prisma } from '@prisma/client'; + +import { createPostgresConnector } from '@sammo-ts/infra'; + +export interface ReservedTurnEntry { + action: string; + args: Record; +} + +export interface ReservedTurnStoreOptions { + databaseUrl: string; + maxGeneralTurns?: number; + maxNationTurns?: number; +} + +export interface ReservedTurnStoreHandle { + store: InMemoryReservedTurnStore; + close(): Promise; +} + +const DEFAULT_TURN_ACTION = '휴식'; +const DEFAULT_GENERAL_TURNS = 30; +const DEFAULT_NATION_TURNS = 12; + +const asJson = (value: unknown): Prisma.InputJsonValue => + value as Prisma.InputJsonValue; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const normalizeAction = (action: string | null | undefined): string => + action && action.length > 0 ? action : DEFAULT_TURN_ACTION; + +const normalizeArgs = (args: unknown): Record => + isRecord(args) ? args : {}; + +const createDefaultEntry = (): ReservedTurnEntry => ({ + action: DEFAULT_TURN_ACTION, + args: {}, +}); + +const buildDefaultTurns = (length: number): ReservedTurnEntry[] => + Array.from({ length }, () => createDefaultEntry()); + +const applyShift = ( + turns: ReservedTurnEntry[], + amount: number +): ReservedTurnEntry[] => { + if (amount === 0) { + return turns.slice(); + } + if (amount > 0) { + const shift = Math.min(turns.length, amount); + const padding = Array.from({ length: shift }, () => createDefaultEntry()); + const sliced = turns.slice(0, Math.max(0, turns.length - shift)); + return padding.concat(sliced); + } + const shift = Math.min(turns.length, Math.abs(amount)); + const padding = Array.from({ length: shift }, () => createDefaultEntry()); + const sliced = turns.slice(shift); + return sliced.concat(padding); +}; + +const buildTurnListFromRows = ( + rows: Array<{ turnIdx: number; actionCode: string; arg: unknown }>, + maxTurns: number +): ReservedTurnEntry[] => { + const result = buildDefaultTurns(maxTurns); + for (const row of rows) { + if (row.turnIdx < 0 || row.turnIdx >= maxTurns) { + continue; + } + result[row.turnIdx] = { + action: normalizeAction(row.actionCode), + args: normalizeArgs(row.arg), + }; + } + return result; +}; + +const buildNationKey = (nationId: number, officerLevel: number): string => + `${nationId}:${officerLevel}`; + +interface PrismaReservedTurnClient { + generalTurn: { + findMany(args?: unknown): Promise< + Array<{ + generalId: number; + turnIdx: number; + actionCode: string; + arg: unknown; + }> + >; + deleteMany(args: { where: { generalId: number } }): Promise; + createMany(args: { + data: Array<{ + generalId: number; + turnIdx: number; + actionCode: string; + arg: Prisma.InputJsonValue; + }>; + }): Promise; + }; + nationTurn: { + findMany(args?: unknown): Promise< + Array<{ + nationId: number; + officerLevel: number; + turnIdx: number; + actionCode: string; + arg: unknown; + }> + >; + deleteMany(args: { + where: { nationId: number; officerLevel: number }; + }): Promise; + createMany(args: { + data: Array<{ + nationId: number; + officerLevel: number; + turnIdx: number; + actionCode: string; + arg: Prisma.InputJsonValue; + }>; + }): Promise; + }; +} + +export class InMemoryReservedTurnStore { + private readonly generalTurns = new Map(); + private readonly nationTurns = new Map(); + private readonly dirtyGeneralIds = new Set(); + private readonly dirtyNationKeys = new Set(); + private readonly maxGeneralTurns: number; + private readonly maxNationTurns: number; + + constructor( + private readonly prisma: PrismaClientOrAdapter, + options: { maxGeneralTurns: number; maxNationTurns: number } + ) { + this.maxGeneralTurns = options.maxGeneralTurns; + this.maxNationTurns = options.maxNationTurns; + } + + async loadAll(): Promise { + const [generalRows, nationRows] = await Promise.all([ + this.prisma.generalTurn.findMany(), + this.prisma.nationTurn.findMany(), + ]); + + const generalGroups = new Map(); + for (const row of generalRows) { + const list = generalGroups.get(row.generalId); + if (list) { + list.push(row); + } else { + generalGroups.set(row.generalId, [row]); + } + } + for (const [generalId, rows] of generalGroups.entries()) { + this.generalTurns.set( + generalId, + buildTurnListFromRows(rows, this.maxGeneralTurns) + ); + } + + const nationGroups = new Map(); + for (const row of nationRows) { + const key = buildNationKey(row.nationId, row.officerLevel); + const list = nationGroups.get(key); + if (list) { + list.push(row); + } else { + nationGroups.set(key, [row]); + } + } + for (const [key, rows] of nationGroups.entries()) { + this.nationTurns.set( + key, + buildTurnListFromRows(rows, this.maxNationTurns) + ); + } + } + + async refreshGeneralTurns(generalId: number): Promise { + if (this.dirtyGeneralIds.has(generalId)) { + return; + } + const rows = await this.prisma.generalTurn.findMany({ + where: { generalId }, + orderBy: [{ turnIdx: 'asc' }], + }); + this.generalTurns.set( + generalId, + buildTurnListFromRows(rows, this.maxGeneralTurns) + ); + } + + async refreshNationTurns( + nationId: number, + officerLevel: number + ): Promise { + const key = buildNationKey(nationId, officerLevel); + if (this.dirtyNationKeys.has(key)) { + return; + } + const rows = await this.prisma.nationTurn.findMany({ + where: { nationId, officerLevel }, + orderBy: [{ turnIdx: 'asc' }], + }); + this.nationTurns.set( + key, + buildTurnListFromRows(rows, this.maxNationTurns) + ); + } + + getGeneralTurns(generalId: number): ReservedTurnEntry[] { + const current = this.generalTurns.get(generalId); + if (current) { + return current; + } + const created = buildDefaultTurns(this.maxGeneralTurns); + this.generalTurns.set(generalId, created); + return created; + } + + getNationTurns( + nationId: number, + officerLevel: number + ): ReservedTurnEntry[] { + const key = buildNationKey(nationId, officerLevel); + const current = this.nationTurns.get(key); + if (current) { + return current; + } + const created = buildDefaultTurns(this.maxNationTurns); + this.nationTurns.set(key, created); + return created; + } + + getGeneralTurn(generalId: number, turnIdx: number): ReservedTurnEntry { + const list = this.getGeneralTurns(generalId); + return list[turnIdx] ?? createDefaultEntry(); + } + + getNationTurn( + nationId: number, + officerLevel: number, + turnIdx: number + ): ReservedTurnEntry { + const list = this.getNationTurns(nationId, officerLevel); + return list[turnIdx] ?? createDefaultEntry(); + } + + shiftGeneralTurns(generalId: number, amount: number): void { + const list = this.getGeneralTurns(generalId); + this.generalTurns.set(generalId, applyShift(list, amount)); + this.dirtyGeneralIds.add(generalId); + } + + shiftNationTurns(nationId: number, officerLevel: number, amount: number): void { + const key = buildNationKey(nationId, officerLevel); + const list = this.getNationTurns(nationId, officerLevel); + this.nationTurns.set(key, applyShift(list, amount)); + this.dirtyNationKeys.add(key); + } + + async flushChanges(): Promise { + const generalIds = Array.from(this.dirtyGeneralIds); + for (const generalId of generalIds) { + const turns = this.getGeneralTurns(generalId); + await this.prisma.generalTurn.deleteMany({ where: { generalId } }); + await this.prisma.generalTurn.createMany({ + data: turns.map((entry, turnIdx) => ({ + generalId, + turnIdx, + actionCode: normalizeAction(entry.action), + arg: asJson(normalizeArgs(entry.args)), + })), + }); + } + + const nationKeys = Array.from(this.dirtyNationKeys); + for (const key of nationKeys) { + const [nationIdRaw, officerLevelRaw] = key.split(':'); + const nationId = Number(nationIdRaw); + const officerLevel = Number(officerLevelRaw); + const turns = this.getNationTurns(nationId, officerLevel); + await this.prisma.nationTurn.deleteMany({ + where: { nationId, officerLevel }, + }); + await this.prisma.nationTurn.createMany({ + data: turns.map((entry, turnIdx) => ({ + nationId, + officerLevel, + turnIdx, + actionCode: normalizeAction(entry.action), + arg: asJson(normalizeArgs(entry.args)), + })), + }); + } + + this.dirtyGeneralIds.clear(); + this.dirtyNationKeys.clear(); + } +} + +type PrismaClientOrAdapter = ReturnType< + typeof createPostgresConnector +>['prisma'] & + PrismaReservedTurnClient; + +export const createReservedTurnStore = async ( + options: ReservedTurnStoreOptions +): Promise => { + const connector = createPostgresConnector({ url: options.databaseUrl }); + await connector.connect(); + const store = new InMemoryReservedTurnStore( + connector.prisma as PrismaClientOrAdapter, + { + maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS, + maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS, + } + ); + await store.loadAll(); + return { + store, + close: () => connector.disconnect(), + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index bf83eed..faf8e8f 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -20,6 +20,8 @@ import type { import { InMemoryTurnWorld } from './inMemoryWorld.js'; import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js'; import { InMemoryTurnStateStore } from './inMemoryStateStore.js'; +import { createReservedTurnHandler } from './reservedTurnHandler.js'; +import { createReservedTurnStore } from './reservedTurnStore.js'; import { loadTurnWorldFromDatabase } from './worldLoader.js'; export interface TurnDaemonRuntimeOptions { @@ -71,24 +73,63 @@ export const createTurnDaemonRuntime = async ( ? { ...state, tickSeconds: tickMinutes * 60 } : state; const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); + const reservedTurnStoreHandle = options.generalTurnHandler + ? null + : await createReservedTurnStore({ + databaseUrl: options.databaseUrl, + }); + let worldRef: InMemoryTurnWorld | null = null; const worldOptions: InMemoryTurnWorldOptions = { schedule, - generalTurnHandler: options.generalTurnHandler, + generalTurnHandler: + options.generalTurnHandler ?? + createReservedTurnHandler({ + reservedTurns: reservedTurnStoreHandle!.store, + scenarioConfig: snapshot.scenarioConfig, + scenarioMeta: snapshot.scenarioMeta, + diplomacy: snapshot.diplomacy, + getWorld: () => worldRef, + }), calendarHandler: options.calendarHandler, }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); + worldRef = world; const stateStore = new InMemoryTurnStateStore(world); - const processor = new InMemoryTurnProcessor(world, { tickMinutes }); + const processor = new InMemoryTurnProcessor(world, { + tickMinutes, + beforeExecuteGeneral: reservedTurnStoreHandle + ? async (general) => { + await reservedTurnStoreHandle.store.refreshGeneralTurns( + general.id + ); + if (general.nationId > 0 && general.officerLevel >= 5) { + await reservedTurnStoreHandle.store.refreshNationTurns( + general.nationId, + general.officerLevel + ); + } + } + : undefined, + }); const controlQueue = options.controlQueue ?? new InMemoryControlQueue(); const clock = options.clock ?? new SystemClock(); let hooks: TurnDaemonHooks | undefined; let close = async () => {}; if (options.enableDatabaseFlush ?? true) { - const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world); + const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, { + reservedTurns: reservedTurnStoreHandle?.store, + }); hooks = dbHooks.hooks; - close = dbHooks.close; + close = async () => { + await dbHooks.close(); + if (reservedTurnStoreHandle) { + await reservedTurnStoreHandle.close(); + } + }; + } else if (reservedTurnStoreHandle) { + close = async () => reservedTurnStoreHandle.close(); } const defaultBudget: TurnRunBudget = options.defaultBudget ?? { diff --git a/packages/infra/prisma/schema.prisma b/packages/infra/prisma/schema.prisma index 2a7dc9d..be163e7 100644 --- a/packages/infra/prisma/schema.prisma +++ b/packages/infra/prisma/schema.prisma @@ -147,6 +147,31 @@ model General { @@map("general") } +model GeneralTurn { + id Int @id @default(autoincrement()) + generalId Int @map("general_id") + turnIdx Int @map("turn_idx") + actionCode String @map("action_code") + arg Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") + + @@unique([generalId, turnIdx]) + @@map("general_turn") +} + +model NationTurn { + id Int @id @default(autoincrement()) + nationId Int @map("nation_id") + officerLevel Int @map("officer_level") + turnIdx Int @map("turn_idx") + actionCode String @map("action_code") + arg Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") + + @@unique([nationId, officerLevel, turnIdx]) + @@map("nation_turn") +} + model Diplomacy { id Int @id @default(autoincrement()) srcNationId Int @map("src_nation_id")