feat: 장수 관련 명령어 처리 및 설정 기능 추가
This commit is contained in:
@@ -22,6 +22,7 @@ export interface WorldStateRow {
|
||||
|
||||
export interface GeneralRow {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
|
||||
@@ -34,6 +34,13 @@ import { loadWorldMap } from './maps/worldMap.js';
|
||||
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
const zGeneralSettings = z.object({
|
||||
tnmt: z.number().int().optional(),
|
||||
defence_train: z.number().int().optional(),
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
});
|
||||
|
||||
const zTurnRunBudget = z.object({
|
||||
budgetMs: z.number().int().positive(),
|
||||
maxGenerals: z.number().int().positive(),
|
||||
@@ -60,6 +67,19 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const getMyGeneral = async (ctx: { db: any, auth: any }) => {
|
||||
if (!ctx.auth?.user.id) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId: parseInt(ctx.auth.user.id) },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
}
|
||||
return general;
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
health: router({
|
||||
ping: procedure.query(({ ctx }) => ({
|
||||
@@ -693,6 +713,161 @@ export const appRouter = router({
|
||||
return { ok: true, wasLeader: result.wasLeader };
|
||||
}),
|
||||
}),
|
||||
general: router({
|
||||
dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'dieOnPrestart',
|
||||
generalId: general.id,
|
||||
});
|
||||
if (!result || result.type !== 'dieOnPrestart') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
buildNationCandidate: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'buildNationCandidate',
|
||||
generalId: general.id,
|
||||
});
|
||||
if (!result || result.type !== 'buildNationCandidate') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
instantRetreat: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'instantRetreat',
|
||||
generalId: general.id,
|
||||
});
|
||||
if (!result || result.type !== 'instantRetreat') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
vacation: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'vacation',
|
||||
generalId: general.id,
|
||||
});
|
||||
if (!result || result.type !== 'vacation') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
setMySetting: authedProcedure
|
||||
.input(zGeneralSettings)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: input,
|
||||
});
|
||||
if (!result || result.type !== 'setMySetting') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
dropItem: authedProcedure
|
||||
.input(z.object({ itemType: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'dropItem',
|
||||
generalId: general.id,
|
||||
itemType: input.itemType,
|
||||
});
|
||||
if (!result || result.type !== 'dropItem') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
}),
|
||||
nation: router({
|
||||
changePermission: authedProcedure
|
||||
.input(z.object({
|
||||
isAmbassador: z.boolean(),
|
||||
targetGeneralIds: z.array(z.number().int().positive()),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'changePermission',
|
||||
generalId: general.id,
|
||||
isAmbassador: input.isAmbassador,
|
||||
targetGeneralIds: input.targetGeneralIds,
|
||||
});
|
||||
if (!result || result.type !== 'changePermission') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
kick: authedProcedure
|
||||
.input(z.object({ destGeneralId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'kick',
|
||||
generalId: general.id,
|
||||
destGeneralId: input.destGeneralId,
|
||||
});
|
||||
if (!result || result.type !== 'kick') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
appoint: authedProcedure
|
||||
.input(z.object({
|
||||
destGeneralId: z.number().int().nonnegative(),
|
||||
destCityId: z.number().int().nonnegative(),
|
||||
officerLevel: z.number().int().nonnegative(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'appoint',
|
||||
generalId: general.id,
|
||||
destGeneralId: input.destGeneralId,
|
||||
destCityId: input.destCityId,
|
||||
officerLevel: input.officerLevel,
|
||||
});
|
||||
if (!result || result.type !== 'appoint') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
}),
|
||||
turnDaemon: router({
|
||||
run: procedure
|
||||
.input(
|
||||
|
||||
@@ -246,13 +246,35 @@ export class TurnDaemonLifecycle {
|
||||
return;
|
||||
case 'troopJoin':
|
||||
case 'troopExit':
|
||||
case 'dieOnPrestart':
|
||||
case 'buildNationCandidate':
|
||||
case 'instantRetreat':
|
||||
case 'vacation':
|
||||
case 'setMySetting':
|
||||
case 'dropItem':
|
||||
case 'changePermission':
|
||||
case 'kick':
|
||||
case 'appoint':
|
||||
await this.handleMutationCommand(command);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMutationCommand(
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopJoin' | 'troopExit' }>
|
||||
command: Extract<
|
||||
TurnDaemonCommand,
|
||||
| { type: 'troopJoin' }
|
||||
| { type: 'troopExit' }
|
||||
| { type: 'dieOnPrestart' }
|
||||
| { type: 'buildNationCandidate' }
|
||||
| { type: 'instantRetreat' }
|
||||
| { type: 'vacation' }
|
||||
| { type: 'setMySetting' }
|
||||
| { type: 'dropItem' }
|
||||
| { type: 'changePermission' }
|
||||
| { type: 'kick' }
|
||||
| { type: 'appoint' }
|
||||
>
|
||||
): Promise<void> {
|
||||
let result: TurnDaemonCommandResult | null = null;
|
||||
try {
|
||||
@@ -264,14 +286,8 @@ export class TurnDaemonLifecycle {
|
||||
type: command.type,
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
...(command.type === 'troopJoin'
|
||||
? {
|
||||
troopId: command.troopId,
|
||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
||||
}
|
||||
: {
|
||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
||||
}),
|
||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
||||
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}),
|
||||
} as TurnDaemonCommandResult;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -281,9 +297,8 @@ export class TurnDaemonLifecycle {
|
||||
type: command.type,
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
...(command.type === 'troopJoin'
|
||||
? { troopId: command.troopId, reason }
|
||||
: { reason }),
|
||||
reason,
|
||||
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}),
|
||||
} as TurnDaemonCommandResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ export const createDatabaseTurnHooks = async (
|
||||
nations,
|
||||
troops,
|
||||
deletedTroops,
|
||||
deletedGenerals,
|
||||
diplomacy,
|
||||
logs,
|
||||
createdGenerals,
|
||||
@@ -300,6 +301,12 @@ export const createDatabaseTurnHooks = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (deletedGenerals.length > 0) {
|
||||
await prisma.general.deleteMany({
|
||||
where: { id: { in: deletedGenerals } },
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...generals
|
||||
.filter((general) => !createdIds.has(general.id))
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
City,
|
||||
LogEntryDraft,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
Troop,
|
||||
TurnSchedule,
|
||||
} from '@sammo-ts/logic';
|
||||
@@ -190,7 +191,9 @@ export class InMemoryTurnWorld {
|
||||
private readonly createdTroopIds = new Set<number>();
|
||||
private readonly createdDiplomacyKeys = new Set<string>();
|
||||
private readonly deletedTroopIds = new Set<number>();
|
||||
private readonly deletedGeneralIds = new Set<number>();
|
||||
private readonly logs: LogEntryDraft[] = [];
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
private checkpoint?: TurnCheckpoint;
|
||||
private state: TurnWorldState;
|
||||
|
||||
@@ -200,6 +203,7 @@ export class InMemoryTurnWorld {
|
||||
options: InMemoryTurnWorldOptions
|
||||
) {
|
||||
this.state = { ...state };
|
||||
this.scenarioConfig = snapshot.scenarioConfig;
|
||||
this.schedule = options.schedule;
|
||||
this.generalTurnHandler =
|
||||
options.generalTurnHandler ??
|
||||
@@ -237,6 +241,10 @@ export class InMemoryTurnWorld {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
getScenarioConfig(): ScenarioConfig {
|
||||
return this.scenarioConfig;
|
||||
}
|
||||
|
||||
getGeneralById(id: number): TurnGeneral | null {
|
||||
return this.generals.get(id) ?? null;
|
||||
}
|
||||
@@ -312,6 +320,39 @@ export class InMemoryTurnWorld {
|
||||
return next;
|
||||
}
|
||||
|
||||
removeGeneral(id: number): boolean {
|
||||
if (!this.generals.has(id)) {
|
||||
return false;
|
||||
}
|
||||
this.generals.delete(id);
|
||||
this.dirtyGeneralIds.delete(id);
|
||||
this.createdGeneralIds.delete(id);
|
||||
this.deletedGeneralIds.add(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
updateCity(id: number, patch: Partial<City>): City | null {
|
||||
const target = this.cities.get(id);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const next = { ...target, ...patch };
|
||||
this.cities.set(id, next);
|
||||
this.dirtyCityIds.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
updateNation(id: number, patch: Partial<Nation>): Nation | null {
|
||||
const target = this.nations.get(id);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const next = { ...target, ...patch };
|
||||
this.nations.set(id, next);
|
||||
this.dirtyNationIds.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
updateTroop(id: number, patch: Partial<Troop>): Troop | null {
|
||||
const target = this.troops.get(id);
|
||||
if (!target) {
|
||||
@@ -548,6 +589,7 @@ export class InMemoryTurnWorld {
|
||||
nations: Nation[];
|
||||
troops: Troop[];
|
||||
deletedTroops: number[];
|
||||
deletedGenerals: number[];
|
||||
diplomacy: TurnDiplomacy[];
|
||||
logs: LogEntryDraft[];
|
||||
createdGenerals: TurnGeneral[];
|
||||
@@ -579,6 +621,7 @@ export class InMemoryTurnWorld {
|
||||
.map((key) => this.diplomacy.get(key))
|
||||
.filter((entry): entry is TurnDiplomacy => Boolean(entry));
|
||||
const deletedTroops = Array.from(this.deletedTroopIds);
|
||||
const deletedGenerals = Array.from(this.deletedGeneralIds);
|
||||
const logs = this.logs.splice(0, this.logs.length);
|
||||
|
||||
this.dirtyGeneralIds.clear();
|
||||
@@ -590,6 +633,7 @@ export class InMemoryTurnWorld {
|
||||
this.createdTroopIds.clear();
|
||||
this.createdDiplomacyKeys.clear();
|
||||
this.deletedTroopIds.clear();
|
||||
this.deletedGeneralIds.clear();
|
||||
|
||||
return {
|
||||
generals,
|
||||
@@ -597,6 +641,7 @@ export class InMemoryTurnWorld {
|
||||
nations,
|
||||
troops,
|
||||
deletedTroops,
|
||||
deletedGenerals,
|
||||
diplomacy,
|
||||
logs,
|
||||
createdGenerals,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -26,7 +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 { createTurnDaemonCommandHandler } from './worldCommandHandler.js';
|
||||
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
||||
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
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));
|
||||
};
|
||||
|
||||
interface CommandHandlerContext {
|
||||
world: InMemoryTurnWorld;
|
||||
hooks?: TurnDaemonHooks;
|
||||
}
|
||||
|
||||
async function handleTroopJoin(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = 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 = world.getTroopById(command.troopId);
|
||||
if (!troop || troop.nationId !== general.nationId) {
|
||||
return {
|
||||
type: 'troopJoin',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
troopId: command.troopId,
|
||||
reason: '부대가 올바르지 않습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
world.updateGeneral(command.generalId, {
|
||||
troopId: command.troopId,
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'troopJoin',
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
troopId: command.troopId,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTroopExit(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopExit' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = 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) {
|
||||
world.updateGeneral(command.generalId, {
|
||||
troopId: 0,
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'troopExit',
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
wasLeader: false,
|
||||
};
|
||||
}
|
||||
|
||||
const troopId = general.troopId;
|
||||
const members = world
|
||||
.listGenerals()
|
||||
.filter((entry) => entry.troopId === troopId);
|
||||
for (const member of members) {
|
||||
world.updateGeneral(member.id, { troopId: 0 });
|
||||
}
|
||||
world.removeTroop(troopId);
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'troopExit',
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
wasLeader: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDieOnPrestart(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const worldState = world.getState();
|
||||
const opentime = worldState.meta.opentime as string | undefined;
|
||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
||||
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '가오픈 기간이 아닙니다.' };
|
||||
}
|
||||
|
||||
if (general.npcState !== 0 || general.nationId !== 0) {
|
||||
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '삭제할 수 없는 상태입니다.' };
|
||||
}
|
||||
|
||||
world.removeGeneral(command.generalId);
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleBuildNationCandidate(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
if (general.nationId !== 0) {
|
||||
return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '이미 국가에 소속되어 있습니다.' };
|
||||
}
|
||||
|
||||
const worldState = world.getState();
|
||||
const opentime = worldState.meta.opentime as string | undefined;
|
||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
||||
return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '가오픈 기간이 아닙니다.' };
|
||||
}
|
||||
|
||||
return { type: 'buildNationCandidate', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleInstantRetreat(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'instantRetreat' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'instantRetreat', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const config = world.getScenarioConfig();
|
||||
const availableInstantAction = config.const.availableInstantAction as Record<string, boolean> | undefined;
|
||||
if (!availableInstantAction?.instantRetreat) {
|
||||
return { type: 'instantRetreat', ok: false, generalId: command.generalId, reason: '즉시 귀환이 허용되지 않는 서버입니다.' };
|
||||
}
|
||||
|
||||
return { type: 'instantRetreat', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleVacation(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'vacation' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { type: 'vacation', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleSetMySetting(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'setMySetting', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
world.updateGeneral(command.generalId, {
|
||||
meta: {
|
||||
...general.meta,
|
||||
...command.settings,
|
||||
}
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleDropItem(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'dropItem' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const { itemType } = command;
|
||||
const items = { ...general.role.items };
|
||||
if (items.horse === itemType) items.horse = null;
|
||||
else if (items.weapon === itemType) items.weapon = null;
|
||||
else if (items.book === itemType) items.book = null;
|
||||
else if (items.item === itemType) items.item = null;
|
||||
else {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
|
||||
}
|
||||
|
||||
world.updateGeneral(command.generalId, {
|
||||
role: {
|
||||
...general.role,
|
||||
items,
|
||||
}
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'dropItem', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleChangePermission(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'changePermission', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const nation = world.getNationById(general.nationId);
|
||||
if (!nation || nation.chiefGeneralId !== general.id) {
|
||||
return { type: 'changePermission', ok: false, generalId: command.generalId, reason: '권한이 없습니다.' };
|
||||
}
|
||||
|
||||
for (const targetId of command.targetGeneralIds) {
|
||||
const target = world.getGeneralById(targetId);
|
||||
if (target && target.nationId === general.nationId) {
|
||||
world.updateGeneral(targetId, {
|
||||
meta: {
|
||||
...target.meta,
|
||||
permission: command.isAmbassador ? 'ambassador' : 'auditor',
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'changePermission', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleKick(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'kick' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const nation = world.getNationById(general.nationId);
|
||||
if (!nation || nation.chiefGeneralId !== general.id) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '권한이 없습니다.' };
|
||||
}
|
||||
|
||||
const target = world.getGeneralById(command.destGeneralId);
|
||||
if (!target || target.nationId !== general.nationId) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.' };
|
||||
}
|
||||
|
||||
world.updateGeneral(command.destGeneralId, {
|
||||
nationId: 0,
|
||||
officerLevel: 0,
|
||||
});
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'kick', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleAppoint(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'appoint' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const nation = world.getNationById(general.nationId);
|
||||
if (!nation || nation.chiefGeneralId !== general.id) {
|
||||
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '권한이 없습니다.' };
|
||||
}
|
||||
|
||||
const target = world.getGeneralById(command.destGeneralId);
|
||||
if (command.destGeneralId !== 0 && (!target || target.nationId !== general.nationId)) {
|
||||
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.' };
|
||||
}
|
||||
|
||||
if (command.officerLevel >= 5) {
|
||||
for (const g of world.listGenerals()) {
|
||||
if (g.nationId === general.nationId && g.officerLevel === command.officerLevel) {
|
||||
world.updateGeneral(g.id, { officerLevel: 0 });
|
||||
}
|
||||
}
|
||||
if (command.destGeneralId !== 0) {
|
||||
world.updateGeneral(command.destGeneralId, { officerLevel: command.officerLevel });
|
||||
}
|
||||
} else {
|
||||
const city = world.getCityById(command.destCityId);
|
||||
if (!city || city.nationId !== general.nationId) {
|
||||
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '도시를 찾을 수 없거나 아군 도시가 아닙니다.' };
|
||||
}
|
||||
for (const g of world.listGenerals()) {
|
||||
if (g.nationId === general.nationId && g.meta.officerCity === command.destCityId && g.officerLevel === command.officerLevel) {
|
||||
world.updateGeneral(g.id, { officerLevel: 0, meta: { ...g.meta, officerCity: 0 } });
|
||||
}
|
||||
}
|
||||
if (command.destGeneralId !== 0) {
|
||||
world.updateGeneral(command.destGeneralId, {
|
||||
officerLevel: command.officerLevel,
|
||||
meta: { ...target!.meta, officerCity: command.destCityId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'appoint', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
export const createTurnDaemonCommandHandler = (options: {
|
||||
world: InMemoryTurnWorld;
|
||||
hooks?: TurnDaemonHooks;
|
||||
}): TurnDaemonCommandHandler => {
|
||||
const ctx = { world: options.world, hooks: options.hooks };
|
||||
|
||||
return {
|
||||
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
|
||||
switch (command.type) {
|
||||
case 'troopJoin': return handleTroopJoin(ctx, command);
|
||||
case 'troopExit': return handleTroopExit(ctx, command);
|
||||
case 'dieOnPrestart': return handleDieOnPrestart(ctx, command);
|
||||
case 'buildNationCandidate': return handleBuildNationCandidate(ctx, command);
|
||||
case 'instantRetreat': return handleInstantRetreat(ctx, command);
|
||||
case 'vacation': return handleVacation(ctx, command);
|
||||
case 'setMySetting': return handleSetMySetting(ctx, command);
|
||||
case 'dropItem': return handleDropItem(ctx, command);
|
||||
case 'changePermission': return handleChangePermission(ctx, command);
|
||||
case 'kick': return handleKick(ctx, command);
|
||||
case 'appoint': return handleAppoint(ctx, command);
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -27,6 +27,8 @@ export interface TurnRunResult {
|
||||
durationMs: number;
|
||||
partial: boolean;
|
||||
checkpoint?: TurnCheckpoint;
|
||||
deletedGenerals?: number[];
|
||||
deletedTroops?: number[];
|
||||
}
|
||||
|
||||
export interface TurnDaemonStatus {
|
||||
@@ -55,7 +57,39 @@ export type TurnDaemonCommand =
|
||||
| { type: 'shutdown'; reason?: string }
|
||||
| { type: 'getStatus'; requestId?: string }
|
||||
| { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number }
|
||||
| { type: 'troopExit'; requestId?: string; generalId: number };
|
||||
| { type: 'troopExit'; requestId?: string; generalId: number }
|
||||
| { type: 'dieOnPrestart'; requestId?: string; generalId: number }
|
||||
| { type: 'buildNationCandidate'; requestId?: string; generalId: number }
|
||||
| { type: 'instantRetreat'; requestId?: string; generalId: number }
|
||||
| { type: 'vacation'; requestId?: string; generalId: number }
|
||||
| {
|
||||
type: 'setMySetting';
|
||||
requestId?: string;
|
||||
generalId: number;
|
||||
settings: {
|
||||
tnmt?: number;
|
||||
defence_train?: number;
|
||||
use_treatment?: number;
|
||||
use_auto_nation_turn?: number;
|
||||
};
|
||||
}
|
||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||
| {
|
||||
type: 'changePermission';
|
||||
requestId?: string;
|
||||
generalId: number;
|
||||
isAmbassador: boolean;
|
||||
targetGeneralIds: number[];
|
||||
}
|
||||
| { type: 'kick'; requestId?: string; generalId: number; destGeneralId: number }
|
||||
| {
|
||||
type: 'appoint';
|
||||
requestId?: string;
|
||||
generalId: number;
|
||||
destGeneralId: number;
|
||||
destCityId: number;
|
||||
officerLevel: number;
|
||||
};
|
||||
|
||||
export type TurnDaemonCommandResult =
|
||||
| {
|
||||
@@ -82,7 +116,16 @@ export type TurnDaemonCommandResult =
|
||||
ok: false;
|
||||
generalId: number;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
| { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'vacation'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'setMySetting'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'dropItem'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'changePermission'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'kick'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'appoint'; ok: boolean; generalId: number; reason?: string };
|
||||
|
||||
export type TurnDaemonEvent =
|
||||
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
||||
|
||||
Reference in New Issue
Block a user