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
@@ -90,7 +90,7 @@ const sourceEvent: TurnEvent = {
meta: {},
};
const buildWorld = () => {
const buildWorld = (options: { promotedNeutral?: boolean; generals?: TurnGeneral[]; nations?: Nation[] } = {}) => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
@@ -111,9 +111,13 @@ const buildWorld = () => {
{
scenarioConfig,
map: { id: 'test', name: 'test', cities: [] },
generals: [buildGeneral(1), buildGeneral(2)],
generals: options.generals ?? [buildGeneral(1), buildGeneral(2)],
cities: [buildCity(1), buildCity(2)],
nations: [buildNation(1, 100), buildNation(2, 300)],
nations: options.nations ?? [
...(options.promotedNeutral ? [{ ...buildNation(0, 0), name: '재야', level: 1 }] : []),
buildNation(1, 100),
buildNation(2, 300),
],
troops: [],
diplomacy: [],
events: [sourceEvent],
@@ -146,25 +150,18 @@ describe('nation betting monthly actions', () => {
closeYearMonth: 2_424,
bonusPoint: 500,
});
expect(dirty.pendingNationBettingOpens[0]?.candidates.map((candidate) => candidate.aux.nation)).toEqual([
2, 1,
]);
expect(dirty.pendingNationBettingOpens[0]?.candidates.map((candidate) => candidate.aux.nation)).toEqual([2, 1]);
expect(dirty.createdEvents).toEqual([
expect.objectContaining({
targetCode: 'DESTROY_NATION',
priority: 1_000,
condition: ['RemainNation', '<=', 1],
action: [
['FinishNationBetting', 5],
['DeleteEvent'],
],
action: [['FinishNationBetting', 5], ['DeleteEvent']],
}),
]);
expect(dirty.logs).toHaveLength(1);
expect(dirty.messages).toHaveLength(2);
expect(dirty.messages[0]?.text).toBe(
'새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.'
);
expect(dirty.messages[0]?.text).toBe('새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.');
await createFinishNationBettingHandler({ getWorld: () => world })([5], environment, sourceEvent);
expect(world.peekDirtyState().pendingNationBettingFinishes).toEqual([
@@ -177,4 +174,55 @@ describe('nation betting monthly actions', () => {
},
]);
});
it('never treats the synthetic neutral row as a nation-betting winner', async () => {
const world = buildWorld({ promotedNeutral: true });
const environment = {
year: 200,
month: 1,
startyear: 190,
currentEventID: 7,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
};
await createFinishNationBettingHandler({ getWorld: () => world })([5], environment, sourceEvent);
expect(world.peekDirtyState().pendingNationBettingFinishes).toEqual([
expect.objectContaining({ winnerNationIds: [1, 2] }),
]);
});
it('uses stored gennum and excludes npc state 5 from the fallback candidate count', async () => {
const nationOne = buildNation(1, 100);
nationOne.meta = { ...nationOne.meta, gennum: 0 };
const nationTwo = buildNation(2, 300);
const nationTwoMeta = { ...nationTwo.meta };
delete nationTwoMeta.gennum;
nationTwo.meta = nationTwoMeta;
const npcFiveNationOne = { ...buildGeneral(3), nationId: 1, npcState: 5 };
const npcFiveNationTwo = { ...buildGeneral(4), nationId: 2, npcState: 5 };
const world = buildWorld({
generals: [buildGeneral(1), buildGeneral(2), npcFiveNationOne, npcFiveNationTwo],
nations: [nationOne, nationTwo],
});
const environment = {
year: 200,
month: 1,
startyear: 190,
currentEventID: 7,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
};
await createOpenNationBettingHandler({ getWorld: () => world })([1, 500], environment, sourceEvent);
const candidates = world.peekDirtyState().pendingNationBettingOpens[0]?.candidates;
expect(candidates?.find((candidate) => candidate.aux.nation === 1)).toMatchObject({
info: '국력: 100<br>장수 수: 0<br>도시 수: 1',
aux: { gennum: 0 },
});
expect(candidates?.find((candidate) => candidate.aux.nation === 2)).toMatchObject({
info: '국력: 300<br>장수 수: 1<br>도시 수: 1',
aux: { gennum: 1 },
});
});
});