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
+77
View File
@@ -24,6 +24,7 @@ import {
LogFormat,
LogScope,
sendMessage,
readScenarioGeneralPoolClaim,
type City,
type LogEntryDraft,
type MessageRecordDraft,
@@ -1192,6 +1193,21 @@ export const createDatabaseTurnHooks = async (
WHERE status = 'OPEN'
`
);
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE select_pool
SET reserved_until_tick = CASE
WHEN reserved_until_tick IS NULL THEN NULL
ELSE reserved_until_tick + ${deltaTicks}
END,
reserved_until = CASE
WHEN reserved_until IS NULL THEN NULL
ELSE reserved_until + (${deltaSeconds} * INTERVAL '1 second')
END
WHERE general_id IS NULL
AND (reserved_until_tick IS NOT NULL OR reserved_until IS NOT NULL)
`
);
}
if (
@@ -1360,6 +1376,66 @@ export const createDatabaseTurnHooks = async (
createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)
);
// Ref first claims a select_pool row and then finalizes it with the
// newly inserted general ID. Core computes the turn in memory, so
// perform the equivalent CAS in the same fenced flush transaction.
// Releasing deaths first also preserves same-batch pool reuse.
if (deletedGenerals.length > 0) {
await prisma.selectPoolEntry.updateMany({
where: { generalId: { in: deletedGenerals } },
data: {
generalId: null,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
}
const createdGeneralPoolClaims = createdGenerals.flatMap((general) => {
const claim = readScenarioGeneralPoolClaim(general.meta);
return claim ? [{ general, claim }] : [];
});
const claimedPoolIds = new Set<number>();
for (const { general, claim } of createdGeneralPoolClaims) {
if (claimedPoolIds.has(claim.poolEntryId)) {
throw new Error(`한 flush에서 select_pool 후보 ${claim.poolEntryId}를 중복 점유할 수 없습니다.`);
}
claimedPoolIds.add(claim.poolEntryId);
const claimedAt = new Date(claim.claimedAt);
const claimedAtTick = world.dateToGameTick(claimedAt);
if (!Number.isSafeInteger(claimedAtTick)) {
throw new Error(
`select_pool 후보 점유 tick이 안전한 정수 범위를 벗어났습니다: ${claim.uniqueName}`
);
}
const occupied = await prisma.selectPoolEntry.updateMany({
where: {
id: claim.poolEntryId,
uniqueName: claim.uniqueName,
OR: [
{ generalId: general.id },
{
generalId: null,
OR: [
{ ownerUserId: null, reservedUntil: null, reservedUntilTick: null },
{ reservedUntilTick: { lt: BigInt(claimedAtTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: claimedAt } },
],
},
],
},
data: {
generalId: general.id,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
if (occupied.count !== 1) {
throw new Error(`select_pool 후보를 점유하지 못했습니다: ${claim.uniqueName}`);
}
}
if (createdGenerals.length > 0) {
await prisma.general.createMany({
data: createdGenerals.map(buildGeneralCreate),
@@ -1426,6 +1502,7 @@ export const createDatabaseTurnHooks = async (
generalId: null,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
if (prisma.generalTurnRevision) {