diff --git a/app/game-engine/src/turn/monthlyWanderHandler.ts b/app/game-engine/src/turn/monthlyWanderHandler.ts index 30e761b..39d40b4 100644 --- a/app/game-engine/src/turn/monthlyWanderHandler.ts +++ b/app/game-engine/src/turn/monthlyWanderHandler.ts @@ -70,6 +70,7 @@ export const createMonthlyWanderHandler = (options: { belong: 0, officer_city: 0, officerCity: 0, + permission: 'normal', ...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}), }, }); @@ -150,7 +151,7 @@ export const createMonthlyWanderHandler = (options: { }); world.pushLog({ scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, text: `${wanderer.name}${wandererNameJosaYi} 세력을 해산했습니다.`, format: LogFormat.MONTH, }); diff --git a/app/game-engine/test/monthlyWanderHandler.test.ts b/app/game-engine/test/monthlyWanderHandler.test.ts index fd8cd48..3e459c6 100644 --- a/app/game-engine/test/monthlyWanderHandler.test.ts +++ b/app/game-engine/test/monthlyWanderHandler.test.ts @@ -16,6 +16,7 @@ const buildGeneral = (options: { gold: number; rice: number; belong: number; + permission?: string; turnTime: Date; }): TurnGeneral => ({ id: options.id, @@ -43,7 +44,7 @@ const buildGeneral = (options: { age: 20, npcState: options.npcState, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, - meta: { killturn: 0, makelimit: 0, belong: options.belong }, + meta: { killturn: 0, makelimit: 0, belong: options.belong, permission: options.permission ?? 'normal' }, turnTime: options.turnTime, }); @@ -130,6 +131,7 @@ describe('monthly wandering nation cleanup', () => { gold: 2_000, rice: 3_000, belong: 7, + permission: 'ambassador', turnTime: new Date('0195-02-01T12:28:00.000Z'), }), buildGeneral({ @@ -142,6 +144,7 @@ describe('monthly wandering nation cleanup', () => { gold: 1_500, rice: 4_000, belong: 5, + permission: 'auditor', turnTime: new Date('0195-02-01T12:53:00.000Z'), }), buildGeneral({ @@ -180,14 +183,14 @@ describe('monthly wandering nation cleanup', () => { gold: 1_000, rice: 1_000, lastTurn: { command: '해산', arg: {} }, - meta: { belong: 0, makelimit: 12 }, + meta: { belong: 0, makelimit: 12, permission: 'normal' }, }); expect(world.getGeneralById(2)).toMatchObject({ nationId: 0, officerLevel: 0, gold: 1_000, rice: 4_000, - meta: { belong: 0, max_belong: 5 }, + meta: { belong: 0, max_belong: 5, permission: 'normal' }, }); expect(world.getGeneralById(3)).toMatchObject({ nationId: 1, @@ -264,7 +267,7 @@ describe('monthly wandering nation cleanup', () => { }, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, text: '방랑주가 세력을 해산했습니다.', format: LogFormat.MONTH, }, diff --git a/app/game-engine/test/monthlyWanderPersistence.integration.test.ts b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts index 80d0896..c8bd51f 100644 --- a/app/game-engine/test/monthlyWanderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts @@ -26,6 +26,7 @@ const buildGeneral = (options: { gold: number; rice: number; belong: number; + permission?: string; turnTime: Date; }): TurnGeneral => ({ id: options.id, @@ -53,7 +54,7 @@ const buildGeneral = (options: { age: 20, npcState: options.npcState, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, - meta: { killturn: 0, makelimit: 0, belong: options.belong }, + meta: { killturn: 0, makelimit: 0, belong: options.belong, permission: options.permission ?? 'normal' }, turnTime: options.turnTime, }); @@ -155,6 +156,7 @@ integration('monthly wandering nation persistence', () => { gold: 2_000, rice: 3_000, belong: 7, + permission: 'ambassador', turnTime: new Date('0195-02-01T12:28:00.000Z'), }), buildGeneral({ @@ -167,6 +169,7 @@ integration('monthly wandering nation persistence', () => { gold: 1_500, rice: 4_000, belong: 5, + permission: 'auditor', turnTime: new Date('0195-02-01T12:53:00.000Z'), }), buildGeneral({ @@ -336,14 +339,14 @@ integration('monthly wandering nation persistence', () => { gold: 1_000, rice: 1_000, lastTurn: { command: '해산', arg: {} }, - meta: expect.objectContaining({ belong: 0, makelimit: 12 }), + meta: expect.objectContaining({ belong: 0, makelimit: 12, permission: 'normal' }), }), expect.objectContaining({ nationId: 0, officerLevel: 0, gold: 1_000, rice: 4_000, - meta: expect.objectContaining({ belong: 0, max_belong: 5 }), + meta: expect.objectContaining({ belong: 0, max_belong: 5, permission: 'normal' }), }), ]); expect(await db.city.findUniqueOrThrow({ where: { id: cityIds[0]! } })).toMatchObject({ @@ -443,7 +446,7 @@ integration('monthly wandering nation persistence', () => { }, { scope: 'SYSTEM', - category: 'ACTION', + category: 'SUMMARY', generalId: null, year: 195, month: 2, diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index e74e67f..d3dce61 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -98,6 +98,8 @@ export class ActionResolver< // 2. Recruiter Rewards const recruiterExperience = destGeneral.experience + 100; const recruiterDedication = destGeneral.dedication + 100; + const belong = readMetaNumberFromUnknown(general.meta, 'belong') ?? 0; + const maxBelong = readMetaNumberFromUnknown(general.meta, 'max_belong') ?? 0; const recruiterExpLevel = Math.max( 0, Math.min( @@ -242,6 +244,7 @@ export class ActionResolver< ...(general.npcState < 2 ? { killturn: context.worldKillturn ?? general.meta.killturn, + max_belong: Math.max(belong, maxBelong), } : {}), }, diff --git a/packages/logic/src/actions/turn/general/che_해산.ts b/packages/logic/src/actions/turn/general/che_해산.ts index d0cce43..c26cc17 100644 --- a/packages/logic/src/actions/turn/general/che_해산.ts +++ b/packages/logic/src/actions/turn/general/che_해산.ts @@ -10,6 +10,7 @@ import type { import { createCityPatchEffect, createGeneralPatchEffect, + createLogEffect, createNationPatchEffect, } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; @@ -22,6 +23,9 @@ import type { GeneralTurnCommandSpec } from './index.js'; const ACTION_NAME = '해산'; const ACTION_KEY = 'che_해산'; +const readMetaNumber = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0; + export interface DisbandFactionArgs {} export interface DisbandFactionResolveContext< @@ -76,6 +80,8 @@ export class ActionDefinition< const nationGenerals = context.nationGenerals ?? []; for (const targetGeneral of nationGenerals) { const isActor = targetGeneral.id === general.id; + const belong = readMetaNumber(targetGeneral.meta.belong); + const maxBelong = readMetaNumber(targetGeneral.meta.max_belong); effects.push( createGeneralPatchEffect( { @@ -92,6 +98,8 @@ export class ActionDefinition< belong: 0, officer_city: 0, officerCity: 0, + permission: 'normal', + ...(targetGeneral.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}), ...(isActor ? { makelimit: 12 } : {}), }, }, @@ -135,13 +143,39 @@ export class ActionDefinition< }); context.addLog(`${general.name}${josaYi} 세력을 해산했습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, }); context.addLog(`${nation.name}${josaUl} 해산`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, }); + const josaUn = JosaUtil.pick(nation.name, '은'); + const josaNationYi = JosaUtil.pick(nation.name, '이'); + effects.push( + createLogEffect(`【멸망】${nation.name}${josaUn} 멸망했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }) + ); + for (const targetGeneral of nationGenerals) { + effects.push( + createLogEffect(`${nation.name}${josaNationYi} 멸망했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + generalId: targetGeneral.id, + }), + createLogEffect(`${nation.name}${josaNationYi} 멸망`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + generalId: targetGeneral.id, + }) + ); + } + return { effects }; } } diff --git a/packages/logic/test/scenarios/general/che_등용수락.test.ts b/packages/logic/test/scenarios/general/che_등용수락.test.ts index 3484864..1fcd82a 100644 --- a/packages/logic/test/scenarios/general/che_등용수락.test.ts +++ b/packages/logic/test/scenarios/general/che_등용수락.test.ts @@ -172,7 +172,7 @@ describe('che_등용수락', () => { age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, - meta: { killturn: 24 }, + meta: { killturn: 24, belong: 10, max_belong: 2 }, }; const recruiterGen: General = { @@ -244,6 +244,11 @@ describe('che_등용수락', () => { expect(updatedSelf?.cityId).toBe(102); // Capital of Nation2 expect(updatedSelf?.experience).toBe(100); expect(updatedSelf?.dedication).toBe(100); + expect(updatedSelf?.meta).toMatchObject({ + belong: 1, + max_belong: 10, + permission: 'normal', + }); expect(updatedRecruiter?.experience).toBe(100); expect(updatedRecruiter?.dedication).toBe(100); diff --git a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts index 6df80dc..e26b4fc 100644 --- a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts +++ b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts @@ -253,8 +253,26 @@ describe('migrated general commands', () => { }); it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => { - const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, gold: 1500, rice: 1800 }); - const member = makeGeneral({ id: 2, nationId: 1, cityId: 2, name: '부하', officerLevel: 1, gold: 2500, rice: 500 }); + const lord = makeGeneral({ + id: 1, + nationId: 1, + cityId: 1, + name: '군주', + officerLevel: 12, + gold: 1500, + rice: 1800, + meta: { belong: 5, max_belong: 2, permission: 'ambassador' }, + }); + const member = makeGeneral({ + id: 2, + nationId: 1, + cityId: 2, + name: '부하', + officerLevel: 1, + gold: 2500, + rice: 500, + meta: { belong: 7, max_belong: 3, permission: 'auditor' }, + }); const nation = makeNation({ id: 1, name: '방랑군', @@ -294,6 +312,8 @@ describe('migrated general commands', () => { expect(updatedLord.meta.makelimit).toBe(12); expect(updatedLord.gold).toBe(1000); expect(updatedMember.gold).toBe(1000); + expect(updatedLord.meta).toMatchObject({ belong: 0, max_belong: 5, permission: 'normal' }); + expect(updatedMember.meta).toMatchObject({ belong: 0, max_belong: 7, permission: 'normal' }); expect(world.getCity(1)!.nationId).toBe(0); expect(world.getCity(2)!.nationId).toBe(0); expect(world.getNation(1)!.meta.collapsed).toBe(true); diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index 851151f..6f897c8 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -124,6 +124,9 @@ export const projectCoreDatabaseSnapshot = (rows: { 'officerCityId', readNumber(meta, 'officer_city', readNumber(meta, 'officerCity', readNumber(meta, 'officerCityId'))) ), + belong: readNumber(row, 'belong', readNumber(meta, 'belong')), + permission: readString(row, 'permission') ?? readString(meta, 'permission') ?? 'normal', + maxBelong: readNumber(meta, 'max_belong'), betray: row.betray, personality: row.personality ?? null, specialDomestic: row.specialDomestic ?? null, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 535ac34..f95aa21 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -281,6 +281,8 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur 'officerCityId', readNumber(meta, 'officer_city', readNumber(meta, 'officerCity')) ), + belong: readNumber(row, 'belong', readNumber(meta, 'belong')), + permission: readString(row, 'permission', readString(meta, 'permission', 'normal')), block: readNumber(row, 'blockState', readNumber(meta, 'block')), }, ...(lastTurn ? { lastTurn } : {}), @@ -543,6 +545,9 @@ const projectWorld = ( 'officer_city', readNumber(general.meta, 'officerCity', readNumber(general.meta, 'officerCityId')) ), + belong: readNumber(general.meta, 'belong'), + permission: readString(general.meta, 'permission', 'normal'), + maxBelong: readNumber(general.meta, 'max_belong'), betray: readNumber(general.meta, 'betray'), personality: general.role.personality, specialDomestic: general.role.specialDomestic, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index c2bdb29..e458d18 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -88,6 +88,8 @@ const general = (id: number, nationId: number, cityId: number, officerLevel: num killTurn: 24, npcState: 0, blockState: 0, + belong: 10, + permission: 'normal', personality: 'None', specialDomestic: 'None', specialWar: 'None', @@ -487,6 +489,112 @@ integration('NPC active command boundary parity', () => { ); }); +interface DisbandFactionBoundaryCase { + name: string; + actorPatch?: Record; + fixturePatches: FixturePatches; + completed: boolean; + compareLogs?: boolean; +} + +const disbandFactionBoundaryCases: DisbandFactionBoundaryCase[] = [ + { + name: 'a non-lord cannot disband the wandering nation', + actorPatch: { officerLevel: 11 }, + fixturePatches: { + nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } }, + cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } }, + }, + completed: false, + }, + { + name: 'a settled nation cannot be disbanded', + fixturePatches: { + nations: { 1: { level: 1 } }, + }, + completed: false, + }, + { + name: 'disbanding preserves the legacy member state, resources, and destruction logs', + actorPatch: { + troopId: 3, + belong: 5, + permission: 'ambassador', + gold: 2_000, + rice: 3_000, + meta: { max_belong: 2 }, + }, + fixturePatches: { + nations: { 1: { name: '방랑군', level: 0, capitalCityId: 0, typeCode: 'None' } }, + cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } }, + generals: { + 3: { + troopId: 3, + belong: 7, + permission: 'auditor', + gold: 2_500, + rice: 4_000, + meta: { max_belong: 3 }, + }, + }, + troops: [{ id: 3, nationId: 1, name: '방랑군 부대' }], + }, + completed: true, + compareLogs: true, + }, +]; + +integration('general faction disband boundary, state, and log parity', () => { + it.each(disbandFactionBoundaryCases)( + '$name', + async ({ name, actorPatch, fixturePatches, completed, compareLogs }) => { + const request = buildRequest('che_해산', undefined, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = `general-disband-${name}`; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') { + process.stderr.write( + `${JSON.stringify( + { + name, + referenceGenerals: reference.after.generals, + coreGenerals: core.after.generals, + referenceLogs: addedReferenceLogs(reference.before, reference.after.logs), + coreLogs: core.after.logs, + }, + null, + 2 + )}\n` + ); + } + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_해산', + actionKey: completed ? 'che_해산' : '휴식', + usedFallback: !completed, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (compareLogs) { + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + interface SelfStateBoundaryCase { name: string; action: 'che_단련' | 'che_숙련전환' | 'che_사기진작' | 'che_요양';