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:
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { processDueAuctionId } from '../src/auction/worker.js';
|
||||
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
||||
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
|
||||
|
||||
const buildRedis = () => ({
|
||||
@@ -32,14 +32,16 @@ const buildDb = (options: {
|
||||
const transaction = {
|
||||
$executeRaw: vi.fn(async () => options.updated),
|
||||
auction: {
|
||||
findUnique: vi.fn(async () => options.auction ?? null),
|
||||
findUnique: vi.fn(async () =>
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
|
||||
),
|
||||
},
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(
|
||||
async ({ where }: { where: { requestId: string } }) =>
|
||||
options.existingEvents?.find((event) => event.requestId === where.requestId) ?? null
|
||||
),
|
||||
create: vi.fn(async () => ({ sequence: 1n })),
|
||||
create: vi.fn(async (_args?: { data: { eventType: string } }) => ({ sequence: 1n })),
|
||||
},
|
||||
};
|
||||
return {
|
||||
@@ -84,6 +86,108 @@ describe('auction worker clock-shift race', () => {
|
||||
).toBe(36_000_000);
|
||||
});
|
||||
|
||||
it('requeues an auction immediately after the engine commits an OPEN extension', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
||||
const pendingRequestId = 'auction:finalize:8:tick:72000000';
|
||||
const db = {
|
||||
auction: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 7, status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||
{ id: 8, status: 'FINALIZING', closeAt, closeTick: 72_000_000n },
|
||||
{ id: 9, status: 'FINISHED', closeAt, closeTick: 72_000_000n },
|
||||
]),
|
||||
},
|
||||
inputEvent: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
requestId: pendingRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: pendingRequestId,
|
||||
auctionId: 8,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
status: 'PENDING',
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as unknown as Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
|
||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||
|
||||
await expect(
|
||||
reconcilePendingAuctionTimers({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
auctionIds: [7, 8, 9],
|
||||
gameTime: {
|
||||
now,
|
||||
wallNow: now,
|
||||
tick: 36_000_000,
|
||||
mode: 'manual',
|
||||
running: false,
|
||||
startsAt: null,
|
||||
dateToTick: () => 72_000_000,
|
||||
},
|
||||
})
|
||||
).resolves.toEqual({ pendingIds: [8], rescheduled: 1 });
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
||||
expect(redis.zRem).toHaveBeenCalledWith('timer', ['8']);
|
||||
});
|
||||
|
||||
it('ignores a prior pending generation and schedules the extended OPEN deadline', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2099-01-01T00:30:00.000Z');
|
||||
const previousRequestId = 'auction:finalize:7:tick:72000000';
|
||||
const db = {
|
||||
auction: {
|
||||
findMany: vi.fn(async () => [{ id: 7, status: 'OPEN', closeAt, closeTick: 108_000_000n }]),
|
||||
},
|
||||
inputEvent: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
requestId: previousRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: previousRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: new Date('2099-01-01T00:00:00.000Z').toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
status: 'PENDING',
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as unknown as Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
|
||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||
|
||||
await expect(
|
||||
reconcilePendingAuctionTimers({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
auctionIds: [7],
|
||||
gameTime: {
|
||||
now,
|
||||
wallNow: now,
|
||||
tick: 72_000_000,
|
||||
mode: 'manual',
|
||||
running: false,
|
||||
startsAt: null,
|
||||
dateToTick: () => 108_000_000,
|
||||
},
|
||||
})
|
||||
).resolves.toEqual({ pendingIds: [], rescheduled: 1 });
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 108_000_000, value: '7' }]);
|
||||
expect(redis.zRem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T12:15:00.000Z');
|
||||
@@ -128,11 +232,11 @@ describe('auction worker clock-shift race', () => {
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
||||
});
|
||||
|
||||
it('commits the FINALIZING transition and durable command in one transaction before recording history', async () => {
|
||||
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const { db, transaction } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||
|
||||
await expect(
|
||||
@@ -144,7 +248,7 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs,
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: nowMs, value: '7' }]);
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
@@ -152,15 +256,119 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues finalization at the exact close tick without changing auction status', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||
});
|
||||
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2042-01-01T00:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an already-enqueued bid ahead of finalization and does not block it out of band', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const queuedTypes = ['auctionBid'];
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
const created = transaction.inputEvent.create.mock.calls[0]?.[0] as { data: { eventType: string } } | undefined;
|
||||
if (created) queuedTypes.push(created.data.eventType);
|
||||
expect(queuedTypes).toEqual(['auctionBid', 'auctionFinalize']);
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
existingEvents: [
|
||||
{
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records operational history time separately from logical settlement time', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const { db } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
|
||||
const { db } = buildDb({ updated: 0, auction: { status: 'FINALIZING', closeAt } });
|
||||
const logicalNowMs = new Date('0190-01-01T00:00:00.000Z').getTime();
|
||||
const operationalNowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||
|
||||
@@ -204,7 +412,7 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('history', [
|
||||
@@ -241,14 +449,19 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
requestId: retryRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId: retryRequestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: retryRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -260,8 +473,8 @@ describe('auction worker clock-shift race', () => {
|
||||
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 1,
|
||||
auction: { status: 'FINALIZING', closeAt },
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
existingEvents: [
|
||||
{
|
||||
requestId: previousRequestId,
|
||||
@@ -283,22 +496,27 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls the auction transition back when durable event creation fails', async () => {
|
||||
it('does not touch auction status when durable event creation fails', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const { db, transaction } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
transaction.inputEvent.create.mockRejectedValueOnce(new Error('event insert failed'));
|
||||
|
||||
await expect(
|
||||
@@ -313,5 +531,6 @@ describe('auction worker clock-shift race', () => {
|
||||
).rejects.toThrow('event insert failed');
|
||||
|
||||
expect(redis.zAdd).not.toHaveBeenCalled();
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user