feat: 군대 관련 기능 추가 및 명령어 정의 업데이트

This commit is contained in:
2026-01-04 13:54:30 +00:00
parent 4fe4af868a
commit b2a625d4e7
16 changed files with 534 additions and 8 deletions
+24 -1
View File
@@ -4,8 +4,12 @@ export interface DatabaseClient<
CityRow = unknown,
NationRow = unknown,
GeneralTurnRow = unknown,
NationTurnRow = unknown
NationTurnRow = unknown,
TroopRow = unknown
> {
$transaction<T = unknown>(
queries: Array<Promise<T>>
): Promise<T[]>;
$queryRaw<T = unknown>(
query: TemplateStringsArray,
...values: unknown[]
@@ -19,9 +23,20 @@ export interface DatabaseClient<
where: { userId?: string; npcState?: number | { gt: number } };
select?: { name?: boolean; picture?: boolean };
}): Promise<GeneralRow | null>;
findMany(args: {
where?: { nationId?: number };
}): Promise<GeneralRow[]>;
count(args?: {
where?: { npcState?: number | { gt: number } };
}): Promise<number>;
update(args: {
where: { id: number };
data: Partial<GeneralRow>;
}): Promise<GeneralRow>;
updateMany(args: {
where: { troopId?: number };
data: Partial<GeneralRow>;
}): Promise<unknown>;
};
city: {
findUnique(args: { where: { id: number } }): Promise<CityRow | null>;
@@ -63,4 +78,12 @@ export interface DatabaseClient<
}>;
}): Promise<unknown>;
};
troop: {
findUnique(args: {
where: { troopLeaderId: number };
}): Promise<TroopRow | null>;
deleteMany(args: {
where: { troopLeaderId: number };
}): Promise<unknown>;
};
}
@@ -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 {
@@ -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<TriggerState> {
troop: Troop | null;
troopMembers: Array<General<TriggerState>>;
}
const ACTION_NAME = '집합';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
AssemblyArgs,
AssemblyResolveContext<TriggerState>
> {
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<TriggerState>,
_args: AssemblyArgs
): GeneralActionOutcome<TriggerState> {
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(`<G><b>${cityName}</b></>에서 집합을 실시했습니다.`);
const effects: Array<GeneralActionEffect<TriggerState>> = [];
const targets = context.troopMembers.filter(
(member) => member.cityId !== city.id
);
for (const member of targets) {
effects.push(
createGeneralPatchEffect(
{ cityId: city.id } as Partial<General<TriggerState>>,
member.id
)
);
effects.push(
createLogEffect(
`${troopName} 부대원들은 <G><b>${cityName}</b></>${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(),
};
@@ -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'),
@@ -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<TriggerState> {
destGeneral: General<TriggerState>;
}
const ACTION_NAME = '부대 탈퇴 지시';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
TroopKickArgs,
TroopKickResolveContext<TriggerState>
> {
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<TriggerState>,
_args: TroopKickArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const destGeneral = context.destGeneral;
const destGeneralName = destGeneral.name;
const josaUn = JosaUtil.pick(destGeneralName, '은');
const effects: Array<GeneralActionEffect<TriggerState>> = [];
if (destGeneral.troopId === 0) {
context.addLog(
`<Y>${destGeneralName}</>${josaUn} 부대원이 아닙니다.`
);
return { effects };
}
if (destGeneral.troopId === destGeneral.id) {
context.addLog(
`<Y>${destGeneralName}</>${josaUn} 부대장입니다.`
);
return { effects };
}
effects.push(
createGeneralPatchEffect(
{ troopId: 0 } as Partial<General<TriggerState>>,
destGeneral.id
)
);
effects.push(
createLogEffect(
`<Y>${destGeneralName}</>에게 부대 탈퇴를 지시했습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
)
);
effects.push(
createLogEffect(
`<Y>${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(),
};
@@ -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'),
@@ -4,3 +4,4 @@ export * from './general.js';
export * from './helpers.js';
export * from './misc.js';
export * from './nation.js';
export * from './troop.js';
+56
View File
@@ -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: '집합 가능한 부대원이 없습니다.' };
},
});
+1
View File
@@ -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 }