merge: 최신 main 변경을 계략 명령 브랜치에 통합

# Conflicts:
#	app/game-api/test/commandTable.test.ts
#	app/game-frontend/e2e/commandArguments.spec.ts
This commit is contained in:
2026-08-15 19:13:17 +00:00
44 changed files with 1408 additions and 285 deletions
@@ -0,0 +1,56 @@
import { createHmac } from 'node:crypto';
import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common';
const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1';
const profileStatuses = new Set<string>(GATEWAY_PROFILE_STATUSES);
export interface ProfileStatusSource {
get(profileName: string): Promise<GatewayProfileStatus | null>;
}
const deriveInternalToken = (secret: string): string =>
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
const parseProfileStatus = (value: unknown, profileName: string): GatewayProfileStatus => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('invalid gateway profile status projection');
}
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'profileName,status' ||
record.profileName !== profileName ||
typeof record.status !== 'string' ||
!profileStatuses.has(record.status)
) {
throw new Error('invalid gateway profile status projection');
}
return record.status as GatewayProfileStatus;
};
export class GatewayHttpProfileStatusSource implements ProfileStatusSource {
private readonly baseUrl: string;
constructor(
baseUrl: string,
private readonly secret: string,
private readonly timeoutMs = 2_000
) {
this.baseUrl = baseUrl.replace(/\/$/u, '');
}
async get(profileName: string): Promise<GatewayProfileStatus | null> {
const response = await fetch(`${this.baseUrl}/internal/profile-status/${encodeURIComponent(profileName)}`, {
headers: {
'x-sammo-internal-token': deriveInternalToken(this.secret),
},
signal: AbortSignal.timeout(this.timeoutMs),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Gateway profile status request failed with HTTP ${response.status}.`);
}
return parseProfileStatus(await response.json(), profileName);
}
}
+6
View File
@@ -8,6 +8,7 @@ import type { BattleSimTransport } from './battleSim/transport.js';
import type { FlushStore } from './auth/flushStore.js';
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import type { AccountIconSource } from './auth/accountIconSource.js';
import type { ProfileStatusSource } from './auth/profileStatusSource.js';
import type { ContentImageUploadStore } from './services/remoteContentImageStore.js';
export interface GameProfile {
@@ -99,6 +100,9 @@ export interface GameApiContext {
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
// Runtime context always supplies this. Partial router fixtures may omit it;
// access scoring then fails open and never penalizes a test-only request.
profileStatusSource?: ProfileStatusSource;
}
export const createGameApiContext = (options: {
@@ -118,6 +122,7 @@ export const createGameApiContext = (options: {
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
profileStatusSource: ProfileStatusSource;
}): GameApiContext => {
return {
requestId: options.requestId,
@@ -137,5 +142,6 @@ export const createGameApiContext = (options: {
flushStore: options.flushStore,
gameTokenSecret: options.gameTokenSecret,
...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}),
profileStatusSource: options.profileStatusSource,
};
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { accessLimitAuthedProcedure, router } from '../../trpc.js';
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
import { getBoardAccess } from '../board/index.js';
import { getGeneralContext } from '../general/index.js';
@@ -31,7 +31,7 @@ const zContextBundleInput = z
});
export const dashboardRouter = router({
getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
const viewerId = ctx.auth?.user.id;
if (!viewerId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
+2 -1
View File
@@ -10,6 +10,7 @@ import {
accessAuthedInputProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
authedProcedure,
engineAuthedProcedure,
router,
@@ -735,7 +736,7 @@ export const generalRouter = router({
})),
};
}),
getRecentRecords: authedProcedure
getRecentRecords: accessLimitAuthedProcedure
.input(
z.object({
lastGeneralRecordId: z.number().int().nonnegative().default(0),
+4
View File
@@ -4,6 +4,7 @@ import { 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 { procedure, router } from '../../trpc.js';
export const lobbyRouter = router({
@@ -26,6 +27,7 @@ export const lobbyRouter = router({
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
const gameTime = await loadCurrentGameTime(ctx.db);
let myGeneral = null;
if (ctx.auth?.user.id) {
@@ -54,6 +56,8 @@ export const lobbyRouter = router({
starttime: worldState.meta.starttime ?? '',
opentime: worldState.meta.opentime ?? '',
turntime: worldState.meta.turntime ?? '',
serverTime: gameTime.now.toISOString(),
clockMode: gameTime.mode ?? 'realtime',
otherTextInfo: worldState.meta.otherTextInfo ?? '',
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
+102 -104
View File
@@ -4,7 +4,7 @@ import { asRecord } from '@sammo-ts/common';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -73,116 +73,114 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
};
export const messagesRouter = router({
getRecent: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
sequence: z.number().int().optional(),
})
)
.query(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
getRecent: accessLimitAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
sequence: z.number().int().optional(),
})
).query(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const sequence = input.sequence ?? -1;
const nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const sequence = input.sequence ?? -1;
const nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
nationId > 0
? ctx.db.nation.findUnique({
where: { id: nationId },
select: { meta: true },
})
: null,
]);
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
nationId > 0
? ctx.db.nation.findUnique({
where: { id: nationId },
select: { meta: true },
})
: null,
]);
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
let nextSequence = sequence;
let minSequence = sequence;
let lastType: MessageType | null = null;
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
for (const message of messages) {
if (message.id > nextSequence) {
nextSequence = message.id;
}
if (message.id <= minSequence) {
minSequence = message.id;
lastType = type;
}
let nextSequence = sequence;
let minSequence = sequence;
let lastType: MessageType | null = null;
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
for (const message of messages) {
if (message.id > nextSequence) {
nextSequence = message.id;
}
if (message.id <= minSequence) {
minSequence = message.id;
lastType = type;
}
};
updateSequence('private', privateMessages);
updateSequence('public', publicMessages);
updateSequence('national', nationalMessages);
updateSequence('diplomacy', diplomacyMessages);
if (lastType === 'private' && messageBuckets.private.length > 0) {
messageBuckets.private.pop();
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
messageBuckets.public.pop();
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
messageBuckets.national.pop();
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
messageBuckets.diplomacy.pop();
}
};
return {
result: true,
...messageBuckets,
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
updateSequence('private', privateMessages);
updateSequence('public', publicMessages);
updateSequence('national', nationalMessages);
updateSequence('diplomacy', diplomacyMessages);
if (lastType === 'private' && messageBuckets.private.length > 0) {
messageBuckets.private.pop();
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
messageBuckets.public.pop();
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
messageBuckets.national.pop();
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
messageBuckets.diplomacy.pop();
}
return {
result: true,
...messageBuckets,
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
getContacts: authedProcedure
.input(z.object({ generalId: z.number().int().positive() }))
.query(async ({ ctx, input }) => {
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js';
import { accessLimitAuthedInputProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
@@ -13,77 +13,75 @@ import {
type GeneralLogType,
} from '../shared.js';
export const getGeneralLog = authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
)
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
export const getGeneralLog = accessLimitAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
).query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, target] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
}),
ctx.db.general.findUnique({
where: { id: input.generalId },
select: { id: true, nationId: true, npcState: true },
}),
]);
const [nation, target] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
}),
ctx.db.general.findUnique({
where: { id: input.generalId },
select: { id: true, nationId: true, npcState: true },
}),
]);
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
}
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
}
const permissionLevel = resolveNationPermission(me, nation.meta, true);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
if (target.nationId !== me.nationId) {
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
}
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
});
}
const categoryMap: Record<GeneralLogType, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: target.id,
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
...(input.type === 'generalHistory' ? {} : { take: 30 }),
const permissionLevel = resolveNationPermission(me, nation.meta, true);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
if (target.nationId !== me.nationId) {
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
}
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
});
}
return {
type: input.type,
const categoryMap: Record<GeneralLogType, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: target.id,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
...(input.type === 'generalHistory' ? {} : { take: 30 }),
});
return {
type: input.type,
generalId: target.id,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
});
+20 -4
View File
@@ -7,7 +7,13 @@ import type { GameApiContext } from '../../context.js';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js';
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
import {
accessPages,
formatGeneralAccessLimitMessage,
generalAccessLimitPages,
getGeneralAccessState,
recordGeneralAccess,
} from '../../services/generalAccess.js';
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
@@ -248,9 +254,19 @@ const sortNpcList = <
export const publicRouter = router({
recordAccess: sessionActivityProcedure
.input(z.object({ page: z.enum(accessPages) }))
.mutation(async ({ ctx, input }) => ({
recorded: await recordGeneralAccess(ctx, input.page),
})),
.mutation(async ({ ctx, input }) => {
const recorded = await recordGeneralAccess(ctx, input.page);
if (ctx.generalAccessTracking === true && generalAccessLimitPages.has(input.page)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
return { recorded };
}),
getMapLayout: procedure.query(async ({ ctx }) => {
return loadMapLayout(ctx.profile.scenario);
}),
+6 -8
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { loadActionModuleBundle } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
import {
@@ -283,13 +283,11 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
};
export const turnsRouter = router({
getCommandTable: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
})
)
.query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
getCommandTable: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
})
).query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
reserved: router({
getGeneral: authedProcedure
.input(
+10 -1
View File
@@ -7,7 +7,12 @@ import { LogCategory, LogScope } from '@sammo-ts/logic';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import { generalAccessEndpointWeights, recordGeneralAccessWeight } from '../../services/generalAccess.js';
import {
formatGeneralAccessLimitMessage,
generalAccessEndpointWeights,
getGeneralAccessState,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -31,6 +36,10 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
return;
}
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
const state = ctx.generalAccessTracking === true ? await getGeneralAccessState(ctx) : null;
if (state?.level === 2) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: formatGeneralAccessLimitMessage(state) });
}
};
const parseTextArray = (value: unknown): string[] =>
+6
View File
@@ -25,6 +25,7 @@ import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
@@ -92,6 +93,10 @@ export const createGameApiServer = async () => {
throw error;
}
const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret);
const profileStatusSource = new GatewayHttpProfileStatusSource(
config.gatewayInternalApiUrl,
config.gameTokenSecret
);
const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
const accountIconResetReconciler = new AccountIconResetReconciler(
@@ -214,6 +219,7 @@ export const createGameApiServer = async () => {
flushStore,
gameTokenSecret: config.gameTokenSecret,
accountIconSource,
profileStatusSource,
});
},
},
+107 -5
View File
@@ -1,4 +1,4 @@
import { asRecord } from '@sammo-ts/common';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
@@ -56,6 +56,7 @@ export const generalAccessEndpointWeights = {
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'turns.getCommandTable': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
@@ -65,6 +66,40 @@ export const generalAccessEndpointWeights = {
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
export const generalAccessLimitPages = new Set<AccessPage>(['nation-list', 'npc-control']);
export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
'world.getGeneralDirectory',
'tournament.getSnapshot',
'nation.getSecretGeneralList',
'nation.getGeneralList',
'nation.getStratFinan',
'nation.getBattleCenter',
'nation.getChiefCenter',
'board.getArticles',
'board.writeArticle',
'board.writeComment',
'diplomacy.getLetters',
'diplomacy.sendLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.destroyLetter',
'betting.getList',
'general.getFrontStatus',
'yearbook.getHistory',
'messages.send',
'turns.getCommandTable',
]);
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
export type GeneralAccessState = {
refreshScore: number;
refreshLimit: number;
level: AccessLimitLevel;
nextAccessAt: Date;
};
export const resolveGeneralAccessEndpointWeight = (
path: string,
input: unknown,
@@ -114,6 +149,58 @@ export const resolveAccessWindows = (
return { periodStartedAt: scoreStartedAt, scoreStartedAt };
};
export const resolveGeneralScoreStartedAt = (tickSeconds: number, nextTurnAt: Date): Date =>
new Date(nextTurnAt.getTime() - Math.max(1, Math.floor(tickSeconds)) * 1_000);
const formatAccessTime = (value: Date): string => {
const kst = new Date(value.getTime() + 9 * 60 * 60 * 1_000);
return kst.toISOString().slice(0, 19).replace('T', ' ');
};
export const formatGeneralAccessLimitMessage = (state: Pick<GeneralAccessState, 'nextAccessAt'>): string =>
`접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ` +
`(다음 접속 가능 시각: ${formatAccessTime(state.nextAccessAt)}) ` +
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.';
export const getGeneralAccessState = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>
): Promise<GeneralAccessState | null> => {
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return null;
}
const [general, worldState] = await Promise.all([
ctx.db.general.findFirst({
where: { userId: user.id },
orderBy: { id: 'asc' },
select: { id: true, turnTime: true },
}),
ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { tickSeconds: true, meta: true },
}),
]);
if (!general || !worldState) {
return null;
}
const access = await ctx.db.generalAccessLog.findUnique({
where: { generalId: general.id },
select: { lastRefresh: true, refreshScore: true },
});
const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime);
const refreshScore =
access?.lastRefresh && access.lastRefresh.getTime() < scoreStartedAt.getTime()
? 0
: (access?.refreshScore ?? 0);
const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit);
return {
refreshScore,
refreshLimit,
level: resolveAccessLimitLevel(refreshScore, refreshLimit),
nextAccessAt: general.turnTime,
};
};
export const upsertGeneralAccess = async (
db: Pick<GameApiContext['db'], '$transaction'>,
input: {
@@ -286,13 +373,13 @@ export const upsertGeneralAccess = async (
};
export const recordGeneralAccess = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
page: AccessPage,
now = new Date()
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
export const recordGeneralAccessWeight = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
weight: number,
now = new Date()
): Promise<boolean> => {
@@ -304,11 +391,25 @@ export const recordGeneralAccessWeight = async (
return false;
}
const profileStatusSource = ctx.profileStatusSource;
if (!profileStatusSource) {
return false;
}
try {
if ((await profileStatusSource.get(ctx.profile.name)) !== 'RUNNING') {
return false;
}
} catch {
// 상태를 확인하지 못한 요청으로 사용자를 벌주지 않는다. 업무 요청은
// 계속 진행하고 다음 요청에서 gateway 상태를 다시 확인한다.
return false;
}
const [general, worldState] = await Promise.all([
ctx.db.general.findFirst({
where: { userId: user.id },
orderBy: { id: 'asc' },
select: { id: true, userId: true },
select: { id: true, userId: true, turnTime: true },
}),
ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
@@ -332,7 +433,8 @@ export const recordGeneralAccessWeight = async (
return false;
}
const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
const { periodStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime);
await upsertGeneralAccess(ctx.db, {
worldStateId: worldState.id,
+47 -1
View File
@@ -5,7 +5,15 @@ import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from './context.js';
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
import { recordGeneralAccessWeight, resolveGeneralAccessEndpointWeight } from './services/generalAccess.js';
import {
formatGeneralAccessLimitMessage,
generalAccessLimitBeforeRecordEndpoints,
generalAccessLimitEndpoints,
getGeneralAccessState,
recordGeneralAccessWeight,
resolveGeneralAccessEndpointWeight,
type GeneralAccessEndpoint,
} from './services/generalAccess.js';
const t = initTRPC.context<GameApiContext>().create();
@@ -84,7 +92,40 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
if (weight === null) {
return next();
}
const endpoint = path as GeneralAccessEndpoint;
if (generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
await recordGeneralAccessWeight(ctx, weight);
if (generalAccessLimitEndpoints.has(endpoint) && !generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
return next();
});
const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
if (ctx.generalAccessTracking !== true) {
return next();
}
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
return next();
});
@@ -116,6 +157,9 @@ export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessLimitMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) =>
@@ -126,3 +170,5 @@ export const accessEngineAuthedInputProcedure: typeof procedure.input = (input)
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware);
+52 -2
View File
@@ -119,6 +119,7 @@ describe('buildTurnCommandTable', () => {
'che_단련',
'che_숙련전환',
'che_견문',
'che_은퇴',
'che_장비매매',
'che_군량매매',
'che_내정특기초기화',
@@ -135,9 +136,58 @@ describe('buildTurnCommandTable', () => {
'che_주민선정',
],
: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'],
: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
});
});
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
buildTurnCommandTable({
worldState: buildWorldState(),
general,
city: buildCity(),
nation,
nationGenerals: null,
});
const findCommand = (table: Awaited<ReturnType<typeof buildTable>>, key: string) =>
table.general.flatMap((group) => group.values).find((command) => command.key === key);
const ordinary = await buildTable(buildGeneral());
expect(findCommand(ordinary, 'che_강행')).toMatchObject({
name: '강행',
reqArg: true,
possible: true,
inputFields: [{ key: 'destCityId', optionSource: 'cities' }],
});
expect(findCommand(ordinary, 'che_은퇴')).toMatchObject({
name: '은퇴',
possible: false,
status: 'blocked',
reason: '나이가 60세 이상이어야 합니다.',
});
expect(findCommand(ordinary, 'che_하야')).toMatchObject({
name: '하야',
possible: true,
status: 'available',
});
const oldEnough = await buildTable({ ...buildGeneral(), age: 60 } as GeneralRow);
expect(findCommand(oldEnough, 'che_은퇴')).toMatchObject({ possible: true, status: 'available' });
const ruler = await buildTable({ ...buildGeneral(), officerLevel: 12 } as GeneralRow);
expect(findCommand(ruler, 'che_하야')).toMatchObject({
possible: false,
status: 'blocked',
reason: expect.stringContaining('군주'),
});
const neutral = await buildTable({ ...buildGeneral(), nationId: 0, officerLevel: 0 } as GeneralRow, null);
expect(findCommand(neutral, 'che_하야')).toMatchObject({
possible: false,
status: 'blocked',
reason: '재야입니다.',
});
});
@@ -73,6 +73,7 @@ const buildContext = (authenticated: boolean) => {
},
city: { findUnique: async () => null },
nation: { findUnique: async () => null },
generalAccessLog: { findUnique: async () => null },
worldState: { findFirst: async () => ({ config: { const: {} } }) },
},
} as unknown as GameApiContext;
@@ -379,6 +379,7 @@ integration('general access tracking persistence', () => {
name: yearbookProfile,
scenario: 'default',
},
profileStatusSource: { get: async () => 'RUNNING' as const },
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
+92 -16
View File
@@ -5,16 +5,28 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js';
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js';
import {
accessPageWeights,
generalAccessEndpointWeights,
getGeneralAccessState,
recordGeneralAccess,
recordGeneralAccessWeight,
resolveGeneralAccessEndpointWeight,
resolveAccessWindows,
resolveGeneralScoreStartedAt,
} from '../src/services/generalAccess.js';
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
const profileStatusSource = { get: vi.fn(async () => 'RUNNING' as const) };
const accessContext = (db: DatabaseClient, token: GameSessionTokenPayload | null = auth()) => ({
auth: token,
db,
profile,
profileStatusSource,
generalAccessTracking: true as const,
});
const auth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
@@ -30,7 +42,10 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({
sanctions: {},
});
const buildDb = (meta: Record<string, unknown> = {}) => {
const buildDb = (
meta: Record<string, unknown> = {},
access: { lastRefresh: Date | null; refreshScore: number } | null = null
) => {
const executeRaw = vi.fn(async (_query: unknown) => 1);
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
const transaction = vi.fn(
@@ -38,7 +53,11 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
);
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
const findGeneral = vi.fn(async () => ({
id: 7,
userId: 'user-7',
turnTime: new Date('2026-07-26T03:10:00.000Z'),
}));
const findWorld = vi.fn(async () => ({
id: 3,
currentYear: 185,
@@ -53,6 +72,7 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
const db = {
$transaction: transaction,
general: { findFirst: findGeneral },
generalAccessLog: { findUnique: vi.fn(async () => access) },
worldState: { findFirst: findWorld },
} as unknown as DatabaseClient;
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
@@ -91,6 +111,7 @@ describe('general access tracking', () => {
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'turns.getCommandTable': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
@@ -120,17 +141,20 @@ describe('general access tracking', () => {
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
});
expect(resolveGeneralScoreStartedAt(600, new Date('2026-07-26T03:20:00.000Z'))).toEqual(
new Date('2026-07-26T03:10:00.000Z')
);
});
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
const now = new Date('2026-07-26T03:05:00.000Z');
await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true);
await expect(recordGeneralAccess(accessContext(db), 'nation-list', now)).resolves.toBe(true);
expect(findGeneral).toHaveBeenCalledWith({
where: { userId: 'user-7' },
orderBy: { id: 'asc' },
select: { id: true, userId: true },
select: { id: true, userId: true, turnTime: true },
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
@@ -167,7 +191,7 @@ describe('general access tracking', () => {
const { db, executeRaw, queryRaw, transaction } = buildDb();
const now = new Date('2026-07-26T03:06:00.000Z');
await expect(recordGeneralAccessWeight({ auth: auth(), db }, 0, now)).resolves.toBe(true);
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
expect(transaction).toHaveBeenCalledTimes(1);
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
@@ -175,14 +199,42 @@ describe('general access tracking', () => {
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
});
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
const blocked = buildDb(
{ refreshLimit: 120 },
{ lastRefresh: new Date('2026-07-26T03:05:00.000Z'), refreshScore: 121 }
);
await expect(getGeneralAccessState(accessContext(blocked.db))).resolves.toMatchObject({
refreshScore: 121,
refreshLimit: 120,
level: 2,
nextAccessAt: new Date('2026-07-26T03:10:00.000Z'),
});
const stale = buildDb(
{ refreshLimit: 120 },
{ lastRefresh: new Date('2026-07-26T02:59:59.999Z'), refreshScore: 999 }
);
await expect(getGeneralAccessState(accessContext(stale.db))).resolves.toMatchObject({
refreshScore: 0,
level: 0,
});
const resolver = vi.fn(() => ({ ok: true }));
const limitedRouter = router({ read: accessLimitAuthedProcedure.query(resolver) });
await expect(
limitedRouter.createCaller(accessContext(blocked.db) as unknown as GameApiContext).read()
).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
message: expect.stringContaining('자신의 턴이 되면 다시 접속 가능합니다.'),
});
expect(resolver).not.toHaveBeenCalled();
});
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
const fixture = buildDb();
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, -1)).rejects.toBeInstanceOf(
RangeError
);
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, 0.5)).rejects.toBeInstanceOf(
RangeError
);
await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError);
await expect(recordGeneralAccessWeight(accessContext(fixture.db), 0.5)).rejects.toBeInstanceOf(RangeError);
expect(fixture.findGeneral).not.toHaveBeenCalled();
});
@@ -201,6 +253,13 @@ describe('general access tracking', () => {
findFirst: vi.fn(async () => ({
id: 7,
userId: 'user-7',
turnTime: new Date('2026-07-26T03:10:00.000Z'),
})),
},
generalAccessLog: {
findUnique: vi.fn(async () => ({
lastRefresh: new Date('2026-07-26T03:05:00.000Z'),
refreshScore: 1,
})),
},
worldState: {
@@ -253,6 +312,7 @@ describe('general access tracking', () => {
generalAccessTracking: true,
requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' },
profileStatusSource,
} as unknown as GameApiContext;
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
@@ -279,21 +339,37 @@ describe('general access tracking', () => {
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
const anonymous = buildDb();
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
await expect(recordGeneralAccess(accessContext(anonymous.db, null), 'traffic')).resolves.toBe(false);
expect(anonymous.findGeneral).not.toHaveBeenCalled();
const admin = buildDb();
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
await expect(recordGeneralAccess(accessContext(admin.db, auth(['admin'])), 'traffic')).resolves.toBe(false);
expect(admin.findGeneral).not.toHaveBeenCalled();
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
await expect(
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
recordGeneralAccess(accessContext(future.db), 'traffic', new Date('2026-07-26T03:05:00.000Z'))
).resolves.toBe(false);
expect(future.transaction).not.toHaveBeenCalled();
const united = buildDb({ isUnited: 2 });
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
await expect(recordGeneralAccess(accessContext(united.db), 'traffic')).resolves.toBe(false);
expect(united.transaction).not.toHaveBeenCalled();
const paused = buildDb();
const pausedContext = {
...accessContext(paused.db),
profileStatusSource: { get: vi.fn(async () => 'PAUSED' as const) },
};
await expect(recordGeneralAccess(pausedContext, 'traffic')).resolves.toBe(false);
expect(paused.findGeneral).not.toHaveBeenCalled();
const unavailable = buildDb();
const unavailableContext = {
...accessContext(unavailable.db),
profileStatusSource: { get: vi.fn(async () => Promise.reject(new Error('gateway unavailable'))) },
};
await expect(recordGeneralAccess(unavailableContext, 'traffic')).resolves.toBe(false);
expect(unavailable.findGeneral).not.toHaveBeenCalled();
});
});
+32 -1
View File
@@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
const buildContext = (meta: Record<string, unknown>): GameApiContext =>
const buildContext = (
meta: Record<string, unknown>,
clock: {
baseTime?: Date;
tick?: bigint;
mode?: string;
wallAnchor?: Date;
} = {}
): GameApiContext =>
({
auth: null,
db: {
@@ -16,6 +24,10 @@ const buildContext = (meta: Record<string, unknown>): GameApiContext =>
tickSeconds: 3_600,
config: {},
meta,
clockBaseTime: clock.baseTime ?? null,
clockTick: clock.tick ?? null,
clockMode: clock.mode ?? 'realtime',
clockWallAnchor: clock.wallAnchor ?? null,
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
})),
},
@@ -36,4 +48,23 @@ describe('lobby season state', () => {
expect(result.isUnited).toBe(isunited);
});
it('returns the projected server game time and whether the clock is running', async () => {
const result = await appRouter
.createCaller(
buildContext(
{},
{
baseTime: new Date('2026-08-15T00:00:00.000Z'),
tick: 72_000_000n,
mode: 'manual',
wallAnchor: new Date('2026-08-15T17:00:00.000Z'),
}
)
)
.lobby.info();
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
expect(result.clockMode).toBe('manual');
});
});
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GatewayHttpProfileStatusSource } from '../src/auth/profileStatusSource.js';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('GatewayHttpProfileStatusSource', () => {
it('uses an encoded path and a purpose-derived credential', async () => {
const fetchMock = vi.fn<(input: string | URL, init?: RequestInit) => Promise<Response>>(
async () =>
new Response(JSON.stringify({ profileName: 'che:default/한글', status: 'RUNNING' }), { status: 200 })
);
vi.stubGlobal('fetch', fetchMock);
const source = new GatewayHttpProfileStatusSource('http://gateway.internal/', 'root-secret');
await expect(source.get('che:default/한글')).resolves.toBe('RUNNING');
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('http://gateway.internal/internal/profile-status/che%3Adefault%2F%ED%95%9C%EA%B8%80');
expect(init?.headers).toMatchObject({
'x-sammo-internal-token': expect.not.stringContaining('root-secret'),
});
});
it('returns null only for a missing profile and rejects malformed status values', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 404 }))
);
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('missing')).resolves.toBeNull();
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ profileName: 'che', status: 'UNKNOWN' }), { status: 200 }))
);
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('che')).rejects.toThrow(
'invalid'
);
});
});
@@ -914,6 +914,7 @@ export const createDatabaseTurnHooks = async (
let persistedVisibleLogs: PersistedVisibleLogRow[] = [];
let visibleLogFloor = directLogFloor;
const {
accessScoreResetGeneralIds,
generals,
cities,
nations,
@@ -1012,6 +1013,13 @@ export const createDatabaseTurnHooks = async (
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
);
if (accessScoreResetGeneralIds.length > 0) {
await prisma.generalAccessLog.updateMany({
where: { generalId: { in: accessScoreResetGeneralIds } },
data: { refreshScore: 0 },
});
}
if (inheritancePointAdjustments.length > 0) {
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
for (const entry of inheritancePointAdjustments) {
@@ -2,7 +2,7 @@ import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
import { asNumber, asRecord } from '@sammo-ts/common';
import { asNumber, asRecord, calculateAccessRefreshLimit } from '@sammo-ts/common';
export interface InMemoryTurnProcessorOptions {
tickMinutes?: number;
@@ -50,6 +50,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
const isBudgetExpired = () => Date.now() >= deadlineMs;
this.world.setCheckpoint(checkpoint);
this.world.updateWorldMeta({
refreshLimit: calculateAccessRefreshLimit(this.world.getState().tickSeconds),
});
let processedGenerals = 0;
let processedTurns = 0;
@@ -97,6 +100,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
if (executionError !== undefined) {
throw executionError;
}
// Ref의 updateTurnTime()은 장수 명령이 성공한 뒤 그 장수의
// 순간 벌점을 같은 턴 flush에서 초기화한다.
this.world.markGeneralAccessScoreReset(general.id);
processedGenerals += 1;
nextCheckpoint = {
turnTime: executedAt.toISOString(),
+16
View File
@@ -115,6 +115,7 @@ export interface InMemoryGameClockState {
}
export interface TurnWorldChanges {
accessScoreResetGeneralIds: number[];
generals: TurnGeneral[];
cities: City[];
nations: Nation[];
@@ -156,6 +157,7 @@ export interface InMemoryTurnWorldStateSnapshot {
dirtyNationIds: number[];
dirtyTroopIds: number[];
dirtyDiplomacyKeys: string[];
accessScoreResetGeneralIds: number[];
createdGeneralIds: number[];
createdNationIds: number[];
createdTroopIds: number[];
@@ -419,6 +421,7 @@ export class InMemoryTurnWorld {
private readonly dirtyNationIds = new Set<number>();
private readonly dirtyTroopIds = new Set<number>();
private readonly dirtyDiplomacyKeys = new Set<string>();
private readonly accessScoreResetGeneralIds = new Set<number>();
private readonly createdGeneralIds = new Set<number>();
private nextLegacyGeneralScanOrder = 0;
private readonly createdNationIds = new Set<number>();
@@ -606,6 +609,7 @@ export class InMemoryTurnWorld {
dirtyNationIds: Array.from(this.dirtyNationIds),
dirtyTroopIds: Array.from(this.dirtyTroopIds),
dirtyDiplomacyKeys: Array.from(this.dirtyDiplomacyKeys),
accessScoreResetGeneralIds: Array.from(this.accessScoreResetGeneralIds),
createdGeneralIds: Array.from(this.createdGeneralIds),
createdNationIds: Array.from(this.createdNationIds),
createdTroopIds: Array.from(this.createdTroopIds),
@@ -644,6 +648,7 @@ export class InMemoryTurnWorld {
this.replaceSet(this.dirtyNationIds, restored.dirtyNationIds);
this.replaceSet(this.dirtyTroopIds, restored.dirtyTroopIds);
this.replaceSet(this.dirtyDiplomacyKeys, restored.dirtyDiplomacyKeys);
this.replaceSet(this.accessScoreResetGeneralIds, restored.accessScoreResetGeneralIds ?? []);
this.replaceSet(this.createdGeneralIds, restored.createdGeneralIds);
this.replaceSet(this.createdNationIds, restored.createdNationIds);
this.replaceSet(this.createdTroopIds, restored.createdTroopIds);
@@ -693,6 +698,12 @@ export class InMemoryTurnWorld {
};
}
markGeneralAccessScoreReset(generalId: number): void {
if (Number.isSafeInteger(generalId) && generalId > 0) {
this.accessScoreResetGeneralIds.add(generalId);
}
}
changeTurnTerm(tickMinutes: number): void {
if (!Number.isInteger(tickMinutes) || tickMinutes <= 0) {
throw new Error('Turn term must be a positive integer.');
@@ -1512,8 +1523,12 @@ export class InMemoryTurnWorld {
}));
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
(left, right) => left - right
);
return {
accessScoreResetGeneralIds,
generals,
cities,
nations,
@@ -1542,6 +1557,7 @@ export class InMemoryTurnWorld {
}
acknowledgeDirtyState(changes: TurnWorldChanges): void {
for (const id of changes.accessScoreResetGeneralIds) this.accessScoreResetGeneralIds.delete(id);
for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id);
for (const city of changes.cities) this.dirtyCityIds.delete(city.id);
for (const nation of changes.nations) this.dirtyNationIds.delete(nation.id);
@@ -330,8 +330,8 @@ export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
return new Date(baseTime + alignedSeconds * 1000);
};
const resolveTurnTime = (
rng: RandUtil,
export const resolveJoinTurnTime = (
rng: Pick<RandUtil, 'nextRangeInt'>,
worldState: WorldStateRow,
acceptedAt: Date,
runtimeTurnTime: Date,
@@ -348,7 +348,12 @@ const resolveTurnTime = (
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
offsetMicros = rng.nextRangeInt(0, 999_999);
} else {
turnTimeBase = base;
// Ref normally uses game_env.turntime as a near-current cursor. Core's
// durable daemon can legitimately be catching up from an older cursor,
// so scheduling from runtimeTurnTime may put a newly created general
// hours behind the game clock. The accepted game time is the equivalent
// current-time boundary for a new general.
turnTimeBase = acceptedAt;
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
offsetMicros = rng.nextRangeInt(0, 999_999);
}
@@ -662,7 +667,7 @@ export const createGeneralFromJoin = async (options: {
}
const experience = await resolveCatchupExperience(db, relativeYear);
const turnTime = resolveTurnTime(
const turnTime = resolveJoinTurnTime(
rng,
worldState,
acceptedAt,
@@ -18,8 +18,10 @@ const GENERAL_AI_ACTIONS = [
const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const;
const GENERAL_REF_EDITOR_ACTIONS = [
'che_은퇴',
'che_임관',
'che_랜덤임관',
'che_강행',
'che_징병',
'che_출병',
'che_농지개간',
@@ -28,6 +30,7 @@ const GENERAL_REF_EDITOR_ACTIONS = [
'che_파괴',
'che_화계',
'che_증여',
'che_하야',
'che_장비매매',
] as const;
const GENERAL_REF_STRATEGY_ACTIONS = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const;
@@ -0,0 +1,120 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalId = 991_815;
const scenarioCode = 'general-access-score-reset-persistence';
integration('general access score reset persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
const cleanup = async () => {
await db.generalAccessLog.deleteMany({ where: { generalId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.worldState.deleteMany({ where: { scenarioCode } });
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await cleanup();
});
afterAll(async () => {
await cleanup();
await closeDb?.();
});
it('commits the own-turn reset marker in the same world flush', async () => {
const turnTime = new Date('2026-08-15T00:10:00.000Z');
await db.general.create({
data: {
id: generalId,
userId: 'access-reset-persistence-user',
name: '접속점수초기화장수',
turnTime,
},
});
await db.generalAccessLog.create({
data: {
generalId,
userId: 'access-reset-persistence-user',
lastRefresh: new Date('2026-08-15T00:09:59.000Z'),
refresh: 120,
refreshTotal: 500,
refreshScore: 351,
refreshScoreTotal: 999,
},
});
const row = await db.worldState.create({
data: {
scenarioCode,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
},
});
const state: TurnWorldState = {
id: row.id,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-15T00:00:00.000Z'),
meta: {},
};
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
};
const world = new InMemoryTurnWorld(
state,
{
scenarioConfig,
map: { id: 'test', name: 'test', cities: [] },
generals: [],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
},
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
);
world.markGeneralAccessScoreReset(generalId);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
});
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
refresh: 120,
refreshTotal: 500,
refreshScore: 0,
refreshScoreTotal: 999,
});
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]);
} finally {
await hooks.close();
}
});
});
@@ -4,6 +4,7 @@ import {
buildJoinCreateGeneralSeed,
cutJoinTurnTime,
JOIN_WELCOME_MESSAGE,
resolveJoinTurnTime,
} from '../src/turn/joinCreateGeneralService.js';
describe('generic join legacy time contracts', () => {
@@ -19,6 +20,35 @@ describe('generic join legacy time contracts', () => {
);
});
it('schedules a new general within one turn of the accepted game time even when the daemon cursor is stale', () => {
const calls: Array<[number, number]> = [];
const values = [59, 250_000];
const rng = {
nextRangeInt(min: number, max: number) {
calls.push([min, max]);
return values.shift() ?? min;
},
};
const acceptedAt = new Date('2026-08-15T17:57:05.837Z');
const staleRuntimeTurnTime = new Date('2026-08-15T07:10:00.000Z');
const turnTime = resolveJoinTurnTime(
rng,
{ tickSeconds: 120 } as Parameters<typeof resolveJoinTurnTime>[1],
acceptedAt,
staleRuntimeTurnTime,
undefined
);
expect(turnTime.toISOString()).toBe('2026-08-15T17:58:05.087Z');
expect(turnTime.getTime()).toBeGreaterThan(acceptedAt.getTime());
expect(turnTime.getTime()).toBeLessThanOrEqual(acceptedAt.getTime() + 120_000);
expect(calls).toEqual([
[0, 119],
[0, 999_999],
]);
});
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
+2
View File
@@ -174,6 +174,8 @@ describe('InMemoryTurnProcessor ordering', () => {
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
expect(tiedGeneralResult.processedTurns).toBe(0);
expect(executed).toEqual([3, 2]);
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([2, 3]);
expect(world.getState().meta).toMatchObject({ refreshLimit: 350 });
expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime());
expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull();
expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4);
+114 -3
View File
@@ -314,7 +314,7 @@ const chiefCenter = {
})),
};
const install = async (page: Page, rejectGeneral = false) => {
const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => {
const requests: unknown[] = [];
const generalTurns = turns(30);
const nationTurns = turns(12);
@@ -364,7 +364,7 @@ const install = async (page: Page, rejectGeneral = false) => {
? {
kind: 'snapshot',
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
data: commandTable,
data: commandTableResponse,
}
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
boardAccess: initial
@@ -420,7 +420,7 @@ const install = async (page: Page, rejectGeneral = false) => {
myCity: 1,
myNation: 1,
});
if (name === 'turns.getCommandTable') return response(commandTable);
if (name === 'turns.getCommandTable') return response(commandTableResponse);
if (name === 'nation.getChiefCenter') return response(chiefCenter);
if (name === 'turns.reserved.getGeneral')
return response({ turns: generalTurns, revision: generalRevision });
@@ -507,6 +507,117 @@ test('renders and accepts every Ref strategy command at mobile width', async ({
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') });
});
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
const specialCommandTable = {
general: [
{
category: '개인',
values: [
{
key: 'che_은퇴',
name: '은퇴',
reqArg: false,
possible: false,
status: 'blocked',
reason: '나이가 60세 이상이어야 합니다.',
inputFields: [],
},
],
},
{
category: '인사',
values: [
{
key: 'che_강행',
name: '강행',
reqArg: true,
possible: true,
status: 'available',
inputFields: [
{
key: 'destCityId',
label: '대상 도시',
kind: 'select',
required: true,
optionSource: 'cities',
},
],
},
],
},
{
category: '국가',
values: [
{
key: 'che_하야',
name: '하야',
reqArg: false,
possible: true,
status: 'available',
inputFields: [],
},
],
},
],
nation: [],
inputOptions,
};
const requests = await install(page, false, specialCommandTable);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const editor = page.locator('[data-command-scope="general"]');
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
let picker = page.getByTestId('command-picker');
const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
await expect(retirement).toHaveClass(/blocked/);
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
await retirement.hover();
await retirement.focus();
await expect(retirement).toBeFocused();
await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') });
await retirement.click();
await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴');
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '하야', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야');
await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '인사', exact: true }).click();
await picker.getByRole('button', { name: '강행', exact: true }).click();
const forceMoveForm = picker.getByTestId('command-argument-form');
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
await forceMoveForm.locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행');
const serialized = JSON.stringify(requests);
expect(serialized).toContain('"action":"che_은퇴","args":{}');
expect(serialized).toContain('"action":"che_하야","args":{}');
expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}');
await page.setViewportSize({ width: 500, height: 900 });
await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']);
const mobileGeometry = await picker.evaluate((element) => ({
width: element.getBoundingClientRect().width,
horizontalOverflow: element.scrollWidth - element.clientWidth,
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
}));
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
await picker.getByRole('button', { name: '개인', exact: true }).click();
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
});
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
+52 -7
View File
@@ -179,26 +179,54 @@ test('prioritizes core general fields and keeps context and inheritance progress
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
height: rect.height,
backgroundColor: style.backgroundColor,
color: style.color,
borderTopWidth: style.borderTopWidth,
borderRightWidth: style.borderRightWidth,
borderBottomWidth: style.borderBottomWidth,
borderBottomColor: style.borderBottomColor,
marginTop: style.marginTop,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
cursor: style.cursor,
};
});
expect(defaultButtonStyle).toEqual({
expect(defaultButtonStyle).toMatchObject({
height: 40,
backgroundColor: 'rgb(0, 88, 44)',
color: 'rgb(255, 255, 255)',
borderTopWidth: '0px',
borderRightWidth: '1px',
borderBottomWidth: '4px',
borderBottomColor: 'rgb(0, 79, 40)',
marginTop: '0px',
fontSize: '14px',
fontWeight: '700',
cursor: 'pointer',
});
await randomButton.hover();
await expect
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe('rgb(0, 109, 55)');
const hoverButtonStyle = await randomButton.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
height: rect.height,
backgroundColor: style.backgroundColor,
borderBottomWidth: style.borderBottomWidth,
borderBottomColor: style.borderBottomColor,
marginTop: style.marginTop,
};
});
expect(hoverButtonStyle).toMatchObject({
top: defaultButtonStyle.top + 1,
height: 39,
backgroundColor: 'rgb(0, 88, 44)',
borderBottomWidth: '3px',
borderBottomColor: 'rgb(0, 79, 40)',
marginTop: '1px',
});
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-hover-desktop.png'), fullPage: true });
await randomButton.focus();
await expect(randomButton).toBeFocused();
@@ -210,9 +238,26 @@ test('prioritizes core general fields and keeps context and inheritance progress
randomButtonBox!.y + randomButtonBox!.height / 2
);
await page.mouse.down();
await expect
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe('rgb(0, 69, 35)');
const activeButtonStyle = await randomButton.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
height: rect.height,
backgroundColor: style.backgroundColor,
borderBottomWidth: style.borderBottomWidth,
borderBottomColor: style.borderBottomColor,
marginTop: style.marginTop,
};
});
expect(activeButtonStyle).toMatchObject({
top: defaultButtonStyle.top + 2,
height: 38,
backgroundColor: 'rgb(0, 88, 44)',
borderBottomWidth: '2px',
borderBottomColor: 'rgb(0, 79, 40)',
marginTop: '2px',
});
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-active-desktop.png'), fullPage: true });
await page.mouse.up();
const setRandomValues = async (values: number[]) => {
+104 -14
View File
@@ -3,6 +3,13 @@ import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32029,
data: { code: 'TOO_MANY_REQUESTS', httpStatus: 429, path },
},
});
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
@@ -21,6 +28,8 @@ type NavigationFixture = {
operations: string[];
generalName?: string;
generalTurnTime?: string;
serverTime?: string;
clockMode?: 'realtime' | 'manual';
cityDefence?: number;
cityState?: number;
nationRate?: number;
@@ -31,6 +40,7 @@ type NavigationFixture = {
commandBlockedCount?: number;
forceSnapshotCalls?: number;
refreshDelayMs?: number;
accessLimitAfterCalls?: number;
largeCommandTable?: boolean;
refCommandCategories?: boolean;
currentYear?: number;
@@ -363,11 +373,21 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
year: state.currentYear ?? 185,
month: state.currentMonth ?? 1,
turnTerm: 10,
serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
clockMode: state.clockMode ?? 'realtime',
scenarioTitle: state.scenarioTitle ?? '',
});
}
if (operation === 'dashboard.getContextBundleDelta') {
state.generalMeCalls += 1;
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
return errorResponse(
operation,
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
'(다음 접속 가능 시각: 2026-08-15 12:34:56) ' +
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.'
);
}
const input = operationInput(route, index);
const include = input.include ?? {};
const forceSnapshot = input.forceSnapshot === true;
@@ -499,7 +519,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
operations.forEach((operation, index) => {
if (operation !== 'dashboard.getContextBundleDelta') return;
const item = results[index];
if (!item) return;
if (!item || !('result' in item)) return;
const data = item.result.data as {
context?: { kind: string };
commandTable?: { kind: string };
@@ -787,7 +807,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('main general card and command clock render the next turn with second precision', async ({ page }) => {
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
@@ -797,22 +817,29 @@ test('main general card and command clock render the next turn with second preci
generalMeCalls: 0,
operations: [],
generalName: 'Administrator',
generalTurnTime: '2026-08-13T00:07:06.713Z',
generalTurnTime: '2026-08-13T00:09:10.713Z',
serverTime: '2026-08-13T00:07:06.250Z',
clockMode: 'realtime',
currentYear: 179,
currentMonth: 8,
};
await installFixture(page, state);
await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') });
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' });
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const title = page.locator('[data-main-target="general"] .general-title').first();
await expect(title).toContainText('Administrator');
await expect(title).toContainText('용장');
await expect(title).toContainText('09:07:06');
await expect(title).not.toContainText('00:07');
await expect(title).toContainText('09:09:10');
await expect(title).not.toContainText('00:09');
const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
await expect(commandClock).toHaveText('09:07:06');
await expect(commandClock).not.toHaveText('00:07');
await page.clock.runFor(1_000);
await expect(commandClock).toHaveText('09:07:07');
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
await expect(generalCard).toContainText('수비 함(훈사80)');
await expect(generalCard).toContainText('5 턴');
@@ -857,9 +884,9 @@ test('main general card and command clock render the next turn with second preci
const target = resolve(artifactRoot);
await mkdir(target, { recursive: true });
await Promise.all([
page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }),
page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }),
writeFile(
resolve(target, 'main-turn-time-seoul-desktop-1200.json'),
resolve(target, 'main-turn-time-local-desktop-1200.json'),
`${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
),
]);
@@ -867,9 +894,9 @@ test('main general card and command clock render the next turn with second preci
await page.setViewportSize({ width: 500, height: 900 });
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
await expect(mobileTitle).toContainText('09:07:06');
await expect(mobileTitle).toContainText('09:09:10');
const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
await expect(mobileCommandClock).toHaveText('09:07:06');
await expect(mobileCommandClock).toHaveText('09:07:07');
const mobileGeometry = {
title: await mobileTitle.evaluate((element) => ({
width: element.getBoundingClientRect().width,
@@ -895,15 +922,23 @@ test('main general card and command clock render the next turn with second preci
if (artifactRoot) {
await Promise.all([
page.screenshot({
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'),
fullPage: true,
}),
writeFile(
resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.json'),
resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'),
`${JSON.stringify(mobileGeometry, null, 2)}\n`
),
]);
}
state.clockMode = 'manual';
state.serverTime = '2026-08-13T00:08:30.000Z';
await page.reload();
const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
await expect(frozenClock).toHaveText('09:08:30');
await page.clock.runFor(2_000);
await expect(frozenClock).toHaveText('09:08:30');
});
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
@@ -2041,7 +2076,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
});
await expect
.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
.toEqual(['general.getFrontStatus']);
.toEqual(['dashboard.getContextBundleDelta', 'general.getFrontStatus']);
const profile = await page.evaluate(() => {
const probe = (
@@ -2199,6 +2234,55 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
});
test('access limit stops automatic main refresh and closes realtime until a manual retry can pass', async ({
page,
}) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
accessLimitAfterCalls: 1,
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
const operationsBeforeLimit = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(false);
expect(state.operations.slice(operationsBeforeLimit)).toEqual(['dashboard.getContextBundleDelta']);
const operationsAfterLimit = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
await new Promise((resolve) => setTimeout(resolve, 300));
expect(state.operations).toHaveLength(operationsAfterLimit);
state.accessLimitAfterCalls = undefined;
await page.getByRole('button', { name: '갱 신' }).click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
});
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
@@ -2226,7 +2310,10 @@ test('global activity, world history, and a month boundary refresh their visible
const operationsBeforeGlobal = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']);
expect(state.operations.slice(operationsBeforeGlobal)).toEqual([
'dashboard.getContextBundleDelta',
'general.getRecentRecords',
]);
state.worldHistory = [
{ id: 5, text: '자동 갱신된 중원 정세' },
@@ -2235,7 +2322,10 @@ test('global activity, world history, and a month boundary refresh their visible
const operationsBeforeHistory = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
expect(state.operations.slice(operationsBeforeHistory)).toEqual([
'dashboard.getContextBundleDelta',
'general.getRecentRecords',
]);
state.currentMonth = 2;
const operationsBeforeMonth = state.operations.length;
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import type {
CommandMapData,
CommandMapLayout,
@@ -19,6 +19,8 @@ const props = defineProps<{
currentYear?: number;
currentMonth?: number;
turnTermMinutes?: number;
serverTime?: string;
clockMode?: 'realtime' | 'manual';
autorunLimit?: number | null;
storageKey?: string;
mapData?: CommandMapData | null;
@@ -56,16 +58,48 @@ const rows = computed<ReservedCommandRow[]>(() => {
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
time: date
? term >= 5
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
: `${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')}`
? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
: `${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
: '--:--',
};
});
});
const currentTurnTime = computed(() =>
props.general?.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '--:--:--'
const currentServerTime = ref('--:--:--');
let sampledServerTimeMs: number | null = null;
let sampledClientTimeMs = 0;
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
const updateServerClock = () => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
serverClockTimer = undefined;
if (sampledServerTimeMs === null) {
currentServerTime.value = '--:--:--';
return;
}
const projectedTime = new Date(
props.clockMode === 'manual' ? sampledServerTimeMs : sampledServerTimeMs + Date.now() - sampledClientTimeMs
);
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (props.clockMode !== 'manual') {
serverClockTimer = setTimeout(updateServerClock, 1_000 - projectedTime.getMilliseconds());
}
};
watch(
() => [props.serverTime, props.clockMode] as const,
([serverTime]) => {
const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN;
sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null;
sampledClientTimeMs = Date.now();
updateServerClock();
},
{ immediate: true }
);
onUnmounted(() => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
});
</script>
<template>
@@ -75,7 +109,7 @@ const currentTurnTime = computed(() =>
:command-table="props.commandTable"
:loading="props.loading"
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
:current-time="currentTurnTime"
:current-time="currentServerTime"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('set-general-turns', $event)"
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
@@ -232,7 +232,7 @@ const specialText = computed(() => {
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span>
<span data-general-turn-time>{{
props.general.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '-'
props.general.turnTime ? formatLocalTimeSeconds(props.general.turnTime) : '-'
}}</span>
</div>
+29 -17
View File
@@ -82,6 +82,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const realtimeEnabled = ref(true);
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
const realtimeActive = ref(false);
const accessLimited = ref(false);
const handleDashboardError = (value: unknown) => {
const message = resolveErrorMessage(value);
error.value = message;
if (message.startsWith('접속 제한중입니다.')) {
accessLimited.value = true;
realtimeStatus.value = 'paused';
}
};
const general = ref<PresentGeneralContext['general'] | null>(null);
const city = ref<PresentGeneralContext['city'] | null>(null);
@@ -508,6 +518,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
{ context: true, commandTable: true, boardAccess: true },
true
);
accessLimited.value = false;
applyDashboardPatch(contextPatch);
const context = contextSnapshot;
@@ -573,7 +584,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
initialized = true;
} catch (err) {
error.value = resolveErrorMessage(err);
handleDashboardError(err);
} finally {
if (isInitialLoad) {
loading.value = false;
@@ -626,14 +637,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (plan.records) recordsError.value = null;
if (plan.frontStatus) frontStatusError.value = null;
try {
const contextBundlePromise =
plan.context || plan.commands || plan.boardAccess
? fetchContextBundlePatch({
context: plan.context,
commandTable: plan.commands,
boardAccess: plan.boardAccess,
})
: Promise.resolve(undefined);
const contextPatch = await fetchContextBundlePatch({
// Every automatic refresh crosses this access-limit gate. The
// context delta is usually unchanged and therefore stays small.
context: true,
commandTable: plan.commands,
boardAccess: plan.boardAccess,
});
accessLimited.value = false;
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
const mapPromise = plan.map
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
@@ -659,8 +670,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
})
: Promise.resolve(undefined);
const [contextPatch, lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
contextBundlePromise,
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
lobbyPromise,
mapPromise,
contactsPromise,
@@ -669,7 +679,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
frontPromise,
]);
const patch: DashboardReadModelPatch = contextPatch ? { ...contextPatch } : {};
const patch: DashboardReadModelPatch = { ...contextPatch };
if (lobby !== undefined) patch.lobbyInfo = lobby;
if (map !== undefined) patch.worldMap = map;
if (contacts !== undefined) patch.messageContacts = contacts;
@@ -690,7 +700,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
error.value = resolveErrorMessage(err);
handleDashboardError(err);
} finally {
refreshing.value = false;
}
@@ -709,7 +719,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
error.value = resolveErrorMessage(err);
handleDashboardError(err);
}
};
@@ -970,6 +980,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
realtimeActive.value &&
document.visibilityState !== 'hidden' &&
realtimeEnabled.value &&
!accessLimited.value &&
session.isReady &&
session.hasGeneral &&
generalId.value !== null;
@@ -1158,11 +1169,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
session.profile,
session.user?.id,
generalId.value,
accessLimited.value,
],
([active, enabled, ready, hasGeneral]) => {
realtimeStatus.value = !enabled ? 'paused' : realtimeStatus.value;
([active, enabled, ready, hasGeneral, , , , , limited]) => {
realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;
if (!active || !ready || !hasGeneral) {
realtimeStatus.value = enabled ? 'idle' : 'paused';
realtimeStatus.value = enabled && !limited ? 'idle' : 'paused';
}
reconcileRealtimeCoordinator();
}
@@ -7,3 +7,11 @@ export const formatSeoulHourMinute = (value: string | Date): string =>
export const formatSeoulTimeSeconds = (value: string | Date): string =>
formatServerDateTime(value, { format: 'timeSeconds' });
export const formatLocalTimeSeconds = (value: string | Date): string => {
const parsed = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(parsed.getTime())) return '-';
return [parsed.getHours(), parsed.getMinutes(), parsed.getSeconds()]
.map((part) => String(part).padStart(2, '0'))
.join(':');
};
+27 -16
View File
@@ -726,10 +726,26 @@ onUnmounted(() => {
</div>
<div class="stat-actions" role="group" aria-label="능력치 빠른 설정">
<button type="button" @click="applyRandomStats">랜덤형</button>
<button type="button" @click="applyLeadpowStats">통솔무력형</button>
<button type="button" @click="applyLeadintStats">통솔지력형</button>
<button type="button" @click="applyPowintStats">무력지력형</button>
<button class="legacy-button legacy-button--navigation" type="button" @click="applyRandomStats">
랜덤형
</button>
<button
class="legacy-button legacy-button--navigation"
type="button"
@click="applyLeadpowStats"
>
통솔무력형
</button>
<button
class="legacy-button legacy-button--navigation"
type="button"
@click="applyLeadintStats"
>
통솔지력형
</button>
<button class="legacy-button legacy-button--navigation" type="button" @click="applyPowintStats">
무력지력형
</button>
</div>
<div v-if="accountIcons.length" class="icon-choice">
@@ -1385,26 +1401,21 @@ onUnmounted(() => {
.stat-actions button {
flex: 1 1 140px;
height: 40px;
min-height: 40px;
border: 1px solid #004f28;
border-radius: 3px;
background: #00582c;
padding: 8px 14px;
color: #fff;
font-size: 0.875rem;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.stat-actions button:hover {
border-color: #005f30;
background: #006d37;
.stat-actions button:not(:disabled):hover {
height: 39px;
min-height: 39px;
}
.stat-actions button:active {
border-color: #003d1f;
background: #004523;
.stat-actions button:not(:disabled):active {
height: 38px;
min-height: 38px;
}
.stat-summary {
+4
View File
@@ -205,6 +205,8 @@ watch(
:current-year="lobbyInfo?.year"
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
:server-time="lobbyInfo?.serverTime"
:clock-mode="lobbyInfo?.clockMode"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@@ -331,6 +333,8 @@ watch(
:current-year="lobbyInfo?.year"
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
:server-time="lobbyInfo?.serverTime"
:clock-mode="lobbyInfo?.clockMode"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
+16 -1
View File
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatSeoulDateTime, formatSeoulHourMinute, formatSeoulTimeSeconds } from '../src/utils/legacyDateTime.ts';
import {
formatLocalTimeSeconds,
formatSeoulDateTime,
formatSeoulHourMinute,
formatSeoulTimeSeconds,
} from '../src/utils/legacyDateTime.ts';
void test('formats API UTC timestamps in the server Seoul timezone', () => {
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
@@ -14,3 +19,13 @@ void test('keeps legacy timezone-less server timestamps unchanged', () => {
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
});
void test('formats an ISO instant with the client local clock', () => {
const instant = new Date('2026-08-13T00:07:06.713Z');
const expected = [instant.getHours(), instant.getMinutes(), instant.getSeconds()]
.map((part) => String(part).padStart(2, '0'))
.join(':');
assert.equal(formatLocalTimeSeconds(instant), expected);
assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected);
assert.equal(formatLocalTimeSeconds('invalid'), '-');
});
@@ -0,0 +1,44 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import type { GatewayProfileRepository } from '../orchestrator/profileRepository.js';
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1';
const deriveInternalToken = (secret: string): string =>
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
const candidate = Array.isArray(provided) ? provided[0] : provided;
if (!candidate) {
return false;
}
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
};
export const registerProfileStatusInternalRoute = (
app: FastifyInstance,
options: {
profiles: GatewayProfileRepository;
secret: string;
}
): void => {
app.get<{ Params: { profileName: string } }>('/internal/profile-status/:profileName', async (request, reply) => {
void reply.header('Cache-Control', 'no-store');
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
const profileName = request.params.profileName;
const profile = await options.profiles.getProfile(profileName);
if (!profile) {
await reply.status(404).send({ ok: false, error: 'not_found' });
return;
}
await reply.send({ profileName: profile.profileName, status: profile.status });
});
};
+5
View File
@@ -26,6 +26,7 @@ import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRep
import { appRouter } from './router.js';
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
import { registerProfileStatusInternalRoute } from './lobby/profileStatusInternalRoute.js';
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
@@ -98,6 +99,10 @@ export const createGatewayApiServer = async () => {
users,
secret: config.gameTokenSecret,
});
registerProfileStatusInternalRoute(app, {
profiles,
secret: config.gameTokenSecret,
});
await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath,
@@ -0,0 +1,50 @@
import { createHmac } from 'node:crypto';
import fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
import { registerProfileStatusInternalRoute } from '../src/lobby/profileStatusInternalRoute.js';
const secret = 'gateway-profile-status-test-secret';
const token = createHmac('sha256', secret).update('sammo:profile-status-source:v1').digest('hex');
describe('profile status internal route', () => {
it('requires a purpose-derived token and returns only the durable status', async () => {
const app = fastify();
const profiles = {
getProfile: vi.fn(async (profileName: string) => ({ profileName, status: 'PAUSED' })),
} as unknown as GatewayProfileRepository;
registerProfileStatusInternalRoute(app, { profiles, secret });
const unauthorized = await app.inject({
method: 'GET',
url: '/internal/profile-status/che%3Adefault',
headers: { 'x-sammo-internal-token': secret },
});
expect(unauthorized.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: '/internal/profile-status/che%3Adefault',
headers: { 'x-sammo-internal-token': token },
});
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.json()).toEqual({ profileName: 'che:default', status: 'PAUSED' });
expect(Object.keys(response.json()).sort()).toEqual(['profileName', 'status']);
});
it('returns 404 for an unknown profile', async () => {
const app = fastify();
const profiles = { getProfile: vi.fn(async () => null) } as unknown as GatewayProfileRepository;
registerProfileStatusInternalRoute(app, { profiles, secret });
const response = await app.inject({
method: 'GET',
url: '/internal/profile-status/missing',
headers: { 'x-sammo-internal-token': token },
});
expect(response.statusCode).toBe(404);
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
});
});
+28
View File
@@ -0,0 +1,28 @@
export const ACCESS_REFRESH_LIMIT_COEFFICIENT = 10;
export type AccessLimitLevel = 0 | 1 | 2;
export const calculateAccessRefreshLimit = (tickSeconds: number): number => {
if (!Number.isFinite(tickSeconds) || tickSeconds <= 0) {
throw new RangeError('tickSeconds must be a positive finite number.');
}
const turnMinutes = tickSeconds / 60;
return Math.round(Math.pow(turnMinutes, 0.6) * 3) * ACCESS_REFRESH_LIMIT_COEFFICIENT;
};
export const resolveAccessRefreshLimit = (tickSeconds: number, storedLimit: unknown): number => {
if (typeof storedLimit === 'number' && Number.isSafeInteger(storedLimit) && storedLimit > 0) {
return storedLimit;
}
return calculateAccessRefreshLimit(tickSeconds);
};
export const resolveAccessLimitLevel = (refreshScore: number, refreshLimit: number): AccessLimitLevel => {
if (refreshScore > refreshLimit) {
return 2;
}
if (refreshScore > refreshLimit * 0.9) {
return 1;
}
return 0;
};
+1
View File
@@ -23,3 +23,4 @@ export * from './ranking/legacyColor.js';
export * from './auth/accountIconProjection.js';
export * from './logging/formatLegacyLogHtml.js';
export * from './gateway/profileStatus.js';
export * from './game/accessPenalty.js';
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
calculateAccessRefreshLimit,
resolveAccessLimitLevel,
resolveAccessRefreshLimit,
} from '../src/game/accessPenalty.js';
describe('legacy access penalty', () => {
it.each([
[60, 30],
[300, 80],
[600, 120],
[1_200, 180],
])('calculates the Ref refresh limit for %i-second turns', (tickSeconds, expected) => {
expect(calculateAccessRefreshLimit(tickSeconds)).toBe(expected);
});
it('keeps a persisted positive limit and derives a missing one', () => {
expect(resolveAccessRefreshLimit(600, 777)).toBe(777);
expect(resolveAccessRefreshLimit(600, undefined)).toBe(120);
expect(resolveAccessRefreshLimit(600, 0)).toBe(120);
});
it('uses the strict Ref threshold and warns only above ninety percent', () => {
expect(resolveAccessLimitLevel(108, 120)).toBe(0);
expect(resolveAccessLimitLevel(109, 120)).toBe(1);
expect(resolveAccessLimitLevel(120, 120)).toBe(1);
expect(resolveAccessLimitLevel(121, 120)).toBe(2);
});
});
+3
View File
@@ -11,6 +11,7 @@
"che_사기진작",
"che_요양",
"che_견문",
"che_은퇴",
"che_내정특기초기화",
"che_전투특기초기화",
"che_장비매매",
@@ -37,6 +38,8 @@
"che_증여",
"che_헌납",
"che_이동",
"che_강행",
"che_하야",
"che_선양",
"che_해산",
"휴식"