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:
@@ -8,6 +8,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { hasAuctionClosePassed } from '../src/router/auction/index.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 7,
|
||||
@@ -80,6 +81,7 @@ const buildContext = (options: {
|
||||
isunited?: number;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
clockTick?: number;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
@@ -106,6 +108,14 @@ const buildContext = (options: {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
...(options.clockTick === undefined
|
||||
? {}
|
||||
: {
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
}),
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
@@ -169,6 +179,20 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
describe('auction router actor and permission boundaries', () => {
|
||||
it('keeps the auction open through its authoritative close tick', () => {
|
||||
const closeAt = new Date('2026-07-27T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
|
||||
expect(hasAuctionClosePassed(auction, { now: closeAt, tick: 72_000_000 })).toBe(false);
|
||||
expect(
|
||||
hasAuctionClosePassed(auction, {
|
||||
now: new Date(closeAt.getTime() + 1),
|
||||
tick: 72_000_001,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
|
||||
@@ -329,6 +353,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
detail: { startBidAmount: 100, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||
closeTick: 100n,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -346,6 +371,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
}
|
||||
return [];
|
||||
},
|
||||
clockTick: 100,
|
||||
});
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.bidUnique({
|
||||
@@ -358,7 +384,43 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the Ref 1000 gold reserve after a resource-auction bid', async () => {
|
||||
const queryRaw = async (query: GamePrisma.Sql) => {
|
||||
const text = sqlText(query);
|
||||
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 88,
|
||||
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('FROM auction_bid')) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const accepted = buildContext({ general: buildGeneral({ gold: 1_500 }), queryRaw });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(accepted.context).auction.bidBuyRice({ auctionId: 31, amount: 500 })
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(accepted.requestCommand).toHaveBeenCalledOnce();
|
||||
|
||||
const rejected = buildContext({ general: buildGeneral({ gold: 1_499 }), queryRaw });
|
||||
await expect(
|
||||
appRouter.createCaller(rejected.context).auction.bidBuyRice({ auctionId: 31, amount: 500 })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
|
||||
expect(rejected.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { buildAuctionTimerKeys } from '../src/auction/keys.js';
|
||||
import { processDueAuctionId, runAuctionWorker } from '../src/auction/worker.js';
|
||||
import { buildAuctionFinalizeRequestId, processDueAuctionId, runAuctionWorker } from '../src/auction/worker.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const liveDescribe = databaseUrl && process.env.REDIS_URL ? describe : describe.skip;
|
||||
@@ -80,8 +80,11 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
return auction;
|
||||
};
|
||||
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date }): string =>
|
||||
`auction:finalize:${auction.id}:${auction.closeAt.getTime()}`;
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
|
||||
buildAuctionFinalizeRequestId(auction.id, {
|
||||
closeAt: auction.closeAt,
|
||||
closeTick: auction.closeTick ?? null,
|
||||
});
|
||||
|
||||
const memoryRedis = () => ({
|
||||
zRangeByScore: vi.fn(async () => []),
|
||||
@@ -91,7 +94,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
zRemRangeByScore: vi.fn(async () => 0),
|
||||
});
|
||||
|
||||
it('atomically moves OPEN to FINALIZING and creates one deterministic input event', async () => {
|
||||
it('leaves OPEN and creates one deterministic input event', async () => {
|
||||
const auction = await createAuction('OPEN');
|
||||
const redis = memoryRedis();
|
||||
|
||||
@@ -104,7 +107,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db: connector.prisma,
|
||||
@@ -114,13 +117,13 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
const [storedAuction, events] = await Promise.all([
|
||||
connector.prisma.auction.findUniqueOrThrow({ where: { id: auction.id } }),
|
||||
connector.prisma.inputEvent.findMany({ where: { requestId: requestIdFor(auction) } }),
|
||||
]);
|
||||
expect(storedAuction.status).toBe('FINALIZING');
|
||||
expect(storedAuction.status).toBe('OPEN');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
target: 'ENGINE',
|
||||
@@ -130,6 +133,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
type: 'auctionFinalize',
|
||||
requestId: requestIdFor(auction),
|
||||
auctionId: auction.id,
|
||||
expectedCloseAt: auction.closeAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -191,7 +195,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db: connector.prisma,
|
||||
@@ -201,7 +205,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
connector.prisma.inputEvent.findMany({
|
||||
where: { requestId: { startsWith: requestId } },
|
||||
@@ -258,7 +262,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
await expect(
|
||||
connector.prisma.inputEvent.findUnique({ where: { requestId: requestIdFor(auction) } })
|
||||
@@ -531,7 +535,11 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
where: { id: extensionAuction.id },
|
||||
data: { closeAt: secondCloseAt, closeTick: BigInt(secondCloseTick) },
|
||||
});
|
||||
const secondExtensionRequestId = requestIdFor({ id: extensionAuction.id, closeAt: secondCloseAt });
|
||||
const secondExtensionRequestId = requestIdFor({
|
||||
id: extensionAuction.id,
|
||||
closeAt: secondCloseAt,
|
||||
closeTick: BigInt(secondCloseTick),
|
||||
});
|
||||
await processDueAuctionId({
|
||||
db: connector.prisma,
|
||||
redis: memoryRedis(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ const classifications = {
|
||||
'diplomacy.respondLetter',
|
||||
'diplomacy.rollbackLetter',
|
||||
'diplomacy.sendLetter',
|
||||
'join.getSelectionPool',
|
||||
'join.listPossessCandidates',
|
||||
'messages.readLatest',
|
||||
'turns.repeatNation',
|
||||
@@ -63,6 +62,7 @@ const classifications = {
|
||||
'general.vacation',
|
||||
'inherit.openUniqueAuction',
|
||||
'join.createGeneral',
|
||||
'join.getSelectionPool',
|
||||
'join.possessGeneral',
|
||||
'join.reselectPoolGeneral',
|
||||
'join.selectPoolGeneral',
|
||||
|
||||
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { IdempotentTurnDaemonTransport } from '../src/daemon/idempotentTransport.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import {
|
||||
commandIdentityJson,
|
||||
ConflictingTurnDaemonCommandError,
|
||||
DatabaseTurnDaemonTransport,
|
||||
} from '../src/daemon/databaseTransport.js';
|
||||
|
||||
describe('IdempotentTurnDaemonTransport', () => {
|
||||
it('derives stable ordered engine request IDs from the API input event', async () => {
|
||||
@@ -19,4 +24,108 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
'api-event:engine:0:vacation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the first durable acceptance tick authoritative across an idempotent retry', () => {
|
||||
const auctionBid = {
|
||||
type: 'auctionBid',
|
||||
requestId: 'auction-bid',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
expect(commandIdentityJson({ ...auctionBid, acceptedGameTick: 101 })).toBe(commandIdentityJson(auctionBid));
|
||||
expect(commandIdentityJson({ ...auctionBid, amount: 501 })).not.toBe(commandIdentityJson(auctionBid));
|
||||
|
||||
const voteReward = {
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-reward',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
expect(commandIdentityJson({ ...voteReward, acceptedGameTick: 101 })).toBe(commandIdentityJson(voteReward));
|
||||
expect(commandIdentityJson({ ...voteReward, selection: [1] })).not.toBe(commandIdentityJson(voteReward));
|
||||
|
||||
const selectionCommands = [
|
||||
{
|
||||
type: 'selectPoolReserve',
|
||||
requestId: 'select-pool-reserve',
|
||||
userId: 'user-7',
|
||||
seedOwnerIdentity: 7,
|
||||
acceptedGameAt: '0200-01-01T00:00:00.000Z',
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
{
|
||||
type: 'selectPoolCreate',
|
||||
requestId: 'select-pool-create',
|
||||
userId: 'user-7',
|
||||
ownerDisplayName: '사용자',
|
||||
uniqueName: '풀장수',
|
||||
personality: 'che_안전',
|
||||
acceptedGameAt: '0200-01-01T00:00:00.000Z',
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
{
|
||||
type: 'selectPoolReselect',
|
||||
requestId: 'select-pool-reselect',
|
||||
userId: 'user-7',
|
||||
ownerDisplayName: '사용자',
|
||||
uniqueName: '풀장수',
|
||||
acceptedGameAt: '0200-01-01T00:00:00.000Z',
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
];
|
||||
for (const command of selectionCommands) {
|
||||
expect(
|
||||
commandIdentityJson({
|
||||
...command,
|
||||
acceptedGameAt: '0200-01-01T00:01:00.000Z',
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).toBe(commandIdentityJson(command));
|
||||
expect(commandIdentityJson({ ...command, userId: 'other-user' })).not.toBe(commandIdentityJson(command));
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
|
||||
const persistedPayload = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
const create = async () => {
|
||||
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
||||
};
|
||||
const transport = new DatabaseTurnDaemonTransport(
|
||||
{
|
||||
inputEvent: {
|
||||
create,
|
||||
findUniqueOrThrow: async () => ({ eventType: 'voteReward', payload: persistedPayload }),
|
||||
},
|
||||
} as any,
|
||||
100
|
||||
);
|
||||
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).resolves.toBe('vote-reward');
|
||||
|
||||
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
...changedIdentity,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ const buildContext = (options: {
|
||||
rankRows?: Array<{ type: string; value: number }>;
|
||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||
configConst?: Record<string, unknown>;
|
||||
configMap?: Record<string, unknown>;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
@@ -128,12 +129,14 @@ const buildContext = (options: {
|
||||
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
||||
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const activeWorldState =
|
||||
options.configConst === undefined
|
||||
options.configConst === undefined && options.configMap === undefined
|
||||
? worldState
|
||||
: {
|
||||
...worldState,
|
||||
config: {
|
||||
const: options.configConst,
|
||||
...worldState.config,
|
||||
...(options.configConst === undefined ? {} : { const: options.configConst }),
|
||||
...(options.configMap === undefined ? {} : { map: options.configMap }),
|
||||
},
|
||||
};
|
||||
const messageRows: CapturedMessage[] = [];
|
||||
@@ -269,6 +272,30 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => {
|
||||
const fixture = buildContext({
|
||||
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
||||
inheritancePoint: 0,
|
||||
});
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.inherit.getStatus()).resolves.toMatchObject({ canResetStat: false });
|
||||
await expect(
|
||||
caller.inherit.resetStat({
|
||||
leadership: 70,
|
||||
strength: 45,
|
||||
intel: 85,
|
||||
inheritBonusStat: [2, 1, 1],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('projects every Ref inheritance source with its own coefficient and stored/calculated boundary', async () => {
|
||||
const fixture = buildContext({
|
||||
general: buildGeneral({
|
||||
|
||||
@@ -693,6 +693,42 @@ describe('appRouter', () => {
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const requestId = 'select-pool-reserve-http';
|
||||
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
||||
const acceptedGameAt = '2026-07-30T12:00:00.000Z';
|
||||
const reservation = {
|
||||
poolName: 'SPoolUnderU30',
|
||||
hasGeneral: false,
|
||||
validUntil: '2026-07-30T12:20:00.000Z',
|
||||
candidates: [],
|
||||
};
|
||||
transport.setCommandResult(commandRequestId, {
|
||||
type: 'selectPoolReserve',
|
||||
ok: true,
|
||||
reservation,
|
||||
});
|
||||
const state = {
|
||||
...buildWorldState(),
|
||||
clockBaseTime: new Date(acceptedGameAt),
|
||||
clockTick: 0n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date(acceptedGameAt),
|
||||
} as WorldStateRow;
|
||||
const context = { ...buildContext({ state, transport }), requestId };
|
||||
|
||||
await expect(appRouter.createCaller(context).join.getSelectionPool()).resolves.toEqual(reservation);
|
||||
expect(transport.commands.at(-1)?.command).toEqual({
|
||||
type: 'selectPoolReserve',
|
||||
requestId: commandRequestId,
|
||||
userId: 'user-1',
|
||||
seedOwnerIdentity: 'user-1',
|
||||
acceptedGameAt,
|
||||
acceptedGameTick: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('queues turn daemon run commands', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const caller = appRouter.createCaller(buildContext({ transport }));
|
||||
|
||||
@@ -221,6 +221,25 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
expect(concurrentReservation).toEqual(firstReservation);
|
||||
expect(firstReservation.candidates).toHaveLength(14);
|
||||
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(14);
|
||||
const reservedNames = new Set(firstReservation.candidates.map((candidate) => candidate.uniqueName));
|
||||
expect(
|
||||
runtime!.world
|
||||
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
|
||||
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
||||
).toBe(false);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
eventType: 'selectPoolReserve',
|
||||
status: 'SUCCEEDED',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
});
|
||||
|
||||
const attempts = await Promise.allSettled([
|
||||
appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({
|
||||
@@ -290,6 +309,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(true);
|
||||
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
|
||||
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
|
||||
expect(runtime!.world.listGeneralPoolEntries()?.filter((entry) => entry.ownerUserId === userId)).toEqual([]);
|
||||
expect(
|
||||
runtime!.world.listGeneralPoolEntries()?.find((entry) => entry.generalId === initial.id)?.candidate.name
|
||||
).toBe(initial.name);
|
||||
expect(
|
||||
await db.logEntry.count({
|
||||
where: { meta: { path: ['ownerUserId'], equals: userId } },
|
||||
@@ -336,6 +359,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.createCaller(buildContext('select-pool-reselect'))
|
||||
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
|
||||
).resolves.toMatchObject({
|
||||
eventType: 'selectPoolReselect',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
||||
expect(updated).toMatchObject({
|
||||
@@ -368,6 +401,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
});
|
||||
expect(runtime!.world.listGeneralPoolEntries()?.filter((entry) => entry.ownerUserId === userId)).toEqual([]);
|
||||
expect(
|
||||
runtime!.world.listGeneralPoolEntries()?.find((entry) => entry.generalId === initial.id)?.uniqueName
|
||||
).toBe(target.uniqueName);
|
||||
expect(
|
||||
await db.logEntry.count({
|
||||
where: { meta: { path: ['ownerUserId'], equals: userId } },
|
||||
@@ -518,6 +555,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: otherUserId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
|
||||
|
||||
const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; draws: number[] }> => {
|
||||
const weighted = await loadWeightedRows();
|
||||
const now = new Date('2026-07-30T03:34:56.000Z');
|
||||
const nowTick = 72_000_000;
|
||||
const draws: number[] = [];
|
||||
const selected = await claimWeightedSelectionCandidates({
|
||||
weighted,
|
||||
rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, now))),
|
||||
rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, nowTick))),
|
||||
count: 14,
|
||||
claim: async () => true,
|
||||
onDraw: (candidate) => draws.push(candidate.id),
|
||||
@@ -33,21 +33,21 @@ const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; dra
|
||||
|
||||
describe('select pool Ref RNG parity', () => {
|
||||
it('uses the legacy seed serialization and fixed UnderS30 draw vector', async () => {
|
||||
const now = new Date('2026-07-30T03:34:56.000Z');
|
||||
expect(buildSelectPoolSeed('vector-hidden', 42, now)).toBe(
|
||||
'str(13,vector-hidden)|str(10,selectPool)|int(42)|str(19,2026-07-30 12:34:56)'
|
||||
const nowTick = 72_000_000;
|
||||
expect(buildSelectPoolSeed('vector-hidden', 42, nowTick)).toBe(
|
||||
'str(13,vector-hidden)|str(10,selectPool)|int(42)|int(72000000)'
|
||||
);
|
||||
|
||||
await expect(drawVector('vector-hidden')).resolves.toEqual({
|
||||
selected: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
|
||||
draws: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
|
||||
selected: [1547, 199, 1266, 756, 1741, 1435, 303, 753, 214, 576, 387, 388, 394, 252],
|
||||
draws: [1547, 199, 1266, 756, 1741, 1435, 303, 753, 214, 576, 387, 388, 394, 252],
|
||||
});
|
||||
});
|
||||
|
||||
it('consumes duplicate draws without removing the candidate from the weighted pool', async () => {
|
||||
await expect(drawVector('vector-hidden-28')).resolves.toEqual({
|
||||
selected: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 152, 39, 189, 760],
|
||||
draws: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 27, 152, 39, 189, 760],
|
||||
await expect(drawVector('vector-hidden-2')).resolves.toEqual({
|
||||
selected: [1632, 543, 640, 1351, 691, 966, 1110, 1358, 224, 936, 262, 109, 852, 456],
|
||||
draws: [1632, 543, 640, 1351, 691, 966, 1110, 1358, 224, 936, 262, 109, 966, 852, 456],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,7 @@ const buildContext = (options: {
|
||||
userId: string;
|
||||
roles?: string[];
|
||||
develCost?: number;
|
||||
currentDevelCost?: number;
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
}): GameApiContext => {
|
||||
const db = {
|
||||
@@ -142,7 +143,10 @@ const buildContext = (options: {
|
||||
findMany: async () => options.rankRows ?? [],
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({ config: { const: { develCost: options.develCost ?? 200 } } }),
|
||||
findFirst: async () => ({
|
||||
config: { const: { develCost: options.develCost ?? 200 } },
|
||||
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
|
||||
}),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
return {
|
||||
@@ -267,6 +271,42 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(snapshot.participants[0]!.groupId).toBeLessThan(8);
|
||||
});
|
||||
|
||||
it('charges the current game_env develcost instead of the scenario snapshot', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 1,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
await redis.set('sammo:che:default:tournament:participants', '[]');
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
redis,
|
||||
transport,
|
||||
generals: [general],
|
||||
userId: 'user-1',
|
||||
develCost: 200,
|
||||
currentDevelCost: 64,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||
expect(transport.commands).toContainEqual({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
|
||||
@@ -320,6 +320,24 @@ describe('tournament worker (in-memory)', () => {
|
||||
).toEqual({ payouts: [], total: 300, refundAll: false });
|
||||
});
|
||||
|
||||
it('coalesces duplicate legacy rows before one winner payout and rounding', () => {
|
||||
expect(
|
||||
buildBettingPayouts(10, [
|
||||
{ generalId: 1, targetId: 10, amount: 101 },
|
||||
{ generalId: 1, targetId: 10, amount: 99 },
|
||||
{ generalId: 2, targetId: 10, amount: 100 },
|
||||
{ generalId: 3, targetId: 11, amount: 100 },
|
||||
])
|
||||
).toEqual({
|
||||
payouts: [
|
||||
{ generalId: 1, amount: 267 },
|
||||
{ generalId: 2, amount: 133 },
|
||||
],
|
||||
total: 400,
|
||||
refundAll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks 64 applicants into eight groups of eight', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { hasPollEnded } from '../src/router/vote/index.js';
|
||||
|
||||
const poll = {
|
||||
id: 1,
|
||||
@@ -95,10 +96,11 @@ const buildContext = (options: {
|
||||
configConst?: Record<string, unknown>;
|
||||
metaDevelCost?: number;
|
||||
auctionTargets?: string[];
|
||||
clockTick?: number;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
const requestCommand = vi.fn(async () => ({
|
||||
const requestCommand = vi.fn(async (_command?: unknown) => ({
|
||||
type: 'voteReward' as const,
|
||||
ok: true as const,
|
||||
voteId: 1,
|
||||
@@ -139,6 +141,14 @@ const buildContext = (options: {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
...(options.clockTick === undefined
|
||||
? {}
|
||||
: {
|
||||
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
}),
|
||||
config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } },
|
||||
meta: {
|
||||
...(options.metaDevelCost === undefined ? {} : { develcost: options.metaDevelCost }),
|
||||
@@ -201,6 +211,29 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
describe('vote router actor and permission boundaries', () => {
|
||||
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
|
||||
const now = new Date('2026-07-26T00:00:00Z');
|
||||
const time = {
|
||||
now,
|
||||
wallNow: now,
|
||||
tick: 100,
|
||||
mode: 'manual' as const,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
dateToTick: () => 100,
|
||||
};
|
||||
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false);
|
||||
expect(
|
||||
hasPollEnded(
|
||||
{ closed_at: null, end_at: now, end_tick: null },
|
||||
{ ...time, now: new Date(now.getTime() + 1), tick: null }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated survey access', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
|
||||
@@ -211,18 +244,20 @@ describe('vote router actor and permission boundaries', () => {
|
||||
|
||||
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
|
||||
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
|
||||
const fixture = buildContext({ general: owned });
|
||||
const fixture = buildContext({ general: owned, clockTick: 100 });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
|
||||
).resolves.toEqual({ ok: true, wonLottery: false });
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'voteReward',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
goldReward: 90,
|
||||
})
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'voteReward',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
||||
false
|
||||
);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
|
||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||
@@ -230,7 +265,10 @@ describe('vote router actor and permission boundaries', () => {
|
||||
});
|
||||
|
||||
it('publishes a global front-status projection after creating a survey', async () => {
|
||||
const fixture = buildContext({ auth: buildAuth(['admin.survey.open']) });
|
||||
const auth = buildAuth(['admin.survey.open']);
|
||||
auth.user.username = 'admin-account';
|
||||
auth.user.displayName = '관리자 표시명';
|
||||
const fixture = buildContext({ auth, general: buildGeneral({ name: '관리자 장수' }) });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).vote.createPoll({
|
||||
@@ -244,19 +282,22 @@ describe('vote router actor and permission boundaries', () => {
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||
const insert = fixture.queryRaw.mock.calls
|
||||
.map(([query]) => query)
|
||||
.find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
|
||||
expect(insert?.values).toContain('admin-account');
|
||||
expect(insert?.values).not.toContain('관리자 장수');
|
||||
});
|
||||
|
||||
it('uses the current world develcost for the legacy five-times survey reward', async () => {
|
||||
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
||||
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).resolves.toMatchObject({
|
||||
voteReward: 150,
|
||||
});
|
||||
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(expect.objectContaining({ goldReward: 150 }));
|
||||
});
|
||||
|
||||
it('includes active unique auctions in the API-side reward expectation', async () => {
|
||||
it('leaves live reward and unique occupancy calculation to ENGINE', async () => {
|
||||
const fixture = buildContext({
|
||||
configConst: {
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
@@ -270,11 +311,11 @@ describe('vote router actor and permission boundaries', () => {
|
||||
|
||||
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
unique: { expected: false, itemKey: null },
|
||||
})
|
||||
);
|
||||
expect(fixture.db.general.findMany).not.toHaveBeenCalled();
|
||||
expect(fixture.db.general.count).not.toHaveBeenCalled();
|
||||
expect(fixture.db.auction.findMany).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand.mock.calls[0]?.[0]).not.toHaveProperty('goldReward');
|
||||
expect(fixture.requestCommand.mock.calls[0]?.[0]).not.toHaveProperty('unique');
|
||||
});
|
||||
|
||||
it('rejects voting and comments when the authenticated user owns no general', async () => {
|
||||
|
||||
@@ -97,6 +97,9 @@ const buildContext = (
|
||||
hasGeneral?: boolean;
|
||||
worldMeta?: unknown;
|
||||
liveLogs?: { history: string[]; action: string[] };
|
||||
liveNations?: Array<Record<string, unknown>>;
|
||||
liveCities?: Array<Record<string, unknown>>;
|
||||
liveGenerals?: Array<Record<string, unknown>>;
|
||||
} = {}
|
||||
): GameApiContext => {
|
||||
const db = {
|
||||
@@ -104,13 +107,13 @@ const buildContext = (
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
|
||||
findMany: async () => [],
|
||||
findMany: async () => options.liveGenerals ?? [],
|
||||
},
|
||||
city: {
|
||||
findMany: async () => [],
|
||||
findMany: async () => options.liveCities ?? [],
|
||||
},
|
||||
nation: {
|
||||
findMany: async () => [],
|
||||
findMany: async () => options.liveNations ?? [],
|
||||
},
|
||||
logEntry: {
|
||||
findMany: async ({ where }: { where: { category: unknown } }) => {
|
||||
@@ -326,4 +329,82 @@ describe('historical yearbook access from dynasty', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stored Ref nation projections, canonical neutral values, and descending power in the live month', async () => {
|
||||
const zeroCityStats = {
|
||||
population: 0,
|
||||
agriculture: 0,
|
||||
commerce: 0,
|
||||
security: 0,
|
||||
defence: 0,
|
||||
wall: 0,
|
||||
populationMax: 1,
|
||||
agricultureMax: 1,
|
||||
commerceMax: 1,
|
||||
securityMax: 1,
|
||||
defenceMax: 1,
|
||||
wallMax: 1,
|
||||
};
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext(authFor('owner-a'), {
|
||||
liveNations: [
|
||||
{
|
||||
id: 0,
|
||||
name: '오염된 재야',
|
||||
color: '#ffffff',
|
||||
level: 9,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
meta: { power: 90, gennum: 90 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: '촉',
|
||||
color: '#ff0000',
|
||||
level: 7,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
meta: { power: 777, gennum: 9 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#0000ff',
|
||||
level: 7,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
meta: { power: 0, gennum: 2 },
|
||||
},
|
||||
],
|
||||
liveCities: [
|
||||
{ id: 0, name: '낙양', nationId: 0, ...zeroCityStats },
|
||||
{ id: 1, name: '성도', nationId: 1, ...zeroCityStats },
|
||||
{ id: 2, name: '허창', nationId: 2, ...zeroCityStats },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const result = await caller.yearbook.getHistory({ year: 220, month: 1 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
notModified: false,
|
||||
data: {
|
||||
nations: [
|
||||
expect.objectContaining({ id: 1, power: 777, generalCount: 9 }),
|
||||
expect.objectContaining({
|
||||
id: 0,
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
level: 0,
|
||||
power: 1,
|
||||
generalCount: 1,
|
||||
}),
|
||||
expect.objectContaining({ id: 2, power: 0, generalCount: 2 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user