fix scenario 911 assignment troop gather

This commit is contained in:
2026-07-27 09:49:35 +00:00
parent 7afb99256d
commit de510da48e
5 changed files with 177 additions and 14 deletions
@@ -59,6 +59,11 @@ import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js'; import type { AiReservedTurnProvider } from './ai/types.js';
import { rankMetaKey } from './rankData.js'; import { rankMetaKey } from './rankData.js';
import {
hasScenarioStaticEventHandler,
IMMEDIATE_ASSIGNMENT_GATHER_HANDLER,
LEGACY_NATION_ASSIGNMENT_EVENT,
} from './scenarioStaticEvents.js';
const DEFAULT_ACTION = '휴식'; 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} 부대원들은 <G><b>${destCity.name}</b></>${cityJosa} 즉시 집합되었습니다.`,
});
}
}
}
}
if (resolution.created?.generals) { if (resolution.created?.generals) {
const newGenerals = resolution.created.generals as TurnGeneral[]; const newGenerals = resolution.created.generals as TurnGeneral[];
createdGenerals.push(...newGenerals); createdGenerals.push(...newGenerals);
@@ -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);
};
+12 -10
View File
@@ -34,6 +34,11 @@ import {
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js'; import type { TurnGeneral } from './types.js';
import { openAuction } from '../auction/opener.js'; import { openAuction } from '../auction/opener.js';
import {
hasScenarioStaticEventHandler,
IMMEDIATE_TROOP_JOIN_MOVE_HANDLER,
LEGACY_TROOP_JOIN_EVENT,
} from './scenarioStaticEvents.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null; let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -89,15 +94,6 @@ const resolveMaxSecretPermission = (general: TurnGeneral): number => {
return 4; 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 refreshActorKillturn = (world: InMemoryTurnWorld, actor: TurnGeneral): void => {
const worldKillturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); const worldKillturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0);
const actorKillturn = readMetaNumber(asRecord(actor.meta), 'killturn', 0); const actorKillturn = readMetaNumber(asRecord(actor.meta), 'killturn', 0);
@@ -494,7 +490,13 @@ async function handleTroopJoin(
world.updateGeneral(command.generalId, { world.updateGeneral(command.generalId, {
troopId: command.troopId, 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); const leader = world.getGeneralById(troop.id);
if ( if (
leader && leader &&
@@ -51,6 +51,7 @@ export interface TurnCommandFixtureRequest {
year?: number; year?: number;
month?: number; month?: number;
hiddenSeed?: string; hiddenSeed?: string;
staticEventHandlers?: Record<string, string[]>;
}; };
isolateWorld?: boolean; isolateWorld?: boolean;
generals?: Array<Record<string, unknown>>; generals?: Array<Record<string, unknown>>;
@@ -423,6 +424,9 @@ export const buildCoreTurnCommandWorldInput = (
maxLevel: 255, maxLevel: 255,
maxDedLevel: 30, maxDedLevel: 30,
upgradeLimit: 30, upgradeLimit: 30,
...(request.setup?.world?.staticEventHandlers
? { staticEventHandlers: request.setup.world.staticEventHandlers }
: {}),
}, },
environment: { mapName: map.id, unitSet: unitSet.id }, environment: { mapName: map.id, unitSet: unitSet.id },
}, },
@@ -1415,10 +1415,7 @@ integration('nation personnel command boundary parity', () => {
}) })
).toEqual([]); ).toEqual([]);
if (!completed) { if (!completed) {
if ( if (originalCommandFailure || (action === 'che_발령' && args.destGeneralID !== 9)) {
originalCommandFailure ||
(action === 'che_발령' && args.destGeneralID !== 9)
) {
expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) 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<string, unknown>
);
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'; type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed';
const volunteerRecruitBoundaryCases: Array<{ const volunteerRecruitBoundaryCases: Array<{