diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index a304862..8878927 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,6 +1,7 @@ import type { Prisma } from '@prisma/client'; import { createPostgresConnector } from '@sammo-ts/infra'; +import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic'; import type { TurnDaemonHooks } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; @@ -85,6 +86,34 @@ const buildNationUpdate = ( meta: asJson(nation.meta), }); +const buildLogCreateData = ( + entry: LogEntryDraft, + context: { year: number; month: number; at: Date } +): Prisma.LogEntryCreateManyInput | null => { + const record = finalizeLogEntry(entry, { + year: context.year, + month: context.month, + at: context.at, + }); + if (!record) { + return null; + } + + return { + scope: record.scope, + category: record.category, + subType: record.subType ?? null, + year: record.year, + month: record.month, + text: record.text, + generalId: record.generalId ?? null, + nationId: record.nationId ?? null, + userId: record.userId ?? null, + meta: asJson(record.meta ?? {}), + createdAt: record.createdAt, + }; +}; + export const createDatabaseTurnHooks = async ( databaseUrl: string, world: InMemoryTurnWorld @@ -130,7 +159,22 @@ export const createDatabaseTurnHooks = async ( ]); if (logs.length > 0) { - // TODO: API 서버 연동 전까지는 로그를 별도 처리하지 않는다. + const logContext = { + year: state.currentYear, + month: state.currentMonth, + at: state.lastTurnTime, + }; + const payload = logs + .map((entry) => buildLogCreateData(entry, logContext)) + .filter( + (entry): entry is Prisma.LogEntryCreateManyInput => + Boolean(entry) + ); + if (payload.length > 0) { + await connector.prisma.logEntry.createMany({ + data: payload, + }); + } } }, }; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 9c49e4f..b3214ce 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1,4 +1,4 @@ -import type { City, Nation, TurnSchedule } from '@sammo-ts/logic'; +import type { City, LogEntryDraft, Nation, TurnSchedule } from '@sammo-ts/logic'; import { getNextTurnAt } from '@sammo-ts/logic'; import type { TurnCheckpoint } from '../lifecycle/types.js'; @@ -17,7 +17,7 @@ export interface GeneralTurnResult { city?: City; nation?: Nation | null; nextTurnAt?: Date; - logs?: string[]; + logs?: LogEntryDraft[]; } export interface GeneralTurnHandler { @@ -85,7 +85,7 @@ export class InMemoryTurnWorld { private readonly dirtyGeneralIds = new Set(); private readonly dirtyCityIds = new Set(); private readonly dirtyNationIds = new Set(); - private readonly logs: string[] = []; + private readonly logs: LogEntryDraft[] = []; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -242,7 +242,7 @@ export class InMemoryTurnWorld { generals: TurnGeneral[]; cities: City[]; nations: Nation[]; - logs: string[]; + logs: LogEntryDraft[]; } { const generals = Array.from(this.dirtyGeneralIds) .map((id) => this.generals.get(id)) diff --git a/packages/infra/prisma/schema.prisma b/packages/infra/prisma/schema.prisma index 3df6bdd..335335b 100644 --- a/packages/infra/prisma/schema.prisma +++ b/packages/infra/prisma/schema.prisma @@ -6,6 +6,22 @@ datasource db { provider = "postgresql" } +enum LogScope { + SYSTEM + NATION + GENERAL + USER +} + +enum LogCategory { + HISTORY + SUMMARY + ACTION + BATTLE_BRIEF + BATTLE_DETAIL + USER +} + model WorldState { id Int @id @default(autoincrement()) scenarioCode String @map("scenario_code") @@ -133,3 +149,24 @@ model Event { @@map("event") } + +model LogEntry { + id Int @id @default(autoincrement()) + scope LogScope + category LogCategory + subType String? @map("sub_type") + year Int + month Int + text String + generalId Int? @map("general_id") + nationId Int? @map("nation_id") + userId Int? @map("user_id") + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") + + @@index([scope, category, id]) + @@index([generalId, category, id]) + @@index([nationId, category, id]) + @@index([userId, category, id]) + @@map("log_entry") +} diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index a56306f..1ab8565 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -1,2 +1,3 @@ export * from './postgres.js'; +export * from './logRepository.js'; export * from './redis.js'; diff --git a/packages/infra/src/logRepository.ts b/packages/infra/src/logRepository.ts new file mode 100644 index 0000000..29beb92 --- /dev/null +++ b/packages/infra/src/logRepository.ts @@ -0,0 +1,117 @@ +import { LogCategory, LogScope } from '@prisma/client'; +import type { Prisma, PrismaClient } from '@prisma/client'; + +export interface LogQueryOptions { + limit?: number; + beforeId?: number; +} + +export interface LogEntryView { + id: number; + scope: LogScope; + category: LogCategory; + subType: string | null; + text: string; + year: number; + month: number; + createdAt: Date; + generalId: number | null; + nationId: number | null; + userId: number | null; +} + +const buildPaginationWhere = ( + base: Prisma.LogEntryWhereInput, + options: LogQueryOptions +): Prisma.LogEntryWhereInput => { + if (options.beforeId) { + return { + ...base, + id: { lt: options.beforeId }, + }; + } + return base; +}; + +const buildFindArgs = ( + where: Prisma.LogEntryWhereInput, + options: LogQueryOptions +): Prisma.LogEntryFindManyArgs => ({ + where: buildPaginationWhere(where, options), + orderBy: { id: 'desc' }, + take: options.limit ?? 50, +}); + +export class LogRepository { + constructor(private readonly prisma: PrismaClient) {} + + // 전역(시스템) 로그 조회 + async listSystemLogs( + category: LogCategory, + options: LogQueryOptions = {} + ): Promise { + return this.prisma.logEntry.findMany( + buildFindArgs( + { + scope: LogScope.SYSTEM, + category, + }, + options + ) + ); + } + + // 국가 로그 조회 + async listNationLogs( + nationId: number, + category: LogCategory, + options: LogQueryOptions = {} + ): Promise { + return this.prisma.logEntry.findMany( + buildFindArgs( + { + scope: LogScope.NATION, + category, + nationId, + }, + options + ) + ); + } + + // 장수 로그 조회 + async listGeneralLogs( + generalId: number, + category: LogCategory, + options: LogQueryOptions = {} + ): Promise { + return this.prisma.logEntry.findMany( + buildFindArgs( + { + scope: LogScope.GENERAL, + category, + generalId, + }, + options + ) + ); + } + + // 유저 로그 조회 + async listUserLogs( + userId: number, + options: LogQueryOptions & { subType?: string } = {} + ): Promise { + return this.prisma.logEntry.findMany( + buildFindArgs( + { + scope: LogScope.USER, + category: LogCategory.USER, + userId, + subType: options.subType, + }, + options + ) + ); + } +} diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 93d7141..d8290b9 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -12,6 +12,12 @@ import type { } from '../domain/entities.js'; import type { GeneralActionContext } from '../triggers/general.js'; import { getNextTurnAt, type TurnSchedule } from '../turn/calendar.js'; +import { + LogCategory, + type LogEntryDraft, + LogFormat, + LogScope, +} from '../logging/types.js'; export interface GeneralActionResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -45,7 +51,7 @@ export interface NationPatchEffect { export interface LogEffect { type: 'log'; - message: string; + entry: LogEntryDraft; } export interface NextTurnOverrideEffect { @@ -82,7 +88,7 @@ export interface GeneralActionResolution { city?: City; nation?: Nation | null; nextTurnAt: Date; - logs: string[]; + logs: LogEntryDraft[]; effects: GeneralActionEffect[]; dirty?: { general: boolean; @@ -171,9 +177,26 @@ export const createNationPatchEffect = ( patch, }); -export const createLogEffect = (message: string): LogEffect => ({ +export const createLogEffect = ( + message: string, + options: Partial> = {} +): LogEffect => ({ type: 'log', - message, + entry: { + scope: options.scope ?? LogScope.GENERAL, + category: options.category ?? LogCategory.ACTION, + text: message, + ...(options.generalId !== undefined + ? { generalId: options.generalId } + : {}), + ...(options.nationId !== undefined + ? { nationId: options.nationId } + : {}), + ...(options.userId !== undefined ? { userId: options.userId } : {}), + ...(options.subType !== undefined ? { subType: options.subType } : {}), + ...(options.meta !== undefined ? { meta: options.meta } : {}), + format: options.format ?? LogFormat.MONTH, + }, }); export const createNextTurnOverrideEffect = ( @@ -192,7 +215,7 @@ export const resolveGeneralAction = < scheduleContext: TurnScheduleContext ): GeneralActionResolution => { const outcome = resolver.resolve(context); - const logs: string[] = []; + const logs: LogEntryDraft[] = []; let nextGeneral = context.general; let nextCity = context.city; let nextNation = context.nation ?? null; @@ -229,7 +252,37 @@ export const resolveGeneralAction = < } break; case 'log': - logs.push(effect.message); + // 로그 대상이 비어 있으면 현재 장수/국가 기준으로 보정한다. + switch (effect.entry.scope) { + case LogScope.GENERAL: + logs.push({ + ...effect.entry, + generalId: + effect.entry.generalId ?? context.general.id, + }); + break; + case LogScope.NATION: + if (effect.entry.nationId !== undefined) { + logs.push(effect.entry); + break; + } + if (context.nation?.id !== undefined) { + logs.push({ + ...effect.entry, + nationId: context.nation.id, + }); + } + break; + case LogScope.USER: + if (effect.entry.userId) { + logs.push(effect.entry); + } + break; + case LogScope.SYSTEM: + default: + logs.push(effect.entry); + break; + } break; case 'schedule:override': nextTurnAtOverride = effect.nextTurnAt; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 70f9b62..287e613 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -1,6 +1,7 @@ export * from './domain/entities.js'; export type { RandomGenerator } from '@sammo-ts/common'; export * from './actions/index.js'; +export * from './logging/index.js'; export * from './ports/world.js'; export * from './ports/worldSnapshot.js'; export * from './scenario/index.js'; diff --git a/packages/logic/src/logging/actionLogger.ts b/packages/logic/src/logging/actionLogger.ts new file mode 100644 index 0000000..7f5a504 --- /dev/null +++ b/packages/logic/src/logging/actionLogger.ts @@ -0,0 +1,147 @@ +import { + LogCategory, + type LogEntryDraft, + LogFormat, + LogScope, +} from './types.js'; + +export class ActionLogger { + private readonly generalId: number | undefined; + private readonly nationId: number | undefined; + private readonly logs: LogEntryDraft[] = []; + + constructor(options: { generalId?: number; nationId?: number } = {}) { + this.generalId = options.generalId; + this.nationId = options.nationId; + } + + // 장수/국가/전역 로그를 한번에 모아두고 외부에서 저장한다. + public flush(): LogEntryDraft[] { + const items = this.logs.splice(0, this.logs.length); + return items; + } + + public rollback(): LogEntryDraft[] { + const backup = this.logs.splice(0, this.logs.length); + return backup; + } + + public pushGeneralHistoryLog( + text: string | string[], + format: LogFormat = LogFormat.YEAR_MONTH + ): void { + this.pushBatch(text, (message) => ({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + text: message, + ...(this.generalId !== undefined + ? { generalId: this.generalId } + : {}), + format, + })); + } + + public pushGeneralActionLog( + text: string | string[], + format: LogFormat = LogFormat.MONTH + ): void { + this.pushBatch(text, (message) => ({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + text: message, + ...(this.generalId !== undefined + ? { generalId: this.generalId } + : {}), + format, + })); + } + + public pushGeneralBattleResultLog( + text: string | string[], + format: LogFormat = LogFormat.RAWTEXT + ): void { + this.pushBatch(text, (message) => ({ + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + text: message, + ...(this.generalId !== undefined + ? { generalId: this.generalId } + : {}), + format, + })); + } + + public pushGeneralBattleDetailLog( + text: string | string[], + format: LogFormat = LogFormat.PLAIN + ): void { + this.pushBatch(text, (message) => ({ + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_DETAIL, + text: message, + ...(this.generalId !== undefined + ? { generalId: this.generalId } + : {}), + format, + })); + } + + public pushNationHistoryLog( + text: string | string[], + format: LogFormat = LogFormat.YEAR_MONTH, + nationId: number | undefined = this.nationId + ): void { + if (!nationId) { + return; + } + this.pushBatch(text, (message) => ({ + scope: LogScope.NATION, + category: LogCategory.HISTORY, + text: message, + nationId, + format, + })); + } + + public pushGlobalHistoryLog( + text: string | string[], + format: LogFormat = LogFormat.YEAR_MONTH + ): void { + this.pushBatch(text, (message) => ({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: message, + format, + })); + } + + public pushGlobalActionLog( + text: string | string[], + format: LogFormat = LogFormat.MONTH + ): void { + this.pushBatch(text, (message) => ({ + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + text: message, + format, + })); + } + + private pushBatch( + text: string | string[], + builder: (message: string) => LogEntryDraft + ): void { + if (Array.isArray(text)) { + for (const item of text) { + if (item) { + this.logs.push(builder(item)); + } + } + return; + } + if (!text) { + return; + } + this.logs.push(builder(text)); + } +} diff --git a/packages/logic/src/logging/entries.ts b/packages/logic/src/logging/entries.ts new file mode 100644 index 0000000..1c1bf88 --- /dev/null +++ b/packages/logic/src/logging/entries.ts @@ -0,0 +1,62 @@ +import { formatLogText } from './formatter.js'; +import { + type LogContext, + type LogEntryDraft, + type LogEntryRecord, + LogFormat, + LogScope, +} from './types.js'; + +const shouldDropEntry = (entry: LogEntryDraft): boolean => { + if (entry.scope === LogScope.GENERAL && !entry.generalId) { + return true; + } + if (entry.scope === LogScope.NATION && !entry.nationId) { + return true; + } + if (entry.scope === LogScope.USER && !entry.userId) { + return true; + } + return false; +}; + +export const finalizeLogEntry = ( + entry: LogEntryDraft, + context: LogContext +): LogEntryRecord | null => { + if (shouldDropEntry(entry)) { + return null; + } + + const format = entry.format ?? LogFormat.RAWTEXT; + const text = formatLogText(entry.text, format, context.year, context.month); + + const record: LogEntryRecord = { + scope: entry.scope, + category: entry.category, + text, + year: context.year, + month: context.month, + }; + + if (entry.generalId !== undefined) { + record.generalId = entry.generalId; + } + if (entry.nationId !== undefined) { + record.nationId = entry.nationId; + } + if (entry.userId !== undefined) { + record.userId = entry.userId; + } + if (entry.subType !== undefined) { + record.subType = entry.subType; + } + if (entry.meta !== undefined) { + record.meta = entry.meta; + } + if (context.at !== undefined) { + record.createdAt = context.at; + } + + return record; +}; diff --git a/packages/logic/src/logging/formatter.ts b/packages/logic/src/logging/formatter.ts new file mode 100644 index 0000000..854deff --- /dev/null +++ b/packages/logic/src/logging/formatter.ts @@ -0,0 +1,32 @@ +import { LogFormat } from './types.js'; + +// 로그 포맷은 기존 표시 규칙(// + 기호)을 그대로 유지한다. +export const formatLogText = ( + text: string, + format: LogFormat, + year: number, + month: number +): string => { + switch (format) { + case LogFormat.RAWTEXT: + return text; + case LogFormat.PLAIN: + return `●${text}`; + case LogFormat.YEAR_MONTH: + return `●${year}년 ${month}월:${text}`; + case LogFormat.YEAR: + return `●${year}년:${text}`; + case LogFormat.MONTH: + return `●${month}월:${text}`; + case LogFormat.EVENT_PLAIN: + return `◆${text}`; + case LogFormat.EVENT_YEAR_MONTH: + return `◆${year}년 ${month}월:${text}`; + case LogFormat.NOTICE: + return `★${text}`; + case LogFormat.NOTICE_YEAR_MONTH: + return `★${year}년 ${month}월:${text}`; + default: + return text; + } +}; diff --git a/packages/logic/src/logging/index.ts b/packages/logic/src/logging/index.ts new file mode 100644 index 0000000..a15032b --- /dev/null +++ b/packages/logic/src/logging/index.ts @@ -0,0 +1,5 @@ +export * from './actionLogger.js'; +export * from './entries.js'; +export * from './formatter.js'; +export * from './types.js'; +export * from './userLogger.js'; diff --git a/packages/logic/src/logging/types.ts b/packages/logic/src/logging/types.ts new file mode 100644 index 0000000..5e1a304 --- /dev/null +++ b/packages/logic/src/logging/types.ts @@ -0,0 +1,72 @@ +export const LogScope = { + SYSTEM: 'SYSTEM', + NATION: 'NATION', + GENERAL: 'GENERAL', + USER: 'USER', +} as const; + +export type LogScope = (typeof LogScope)[keyof typeof LogScope]; + +export const LogCategory = { + HISTORY: 'HISTORY', + SUMMARY: 'SUMMARY', + ACTION: 'ACTION', + BATTLE_BRIEF: 'BATTLE_BRIEF', + BATTLE_DETAIL: 'BATTLE_DETAIL', + USER: 'USER', +} as const; + +export type LogCategory = + (typeof LogCategory)[keyof typeof LogCategory]; + +export interface LogEntryDraft { + scope: LogScope; + category: LogCategory; + text: string; + generalId?: number; + nationId?: number; + userId?: number; + subType?: string; + meta?: Record; + format?: LogFormat; +} + +export interface LogEntryRecord { + scope: LogScope; + category: LogCategory; + text: string; + year: number; + month: number; + generalId?: number; + nationId?: number; + userId?: number; + subType?: string; + meta?: Record; + createdAt?: Date; +} + +export interface LogContext { + year: number; + month: number; + at?: Date; +} + +export enum LogFormat { + RAWTEXT = 0, + /** ● */ + PLAIN = 1, + /** ●{$year}년 {$month}월: */ + YEAR_MONTH = 2, + /** ●{$year}년: */ + YEAR = 3, + /** ●{$month}월: */ + MONTH = 4, + /** ◆ */ + EVENT_PLAIN = 5, + /** ◆{$year}년 {$month}월: */ + EVENT_YEAR_MONTH = 6, + /** ★ */ + NOTICE = 7, + /** ★{$year}년 {$month}월: */ + NOTICE_YEAR_MONTH = 8, +} diff --git a/packages/logic/src/logging/userLogger.ts b/packages/logic/src/logging/userLogger.ts new file mode 100644 index 0000000..569e261 --- /dev/null +++ b/packages/logic/src/logging/userLogger.ts @@ -0,0 +1,48 @@ +import { LogCategory, type LogEntryDraft, LogScope } from './types.js'; + +export class UserLogger { + private readonly userId: number; + private readonly logs: LogEntryDraft[] = []; + + constructor(userId: number) { + this.userId = userId; + } + + // 유저 단위의 기록을 모아두었다가 외부에서 저장한다. + public flush(): LogEntryDraft[] { + const items = this.logs.splice(0, this.logs.length); + return items; + } + + public rollback(): LogEntryDraft[] { + const backup = this.logs.splice(0, this.logs.length); + return backup; + } + + public push(text: string | string[], subType: string): void { + if (Array.isArray(text)) { + for (const item of text) { + if (item) { + this.logs.push({ + scope: LogScope.USER, + category: LogCategory.USER, + text: item, + userId: this.userId, + subType, + }); + } + } + return; + } + if (!text) { + return; + } + this.logs.push({ + scope: LogScope.USER, + category: LogCategory.USER, + text, + userId: this.userId, + subType, + }); + } +}