merge: 최신 main의 내부 예약 명령 경계를 반영한다

# Conflicts:
#	app/game-api/src/router/turns/index.ts
This commit is contained in:
2026-08-24 16:31:39 +00:00
23 changed files with 480 additions and 103 deletions
+3
View File
@@ -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),
+16 -29
View File
@@ -14,9 +14,8 @@ import {
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
assertReservedTurnActionAvailable,
buildEquipmentTradeItemOptions,
parseRegisteredTurnArgs,
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions,
} from '../../turns/commandInput.js';
@@ -66,9 +65,17 @@ const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
args: z.unknown().optional(),
});
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
const parseCommandArgs = async (
scope: 'general' | 'nation',
action: string,
args: unknown,
worldState: WorldStateRow
) => {
try {
return await parseRegisteredTurnArgs(scope, action, args);
// 사용자 입력은 action별 argument schema보다 먼저 현재 scenario의
// 선택 가능 profile을 통과해야 한다. 내부 전용 명령의 parser를 외부
// 요청이 직접 호출하지 못하게 하는 첫 경계다.
return await parseReservedTurnArgs(scope, action, args, asRecord(worldState.config).const);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -78,22 +85,6 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg
}
};
const assertScenarioCommandAvailable = async (
scope: 'general' | 'nation',
action: string,
worldState: WorldStateRow
): Promise<void> => {
try {
await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: error instanceof Error ? error.message : 'Unavailable turn command.',
cause: error,
});
}
};
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
try {
return await mutation();
@@ -492,9 +483,8 @@ export const turnsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const args = await parseCommandArgs('general', input.action, input.args);
const worldState = await getReservationWorldState(ctx);
await assertScenarioCommandAvailable('general', input.action, worldState);
const args = await parseCommandArgs('general', input.action, input.args, worldState);
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
const snapshot = await mutateReservedTurns(() =>
@@ -549,16 +539,15 @@ export const turnsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
const updates = await Promise.all(
input.entries.map(async (entry) => ({
turnIndices: expandGeneralTurnIndices(entry.turnList),
action: entry.action,
args: await parseCommandArgs('general', entry.action, entry.args),
args: await parseCommandArgs('general', entry.action, entry.args, worldState),
}))
);
const worldState = await getReservationWorldState(ctx);
for (const update of updates) {
await assertScenarioCommandAvailable('general', update.action, worldState);
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
}
const snapshot = await mutateReservedTurns(() =>
@@ -596,9 +585,8 @@ export const turnsRouter = router({
message: 'General is not an officer.',
});
}
const args = await parseCommandArgs('nation', input.action, input.args);
const worldState = await getReservationWorldState(ctx);
await assertScenarioCommandAvailable('nation', input.action, worldState);
const args = await parseCommandArgs('nation', input.action, input.args, worldState);
assertNationTurnInputAllowed(general);
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
@@ -714,9 +702,8 @@ export const turnsRouter = router({
const update = {
turnIndices: entry.turnList,
action: entry.action,
args: await parseCommandArgs('nation', entry.action, entry.args),
args: await parseCommandArgs('nation', entry.action, entry.args, worldState),
};
await assertScenarioCommandAvailable('nation', update.action, worldState);
assertNationTurnInputAllowed(general);
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
updates.push(update);
+18 -5
View File
@@ -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;
});
}
}
+1 -1
View File
@@ -362,7 +362,7 @@ export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => {
};
};
export const parseRegisteredTurnArgs = async (
const parseRegisteredTurnArgs = async (
scope: 'general' | 'nation',
action: string,
rawArgs: unknown