diff --git a/app/game-engine/src/turn/monthlyEventHandler.ts b/app/game-engine/src/turn/monthlyEventHandler.ts index fabf00d..deb12b7 100644 --- a/app/game-engine/src/turn/monthlyEventHandler.ts +++ b/app/game-engine/src/turn/monthlyEventHandler.ts @@ -20,6 +20,40 @@ export type MonthlyEventActionHandler = ( export type MonthlyEventActionRegistry = ReadonlyMap; +export const MONTHLY_EVENT_ACTION_CATALOG = [ + 'ProcessIncome', + 'NoticeToHistoryLog', + 'NewYear', + 'ResetOfficerLock', + 'RandomizeCityTradeRate', + 'RaiseDisaster', + 'UpdateCitySupply', + 'UpdateNationLevel', + 'ProcessSemiAnnual', + 'ProcessWarIncome', + 'CreateAdminNPC', + 'CreateManyNPC', + 'RegNPC', + 'RegNeutralNPC', + 'RaiseNPCNation', + 'RaiseInvader', + 'AutoDeleteInvader', + 'InvaderEnding', + 'ChangeCity', + 'ProvideNPCTroopLeader', + 'OpenNationBetting', + 'FinishNationBetting', + 'BlockScoutAction', + 'UnblockScoutAction', + 'AssignGeneralSpeciality', + 'AddGlobalBetray', + 'LostUniqueItem', + 'MergeInheritPointRank', + 'DeleteEvent', +] as const; + +export type MonthlyEventActionName = (typeof MONTHLY_EVENT_ACTION_CATALOG)[number]; + const COMPARATORS = new Set(['==', '!=', '<', '>', '<=', '>=']); const CITY_TRADE_PROBABILITY_BY_LEVEL: Readonly> = { 1: 0, diff --git a/app/game-engine/test/monthlyCatalogCoverage.test.ts b/app/game-engine/test/monthlyCatalogCoverage.test.ts new file mode 100644 index 0000000..c2c551e --- /dev/null +++ b/app/game-engine/test/monthlyCatalogCoverage.test.ts @@ -0,0 +1,173 @@ +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { + MONTHLY_EVENT_ACTION_CATALOG, + type MonthlyEventActionName, +} from '../src/turn/monthlyEventHandler.js'; + +interface CatalogSegment { + name: string; + kind: 'single-boundary' | 'multi-month' | 'no-op' | 'expected-failure'; + actions: readonly MonthlyEventActionName[]; + coreEvidence: readonly string[]; + refEvidence: readonly string[]; +} + +const segments = [ + { + name: 'aggregate-core-boundary', + kind: 'single-boundary', + actions: [ + 'ProcessIncome', + 'NoticeToHistoryLog', + 'NewYear', + 'ResetOfficerLock', + 'RandomizeCityTradeRate', + 'ChangeCity', + 'DeleteEvent', + ], + coreEvidence: ['monthlyCatalogBoundaryPersistence.integration.test.ts'], + refEvidence: ['monthly_boundary_core_catalog.json'], + }, + { + name: 'city-economy-boundaries', + kind: 'single-boundary', + actions: [ + 'RaiseDisaster', + 'UpdateCitySupply', + 'UpdateNationLevel', + 'ProcessSemiAnnual', + 'ProcessWarIncome', + ], + coreEvidence: [ + 'monthlyDisasterPersistence.integration.test.ts', + 'monthlyCitySupplyPersistence.integration.test.ts', + 'monthlyNationLevelPersistence.integration.test.ts', + 'monthlySemiAnnualPersistence.integration.test.ts', + 'monthlyWarIncomePersistence.integration.test.ts', + ], + refEvidence: [ + 'raise_disaster fixed-seed trace', + 'monthly_update_city_supply.json', + 'monthly_update_nation_level.json', + 'monthly_process_semi_annual.json', + 'monthly_process_war_income.json', + ], + }, + { + name: 'legacy-admin-npc-no-op', + kind: 'no-op', + actions: ['CreateAdminNPC'], + coreEvidence: ['monthlyCreateAdminNpcAction.test.ts'], + refEvidence: ['monthly_create_admin_npc.json'], + }, + { + name: 'npc-registration-boundaries', + kind: 'single-boundary', + actions: ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseNPCNation'], + coreEvidence: [ + 'monthlyCreateManyNpcPersistence.integration.test.ts', + 'monthlyRegisterNpcPersistence.integration.test.ts', + 'monthlyRaiseNpcNationPersistence.integration.test.ts', + ], + refEvidence: [ + 'monthly_create_many_npc.json', + 'monthly_reg_npc.json', + 'monthly_reg_neutral_npc.json', + 'monthly_raise_npc_nation.json', + ], + }, + { + name: 'invader-lifecycle', + kind: 'multi-month', + actions: ['RaiseInvader', 'AutoDeleteInvader', 'InvaderEnding'], + coreEvidence: ['monthlyInvaderPersistence.integration.test.ts'], + refEvidence: [ + 'monthly_raise_invader.json', + 'monthly_auto_delete_invader.json', + 'monthly_invader_ending.json', + ], + }, + { + name: 'npc-troop-support', + kind: 'single-boundary', + actions: ['ProvideNPCTroopLeader'], + coreEvidence: ['monthlyNpcSupportPersistence.integration.test.ts'], + refEvidence: ['ProvideNPCTroopLeader fixed-seed trace'], + }, + { + name: 'nation-betting-lifecycle', + kind: 'multi-month', + actions: ['OpenNationBetting', 'FinishNationBetting'], + coreEvidence: ['monthlyNationBettingPersistence.integration.test.ts'], + refEvidence: ['monthly_open_nation_betting.json', 'monthly_finish_nation_betting.json'], + }, + { + name: 'scout-block-boundaries', + kind: 'single-boundary', + actions: ['BlockScoutAction'], + coreEvidence: ['monthlyScoutBlockPersistence.integration.test.ts'], + refEvidence: ['monthly_block_scout.json'], + }, + { + name: 'legacy-unblock-scout-error', + kind: 'expected-failure', + actions: ['UnblockScoutAction'], + coreEvidence: ['monthlyScoutBlockAction.test.ts'], + refEvidence: ['monthly_unblock_scout.json'], + }, + { + name: 'speciality-betray-chain', + kind: 'single-boundary', + actions: ['AssignGeneralSpeciality', 'AddGlobalBetray'], + coreEvidence: ['monthlySpecialityBetrayPersistence.integration.test.ts'], + refEvidence: ['monthly_assign_general_speciality.json', 'monthly_add_global_betray.json'], + }, + { + name: 'unique-inherit-chain', + kind: 'single-boundary', + actions: ['LostUniqueItem', 'MergeInheritPointRank'], + coreEvidence: ['monthlyUniqueInheritPersistence.integration.test.ts'], + refEvidence: ['monthly_lost_unique_item.json', 'monthly_merge_inherit_point_rank.json'], + }, +] as const satisfies readonly CatalogSegment[]; + +describe('monthly event catalog coverage', () => { + it('assigns every legacy action to exactly one dependency-safe segment', () => { + const covered = segments.flatMap((segment) => segment.actions); + + expect(covered).toHaveLength(29); + expect(new Set(covered).size).toBe(29); + expect([...covered].sort()).toEqual([...MONTHLY_EVENT_ACTION_CATALOG].sort()); + }); + + it('keeps every core evidence file executable in this suite', () => { + const testDirectory = fileURLToPath(new URL('.', import.meta.url)); + const evidenceFiles = new Set(segments.flatMap((segment) => segment.coreEvidence)); + + for (const evidenceFile of evidenceFiles) { + expect(existsSync(`${testDirectory}/${evidenceFile}`), evidenceFile).toBe(true); + } + }); + + it('keeps lifecycle-only dispositions out of the aggregate single-boundary claim', () => { + const multiMonthActions = segments + .filter((segment) => segment.kind === 'multi-month') + .flatMap((segment) => segment.actions); + const specialDispositions = segments + .filter((segment) => segment.kind === 'no-op' || segment.kind === 'expected-failure') + .flatMap((segment) => segment.actions); + + expect(multiMonthActions).toEqual([ + 'RaiseInvader', + 'AutoDeleteInvader', + 'InvaderEnding', + 'OpenNationBetting', + 'FinishNationBetting', + ]); + expect(specialDispositions).toEqual(['CreateAdminNPC', 'UnblockScoutAction']); + expect(segments.every((segment) => segment.refEvidence.length > 0)).toBe(true); + }); +}); diff --git a/docs/test-suite-audit.md b/docs/test-suite-audit.md index 361268a..e71d9a0 100644 --- a/docs/test-suite-audit.md +++ b/docs/test-suite-audit.md @@ -67,6 +67,7 @@ environment-dependent check. | `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. | | `test/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. | | `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. | +| `test/monthlyCatalogCoverage.test.ts` | added | compatibility | Exact 29-action ref catalog coverage, dependency-safe segment assignment, executable core evidence files, and explicit multi-month/no-op/error dispositions. Behavioral claims remain in the referenced PostgreSQL/ref fixtures. | | `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. | | `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. | | `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |