diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 775f603..7fe186f 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -59,6 +59,11 @@ import { buildActionContext } from './reservedTurnActionContext.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import type { AiReservedTurnProvider } from './ai/types.js'; import { rankMetaKey } from './rankData.js'; +import { + hasScenarioStaticEventHandler, + IMMEDIATE_ASSIGNMENT_GATHER_HANDLER, + LEGACY_NATION_ASSIGNMENT_EVENT, +} from './scenarioStaticEvents.js'; const DEFAULT_ACTION = '휴식'; @@ -1262,6 +1267,59 @@ export const createReservedTurnHandler = async (options: { } } + if ( + kind === 'nation' && + actionKey === 'che_발령' && + !usedFallback && + resolution.completed && + worldOverlay && + hasScenarioStaticEventHandler( + options.scenarioConfig, + LEGACY_NATION_ASSIGNMENT_EVENT, + IMMEDIATE_ASSIGNMENT_GATHER_HANDLER + ) + ) { + const destGeneralId = actionArgsRecord.destGeneralId; + const destCityId = actionArgsRecord.destCityId; + if (typeof destGeneralId === 'number' && typeof destCityId === 'number') { + const destGeneral = worldOverlay.view.getGeneralById(destGeneralId); + const destCity = worldOverlay.view.getCityById(destCityId); + if ( + destGeneral && + destCity && + destGeneral.id === destGeneral.troopId && + destGeneral.nationId === currentGeneral.nationId + ) { + const troopName = worldOverlay.view.getTroopById(destGeneral.id)?.name ?? ''; + const cityJosa = JosaUtil.pick(destCity.name, '로'); + const troopMembers = worldOverlay.view + .listGenerals() + .filter( + (member) => + member.id !== destGeneral.id && + member.nationId === destGeneral.nationId && + member.troopId === destGeneral.id && + member.cityId !== destCity.id + ); + for (const member of troopMembers) { + const patch = { cityId: destCity.id }; + patches.generals.push({ id: member.id, patch }); + worldOverlay.applyGeneralPatch(member.id, patch); + if (member.id === currentGeneral.id) { + currentGeneral = applyGeneralPatch(currentGeneral, patch); + } + logs.push({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: member.id, + format: LogFormat.PLAIN, + text: `${troopName} 부대원들은 ${destCity.name}${cityJosa} 즉시 집합되었습니다.`, + }); + } + } + } + } + if (resolution.created?.generals) { const newGenerals = resolution.created.generals as TurnGeneral[]; createdGenerals.push(...newGenerals); diff --git a/app/game-engine/src/turn/scenarioStaticEvents.ts b/app/game-engine/src/turn/scenarioStaticEvents.ts new file mode 100644 index 0000000..ff14625 --- /dev/null +++ b/app/game-engine/src/turn/scenarioStaticEvents.ts @@ -0,0 +1,17 @@ +import { asRecord } from '@sammo-ts/common'; +import type { ScenarioConfig } from '@sammo-ts/logic'; + +export const LEGACY_TROOP_JOIN_EVENT = 'sammo\\API\\Troop\\JoinTroop'; +export const IMMEDIATE_TROOP_JOIN_MOVE_HANDLER = 'event_부대탑승즉시이동'; +export const LEGACY_NATION_ASSIGNMENT_EVENT = 'sammo\\Command\\Nation\\che_발령'; +export const IMMEDIATE_ASSIGNMENT_GATHER_HANDLER = 'event_부대발령즉시집합'; + +export const hasScenarioStaticEventHandler = ( + scenarioConfig: ScenarioConfig, + eventType: string, + handlerName: string +): boolean => { + const handlersByEvent = asRecord(scenarioConfig.const.staticEventHandlers); + const handlers = handlersByEvent[eventType]; + return Array.isArray(handlers) && handlers.includes(handlerName); +}; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 21a9013..0410cfb 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -34,6 +34,11 @@ import { import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnGeneral } from './types.js'; import { openAuction } from '../auction/opener.js'; +import { + hasScenarioStaticEventHandler, + IMMEDIATE_TROOP_JOIN_MOVE_HANDLER, + LEGACY_TROOP_JOIN_EVENT, +} from './scenarioStaticEvents.js'; let itemRegistryPromise: Promise> | null = null; @@ -89,15 +94,6 @@ const resolveMaxSecretPermission = (general: TurnGeneral): number => { return 4; }; -const LEGACY_TROOP_JOIN_EVENT = 'sammo\\API\\Troop\\JoinTroop'; -const IMMEDIATE_TROOP_JOIN_MOVE_HANDLER = 'event_부대탑승즉시이동'; - -const hasStaticEventHandler = (world: InMemoryTurnWorld, eventType: string, handlerName: string): boolean => { - const handlersByEvent = asRecord(world.getScenarioConfig().const.staticEventHandlers); - const handlers = handlersByEvent[eventType]; - return Array.isArray(handlers) && handlers.includes(handlerName); -}; - const refreshActorKillturn = (world: InMemoryTurnWorld, actor: TurnGeneral): void => { const worldKillturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); const actorKillturn = readMetaNumber(asRecord(actor.meta), 'killturn', 0); @@ -494,7 +490,13 @@ async function handleTroopJoin( world.updateGeneral(command.generalId, { troopId: command.troopId, }); - if (hasStaticEventHandler(world, LEGACY_TROOP_JOIN_EVENT, IMMEDIATE_TROOP_JOIN_MOVE_HANDLER)) { + if ( + hasScenarioStaticEventHandler( + world.getScenarioConfig(), + LEGACY_TROOP_JOIN_EVENT, + IMMEDIATE_TROOP_JOIN_MOVE_HANDLER + ) + ) { const leader = world.getGeneralById(troop.id); if ( leader && diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 97a97fd..85c0994 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -51,6 +51,7 @@ export interface TurnCommandFixtureRequest { year?: number; month?: number; hiddenSeed?: string; + staticEventHandlers?: Record; }; isolateWorld?: boolean; generals?: Array>; @@ -423,6 +424,9 @@ export const buildCoreTurnCommandWorldInput = ( maxLevel: 255, maxDedLevel: 30, upgradeLimit: 30, + ...(request.setup?.world?.staticEventHandlers + ? { staticEventHandlers: request.setup.world.staticEventHandlers } + : {}), }, environment: { mapName: map.id, unitSet: unitSet.id }, }, diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index aeb8dc1..4aee58d 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -1415,10 +1415,7 @@ integration('nation personnel command boundary parity', () => { }) ).toEqual([]); if (!completed) { - if ( - originalCommandFailure || - (action === 'che_발령' && args.destGeneralID !== 9) - ) { + if (originalCommandFailure || (action === 'che_발령' && args.destGeneralID !== 9)) { expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) ); @@ -1445,6 +1442,91 @@ integration('nation personnel command boundary parity', () => { ); }); +const scenario911AssignmentCases: Array<{ + name: string; + targetTroopId: number; + expectedActorCityId: number; + expectedMemberCityId: number; +}> = [ + { + name: 'gathers remote members when the assigned general is their troop leader', + targetTroopId: 3, + expectedActorCityId: 70, + expectedMemberCityId: 70, + }, + { + name: 'does not gather members when the assigned general is not their troop leader', + targetTroopId: 1, + expectedActorCityId: 3, + expectedMemberCityId: 3, + }, +]; + +integration('scenario 911 assignment troop gather parity', () => { + it.each(scenario911AssignmentCases)( + '$name', + async ({ targetTroopId, expectedActorCityId, expectedMemberCityId }) => { + const request = buildRequest( + 'che_발령', + { destGeneralID: 3, destCityID: 70 }, + { + world: { + staticEventHandlers: { + 'sammo\\Command\\Nation\\che_발령': ['event_부대발령즉시집합'], + }, + }, + cities: { 70: { nationId: 1 } }, + generals: { + 1: { troopId: 3 }, + 3: { troopId: targetTroopId }, + 4: { nationId: 1, cityId: 3, troopId: 3 }, + 5: { nationId: 1, cityId: 70, troopId: 3 }, + 6: { nationId: 1, cityId: 3, troopId: 6 }, + }, + troops: [ + { id: 1, nationId: 1, name: '선봉대' }, + { id: 3, nationId: 1, name: '백마대' }, + { id: 6, nationId: 1, name: '별동대' }, + ], + } + ); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_발령', + actionKey: 'che_발령', + usedFallback: false, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + for (const [generalId, expectedCityId] of [ + [1, expectedActorCityId], + [3, 70], + [4, expectedMemberCityId], + [5, 70], + [6, 3], + ] as const) { + expect(reference.after.generals.find((entry) => entry.id === generalId)?.cityId).toBe(expectedCityId); + expect(core.after.generals.find((entry) => entry.id === generalId)?.cityId).toBe(expectedCityId); + } + expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + }, + 120_000 + ); +}); + type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed'; const volunteerRecruitBoundaryCases: Array<{