fix: Ref 게임 로직과 시나리오 풀 호환을 보정

월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
2026-08-23 16:26:14 +00:00
parent bf6b7be7b0
commit 85591c68ad
114 changed files with 13327 additions and 901 deletions
@@ -1,11 +1,23 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
import {
parseScenarioGeneralPoolCandidate,
PERSONALITY_TRAIT_KEYS,
type City,
type MapDefinition,
type Nation,
} from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createRaiseNpcNationHandler } from '../src/turn/monthlyRaiseNpcNationAction.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import type {
TurnEvent,
TurnGeneral,
TurnGeneralPoolEntry,
TurnWorldSnapshot,
TurnWorldState,
} from '../src/turn/types.js';
const buildCity = (id: number, nationId: number, level = 5): City => ({
id,
@@ -119,7 +131,13 @@ const event: TurnEvent = {
meta: {},
};
const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fixture') => {
const buildHarness = (
archivedNationMaxId = 0,
hiddenSeed = 'raise-npc-nation-fixture',
generalPoolEntries?: TurnGeneralPoolEntry[],
additionalGeneralCount = 0,
poolName = 'SPoolUnderU30'
) => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
@@ -132,7 +150,12 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
map: generalPoolEntries
? {
targetGeneralPool: poolName,
...(poolName === 'SPoolUnderU100' ? { centennialNpcDexTargetRatio: 0.4 } : {}),
}
: {},
const: {
retirementYear: 80,
availablePersonality: ['che_안전'],
@@ -143,19 +166,22 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
environment: { mapName: 'test', unitSet: 'default' },
},
map,
generals: [buildGeneral()],
cities: [
buildCity(1, 1),
buildCity(2, 0),
buildCity(3, 0, 4),
buildCity(4, 0),
buildCity(5, 0, 4),
generals: [
buildGeneral(),
...Array.from({ length: additionalGeneralCount }, (_, index) => ({
...buildGeneral(),
id: index + 2,
name: `장수${index + 2}`,
officerLevel: 1,
})),
],
cities: [buildCity(1, 1), buildCity(2, 0), buildCity(3, 0, 4), buildCity(4, 0), buildCity(5, 0, 4)],
nations: [buildNation(1)],
troops: [],
diplomacy: [],
events: [event],
initialEvents: [],
...(generalPoolEntries ? { generalPoolEntries } : {}),
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
@@ -202,6 +228,94 @@ describe('RaiseNPCNation monthly action', () => {
vi.restoreAllMocks();
});
it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
const info = {
generalName: '부장후보',
leadership: 99,
strength: 1,
intel: 1,
specialDomestic: 'che_event_징병',
dex: [10, 20, 30, 40, 50],
imgsvr: 1,
picture: 'subordinate.gif',
};
const entry: TurnGeneralPoolEntry = {
id: 41,
uniqueName: info.generalName,
ownerUserId: null,
generalId: null,
reservedUntil: null,
reservedUntilTick: null,
candidate: parseScenarioGeneralPoolCandidate({ id: 41, uniqueName: info.generalName, info }),
};
const { world, handler, environment } = buildHarness(0, 'raise-pool-fixture', [entry], 1);
await handler([], environment, event);
const subordinate = world.peekDirtyState().createdGenerals.find((general) => general.name === 'ⓤ부장후보');
expect(subordinate).toMatchObject({
name: 'ⓤ부장후보',
picture: 'subordinate.gif',
imageServer: 1,
role: { specialDomestic: 'che_event_징병' },
meta: {
dex1: 10,
dex2: 20,
dex3: 30,
dex4: 40,
dex5: 50,
scenarioGeneralPoolClaim: {
poolEntryId: 41,
uniqueName: '부장후보',
},
},
});
expect(subordinate?.stats).not.toEqual({ leadership: 99, strength: 1, intelligence: 1 });
});
it('attaches the S100 target to a type-6 subordinate without applying current growth', async () => {
const info = {
generalName: '100기건국',
leadership: 100,
strength: 80,
intel: 10,
specialDomestic: 'che_event_징병',
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
imgsvr: 1,
picture: 'centennial-ruler.gif',
event100Growth: true,
};
const entry: TurnGeneralPoolEntry = {
id: 101,
uniqueName: 'A1000101',
ownerUserId: null,
generalId: null,
reservedUntil: null,
reservedUntilTick: null,
candidate: parseScenarioGeneralPoolCandidate({ id: 101, uniqueName: 'A1000101', info }),
};
const { world, handler, environment } = buildHarness(0, 'raise-s100-fixture', [entry], 1, 'SPoolUnderU100');
await handler([], environment, event);
const subordinate = world.peekDirtyState().createdGenerals.find((general) => general.name === 'ⓤ100기건국')!;
expect(subordinate.stats).not.toEqual({ leadership: 100, strength: 80, intelligence: 10 });
expect(subordinate.role.specialDomestic).toBeNull();
expect(subordinate.meta).toMatchObject({
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
scenarioGeneralPoolClaim: { poolEntryId: 101, uniqueName: 'A1000101' },
event100_allstar: {
targetId: 'A1000101',
progressMonth: -1,
milestone: 0,
},
});
});
it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => {
const { world, reservedTurns, handler, environment } = buildHarness();
@@ -265,12 +379,7 @@ describe('RaiseNPCNation monthly action', () => {
});
expect(world.getCityById(5)?.nationId).toBe(0);
expect(reservedTurns.getGeneralTurns(2)).toHaveLength(30);
expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual([
'2:12',
'2:11',
'2:10',
'2:9',
]);
expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual(['2:12', '2:11', '2:10', '2:9']);
expect(dirty.logs).toEqual([
expect.objectContaining({
category: 'HISTORY',