코드 수정

This commit is contained in:
2026-01-11 11:48:12 +00:00
parent 511aade81c
commit cc41d1538e
8 changed files with 87 additions and 122 deletions
+9 -3
View File
@@ -18,6 +18,8 @@ import {
type UnitSetDefinition,
type WarBattleOutcome,
type WarActionModule,
type WarUnitReport,
type CrewTypeDefinition,
} from '@sammo-ts/logic';
import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload } from './types.js';
@@ -217,7 +219,7 @@ const resolveCityRiceConsumption = (options: {
year: number;
startYear: number;
}): number => {
const cityReport = options.battle.reports.find((report: any) => report.type === 'city');
const cityReport = options.battle.reports.find((report: WarUnitReport) => report.type === 'city');
if (!cityReport) {
return 0;
}
@@ -225,7 +227,9 @@ const resolveCityRiceConsumption = (options: {
return 0;
}
const crewType = options.unitSet.crewTypes?.find((item: any) => item.id === options.castleCrewTypeId);
const crewType = options.unitSet.crewTypes?.find(
(item: CrewTypeDefinition) => item.id === options.castleCrewTypeId
);
const riceCoef = crewType?.rice ?? 1;
const tech = Number(options.defenderNation.meta.tech ?? 0);
const trainAtmos = resolveCityTrainAtmos(options.year, options.startYear);
@@ -333,7 +337,9 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
});
lastBattle = outcome;
const attackerReport = outcome.reports.find((report: any) => report.type === 'general' && report.isAttacker);
const attackerReport = outcome.reports.find(
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
);
const killed = attackerReport?.killed ?? 0;
const dead = attackerReport?.dead ?? 0;
+1 -1
View File
@@ -328,7 +328,7 @@ const evaluateAvailability = (
reason: result.reason,
};
}
const missingKinds = new Set(result.missing.map((req: any) => req.kind));
const missingKinds = new Set(result.missing.map((req: RequirementKey) => req.kind));
const inputOnlyMissing =
missingKinds.size === 0 ? reqArg : Array.from(missingKinds).every((kind) => INPUT_REQUIREMENT_KINDS.has(kind));
if (inputOnlyMissing) {
@@ -5,7 +5,6 @@ import {
nearCity,
reqGeneralGold,
reqGeneralRice,
existsDestCity,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
@@ -22,7 +22,7 @@ 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 { }
export interface ReturnArgs {}
const ACTION_NAME = '귀환';
const ACTION_KEY = 'che_귀환';
@@ -1,14 +1,12 @@
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 {
@@ -44,6 +42,8 @@ export class ActionResolver<
> implements GeneralActionResolver<TriggerState, AcceptScoutArgs> {
readonly key = ACTION_KEY;
constructor(private readonly env: TurnCommandEnv) {}
resolve(
context: AcceptScoutResolveContext<TriggerState>,
_args: AcceptScoutArgs
@@ -59,7 +59,6 @@ export class ActionResolver<
// 1. Logs
const destNationName = destNation.name;
const recruiterName = destGeneral.name;
const generalName = general.name;
const josaRo = JosaUtil.pick(destNationName, '로');
@@ -67,28 +66,14 @@ export class ActionResolver<
// 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?
format: LogFormat.PLAIN,
});
// 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.
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
});
// 2. Recruiter Rewards
@@ -103,12 +88,9 @@ export class ActionResolver<
);
// 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;
const safeGold = this.env.baseGold || 1000;
const safeRice = this.env.baseRice || 1000;
let newGold = general.gold;
let newRice = general.rice;
@@ -147,10 +129,6 @@ export class ActionResolver<
// 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));
@@ -162,9 +140,8 @@ export class ActionResolver<
}
// 4. Update General (Self)
let targetCityId = destGeneral.cityId; // Join recruiter
// If recruiter is not valid city?
if (!targetCityId) targetCityId = destNation.capitalCityId!;
const targetCityId = destNation.capitalCityId;
if (!targetCityId) throw new Error('Capital city not found.');
effects.push(
createGeneralPatchEffect(
@@ -176,30 +153,17 @@ export class ActionResolver<
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 };
}
}
@@ -211,26 +175,18 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor() {
this.resolver = new ActionResolver();
constructor(env: TurnCommandEnv) {
this.resolver = new ActionResolver(env);
}
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(),
];
return [existsDestNation(), existsDestGeneral(), notSameDestNation(), destGeneralInDestNation(), notLord()];
}
resolve(
@@ -242,16 +198,15 @@ export class ActionDefinition<
}
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
// Populate destNation and destGeneral
const args = base.args as Partial<AcceptScoutArgs>;
const args = options.actionArgs as Partial<AcceptScoutArgs>;
let destNation: Nation | undefined;
let destGeneral: General | undefined;
if (args.destNationId) {
destNation = options.worldRef?.getNation(args.destNationId);
destNation = options.worldRef?.getNationById(args.destNationId) ?? undefined;
}
if (args.destGeneralId) {
destGeneral = options.worldRef?.getGeneral(args.destGeneralId);
destGeneral = options.worldRef?.getGeneralById(args.destGeneralId) ?? undefined;
}
return {
@@ -263,14 +218,11 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
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 '인사'.
category: '인사',
reqArg: true,
args: {
destNationId: 'number',
destGeneralId: 'number',
},
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -111,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);
+15 -15
View File
@@ -114,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);
@@ -250,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);
@@ -316,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);
@@ -5,7 +5,6 @@ import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { readGeneral } from '../../../src/constraints/helpers.js';
const MOCK_SCENARIO_BASE = {
title: 'Test',
@@ -84,15 +83,15 @@ const systemEnv: TurnCommandEnv = {
maxResourceActionAmount: 1000,
};
function createConstraintContext(actor: General, year: number = 200, args: any = {}): ConstraintContext {
function createConstraintContext(actorId: number, cityId: number, nationId: number, args: any = {}): ConstraintContext {
return {
actorId: actor.id,
cityId: actor.cityId,
nationId: actor.nationId || 0,
actorId,
cityId,
nationId,
args,
env: {
...systemEnv,
world: { currentYear: year },
world: { currentYear: 200 },
openingPartYear: systemEnv.openingPartYear,
map: MINIMAL_MAP,
cities: MINIMAL_MAP.cities,
@@ -186,7 +185,12 @@ describe('che_등용수락', () => {
experience: 0,
dedication: 0,
officerLevel: 1,
role: null as any,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1000,
rice: 1000,
@@ -218,18 +222,17 @@ describe('che_등용수락', () => {
world.snapshot.generals.push(recruiterGen);
world.snapshot.nations.push(nation2);
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
await runner.runTurn([
{
generalId: neutralGen.id,
commandKey: 'che_등용수락',
resolver: (
await import('../../../src/actions/turn/general/che_등용수락.js')
).commandSpec.createDefinition({} as any),
resolver: commandSpec.createDefinition(systemEnv),
args: { destNationId: 2, destGeneralId: 2 },
context: {
destNation: nation2,
destGeneral: recruiterGen,
env: systemEnv,
},
},
]);
@@ -238,7 +241,7 @@ describe('che_등용수락', () => {
const updatedRecruiter = world.getGeneral(recruiterGen.id);
expect(updatedSelf?.nationId).toBe(2);
expect(updatedSelf?.cityId).toBe(102);
expect(updatedSelf?.cityId).toBe(102); // Capital of Nation2
expect(updatedSelf?.experience).toBe(100);
expect(updatedSelf?.dedication).toBe(100);
@@ -265,7 +268,12 @@ describe('che_등용수락', () => {
experience: 1000,
dedication: 1000,
officerLevel: 1,
role: null as any,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 2000,
rice: 2000,
@@ -302,7 +310,12 @@ describe('che_등용수락', () => {
experience: 0,
dedication: 0,
officerLevel: 1,
role: null as any,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1000,
rice: 1000,
@@ -334,18 +347,17 @@ describe('che_등용수락', () => {
world.snapshot.nations.push(nation1);
world.snapshot.nations.push(nation2);
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
await runner.runTurn([
{
generalId: betrayer.id,
commandKey: 'che_등용수락',
resolver: (
await import('../../../src/actions/turn/general/che_등용수락.js')
).commandSpec.createDefinition({} as any),
resolver: commandSpec.createDefinition(systemEnv),
args: { destNationId: 2, destGeneralId: 2 },
context: {
destNation: nation2,
destGeneral: recruiterGen,
env: systemEnv,
},
},
]);
@@ -453,22 +465,16 @@ describe('che_등용수락', () => {
world.snapshot.nations.push(nation1);
world.snapshot.nations.push(nation2);
// Manually check constraints for denial
const def = (await import('../../../src/actions/turn/general/che_등용수락.js')).commandSpec.createDefinition(
{} as any
);
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
const def = commandSpec.createDefinition(systemEnv);
const args = { destNationId: 2, destGeneralId: 2 };
const ctx = createConstraintContext(monarch, 200, args);
const ctx = createConstraintContext(monarch.id, monarch.cityId, monarch.nationId!, args);
const view = createViewState(world, 200);
const result = evaluateActionConstraints(def, ctx, view, args);
expect(result.kind).toBe('deny');
if (result.kind === 'deny') {
const reason = result.constraintName || result.reason;
// notLord constraint failure.
// In TS presets, notLord(monarch) returns deny.
// ConstraintName should be 'notLord' or 'NotLord'.
expect(result.constraintName).toMatch(/NotLord/i);
}
});
@@ -490,7 +496,12 @@ describe('che_등용수락', () => {
experience: 0,
dedication: 0,
officerLevel: 1,
role: null as any,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1000,
rice: 1000,
@@ -516,8 +527,6 @@ describe('che_등용수락', () => {
typeCode: 'che_def',
meta: {},
};
// Recruiter also in same nation? Or different?
// Arg destNationId is key.
const recruiterGen: General = {
id: 2,
name: 'Recruiter',
@@ -546,12 +555,11 @@ describe('che_등용수락', () => {
world.snapshot.generals.push(recruiterGen);
world.snapshot.nations.push(nation1);
const def = (await import('../../../src/actions/turn/general/che_등용수락.js')).commandSpec.createDefinition(
{} as any
);
const args = { destNationId: 1, destGeneralId: 2 }; // Target same nation 1
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
const def = commandSpec.createDefinition(systemEnv);
const args = { destNationId: 1, destGeneralId: 2 };
const ctx = createConstraintContext(general, 200, args);
const ctx = createConstraintContext(general.id, general.cityId, general.nationId!, args);
const view = createViewState(world, 200);
const result = evaluateActionConstraints(def, ctx, view, args);