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();
|
||||
};
|
||||
|
||||
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 (
|
||||
db: DatabaseClient,
|
||||
redis: RedisSortedSetClient,
|
||||
@@ -44,7 +52,7 @@ export const seedAuctionTimers = async (
|
||||
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const payload = rows.map((row) => ({
|
||||
score: resolveAuctionTimerScore(gameTime, row.closeAt, row.closeTick),
|
||||
score: resolveAuctionSeedScore(gameTime, row),
|
||||
value: String(row.id),
|
||||
}));
|
||||
await redis.zAdd(keys.timerKey, payload);
|
||||
|
||||
@@ -88,8 +88,9 @@ export const processDueAuctionId = async (options: {
|
||||
id: string;
|
||||
nowMs: number;
|
||||
nowTick?: number | null;
|
||||
historyNowMs?: number;
|
||||
}): 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);
|
||||
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
|
||||
return 'IGNORED';
|
||||
@@ -160,7 +161,8 @@ export const processDueAuctionId = async (options: {
|
||||
});
|
||||
|
||||
if (outcome.status === 'FINALIZING') {
|
||||
await redis.zAdd(historyKey, [{ score: nowMs, value: id }]);
|
||||
// history retention은 운영 경과시간 기준이며 게임의 논리 시각과 분리한다.
|
||||
await redis.zAdd(historyKey, [{ score: historyNowMs, value: id }]);
|
||||
return 'FINALIZING';
|
||||
}
|
||||
if (outcome.status === 'RESCHEDULED') {
|
||||
@@ -224,6 +226,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
id,
|
||||
nowMs: gameNowMs,
|
||||
nowTick: gameTime.tick,
|
||||
historyNowMs: operationalNowMs,
|
||||
});
|
||||
} catch (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 { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
@@ -20,6 +21,18 @@ const stableJson = (value: unknown): string => {
|
||||
}
|
||||
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 {
|
||||
constructor(readonly requestId: string) {
|
||||
@@ -57,6 +70,9 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
||||
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
||||
if (durableCommand.type === 'npcPossessGeneral') {
|
||||
delete durableCommand.acceptedGameAt;
|
||||
}
|
||||
if (command.type === 'npcPossessGeneral') {
|
||||
const existing = await this.db.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
@@ -65,7 +81,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
if (existing) {
|
||||
if (
|
||||
existing.eventType !== command.type ||
|
||||
stableJson(existing.payload) !== stableJson(durableCommand)
|
||||
commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand)
|
||||
) {
|
||||
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))`
|
||||
);
|
||||
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
|
||||
const token = await transaction.npcSelectionToken.findFirst({
|
||||
where: {
|
||||
ownerUserId: command.userId,
|
||||
nonce: command.tokenNonce,
|
||||
validUntil: { gte: acceptedAt },
|
||||
validUntil: { gte: acceptedGameAt },
|
||||
},
|
||||
select: { pickResult: true },
|
||||
});
|
||||
@@ -101,7 +118,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
) {
|
||||
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;
|
||||
});
|
||||
if (rejectionReason) {
|
||||
@@ -123,7 +144,10 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
where: { requestId },
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user