diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 8bffaf2..21a9013 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -89,6 +89,15 @@ 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); @@ -485,6 +494,32 @@ async function handleTroopJoin( world.updateGeneral(command.generalId, { troopId: command.troopId, }); + if (hasStaticEventHandler(world, LEGACY_TROOP_JOIN_EVENT, IMMEDIATE_TROOP_JOIN_MOVE_HANDLER)) { + const leader = world.getGeneralById(troop.id); + if ( + leader && + leader.troopId === leader.id && + leader.nationId === general.nationId && + leader.cityId !== general.cityId + ) { + const city = world.getCityById(leader.cityId); + if (city) { + world.updateGeneral(command.generalId, { + troopId: command.troopId, + cityId: leader.cityId, + }); + const josaRo = JosaUtil.pick(city.name, '로'); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + text: `부대 주둔지인 ${city.name}${josaRo} 즉시 이동합니다.`, + generalId: general.id, + meta: {}, + }); + } + } + } return { type: 'troopJoin', ok: true, diff --git a/app/game-engine/test/troopManagement.test.ts b/app/game-engine/test/troopManagement.test.ts index 154df6e..ce6e155 100644 --- a/app/game-engine/test/troopManagement.test.ts +++ b/app/game-engine/test/troopManagement.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic'; +import { LogCategory, LogFormat, LogScope, type City, type TriggerValue, type TurnSchedule } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; @@ -41,10 +41,35 @@ const buildGeneral = (id: number, overrides: Partial = {}): TurnGen ...overrides, }); +const buildCity = (id: number, name: string): City => ({ + id, + name, + nationId: 1, + level: 1, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + meta: {}, +}); + const buildWorld = (options: { generals?: TurnGeneral[]; + cities?: City[]; troops?: Array<{ id: number; nationId: number; name: string }>; nationMeta?: Record; + staticEventHandlers?: Record; }) => { const state: TurnWorldState = { id: 1, @@ -56,7 +81,7 @@ const buildWorld = (options: { }; const snapshot: TurnWorldSnapshot = { generals: options.generals ?? [buildGeneral(1)], - cities: [], + cities: options.cities ?? [], nations: [ { id: 1, @@ -80,7 +105,9 @@ const buildWorld = (options: { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, iconPath: '', map: {}, - const: {}, + const: { + ...(options.staticEventHandlers ? { staticEventHandlers: options.staticEventHandlers } : {}), + }, environment: { mapName: 'test', unitSet: 'test' }, }, map: { @@ -94,6 +121,69 @@ const buildWorld = (options: { }; describe('troop management world commands', () => { + it('applies scenario 911 immediate movement and plain action log after joining a troop', async () => { + const world = buildWorld({ + generals: [buildGeneral(1, { cityId: 1 }), buildGeneral(2, { cityId: 2, troopId: 2 })], + cities: [buildCity(1, '장안'), buildCity(2, '낙양')], + troops: [{ id: 2, nationId: 1, name: '백마대' }], + staticEventHandlers: { + 'sammo\\API\\Troop\\JoinTroop': ['event_부대탑승즉시이동'], + }, + }); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toEqual({ + type: 'troopJoin', + ok: true, + generalId: 1, + troopId: 2, + }); + expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 2 }); + expect(world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + text: '부대 주둔지인 낙양으로 즉시 이동합니다.', + generalId: 1, + meta: {}, + }, + ]); + }); + + it('keeps the ordinary join location when the scenario static handler is absent', async () => { + const world = buildWorld({ + generals: [buildGeneral(1, { cityId: 1 }), buildGeneral(2, { cityId: 2, troopId: 2 })], + cities: [buildCity(1, '장안'), buildCity(2, '낙양')], + troops: [{ id: 2, nationId: 1, name: '백마대' }], + }); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({ + ok: true, + }); + expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 1 }); + expect(world.peekDirtyState().logs).toEqual([]); + }); + + it('does not emit the scenario movement log when the member is already with the troop leader', async () => { + const world = buildWorld({ + generals: [buildGeneral(1, { cityId: 2 }), buildGeneral(2, { cityId: 2, troopId: 2 })], + cities: [buildCity(2, '낙양')], + troops: [{ id: 2, nationId: 1, name: '백마대' }], + staticEventHandlers: { + 'sammo\\API\\Troop\\JoinTroop': ['event_부대탑승즉시이동'], + }, + }); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({ + ok: true, + }); + expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 2 }); + expect(world.peekDirtyState().logs).toEqual([]); + }); + it('creates a troop and assigns the authenticated general atomically in dirty state', async () => { const world = buildWorld({}); const handler = createTurnDaemonCommandHandler({ world }); diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index db8cea8..76249a0 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -34,7 +34,7 @@ export interface CanonicalTurnCommandTrace { schemaVersion: 1; engine: CanonicalEngine; execution: { - kind: 'general' | 'nation' | 'instantNation'; + kind: 'general' | 'nation' | 'instantNation' | 'troopJoinStaticEvent'; actorGeneralId: number; action: string; args: unknown; diff --git a/tools/integration-tests/test/troopStaticEvent.integration.test.ts b/tools/integration-tests/test/troopStaticEvent.integration.test.ts new file mode 100644 index 0000000..6a73e3d --- /dev/null +++ b/tools/integration-tests/test/troopStaticEvent.integration.test.ts @@ -0,0 +1,401 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { formatLogText, LogFormat, type City, type TurnSchedule } from '@sammo-ts/logic'; +import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js'; +import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js'; +import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '@sammo-ts/game-engine/turn/types.js'; +import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; + +import { + findTurnDifferentialWorkspaceRoot, + runReferenceTurnCommandTraceRequest, +} from '../src/turn-differential/referenceSnapshot.js'; + +const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const persistence = describe.skipIf(!databaseUrl); +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (id: number, cityId: number, troopId: number): TurnGeneral => ({ + id, + name: `fixture-general-${id}`, + nationId: 1, + cityId, + troopId, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('0180-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, +}); + +const buildCity = (id: number, name: string): City => ({ + id, + name, + nationId: 1, + level: 1, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + meta: {}, +}); + +integration('scenario 911 troop join static event parity', () => { + it('moves the joining member to the leader city with the same plain log', async () => { + const reference = runReferenceTurnCommandTraceRequest(workspaceRoot!, { + kind: 'troopJoinStaticEvent', + actorGeneralId: 1, + action: 'sammo\\API\\Troop\\JoinTroop', + args: { troopID: 2 }, + setup: { + isolateWorld: true, + world: { year: 180, month: 1 }, + nations: [{ id: 1, name: '테스트국', capitalCityId: 3, level: 1 }], + cities: [ + { id: 3, nationId: 1 }, + { id: 70, nationId: 1 }, + ], + generals: [ + { id: 1, name: '가입장수', nationId: 1, cityId: 3, troopId: 0 }, + { id: 2, name: '부대장', nationId: 1, cityId: 70, troopId: 2 }, + ], + troops: [{ id: 2, nationId: 1, name: '백마대' }], + }, + observe: { + generalIds: [1, 2], + cityIds: [3, 70], + nationIds: [1], + }, + }); + const referenceActor = reference.after.generals.find((entry) => entry.id === 1); + const destination = reference.after.cities.find((entry) => entry.id === 70); + expect(reference.execution.outcome).toEqual({ completed: true }); + expect(reference.rng).toEqual([]); + expect(referenceActor).toMatchObject({ troopId: 2, cityId: 70 }); + expect(destination?.name).toEqual(expect.any(String)); + + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: { killturn: 24 }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [buildGeneral(1, 3, 0), buildGeneral(2, 70, 2)], + cities: [buildCity(3, '출발지'), buildCity(70, String(destination?.name))], + nations: [ + { + id: 1, + name: '테스트국', + color: '#ff0000', + capitalCityId: 3, + chiefGeneralId: 2, + gold: 1_000, + rice: 1_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, + }, + ], + troops: [{ id: 2, nationId: 1, name: '백마대' }], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: { + staticEventHandlers: { + 'sammo\\API\\Troop\\JoinTroop': ['event_부대탑승즉시이동'], + }, + }, + environment: { mapName: 'miniche', unitSet: 'che_except_siege' }, + }, + map: { + id: 'miniche', + name: 'miniche', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({ + ok: true, + }); + expect(world.getGeneralById(1)).toMatchObject({ + troopId: referenceActor?.troopId, + cityId: referenceActor?.cityId, + }); + + const referenceLog = reference.after.logs.find( + (entry) => + typeof entry.id === 'number' && entry.id > reference.before.watermarks.logId && entry.generalId === 1 + ); + const coreLog = world.peekDirtyState().logs.at(-1); + expect(coreLog).toBeDefined(); + expect(referenceLog).toMatchObject({ + scope: 'general', + category: 'action', + generalId: 1, + text: formatLogText(coreLog!.text, coreLog!.format ?? LogFormat.RAWTEXT, 180, 1), + }); + }, 120_000); +}); + +persistence('scenario 911 troop join static event persistence', () => { + const nationId = 994_911; + const actorId = 994_911; + const leaderId = 994_912; + const sourceCityId = 994_911; + const destinationCityId = 994_912; + const scenarioCode = 'scenario911-troop-join-static-event'; + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async (): Promise => { + await db.logEntry.deleteMany({ where: { generalId: { in: [actorId, leaderId] } } }); + await db.troop.deleteMany({ where: { troopLeaderId: leaderId } }); + await db.general.deleteMany({ where: { id: { in: [actorId, leaderId] } } }); + await db.city.deleteMany({ where: { id: { in: [sourceCityId, destinationCityId] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('flushes membership, immediate city movement, and the plain log together', async () => { + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + staticEventHandlers: { + 'sammo\\API\\Troop\\JoinTroop': ['event_부대탑승즉시이동'], + }, + }, + environment: { mapName: 'miniche', unitSet: 'che_except_siege' }, + }; + const actor = buildGeneral(actorId, sourceCityId, 0); + const leader = buildGeneral(leaderId, destinationCityId, leaderId); + actor.name = '가입장수'; + actor.nationId = nationId; + leader.name = '부대장'; + leader.nationId = nationId; + const sourceCity = buildCity(sourceCityId, '장안'); + const destinationCity = buildCity(destinationCityId, '낙양'); + sourceCity.nationId = nationId; + destinationCity.nationId = nationId; + + await db.nation.create({ + data: { + id: nationId, + name: '테스트국', + color: '#ff0000', + capitalCityId: sourceCityId, + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + tech: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, + }, + }); + await db.city.createMany({ + data: [sourceCity, destinationCity].map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + supplyState: city.supplyState, + frontState: city.frontState, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + })), + }); + await db.general.createMany({ + data: [actor, leader].map((general) => ({ + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + officerLevel: general.officerLevel, + npcState: general.npcState, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + age: general.age, + turnTime: general.turnTime, + meta: general.meta, + })), + }); + await db.troop.create({ + data: { + troopLeaderId: leaderId, + nationId, + name: '백마대', + }, + }); + const worldRow = await db.worldState.create({ + data: { + scenarioCode, + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + config: scenarioConfig as unknown as GamePrisma.InputJsonValue, + meta: {}, + }, + }); + const state: TurnWorldState = { + id: worldRow.id, + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + map: { + id: 'miniche', + name: 'miniche', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [actor, leader], + cities: [sourceCity, destinationCity], + nations: [ + { + id: nationId, + name: '테스트국', + color: '#ff0000', + capitalCityId: sourceCityId, + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, + }, + ], + troops: [{ id: leaderId, nationId, name: '백마대' }], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const handler = createTurnDaemonCommandHandler({ world }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world); + + try { + await expect( + handler.handle({ type: 'troopJoin', generalId: actorId, troopId: leaderId }) + ).resolves.toMatchObject({ + ok: true, + }); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.general.findUniqueOrThrow({ where: { id: actorId } })).toMatchObject({ + troopId: leaderId, + cityId: destinationCityId, + }); + expect( + await db.logEntry.findFirstOrThrow({ + where: { generalId: actorId }, + select: { scope: true, category: true, text: true, year: true, month: true }, + }) + ).toEqual({ + scope: 'GENERAL', + category: 'ACTION', + text: '●부대 주둔지인 낙양으로 즉시 이동합니다.', + year: 180, + month: 1, + }); + } finally { + await dbHooks.close(); + } + }); +});