merge: 최신 main을 내부 예약 명령 변경에 통합한다
This commit is contained in:
@@ -5,6 +5,7 @@ import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { loadTurnEngineRunning } from '../../services/turnEngineStatus.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
|
||||
export const lobbyRouter = router({
|
||||
@@ -35,6 +36,7 @@ export const lobbyRouter = router({
|
||||
.map(([option]) => option)
|
||||
: [];
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const turnEngineRunning = await loadTurnEngineRunning(ctx.profileStatusSource, ctx.db, ctx.profile.name);
|
||||
|
||||
let myGeneral = null;
|
||||
if (ctx.auth?.user.id) {
|
||||
@@ -72,6 +74,7 @@ export const lobbyRouter = router({
|
||||
clockMode: gameTime.mode ?? 'realtime',
|
||||
clockRunning: gameTime.running,
|
||||
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
||||
turnEngineRunning,
|
||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||
npcMode: worldState.config.npcMode ?? 0,
|
||||
defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165),
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from './realtime/publicEvent.js';
|
||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
||||
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
||||
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
|
||||
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
|
||||
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
|
||||
@@ -115,6 +116,7 @@ export const createGameApiServer = async () => {
|
||||
config.gatewayInternalApiUrl,
|
||||
config.gameTokenSecret
|
||||
);
|
||||
const turnEngineStatus = new CachedTurnEngineStatus(profileStatusSource, postgres.prisma, config.profileName);
|
||||
|
||||
const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
|
||||
const accountIconResetReconciler = new AccountIconResetReconciler(
|
||||
@@ -383,13 +385,24 @@ export const createGameApiServer = async () => {
|
||||
});
|
||||
});
|
||||
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ping',
|
||||
data: '{}',
|
||||
if (heartbeatPending) return;
|
||||
heartbeatPending = true;
|
||||
void turnEngineStatus
|
||||
.get()
|
||||
.then((turnEngineRunning) => {
|
||||
if (closed) return;
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ turnEngineRunning }),
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
.finally(() => {
|
||||
heartbeatPending = false;
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
|
||||
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
||||
|
||||
interface TurnDaemonLeaseSource {
|
||||
turnDaemonLease: {
|
||||
findUnique(input: {
|
||||
where: { profile: string };
|
||||
select: { leaseUntil: true };
|
||||
}): Promise<{ leaseUntil: Date } | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export const loadTurnEngineRunning = async (
|
||||
source: ProfileStatusSource | undefined,
|
||||
db: TurnDaemonLeaseSource,
|
||||
profileName: string,
|
||||
now = new Date()
|
||||
): Promise<boolean | null> => {
|
||||
if (!source) return null;
|
||||
try {
|
||||
const status = await source.get(profileName);
|
||||
if (status === null) return null;
|
||||
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
||||
const lease = await db.turnDaemonLease.findUnique({
|
||||
where: { profile: profileName },
|
||||
select: { leaseUntil: true },
|
||||
});
|
||||
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export class CachedTurnEngineStatus {
|
||||
private cachedAt = Number.NEGATIVE_INFINITY;
|
||||
private cachedValue: boolean | null = null;
|
||||
private pending: Promise<boolean | null> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly source: ProfileStatusSource,
|
||||
private readonly db: TurnDaemonLeaseSource,
|
||||
private readonly profileName: string,
|
||||
private readonly cacheMs = 2_000,
|
||||
private readonly now = () => Date.now()
|
||||
) {}
|
||||
|
||||
get(): Promise<boolean | null> {
|
||||
if (this.now() - this.cachedAt < this.cacheMs) {
|
||||
return Promise.resolve(this.cachedValue);
|
||||
}
|
||||
if (this.pending) return this.pending;
|
||||
|
||||
this.pending = loadTurnEngineRunning(this.source, this.db, this.profileName).then((value) => {
|
||||
this.cachedValue = value;
|
||||
this.cachedAt = this.now();
|
||||
return value;
|
||||
});
|
||||
return this.pending.finally(() => {
|
||||
this.pending = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,12 @@ const buildContext = (
|
||||
nation: {
|
||||
count: vi.fn(async () => 0),
|
||||
},
|
||||
turnDaemonLease: {
|
||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
||||
},
|
||||
} as unknown as DatabaseClient,
|
||||
}) as GameApiContext;
|
||||
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
describe('lobby season state', () => {
|
||||
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||
@@ -70,9 +74,28 @@ describe('lobby season state', () => {
|
||||
expect(result.clockMode).toBe('manual');
|
||||
expect(result.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBeNull();
|
||||
expect(result.turnEngineRunning).toBe(true);
|
||||
expect(new Date(result.serverWallTime).getTime()).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('projects the explicit Gateway turn-running capability independently of the game clock mode', async () => {
|
||||
const context = buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||
tick: 72_000_000n,
|
||||
mode: 'realtime',
|
||||
wallAnchor: new Date('2026-08-15T00:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
context.profileStatusSource = { get: vi.fn(async () => 'PAUSED' as const) };
|
||||
|
||||
const result = await appRouter.createCaller(context).lobby.info();
|
||||
|
||||
expect(result.clockRunning).toBe(true);
|
||||
expect(result.turnEngineRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => {
|
||||
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
||||
const result = await appRouter
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/turnEngineStatus.js';
|
||||
|
||||
describe('turn engine status projection', () => {
|
||||
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
|
||||
const activeLease = {
|
||||
turnDaemonLease: {
|
||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })),
|
||||
},
|
||||
};
|
||||
const now = new Date('2026-08-24T00:00:00.000Z');
|
||||
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
await expect(loadTurnEngineRunning({ get: async () => 'PREOPEN' }, activeLease, 'che:default', now)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
await expect(loadTurnEngineRunning({ get: async () => 'PAUSED' }, activeLease, 'che:default', now)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
await expect(loadTurnEngineRunning({ get: async () => null }, activeLease, 'che:default', now)).resolves.toBeNull();
|
||||
await expect(
|
||||
loadTurnEngineRunning(
|
||||
{ get: async () => Promise.reject(new Error('gateway unavailable')) },
|
||||
activeLease,
|
||||
'che:default',
|
||||
now
|
||||
)
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('marks a RUNNING profile stopped when its daemon lease is missing or expired', async () => {
|
||||
const source = { get: async () => 'RUNNING' as const };
|
||||
const now = new Date('2026-08-24T00:00:00.000Z');
|
||||
await expect(
|
||||
loadTurnEngineRunning(
|
||||
source,
|
||||
{ turnDaemonLease: { findUnique: async () => null } },
|
||||
'che:default',
|
||||
now
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
loadTurnEngineRunning(
|
||||
source,
|
||||
{
|
||||
turnDaemonLease: {
|
||||
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
|
||||
},
|
||||
},
|
||||
'che:default',
|
||||
now
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
|
||||
let now = 1_000;
|
||||
const get = vi.fn(async () => 'RUNNING' as const);
|
||||
const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') }));
|
||||
const cache = new CachedTurnEngineStatus(
|
||||
{ get },
|
||||
{ turnDaemonLease: { findUnique } },
|
||||
'che:default',
|
||||
2_000,
|
||||
() => now
|
||||
);
|
||||
|
||||
await expect(Promise.all([cache.get(), cache.get()])).resolves.toEqual([true, true]);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
now += 1_999;
|
||||
await expect(cache.get()).resolves.toBe(true);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
now += 1;
|
||||
await expect(cache.get()).resolves.toBe(true);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user