feat: 턴 데몬 명령 처리 및 결과 응답 기능 추가
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user