feat: 턴 데몬 명령 처리 및 결과 응답 기능 추가

This commit is contained in:
2026-01-04 16:05:22 +00:00
parent b2a625d4e7
commit 2753b85a26
12 changed files with 716 additions and 63 deletions
@@ -248,6 +248,7 @@ export const createDatabaseTurnHooks = async (
cities,
nations,
troops,
deletedTroops,
diplomacy,
logs,
createdGenerals,
@@ -293,6 +294,11 @@ export const createDatabaseTurnHooks = async (
data: createdDiplomacy.map(buildDiplomacyCreate),
});
}
if (deletedTroops.length > 0) {
await prisma.troop.deleteMany({
where: { troopLeaderId: { in: deletedTroops } },
});
}
await Promise.all([
...generals
+41
View File
@@ -189,6 +189,7 @@ export class InMemoryTurnWorld {
private readonly createdGeneralIds = new Set<number>();
private readonly createdTroopIds = new Set<number>();
private readonly createdDiplomacyKeys = new Set<string>();
private readonly deletedTroopIds = new Set<number>();
private readonly logs: LogEntryDraft[] = [];
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -297,6 +298,42 @@ export class InMemoryTurnWorld {
}));
}
updateGeneral(
id: number,
patch: Partial<TurnGeneral>
): TurnGeneral | null {
const target = this.generals.get(id);
if (!target) {
return null;
}
const next = applyGeneralPatch(target, patch);
this.generals.set(id, next);
this.dirtyGeneralIds.add(id);
return next;
}
updateTroop(id: number, patch: Partial<Troop>): Troop | null {
const target = this.troops.get(id);
if (!target) {
return null;
}
const next = applyTroopPatch(target, patch);
this.troops.set(id, next);
this.dirtyTroopIds.add(id);
return next;
}
removeTroop(id: number): boolean {
if (!this.troops.has(id)) {
return false;
}
this.troops.delete(id);
this.dirtyTroopIds.delete(id);
this.createdTroopIds.delete(id);
this.deletedTroopIds.add(id);
return true;
}
applyDiplomacyPatch(input: {
srcNationId: number;
destNationId: number;
@@ -510,6 +547,7 @@ export class InMemoryTurnWorld {
cities: City[];
nations: Nation[];
troops: Troop[];
deletedTroops: number[];
diplomacy: TurnDiplomacy[];
logs: LogEntryDraft[];
createdGenerals: TurnGeneral[];
@@ -540,6 +578,7 @@ export class InMemoryTurnWorld {
const createdDiplomacy = Array.from(this.createdDiplomacyKeys)
.map((key) => this.diplomacy.get(key))
.filter((entry): entry is TurnDiplomacy => Boolean(entry));
const deletedTroops = Array.from(this.deletedTroopIds);
const logs = this.logs.splice(0, this.logs.length);
this.dirtyGeneralIds.clear();
@@ -550,12 +589,14 @@ export class InMemoryTurnWorld {
this.createdGeneralIds.clear();
this.createdTroopIds.clear();
this.createdDiplomacyKeys.clear();
this.deletedTroopIds.clear();
return {
generals,
cities,
nations,
troops,
deletedTroops,
diplomacy,
logs,
createdGenerals,
@@ -0,0 +1,137 @@
import type { TurnDaemonHooks, TurnDaemonCommandHandler, TurnDaemonCommandResult, TurnRunResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
const state = world.getState();
return {
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
checkpoint: world.getCheckpoint(),
};
};
const flushWorld = async (
world: InMemoryTurnWorld,
hooks?: TurnDaemonHooks
): Promise<void> => {
if (!hooks?.flushChanges) {
return;
}
await hooks.flushChanges(buildFlushResult(world));
};
export const createTurnDaemonCommandHandler = (options: {
world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
}): TurnDaemonCommandHandler => {
return {
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
if (command.type === 'troopJoin') {
const general = options.world.getGeneralById(command.generalId);
if (!general) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (general.troopId !== 0) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '이미 부대에 소속되어 있습니다.',
};
}
if (general.nationId <= 0) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '국가에 소속되어 있지 않습니다.',
};
}
const troop = options.world.getTroopById(command.troopId);
if (!troop || troop.nationId !== general.nationId) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '부대가 올바르지 않습니다.',
};
}
options.world.updateGeneral(command.generalId, {
troopId: command.troopId,
});
await flushWorld(options.world, options.hooks);
return {
type: 'troopJoin',
ok: true,
generalId: command.generalId,
troopId: command.troopId,
};
}
if (command.type === 'troopExit') {
const general = options.world.getGeneralById(command.generalId);
if (!general) {
return {
type: 'troopExit',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (general.troopId === 0) {
return {
type: 'troopExit',
ok: false,
generalId: command.generalId,
reason: '부대에 소속되어 있지 않습니다.',
};
}
if (general.troopId !== general.id) {
options.world.updateGeneral(command.generalId, {
troopId: 0,
});
await flushWorld(options.world, options.hooks);
return {
type: 'troopExit',
ok: true,
generalId: command.generalId,
wasLeader: false,
};
}
const troopId = general.troopId;
const members = options.world
.listGenerals()
.filter((entry) => entry.troopId === troopId);
for (const member of members) {
options.world.updateGeneral(member.id, { troopId: 0 });
}
options.world.removeTroop(troopId);
await flushWorld(options.world, options.hooks);
return {
type: 'troopExit',
ok: true,
generalId: command.generalId,
wasLeader: true,
};
}
return null;
},
};
};
+56 -5
View File
@@ -1,4 +1,5 @@
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { SystemClock } from '../lifecycle/clock.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
@@ -10,6 +11,7 @@ import type {
TurnRunBudget,
} from '../lifecycle/types.js';
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
import { buildTurnDaemonStreamKeys, RedisTurnDaemonCommandStream } from '../lifecycle/redisCommandStream.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { createDatabaseTurnHooks } from './databaseHooks.js';
import type {
@@ -24,6 +26,7 @@ import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
import { createTurnDaemonCommandHandler } from './troopCommandHandler.js';
import { loadTurnCommandProfile } from './turnCommandProfile.js';
import { loadTurnWorldFromDatabase } from './worldLoader.js';
@@ -45,6 +48,8 @@ export interface TurnDaemonRuntimeOptions {
commandProfile?: TurnCommandProfile;
commandProfilePath?: string;
adminActionIntervalMs?: number;
redisUrl?: string;
commandStreamStartId?: string;
}
export interface TurnDaemonRuntime {
@@ -68,6 +73,19 @@ const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({
entries: [{ startMinute: 0, tickMinutes }],
});
const resolveRedisConfig = (
redisUrl?: string,
env: NodeJS.ProcessEnv = process.env
) => {
if (redisUrl) {
return { url: redisUrl };
}
if (!env.REDIS_URL) {
return null;
}
return resolveRedisConfigFromEnv(env);
};
export const createTurnDaemonRuntime = async (
options: TurnDaemonRuntimeOptions
): Promise<TurnDaemonRuntime> => {
@@ -135,6 +153,10 @@ export const createTurnDaemonRuntime = async (
let hooks: TurnDaemonHooks | undefined;
let close = async () => {};
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
let redisConnector:
| ReturnType<typeof createRedisConnector>
| null = null;
let pauseGate: (() => Promise<boolean>) | undefined;
let adminActionConsumer: Awaited<
ReturnType<typeof createGatewayAdminActionConsumer>
@@ -197,6 +219,33 @@ export const createTurnDaemonRuntime = async (
};
}
const redisConfig = resolveRedisConfig(options.redisUrl);
if (redisConfig) {
redisConnector = createRedisConnector(redisConfig);
await redisConnector.connect();
redisCommandStream = new RedisTurnDaemonCommandStream(
redisConnector.client,
{
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
startId: options.commandStreamStartId,
}
);
}
const baseClose = close;
close = async () => {
await baseClose();
if (redisConnector) {
await redisConnector.disconnect();
}
};
const resolvedControlQueue = options.controlQueue ?? redisCommandStream ?? controlQueue;
const commandHandler = createTurnDaemonCommandHandler({
world,
hooks,
});
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
budgetMs: 5000,
maxGenerals: 200,
@@ -206,13 +255,15 @@ export const createTurnDaemonRuntime = async (
const lifecycle = new TurnDaemonLifecycle(
{
clock,
controlQueue,
controlQueue: resolvedControlQueue,
getNextTickTime: (lastTurnTime) =>
getNextTickTime(lastTurnTime, tickMinutes),
stateStore,
processor,
hooks,
pauseGate,
commandHandler,
commandResponder: redisCommandStream ?? undefined,
},
{ profile: options.profile, defaultBudget }
);
@@ -232,14 +283,14 @@ export const createTurnDaemonRuntime = async (
}
switch (action.action) {
case 'RESUME':
controlQueue.enqueue({ type: 'resume', reason });
resolvedControlQueue.enqueue({ type: 'resume', reason });
return { status: 'APPLIED', detail: 'resume queued' };
case 'PAUSE':
controlQueue.enqueue({ type: 'pause', reason });
resolvedControlQueue.enqueue({ type: 'pause', reason });
return { status: 'APPLIED', detail: 'pause queued' };
case 'STOP':
case 'SHUTDOWN':
controlQueue.enqueue({ type: 'shutdown', reason });
resolvedControlQueue.enqueue({ type: 'shutdown', reason });
return { status: 'APPLIED', detail: 'shutdown queued' };
default:
return { status: 'IGNORED', detail: 'not implemented' };
@@ -252,7 +303,7 @@ export const createTurnDaemonRuntime = async (
return {
lifecycle,
world,
controlQueue,
controlQueue: resolvedControlQueue,
stateStore,
processor,
hooks,