fix: align legacy general access call boundaries
This commit is contained in:
@@ -71,6 +71,7 @@ export type DatabaseClient = InfraDatabaseClient;
|
||||
|
||||
export interface GameApiContext {
|
||||
requestId?: string;
|
||||
generalAccessTracking?: boolean;
|
||||
db: DatabaseClient;
|
||||
redis: RedisConnector['client'];
|
||||
turnDaemon: TurnDaemonTransport;
|
||||
@@ -102,6 +103,7 @@ export const createGameApiContext = (options: {
|
||||
}): GameApiContext => {
|
||||
return {
|
||||
requestId: options.requestId,
|
||||
generalAccessTracking: true,
|
||||
db: options.db,
|
||||
redis: options.redis,
|
||||
turnDaemon: options.turnDaemon,
|
||||
|
||||
@@ -4,7 +4,13 @@ import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { getDexLevel } from '@sammo-ts/logic';
|
||||
|
||||
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
accessAuthedInputProcedure,
|
||||
accessReadOnlyAuthedInputProcedure,
|
||||
authedProcedure,
|
||||
readOnlyAuthedProcedure,
|
||||
router,
|
||||
} from '../../trpc.js';
|
||||
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
||||
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
||||
import {
|
||||
@@ -56,7 +62,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
|
||||
};
|
||||
|
||||
export const battleRouter = router({
|
||||
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
@@ -169,8 +175,7 @@ export const battleRouter = router({
|
||||
generalsByNation,
|
||||
};
|
||||
}),
|
||||
getGeneralDetail: authedProcedure
|
||||
.input(
|
||||
getGeneralDetail: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { appendInheritanceLog, readInheritancePoint, setInheritancePoint } from '../../services/inheritance.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
@@ -30,8 +30,7 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
|
||||
};
|
||||
|
||||
export const bettingRouter = router({
|
||||
getList: authedProcedure
|
||||
.input(z.object({ req: z.literal('bettingNation').optional() }).optional())
|
||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
requireUserId(ctx.auth);
|
||||
await getMyGeneral(ctx);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { promises as fs } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import sharp, { type WebpOptions } from 'sharp';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
@@ -107,7 +107,7 @@ export const boardRouter = router({
|
||||
canSecret: permission >= 2,
|
||||
};
|
||||
}),
|
||||
getArticles: authedProcedure.input(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
|
||||
getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
|
||||
const { general, permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, input.isSecret);
|
||||
|
||||
@@ -159,8 +159,7 @@ export const boardRouter = router({
|
||||
})),
|
||||
}));
|
||||
}),
|
||||
writeArticle: authedProcedure
|
||||
.input(
|
||||
writeArticle: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
isSecret: z.boolean(),
|
||||
title: z.string().trim().max(250),
|
||||
@@ -189,8 +188,7 @@ export const boardRouter = router({
|
||||
|
||||
return { id: post.id };
|
||||
}),
|
||||
writeComment: authedProcedure
|
||||
.input(
|
||||
writeComment: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
postId: z.number().int().positive(),
|
||||
content: z.string().trim().max(2000),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
|
||||
@@ -28,7 +28,7 @@ const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' |
|
||||
};
|
||||
|
||||
export const diplomacyRouter = router({
|
||||
getLetters: authedProcedure.query(async ({ ctx }) => {
|
||||
getLetters: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
@@ -98,8 +98,7 @@ export const diplomacyRouter = router({
|
||||
permission,
|
||||
};
|
||||
}),
|
||||
sendLetter: authedProcedure
|
||||
.input(
|
||||
sendLetter: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
destNationId: z.number().int().positive(),
|
||||
prevId: z.number().int().positive().nullable().optional(),
|
||||
@@ -217,8 +216,7 @@ export const diplomacyRouter = router({
|
||||
|
||||
return { id: created.id };
|
||||
}),
|
||||
respondLetter: authedProcedure
|
||||
.input(
|
||||
respondLetter: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
letterId: z.number().int().positive(),
|
||||
agree: z.boolean(),
|
||||
@@ -288,8 +286,7 @@ export const diplomacyRouter = router({
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
rollbackLetter: authedProcedure
|
||||
.input(z.object({ letterId: z.number().int().positive() }))
|
||||
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
@@ -324,8 +321,7 @@ export const diplomacyRouter = router({
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
destroyLetter: authedProcedure
|
||||
.input(z.object({ letterId: z.number().int().positive() }))
|
||||
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
@@ -5,7 +5,14 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
accessAuthedProcedure,
|
||||
accessAuthedInputProcedure,
|
||||
accessEngineAuthedProcedure,
|
||||
accessEngineAuthedInputProcedure,
|
||||
authedProcedure,
|
||||
router,
|
||||
} from '../../trpc.js';
|
||||
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
|
||||
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
@@ -283,7 +290,7 @@ export const generalRouter = router({
|
||||
penalties,
|
||||
};
|
||||
}),
|
||||
ensureDieOnPrestartStatus: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
@@ -319,14 +326,11 @@ export const generalRouter = router({
|
||||
availableAt: result.availableAt ?? null,
|
||||
};
|
||||
}),
|
||||
dieOnPrestart: engineAuthedProcedure
|
||||
.input(zImmediateActionInput)
|
||||
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput)
|
||||
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')),
|
||||
buildNationCandidate: engineAuthedProcedure
|
||||
.input(zImmediateActionInput)
|
||||
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput)
|
||||
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
|
||||
instantRetreat: engineAuthedProcedure
|
||||
.input(zImmediateActionInput)
|
||||
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput)
|
||||
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'instantRetreat')),
|
||||
vacation: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
@@ -342,7 +346,7 @@ export const generalRouter = router({
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
setMySetting: authedProcedure.input(zGeneralSettings).mutation(async ({ ctx, input }) => {
|
||||
setMySetting: accessAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
@@ -459,7 +463,7 @@ export const generalRouter = router({
|
||||
history: trimRecentRecords(history, input.lastWorldHistoryId),
|
||||
};
|
||||
}),
|
||||
getFrontStatus: authedProcedure.query(async ({ ctx }) => {
|
||||
getFrontStatus: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
const worldState = await ctx.db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
|
||||
@@ -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 { authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
MESSAGE_MAILBOX_PUBLIC,
|
||||
@@ -388,8 +388,7 @@ export const messagesRouter = router({
|
||||
...messageBuckets,
|
||||
};
|
||||
}),
|
||||
send: authedProcedure
|
||||
.input(
|
||||
send: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
mailbox: z.number().int(),
|
||||
|
||||
@@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { LogCategory } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, formatDateTime, resolveNationPermission } from '../shared.js';
|
||||
|
||||
export const getBattleCenter = authedProcedure.query(async ({ ctx }) => {
|
||||
export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { resolveSecretPermission } from '../../shared/secretPermission.js';
|
||||
import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js';
|
||||
import { assertNationAccess } from '../shared.js';
|
||||
|
||||
export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
|
||||
export const getChiefCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import {
|
||||
assertNationAccess,
|
||||
@@ -18,7 +18,7 @@ const experienceLevel = (experience: number): number =>
|
||||
const dedicationLevel = (dedication: number): number =>
|
||||
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10)));
|
||||
|
||||
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||
export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
assertNationAccess(general);
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, checkSecretMaxPermission, mapGeneralList, resolveChiefStatMin } from '../shared.js';
|
||||
|
||||
export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../shared.js';
|
||||
|
||||
@@ -25,7 +25,7 @@ const leadershipBonus = (officerLevel: number, nationLevel: number): number =>
|
||||
const defenceTrainText = (value: number): string =>
|
||||
value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△';
|
||||
|
||||
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||
export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome, type NationIncomeContext } from '@sammo-ts/logic';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import {
|
||||
assertNationAccess,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type NationStratRow,
|
||||
} from '../shared.js';
|
||||
|
||||
export const getStratFinan = authedProcedure.query(async ({ ctx }) => {
|
||||
export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
@@ -537,7 +537,7 @@ export const npcRouter = router({
|
||||
permissionLevel,
|
||||
};
|
||||
}),
|
||||
setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
|
||||
setNationPolicy: accessAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
@@ -700,7 +700,7 @@ export const npcRouter = router({
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
setNationPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
@@ -753,7 +753,7 @@ export const npcRouter = router({
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
setGeneralPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
|
||||
type WorldTrendSnapshot = {
|
||||
@@ -477,8 +477,7 @@ export const publicRouter = router({
|
||||
intelligence: general.intel,
|
||||
}));
|
||||
}),
|
||||
getNpcList: procedure
|
||||
.input(
|
||||
getNpcList: accessInputProcedure(
|
||||
z
|
||||
.object({
|
||||
sort: z.number().int().min(1).max(8).catch(1).optional(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
|
||||
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
|
||||
|
||||
const DEFAULT_BG_COLOR = '#330000';
|
||||
const DEFAULT_FG_COLOR = '#ffffff';
|
||||
@@ -81,8 +81,7 @@ const loadUniqueItems = () => {
|
||||
};
|
||||
|
||||
export const rankingRouter = router({
|
||||
getBestGeneral: authedProcedure
|
||||
.input(
|
||||
getBestGeneral: accessAuthedInputProcedure(
|
||||
z
|
||||
.object({
|
||||
view: z.enum(['user', 'npc']).optional(),
|
||||
@@ -369,8 +368,7 @@ export const rankingRouter = router({
|
||||
}
|
||||
return Array.from(seasonMap.values());
|
||||
}),
|
||||
getHallOfFame: procedure
|
||||
.input(
|
||||
getHallOfFame: accessInputProcedure(
|
||||
z.object({
|
||||
season: z.number().int(),
|
||||
scenario: z.number().int().optional(),
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
import { TournamentStore } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
@@ -127,7 +127,7 @@ export const tournamentRouter = router({
|
||||
return store.getState();
|
||||
}),
|
||||
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
||||
getSnapshot: authedProcedure.query(async ({ ctx }) => {
|
||||
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, participants, matches, bets] = await Promise.all([
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
const troopNameSchema = z
|
||||
@@ -33,7 +33,7 @@ const assertCommandResult = <T extends 'troopCreate' | 'troopJoin' | 'troopExit'
|
||||
};
|
||||
|
||||
export const troopRouter = router({
|
||||
getList: authedProcedure.query(async ({ ctx }) => {
|
||||
getList: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
if (me.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure } from '../../trpc.js';
|
||||
import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
@@ -204,8 +204,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const getGeneralDirectory = authedProcedure
|
||||
.input(z.object({ sort: zDirectorySort }).optional())
|
||||
export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const sort = input?.sort ?? 9;
|
||||
|
||||
@@ -2,8 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure } from '../../trpc.js';
|
||||
import { accessAuthedProcedure, authedProcedure, procedure, router } from '../../trpc.js';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { loadWorldMap } from '../../maps/worldMap.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
@@ -71,7 +70,7 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||
export const worldRouter = router({
|
||||
getNationDirectory,
|
||||
getGeneralDirectory,
|
||||
getGlobalInfo: authedProcedure.query(async ({ ctx }) => {
|
||||
getGlobalInfo: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
const [nations, cities, diplomacy, map] = await Promise.all([
|
||||
ctx.db.nation.findMany({ where: { level: { gt: 0 } } }),
|
||||
|
||||
@@ -7,6 +7,10 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
||||
import {
|
||||
generalAccessEndpointWeights,
|
||||
recordGeneralAccessWeight,
|
||||
} from '../../services/generalAccess.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
@@ -24,6 +28,13 @@ const zServerId = z.string().trim().min(1).max(64);
|
||||
|
||||
const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
|
||||
const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
|
||||
if (ctx.generalAccessTracking !== true) {
|
||||
return;
|
||||
}
|
||||
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
|
||||
};
|
||||
|
||||
const parseTextArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
@@ -257,6 +268,10 @@ export const yearbookRouter = router({
|
||||
}
|
||||
const targetProfileName = input.serverID ?? ctx.profile.name;
|
||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
||||
const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash);
|
||||
if (isCurrentProfile && !shouldRecordAfterHashCheck) {
|
||||
await recordHistoryAccess(ctx);
|
||||
}
|
||||
|
||||
const isCurrent =
|
||||
isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month;
|
||||
@@ -280,6 +295,9 @@ export const yearbookRouter = router({
|
||||
if (input.hash && input.hash === hash) {
|
||||
return { notModified: true, hash };
|
||||
}
|
||||
if (shouldRecordAfterHashCheck) {
|
||||
await recordHistoryAccess(ctx);
|
||||
}
|
||||
return { notModified: false, hash, data };
|
||||
}
|
||||
|
||||
@@ -317,6 +335,9 @@ export const yearbookRouter = router({
|
||||
if (input.hash && input.hash === hash) {
|
||||
return { notModified: true, hash };
|
||||
}
|
||||
if (shouldRecordAfterHashCheck) {
|
||||
await recordHistoryAccess(ctx);
|
||||
}
|
||||
return { notModified: false, hash, data };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,59 +4,86 @@ import { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameApiContext } from '../context.js';
|
||||
|
||||
export const accessPages = [
|
||||
'front-info',
|
||||
'nation-info',
|
||||
'nation-cities',
|
||||
'global-info',
|
||||
'nation-list',
|
||||
'general-list',
|
||||
'current-city',
|
||||
'diplomacy',
|
||||
'nation-generals',
|
||||
'nation-personnel',
|
||||
'nation-finance',
|
||||
'battle-center',
|
||||
'board',
|
||||
'best-general',
|
||||
'hall-of-fame',
|
||||
'dynasty',
|
||||
'yearbook',
|
||||
'nation-betting',
|
||||
'traffic',
|
||||
'npc-list',
|
||||
'my-page',
|
||||
'npc-control',
|
||||
'tournament',
|
||||
'betting',
|
||||
] as const;
|
||||
|
||||
export type AccessPage = (typeof accessPages)[number];
|
||||
|
||||
export const accessPageWeights: Record<AccessPage, number> = {
|
||||
'front-info': 1,
|
||||
'nation-info': 1,
|
||||
'nation-cities': 1,
|
||||
'global-info': 1,
|
||||
'nation-list': 2,
|
||||
'general-list': 2,
|
||||
'current-city': 1,
|
||||
diplomacy: 1,
|
||||
'nation-generals': 1,
|
||||
'nation-personnel': 1,
|
||||
'nation-finance': 1,
|
||||
'battle-center': 1,
|
||||
board: 1,
|
||||
'best-general': 1,
|
||||
'hall-of-fame': 1,
|
||||
dynasty: 1,
|
||||
yearbook: 1,
|
||||
'nation-betting': 1,
|
||||
traffic: 1,
|
||||
'npc-list': 2,
|
||||
'my-page': 1,
|
||||
'npc-control': 1,
|
||||
tournament: 1,
|
||||
betting: 1,
|
||||
};
|
||||
|
||||
export const generalAccessEndpointWeights = {
|
||||
'world.getGeneralDirectory': 2,
|
||||
'public.getNpcList': 2,
|
||||
'ranking.getBestGeneral': 1,
|
||||
'ranking.getHallOfFame': 1,
|
||||
'tournament.getSnapshot': 1,
|
||||
'nation.getSecretGeneralList': 1,
|
||||
'nation.getPersonnelInfo': 1,
|
||||
'nation.getGeneralList': 1,
|
||||
'general.ensureDieOnPrestartStatus': 1,
|
||||
'nation.getStratFinan': 1,
|
||||
'board.getArticles': 1,
|
||||
'diplomacy.getLetters': 2,
|
||||
'battle.getGeneralDetail': 1,
|
||||
'betting.getList': 1,
|
||||
'general.getFrontStatus': 1,
|
||||
'yearbook.getHistory': 1,
|
||||
'world.getGlobalInfo': 1,
|
||||
'nation.getBattleCenter': 1,
|
||||
'nation.getChiefCenter': 1,
|
||||
'troop.getList': 1,
|
||||
'board.writeArticle': 1,
|
||||
'board.writeComment': 1,
|
||||
'diplomacy.sendLetter': 1,
|
||||
'diplomacy.respondLetter': 1,
|
||||
'diplomacy.rollbackLetter': 1,
|
||||
'diplomacy.destroyLetter': 1,
|
||||
'general.buildNationCandidate': 1,
|
||||
'general.dieOnPrestart': 1,
|
||||
'general.instantRetreat': 1,
|
||||
'messages.send': 1,
|
||||
'general.setMySetting': 0,
|
||||
'npc.setNationPolicy': 0,
|
||||
'npc.setNationPriority': 0,
|
||||
'npc.setGeneralPriority': 0,
|
||||
'battle.simulate': 0,
|
||||
} as const satisfies Record<string, 0 | 1 | 2>;
|
||||
|
||||
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
|
||||
|
||||
export const resolveGeneralAccessEndpointWeight = (
|
||||
path: string,
|
||||
input: unknown,
|
||||
currentProfileName: string
|
||||
): 0 | 1 | 2 | null | undefined => {
|
||||
const weight = generalAccessEndpointWeights[path as GeneralAccessEndpoint];
|
||||
if (weight === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (path === 'yearbook.getHistory') {
|
||||
const requestedServer =
|
||||
typeof input === 'object' && input !== null && 'serverID' in input
|
||||
? (input as { serverID?: unknown }).serverID
|
||||
: undefined;
|
||||
if (requestedServer !== undefined && requestedServer !== currentProfileName) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return weight;
|
||||
};
|
||||
|
||||
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||
@@ -262,7 +289,16 @@ export const recordGeneralAccess = async (
|
||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||
page: AccessPage,
|
||||
now = new Date()
|
||||
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
|
||||
|
||||
export const recordGeneralAccessWeight = async (
|
||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||
weight: number,
|
||||
now = new Date()
|
||||
): Promise<boolean> => {
|
||||
if (!Number.isInteger(weight) || weight < 0) {
|
||||
throw new RangeError('General access weight must be a non-negative integer.');
|
||||
}
|
||||
const user = ctx.auth?.user;
|
||||
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||
return false;
|
||||
@@ -296,7 +332,6 @@ export const recordGeneralAccess = async (
|
||||
return false;
|
||||
}
|
||||
|
||||
const weight = accessPageWeights[page];
|
||||
const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||
|
||||
await upsertGeneralAccess(ctx.db, {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
const t = initTRPC.context<GameApiContext>().create();
|
||||
|
||||
@@ -67,14 +68,48 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
||||
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
||||
// Router 단위 테스트의 부분 DB mock은 명시적으로 opt-in할 때만 계측한다.
|
||||
if (ctx.generalAccessTracking !== true) {
|
||||
return next();
|
||||
}
|
||||
const weight = resolveGeneralAccessEndpointWeight(path, input, ctx.profile.name);
|
||||
if (weight === undefined) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `General access endpoint is not registered: ${path}`,
|
||||
});
|
||||
}
|
||||
if (weight === null) {
|
||||
return next();
|
||||
}
|
||||
await recordGeneralAccessWeight(ctx, weight);
|
||||
return next();
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||
|
||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
|
||||
export const accessProcedure: typeof procedure = t.procedure
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
export const accessAuthedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
|
||||
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
|
||||
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
|
||||
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
|
||||
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalAccessEndpointMiddleware);
|
||||
|
||||
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
||||
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
||||
@@ -83,3 +118,17 @@ export const sessionActivityProcedure = t.procedure;
|
||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
export const accessReadOnlyAuthedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalAccessEndpointMiddleware);
|
||||
|
||||
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
|
||||
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
||||
export const accessInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
|
||||
export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
|
||||
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);
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
|
||||
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 9_980_071;
|
||||
const secondGeneralId = generalId + 1;
|
||||
const rollbackGeneralId = generalId + 2;
|
||||
const zeroWeightGeneralId = generalId + 3;
|
||||
const endpointGeneralId = generalId + 4;
|
||||
const endpointUserId = `access-endpoint-user-${endpointGeneralId}`;
|
||||
const scenarioCode = `traffic-period-${generalId}`;
|
||||
const endpointRequestPrefix = `access-endpoint-${endpointGeneralId}`;
|
||||
const yearbookProfile = `access-profile-${endpointGeneralId}`;
|
||||
|
||||
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: `${yearbookProfile}:default`,
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: `access-session-${endpointGeneralId}`,
|
||||
user: {
|
||||
id: endpointUserId,
|
||||
username: endpointUserId,
|
||||
displayName: endpointUserId,
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const endpointBoundaryRouter = router({
|
||||
world: router({
|
||||
getGeneralDirectory: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).query(() => ({
|
||||
ok: true,
|
||||
})),
|
||||
}),
|
||||
general: router({
|
||||
setMySetting: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => ({
|
||||
ok: true,
|
||||
})),
|
||||
}),
|
||||
board: router({
|
||||
writeArticle: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' });
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
@@ -21,8 +65,13 @@ integration('general access tracking persistence', () => {
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.generalAccessLog.deleteMany({
|
||||
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
|
||||
where: {
|
||||
generalId: {
|
||||
in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
@@ -35,12 +84,47 @@ integration('general access tracking persistence', () => {
|
||||
},
|
||||
});
|
||||
worldStateId = world.id;
|
||||
await db.general.deleteMany({ where: { id: endpointGeneralId } });
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: endpointGeneralId,
|
||||
userId: endpointUserId,
|
||||
name: '접속경계',
|
||||
turnTime: new Date('2026-07-26T03:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
|
||||
await db.yearbookHistory.create({
|
||||
data: {
|
||||
profileName: yearbookProfile,
|
||||
sourceId: 1,
|
||||
year: 184,
|
||||
month: 12,
|
||||
map: {
|
||||
year: 184,
|
||||
month: 12,
|
||||
startYear: 180,
|
||||
cityList: [],
|
||||
nationList: [],
|
||||
},
|
||||
nations: [],
|
||||
globalHistory: ['기록'],
|
||||
globalAction: ['행동'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.generalAccessLog.deleteMany({
|
||||
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
|
||||
where: {
|
||||
generalId: {
|
||||
in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
|
||||
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
|
||||
await db.general.deleteMany({ where: { id: endpointGeneralId } });
|
||||
await db.worldState.deleteMany({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
@@ -233,4 +317,151 @@ integration('general access tracking persistence', () => {
|
||||
refreshTotal: 2_147_483_647,
|
||||
});
|
||||
});
|
||||
|
||||
it('records weight zero membership and last refresh without changing counters', async () => {
|
||||
const now = new Date('2026-07-26T03:40:00.000Z');
|
||||
await upsertGeneralAccess(db, {
|
||||
worldStateId,
|
||||
year: 186,
|
||||
month: 2,
|
||||
tickSeconds: 600,
|
||||
generalId: zeroWeightGeneralId,
|
||||
userId: 'zero-weight-user',
|
||||
now,
|
||||
periodStartedAt: now,
|
||||
scoreStartedAt: now,
|
||||
weight: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: zeroWeightGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
userId: 'zero-weight-user',
|
||||
lastRefresh: now,
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
});
|
||||
await expect(
|
||||
db.trafficPeriod.findUniqueOrThrow({
|
||||
where: {
|
||||
worldStateId_year_month: {
|
||||
worldStateId,
|
||||
year: 186,
|
||||
month: 2,
|
||||
},
|
||||
},
|
||||
include: { generals: true },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
refresh: 0,
|
||||
online: 1,
|
||||
generals: [
|
||||
{
|
||||
generalId: zeroWeightGeneralId,
|
||||
userId: 'zero-weight-user',
|
||||
refresh: 0,
|
||||
lastRefresh: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('runs parser, access, business event, zero-weight, admin, and yearbook cache boundaries on PostgreSQL', async () => {
|
||||
const context = {
|
||||
auth: endpointAuth(),
|
||||
db,
|
||||
generalAccessTracking: true,
|
||||
requestId: endpointRequestPrefix,
|
||||
profile: {
|
||||
id: `${yearbookProfile}:default`,
|
||||
name: yearbookProfile,
|
||||
scenario: 'default',
|
||||
},
|
||||
} as unknown as GameApiContext;
|
||||
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
|
||||
|
||||
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
|
||||
|
||||
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refresh: 2,
|
||||
refreshTotal: 2,
|
||||
});
|
||||
|
||||
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refresh: 2,
|
||||
refreshTotal: 2,
|
||||
});
|
||||
|
||||
await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'business rejected',
|
||||
});
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refresh: 3,
|
||||
refreshTotal: 3,
|
||||
});
|
||||
|
||||
const adminCaller = endpointBoundaryRouter.createCaller({
|
||||
...context,
|
||||
auth: endpointAuth(['admin']),
|
||||
});
|
||||
await expect(adminCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refresh: 3,
|
||||
refreshTotal: 3,
|
||||
});
|
||||
|
||||
const yearbookCaller = appRouter.createCaller({
|
||||
...context,
|
||||
requestId: `${endpointRequestPrefix}-yearbook`,
|
||||
});
|
||||
const first = await yearbookCaller.yearbook.getHistory({ year: 184, month: 12 });
|
||||
expect(first.notModified).toBe(false);
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refresh: 4,
|
||||
refreshTotal: 4,
|
||||
});
|
||||
|
||||
const cached = await yearbookCaller.yearbook.getHistory({
|
||||
year: 184,
|
||||
month: 12,
|
||||
hash: first.hash,
|
||||
});
|
||||
expect(cached).toEqual({ notModified: true, hash: first.hash });
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refresh: 4,
|
||||
refreshTotal: 4,
|
||||
});
|
||||
|
||||
await yearbookCaller.yearbook.getHistory({
|
||||
year: 184,
|
||||
month: 12,
|
||||
hash: 'stale-hash',
|
||||
});
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refresh: 5,
|
||||
refreshTotal: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { z } from 'zod';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
|
||||
import { accessPageWeights, recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
|
||||
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
|
||||
import {
|
||||
accessPageWeights,
|
||||
generalAccessEndpointWeights,
|
||||
recordGeneralAccess,
|
||||
recordGeneralAccessWeight,
|
||||
resolveGeneralAccessEndpointWeight,
|
||||
resolveAccessWindows,
|
||||
} from '../src/services/generalAccess.js';
|
||||
|
||||
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
@@ -48,9 +59,56 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
};
|
||||
|
||||
describe('general access tracking', () => {
|
||||
it('uses the legacy weight two for both global directory pages', () => {
|
||||
it('owns every migrated Ref call at one explicit server endpoint weight', () => {
|
||||
expect(generalAccessEndpointWeights).toEqual({
|
||||
'world.getGeneralDirectory': 2,
|
||||
'public.getNpcList': 2,
|
||||
'ranking.getBestGeneral': 1,
|
||||
'ranking.getHallOfFame': 1,
|
||||
'tournament.getSnapshot': 1,
|
||||
'nation.getSecretGeneralList': 1,
|
||||
'nation.getPersonnelInfo': 1,
|
||||
'nation.getGeneralList': 1,
|
||||
'general.ensureDieOnPrestartStatus': 1,
|
||||
'nation.getStratFinan': 1,
|
||||
'board.getArticles': 1,
|
||||
'diplomacy.getLetters': 2,
|
||||
'battle.getGeneralDetail': 1,
|
||||
'betting.getList': 1,
|
||||
'general.getFrontStatus': 1,
|
||||
'yearbook.getHistory': 1,
|
||||
'world.getGlobalInfo': 1,
|
||||
'nation.getBattleCenter': 1,
|
||||
'nation.getChiefCenter': 1,
|
||||
'troop.getList': 1,
|
||||
'board.writeArticle': 1,
|
||||
'board.writeComment': 1,
|
||||
'diplomacy.sendLetter': 1,
|
||||
'diplomacy.respondLetter': 1,
|
||||
'diplomacy.rollbackLetter': 1,
|
||||
'diplomacy.destroyLetter': 1,
|
||||
'general.buildNationCandidate': 1,
|
||||
'general.dieOnPrestart': 1,
|
||||
'general.instantRetreat': 1,
|
||||
'messages.send': 1,
|
||||
'general.setMySetting': 0,
|
||||
'npc.setNationPolicy': 0,
|
||||
'npc.setNationPriority': 0,
|
||||
'npc.setGeneralPriority': 0,
|
||||
'battle.simulate': 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts only current-profile yearbook reads like Ref Global.GetHistory', () => {
|
||||
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1);
|
||||
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1);
|
||||
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull();
|
||||
expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the legacy route weight while endpoint-owned directories use the server map', () => {
|
||||
expect(accessPageWeights['nation-list']).toBe(2);
|
||||
expect(accessPageWeights['general-list']).toBe(2);
|
||||
expect(generalAccessEndpointWeights['world.getGeneralDirectory']).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the latest processed game turn as the traffic period and score window', () => {
|
||||
@@ -68,7 +126,7 @@ describe('general access tracking', () => {
|
||||
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
|
||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true);
|
||||
expect(findGeneral).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-7' },
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -105,6 +163,120 @@ describe('general access tracking', () => {
|
||||
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
|
||||
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);
|
||||
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);
|
||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
expect(fixture.findGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('parses input, commits access, and only then opens the failing business event boundary', async () => {
|
||||
const events: string[] = [];
|
||||
let transactionCount = 0;
|
||||
const transactionClient = {
|
||||
$queryRaw: vi.fn(async () => [{ id: 41 }]),
|
||||
$executeRaw: vi.fn(async () => 1),
|
||||
inputEvent: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
};
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 7,
|
||||
userId: 'user-7',
|
||||
})),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 3,
|
||||
currentYear: 185,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
meta: {
|
||||
opentime: '2026-07-25T00:00:00.000Z',
|
||||
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
},
|
||||
inputEvent: {
|
||||
create: vi.fn(async () => {
|
||||
events.push('input-event-create');
|
||||
return {};
|
||||
}),
|
||||
update: vi.fn(async () => {
|
||||
events.push('input-event-failed');
|
||||
return {};
|
||||
}),
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise<unknown>) => {
|
||||
transactionCount += 1;
|
||||
events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction');
|
||||
return callback(transactionClient);
|
||||
}),
|
||||
};
|
||||
const trackedRouter = router({
|
||||
board: router({
|
||||
writeArticle: accessAuthedInputProcedure(
|
||||
z.object({
|
||||
value: z.string().transform((value) => {
|
||||
events.push('input-parse');
|
||||
return value;
|
||||
}),
|
||||
})
|
||||
).mutation(() => {
|
||||
events.push('resolver');
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' });
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const context = {
|
||||
auth: auth(),
|
||||
db,
|
||||
generalAccessTracking: true,
|
||||
requestId: 'access-boundary-test',
|
||||
profile: { id: 'che:default', name: 'che' },
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
|
||||
message: 'business rejected',
|
||||
});
|
||||
expect(events).toEqual([
|
||||
'input-parse',
|
||||
'access-transaction',
|
||||
'input-event-create',
|
||||
'business-transaction',
|
||||
'resolver',
|
||||
'input-event-failed',
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
transactionCount = 0;
|
||||
await expect(
|
||||
trackedRouter.createCaller(context).board.writeArticle({ value: 7 } as never)
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -161,9 +161,9 @@ const install = async (
|
||||
);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
| Record<string, { json?: { page?: unknown }; page?: unknown }>
|
||||
| undefined;
|
||||
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
|
||||
const results = operationNames(route).map((operation, operationIndex) => {
|
||||
if (operation === 'auth.status') return response({ ok: true });
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
|
||||
}
|
||||
@@ -191,7 +191,8 @@ const install = async (
|
||||
};
|
||||
}
|
||||
const sort = parseSort(route);
|
||||
const rows = sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
|
||||
const rows =
|
||||
sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
|
||||
return response({ sort, generals: rows });
|
||||
}
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
@@ -200,6 +201,30 @@ const install = async (
|
||||
});
|
||||
};
|
||||
|
||||
const installAccessBoundary = async (page: Page, accessPages: string[]) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_access_boundary');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/image/**', (route) => route.abort('failed'));
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
|
||||
const results = operationNames(route).map((operation, operationIndex) => {
|
||||
if (operation === 'auth.status') return response({ ok: true });
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조회자' } });
|
||||
if (operation === 'public.recordAccess') {
|
||||
const payload = requestBody?.[String(operationIndex)];
|
||||
const pageName = payload?.json?.page ?? payload?.page;
|
||||
if (typeof pageName === 'string') accessPages.push(pageName);
|
||||
return response({ recorded: true });
|
||||
}
|
||||
return response({});
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
};
|
||||
|
||||
test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => {
|
||||
const accessPages: string[] = [];
|
||||
await install(page, 'general', accessPages);
|
||||
@@ -283,7 +308,8 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
||||
).toBe(65);
|
||||
}
|
||||
}
|
||||
await expect.poll(() => accessPages).toEqual(expect.arrayContaining(['nation-list', 'general-list']));
|
||||
await expect.poll(() => accessPages).toContain('nation-list');
|
||||
expect(accessPages).not.toContain('general-list');
|
||||
|
||||
const header = page.locator('.general-table thead td').first();
|
||||
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
|
||||
@@ -345,3 +371,51 @@ test('an authenticated account without a general is redirected away from both di
|
||||
await expect(page).toHaveURL(/\/che\/join$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('route access belongs only to the eight Ref page boundaries', async ({ page }) => {
|
||||
const accessPages: string[] = [];
|
||||
await installAccessBoundary(page, accessPages);
|
||||
const retained = [
|
||||
['nation/info', 'nation-info'],
|
||||
['nation/cities', 'nation-cities'],
|
||||
['nation-list', 'nation-list'],
|
||||
['current-city', 'current-city'],
|
||||
['dynasty', 'dynasty'],
|
||||
['dynasty/1', 'dynasty'],
|
||||
['traffic', 'traffic'],
|
||||
['npc-control', 'npc-control'],
|
||||
] as const;
|
||||
for (const [path, pageName] of retained) {
|
||||
const before = accessPages.length;
|
||||
await page.goto(path);
|
||||
await expect.poll(() => accessPages.length).toBe(before + 1);
|
||||
expect(accessPages.at(-1)).toBe(pageName);
|
||||
}
|
||||
|
||||
const endpointOwned = [
|
||||
'./',
|
||||
'global-info',
|
||||
'general-list',
|
||||
'diplomacy',
|
||||
'nation/generals',
|
||||
'nation/personnel',
|
||||
'nation/finance',
|
||||
'battle-center',
|
||||
'board',
|
||||
'board/secret',
|
||||
'best-general',
|
||||
'hall-of-fame',
|
||||
'yearbook',
|
||||
'nation-betting',
|
||||
'npc-list',
|
||||
'my-page',
|
||||
'tournament',
|
||||
'betting',
|
||||
];
|
||||
for (const path of endpointOwned) {
|
||||
const before = accessPages.length;
|
||||
await page.goto(path);
|
||||
await page.waitForTimeout(50);
|
||||
expect(accessPages).toHaveLength(before);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,12 +26,16 @@ type FixtureState = {
|
||||
instantRetreatEnabled?: boolean;
|
||||
instantRetreatAttempts?: number;
|
||||
instantRetreatInputs?: Array<Record<string, unknown>>;
|
||||
buildNationCandidateEnabled?: boolean;
|
||||
buildNationCandidateAttempts?: number;
|
||||
buildNationCandidateInputs?: Array<Record<string, unknown>>;
|
||||
dieOnPrestartShow?: boolean;
|
||||
dieOnPrestartAvailableAt?: string;
|
||||
dieOnPrestartAttempts?: number;
|
||||
dieOnPrestartInputs?: Array<Record<string, unknown>>;
|
||||
generalMeQueries?: number;
|
||||
generalLogQueries?: number;
|
||||
ensurePrestartQueries?: number;
|
||||
nationNoticeInput?: string;
|
||||
settingMutations: Array<Record<string, unknown>>;
|
||||
accessPages: string[];
|
||||
@@ -47,7 +51,7 @@ const myGeneral = (state: FixtureState) => ({
|
||||
id: 7,
|
||||
name: '검증장수',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
nationId: state.buildNationCandidateEnabled ? 0 : 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
@@ -165,12 +169,14 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||||
return response(myGeneral(state));
|
||||
}
|
||||
if (operation === 'general.ensureDieOnPrestartStatus')
|
||||
if (operation === 'general.ensureDieOnPrestartStatus') {
|
||||
state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1;
|
||||
return response({
|
||||
show: state.dieOnPrestartShow ?? false,
|
||||
available: false,
|
||||
availableAt: state.dieOnPrestartAvailableAt ?? null,
|
||||
});
|
||||
}
|
||||
if (operation === 'general.getFrontStatus')
|
||||
return response({
|
||||
onlineUserCount: 1,
|
||||
@@ -196,7 +202,9 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
},
|
||||
meta: {
|
||||
turntime: '2026-01-01T00:00:00.000Z',
|
||||
opentime: '2025-12-01T00:00:00.000Z',
|
||||
opentime: state.buildNationCandidateEnabled
|
||||
? '2026-02-01T00:00:00.000Z'
|
||||
: '2025-12-01T00:00:00.000Z',
|
||||
autorun_user: {},
|
||||
},
|
||||
});
|
||||
@@ -261,6 +269,20 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
}
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'general.buildNationCandidate') {
|
||||
state.buildNationCandidateInputs?.push(jsonInput);
|
||||
state.buildNationCandidateAttempts = (state.buildNationCandidateAttempts ?? 0) + 1;
|
||||
if (state.buildNationCandidateAttempts === 1) {
|
||||
return {
|
||||
error: {
|
||||
message: '요청 처리 결과를 확인하지 못했습니다.',
|
||||
code: -32000,
|
||||
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
|
||||
},
|
||||
};
|
||||
}
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'general.dieOnPrestart') {
|
||||
state.dieOnPrestartInputs?.push(jsonInput);
|
||||
state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1;
|
||||
@@ -395,7 +417,8 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
||||
await page.goto('my-page');
|
||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||
await expect.poll(() => state.accessPages).toContain('my-page');
|
||||
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
|
||||
expect(state.accessPages).not.toContain('my-page');
|
||||
const noDefenceOption = page.locator('option[value="999"]');
|
||||
await expect(noDefenceOption).toHaveText('× [훈련 -3,사기 -6]');
|
||||
await expect(page.locator('#defence_train option')).toHaveText([
|
||||
@@ -533,6 +556,7 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
|
||||
instantRetreatInputs: [],
|
||||
generalMeQueries: 0,
|
||||
generalLogQueries: 0,
|
||||
ensurePrestartQueries: 0,
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
};
|
||||
@@ -547,18 +571,21 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
|
||||
const instantRetreatButton = page.getByRole('button', { name: '접경 귀환' });
|
||||
await expect(instantRetreatButton).toBeVisible();
|
||||
await expect.poll(() => state.generalMeQueries).toBe(1);
|
||||
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
|
||||
|
||||
await instantRetreatButton.click();
|
||||
await expect.poll(() => state.instantRetreatInputs?.length).toBe(1);
|
||||
await expect
|
||||
.poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.')))
|
||||
.toBe(true);
|
||||
expect(state.generalMeQueries).toBe(1);
|
||||
await expect.poll(() => state.generalMeQueries).toBe(2);
|
||||
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
|
||||
|
||||
await instantRetreatButton.click();
|
||||
await expect.poll(() => state.instantRetreatInputs?.length).toBe(2);
|
||||
await expect.poll(() => state.generalMeQueries).toBe(2);
|
||||
await expect.poll(() => state.generalLogQueries).toBe(8);
|
||||
await expect.poll(() => state.generalMeQueries).toBe(3);
|
||||
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
|
||||
await expect.poll(() => state.generalLogQueries).toBe(12);
|
||||
await page.evaluate(() => new Promise<void>((resolveFrame) => requestAnimationFrame(() => resolveFrame())));
|
||||
|
||||
await instantRetreatButton.click();
|
||||
@@ -690,6 +717,40 @@ test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 time
|
||||
});
|
||||
});
|
||||
|
||||
test('사전 거병은 timeout reload 뒤 같은 ID를 재시도하고 성공 reload 뒤 새 ID를 만든다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permission: 'head',
|
||||
myset: 3,
|
||||
buildNationCandidateEnabled: true,
|
||||
buildNationCandidateAttempts: 0,
|
||||
buildNationCandidateInputs: [],
|
||||
ensurePrestartQueries: 0,
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
};
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await install(page, state);
|
||||
await page.goto('my-page');
|
||||
|
||||
const build = page.getByRole('button', { name: '사전 거병' });
|
||||
await expect(build).toBeVisible();
|
||||
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
|
||||
|
||||
await build.click();
|
||||
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(1);
|
||||
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
|
||||
|
||||
await build.click();
|
||||
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(2);
|
||||
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
|
||||
|
||||
await build.click();
|
||||
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(3);
|
||||
const requestIds = state.buildNationCandidateInputs?.map((input) => input.clientRequestId);
|
||||
expect(requestIds?.[1]).toBe(requestIds?.[0]);
|
||||
expect(requestIds?.[2]).not.toBe(requestIds?.[1]);
|
||||
});
|
||||
|
||||
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
||||
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, head);
|
||||
|
||||
@@ -41,32 +41,14 @@ import { useSessionStore } from '../stores/session';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const accessPageByRouteName = {
|
||||
home: 'front-info',
|
||||
'nation-info': 'nation-info',
|
||||
'nation-cities': 'nation-cities',
|
||||
'global-info': 'global-info',
|
||||
'nation-list': 'nation-list',
|
||||
'general-list': 'general-list',
|
||||
'current-city': 'current-city',
|
||||
diplomacy: 'diplomacy',
|
||||
'nation-generals': 'nation-generals',
|
||||
'nation-personnel': 'nation-personnel',
|
||||
'nation-finance': 'nation-finance',
|
||||
'battle-center': 'battle-center',
|
||||
board: 'board',
|
||||
'board-secret': 'board',
|
||||
'best-general': 'best-general',
|
||||
'hall-of-fame': 'hall-of-fame',
|
||||
'dynasty-list': 'dynasty',
|
||||
'dynasty-detail': 'dynasty',
|
||||
yearbook: 'yearbook',
|
||||
'nation-betting': 'nation-betting',
|
||||
traffic: 'traffic',
|
||||
'npc-list': 'npc-list',
|
||||
'my-page': 'my-page',
|
||||
'npc-control': 'npc-control',
|
||||
tournament: 'tournament',
|
||||
betting: 'betting',
|
||||
} as const;
|
||||
|
||||
const routes = [
|
||||
|
||||
@@ -86,9 +86,10 @@ const placeBet = async (targetId: number) => {
|
||||
try {
|
||||
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
||||
message.value = '베팅이 등록되었습니다.';
|
||||
await load();
|
||||
} catch (value) {
|
||||
message.value = errorText(value);
|
||||
} finally {
|
||||
await load();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -106,10 +106,7 @@ watch([selectedSeason, selectedScenario], () => {
|
||||
void loadHall();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOptions();
|
||||
await loadHall();
|
||||
});
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -187,7 +187,7 @@ const loadLog = async (type: LogType, beforeId?: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
const loadPage = async (resetImmediateActionIds = true) => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -206,7 +206,9 @@ const loadPage = async () => {
|
||||
Object.assign(form, general.settings);
|
||||
}
|
||||
await Promise.all(logTypes.map((type) => loadLog(type)));
|
||||
resetImmediateActionRequestIds();
|
||||
if (resetImmediateActionIds) {
|
||||
resetImmediateActionRequestIds();
|
||||
}
|
||||
} catch (cause) {
|
||||
error.value = errorText(cause);
|
||||
} finally {
|
||||
@@ -224,13 +226,17 @@ const saveSettings = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
|
||||
const confirmMutation = async (message: string, mutation: () => Promise<unknown>, reloadAfterFailure = false) => {
|
||||
if (!confirm(message)) return;
|
||||
try {
|
||||
await mutation();
|
||||
await loadPage();
|
||||
} catch (cause) {
|
||||
alert(`실패했습니다: ${errorText(cause)}`);
|
||||
if (reloadAfterFailure) {
|
||||
const code = asRecord(asRecord(cause).data).code;
|
||||
await loadPage(code !== 'TIMEOUT');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,7 +294,7 @@ onMounted(() => {
|
||||
<span>내 정 보</span>
|
||||
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
|
||||
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
|
||||
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
|
||||
<button class="legacy-button" type="button" @click="() => loadPage()">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-row">{{ error }}</div>
|
||||
@@ -430,10 +436,13 @@ onMounted(() => {
|
||||
<button
|
||||
class="action-button"
|
||||
@click="
|
||||
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
|
||||
trpc.general.buildNationCandidate.mutate({
|
||||
clientRequestId: immediateActionRequestIds.buildNationCandidate,
|
||||
})
|
||||
confirmMutation(
|
||||
'거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?',
|
||||
() =>
|
||||
trpc.general.buildNationCandidate.mutate({
|
||||
clientRequestId: immediateActionRequestIds.buildNationCandidate,
|
||||
}),
|
||||
true
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -445,10 +454,13 @@ onMounted(() => {
|
||||
<button
|
||||
class="action-button"
|
||||
@click="
|
||||
confirmMutation('아군 접경으로 이동할까요?', () =>
|
||||
trpc.general.instantRetreat.mutate({
|
||||
clientRequestId: immediateActionRequestIds.instantRetreat,
|
||||
})
|
||||
confirmMutation(
|
||||
'아군 접경으로 이동할까요?',
|
||||
() =>
|
||||
trpc.general.instantRetreat.mutate({
|
||||
clientRequestId: immediateActionRequestIds.instantRetreat,
|
||||
}),
|
||||
true
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -102,9 +102,10 @@ const join = async () => {
|
||||
try {
|
||||
await trpc.tournament.join.mutate();
|
||||
actionMessage.value = '참가 신청이 반영되었습니다.';
|
||||
await load();
|
||||
} catch (value) {
|
||||
actionMessage.value = errorText(value);
|
||||
} finally {
|
||||
await load();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -66,13 +66,35 @@ const bettingSummary = {
|
||||
myAmount: 50,
|
||||
};
|
||||
|
||||
type FixtureState = {
|
||||
betCalls: number;
|
||||
joinCalls: number;
|
||||
snapshotQueries: number;
|
||||
summaryQueries: number;
|
||||
rankingQueries: number;
|
||||
accessCalls: number;
|
||||
stage: number;
|
||||
myGeneralId: number;
|
||||
};
|
||||
|
||||
const fixtureByPage = new WeakMap<Page, FixtureState>();
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
const state: FixtureState = {
|
||||
betCalls: 0,
|
||||
joinCalls: 0,
|
||||
snapshotQueries: 0,
|
||||
summaryQueries: 0,
|
||||
rankingQueries: 0,
|
||||
accessCalls: 0,
|
||||
stage: snapshot.state.stage,
|
||||
myGeneralId: 64,
|
||||
};
|
||||
fixtureByPage.set(page, state);
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_tournament_visual');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
let betCalls = 0;
|
||||
let joinCalls = 0;
|
||||
await page.route('**/image/game/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -82,16 +104,32 @@ const installFixture = async (page: Page) => {
|
||||
);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'auth.status') {
|
||||
return response({ userId: 'tournament-user' });
|
||||
}
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: { id: 64, name: '내장수' } });
|
||||
return response({ myGeneral: { id: state.myGeneralId, name: '내장수' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'general.me') {
|
||||
return response({ general: { id: 64, name: '내장수' }, nation: null, city: null });
|
||||
return response({ general: { id: state.myGeneralId, name: '내장수' }, nation: null, city: null });
|
||||
}
|
||||
if (operation === 'tournament.getSnapshot') {
|
||||
state.snapshotQueries += 1;
|
||||
return response({
|
||||
...snapshot,
|
||||
state: {
|
||||
...snapshot.state,
|
||||
stage: state.stage,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (operation === 'tournament.getBettingSummary') {
|
||||
state.summaryQueries += 1;
|
||||
return response(bettingSummary);
|
||||
}
|
||||
if (operation === 'tournament.getSnapshot') return response(snapshot);
|
||||
if (operation === 'tournament.getBettingSummary') return response(bettingSummary);
|
||||
if (operation === 'tournament.getRankings') {
|
||||
state.rankingQueries += 1;
|
||||
return response(
|
||||
[
|
||||
['tt', '전 력 전', '종합'],
|
||||
@@ -123,17 +161,21 @@ const installFixture = async (page: Page) => {
|
||||
if (operation === 'tournament.getAdminStatus')
|
||||
return errorResponse(operation, 'Admin permission is required.');
|
||||
if (operation === 'tournament.join') {
|
||||
joinCalls += 1;
|
||||
return joinCalls === 1
|
||||
state.joinCalls += 1;
|
||||
return state.joinCalls === 1
|
||||
? errorResponse(operation, '금이 부족합니다.')
|
||||
: response({ ok: true, count: 64 });
|
||||
}
|
||||
if (operation === 'tournament.placeBet') {
|
||||
betCalls += 1;
|
||||
return betCalls === 1
|
||||
state.betCalls += 1;
|
||||
return state.betCalls === 1
|
||||
? errorResponse(operation, '500금까지만 베팅 가능합니다.')
|
||||
: response({ ok: true });
|
||||
}
|
||||
if (operation === 'public.recordAccess') {
|
||||
state.accessCalls += 1;
|
||||
return response({ recorded: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
@@ -204,11 +246,33 @@ test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ p
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(2000);
|
||||
});
|
||||
|
||||
test('tournament reloads its snapshot after failed and successful joins without route access', async ({ page }) => {
|
||||
const state = fixtureByPage.get(page)!;
|
||||
state.stage = 1;
|
||||
state.myGeneralId = 999;
|
||||
await page.goto(`${gameUrl}/che/tournament`);
|
||||
const join = page.getByRole('button', { name: '참가', exact: true });
|
||||
await expect(join).toBeVisible();
|
||||
await expect.poll(() => state.snapshotQueries).toBe(1);
|
||||
|
||||
await join.click();
|
||||
await expect(page.getByRole('status')).toHaveText('금이 부족합니다.');
|
||||
await expect.poll(() => state.snapshotQueries).toBe(2);
|
||||
|
||||
await join.click();
|
||||
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
|
||||
await expect.poll(() => state.snapshotQueries).toBe(3);
|
||||
expect(state.summaryQueries).toBe(3);
|
||||
expect(state.accessCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('betting keeps the 1120px and 16 by 70px layout and retains a failed selection', async ({ page }) => {
|
||||
const state = fixtureByPage.get(page)!;
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`${gameUrl}/che/betting`);
|
||||
await expect(page.getByText('베 팅 장')).toBeVisible();
|
||||
await expect(page.locator('.names span')).toHaveCount(16);
|
||||
await expect.poll(() => state.snapshotQueries).toBe(1);
|
||||
|
||||
const geometry = await page.locator('#tournament-betting-container').evaluate((container) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
@@ -251,7 +315,12 @@ test('betting keeps the 1120px and 16 by 70px layout and retains a failed select
|
||||
await bet.click();
|
||||
await expect(page.getByRole('status')).toHaveText('500금까지만 베팅 가능합니다.');
|
||||
await expect(select).toHaveValue('500');
|
||||
await expect.poll(() => state.snapshotQueries).toBe(2);
|
||||
|
||||
await bet.click();
|
||||
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
|
||||
await expect.poll(() => state.snapshotQueries).toBe(3);
|
||||
expect(state.summaryQueries).toBe(3);
|
||||
expect(state.rankingQueries).toBe(3);
|
||||
expect(state.accessCalls).toBe(0);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const passwordPublicKey = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicExponent: 0x10001,
|
||||
}).publicKey.export({ format: 'pem', type: 'spki' }).toString();
|
||||
})
|
||||
.publicKey.export({ format: 'pem', type: 'spki' })
|
||||
.toString();
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
@@ -154,9 +156,7 @@ const installGatewayFixture = async (page: Page): Promise<void> => {
|
||||
return {
|
||||
available: true,
|
||||
normalizedValue:
|
||||
fieldInput?.field === 'username'
|
||||
? fieldInput.value?.toLowerCase()
|
||||
: fieldInput?.value,
|
||||
fieldInput?.field === 'username' ? fieldInput.value?.toLowerCase() : fieldInput?.value,
|
||||
message: '사용할 수 있습니다.',
|
||||
};
|
||||
}
|
||||
@@ -216,15 +216,31 @@ const installGatewayFixture = async (page: Page): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const installHallFixture = async (page: Page): Promise<void> => {
|
||||
type HallFixtureCalls = {
|
||||
options: number;
|
||||
hallInputs: unknown[];
|
||||
};
|
||||
|
||||
const installHallFixture = async (page: Page): Promise<HallFixtureCalls> => {
|
||||
const calls: HallFixtureCalls = {
|
||||
options: 0,
|
||||
hallInputs: [],
|
||||
};
|
||||
await installImages(page);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
await fulfillOperations(route, (operation) => {
|
||||
if (operation === 'ranking.getHallOfFameOptions') return fixture.game.hallOptions;
|
||||
if (operation === 'ranking.getHallOfFame') return fixture.game.hall;
|
||||
await fulfillOperations(route, (operation, input) => {
|
||||
if (operation === 'ranking.getHallOfFameOptions') {
|
||||
calls.options += 1;
|
||||
return fixture.game.hallOptions;
|
||||
}
|
||||
if (operation === 'ranking.getHallOfFame') {
|
||||
calls.hallInputs.push(input);
|
||||
return fixture.game.hall;
|
||||
}
|
||||
throw new Error(`Unhandled hall fixture operation: ${operation}`);
|
||||
});
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
|
||||
@@ -730,8 +746,10 @@ test.describe('best general legacy parity', () => {
|
||||
});
|
||||
|
||||
test.describe('hall of fame legacy parity', () => {
|
||||
let hallCalls: HallFixtureCalls;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installHallFixture(page);
|
||||
hallCalls = await installHallFixture(page);
|
||||
});
|
||||
|
||||
for (const viewport of [
|
||||
@@ -742,6 +760,9 @@ test.describe('hall of fame legacy parity', () => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto('http://127.0.0.1:15102/che/hall-of-fame');
|
||||
await expect(page.getByText('유비')).toBeVisible();
|
||||
await expect.poll(() => hallCalls.options).toBe(1);
|
||||
await expect.poll(() => hallCalls.hallInputs.length).toBe(1);
|
||||
expect(hallCalls.hallInputs[0]).toEqual({ season: 1 });
|
||||
await expect(page.locator('.rankView')).toHaveCount(2);
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
@@ -807,14 +828,19 @@ test.describe('hall of fame legacy parity', () => {
|
||||
await expect(scenario).toBeFocused();
|
||||
await scenario.selectOption('scenario:1:22');
|
||||
await expect(scenario).toHaveValue('scenario:1:22');
|
||||
await expect.poll(() => hallCalls.hallInputs.length).toBe(2);
|
||||
expect(hallCalls.hallInputs[1]).toEqual({ season: 1, scenario: 22 });
|
||||
expect(hallCalls.options).toBe(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps the selected scenario after a hall API error', async ({ page }) => {
|
||||
await page.goto('http://127.0.0.1:15102/che/hall-of-fame');
|
||||
await expect(page.getByText('유비')).toBeVisible();
|
||||
let failedHallRequests = 0;
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
if (operationNames(route).includes('ranking.getHallOfFame')) {
|
||||
failedHallRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
@@ -829,6 +855,9 @@ test.describe('hall of fame legacy parity', () => {
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(scenario).toHaveValue('scenario:1:22');
|
||||
await expect(page.getByText('유비')).toBeVisible();
|
||||
await expect.poll(() => failedHallRequests).toBe(1);
|
||||
await page.waitForTimeout(250);
|
||||
expect(failedHallRequests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user