diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 4f791e25..71e42f15 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -22,6 +22,7 @@ export interface WorldStateRow { export interface GeneralRow { id: number; + userId: number | null; name: string; nationId: number; cityId: number; diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 143135ef..48bf5e64 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -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( diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 00e6f82c..1b4b01ef 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -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 + 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 { 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; } diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 2a63e53a..dd067894 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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)) diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 8f65e593..cc356307 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -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(); private readonly createdDiplomacyKeys = new Set(); private readonly deletedTroopIds = new Set(); + private readonly deletedGeneralIds = new Set(); 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 | 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 | 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 | 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, diff --git a/app/game-engine/src/turn/troopCommandHandler.ts b/app/game-engine/src/turn/troopCommandHandler.ts deleted file mode 100644 index 04b55fb4..00000000 --- a/app/game-engine/src/turn/troopCommandHandler.ts +++ /dev/null @@ -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 => { - if (!hooks?.flushChanges) { - return; - } - await hooks.flushChanges(buildFlushResult(world)); -}; - -export const createTurnDaemonCommandHandler = (options: { - world: InMemoryTurnWorld; - hooks?: TurnDaemonHooks; -}): TurnDaemonCommandHandler => { - return { - handle: async (command): Promise => { - 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; - }, - }; -}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 9807af57..c066d991 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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'; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts new file mode 100644 index 00000000..8f679eb3 --- /dev/null +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -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 => { + if (!hooks?.flushChanges) { + return; + } + await hooks.flushChanges(buildFlushResult(world)); +}; + +interface CommandHandlerContext { + world: InMemoryTurnWorld; + hooks?: TurnDaemonHooks; +} + +async function handleTroopJoin( + ctx: CommandHandlerContext, + command: Extract +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 | 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 => { + 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; + } + }, + }; +}; diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index ef3b71bb..431b5ae5 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -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 }