feat(admin): apply durable runtime clock shifts
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
|
||||
import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
|
||||
|
||||
interface RuntimeRedisClient {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: {
|
||||
NX?: boolean;
|
||||
PX?: number;
|
||||
}
|
||||
): Promise<unknown>;
|
||||
del(key: string): Promise<unknown>;
|
||||
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
||||
}
|
||||
|
||||
type TournamentClockState = {
|
||||
nextAt?: string;
|
||||
bettingCloseAt?: string;
|
||||
runtimeClockShiftActionIds?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const isUniqueConflict = (error: unknown): boolean =>
|
||||
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
|
||||
|
||||
const shiftDateText = (value: unknown, deltaMinutes: number): unknown => {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return value;
|
||||
}
|
||||
const shifted = new Date(new Date(value).getTime() + deltaMinutes * 60_000);
|
||||
return Number.isNaN(shifted.getTime()) ? value : shifted.toISOString();
|
||||
};
|
||||
|
||||
const syncAuctionTimers = async (
|
||||
db: GamePrismaClient,
|
||||
redis: RuntimeRedisClient,
|
||||
profileName: string
|
||||
): Promise<number> => {
|
||||
const auctions = await db.auction.findMany({
|
||||
where: { status: 'OPEN' },
|
||||
select: { id: true, closeAt: true },
|
||||
});
|
||||
if (auctions.length > 0) {
|
||||
await redis.zAdd(
|
||||
`sammo:${profileName}:auction:timer`,
|
||||
auctions.map((auction) => ({
|
||||
score: auction.closeAt.getTime(),
|
||||
value: String(auction.id),
|
||||
}))
|
||||
);
|
||||
}
|
||||
return auctions.length;
|
||||
};
|
||||
|
||||
const shiftTournamentClock = async (
|
||||
redis: RuntimeRedisClient,
|
||||
profileName: string,
|
||||
actionId: string,
|
||||
deltaMinutes: number
|
||||
): Promise<boolean> => {
|
||||
const stateKey = `sammo:${profileName}:tournament:state`;
|
||||
const lockKey = `${stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
const rawState = await redis.get(stateKey);
|
||||
if (!rawState) {
|
||||
return false;
|
||||
}
|
||||
const state = JSON.parse(rawState) as TournamentClockState;
|
||||
const applied = Array.isArray(state.runtimeClockShiftActionIds)
|
||||
? state.runtimeClockShiftActionIds.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [];
|
||||
if (applied.includes(actionId)) {
|
||||
return true;
|
||||
}
|
||||
const nextState: TournamentClockState = {
|
||||
...state,
|
||||
nextAt: shiftDateText(state.nextAt, deltaMinutes) as string | undefined,
|
||||
bettingCloseAt: shiftDateText(state.bettingCloseAt, deltaMinutes) as string | undefined,
|
||||
runtimeClockShiftActionIds: [...applied, actionId],
|
||||
};
|
||||
await redis.set(stateKey, JSON.stringify(nextState));
|
||||
return true;
|
||||
} finally {
|
||||
if ((await redis.get(lockKey)) === token) {
|
||||
await redis.del(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
await sleep(10);
|
||||
}
|
||||
throw new Error('토너먼트 시간 조정 lock을 획득하지 못했습니다.');
|
||||
};
|
||||
|
||||
const ensureEngineCommand = async (
|
||||
db: GamePrismaClient,
|
||||
actionId: string,
|
||||
deltaMinutes: number
|
||||
): Promise<{ requestId: string; result?: TurnDaemonCommandResult; failed?: string }> => {
|
||||
const requestId = `gateway-runtime:${actionId}`;
|
||||
const command: TurnDaemonCommand = {
|
||||
type: 'shiftSchedule',
|
||||
requestId,
|
||||
actionId,
|
||||
deltaMinutes,
|
||||
};
|
||||
try {
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
payload: asJson(command),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConflict(error)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId },
|
||||
select: { eventType: true, payload: true },
|
||||
});
|
||||
const payload = existing.payload as Partial<TurnDaemonCommand>;
|
||||
if (
|
||||
existing.eventType !== command.type ||
|
||||
payload.type !== command.type ||
|
||||
payload.actionId !== actionId ||
|
||||
payload.deltaMinutes !== deltaMinutes
|
||||
) {
|
||||
return { requestId, failed: '같은 action ID에 다른 시간 조정 payload가 이미 존재합니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
const event = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId },
|
||||
select: { status: true, result: true, error: true },
|
||||
});
|
||||
if (event.status === 'FAILED') {
|
||||
return { requestId, failed: event.error ?? '게임 엔진 시간 조정이 실패했습니다.' };
|
||||
}
|
||||
if (event.status !== 'SUCCEEDED') {
|
||||
return { requestId };
|
||||
}
|
||||
return { requestId, result: event.result as TurnDaemonCommandResult };
|
||||
};
|
||||
|
||||
export const applyRuntimeClockShift = async (options: {
|
||||
action: GatewayAdminActionRecord;
|
||||
profileName: string;
|
||||
db: GamePrismaClient;
|
||||
redis?: RuntimeRedisClient;
|
||||
}): Promise<GatewayAdminActionResult> => {
|
||||
const { action, profileName, db, redis } = options;
|
||||
if (!action.id) {
|
||||
return { status: 'FAILED', detail: '시간 조정 action ID가 없습니다.' };
|
||||
}
|
||||
if (!Number.isInteger(action.durationMinutes) || (action.durationMinutes ?? 0) < 1) {
|
||||
return { status: 'FAILED', detail: '시간 조정 분은 1 이상의 정수여야 합니다.' };
|
||||
}
|
||||
const direction = action.action === 'ACCELERATE' ? -1 : action.action === 'DELAY' ? 1 : 0;
|
||||
if (direction === 0) {
|
||||
return { status: 'IGNORED', detail: `지원하지 않는 시간 조정 action입니다: ${action.action ?? ''}` };
|
||||
}
|
||||
const deltaMinutes = direction * action.durationMinutes!;
|
||||
const engine = await ensureEngineCommand(db, action.id, deltaMinutes);
|
||||
if (engine.failed) {
|
||||
return { status: 'FAILED', detail: engine.failed };
|
||||
}
|
||||
if (!engine.result) {
|
||||
return { status: 'REQUESTED', detail: `게임 엔진 처리 대기 중: ${engine.requestId}` };
|
||||
}
|
||||
if (engine.result.type !== 'shiftSchedule' || !engine.result.ok) {
|
||||
return {
|
||||
status: 'FAILED',
|
||||
detail:
|
||||
engine.result.type === 'shiftSchedule' ? engine.result.reason : '게임 엔진이 다른 결과를 반환했습니다.',
|
||||
};
|
||||
}
|
||||
if (!redis) {
|
||||
return {
|
||||
status: 'PARTIAL',
|
||||
detail: `DB 시간 조정은 적용됐지만 Redis timer 동기화를 기다리는 중입니다: ${engine.requestId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const syncedAuctions = await syncAuctionTimers(db, redis, profileName);
|
||||
const shiftedTournament = await shiftTournamentClock(redis, profileName, action.id, deltaMinutes);
|
||||
return {
|
||||
status: 'APPLIED',
|
||||
detail: [
|
||||
`${Math.abs(deltaMinutes)}분 ${deltaMinutes < 0 ? '가속' : '연기'}`,
|
||||
`장수 ${engine.result.shiftedGenerals}명`,
|
||||
`경매 ${engine.result.shiftedAuctions}건(DB)/${syncedAuctions}건(timer)`,
|
||||
shiftedTournament ? '토너먼트 적용' : '활성 토너먼트 없음',
|
||||
].join(' · '),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user