diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index c6d98a9..d6576a7 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -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; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 4ee230d..f708f0b 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -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) { diff --git a/packages/logic/src/actions/turn/general/che_강행.ts b/packages/logic/src/actions/turn/general/che_강행.ts index 7039aec..6f3bd40 100644 --- a/packages/logic/src/actions/turn/general/che_강행.ts +++ b/packages/logic/src/actions/turn/general/che_강행.ts @@ -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'; diff --git a/packages/logic/src/actions/turn/general/che_귀환.ts b/packages/logic/src/actions/turn/general/che_귀환.ts index c2390c5..83456d1 100644 --- a/packages/logic/src/actions/turn/general/che_귀환.ts +++ b/packages/logic/src/actions/turn/general/che_귀환.ts @@ -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_귀환'; diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 86141a7..c202a9e 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -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 { readonly key = ACTION_KEY; + constructor(private readonly env: TurnCommandEnv) {} + resolve( context: AcceptScoutResolveContext, _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(`${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(`${generalName}${josaYi} ${destNationName}${josaRo} 망명하였습니다.`, { - 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; - 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; 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; + const args = options.actionArgs as Partial; 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), }; diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 2900289..20e4a24 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -111,7 +111,7 @@ export class GeneralTurnCommandLoader { constructor( private readonly importers: Record = defaultImporters - ) { } + ) {} async load(key: GeneralTurnCommandKey): Promise { const cached = this.cache.get(key); diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index 0473a60..93c6098 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -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); diff --git a/packages/logic/test/scenarios/general/che_등용수락.test.ts b/packages/logic/test/scenarios/general/che_등용수락.test.ts index 393c3c2..07a9a21 100644 --- a/packages/logic/test/scenarios/general/che_등용수락.test.ts +++ b/packages/logic/test/scenarios/general/che_등용수락.test.ts @@ -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);