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:
2025-12-30 04:45:07 +00:00
parent e4c57e2467
commit 6fe6d54432
13 changed files with 1949 additions and 15 deletions
+60 -8
View File
@@ -5,6 +5,7 @@ import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic';
import type { TurnDaemonHooks } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -50,6 +51,40 @@ const buildGeneralUpdate = (
recentWarTime: general.recentWarTime ?? null,
});
const buildGeneralCreate = (
general: ReturnType<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 = (
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
): Prisma.CityUpdateInput => ({
@@ -116,7 +151,8 @@ const buildLogCreateData = (
export const createDatabaseTurnHooks = async (
databaseUrl: string,
world: InMemoryTurnWorld
world: InMemoryTurnWorld,
options?: { reservedTurns?: InMemoryReservedTurnStore }
): Promise<DatabaseTurnHooks> => {
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
const connector = createPostgresConnector({ url: databaseUrl });
@@ -125,7 +161,8 @@ export const createDatabaseTurnHooks = async (
const hooks: TurnDaemonHooks = {
flushChanges: async () => {
const state = world.getState();
const { generals, cities, nations, logs } = world.consumeDirtyState();
const { generals, cities, nations, logs, createdGenerals } =
world.consumeDirtyState();
await connector.prisma.worldState.update({
where: { id: state.id },
@@ -137,13 +174,25 @@ export const createDatabaseTurnHooks = async (
},
});
const createdIds = new Set(
createdGenerals.map((general) => general.id)
);
if (createdGenerals.length > 0) {
await connector.prisma.general.createMany({
data: createdGenerals.map(buildGeneralCreate),
});
}
await Promise.all([
...generals.map((general) =>
connector.prisma.general.update({
where: { id: general.id },
data: buildGeneralUpdate(general),
})
),
...generals
.filter((general) => !createdIds.has(general.id))
.map((general) =>
connector.prisma.general.update({
where: { id: general.id },
data: buildGeneralUpdate(general),
})
),
...cities.map((city) =>
connector.prisma.city.update({
where: { id: city.id },
@@ -176,6 +225,9 @@ export const createDatabaseTurnHooks = async (
});
}
}
if (options?.reservedTurns) {
await options.reservedTurns.flushChanges();
}
},
};
@@ -6,9 +6,11 @@ import type {
} from '../lifecycle/types.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
export interface InMemoryTurnProcessorOptions {
tickMinutes?: number;
beforeExecuteGeneral?: (general: TurnGeneral) => Promise<void>;
}
const resolveTickMinutes = (
@@ -26,10 +28,14 @@ export class InMemoryTurnProcessor implements TurnProcessor {
// 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다.
private readonly world: InMemoryTurnWorld;
private readonly tickMinutes: number;
private readonly beforeExecuteGeneral?: (
general: TurnGeneral
) => Promise<void>;
constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) {
this.world = world;
this.tickMinutes = resolveTickMinutes(world, options.tickMinutes);
this.beforeExecuteGeneral = options.beforeExecuteGeneral;
}
async run(
@@ -57,6 +63,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
break;
}
const executedAt = new Date(general.turnTime.getTime());
if (this.beforeExecuteGeneral) {
await this.beforeExecuteGeneral(general);
}
this.world.executeGeneralTurn(general);
processedGenerals += 1;
nextCheckpoint = {
+144 -1
View File
@@ -18,6 +18,14 @@ export interface GeneralTurnResult {
nation?: Nation | null;
nextTurnAt?: Date;
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 {
@@ -74,6 +82,65 @@ const shouldProcessByCheckpoint = (
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 {
// DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다.
private readonly schedule: TurnSchedule;
@@ -85,6 +152,7 @@ export class InMemoryTurnWorld {
private readonly dirtyGeneralIds = new Set<number>();
private readonly dirtyCityIds = new Set<number>();
private readonly dirtyNationIds = new Set<number>();
private readonly createdGeneralIds = new Set<number>();
private readonly logs: LogEntryDraft[] = [];
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -118,6 +186,34 @@ export class InMemoryTurnWorld {
return { ...this.state };
}
getGeneralById(id: number): TurnGeneral | null {
return this.generals.get(id) ?? null;
}
getCityById(id: number): City | null {
return this.cities.get(id) ?? null;
}
getNationById(id: number): Nation | null {
return this.nations.get(id) ?? null;
}
listGenerals(): TurnGeneral[] {
return Array.from(this.generals.values()).map((general) => ({
...general,
}));
}
listCities(): City[] {
return Array.from(this.cities.values()).map((city) => ({ ...city }));
}
listNations(): Nation[] {
return Array.from(this.nations.values()).map((nation) => ({
...nation,
}));
}
setLastTurnTime(turnTime: Date): void {
const meta = {
...this.state.meta,
@@ -199,6 +295,48 @@ export class InMemoryTurnWorld {
if (result.logs && result.logs.length > 0) {
this.logs.push(...result.logs);
}
if (result.patches) {
for (const patch of result.patches.generals) {
const target = this.generals.get(patch.id);
if (!target) {
continue;
}
this.generals.set(
patch.id,
applyGeneralPatch(target, patch.patch)
);
this.dirtyGeneralIds.add(patch.id);
}
for (const patch of result.patches.cities) {
const target = this.cities.get(patch.id);
if (!target) {
continue;
}
this.cities.set(patch.id, applyCityPatch(target, patch.patch));
this.dirtyCityIds.add(patch.id);
}
for (const patch of result.patches.nations) {
const target = this.nations.get(patch.id);
if (!target) {
continue;
}
this.nations.set(
patch.id,
applyNationPatch(target, patch.patch)
);
this.dirtyNationIds.add(patch.id);
}
}
if (result.created) {
for (const createdGeneral of result.created.generals) {
if (this.generals.has(createdGeneral.id)) {
continue;
}
this.generals.set(createdGeneral.id, { ...createdGeneral });
this.dirtyGeneralIds.add(createdGeneral.id);
this.createdGeneralIds.add(createdGeneral.id);
}
}
return nextTurnAt;
}
@@ -243,10 +381,14 @@ export class InMemoryTurnWorld {
cities: City[];
nations: Nation[];
logs: LogEntryDraft[];
createdGenerals: TurnGeneral[];
} {
const generals = Array.from(this.dirtyGeneralIds)
.map((id) => this.generals.get(id))
.filter((general): general is TurnGeneral => Boolean(general));
const createdGenerals = Array.from(this.createdGeneralIds)
.map((id) => this.generals.get(id))
.filter((general): general is TurnGeneral => Boolean(general));
const cities = Array.from(this.dirtyCityIds)
.map((id) => this.cities.get(id))
.filter((city): city is City => Boolean(city));
@@ -258,7 +400,8 @@ export class InMemoryTurnWorld {
this.dirtyGeneralIds.clear();
this.dirtyCityIds.clear();
this.dirtyNationIds.clear();
this.createdGeneralIds.clear();
return { generals, cities, nations, logs };
return { generals, cities, nations, logs, createdGenerals };
}
}
@@ -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(),
};
};
+45 -4
View File
@@ -20,6 +20,8 @@ import type {
import { InMemoryTurnWorld } from './inMemoryWorld.js';
import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
import { InMemoryTurnStateStore } from './inMemoryStateStore.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
import { loadTurnWorldFromDatabase } from './worldLoader.js';
export interface TurnDaemonRuntimeOptions {
@@ -71,24 +73,63 @@ export const createTurnDaemonRuntime = async (
? { ...state, tickSeconds: tickMinutes * 60 }
: state;
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
const reservedTurnStoreHandle = options.generalTurnHandler
? null
: await createReservedTurnStore({
databaseUrl: options.databaseUrl,
});
let worldRef: InMemoryTurnWorld | null = null;
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler: options.generalTurnHandler,
generalTurnHandler:
options.generalTurnHandler ??
createReservedTurnHandler({
reservedTurns: reservedTurnStoreHandle!.store,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
diplomacy: snapshot.diplomacy,
getWorld: () => worldRef,
}),
calendarHandler: options.calendarHandler,
};
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world;
const stateStore = new InMemoryTurnStateStore(world);
const processor = new InMemoryTurnProcessor(world, { tickMinutes });
const processor = new InMemoryTurnProcessor(world, {
tickMinutes,
beforeExecuteGeneral: reservedTurnStoreHandle
? async (general) => {
await reservedTurnStoreHandle.store.refreshGeneralTurns(
general.id
);
if (general.nationId > 0 && general.officerLevel >= 5) {
await reservedTurnStoreHandle.store.refreshNationTurns(
general.nationId,
general.officerLevel
);
}
}
: undefined,
});
const controlQueue = options.controlQueue ?? new InMemoryControlQueue();
const clock = options.clock ?? new SystemClock();
let hooks: TurnDaemonHooks | undefined;
let close = async () => {};
if (options.enableDatabaseFlush ?? true) {
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world);
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
reservedTurns: reservedTurnStoreHandle?.store,
});
hooks = dbHooks.hooks;
close = dbHooks.close;
close = async () => {
await dbHooks.close();
if (reservedTurnStoreHandle) {
await reservedTurnStoreHandle.close();
}
};
} else if (reservedTurnStoreHandle) {
close = async () => reservedTurnStoreHandle.close();
}
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {