feat: 장수 관련 명령어 처리 및 설정 기능 추가

This commit is contained in:
2026-01-04 16:51:58 +00:00
parent ef2ced5f5e
commit 0e06ae9c38
9 changed files with 699 additions and 152 deletions
+1
View File
@@ -22,6 +22,7 @@ export interface WorldStateRow {
export interface GeneralRow {
id: number;
userId: number | null;
name: string;
nationId: number;
cityId: number;
+175
View File
@@ -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))
+45
View File
@@ -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;
},
};
};
+1 -1
View File
@@ -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;
}
},
};
};
+45 -2
View File
@@ -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 }