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.
This commit is contained in:
@@ -48,6 +48,23 @@ export interface GeneralRow {
|
|||||||
meta: unknown;
|
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 {
|
export interface CityRow {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -95,6 +112,39 @@ export interface DatabaseClient {
|
|||||||
nation: {
|
nation: {
|
||||||
findUnique(args: { where: { id: number } }): Promise<NationRow | null>;
|
findUnique(args: { where: { id: number } }): Promise<NationRow | null>;
|
||||||
};
|
};
|
||||||
|
generalTurn: {
|
||||||
|
findMany(args: {
|
||||||
|
where: { generalId: number };
|
||||||
|
orderBy?: { turnIdx: 'asc' | 'desc' }[];
|
||||||
|
}): Promise<GeneralTurnRow[]>;
|
||||||
|
deleteMany(args: { where: { generalId: number } }): Promise<unknown>;
|
||||||
|
createMany(args: {
|
||||||
|
data: Array<{
|
||||||
|
generalId: number;
|
||||||
|
turnIdx: number;
|
||||||
|
actionCode: string;
|
||||||
|
arg: unknown;
|
||||||
|
}>;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
};
|
||||||
|
nationTurn: {
|
||||||
|
findMany(args: {
|
||||||
|
where: { nationId: number; officerLevel: number };
|
||||||
|
orderBy?: { turnIdx: 'asc' | 'desc' }[];
|
||||||
|
}): Promise<NationTurnRow[]>;
|
||||||
|
deleteMany(args: {
|
||||||
|
where: { nationId: number; officerLevel: number };
|
||||||
|
}): Promise<unknown>;
|
||||||
|
createMany(args: {
|
||||||
|
data: Array<{
|
||||||
|
nationId: number;
|
||||||
|
officerLevel: number;
|
||||||
|
turnIdx: number;
|
||||||
|
actionCode: string;
|
||||||
|
arg: unknown;
|
||||||
|
}>;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameApiContext {
|
export interface GameApiContext {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { z } from 'zod';
|
|||||||
import type { WorldStateRow } from './context.js';
|
import type { WorldStateRow } from './context.js';
|
||||||
import { authedProcedure, procedure, router } from './trpc.js';
|
import { authedProcedure, procedure, router } from './trpc.js';
|
||||||
import { buildTurnCommandTable } from './turns/commandTable.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']);
|
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||||
|
|
||||||
@@ -13,6 +21,15 @@ const zTurnRunBudget = z.object({
|
|||||||
catchUpCap: z.number().int().positive(),
|
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) => ({
|
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||||
scenarioCode: row.scenarioCode,
|
scenarioCode: row.scenarioCode,
|
||||||
currentYear: row.currentYear,
|
currentYear: row.currentYear,
|
||||||
@@ -84,6 +101,148 @@ export const appRouter = router({
|
|||||||
nation,
|
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({
|
turnDaemon: router({
|
||||||
run: procedure
|
run: procedure
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||||
import { createGameApiContext } from './context.js';
|
import { createGameApiContext, type DatabaseClient } from './context.js';
|
||||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||||
@@ -75,7 +75,7 @@ export const createGameApiServer = async () => {
|
|||||||
const token = extractBearerToken(req.headers.authorization);
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
const auth = token ? tokenVerifier.verify(token) : null;
|
const auth = token ? tokenVerifier.verify(token) : null;
|
||||||
return createGameApiContext({
|
return createGameApiContext({
|
||||||
db: postgres.prisma,
|
db: postgres.prisma as unknown as DatabaseClient,
|
||||||
turnDaemon,
|
turnDaemon,
|
||||||
profile: {
|
profile: {
|
||||||
id: config.profile,
|
id: config.profile,
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservedTurnView {
|
||||||
|
index: number;
|
||||||
|
action: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
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<string, unknown> =>
|
||||||
|
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<GeneralTurnRow | NationTurnRow>,
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<ReservedTurnEntry[]> => {
|
||||||
|
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<ReservedTurnEntry[]> => {
|
||||||
|
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<ReservedTurnView[]> => {
|
||||||
|
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<ReservedTurnView[]> => {
|
||||||
|
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<ReservedTurnView[]> => {
|
||||||
|
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<ReservedTurnView[]> => {
|
||||||
|
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||||
|
const shifted = applyShift(turns, amount);
|
||||||
|
await persistNationTurns(db, nationId, officerLevel, shifted);
|
||||||
|
return serializeTurnList(shifted);
|
||||||
|
};
|
||||||
@@ -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<number, GeneralTurnRow[]>();
|
||||||
|
const nationTurns = new Map<string, NationTurnRow[]>();
|
||||||
|
|
||||||
|
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_포상');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,16 @@ const buildContext = (options?: {
|
|||||||
nation: {
|
nation: {
|
||||||
findUnique: async () => null,
|
findUnique: async () => null,
|
||||||
},
|
},
|
||||||
|
generalTurn: {
|
||||||
|
findMany: async () => [],
|
||||||
|
deleteMany: async () => ({}),
|
||||||
|
createMany: async () => ({}),
|
||||||
|
},
|
||||||
|
nationTurn: {
|
||||||
|
findMany: async () => [],
|
||||||
|
deleteMany: async () => ({}),
|
||||||
|
createMany: async () => ({}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic';
|
|||||||
|
|
||||||
import type { TurnDaemonHooks } from '../lifecycle/types.js';
|
import type { TurnDaemonHooks } from '../lifecycle/types.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||||
|
|
||||||
export interface DatabaseTurnHooks {
|
export interface DatabaseTurnHooks {
|
||||||
hooks: TurnDaemonHooks;
|
hooks: TurnDaemonHooks;
|
||||||
@@ -50,6 +51,40 @@ const buildGeneralUpdate = (
|
|||||||
recentWarTime: general.recentWarTime ?? null,
|
recentWarTime: general.recentWarTime ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const buildGeneralCreate = (
|
||||||
|
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['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 = (
|
const buildCityUpdate = (
|
||||||
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
|
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
|
||||||
): Prisma.CityUpdateInput => ({
|
): Prisma.CityUpdateInput => ({
|
||||||
@@ -116,7 +151,8 @@ const buildLogCreateData = (
|
|||||||
|
|
||||||
export const createDatabaseTurnHooks = async (
|
export const createDatabaseTurnHooks = async (
|
||||||
databaseUrl: string,
|
databaseUrl: string,
|
||||||
world: InMemoryTurnWorld
|
world: InMemoryTurnWorld,
|
||||||
|
options?: { reservedTurns?: InMemoryReservedTurnStore }
|
||||||
): Promise<DatabaseTurnHooks> => {
|
): Promise<DatabaseTurnHooks> => {
|
||||||
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
|
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
|
||||||
const connector = createPostgresConnector({ url: databaseUrl });
|
const connector = createPostgresConnector({ url: databaseUrl });
|
||||||
@@ -125,7 +161,8 @@ export const createDatabaseTurnHooks = async (
|
|||||||
const hooks: TurnDaemonHooks = {
|
const hooks: TurnDaemonHooks = {
|
||||||
flushChanges: async () => {
|
flushChanges: async () => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const { generals, cities, nations, logs } = world.consumeDirtyState();
|
const { generals, cities, nations, logs, createdGenerals } =
|
||||||
|
world.consumeDirtyState();
|
||||||
|
|
||||||
await connector.prisma.worldState.update({
|
await connector.prisma.worldState.update({
|
||||||
where: { id: state.id },
|
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([
|
await Promise.all([
|
||||||
...generals.map((general) =>
|
...generals
|
||||||
connector.prisma.general.update({
|
.filter((general) => !createdIds.has(general.id))
|
||||||
where: { id: general.id },
|
.map((general) =>
|
||||||
data: buildGeneralUpdate(general),
|
connector.prisma.general.update({
|
||||||
})
|
where: { id: general.id },
|
||||||
),
|
data: buildGeneralUpdate(general),
|
||||||
|
})
|
||||||
|
),
|
||||||
...cities.map((city) =>
|
...cities.map((city) =>
|
||||||
connector.prisma.city.update({
|
connector.prisma.city.update({
|
||||||
where: { id: city.id },
|
where: { id: city.id },
|
||||||
@@ -176,6 +225,9 @@ export const createDatabaseTurnHooks = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (options?.reservedTurns) {
|
||||||
|
await options.reservedTurns.flushChanges();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type {
|
|||||||
} from '../lifecycle/types.js';
|
} from '../lifecycle/types.js';
|
||||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral } from './types.js';
|
||||||
|
|
||||||
export interface InMemoryTurnProcessorOptions {
|
export interface InMemoryTurnProcessorOptions {
|
||||||
tickMinutes?: number;
|
tickMinutes?: number;
|
||||||
|
beforeExecuteGeneral?: (general: TurnGeneral) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveTickMinutes = (
|
const resolveTickMinutes = (
|
||||||
@@ -26,10 +28,14 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
|||||||
// 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다.
|
// 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다.
|
||||||
private readonly world: InMemoryTurnWorld;
|
private readonly world: InMemoryTurnWorld;
|
||||||
private readonly tickMinutes: number;
|
private readonly tickMinutes: number;
|
||||||
|
private readonly beforeExecuteGeneral?: (
|
||||||
|
general: TurnGeneral
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) {
|
constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.tickMinutes = resolveTickMinutes(world, options.tickMinutes);
|
this.tickMinutes = resolveTickMinutes(world, options.tickMinutes);
|
||||||
|
this.beforeExecuteGeneral = options.beforeExecuteGeneral;
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(
|
async run(
|
||||||
@@ -57,6 +63,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const executedAt = new Date(general.turnTime.getTime());
|
const executedAt = new Date(general.turnTime.getTime());
|
||||||
|
if (this.beforeExecuteGeneral) {
|
||||||
|
await this.beforeExecuteGeneral(general);
|
||||||
|
}
|
||||||
this.world.executeGeneralTurn(general);
|
this.world.executeGeneralTurn(general);
|
||||||
processedGenerals += 1;
|
processedGenerals += 1;
|
||||||
nextCheckpoint = {
|
nextCheckpoint = {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export interface GeneralTurnResult {
|
|||||||
nation?: Nation | null;
|
nation?: Nation | null;
|
||||||
nextTurnAt?: Date;
|
nextTurnAt?: Date;
|
||||||
logs?: LogEntryDraft[];
|
logs?: LogEntryDraft[];
|
||||||
|
patches?: {
|
||||||
|
generals: Array<{ id: number; patch: Partial<TurnGeneral> }>;
|
||||||
|
cities: Array<{ id: number; patch: Partial<City> }>;
|
||||||
|
nations: Array<{ id: number; patch: Partial<Nation> }>;
|
||||||
|
};
|
||||||
|
created?: {
|
||||||
|
generals: TurnGeneral[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneralTurnHandler {
|
export interface GeneralTurnHandler {
|
||||||
@@ -74,6 +82,65 @@ const shouldProcessByCheckpoint = (
|
|||||||
return general.id > checkpoint.generalId;
|
return general.id > checkpoint.generalId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeStats = (
|
||||||
|
base: TurnGeneral['stats'],
|
||||||
|
patch: Partial<TurnGeneral['stats']>
|
||||||
|
): 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']>
|
||||||
|
): TurnGeneral['role'] => ({
|
||||||
|
...base,
|
||||||
|
...patch,
|
||||||
|
items: {
|
||||||
|
...base.items,
|
||||||
|
...(patch.items ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergeTriggerState = (
|
||||||
|
base: TurnGeneral['triggerState'],
|
||||||
|
patch: Partial<TurnGeneral['triggerState']>
|
||||||
|
): 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>
|
||||||
|
): 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>): City => ({
|
||||||
|
...base,
|
||||||
|
...patch,
|
||||||
|
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyNationPatch = (base: Nation, patch: Partial<Nation>): Nation => ({
|
||||||
|
...base,
|
||||||
|
...patch,
|
||||||
|
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
|
||||||
|
});
|
||||||
|
|
||||||
export class InMemoryTurnWorld {
|
export class InMemoryTurnWorld {
|
||||||
// DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다.
|
// DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다.
|
||||||
private readonly schedule: TurnSchedule;
|
private readonly schedule: TurnSchedule;
|
||||||
@@ -85,6 +152,7 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly dirtyGeneralIds = new Set<number>();
|
private readonly dirtyGeneralIds = new Set<number>();
|
||||||
private readonly dirtyCityIds = new Set<number>();
|
private readonly dirtyCityIds = new Set<number>();
|
||||||
private readonly dirtyNationIds = new Set<number>();
|
private readonly dirtyNationIds = new Set<number>();
|
||||||
|
private readonly createdGeneralIds = new Set<number>();
|
||||||
private readonly logs: LogEntryDraft[] = [];
|
private readonly logs: LogEntryDraft[] = [];
|
||||||
private checkpoint?: TurnCheckpoint;
|
private checkpoint?: TurnCheckpoint;
|
||||||
private state: TurnWorldState;
|
private state: TurnWorldState;
|
||||||
@@ -118,6 +186,34 @@ export class InMemoryTurnWorld {
|
|||||||
return { ...this.state };
|
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 {
|
setLastTurnTime(turnTime: Date): void {
|
||||||
const meta = {
|
const meta = {
|
||||||
...this.state.meta,
|
...this.state.meta,
|
||||||
@@ -199,6 +295,48 @@ export class InMemoryTurnWorld {
|
|||||||
if (result.logs && result.logs.length > 0) {
|
if (result.logs && result.logs.length > 0) {
|
||||||
this.logs.push(...result.logs);
|
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;
|
return nextTurnAt;
|
||||||
}
|
}
|
||||||
@@ -243,10 +381,14 @@ export class InMemoryTurnWorld {
|
|||||||
cities: City[];
|
cities: City[];
|
||||||
nations: Nation[];
|
nations: Nation[];
|
||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
|
createdGenerals: TurnGeneral[];
|
||||||
} {
|
} {
|
||||||
const generals = Array.from(this.dirtyGeneralIds)
|
const generals = Array.from(this.dirtyGeneralIds)
|
||||||
.map((id) => this.generals.get(id))
|
.map((id) => this.generals.get(id))
|
||||||
.filter((general): general is TurnGeneral => Boolean(general));
|
.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)
|
const cities = Array.from(this.dirtyCityIds)
|
||||||
.map((id) => this.cities.get(id))
|
.map((id) => this.cities.get(id))
|
||||||
.filter((city): city is City => Boolean(city));
|
.filter((city): city is City => Boolean(city));
|
||||||
@@ -258,7 +400,8 @@ export class InMemoryTurnWorld {
|
|||||||
this.dirtyGeneralIds.clear();
|
this.dirtyGeneralIds.clear();
|
||||||
this.dirtyCityIds.clear();
|
this.dirtyCityIds.clear();
|
||||||
this.dirtyNationIds.clear();
|
this.dirtyNationIds.clear();
|
||||||
|
this.createdGeneralIds.clear();
|
||||||
|
|
||||||
return { generals, cities, nations, logs };
|
return { generals, cities, nations, logs, createdGenerals };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||||
|
isRecord(value) ? value : {};
|
||||||
|
|
||||||
|
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||||
|
if (!value || value === 'None') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveNumber = (
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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<string, unknown> => {
|
||||||
|
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<string, number> => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
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 | number>): 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<string, number>,
|
||||||
|
private readonly env: Record<string, unknown>,
|
||||||
|
private readonly args: Record<string, unknown>,
|
||||||
|
private readonly overrides?: {
|
||||||
|
general?: TurnGeneral;
|
||||||
|
city?: City;
|
||||||
|
nation?: Nation | null;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
has(req: Parameters<StateView['has']>[0]): boolean {
|
||||||
|
return this.get(req) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(req: Parameters<StateView['get']>[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<string, GeneralActionDefinition> => {
|
||||||
|
const definitions = new Map<string, GeneralActionDefinition>();
|
||||||
|
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<string, GeneralActionDefinition> => {
|
||||||
|
const definitions = new Map<string, GeneralActionDefinition>();
|
||||||
|
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<string, unknown> =>
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
const buildActionContext = (
|
||||||
|
key: string,
|
||||||
|
base: ActionContextBase,
|
||||||
|
options: {
|
||||||
|
world: TurnWorldState;
|
||||||
|
scenarioMeta?: ScenarioMeta;
|
||||||
|
worldRef: InMemoryTurnWorld | null;
|
||||||
|
actionArgs: Record<string, unknown>;
|
||||||
|
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<string, unknown>,
|
||||||
|
env: Record<string, unknown>
|
||||||
|
): 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<string, GeneralActionDefinition>,
|
||||||
|
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<TurnGeneral> }>,
|
||||||
|
cities: [] as Array<{ id: number; patch: Partial<City> }>,
|
||||||
|
nations: [] as Array<{ id: number; patch: Partial<Nation> }>,
|
||||||
|
};
|
||||||
|
const created: TurnGeneral[] = [];
|
||||||
|
|
||||||
|
let currentGeneral = context.general;
|
||||||
|
let currentCity = context.city;
|
||||||
|
let currentNation = context.nation ?? null;
|
||||||
|
|
||||||
|
const runAction = (
|
||||||
|
definitionMap: Map<string, GeneralActionDefinition>,
|
||||||
|
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<string, unknown>,
|
||||||
|
constraintEnv
|
||||||
|
);
|
||||||
|
const view = new WorldStateView(
|
||||||
|
worldRef,
|
||||||
|
diplomacyMap,
|
||||||
|
constraintEnv,
|
||||||
|
actionArgs as Record<string, unknown>,
|
||||||
|
{
|
||||||
|
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<TurnGeneral>;
|
||||||
|
}>)
|
||||||
|
);
|
||||||
|
patches.cities.push(
|
||||||
|
...(resolution.patches.cities as Array<{
|
||||||
|
id: number;
|
||||||
|
patch: Partial<City>;
|
||||||
|
}>)
|
||||||
|
);
|
||||||
|
patches.nations.push(
|
||||||
|
...(resolution.patches.nations as Array<{
|
||||||
|
id: number;
|
||||||
|
patch: Partial<Nation>;
|
||||||
|
}>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { createPostgresConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
export interface ReservedTurnEntry {
|
||||||
|
action: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservedTurnStoreOptions {
|
||||||
|
databaseUrl: string;
|
||||||
|
maxGeneralTurns?: number;
|
||||||
|
maxNationTurns?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservedTurnStoreHandle {
|
||||||
|
store: InMemoryReservedTurnStore;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> =>
|
||||||
|
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<string, unknown> =>
|
||||||
|
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<unknown>;
|
||||||
|
createMany(args: {
|
||||||
|
data: Array<{
|
||||||
|
generalId: number;
|
||||||
|
turnIdx: number;
|
||||||
|
actionCode: string;
|
||||||
|
arg: Prisma.InputJsonValue;
|
||||||
|
}>;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
};
|
||||||
|
nationTurn: {
|
||||||
|
findMany(args?: unknown): Promise<
|
||||||
|
Array<{
|
||||||
|
nationId: number;
|
||||||
|
officerLevel: number;
|
||||||
|
turnIdx: number;
|
||||||
|
actionCode: string;
|
||||||
|
arg: unknown;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
deleteMany(args: {
|
||||||
|
where: { nationId: number; officerLevel: number };
|
||||||
|
}): Promise<unknown>;
|
||||||
|
createMany(args: {
|
||||||
|
data: Array<{
|
||||||
|
nationId: number;
|
||||||
|
officerLevel: number;
|
||||||
|
turnIdx: number;
|
||||||
|
actionCode: string;
|
||||||
|
arg: Prisma.InputJsonValue;
|
||||||
|
}>;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemoryReservedTurnStore {
|
||||||
|
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
|
||||||
|
private readonly nationTurns = new Map<string, ReservedTurnEntry[]>();
|
||||||
|
private readonly dirtyGeneralIds = new Set<number>();
|
||||||
|
private readonly dirtyNationKeys = new Set<string>();
|
||||||
|
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<void> {
|
||||||
|
const [generalRows, nationRows] = await Promise.all([
|
||||||
|
this.prisma.generalTurn.findMany(),
|
||||||
|
this.prisma.nationTurn.findMany(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const generalGroups = new Map<number, typeof generalRows>();
|
||||||
|
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<string, typeof nationRows>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ReservedTurnStoreHandle> => {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -20,6 +20,8 @@ import type {
|
|||||||
import { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
|
import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
|
||||||
import { InMemoryTurnStateStore } from './inMemoryStateStore.js';
|
import { InMemoryTurnStateStore } from './inMemoryStateStore.js';
|
||||||
|
import { createReservedTurnHandler } from './reservedTurnHandler.js';
|
||||||
|
import { createReservedTurnStore } from './reservedTurnStore.js';
|
||||||
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
||||||
|
|
||||||
export interface TurnDaemonRuntimeOptions {
|
export interface TurnDaemonRuntimeOptions {
|
||||||
@@ -71,24 +73,63 @@ export const createTurnDaemonRuntime = async (
|
|||||||
? { ...state, tickSeconds: tickMinutes * 60 }
|
? { ...state, tickSeconds: tickMinutes * 60 }
|
||||||
: state;
|
: state;
|
||||||
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
|
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
|
||||||
|
const reservedTurnStoreHandle = options.generalTurnHandler
|
||||||
|
? null
|
||||||
|
: await createReservedTurnStore({
|
||||||
|
databaseUrl: options.databaseUrl,
|
||||||
|
});
|
||||||
|
let worldRef: InMemoryTurnWorld | null = null;
|
||||||
const worldOptions: InMemoryTurnWorldOptions = {
|
const worldOptions: InMemoryTurnWorldOptions = {
|
||||||
schedule,
|
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,
|
calendarHandler: options.calendarHandler,
|
||||||
};
|
};
|
||||||
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
|
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
|
||||||
|
worldRef = world;
|
||||||
|
|
||||||
const stateStore = new InMemoryTurnStateStore(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 controlQueue = options.controlQueue ?? new InMemoryControlQueue();
|
||||||
const clock = options.clock ?? new SystemClock();
|
const clock = options.clock ?? new SystemClock();
|
||||||
|
|
||||||
let hooks: TurnDaemonHooks | undefined;
|
let hooks: TurnDaemonHooks | undefined;
|
||||||
let close = async () => {};
|
let close = async () => {};
|
||||||
if (options.enableDatabaseFlush ?? true) {
|
if (options.enableDatabaseFlush ?? true) {
|
||||||
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world);
|
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
||||||
|
reservedTurns: reservedTurnStoreHandle?.store,
|
||||||
|
});
|
||||||
hooks = dbHooks.hooks;
|
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 ?? {
|
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
|
||||||
|
|||||||
@@ -147,6 +147,31 @@ model General {
|
|||||||
@@map("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 {
|
model Diplomacy {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
srcNationId Int @map("src_nation_id")
|
srcNationId Int @map("src_nation_id")
|
||||||
|
|||||||
Reference in New Issue
Block a user