From b2a625d4e7fc5d8b609281c404d4e0e15e07b070 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 4 Jan 2026 13:54:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B5=B0=EB=8C=80=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=A0=95=EC=9D=98=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/context.ts | 9 +- app/game-api/src/router.ts | 100 ++++++++++- app/game-api/src/turns/commandTable.ts | 14 +- app/game-api/tsconfig.json | 11 +- .../src/turn/reservedTurnHandler.ts | 6 + app/gateway-frontend/tsconfig.json | 8 +- packages/infra/src/db.ts | 25 ++- .../logic/src/actions/turn/actionContext.ts | 9 +- .../src/actions/turn/general/che_집합.ts | 135 +++++++++++++++ .../logic/src/actions/turn/general/index.ts | 2 + .../actions/turn/nation/che_부대탈퇴지시.ts | 161 ++++++++++++++++++ .../logic/src/actions/turn/nation/index.ts | 2 + packages/logic/src/constraints/presets.ts | 1 + packages/logic/src/constraints/troop.ts | 56 ++++++ packages/logic/src/constraints/types.ts | 1 + resources/turn-commands/default.json | 2 + 16 files changed, 534 insertions(+), 8 deletions(-) create mode 100644 packages/logic/src/actions/turn/general/che_집합.ts create mode 100644 packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts create mode 100644 packages/logic/src/constraints/troop.ts diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index e31f6ad..4f791e2 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -106,13 +106,20 @@ export interface NationRow { meta: unknown; } +export interface TroopRow { + troopLeaderId: number; + nationId: number; + name: string; +} + export type DatabaseClient = InfraDatabaseClient< WorldStateRow, GeneralRow, CityRow, NationRow, GeneralTurnRow, - NationTurnRow + NationTurnRow, + TroopRow >; export interface GameApiContext { diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index b58ee52..c173891 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -196,7 +196,7 @@ export const appRouter = router({ }); } - const [city, nation] = await Promise.all([ + const [city, nation, nationGenerals] = await Promise.all([ general.cityId > 0 ? ctx.db.city.findUnique({ where: { id: general.cityId }, @@ -207,6 +207,11 @@ export const appRouter = router({ where: { id: general.nationId }, }) : null, + general.nationId > 0 + ? ctx.db.general.findMany({ + where: { nationId: general.nationId }, + }) + : Promise.resolve(null), ]); return buildTurnCommandTable({ @@ -214,6 +219,7 @@ export const appRouter = router({ general, city, nation, + nationGenerals, }); }), reserved: router({ @@ -619,6 +625,98 @@ export const appRouter = router({ return { msgType, msgId: result.receiverId }; }), }), + troop: router({ + join: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + troopId: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + if (general.troopId !== 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Already in a troop.', + }); + } + if (general.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'General is not part of a nation.', + }); + } + + const troop = await ctx.db.troop.findUnique({ + where: { troopLeaderId: input.troopId }, + }); + if (!troop || troop.nationId !== general.nationId) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Troop is invalid.', + }); + } + + await ctx.db.general.update({ + where: { id: general.id }, + data: { troopId: input.troopId }, + }); + + return { ok: true }; + }), + exit: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + if (general.troopId === 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Not in a troop.', + }); + } + + if (general.troopId !== general.id) { + await ctx.db.general.update({ + where: { id: general.id }, + data: { troopId: 0 }, + }); + return { ok: true, wasLeader: false }; + } + + await ctx.db.$transaction([ + ctx.db.general.updateMany({ + where: { troopId: general.troopId }, + data: { troopId: 0 }, + }), + ctx.db.troop.deleteMany({ + where: { troopLeaderId: general.troopId }, + }), + ]); + + return { ok: true, wasLeader: true }; + }), + }), turnDaemon: router({ run: procedure .input( diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 2bd7f75..01d59df 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -117,6 +117,8 @@ class MemoryStateView implements StateView { switch (req.kind) { case 'general': return `general:${req.id}`; + case 'generalList': + return 'general:list'; case 'city': return `city:${req.id}`; case 'nation': @@ -382,7 +384,8 @@ const mapNationRow = (row: NationRow): Nation => ({ const buildStateView = ( general: General, city: City | null, - nation: Nation | null + nation: Nation | null, + generalList: General[] | null ): StateView => { const view = new MemoryStateView(); view.set({ kind: 'general', id: general.id }, general); @@ -392,6 +395,9 @@ const buildStateView = ( if (nation) { view.set({ kind: 'nation', id: nation.id }, nation); } + if (generalList) { + view.set({ kind: 'generalList' }, generalList); + } return view; }; @@ -538,12 +544,16 @@ export const buildTurnCommandTable = async (options: { general: GeneralRow; city: CityRow | null; nation: NationRow | null; + nationGenerals: GeneralRow[] | null; }): Promise => { // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. const general = mapGeneralRow(options.general); const city = options.city ? mapCityRow(options.city) : null; const nation = options.nation ? mapNationRow(options.nation) : null; - const view = buildStateView(general, city, nation); + const generalList = options.nationGenerals + ? options.nationGenerals.map(mapGeneralRow) + : null; + const view = buildStateView(general, city, nation, generalList); const ctx: ConstraintContext = { actorId: general.id, diff --git a/app/game-api/tsconfig.json b/app/game-api/tsconfig.json index 8db040b..ae598fd 100644 --- a/app/game-api/tsconfig.json +++ b/app/game-api/tsconfig.json @@ -3,7 +3,16 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "composite": true + "composite": true, + "baseUrl": ".", + "paths": { + "@sammo-ts/common": ["../../packages/common/src/index.ts"], + "@sammo-ts/common/*": ["../../packages/common/src/*"], + "@sammo-ts/infra": ["../../packages/infra/src/index.ts"], + "@sammo-ts/infra/*": ["../../packages/infra/src/*"], + "@sammo-ts/logic": ["../../packages/logic/src/index.ts"], + "@sammo-ts/logic/*": ["../../packages/logic/src/*"] + } }, "include": ["src"], "references": [ diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index ad6980e..6ee0b4b 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -120,6 +120,7 @@ type WorldView = { getGeneralById(id: number): TurnGeneral | null; getCityById(id: number): City | null; getNationById(id: number): Nation | null; + getTroopById(id: number): Troop | null; getDiplomacyEntry( srcNationId: number, destNationId: number @@ -127,6 +128,7 @@ type WorldView = { listGenerals(): TurnGeneral[]; listCities(): City[]; listNations(): Nation[]; + listTroops(): Troop[]; listDiplomacy(): TurnDiplomacy[]; }; @@ -230,6 +232,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => { getCityById: (id) => cityOverrides.get(id) ?? world.getCityById(id), getNationById: (id) => nationOverrides.get(id) ?? world.getNationById(id), + getTroopById: (id) => world.getTroopById(id), getDiplomacyEntry: (srcNationId, destNationId) => diplomacyOverrides.get( buildDiplomacyKey(srcNationId, destNationId) @@ -246,6 +249,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => { mergeList(world.listNations(), nationOverrides).map((nation) => ({ ...nation, })), + listTroops: () => world.listTroops().map((troop) => ({ ...troop })), listDiplomacy: () => mergeDiplomacyList( world.listDiplomacy(), @@ -330,6 +334,8 @@ class WorldStateView implements StateView { return this.overrides.general; } return this.world.getGeneralById(req.id); + case 'generalList': + return this.world.listGenerals(); case 'destGeneral': return this.world.getGeneralById(req.id); case 'city': diff --git a/app/gateway-frontend/tsconfig.json b/app/gateway-frontend/tsconfig.json index 256a2d3..260f270 100644 --- a/app/gateway-frontend/tsconfig.json +++ b/app/gateway-frontend/tsconfig.json @@ -23,7 +23,13 @@ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@sammo-ts/common": ["../../packages/common/src/index.ts"], + "@sammo-ts/common/*": ["../../packages/common/src/*"], + "@sammo-ts/infra": ["../../packages/infra/src/index.ts"], + "@sammo-ts/infra/*": ["../../packages/infra/src/*"], + "@sammo-ts/logic": ["../../packages/logic/src/index.ts"], + "@sammo-ts/logic/*": ["../../packages/logic/src/*"] } }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 3d8b7c4..bb6823c 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -4,8 +4,12 @@ export interface DatabaseClient< CityRow = unknown, NationRow = unknown, GeneralTurnRow = unknown, - NationTurnRow = unknown + NationTurnRow = unknown, + TroopRow = unknown > { + $transaction( + queries: Array> + ): Promise; $queryRaw( query: TemplateStringsArray, ...values: unknown[] @@ -19,9 +23,20 @@ export interface DatabaseClient< where: { userId?: string; npcState?: number | { gt: number } }; select?: { name?: boolean; picture?: boolean }; }): Promise; + findMany(args: { + where?: { nationId?: number }; + }): Promise; count(args?: { where?: { npcState?: number | { gt: number } }; }): Promise; + update(args: { + where: { id: number }; + data: Partial; + }): Promise; + updateMany(args: { + where: { troopId?: number }; + data: Partial; + }): Promise; }; city: { findUnique(args: { where: { id: number } }): Promise; @@ -63,4 +78,12 @@ export interface DatabaseClient< }>; }): Promise; }; + troop: { + findUnique(args: { + where: { troopLeaderId: number }; + }): Promise; + deleteMany(args: { + where: { troopLeaderId: number }; + }): Promise; + }; } diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index 6108d0c..7995c3a 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -1,4 +1,9 @@ -import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { + City, + General, + Nation, + Troop, +} from '@sammo-ts/logic/domain/entities.js'; import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js'; import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; @@ -32,6 +37,7 @@ export interface ActionContextWorldRef { listGenerals(): ActionContextGeneral[]; listCities(): City[]; listNations(): Nation[]; + listTroops(): Troop[]; listDiplomacy(): Array<{ fromNationId: number; toNationId: number; @@ -48,6 +54,7 @@ export interface ActionContextWorldRef { getGeneralById(id: number): ActionContextGeneral | null; getCityById(id: number): City | null; getNationById(id: number): Nation | null; + getTroopById(id: number): Troop | null; } export interface ActionContextOptions { diff --git a/packages/logic/src/actions/turn/general/che_집합.ts b/packages/logic/src/actions/turn/general/che_집합.ts new file mode 100644 index 0000000..5a15aba --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_집합.ts @@ -0,0 +1,135 @@ +import type { General, GeneralTriggerState, Troop } from '@sammo-ts/logic/domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '@sammo-ts/logic/constraints/types.js'; +import { + mustBeTroopLeader, + notBeNeutral, + occupiedCity, + reqTroopMembers, + suppliedCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, +} from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import { increaseMetaNumber } from '@sammo-ts/logic/war/utils.js'; + +export interface AssemblyArgs {} + +export interface AssemblyResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + troop: Troop | null; + troopMembers: Array>; +} + +const ACTION_NAME = '집합'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + AssemblyArgs, + AssemblyResolveContext + > { + public readonly key = 'che_집합'; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): AssemblyArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: AssemblyArgs + ): Constraint[] { + return [ + notBeNeutral(), + occupiedCity(), + suppliedCity(), + mustBeTroopLeader(), + reqTroopMembers(), + ]; + } + + resolve( + context: AssemblyResolveContext, + _args: AssemblyArgs + ): GeneralActionOutcome { + const city = context.city; + if (!city) { + context.addLog('도시 정보가 없어 집합을 진행할 수 없습니다.'); + return { effects: [] }; + } + + const general = context.general; + const troopName = context.troop?.name ?? '부대'; + const cityName = city.name; + const josaRo = JosaUtil.pick(cityName, '로'); + + context.addLog(`${cityName}에서 집합을 실시했습니다.`); + + const effects: Array> = []; + const targets = context.troopMembers.filter( + (member) => member.cityId !== city.id + ); + for (const member of targets) { + effects.push( + createGeneralPatchEffect( + { cityId: city.id } as Partial>, + member.id + ) + ); + effects.push( + createLogEffect( + `${troopName} 부대원들은 ${cityName}${josaRo} 집합되었습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + generalId: member.id, + } + ) + ); + } + + general.experience += 70; + general.dedication += 100; + increaseMetaNumber(general.meta, 'leadership_exp', 1); + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const troopId = base.general.troopId; + const troop = options.worldRef?.getTroopById(troopId) ?? null; + const troopMembers = + options.worldRef?.listGenerals().filter( + (member) => member.troopId === troopId && member.id !== base.general.id + ) ?? []; + return { + ...base, + troop, + troopMembers, + }; +}; + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_집합', + category: '군사', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index ddaca93..409b1ce 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -21,6 +21,7 @@ export const GENERAL_TURN_COMMAND_KEYS = [ 'che_수비강화', 'che_성벽보수', 'che_화계', + 'che_집합', 'che_인재탐색', 'che_징병', '휴식', @@ -61,6 +62,7 @@ const defaultImporters: Record< che_수비강화: async () => import('./che_수비강화.js'), che_성벽보수: async () => import('./che_성벽보수.js'), che_화계: async () => import('./che_화계.js'), + che_집합: async () => import('./che_집합.js'), che_인재탐색: async () => import('./che_인재탐색.js'), che_징병: async () => import('./che_징병.js'), 휴식: async () => import('./휴식.js'), diff --git a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts new file mode 100644 index 0000000..5ae407b --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts @@ -0,0 +1,161 @@ +import type { + General, + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '@sammo-ts/logic/constraints/types.js'; +import { + alwaysFail, + beChief, + existsDestGeneral, + friendlyDestGeneral, + notBeNeutral, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, +} from '@sammo-ts/logic/actions/engine.js'; +import { + createGeneralPatchEffect, + createLogEffect, +} from '@sammo-ts/logic/actions/engine.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; + +export interface TroopKickArgs { + destGeneralId: number; +} + +export interface TroopKickResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destGeneral: General; +} + +const ACTION_NAME = '부대 탈퇴 지시'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + TroopKickArgs, + TroopKickResolveContext + > { + public readonly key = 'che_부대탈퇴지시'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): TroopKickArgs | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const data = raw as { destGeneralId?: unknown }; + if (typeof data.destGeneralId !== 'number') { + return null; + } + if (data.destGeneralId <= 0) { + return null; + } + return { destGeneralId: data.destGeneralId }; + } + + buildConstraints( + ctx: ConstraintContext, + _args: TroopKickArgs + ): Constraint[] { + if (ctx.destGeneralId !== undefined && ctx.destGeneralId === ctx.actorId) { + return [alwaysFail('본인입니다')]; + } + return [ + notBeNeutral(), + beChief(), + existsDestGeneral(), + friendlyDestGeneral(), + ]; + } + + resolve( + context: TroopKickResolveContext, + _args: TroopKickArgs + ): GeneralActionOutcome { + const general = context.general; + const destGeneral = context.destGeneral; + const destGeneralName = destGeneral.name; + const josaUn = JosaUtil.pick(destGeneralName, '은'); + const effects: Array> = []; + + if (destGeneral.troopId === 0) { + context.addLog( + `${destGeneralName}${josaUn} 부대원이 아닙니다.` + ); + return { effects }; + } + + if (destGeneral.troopId === destGeneral.id) { + context.addLog( + `${destGeneralName}${josaUn} 부대장입니다.` + ); + return { effects }; + } + + effects.push( + createGeneralPatchEffect( + { troopId: 0 } as Partial>, + destGeneral.id + ) + ); + + effects.push( + createLogEffect( + `${destGeneralName}에게 부대 탈퇴를 지시했습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ) + ); + effects.push( + createLogEffect( + `${general.name}에게 부대 탈퇴를 지시 받았습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + generalId: destGeneral.id, + } + ) + ); + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destGeneralId = options.actionArgs.destGeneralId; + if (typeof destGeneralId !== 'number') { + return null; + } + const destGeneral = options.worldRef?.getGeneralById(destGeneralId); + if (!destGeneral) { + return null; + } + return { + ...base, + destGeneral, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_부대탈퇴지시', + category: '인사', + reqArg: true, + args: { destGeneralId: 0 }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index 9868fd6..26c241c 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -3,6 +3,7 @@ import type { TurnCommandModule, TurnCommandSpecBase } from '@sammo-ts/logic/act export const NATION_TURN_COMMAND_KEYS = [ '휴식', 'che_포상', + 'che_부대탈퇴지시', 'che_발령', 'che_선전포고', 'che_불가침제의', @@ -33,6 +34,7 @@ const defaultImporters: Record< > = { 휴식: async () => import('./휴식.js'), che_포상: async () => import('./che_포상.js'), + che_부대탈퇴지시: async () => import('./che_부대탈퇴지시.js'), che_발령: async () => import('./che_발령.js'), che_선전포고: async () => import('./che_선전포고.js'), che_불가침제의: async () => import('./che_불가침제의.js'), diff --git a/packages/logic/src/constraints/presets.ts b/packages/logic/src/constraints/presets.ts index a2b0ac6..e67d1da 100644 --- a/packages/logic/src/constraints/presets.ts +++ b/packages/logic/src/constraints/presets.ts @@ -4,3 +4,4 @@ export * from './general.js'; export * from './helpers.js'; export * from './misc.js'; export * from './nation.js'; +export * from './troop.js'; diff --git a/packages/logic/src/constraints/troop.ts b/packages/logic/src/constraints/troop.ts new file mode 100644 index 0000000..a74d617 --- /dev/null +++ b/packages/logic/src/constraints/troop.ts @@ -0,0 +1,56 @@ +import type { General } from '@sammo-ts/logic/domain/entities.js'; +import { allow, unknownOrDeny } from './helpers.js'; +import type { Constraint, RequirementKey } from './types.js'; + +export const mustBeTroopLeader = (): Constraint => ({ + name: 'MustBeTroopLeader', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + if (general.id === general.troopId) { + return allow(); + } + return { kind: 'deny', reason: '부대장이 아닙니다.' }; + }, +}); + +export const reqTroopMembers = (): Constraint => ({ + name: 'ReqTroopMembers', + requires: (ctx) => [ + { kind: 'general', id: ctx.actorId }, + { kind: 'generalList' }, + ], + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const listReq: RequirementKey = { kind: 'generalList' }; + if (!view.has(listReq)) { + return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.'); + } + const generals = view.get(listReq) as General[] | null; + if (!generals) { + return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.'); + } + const hasMember = generals.some( + (entry) => + entry.troopId === general.troopId && entry.id !== general.id + ); + if (hasMember) { + return allow(); + } + return { kind: 'deny', reason: '집합 가능한 부대원이 없습니다.' }; + }, +}); diff --git a/packages/logic/src/constraints/types.ts b/packages/logic/src/constraints/types.ts index 1d53d55..66b31b6 100644 --- a/packages/logic/src/constraints/types.ts +++ b/packages/logic/src/constraints/types.ts @@ -5,6 +5,7 @@ export type ConstraintResult = export type RequirementKey = | { kind: 'general'; id: number } + | { kind: 'generalList' } | { kind: 'city'; id: number } | { kind: 'nation'; id: number } | { kind: 'destGeneral'; id: number } diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index ff5cf84..afbda80 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -20,6 +20,7 @@ "che_수비강화", "che_성벽보수", "che_화계", + "che_집합", "che_인재탐색", "che_징병", "휴식" @@ -27,6 +28,7 @@ "nation": [ "휴식", "che_포상", + "che_부대탈퇴지시", "che_발령", "che_선전포고", "che_불가침제의",