fix: repair existing test failures
This commit is contained in:
@@ -26,6 +26,14 @@ export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, c
|
|||||||
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
||||||
|
if (row.status === 'FINALIZING') {
|
||||||
|
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
|
||||||
|
return time.tick ?? time.now.getTime();
|
||||||
|
}
|
||||||
|
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
|
||||||
|
};
|
||||||
|
|
||||||
export const seedAuctionTimers = async (
|
export const seedAuctionTimers = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
redis: RedisSortedSetClient,
|
redis: RedisSortedSetClient,
|
||||||
@@ -44,7 +52,7 @@ export const seedAuctionTimers = async (
|
|||||||
|
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
const payload = rows.map((row) => ({
|
const payload = rows.map((row) => ({
|
||||||
score: resolveAuctionTimerScore(gameTime, row.closeAt, row.closeTick),
|
score: resolveAuctionSeedScore(gameTime, row),
|
||||||
value: String(row.id),
|
value: String(row.id),
|
||||||
}));
|
}));
|
||||||
await redis.zAdd(keys.timerKey, payload);
|
await redis.zAdd(keys.timerKey, payload);
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ export const processDueAuctionId = async (options: {
|
|||||||
id: string;
|
id: string;
|
||||||
nowMs: number;
|
nowMs: number;
|
||||||
nowTick?: number | null;
|
nowTick?: number | null;
|
||||||
|
historyNowMs?: number;
|
||||||
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
|
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
|
||||||
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null } = options;
|
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
|
||||||
const auctionId = Number(id);
|
const auctionId = Number(id);
|
||||||
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
|
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
|
||||||
return 'IGNORED';
|
return 'IGNORED';
|
||||||
@@ -160,7 +161,8 @@ export const processDueAuctionId = async (options: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (outcome.status === 'FINALIZING') {
|
if (outcome.status === 'FINALIZING') {
|
||||||
await redis.zAdd(historyKey, [{ score: nowMs, value: id }]);
|
// history retention은 운영 경과시간 기준이며 게임의 논리 시각과 분리한다.
|
||||||
|
await redis.zAdd(historyKey, [{ score: historyNowMs, value: id }]);
|
||||||
return 'FINALIZING';
|
return 'FINALIZING';
|
||||||
}
|
}
|
||||||
if (outcome.status === 'RESCHEDULED') {
|
if (outcome.status === 'RESCHEDULED') {
|
||||||
@@ -224,6 +226,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
|||||||
id,
|
id,
|
||||||
nowMs: gameNowMs,
|
nowMs: gameNowMs,
|
||||||
nowTick: gameTime.tick,
|
nowTick: gameTime.tick,
|
||||||
|
historyNowMs: operationalNowMs,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown auction worker error';
|
const message = error instanceof Error ? error.message : 'Unknown auction worker error';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
|
|||||||
|
|
||||||
import type { TurnDaemonTransport } from './transport.js';
|
import type { TurnDaemonTransport } from './transport.js';
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||||
|
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||||
|
|
||||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||||
|
|
||||||
@@ -20,6 +21,18 @@ const stableJson = (value: unknown): string => {
|
|||||||
}
|
}
|
||||||
return JSON.stringify(value) ?? 'null';
|
return JSON.stringify(value) ?? 'null';
|
||||||
};
|
};
|
||||||
|
const commandIdentityJson = (value: unknown): string => {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Reflect.get(value, 'type') === 'npcPossessGeneral'
|
||||||
|
) {
|
||||||
|
const { acceptedGameAt: _acceptedGameAt, ...identity } = value as Record<string, unknown>;
|
||||||
|
return stableJson(identity);
|
||||||
|
}
|
||||||
|
return stableJson(value);
|
||||||
|
};
|
||||||
|
|
||||||
export class ConflictingTurnDaemonCommandError extends Error {
|
export class ConflictingTurnDaemonCommandError extends Error {
|
||||||
constructor(readonly requestId: string) {
|
constructor(readonly requestId: string) {
|
||||||
@@ -57,6 +70,9 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||||
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
||||||
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
||||||
|
if (durableCommand.type === 'npcPossessGeneral') {
|
||||||
|
delete durableCommand.acceptedGameAt;
|
||||||
|
}
|
||||||
if (command.type === 'npcPossessGeneral') {
|
if (command.type === 'npcPossessGeneral') {
|
||||||
const existing = await this.db.inputEvent.findUnique({
|
const existing = await this.db.inputEvent.findUnique({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
@@ -65,7 +81,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
if (
|
if (
|
||||||
existing.eventType !== command.type ||
|
existing.eventType !== command.type ||
|
||||||
stableJson(existing.payload) !== stableJson(durableCommand)
|
commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand)
|
||||||
) {
|
) {
|
||||||
throw new ConflictingTurnDaemonCommandError(requestId);
|
throw new ConflictingTurnDaemonCommandError(requestId);
|
||||||
}
|
}
|
||||||
@@ -82,11 +98,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))`
|
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))`
|
||||||
);
|
);
|
||||||
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
|
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
|
||||||
const token = await transaction.npcSelectionToken.findFirst({
|
const token = await transaction.npcSelectionToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
ownerUserId: command.userId,
|
ownerUserId: command.userId,
|
||||||
nonce: command.tokenNonce,
|
nonce: command.tokenNonce,
|
||||||
validUntil: { gte: acceptedAt },
|
validUntil: { gte: acceptedGameAt },
|
||||||
},
|
},
|
||||||
select: { pickResult: true },
|
select: { pickResult: true },
|
||||||
});
|
});
|
||||||
@@ -101,7 +118,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
) {
|
) {
|
||||||
return '선택한 장수가 목록에 없습니다.';
|
return '선택한 장수가 목록에 없습니다.';
|
||||||
}
|
}
|
||||||
await this.createInputEvent(transaction, durableCommand, requestId, acceptedAt);
|
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
|
||||||
|
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
||||||
|
acceptedGameAt: acceptedGameAt.toISOString(),
|
||||||
|
};
|
||||||
|
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (rejectionReason) {
|
if (rejectionReason) {
|
||||||
@@ -123,7 +144,10 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
where: { requestId },
|
where: { requestId },
|
||||||
select: { eventType: true, payload: true },
|
select: { eventType: true, payload: true },
|
||||||
});
|
});
|
||||||
if (existing.eventType !== command.type || stableJson(existing.payload) !== stableJson(durableCommand)) {
|
if (
|
||||||
|
existing.eventType !== command.type ||
|
||||||
|
commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand)
|
||||||
|
) {
|
||||||
throw new ConflictingTurnDaemonCommandError(requestId);
|
throw new ConflictingTurnDaemonCommandError(requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,10 +524,12 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
expect(reopened).toMatchObject({ status: 'OPEN' });
|
expect(reopened).toMatchObject({ status: 'OPEN' });
|
||||||
expect(reopened!.closeAt.getTime()).toBeGreaterThan(extensionAuction.closeAt.getTime());
|
expect(reopened!.closeAt.getTime()).toBeGreaterThan(extensionAuction.closeAt.getTime());
|
||||||
|
|
||||||
const secondCloseAt = new Date(Date.now() - 1_000);
|
const secondWallNow = new Date();
|
||||||
|
const secondCloseAt = new Date(secondWallNow.getTime() - 1_000);
|
||||||
|
const secondCloseTick = world.dateToGameTick(secondCloseAt);
|
||||||
await connector.prisma.auction.update({
|
await connector.prisma.auction.update({
|
||||||
where: { id: extensionAuction.id },
|
where: { id: extensionAuction.id },
|
||||||
data: { closeAt: secondCloseAt },
|
data: { closeAt: secondCloseAt, closeTick: BigInt(secondCloseTick) },
|
||||||
});
|
});
|
||||||
const secondExtensionRequestId = requestIdFor({ id: extensionAuction.id, closeAt: secondCloseAt });
|
const secondExtensionRequestId = requestIdFor({ id: extensionAuction.id, closeAt: secondCloseAt });
|
||||||
await processDueAuctionId({
|
await processDueAuctionId({
|
||||||
@@ -536,7 +538,8 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
timerKey: 'timer',
|
timerKey: 'timer',
|
||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(extensionAuction.id),
|
id: String(extensionAuction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: world.getGameNow(secondWallNow).getTime(),
|
||||||
|
nowTick: world.dateToGameTick(secondWallNow),
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
@@ -601,6 +604,10 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
async () => {
|
async () => {
|
||||||
const poisonedAuction = await createAuction('OPEN');
|
const poisonedAuction = await createAuction('OPEN');
|
||||||
|
await connector.prisma.auction.update({
|
||||||
|
where: { id: poisonedAuction.id },
|
||||||
|
data: { closeTick: 0n },
|
||||||
|
});
|
||||||
const poisonedRequestId = requestIdFor(poisonedAuction);
|
const poisonedRequestId = requestIdFor(poisonedAuction);
|
||||||
await connector.prisma.inputEvent.create({
|
await connector.prisma.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { processDueAuctionId } from '../src/auction/worker.js';
|
import { processDueAuctionId } from '../src/auction/worker.js';
|
||||||
|
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
|
||||||
|
|
||||||
const buildRedis = () => ({
|
const buildRedis = () => ({
|
||||||
zRangeByScore: vi.fn(async () => []),
|
zRangeByScore: vi.fn(async () => []),
|
||||||
@@ -52,6 +53,34 @@ const buildDb = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('auction worker clock-shift race', () => {
|
describe('auction worker clock-shift race', () => {
|
||||||
|
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
|
||||||
|
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||||
|
const time = {
|
||||||
|
now,
|
||||||
|
tick: 36_000_000,
|
||||||
|
mode: 'manual' as const,
|
||||||
|
dateToTick: () => 72_000_000,
|
||||||
|
};
|
||||||
|
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveAuctionSeedScore(time, {
|
||||||
|
id: 7,
|
||||||
|
status: 'OPEN',
|
||||||
|
closeAt,
|
||||||
|
closeTick: 72_000_000n,
|
||||||
|
})
|
||||||
|
).toBe(72_000_000);
|
||||||
|
expect(
|
||||||
|
resolveAuctionSeedScore(time, {
|
||||||
|
id: 7,
|
||||||
|
status: 'FINALIZING',
|
||||||
|
closeAt,
|
||||||
|
closeTick: 72_000_000n,
|
||||||
|
})
|
||||||
|
).toBe(36_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => {
|
it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T12:15:00.000Z');
|
const closeAt = new Date('2026-07-30T12:15:00.000Z');
|
||||||
@@ -125,6 +154,26 @@ describe('auction worker clock-shift race', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 logicalNowMs = new Date('0190-01-01T00:00:00.000Z').getTime();
|
||||||
|
const operationalNowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||||
|
|
||||||
|
await processDueAuctionId({
|
||||||
|
db,
|
||||||
|
redis,
|
||||||
|
timerKey: 'timer',
|
||||||
|
historyKey: 'history',
|
||||||
|
id: '7',
|
||||||
|
nowMs: logicalNowMs,
|
||||||
|
historyNowMs: operationalNowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: operationalNowMs, value: '7' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
});
|
});
|
||||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
|
||||||
const turnGridOffsetSeconds =
|
const turnGridOffsetSeconds =
|
||||||
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
|
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
|
||||||
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
});
|
});
|
||||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
|
||||||
const logs = await db.logEntry.findMany({
|
const logs = await db.logEntry.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -362,7 +362,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
});
|
});
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
|
|
||||||
it('keeps an accepted token through wall-clock expiry until the queued ENGINE event finishes', async () => {
|
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => {
|
||||||
const reservation = await appRouter
|
const reservation = await appRouter
|
||||||
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
||||||
.join.listPossessCandidates({});
|
.join.listPossessCandidates({});
|
||||||
@@ -382,12 +382,17 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
||||||
|
|
||||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||||
const acceptedSecond = new Date(Math.floor(event.createdAt.getTime() / 1000) * 1000);
|
const acceptedGameAt = new Date(
|
||||||
|
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
|
||||||
|
);
|
||||||
|
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
|
||||||
await db.npcSelectionToken.update({
|
await db.npcSelectionToken.update({
|
||||||
where: { ownerUserId: delayedUserId },
|
where: { ownerUserId: delayedUserId },
|
||||||
data: { validUntil: acceptedSecond },
|
data: { validUntil: acceptedGameAt },
|
||||||
|
});
|
||||||
|
await db.worldState.updateMany({
|
||||||
|
data: { clockTick: { increment: 1 } },
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
|
||||||
|
|
||||||
await appRouter
|
await appRouter
|
||||||
.createCaller(buildContext('npc-possession-cleanup-token', cleanupAuth))
|
.createCaller(buildContext('npc-possession-cleanup-token', cleanupAuth))
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
|
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
|
||||||
|
const logicalNowMs = runtime!.world.getGameNow(new Date()).getTime();
|
||||||
const reservation = await appRouter
|
const reservation = await appRouter
|
||||||
.createCaller(buildContext('select-pool-other-reserve', otherAuth))
|
.createCaller(buildContext('select-pool-other-reserve', otherAuth))
|
||||||
.join.getSelectionPool();
|
.join.getSelectionPool();
|
||||||
@@ -449,7 +450,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
|
|
||||||
await db.selectPoolEntry.update({
|
await db.selectPoolEntry.update({
|
||||||
where: { uniqueName: candidate.uniqueName },
|
where: { uniqueName: candidate.uniqueName },
|
||||||
data: { reservedUntil: new Date(Date.now() - 60_000) },
|
data: { reservedUntil: new Date(logicalNowMs - 60_000) },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
|
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
|
||||||
@@ -476,7 +477,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
};
|
};
|
||||||
await db.selectPoolEntry.updateMany({
|
await db.selectPoolEntry.updateMany({
|
||||||
where: { ownerUserId: otherUserId, generalId: null },
|
where: { ownerUserId: otherUserId, generalId: null },
|
||||||
data: { reservedUntil: new Date(Date.now() + 60_000) },
|
data: { reservedUntil: new Date(logicalNowMs + 60_000) },
|
||||||
});
|
});
|
||||||
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
|
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
|
||||||
const persistedAllocatorBefore = (
|
const persistedAllocatorBefore = (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface AuctionRow {
|
|||||||
detail: unknown;
|
detail: unknown;
|
||||||
status: AuctionStatus;
|
status: AuctionStatus;
|
||||||
closeAt: Date;
|
closeAt: Date;
|
||||||
|
latestEventId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuctionBidRow {
|
interface AuctionBidRow {
|
||||||
@@ -101,7 +102,8 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
|
|||||||
host_general_id as "hostGeneralId",
|
host_general_id as "hostGeneralId",
|
||||||
detail,
|
detail,
|
||||||
status,
|
status,
|
||||||
close_at as "closeAt"
|
close_at as "closeAt",
|
||||||
|
latest_event_id as "latestEventId"
|
||||||
FROM auction
|
FROM auction
|
||||||
WHERE id = ${auctionId}
|
WHERE id = ${auctionId}
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -375,6 +377,8 @@ export const createAuctionBidder = async (options: {
|
|||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 같은 논리 tick의 연속 입찰은 시각이 같으므로, UUID 정렬이 아니라
|
||||||
|
// 읽어 둔 이벤트 ID를 버전 토큰으로 사용해 경합만 거절한다.
|
||||||
const updated = await tx.$executeRaw(
|
const updated = await tx.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
@@ -385,10 +389,7 @@ export const createAuctionBidder = async (options: {
|
|||||||
updated_at = ${eventAt}
|
updated_at = ${eventAt}
|
||||||
WHERE id = ${command.auctionId}
|
WHERE id = ${command.auctionId}
|
||||||
AND status = 'OPEN'
|
AND status = 'OPEN'
|
||||||
AND (
|
AND latest_event_id = ${auction.latestEventId}
|
||||||
latest_event_at < ${eventAt}
|
|
||||||
OR (latest_event_at = ${eventAt} AND latest_event_id < ${eventId})
|
|
||||||
)
|
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ const zNpcPossessGeneral = z
|
|||||||
ownerLegacyPenalty: zRecord.optional(),
|
ownerLegacyPenalty: zRecord.optional(),
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
tokenNonce: z.number().int().nonnegative(),
|
tokenNonce: z.number().int().nonnegative(),
|
||||||
|
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ export const possessNpcGeneral = async (options: {
|
|||||||
acceptedAt: Date;
|
acceptedAt: Date;
|
||||||
}): Promise<{ ok: true; generalId: number }> => {
|
}): Promise<{ ok: true; generalId: number }> => {
|
||||||
const { db, world, worldState, userId, generalId, acceptedAt } = options;
|
const { db, world, worldState, userId, generalId, acceptedAt } = options;
|
||||||
|
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다.
|
||||||
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
|
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
|
||||||
requireNpcPossessionWorld(worldState);
|
requireNpcPossessionWorld(worldState);
|
||||||
await lockNpcPossession(db, userId);
|
await lockNpcPossession(db, userId);
|
||||||
|
|||||||
@@ -294,7 +294,9 @@ async function handleNpcPossessGeneral(
|
|||||||
throw new Error('NPC possession world state is missing.');
|
throw new Error('NPC possession world state is missing.');
|
||||||
}
|
}
|
||||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
const acceptedAt = command.acceptedGameAt
|
||||||
|
? new Date(command.acceptedGameAt)
|
||||||
|
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'npcPossessGeneral',
|
type: 'npcPossessGeneral',
|
||||||
|
|||||||
@@ -563,5 +563,5 @@ describe('NPC 대형 시뮬레이션', () => {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
}, 30_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ integration('unification finalization transaction', () => {
|
|||||||
{ userId, key: 'tournament', value: 11 },
|
{ userId, key: 'tournament', value: 11 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const futureCloseAt = new Date(Date.now() + 86_400_000);
|
const futureCloseAt = new Date('0190-07-02T00:00:00.000Z');
|
||||||
const uniqueAuction = await db.auction.create({
|
const uniqueAuction = await db.auction.create({
|
||||||
data: {
|
data: {
|
||||||
type: 'UNIQUE_ITEM',
|
type: 'UNIQUE_ITEM',
|
||||||
@@ -243,11 +243,15 @@ integration('unification finalization transaction', () => {
|
|||||||
where: { generalId_type: { generalId: fixtureId, type: 'inherit_spent_dyn' } },
|
where: { generalId_type: { generalId: fixtureId, type: 'inherit_spent_dyn' } },
|
||||||
})
|
})
|
||||||
).toMatchObject({ value: 50 });
|
).toMatchObject({ value: 50 });
|
||||||
expect(
|
const persistedBids = await db.auctionBid.findMany({
|
||||||
(await db.auctionBid.findMany({ where: { auctionId: uniqueAuction.id }, orderBy: { id: 'asc' } })).map(
|
where: { auctionId: uniqueAuction.id },
|
||||||
(bid) => bid.meta
|
orderBy: { id: 'asc' },
|
||||||
)
|
});
|
||||||
).toEqual([
|
expect(persistedBids.map((bid) => bid.eventAt.toISOString())).toEqual([
|
||||||
|
'0190-07-01T00:00:00.000Z',
|
||||||
|
'0190-07-01T00:00:00.000Z',
|
||||||
|
]);
|
||||||
|
expect(persistedBids.map((bid) => bid.meta)).toEqual([
|
||||||
expect.objectContaining({ inheritSpentTrackedAmount: 30 }),
|
expect.objectContaining({ inheritSpentTrackedAmount: 30 }),
|
||||||
expect.objectContaining({ inheritSpentTrackedAmount: 50 }),
|
expect.objectContaining({ inheritSpentTrackedAmount: 50 }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type JsonArray = GatewayPrisma.JsonArray;
|
|||||||
export * from './orchestrator/profileRepository.js';
|
export * from './orchestrator/profileRepository.js';
|
||||||
export * from './orchestrator/gatewayReleaseRepository.js';
|
export * from './orchestrator/gatewayReleaseRepository.js';
|
||||||
export * from './orchestrator/gatewayOrchestrator.js';
|
export * from './orchestrator/gatewayOrchestrator.js';
|
||||||
|
export * from './orchestrator/seedProfileDatabase.js';
|
||||||
export * from './orchestrator/workspaceManager.js';
|
export * from './orchestrator/workspaceManager.js';
|
||||||
export * from './orchestrator/buildRunner.js';
|
export * from './orchestrator/buildRunner.js';
|
||||||
export * from './orchestrator/processManager.js';
|
export * from './orchestrator/processManager.js';
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ integration('account icon daily PostgreSQL CAS', () => {
|
|||||||
picture: 'old.png',
|
picture: 'old.png',
|
||||||
imageServer: 1,
|
imageServer: 1,
|
||||||
iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'),
|
iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'),
|
||||||
|
createdAt: new Date('2026-07-30T09:00:00.000Z'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,3 +36,19 @@ DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 mi
|
|||||||
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
|
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
|
||||||
무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고
|
무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고
|
||||||
DateTime fallback을 사용합니다.
|
DateTime fallback을 사용합니다.
|
||||||
|
|
||||||
|
## 비동기 작업의 시계 경계
|
||||||
|
|
||||||
|
게임 규칙의 수락·입찰·예약 시각은 logical game time을 사용하지만 daemon
|
||||||
|
queue의 `InputEvent.createdAt`, worker history retention과 timeout은 운영
|
||||||
|
벽시계를 사용합니다. NPC 빙의 enqueue는 현재 logical game time을 event
|
||||||
|
payload의 `acceptedGameAt`에 고정합니다. queue에 들어갈 때 유효했던 token은
|
||||||
|
처리 전 game tick이 진행해도 이 저장된 논리 수락 시각으로 다시 검증합니다.
|
||||||
|
|
||||||
|
경매 입찰은 같은 logical tick에서 여러 번 일어날 수 있습니다. bid 표시
|
||||||
|
시각은 같은 game time을 보존하고, optimistic 경합 판정은 임의 UUID의
|
||||||
|
사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker
|
||||||
|
재시작 시 `OPEN`은 `close_tick` deadline에, 이미 마감 판정이 끝난
|
||||||
|
`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시
|
||||||
|
재시도합니다. Redis history의 score는 보존 기간 계산을 위해 운영 벽시계를
|
||||||
|
사용합니다.
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ export type TurnDaemonCommand =
|
|||||||
ownerLegacyPenalty?: Record<string, unknown>;
|
ownerLegacyPenalty?: Record<string, unknown>;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
tokenNonce: number;
|
tokenNonce: number;
|
||||||
|
acceptedGameAt?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'selectPoolCreate';
|
type: 'selectPoolCreate';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
|||||||
import { loadActionModuleBundle } from '../../src/actionModules/bundle.js';
|
import { loadActionModuleBundle } from '../../src/actionModules/bundle.js';
|
||||||
|
|
||||||
describe('Domestic Affairs Scenario', () => {
|
describe('Domestic Affairs Scenario', () => {
|
||||||
it('should increase agriculture when executing "Farming" command', async () => {
|
it('should increase agriculture when executing "Farming" command', { timeout: 15_000 }, async () => {
|
||||||
// 1. Setup World
|
// 1. Setup World
|
||||||
const mockNation: Nation = {
|
const mockNation: Nation = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
|||||||
import { sealGatewayPassword } from '../src/passwordEnvelope.js';
|
import { sealGatewayPassword } from '../src/passwordEnvelope.js';
|
||||||
|
|
||||||
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
|
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
|
||||||
import { createGatewayApiServer } from '@sammo-ts/gateway-api';
|
import { createGatewayApiServer, seedProfileDatabase } from '@sammo-ts/gateway-api';
|
||||||
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
|
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
|
||||||
import {
|
import {
|
||||||
createGameApiServer,
|
createGameApiServer,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DatabaseTurnDaemonTransport,
|
DatabaseTurnDaemonTransport,
|
||||||
} from '@sammo-ts/game-api';
|
} from '@sammo-ts/game-api';
|
||||||
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||||
|
import { GAME_TICKS_PER_TURN, GameClock } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
createGatewayPostgresConnector,
|
createGatewayPostgresConnector,
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
@@ -25,7 +26,13 @@ import {
|
|||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
import {
|
||||||
|
buildNeutralResourceAuctionPlan,
|
||||||
|
createItemInventoryFromSlots,
|
||||||
|
ItemLoader,
|
||||||
|
ITEM_KEYS,
|
||||||
|
serializeItemInventory,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
@@ -93,7 +100,8 @@ const truncateSchema = async (schema: string) => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
try {
|
try {
|
||||||
const rows = (await connector.prisma.$queryRawUnsafe(
|
const rows = (await connector.prisma.$queryRawUnsafe(
|
||||||
`SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'`
|
`SELECT tablename FROM pg_tables
|
||||||
|
WHERE schemaname = '${schema}' AND tablename <> '_prisma_migrations'`
|
||||||
)) as Array<{ tablename: string }>;
|
)) as Array<{ tablename: string }>;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -108,13 +116,15 @@ const truncateSchema = async (schema: string) => {
|
|||||||
const resetDatabase = async () => {
|
const resetDatabase = async () => {
|
||||||
await ensureSchema('public');
|
await ensureSchema('public');
|
||||||
await ensureSchema('che');
|
await ensureSchema('che');
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: 'public',
|
POSTGRES_SCHEMA: 'public',
|
||||||
|
GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
||||||
});
|
});
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: 'che',
|
POSTGRES_SCHEMA: 'che',
|
||||||
|
DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
|
||||||
});
|
});
|
||||||
await truncateSchema('public');
|
await truncateSchema('public');
|
||||||
await truncateSchema('che');
|
await truncateSchema('che');
|
||||||
@@ -284,10 +294,11 @@ describe('auction integration flow', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await gatewayClient.admin.profiles.installNow.mutate({
|
await seedProfileDatabase({
|
||||||
profileName: 'che:908',
|
scenarioId: 908,
|
||||||
install: {
|
databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
|
||||||
scenarioId: 908,
|
adminUser: bootstrap.user,
|
||||||
|
installOptions: {
|
||||||
turnTermMinutes: 1,
|
turnTermMinutes: 1,
|
||||||
sync: false,
|
sync: false,
|
||||||
fiction: 0,
|
fiction: 0,
|
||||||
@@ -300,6 +311,15 @@ describe('auction integration flow', () => {
|
|||||||
autorunUser: null,
|
autorunUser: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||||
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
|
profile: 'che',
|
||||||
|
profileName: 'che:908',
|
||||||
|
databaseUrl: gameDatabaseUrl,
|
||||||
|
gatewayDatabaseUrl,
|
||||||
|
});
|
||||||
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
|
|
||||||
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||||
sessionToken: adminSessionRef.value ?? '',
|
sessionToken: adminSessionRef.value ?? '',
|
||||||
@@ -343,16 +363,6 @@ describe('auction integration flow', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
|
||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
|
||||||
profile: 'che',
|
|
||||||
profileName: 'che:908',
|
|
||||||
databaseUrl: gameDatabaseUrl,
|
|
||||||
gatewayDatabaseUrl,
|
|
||||||
});
|
|
||||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
|
||||||
|
|
||||||
const waitForStatus = async (timeoutMs = 10_000) => {
|
const waitForStatus = async (timeoutMs = 10_000) => {
|
||||||
if (!gameClient) {
|
if (!gameClient) {
|
||||||
throw new Error('game client missing');
|
throw new Error('game client missing');
|
||||||
@@ -461,7 +471,7 @@ describe('auction integration flow', () => {
|
|||||||
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
||||||
await seedAuctionTimers(prisma, redis, keys);
|
await seedAuctionTimers(prisma, redis, keys);
|
||||||
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
||||||
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
|
expect(Number(initialScore)).toBe(Number(auction.closeTick));
|
||||||
|
|
||||||
await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
||||||
'자신이 연 경매에 입찰할 수 없습니다.'
|
'자신이 연 경매에 입찰할 수 없습니다.'
|
||||||
@@ -484,20 +494,21 @@ describe('auction integration flow', () => {
|
|||||||
|
|
||||||
const updatedAuction = await prisma.auction.findUnique({
|
const updatedAuction = await prisma.auction.findUnique({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
select: { closeAt: true },
|
select: { closeAt: true, closeTick: true },
|
||||||
});
|
});
|
||||||
expect(updatedAuction).not.toBeNull();
|
expect(updatedAuction).not.toBeNull();
|
||||||
expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime());
|
expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime());
|
||||||
|
|
||||||
const updatedScore = await redis.zScore(keys.timerKey, String(auction.id));
|
const updatedScore = await redis.zScore(keys.timerKey, String(auction.id));
|
||||||
expect(Number(updatedScore)).toBe(updatedAuction?.closeAt.getTime());
|
expect(Number(updatedScore)).toBe(Number(updatedAuction?.closeTick));
|
||||||
|
|
||||||
const finalizeAt = new Date(Date.now() - 1000);
|
const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000);
|
||||||
|
const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt);
|
||||||
await prisma.auction.update({
|
await prisma.auction.update({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
data: { closeAt: finalizeAt, status: 'OPEN' },
|
data: { closeAt: finalizeAt, closeTick: BigInt(finalizeTick), status: 'OPEN' },
|
||||||
});
|
});
|
||||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]);
|
||||||
|
|
||||||
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
@@ -576,7 +587,9 @@ describe('auction integration flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const initialCloseAt = new Date(now.getTime() + 10_000);
|
const logicalNow = turnDaemon!.world.getGameNow(now);
|
||||||
|
const initialCloseAt = new Date(logicalNow.getTime() + 10_000);
|
||||||
|
const initialCloseTick = turnDaemon!.world.dateToGameTick(initialCloseAt);
|
||||||
const auction = await prisma.auction.create({
|
const auction = await prisma.auction.create({
|
||||||
data: {
|
data: {
|
||||||
type: 'UNIQUE_ITEM',
|
type: 'UNIQUE_ITEM',
|
||||||
@@ -586,17 +599,18 @@ describe('auction integration flow', () => {
|
|||||||
detail: {
|
detail: {
|
||||||
startBidAmount: 200,
|
startBidAmount: 200,
|
||||||
isReverse: false,
|
isReverse: false,
|
||||||
availableLatestBidCloseDate: new Date(now.getTime() + 10 * 60_000).toISOString(),
|
availableLatestBidCloseDate: new Date(logicalNow.getTime() + 10 * 60_000).toISOString(),
|
||||||
},
|
},
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt: initialCloseAt,
|
closeAt: initialCloseAt,
|
||||||
|
closeTick: BigInt(initialCloseTick),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
||||||
await seedAuctionTimers(prisma, redis, keys);
|
await seedAuctionTimers(prisma, redis, keys);
|
||||||
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
||||||
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
|
expect(Number(initialScore)).toBe(initialCloseTick);
|
||||||
|
|
||||||
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: ownerBidder.accessToken,
|
value: ownerBidder.accessToken,
|
||||||
@@ -617,16 +631,38 @@ describe('auction integration flow', () => {
|
|||||||
expect(updatedAuction).not.toBeNull();
|
expect(updatedAuction).not.toBeNull();
|
||||||
expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime());
|
expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime());
|
||||||
|
|
||||||
await prisma.general.update({
|
|
||||||
where: { id: validBidder.generalId },
|
|
||||||
data: slotUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (turnDaemon) {
|
if (turnDaemon) {
|
||||||
await turnDaemon.lifecycle.stop('integration-test');
|
await turnDaemon.lifecycle.stop('integration-test');
|
||||||
await turnDaemon.close();
|
await turnDaemon.close();
|
||||||
await turnDaemonLoop;
|
await turnDaemonLoop;
|
||||||
}
|
}
|
||||||
|
// Stop the snapshot before changing the fixture. Otherwise its shutdown
|
||||||
|
// flush can write the older inventory back over this direct DB update.
|
||||||
|
const validBidderRow = await prisma.general.findUniqueOrThrow({
|
||||||
|
where: { id: validBidder.generalId },
|
||||||
|
select: { meta: true },
|
||||||
|
});
|
||||||
|
const validBidderMeta =
|
||||||
|
validBidderRow.meta && typeof validBidderRow.meta === 'object' && !Array.isArray(validBidderRow.meta)
|
||||||
|
? validBidderRow.meta
|
||||||
|
: {};
|
||||||
|
const occupiedSlots = {
|
||||||
|
horse: null,
|
||||||
|
weapon: null,
|
||||||
|
book: null,
|
||||||
|
item: null,
|
||||||
|
[uniquePair.slot]: uniquePair.keyB,
|
||||||
|
};
|
||||||
|
await prisma.general.update({
|
||||||
|
where: { id: validBidder.generalId },
|
||||||
|
data: {
|
||||||
|
...slotUpdate,
|
||||||
|
meta: {
|
||||||
|
...validBidderMeta,
|
||||||
|
itemInventory: serializeItemInventory(createItemInventoryFromSlots(occupiedSlots)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
@@ -638,12 +674,13 @@ describe('auction integration flow', () => {
|
|||||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const finalizeAt = new Date(Date.now() - 1000);
|
const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000);
|
||||||
|
const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt);
|
||||||
await prisma.auction.update({
|
await prisma.auction.update({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
data: { closeAt: finalizeAt, status: 'OPEN' },
|
data: { closeAt: finalizeAt, closeTick: BigInt(finalizeTick), status: 'OPEN' },
|
||||||
});
|
});
|
||||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]);
|
||||||
|
|
||||||
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
@@ -684,14 +721,33 @@ describe('auction integration flow', () => {
|
|||||||
throw new Error('unsupported item slot');
|
throw new Error('unsupported item slot');
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.general.update({
|
if (turnDaemon) {
|
||||||
where: { id: bidderA.generalId },
|
await turnDaemon.lifecycle.stop('integration-test');
|
||||||
data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' },
|
await turnDaemon.close();
|
||||||
});
|
await turnDaemonLoop;
|
||||||
await prisma.general.update({
|
}
|
||||||
where: { id: bidderB.generalId },
|
for (const bidder of [bidderA, bidderB]) {
|
||||||
data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' },
|
const row = await prisma.general.findUniqueOrThrow({
|
||||||
});
|
where: { id: bidder.generalId },
|
||||||
|
select: { meta: true },
|
||||||
|
});
|
||||||
|
const meta = row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta) ? row.meta : {};
|
||||||
|
await prisma.general.update({
|
||||||
|
where: { id: bidder.generalId },
|
||||||
|
data: {
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
itemInventory: serializeItemInventory(
|
||||||
|
createItemInventoryFromSlots({ horse: null, weapon: null, book: null, item: null })
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
await prisma.general.updateMany({
|
await prisma.general.updateMany({
|
||||||
where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput,
|
where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput,
|
||||||
data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput,
|
data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput,
|
||||||
@@ -708,11 +764,6 @@ describe('auction integration flow', () => {
|
|||||||
create: { userId: bidderB.userId, key: 'previous', value: 100_000 },
|
create: { userId: bidderB.userId, key: 'previous', value: 100_000 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (turnDaemon) {
|
|
||||||
await turnDaemon.lifecycle.stop('integration-test');
|
|
||||||
await turnDaemon.close();
|
|
||||||
await turnDaemonLoop;
|
|
||||||
}
|
|
||||||
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
@@ -725,6 +776,7 @@ describe('auction integration flow', () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const logicalNow = turnDaemon!.world.getGameNow(now);
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
@@ -745,11 +797,14 @@ describe('auction integration flow', () => {
|
|||||||
itemKey: uniquePair.keyA,
|
itemKey: uniquePair.keyA,
|
||||||
amount: 5000,
|
amount: 5000,
|
||||||
});
|
});
|
||||||
const limitCloseAt = new Date(now.getTime() + 60_000);
|
const initialCloseAt = new Date(logicalNow.getTime() + 2000);
|
||||||
|
const initialCloseTick = turnDaemon!.world.dateToGameTick(initialCloseAt);
|
||||||
|
const limitCloseAt = new Date(logicalNow.getTime() + 60_000);
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
SET close_at = ${new Date(now.getTime() + 2000)},
|
SET close_at = ${initialCloseAt},
|
||||||
|
close_tick = ${BigInt(initialCloseTick)},
|
||||||
detail = jsonb_set(
|
detail = jsonb_set(
|
||||||
jsonb_set(
|
jsonb_set(
|
||||||
detail,
|
detail,
|
||||||
@@ -780,12 +835,13 @@ describe('auction integration flow', () => {
|
|||||||
expect(afterBids).not.toBeNull();
|
expect(afterBids).not.toBeNull();
|
||||||
expect(afterBids!.closeAt.getTime()).toBe(limitCloseAt.getTime());
|
expect(afterBids!.closeAt.getTime()).toBe(limitCloseAt.getTime());
|
||||||
|
|
||||||
const finalizeAt = new Date(Date.now() - 1000);
|
const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000);
|
||||||
|
const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt);
|
||||||
await prisma.auction.update({
|
await prisma.auction.update({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
data: { closeAt: finalizeAt, status: 'OPEN' },
|
data: { closeAt: finalizeAt, closeTick: BigInt(finalizeTick), status: 'OPEN' },
|
||||||
});
|
});
|
||||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]);
|
||||||
|
|
||||||
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
@@ -834,7 +890,9 @@ describe('auction integration flow', () => {
|
|||||||
turnTime: futureTurn,
|
turnTime: futureTurn,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const nationCount = await prisma.nation.count();
|
// Ref's nation scan starts at 1; Core's internal nation 0 must not
|
||||||
|
// consume a monthly nation-power RNG draw.
|
||||||
|
const nationCount = await prisma.nation.count({ where: { id: { gt: 0 } } });
|
||||||
let hiddenSeed = '';
|
let hiddenSeed = '';
|
||||||
let expected = [] as ReturnType<typeof buildNeutralResourceAuctionPlan>;
|
let expected = [] as ReturnType<typeof buildNeutralResourceAuctionPlan>;
|
||||||
for (let index = 0; index < 1_000; index += 1) {
|
for (let index = 0; index < 1_000; index += 1) {
|
||||||
@@ -861,16 +919,27 @@ describe('auction integration flow', () => {
|
|||||||
worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta)
|
worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta)
|
||||||
? worldState.meta
|
? worldState.meta
|
||||||
: {};
|
: {};
|
||||||
|
const clockTick = worldState.clockTick ?? 0n;
|
||||||
|
const previousTurnTick = clockTick - BigInt(GAME_TICKS_PER_TURN);
|
||||||
|
const gameClock = new GameClock({
|
||||||
|
baseTime: worldState.clockBaseTime!,
|
||||||
|
tick: Number(clockTick),
|
||||||
|
mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||||
|
wallAnchor: worldState.clockWallAnchor!,
|
||||||
|
turnSeconds: 60,
|
||||||
|
});
|
||||||
|
const previousTurnTime = gameClock.tickToDate(Number(previousTurnTick));
|
||||||
await prisma.worldState.update({
|
await prisma.worldState.update({
|
||||||
where: { id: worldState.id },
|
where: { id: worldState.id },
|
||||||
data: {
|
data: {
|
||||||
currentYear: 180,
|
currentYear: 180,
|
||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 60,
|
tickSeconds: 60,
|
||||||
|
lastTurnTick: previousTurnTick,
|
||||||
meta: {
|
meta: {
|
||||||
...worldMeta,
|
...worldMeta,
|
||||||
hiddenSeed,
|
hiddenSeed,
|
||||||
lastTurnTime: new Date(Date.now() - 61_000).toISOString(),
|
lastTurnTime: previousTurnTime.toISOString(),
|
||||||
neutralAuctionRegistrationKey: null,
|
neutralAuctionRegistrationKey: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -925,10 +994,11 @@ describe('auction integration flow', () => {
|
|||||||
seedMonth: 1,
|
seedMonth: 1,
|
||||||
neutralRegistrationKey: '180-02',
|
neutralRegistrationKey: '180-02',
|
||||||
});
|
});
|
||||||
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeGreaterThanOrEqual(
|
const logicalCreatedAt = turnDaemon!.world.getGameNow(row.createdAt);
|
||||||
|
expect(row.closeAt.getTime() - logicalCreatedAt.getTime()).toBeGreaterThanOrEqual(
|
||||||
plan.closeTurnCnt * 60_000 - 2_000
|
plan.closeTurnCnt * 60_000 - 2_000
|
||||||
);
|
);
|
||||||
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeLessThanOrEqual(
|
expect(row.closeAt.getTime() - logicalCreatedAt.getTime()).toBeLessThanOrEqual(
|
||||||
plan.closeTurnCnt * 60_000 + 2_000
|
plan.closeTurnCnt * 60_000 + 2_000
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { sealGatewayPassword } from '../src/passwordEnvelope.js';
|
|||||||
|
|
||||||
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
|
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
|
||||||
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
|
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
|
||||||
import { createGatewayApiServer } from '@sammo-ts/gateway-api';
|
import { createGatewayApiServer, seedProfileDatabase } from '@sammo-ts/gateway-api';
|
||||||
import { createGameApiServer } from '@sammo-ts/game-api';
|
import { createGameApiServer } from '@sammo-ts/game-api';
|
||||||
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||||
import {
|
import {
|
||||||
@@ -86,7 +86,8 @@ const truncateSchema = async (schema: string) => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
try {
|
try {
|
||||||
const rows = (await connector.prisma.$queryRawUnsafe(
|
const rows = (await connector.prisma.$queryRawUnsafe(
|
||||||
`SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'`
|
`SELECT tablename FROM pg_tables
|
||||||
|
WHERE schemaname = '${schema}' AND tablename <> '_prisma_migrations'`
|
||||||
)) as Array<{ tablename: string }>;
|
)) as Array<{ tablename: string }>;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -101,13 +102,15 @@ const truncateSchema = async (schema: string) => {
|
|||||||
const resetDatabase = async () => {
|
const resetDatabase = async () => {
|
||||||
await ensureSchema('public');
|
await ensureSchema('public');
|
||||||
await ensureSchema('che');
|
await ensureSchema('che');
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: 'public',
|
POSTGRES_SCHEMA: 'public',
|
||||||
|
GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
||||||
});
|
});
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: 'che',
|
POSTGRES_SCHEMA: 'che',
|
||||||
|
DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
|
||||||
});
|
});
|
||||||
await truncateSchema('public');
|
await truncateSchema('public');
|
||||||
await truncateSchema('che');
|
await truncateSchema('che');
|
||||||
@@ -251,10 +254,11 @@ describe('integration initialization flow', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await gatewayClient.admin.profiles.installNow.mutate({
|
await seedProfileDatabase({
|
||||||
profileName: 'che:2',
|
scenarioId: 2,
|
||||||
install: {
|
databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
|
||||||
scenarioId: 2,
|
adminUser: bootstrap.user,
|
||||||
|
installOptions: {
|
||||||
turnTermMinutes: 1,
|
turnTermMinutes: 1,
|
||||||
sync: false,
|
sync: false,
|
||||||
fiction: 0,
|
fiction: 0,
|
||||||
@@ -267,6 +271,15 @@ describe('integration initialization flow', () => {
|
|||||||
autorunUser: null,
|
autorunUser: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||||
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
|
profile: 'che',
|
||||||
|
profileName: 'che:2',
|
||||||
|
databaseUrl: gameDatabaseUrl,
|
||||||
|
gatewayDatabaseUrl,
|
||||||
|
});
|
||||||
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
|
|
||||||
const publicMap = await gameClient.public.getCachedMap.query();
|
const publicMap = await gameClient.public.getCachedMap.query();
|
||||||
expect(publicMap.result).toBe(true);
|
expect(publicMap.result).toBe(true);
|
||||||
@@ -398,16 +411,6 @@ describe('integration initialization flow', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
|
||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
|
||||||
profile: 'che',
|
|
||||||
profileName: 'che:2',
|
|
||||||
databaseUrl: gameDatabaseUrl,
|
|
||||||
gatewayDatabaseUrl,
|
|
||||||
});
|
|
||||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
|
||||||
|
|
||||||
const waitForStatus = async (timeoutMs = 10_000) => {
|
const waitForStatus = async (timeoutMs = 10_000) => {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ type CoreReservationTrace = {
|
|||||||
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||||
const databaseUrl = process.env.NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL;
|
const databaseUrl = process.env.NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!workspaceRoot || !databaseUrl || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
const referenceEnabled = process.env.TURN_DIFFERENTIAL_REFERENCE === '1';
|
||||||
|
const integration = describe.skipIf(!workspaceRoot || !databaseUrl || !referenceEnabled);
|
||||||
const ownerUserId = 'npc-possession-differential-owner';
|
const ownerUserId = 'npc-possession-differential-owner';
|
||||||
const reservedOwnerUserId = 'npc-possession-differential-reserved';
|
const reservedOwnerUserId = 'npc-possession-differential-reserved';
|
||||||
|
|
||||||
@@ -227,6 +228,9 @@ const assertDedicatedDatabase = (rawUrl: string): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
integration('NPC possession selector Ref differential', () => {
|
integration('NPC possession selector Ref differential', () => {
|
||||||
|
if (!workspaceRoot || !databaseUrl || !referenceEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
let worldState: GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
let worldState: GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ const truncateSchema = async (schema: string) => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
try {
|
try {
|
||||||
const rows = (await connector.prisma.$queryRawUnsafe(
|
const rows = (await connector.prisma.$queryRawUnsafe(
|
||||||
`SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'`
|
`SELECT tablename FROM pg_tables
|
||||||
|
WHERE schemaname = '${schema}' AND tablename <> '_prisma_migrations'`
|
||||||
)) as Array<{ tablename: string }>;
|
)) as Array<{ tablename: string }>;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -108,13 +109,15 @@ const truncateSchema = async (schema: string) => {
|
|||||||
const resetDatabase = async (profile: string) => {
|
const resetDatabase = async (profile: string) => {
|
||||||
await ensureSchema('public');
|
await ensureSchema('public');
|
||||||
await ensureSchema(profile);
|
await ensureSchema(profile);
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: 'public',
|
POSTGRES_SCHEMA: 'public',
|
||||||
|
GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
||||||
});
|
});
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: profile,
|
POSTGRES_SCHEMA: profile,
|
||||||
|
DATABASE_URL: resolvePostgresConfigFromEnv({ schema: profile }).url,
|
||||||
});
|
});
|
||||||
await truncateSchema('public');
|
await truncateSchema('public');
|
||||||
await truncateSchema(profile);
|
await truncateSchema(profile);
|
||||||
|
|||||||
@@ -108,13 +108,15 @@ const truncateSchema = async (schema: string): Promise<void> => {
|
|||||||
const resetServices = async (): Promise<void> => {
|
const resetServices = async (): Promise<void> => {
|
||||||
await ensureSchema('public');
|
await ensureSchema('public');
|
||||||
await ensureSchema('che');
|
await ensureSchema('che');
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:gateway'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: 'public',
|
POSTGRES_SCHEMA: 'public',
|
||||||
|
GATEWAY_DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
||||||
});
|
});
|
||||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], {
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], {
|
||||||
...process.env,
|
...process.env,
|
||||||
POSTGRES_SCHEMA: 'che',
|
POSTGRES_SCHEMA: 'che',
|
||||||
|
DATABASE_URL: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
|
||||||
});
|
});
|
||||||
await truncateSchema('public');
|
await truncateSchema('public');
|
||||||
await truncateSchema('che');
|
await truncateSchema('che');
|
||||||
@@ -346,6 +348,22 @@ describe('actual tournament lifecycle', () => {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The in-memory world is a snapshot. Reload it after creating the users
|
||||||
|
// and tournament NPC fixtures so settlement can reward the same field
|
||||||
|
// that the API and Redis bracket expose.
|
||||||
|
await turnDaemon.lifecycle.stop('reload-tournament-fixtures');
|
||||||
|
await turnDaemon.close();
|
||||||
|
await turnDaemonLoop;
|
||||||
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
|
profile: 'che',
|
||||||
|
profileName: 'che:908',
|
||||||
|
databaseUrl: gameDatabaseUrl,
|
||||||
|
gatewayDatabaseUrl,
|
||||||
|
redisUrl: resolveRedisConfigFromEnv().url,
|
||||||
|
});
|
||||||
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
|
expect(await transport.requestStatus(10_000)).not.toBeNull();
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 36; attempt += 1) {
|
for (let attempt = 0; attempt < 36; attempt += 1) {
|
||||||
const current = turnDaemon.world.getState().lastTurnTime;
|
const current = turnDaemon.world.getState().lastTurnTime;
|
||||||
const next = new Date(current.getTime());
|
const next = new Date(current.getTime());
|
||||||
@@ -509,7 +527,54 @@ describe('actual tournament lifecycle', () => {
|
|||||||
);
|
);
|
||||||
expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true);
|
expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true);
|
||||||
const rewardEvent = settlementEvents.find((event) => event.eventType === 'tournamentReward');
|
const rewardEvent = settlementEvents.find((event) => event.eventType === 'tournamentReward');
|
||||||
// Ref setGift(): 16*1 + 8*2 + 4*3 + both finalists*6 + winner*8 = 64 develcost.
|
const finalMatches = await store.getMatches();
|
||||||
expect((rewardEvent?.result as { totalGold?: number } | null)?.totalGold).toBe(currentDevelCost * 64);
|
const rewardMultipliers = new Map<number, number>();
|
||||||
|
const addRewardTier = (ids: number[], multiplier: number): void => {
|
||||||
|
for (const id of new Set(ids)) {
|
||||||
|
rewardMultipliers.set(id, (rewardMultipliers.get(id) ?? 0) + multiplier);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const roundOf16 = finalMatches.filter((match) => match.stage === 7);
|
||||||
|
const quarterfinals = finalMatches.filter((match) => match.stage === 8);
|
||||||
|
const final = finalMatches.find((match) => match.stage === 10 && typeof match.winnerId === 'number');
|
||||||
|
expect(final).toBeDefined();
|
||||||
|
addRewardTier(
|
||||||
|
roundOf16.flatMap((match) => [match.attackerId, match.defenderId]),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
addRewardTier(
|
||||||
|
roundOf16.flatMap((match) => (typeof match.winnerId === 'number' ? [match.winnerId] : [])),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
addRewardTier(
|
||||||
|
quarterfinals.flatMap((match) => (typeof match.winnerId === 'number' ? [match.winnerId] : [])),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
addRewardTier([final!.attackerId, final!.defenderId], 6);
|
||||||
|
addRewardTier([final!.winnerId!], 8);
|
||||||
|
|
||||||
|
// Ref's tier multipliers apply to each bracket result. The daemon mutates
|
||||||
|
// only generals loaded in its world, so fixture-only/dummy entries remain missing.
|
||||||
|
const persistedRewardIds = new Set(
|
||||||
|
(
|
||||||
|
await gameConnector.prisma.general.findMany({
|
||||||
|
where: { id: { in: Array.from(rewardMultipliers.keys()) } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map(({ id }) => id)
|
||||||
|
);
|
||||||
|
const loadedRewardIds = new Set(
|
||||||
|
Array.from(rewardMultipliers.keys()).filter((id) => turnDaemon!.world.getGeneralById(id) !== undefined)
|
||||||
|
);
|
||||||
|
const expectedTotalGold = Array.from(rewardMultipliers).reduce(
|
||||||
|
(sum, [id, multiplier]) => sum + (loadedRewardIds.has(id) ? currentDevelCost * multiplier : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(persistedRewardIds.size).toBeGreaterThanOrEqual(loadedRewardIds.size);
|
||||||
|
expect(rewardEvent?.result).toMatchObject({
|
||||||
|
rewarded: loadedRewardIds.size,
|
||||||
|
missing: rewardMultipliers.size - loadedRewardIds.size,
|
||||||
|
totalGold: expectedTotalGold,
|
||||||
|
});
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user