코드 이식
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
mustBeNPC,
|
||||
reqGeneralGold,
|
||||
unknownOrDeny,
|
||||
existsDestCity,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
|
||||
export interface NPCSelfArgs {
|
||||
optionText: string;
|
||||
destCityId?: number;
|
||||
}
|
||||
|
||||
export type NPCSelfResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> = GeneralActionResolveContext<TriggerState> & {
|
||||
map?: MapDefinition;
|
||||
};
|
||||
|
||||
const ACTION_NAME = 'NPC능동';
|
||||
const ACTION_KEY = 'che_NPC능동';
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionResolver<TriggerState, NPCSelfArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (args.optionText === '순간이동') {
|
||||
if (args.destCityId === undefined) {
|
||||
// Should be caught by constraints/validation, but safe guard
|
||||
throw new Error('Missing destCityId for instant move');
|
||||
}
|
||||
|
||||
const destCityId = args.destCityId;
|
||||
let destCityName = `도시(${destCityId})`;
|
||||
if (context.map) {
|
||||
const c = context.map.cities.find((ct) => ct.id === destCityId);
|
||||
if (c) destCityName = c.name;
|
||||
}
|
||||
|
||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||
|
||||
// Legacy log: "NPC 전용 명령을 이용해 {$cityName}{$josaRo} 이동했습니다."
|
||||
// Note: Legacy didn't use <G> tag here based on file content, but most move commands do.
|
||||
// Following strict legacy log parity from file:
|
||||
// "NPC 전용 명령을 이용해 {$cityName}{$josaRo} 이동했습니다."
|
||||
context.addLog(`NPC 전용 명령을 이용해 ${destCityName}${josaRo} 이동했습니다.`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
...general,
|
||||
cityId: destCityId,
|
||||
// Legacy doesn't show cost/dedication/exp change in 'che_NPC능동.php' run() method for '순간이동'
|
||||
// It only does setVar('city', destCityID) and Logging and LastTurn.
|
||||
},
|
||||
general.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionDefinition<TriggerState, NPCSelfArgs, NPCSelfResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): NPCSelfArgs | null {
|
||||
const data = raw as Partial<NPCSelfArgs>;
|
||||
if (!data.optionText) return null;
|
||||
|
||||
if (data.optionText === '순간이동') {
|
||||
if (typeof data.destCityId !== 'number') return null;
|
||||
// We can check city existence here deeply but constraint is better place usually,
|
||||
// simplified parse just structural.
|
||||
return { optionText: data.optionText, destCityId: data.destCityId };
|
||||
}
|
||||
|
||||
// Only '순간이동' implemented in legacy file shown.
|
||||
return null;
|
||||
}
|
||||
|
||||
buildConstraints(ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
|
||||
const constraints = [
|
||||
mustBeNPC()
|
||||
];
|
||||
|
||||
if (args.optionText === '순간이동') {
|
||||
constraints.push(existsDestCity());
|
||||
}
|
||||
|
||||
return constraints;
|
||||
}
|
||||
|
||||
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_NPC능동',
|
||||
category: '특수', // Valid category? Legacy didn't specify category in static prop usually, handled by mapping. Defaulting to '특수'.
|
||||
reqArg: true,
|
||||
args: { optionText: '', destCityId: 0 },
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
notSameDestCity,
|
||||
nearCity,
|
||||
reqGeneralGold,
|
||||
reqGeneralRice,
|
||||
unknownOrDeny,
|
||||
existsDestCity,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
|
||||
export interface ForcedMoveArgs {
|
||||
destCityId: number;
|
||||
}
|
||||
|
||||
export interface ForcedMoveResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
moveGenerals?: General<TriggerState>[]; // For roaming move
|
||||
map?: MapDefinition;
|
||||
startDevelCost?: number;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '강행';
|
||||
const ACTION_KEY = 'che_강행';
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionResolver<TriggerState, ForcedMoveArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: ForcedMoveResolveContext<TriggerState>, args: ForcedMoveArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const nation = context.nation;
|
||||
const { destCityId } = args;
|
||||
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
// Determine if roaming leader logic applies
|
||||
// Legacy: if ($general->getVar('officer_level') == 12 && $this->nation['level'] == 0)
|
||||
// Roaming nation leader moving -> moves everyone in nation that isn't self (handled in legacy by finding generals)
|
||||
// Actually legacy loop: SELECT no FROM general WHERE nation=%i AND no!=%i
|
||||
|
||||
const isRoamingLeader = general.officerLevel === 12 && nation && nation.level === 0;
|
||||
let moveTargets: General<TriggerState>[] = [general];
|
||||
|
||||
if (isRoamingLeader && context.moveGenerals) {
|
||||
const others = context.moveGenerals.filter((g) => g.nationId === nation.id && g.id !== general.id);
|
||||
// Legacy updates DB directly for others, and logs for them.
|
||||
// Here we queue patch effects for everyone.
|
||||
moveTargets = [general, ...others]; // Self first
|
||||
}
|
||||
|
||||
// Cost calculation
|
||||
// Legacy: env['develcost'] * 5 gold.
|
||||
const develCost = context.startDevelCost ?? 0;
|
||||
const goldCost = develCost * 5;
|
||||
|
||||
// Log destination
|
||||
let destCityName = `도시(${destCityId})`;
|
||||
if (context.map) {
|
||||
const c = context.map.cities.find((ct) => ct.id === destCityId);
|
||||
if (c) destCityName = c.name;
|
||||
}
|
||||
|
||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||
|
||||
// Log for self: "<G><b>{$destCityName}</b></>{$josaRo} 강행했습니다. <1>$date</>"
|
||||
context.addLog(`<G><b>${destCityName}</b></>${josaRo} 강행했습니다.`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
// Effects for self:
|
||||
// city = dest
|
||||
// gold -= cost (limit 0)
|
||||
// train -= 5 (limit 20)
|
||||
// atmos -= 5 (limit 20)
|
||||
// exp += 100
|
||||
// leadership_exp += 1
|
||||
|
||||
const nextGold = Math.max(0, general.gold - goldCost);
|
||||
const nextTrain = Math.max(20, general.train - 5);
|
||||
const nextAtmos = Math.max(20, general.atmos - 5);
|
||||
const nextExp = general.experience + 100;
|
||||
const nextLeadershipExp = (typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1;
|
||||
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
...general,
|
||||
cityId: destCityId,
|
||||
gold: nextGold,
|
||||
train: nextTrain,
|
||||
atmos: nextAtmos,
|
||||
experience: nextExp,
|
||||
meta: {
|
||||
...general.meta,
|
||||
leadership_exp: nextLeadershipExp,
|
||||
},
|
||||
},
|
||||
general.id
|
||||
)
|
||||
);
|
||||
|
||||
// Effects/Logs for subordinates (if roaming leader)
|
||||
if (isRoamingLeader && moveTargets.length > 1) {
|
||||
for (const target of moveTargets) {
|
||||
if (target.id === general.id) continue;
|
||||
|
||||
// Legacy: "방랑군 세력이 <G><b>{$destCityName}</b></>{$josaRo} 강행했습니다." (LOG_PLAIN)
|
||||
// NOTE: We need a way to push log to OTHER general's logger.
|
||||
// In current engine, we return effects. Log effects?
|
||||
// Currently addLog attaches provided logs to turnLog (which is for the actor).
|
||||
// To log for OTHERS, we might need specific effect or handle it differently.
|
||||
// For now, I will omit logs for others or use a special effect if available.
|
||||
// The legacy TS porting pattern for "others" logs isn't fully standardized yet in shared snippets.
|
||||
// Assuming createGeneralPatchEffect handles state. Logs for others might be missing in this iteration unless I find `createLogEffect`.
|
||||
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
...target,
|
||||
cityId: destCityId
|
||||
},
|
||||
target.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionDefinition<TriggerState, ForcedMoveArgs, ForcedMoveResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): ForcedMoveArgs | null {
|
||||
const data = raw as Partial<ForcedMoveArgs>;
|
||||
if (typeof data.destCityId !== 'number') return null;
|
||||
return { destCityId: data.destCityId };
|
||||
}
|
||||
|
||||
buildConstraints(ctx: ConstraintContext, _args: ForcedMoveArgs): Constraint[] {
|
||||
return [
|
||||
existsDestCity(),
|
||||
notSameDestCity(),
|
||||
nearCity(3),
|
||||
reqGeneralGold((_c, _v) => {
|
||||
const cost = ctx.env.develCost as number;
|
||||
return (cost ?? 0) * 5;
|
||||
}),
|
||||
reqGeneralRice(() => 0) // Legacy checks cost[1] which is 0, but included constraint.
|
||||
];
|
||||
}
|
||||
|
||||
resolve(context: ForcedMoveResolveContext<TriggerState>, args: ForcedMoveArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
return {
|
||||
...base,
|
||||
moveGenerals: options.worldRef?.listGenerals() ?? [],
|
||||
map: options.map,
|
||||
startDevelCost: options.scenarioConfig.const.develCost as number | undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_강행',
|
||||
category: '군사',
|
||||
reqArg: true,
|
||||
args: { destCityId: 0 },
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
|
||||
import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
allow,
|
||||
notBeNeutral,
|
||||
notWanderingNation,
|
||||
notCapital,
|
||||
readMetaNumberFromUnknown,
|
||||
unknownOrDeny,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
|
||||
export interface ReturnArgs { }
|
||||
|
||||
const ACTION_NAME = '귀환';
|
||||
const ACTION_KEY = 'che_귀환';
|
||||
|
||||
export interface ReturnResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
nationCities?: City[];
|
||||
// Need city list to find officer city name? Or just assume it exists if ID is valid?
|
||||
// Actually we need the city object to get its name for logging.
|
||||
// Legacy: CityConst::byID($destCityID)->name
|
||||
}
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, ReturnArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: ReturnResolveContext<TriggerState>, _args: ReturnArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const nation = context.nation;
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (!nation) {
|
||||
throw new Error('Return requires a nation context.');
|
||||
}
|
||||
|
||||
const officerLevel = general.officerLevel;
|
||||
let destCityId: number | null = null;
|
||||
|
||||
// Logic: specific officer levels return to their assigned city
|
||||
// 2 (Chief?), 3 (Staff?), 4 (Governor/Taishu)
|
||||
if (officerLevel >= 2 && officerLevel <= 4) {
|
||||
const officerCity = readMetaNumberFromUnknown(general.meta, 'officer_city');
|
||||
if (officerCity) {
|
||||
destCityId = officerCity;
|
||||
}
|
||||
}
|
||||
|
||||
if (destCityId === null) {
|
||||
destCityId = nation.capitalCityId;
|
||||
}
|
||||
|
||||
if (destCityId === null) {
|
||||
throw new Error('Destination city not found (No capital?).');
|
||||
}
|
||||
|
||||
// We need city name for log.
|
||||
// If we don't have city object in context, we can't log name easily unless we fetch it.
|
||||
// We should add `getWorldCity(id)` to context or something.
|
||||
// Or assume resolving involves looking up city.
|
||||
|
||||
// For now, let's look it up from `context.nationCities` if available, or request it in context builder.
|
||||
// Actually context has `city` (location), but dest is different.
|
||||
// We need to request `destCity` in context builder? But we determine it dynamically.
|
||||
// We can request ALL cities or use `worldRef` if available in context?
|
||||
// `GeneralActionResolveContext` usually doesn't have `worldRef`.
|
||||
// We must rely on `contextBuilder` to populate necessary data.
|
||||
|
||||
// Let's iterate `nationCities` if provided.
|
||||
// Or use `ActionContextBuilder` to fetch city by ID if we knew it?
|
||||
// We don't know ID until we check logic.
|
||||
|
||||
// In `ActionContextBuilder`, we can just load all cities of the nation?
|
||||
// `nationCities` is common.
|
||||
|
||||
let destCityName = '알 수 없는 도시';
|
||||
let foundDestCity: City | undefined;
|
||||
|
||||
if (context.nationCities) {
|
||||
foundDestCity = context.nationCities.find(c => c.id === destCityId);
|
||||
if (foundDestCity) {
|
||||
destCityName = foundDestCity.name;
|
||||
}
|
||||
}
|
||||
|
||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||
const date = 'XX:XX'; // Placeholder for date, actual date is handled by log format usually, but legacy includes it explicitly?
|
||||
// Legacy: <1>$date</>
|
||||
// LogFormat.MONTH handles date prefix usually. LogFormat.HM handles HH:MM.
|
||||
// Legacy output: "StartCity로 귀환했습니다. 10:00"
|
||||
|
||||
context.addLog(`<G><b>${destCityName}</b></>${josaRo} 귀환했습니다.`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH, // Or HM if we want HH:MM suffix?
|
||||
// Legacy uses explicit date at end.
|
||||
// We can just use standard MONTH format for now.
|
||||
});
|
||||
|
||||
const exp = 70;
|
||||
const ded = 100;
|
||||
|
||||
effects.push(createGeneralPatchEffect({
|
||||
...general,
|
||||
cityId: destCityId,
|
||||
experience: general.experience + exp,
|
||||
dedication: general.dedication + ded,
|
||||
stats: {
|
||||
...general.stats,
|
||||
leadership: general.stats.leadership, // Update not needed unless verified
|
||||
},
|
||||
// leadership_exp + 1 in legacy?
|
||||
// "increaseVar('leadership_exp', 1)"
|
||||
// General entity doesn't show leadership_exp in Interface?
|
||||
// Checking `entities.ts`... `stats` is Leadership/Strength/Intel.
|
||||
// `experience` is total exp?
|
||||
// If `leadership_exp` is missing in Entity, we must use meta or omit.
|
||||
// Default to meta if needed.
|
||||
meta: {
|
||||
...general.meta,
|
||||
leadership_exp: (readMetaNumberFromUnknown(general.meta, 'leadership_exp') ?? 0) + 1
|
||||
}
|
||||
}, general.id));
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionDefinition<TriggerState, ReturnArgs, ReturnResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): ReturnArgs | null {
|
||||
return {};
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, _args: ReturnArgs): Constraint[] {
|
||||
return [
|
||||
notBeNeutral(),
|
||||
notWanderingNation(),
|
||||
notCapital(true), // Check if ALREADY in capital (true = check current city)
|
||||
];
|
||||
}
|
||||
|
||||
resolve(context: ReturnResolveContext<TriggerState>, args: ReturnArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
return {
|
||||
...base,
|
||||
// We effectively need all cities to find the destination name if it's not the capital.
|
||||
// Or at least nation cities.
|
||||
nationCities: options.worldRef?.listCities().filter(c => c.nationId === base.nation?.id) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_귀환',
|
||||
category: '군사',
|
||||
reqArg: false,
|
||||
args: {},
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
|
||||
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
allow,
|
||||
existsDestNation,
|
||||
existsDestGeneral,
|
||||
notSameDestNation,
|
||||
destGeneralInDestNation,
|
||||
notLord,
|
||||
readMetaNumberFromUnknown,
|
||||
unknownOrDeny,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
|
||||
export interface AcceptScoutArgs {
|
||||
destNationId: number;
|
||||
destGeneralId: number;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '등용수락';
|
||||
const ACTION_KEY = 'che_등용수락';
|
||||
|
||||
export interface AcceptScoutResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destNation?: Nation;
|
||||
destGeneral?: General<TriggerState>;
|
||||
}
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, AcceptScoutArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: AcceptScoutResolveContext<TriggerState>, _args: AcceptScoutArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const currentNation = context.nation;
|
||||
const destNation = context.destNation;
|
||||
const destGeneral = context.destGeneral;
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (!destNation) throw new Error('Target nation not found.');
|
||||
if (!destGeneral) throw new Error('Recruiter not found.');
|
||||
|
||||
// 1. Logs
|
||||
const destNationName = destNation.name;
|
||||
const recruiterName = destGeneral.name;
|
||||
const generalName = general.name;
|
||||
|
||||
const josaRo = JosaUtil.pick(destNationName, '로');
|
||||
const josaYi = JosaUtil.pick(generalName, '이');
|
||||
|
||||
// Self Log
|
||||
context.addLog(`<D>${destNationName}</>${josaRo} 망명하여 수도로 이동합니다.`, { // Text says "Move to Capital", but logic might move to recruiter city.
|
||||
// Legacy log says "수도로 이동합니다", but implementation moves to destGeneral city if present!
|
||||
// We should match implementation or text? Text is just flavor.
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.InGame, // Using InGame or specific format?
|
||||
});
|
||||
|
||||
// Recruiter Log
|
||||
// We need to add log to recruiter? `context.addSideEffectLog`?
|
||||
// Current system mostly logs for the actor.
|
||||
// If we want to log for recruiter, we might need a way to push logs to others in `effects` or strictly via `addLog` with target?
|
||||
// `GeneralActionResolveContext` usually implies logs are for the actor.
|
||||
// But `GeneralTurnOutcome` doesn't explicitly return logs for others.
|
||||
// We can create a patch for recruiter that appends to their log?
|
||||
// Or usage of `addLog` might support target? No, `addLog` in context usually targets actor.
|
||||
// We will skip Recruiter Log for now or rely on Global Log.
|
||||
|
||||
// Global Log
|
||||
context.addLog(`<Y>${generalName}</>${josaYi} <D><b>${destNationName}</b></>${josaRo} <S>망명</>하였습니다.`, {
|
||||
category: LogCategory.ACTION, // Global category?
|
||||
format: LogFormat.InGame, // Global logs are handled by system?
|
||||
// In new system, we might need to specify it.
|
||||
});
|
||||
|
||||
// 2. Recruiter Rewards
|
||||
effects.push(createGeneralPatchEffect({
|
||||
experience: destGeneral.experience + 100,
|
||||
dedication: destGeneral.dedication + 100,
|
||||
}, destGeneral.id));
|
||||
|
||||
// 3. Betrayal Logic
|
||||
// If currentNation exists (and > 0), handle betrayal return logic.
|
||||
const defaultGold = 1000; // From env? context.env.defaultNpcGold? Or GameConst?
|
||||
// Using context.env values if available. SystemEnv has `baseGold`?
|
||||
// Legacy: GameConst::$defaultGold (usually 1000/2000).
|
||||
const safeGold = context.env.baseGold || 1000;
|
||||
const safeRice = context.env.baseRice || 1000;
|
||||
|
||||
let newGold = general.gold;
|
||||
let newRice = general.rice;
|
||||
let newExp = general.experience;
|
||||
let newDed = general.dedication;
|
||||
|
||||
const betrayCount = (readMetaNumberFromUnknown(general.meta, 'betray') ?? 0);
|
||||
let newBetray = betrayCount;
|
||||
|
||||
if (currentNation && currentNation.id !== 0) {
|
||||
// Return excess gold/rice to current nation
|
||||
let returnGold = 0;
|
||||
let returnRice = 0;
|
||||
|
||||
if (general.gold > safeGold) {
|
||||
returnGold = general.gold - safeGold;
|
||||
newGold = safeGold;
|
||||
}
|
||||
if (general.rice > safeRice) {
|
||||
returnRice = general.rice - safeRice;
|
||||
newRice = safeRice;
|
||||
}
|
||||
|
||||
if (returnGold > 0 || returnRice > 0) {
|
||||
effects.push(createNationPatchEffect({
|
||||
gold: currentNation.gold + returnGold,
|
||||
rice: currentNation.rice + returnRice
|
||||
}, currentNation.id));
|
||||
}
|
||||
|
||||
// Penalty
|
||||
// 10% * betray count deduction
|
||||
const penaltyFactor = 1 - (0.1 * betrayCount);
|
||||
if (penaltyFactor < 0) { // Should not be less than 0? capped at ?
|
||||
// Legacy: (1 - 0.1 * betray).
|
||||
}
|
||||
// Apply penalty
|
||||
newExp = Math.floor(newExp * Math.max(0, penaltyFactor));
|
||||
newDed = Math.floor(newDed * Math.max(0, penaltyFactor));
|
||||
newBetray += 1;
|
||||
} else {
|
||||
// Neutral -> Join: Grant Bonus
|
||||
newExp += 100;
|
||||
newDed += 100;
|
||||
}
|
||||
|
||||
// 4. Update General (Self)
|
||||
let targetCityId = destGeneral.cityId; // Join recruiter
|
||||
// If recruiter is not valid city?
|
||||
if (!targetCityId) targetCityId = destNation.capitalCityId!;
|
||||
|
||||
effects.push(createGeneralPatchEffect({
|
||||
nationId: destNation.id,
|
||||
cityId: targetCityId,
|
||||
experience: newExp,
|
||||
dedication: newDed,
|
||||
gold: newGold,
|
||||
rice: newRice,
|
||||
officerLevel: 1, // Reset rank
|
||||
// officer_city: 0 via meta
|
||||
crew: general.crew, // Keep crew? Legacy implies checking troop leader.
|
||||
// If troop leader, disband troop.
|
||||
// TS entity `troopId`.
|
||||
troopId: 0, // Quit troop
|
||||
meta: {
|
||||
...general.meta,
|
||||
officer_city: 0,
|
||||
betray: newBetray,
|
||||
// killturn logic?
|
||||
}
|
||||
}, general.id));
|
||||
|
||||
// 5. Update Nations Gen Count (Visual only? or real count)
|
||||
// Legacy updates `gennum`.
|
||||
// We can create patches for nations if `gennum` is part of Nation entity?
|
||||
// Nation entity usually doesn't store computed `gennum` in TS domain?
|
||||
// If it's real column, we can update.
|
||||
// Checking entity: `Nation` interface does NOT have `gennum`.
|
||||
// So we skip updating gennum on Nation entity.
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionDefinition<TriggerState, AcceptScoutArgs, AcceptScoutResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): AcceptScoutArgs | null {
|
||||
// Validate args
|
||||
const args = raw as Partial<AcceptScoutArgs>;
|
||||
if (typeof args.destNationId !== 'number' || typeof args.destGeneralId !== 'number') return null;
|
||||
return { destNationId: args.destNationId, destGeneralId: args.destGeneralId };
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||
return [
|
||||
// notBeNeutral(), // Ignored to allow betrayal
|
||||
existsDestNation(),
|
||||
existsDestGeneral(), // Need to check if destGeneral exists
|
||||
notSameDestNation(),
|
||||
destGeneralInDestNation(),
|
||||
notLord(),
|
||||
];
|
||||
}
|
||||
|
||||
resolve(context: AcceptScoutResolveContext<TriggerState>, args: AcceptScoutArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
// Populate destNation and destGeneral
|
||||
const args = base.args as Partial<AcceptScoutArgs>;
|
||||
let destNation: Nation | undefined;
|
||||
let destGeneral: General | undefined;
|
||||
|
||||
if (args.destNationId) {
|
||||
destNation = options.worldRef?.getNation(args.destNationId);
|
||||
}
|
||||
if (args.destGeneralId) {
|
||||
destGeneral = options.worldRef?.getGeneral(args.destGeneralId);
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
destNation,
|
||||
destGeneral,
|
||||
};
|
||||
};
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_등용수락',
|
||||
category: '계략', // Strategy? or '인사'(Personnel)? Legacy not checked for category. "군사" in task.md?
|
||||
// che_등용수락 is usually separate.
|
||||
// che_등용 is 인사(Personnel).
|
||||
// Let's use '인사'.
|
||||
reqArg: true,
|
||||
args: {
|
||||
destNationId: 'number',
|
||||
destGeneralId: 'number',
|
||||
},
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -3,6 +3,9 @@ import type { TurnCommandModule, TurnCommandSpecBase } from '@sammo-ts/logic/act
|
||||
export const GENERAL_TURN_COMMAND_KEYS = [
|
||||
'che_거병',
|
||||
'che_임관',
|
||||
'che_랜덤임관',
|
||||
'che_귀환',
|
||||
'che_등용수락',
|
||||
'che_건국',
|
||||
'che_훈련',
|
||||
'che_단련',
|
||||
@@ -39,6 +42,9 @@ export const GENERAL_TURN_COMMAND_KEYS = [
|
||||
'che_파괴',
|
||||
'che_선동',
|
||||
'che_탈취',
|
||||
'che_NPC능동',
|
||||
'che_강행',
|
||||
'che_귀환',
|
||||
'휴식',
|
||||
] as const;
|
||||
|
||||
@@ -53,6 +59,9 @@ export type GeneralTurnCommandImporter = () => Promise<GeneralTurnCommandModule>
|
||||
const defaultImporters: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter> = {
|
||||
che_거병: async () => import('./che_거병.js'),
|
||||
che_임관: async () => import('./che_임관.js'),
|
||||
che_등용수락: () => import('./che_등용수락.js'),
|
||||
che_랜덤임관: () => import('./che_랜덤임관.js'),
|
||||
che_귀환: async () => import('./che_귀환.js'),
|
||||
che_건국: async () => import('./che_건국.js'),
|
||||
che_훈련: async () => import('./che_훈련.js'),
|
||||
che_단련: async () => import('./che_단련.js'),
|
||||
@@ -89,6 +98,8 @@ const defaultImporters: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter
|
||||
che_파괴: async () => import('./che_파괴.js'),
|
||||
che_선동: async () => import('./che_선동.js'),
|
||||
che_탈취: async () => import('./che_탈취.js'),
|
||||
che_NPC능동: async () => import('./che_NPC능동.js'),
|
||||
che_강행: async () => import('./che_강행.js'),
|
||||
휴식: async () => import('./휴식.js'),
|
||||
};
|
||||
|
||||
@@ -100,7 +111,7 @@ export class GeneralTurnCommandLoader {
|
||||
|
||||
constructor(
|
||||
private readonly importers: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter> = defaultImporters
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async load(key: GeneralTurnCommandKey): Promise<GeneralTurnCommandModule> {
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import { getCityDistance } from '../world/distance.js';
|
||||
import {
|
||||
allow,
|
||||
parsePercent,
|
||||
@@ -113,11 +114,11 @@ export const suppliedDestCity = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestCityId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destCity = readDestCity(ctx, view);
|
||||
@@ -249,11 +250,11 @@ export const existsDestCity = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestCityId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
@@ -315,11 +316,11 @@ export const notNeutralDestCity = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestCityId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destCity = readDestCity(ctx, view);
|
||||
@@ -480,3 +481,94 @@ export const reqCityLevel = (levels: number[]): Constraint => ({
|
||||
return { kind: 'deny', reason: '규모가 맞지 않습니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const nearCity = (maxDistance: number): Constraint => ({
|
||||
name: 'nearCity',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [{ kind: 'env', key: 'map' }];
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
if (destCityId !== undefined) {
|
||||
reqs.push({ kind: 'destCity', id: destCityId });
|
||||
}
|
||||
if (ctx.cityId !== undefined) {
|
||||
reqs.push({ kind: 'city', id: ctx.cityId });
|
||||
}
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, view) => {
|
||||
const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | null;
|
||||
if (!map) {
|
||||
return unknownOrDeny(ctx, [], '지도 정보가 없습니다.');
|
||||
}
|
||||
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
if (destCityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
|
||||
const city = readCity(view, ctx.cityId);
|
||||
|
||||
if (!city) {
|
||||
if (ctx.cityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
||||
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
||||
}
|
||||
|
||||
const distance = getCityDistance(map, city.id, destCityId);
|
||||
if (distance <= maxDistance) {
|
||||
return allow();
|
||||
}
|
||||
|
||||
return { kind: 'deny', reason: '너무 멉니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notCapital = (checkCurrentCity = false): Constraint => ({
|
||||
name: 'notCapital',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }];
|
||||
if (checkCurrentCity) {
|
||||
if (ctx.cityId !== undefined) {
|
||||
reqs.push({ kind: 'city', id: ctx.cityId });
|
||||
}
|
||||
} else {
|
||||
// If checking dest, we need destCityId
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
if (destCityId !== undefined) {
|
||||
reqs.push({ kind: 'destCity', id: destCityId });
|
||||
}
|
||||
}
|
||||
// Need nation to know capital
|
||||
reqs.push({ kind: 'nation', id: ctx.nationId });
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, view) => {
|
||||
const nationReq: RequirementKey = { kind: 'nation', id: ctx.nationId };
|
||||
if (!view.has(nationReq)) return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
|
||||
const nation = view.get(nationReq) as Nation | null;
|
||||
if (!nation) return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
|
||||
|
||||
let targetCityId: number | undefined;
|
||||
|
||||
if (checkCurrentCity) {
|
||||
targetCityId = ctx.cityId;
|
||||
if (targetCityId === undefined) {
|
||||
const general = readGeneral(ctx, view);
|
||||
targetCityId = general?.cityId;
|
||||
}
|
||||
} else {
|
||||
targetCityId = resolveDestCityId(ctx);
|
||||
}
|
||||
|
||||
if (targetCityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
|
||||
if (targetCityId === nation.capitalCityId) {
|
||||
return { kind: 'deny', reason: '수도입니다.' };
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,11 +181,11 @@ export const existsDestGeneral = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestGeneralId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destGeneral',
|
||||
id: resolveDestGeneralId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destGeneral',
|
||||
id: resolveDestGeneralId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destGeneralId = resolveDestGeneralId(ctx);
|
||||
@@ -282,3 +282,75 @@ export const friendlyDestGeneral = (): Constraint => ({
|
||||
return { kind: 'deny', reason: '아군이 아닙니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const mustBeNPC = (): Constraint => ({
|
||||
name: 'mustBeNPC',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) {
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
// Assuming npcState >= 2 means NPC. Need to verify exact logic if possible,
|
||||
// but typically 0=human, 1=?, 2=NPC.
|
||||
// Legacy: $general->getNPC() where 0:User, 1:Virtual User(unused?), 2:NPC ...
|
||||
if (general.npcState >= 2) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: 'NPC가 아닙니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notSameDestNation = (): Constraint => ({
|
||||
name: 'notSameDestNation',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [];
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId !== undefined) {
|
||||
reqs.push({ kind: 'destNation', id: destNationId });
|
||||
}
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, _view) => {
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '목표 국가가 없습니다.');
|
||||
}
|
||||
|
||||
if (ctx.nationId === destNationId) {
|
||||
return { kind: 'deny', reason: '이미 소속된 국가입니다.' };
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
export const notLord = (): Constraint => ({
|
||||
name: 'notLord',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
|
||||
if (general.officerLevel !== 12) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '군주는 불가능합니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notChief = (): Constraint => ({
|
||||
name: 'notChief',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
|
||||
if (general.officerLevel <= 4) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '수뇌입니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
|
||||
export const getCityDistance = (map: MapDefinition, startCityId: number, endCityId: number): number => {
|
||||
if (startCityId === endCityId) return 0;
|
||||
|
||||
const visited = new Set<number>();
|
||||
const queue: [number, number][] = [[startCityId, 0]]; // [cityId, distance]
|
||||
visited.add(startCityId);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [currentId, dist] = queue.shift()!;
|
||||
|
||||
const cityDef = map.cities.find(c => c.id === currentId);
|
||||
if (!cityDef) continue;
|
||||
|
||||
for (const neighborId of cityDef.connections) {
|
||||
if (neighborId === endCityId) {
|
||||
return dist + 1;
|
||||
}
|
||||
if (!visited.has(neighborId)) {
|
||||
visited.add(neighborId);
|
||||
queue.push([neighborId, dist + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Infinity;
|
||||
};
|
||||
@@ -2,3 +2,4 @@ export * from './types.js';
|
||||
export * from './bootstrap.js';
|
||||
export * from './loader.js';
|
||||
export * from './unitSet.js';
|
||||
export * from './distance.js';
|
||||
|
||||
Reference in New Issue
Block a user