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:
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { buildScenarioGeneralPoolClaimMeta, parseScenarioGeneralPoolCandidate, type City } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const claimedAt = new Date('0200-05-01T00:00:00.000Z');
|
||||
|
||||
const buildCandidateEntry = (
|
||||
id: number,
|
||||
uniqueName: string,
|
||||
patch: Partial<TurnGeneralPoolEntry> = {}
|
||||
): TurnGeneralPoolEntry => ({
|
||||
id,
|
||||
uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({
|
||||
id,
|
||||
uniqueName,
|
||||
info: {
|
||||
generalName: uniqueName,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
},
|
||||
}),
|
||||
...patch,
|
||||
});
|
||||
|
||||
const buildGeneral = (id: number, name: string, meta: TurnGeneral['meta']): TurnGeneral => ({
|
||||
id,
|
||||
userId: null,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 80, intelligence: 10 },
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
turnTime: claimedAt,
|
||||
meta,
|
||||
});
|
||||
|
||||
const city: City = {
|
||||
id: 1,
|
||||
name: '도시',
|
||||
nationId: 0,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const buildWorld = (
|
||||
generalPoolEntries: TurnGeneralPoolEntry[],
|
||||
generals: TurnGeneral[] = [],
|
||||
stateOverride: Partial<TurnWorldState> = {}
|
||||
): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 5,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: claimedAt,
|
||||
meta: {},
|
||||
...stateOverride,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
generals,
|
||||
cities: [city],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries,
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('in-memory scenario general pool availability', () => {
|
||||
it('uses synchronized reselection rows while excluding live, orphaned, and active reservations', () => {
|
||||
const oldEntry = buildCandidateEntry(1, '이전후보');
|
||||
const currentEntry = buildCandidateEntry(2, '현재후보');
|
||||
const legacyOccupiedEntry = buildCandidateEntry(3, '기존점유', { generalId: 2 });
|
||||
const expiredEntry = buildCandidateEntry(4, '만료예약', {
|
||||
ownerUserId: 'expired-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() + 60_000),
|
||||
reservedUntilTick: -1,
|
||||
});
|
||||
const activeEntry = buildCandidateEntry(5, '활성예약', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() - 60_000),
|
||||
reservedUntilTick: 1,
|
||||
});
|
||||
const orphanedEntry = buildCandidateEntry(6, '고아점유', { generalId: 999 });
|
||||
const exactDeadlineEntry = buildCandidateEntry(7, '동률예약', {
|
||||
ownerUserId: 'exact-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() - 60_000),
|
||||
reservedUntilTick: 0,
|
||||
});
|
||||
const currentClaim = buildScenarioGeneralPoolClaimMeta(currentEntry.candidate, claimedAt);
|
||||
const world = buildWorld(
|
||||
[oldEntry, currentEntry, legacyOccupiedEntry, expiredEntry, activeEntry, orphanedEntry, exactDeadlineEntry],
|
||||
[
|
||||
buildGeneral(1, '현재후보', { killturn: 100, ...currentClaim }),
|
||||
buildGeneral(2, '기존점유', { killturn: 100 }),
|
||||
]
|
||||
);
|
||||
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)?.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'이전후보',
|
||||
'만료예약',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses a linked row only when its general was deleted in the same in-memory batch', () => {
|
||||
const linked = buildCandidateEntry(1, '삭제후보', { generalId: 1 });
|
||||
const world = buildWorld([linked], [buildGeneral(1, '삭제후보', { killturn: 100 })]);
|
||||
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)).toEqual([]);
|
||||
expect(world.removeGeneral(1)).toBe(true);
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)?.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'삭제후보',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rebases reserved rows with the schedule and restores them on rollback', () => {
|
||||
const reservedUntil = new Date(claimedAt.getTime() + 5 * 60_000);
|
||||
const reserved = buildCandidateEntry(1, '예약후보', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil,
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
});
|
||||
const unreserved = buildCandidateEntry(2, '미예약후보');
|
||||
const world = buildWorld([reserved, unreserved]);
|
||||
const before = world.captureState();
|
||||
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
|
||||
|
||||
world.shiftSchedule(15, claimedAt);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
},
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
]);
|
||||
expect(
|
||||
world.listGeneralPoolCandidates(probeAfterOriginalExpiry)?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['미예약후보']);
|
||||
|
||||
world.restoreState(before);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{ id: 1, reservedUntil, reservedUntilTick: GAME_TICKS_PER_TURN / 2 },
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
]);
|
||||
expect(
|
||||
world.listGeneralPoolCandidates(probeAfterOriginalExpiry)?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보', '미예약후보']);
|
||||
});
|
||||
|
||||
it('rebases tick-owned reservations with a long realtime backlog and restores exact expiry semantics', () => {
|
||||
const originalReservedUntilTick = 2 * GAME_TICKS_PER_TURN;
|
||||
const originalReservedUntil = new Date(claimedAt.getTime() + 20 * 60_000);
|
||||
const reserved = buildCandidateEntry(1, '예약후보', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil: originalReservedUntil,
|
||||
reservedUntilTick: originalReservedUntilTick,
|
||||
});
|
||||
const world = buildWorld([reserved], [], {
|
||||
clockBaseTime: claimedAt,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: claimedAt,
|
||||
lastTurnTick: 0,
|
||||
});
|
||||
const before = world.captureState();
|
||||
const resumedAt = new Date(claimedAt.getTime() + 40 * 60_000);
|
||||
|
||||
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({
|
||||
skippedTurns: 4,
|
||||
shiftedTicks: 4 * GAME_TICKS_PER_TURN,
|
||||
});
|
||||
const rebasedReservedUntilTick = 6 * GAME_TICKS_PER_TURN;
|
||||
const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick);
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntilTick: rebasedReservedUntilTick,
|
||||
reservedUntil: rebasedReservedUntil,
|
||||
},
|
||||
]);
|
||||
expect(world.listGeneralPoolCandidates(resumedAt)).toEqual([]);
|
||||
expect(world.listGeneralPoolCandidates(rebasedReservedUntil)).toEqual([]);
|
||||
expect(
|
||||
world
|
||||
.listGeneralPoolCandidates(new Date(rebasedReservedUntil.getTime() + 1))
|
||||
?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보']);
|
||||
|
||||
world.restoreState(before);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntilTick: originalReservedUntilTick,
|
||||
reservedUntil: originalReservedUntil,
|
||||
},
|
||||
]);
|
||||
expect(world.listGeneralPoolCandidates(originalReservedUntil)).toEqual([]);
|
||||
expect(
|
||||
world
|
||||
.listGeneralPoolCandidates(new Date(originalReservedUntil.getTime() + 1))
|
||||
?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user