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
@@ -0,0 +1,252 @@
import { describe, expect, it } from 'vitest';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { reserveSelectionPool } from '../src/turn/selectPoolService.js';
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
interface TestPoolRow {
id: number;
uniqueName: string;
ownerUserId: string | null;
generalId: number | null;
reservedUntil: Date | null;
reservedUntilTick: bigint | null;
info: Record<string, unknown>;
}
interface PoolWhere {
id?: { in: number[] };
ownerUserId?: string | null;
generalId?: number | null;
reservedUntil?: null | { lt?: Date; gte?: Date };
reservedUntilTick?: null | { lt?: bigint; gte?: bigint };
OR?: PoolWhere[];
}
const acceptedAt = new Date('0200-05-01T00:00:00.000Z');
const buildRows = (): TestPoolRow[] =>
Array.from({ length: 29 }, (_, index) => {
const uniqueName = `P${String(index + 1).padStart(2, '0')}`;
return {
id: index + 1,
uniqueName,
ownerUserId: index === 0 ? 'existing-user' : null,
generalId: null,
reservedUntil: index === 0 ? new Date(acceptedAt.getTime() + 30 * 60_000) : null,
reservedUntilTick: index === 0 ? 3_000_000n : null,
info: {
uniqueName,
generalName: uniqueName,
leadership: 70,
strength: 80,
intel: 10,
specialDomestic: null,
dex: [100 + index, 0, 0, 0, 0],
imgsvr: 0,
picture: 'default.jpg',
},
};
});
const buildWorld = (rows: TestPoolRow[]): InMemoryTurnWorld => {
const generalPoolEntries: TurnGeneralPoolEntry[] = rows.map((row) => ({
id: row.id,
uniqueName: row.uniqueName,
ownerUserId: row.ownerUserId,
generalId: row.generalId,
reservedUntil: row.reservedUntil,
reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick),
candidate: parseScenarioGeneralPoolCandidate(row),
}));
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 5,
tickSeconds: 300,
lastTurnTime: acceptedAt,
meta: { hiddenSeed: 'selection-reservation-test' },
};
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: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
generalPoolEntries,
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
});
};
const matchesPoolWhere = (row: TestPoolRow, where: PoolWhere): boolean => {
if (where.id && !where.id.in.includes(row.id)) {
return false;
}
if (where.ownerUserId !== undefined && row.ownerUserId !== where.ownerUserId) {
return false;
}
if (where.generalId !== undefined && row.generalId !== where.generalId) {
return false;
}
if (where.reservedUntil !== undefined) {
if (where.reservedUntil === null) {
if (row.reservedUntil !== null) {
return false;
}
} else if (
row.reservedUntil === null ||
(where.reservedUntil.lt !== undefined && row.reservedUntil >= where.reservedUntil.lt) ||
(where.reservedUntil.gte !== undefined && row.reservedUntil < where.reservedUntil.gte)
) {
return false;
}
}
if (where.reservedUntilTick !== undefined) {
if (where.reservedUntilTick === null) {
if (row.reservedUntilTick !== null) {
return false;
}
} else if (
row.reservedUntilTick === null ||
(where.reservedUntilTick.lt !== undefined && row.reservedUntilTick >= where.reservedUntilTick.lt) ||
(where.reservedUntilTick.gte !== undefined && row.reservedUntilTick < where.reservedUntilTick.gte)
) {
return false;
}
}
return where.OR === undefined || where.OR.some((alternative) => matchesPoolWhere(row, alternative));
};
const buildDb = (rows: TestPoolRow[]) => ({
$executeRaw: async () => 0,
general: {
findFirst: async () => null,
},
selectPoolEntry: {
findMany: async () => structuredClone(rows),
updateMany: async (input: { where: PoolWhere; data: Partial<TestPoolRow> }) => {
let count = 0;
for (const row of rows) {
if (!matchesPoolWhere(row, input.where)) {
continue;
}
Object.assign(row, input.data);
count += 1;
}
return { count };
},
},
});
const worldState = {
currentYear: 200,
currentMonth: 5,
tickSeconds: 300,
config: {
npcMode: 2,
turnTermMinutes: 5,
map: { targetGeneralPool: 'SPoolUnderU30' },
},
meta: { hiddenSeed: 'selection-reservation-test' },
};
describe('selection-pool reservation command state', () => {
it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
const rows = buildRows();
const world = buildWorld(rows);
const db = buildDb(rows);
const reserve = (userId: string, acceptedGameTick: number) =>
reserveSelectionPool({
db: db as never,
world,
worldState: worldState as never,
userId,
seedOwnerIdentity: userId,
now: acceptedAt,
acceptedGameTick,
});
const first = await reserve('first-user', 0);
const retried = await reserve('first-user', 1);
const second = await reserve('second-user', 2);
expect(retried).toEqual(first);
expect(first.candidates).toHaveLength(14);
expect(second.candidates).toHaveLength(14);
expect(new Set(first.candidates.map((candidate) => candidate.uniqueName))).not.toContain('P01');
expect(
first.candidates.some((candidate) =>
second.candidates.some((other) => other.uniqueName === candidate.uniqueName)
)
).toBe(false);
expect(rows.filter((row) => row.ownerUserId === 'first-user')).toHaveLength(14);
expect(rows.filter((row) => row.ownerUserId === 'second-user')).toHaveLength(14);
expect(rows.find((row) => row.ownerUserId === 'first-user')?.reservedUntilTick).toBe(
BigInt(2 * GAME_TICKS_PER_TURN)
);
expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString());
expect(world.listGeneralPoolCandidates(acceptedAt)).toEqual([]);
});
it('uses Ref nowTick equality and resynchronizes DB expiry into the in-memory pool', async () => {
const rows = buildRows();
rows[0]!.ownerUserId = 'stale-user';
rows[0]!.reservedUntil = new Date(acceptedAt.getTime() + 60 * 60_000);
rows[0]!.reservedUntilTick = -1n;
rows[1]!.ownerUserId = 'exact-user';
rows[1]!.reservedUntil = new Date(acceptedAt.getTime() - 60_000);
rows[1]!.reservedUntilTick = 0n;
const world = buildWorld(rows);
const db = buildDb(rows);
const reserve = (userId: string, acceptedGameTick: number) =>
reserveSelectionPool({
db: db as never,
world,
worldState: worldState as never,
userId,
seedOwnerIdentity: userId,
now: acceptedAt,
acceptedGameTick,
});
const first = await reserve('first-user', 0);
expect(rows[0]!.ownerUserId).not.toBe('stale-user');
expect(rows[1]).toMatchObject({ ownerUserId: 'exact-user', reservedUntilTick: 0n });
expect(first.candidates.map((candidate) => candidate.uniqueName)).not.toContain('P02');
expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString());
await reserve('second-user', 1);
expect(rows[1]!.ownerUserId).not.toBe('exact-user');
const synchronizedById = new Map(world.listGeneralPoolEntries()?.map((entry) => [entry.id, entry]));
for (const row of rows) {
expect(synchronizedById.get(row.id)).toMatchObject({
ownerUserId: row.ownerUserId,
generalId: row.generalId,
reservedUntil: row.reservedUntil,
reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick),
});
}
});
});