fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다

수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다.

요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
2026-08-24 12:10:57 +00:00
parent 5389f8ed94
commit 630bc29100
74 changed files with 3893 additions and 1141 deletions
+4
View File
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import type { GameApiContext } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { throwIfCommandRejected } from '../router/shared/turnDaemon.js';
import { buildAuctionTimerKeys } from './keys.js';
import { resolveAuctionTimerScore } from './scheduler.js';
@@ -21,6 +22,7 @@ export type OpenAuctionInput =
export const openAuctionWithDaemon = async (
ctx: GameApiContext,
userId: string,
generalId: number,
input: OpenAuctionInput,
requestId?: string
@@ -28,9 +30,11 @@ export const openAuctionWithDaemon = async (
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionOpen',
...(requestId ? { requestId } : {}),
userId,
generalId,
...input,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'auctionOpen') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
+24
View File
@@ -0,0 +1,24 @@
import { createHash } from 'node:crypto';
export const scopeHttpIdempotencyKey = (options: {
rawKey: string | undefined;
profileId: string;
userId: string | null;
}): string | undefined => {
const rawKey = options.rawKey?.trim();
if (!rawKey) {
return undefined;
}
// Request IDs are global in input_event. Hash the untrusted header with
// its authenticated principal and profile so equal client keys cannot
// collide across users or profiles, and oversized/punctuation-heavy
// headers never flow into DB identifiers or child command IDs.
const digest = createHash('sha256')
.update(options.profileId)
.update('\0')
.update(options.userId ?? 'anonymous')
.update('\0')
.update(rawKey)
.digest('hex');
return `http:${digest}`;
};
+10
View File
@@ -11,6 +11,7 @@ import { buildAuctionAlias } from '@sammo-ts/logic';
import { openAuctionWithDaemon } from '../../auction/open.js';
import { resolveAuctionTimerScore } from '../../auction/scheduler.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
const zBidInput = z.object({
auctionId: z.number().int().positive(),
@@ -340,6 +341,7 @@ export const auctionRouter = router({
await ensureAuctionSeasonActive(ctx.db);
return openAuctionWithDaemon(
ctx,
auth.user.id,
general.id,
{ auctionType: 'BUY_RICE', ...input },
ctx.requestId ? `${ctx.requestId}:auction.openBuyRice:engine:0:auctionOpen` : undefined
@@ -351,6 +353,7 @@ export const auctionRouter = router({
await ensureAuctionSeasonActive(ctx.db);
return openAuctionWithDaemon(
ctx,
auth.user.id,
general.id,
{ auctionType: 'SELL_RICE', ...input },
ctx.requestId ? `${ctx.requestId}:auction.openSellRice:engine:0:auctionOpen` : undefined
@@ -362,6 +365,7 @@ export const auctionRouter = router({
await ensureAuctionSeasonActive(ctx.db);
return openAuctionWithDaemon(
ctx,
auth.user.id,
general.id,
{
auctionType: 'UNIQUE_ITEM',
@@ -425,12 +429,14 @@ export const auctionRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionBid',
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidBuyRice:engine:0:auctionBid` } : {}),
userId: auth.user.id,
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'auctionBid') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -501,12 +507,14 @@ export const auctionRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionBid',
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidSellRice:engine:0:auctionBid` } : {}),
userId: auth.user.id,
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'auctionBid') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -638,12 +646,14 @@ export const auctionRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionBid',
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidUnique:engine:0:auctionBid` } : {}),
userId: auth.user.id,
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'auctionBid') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -5,6 +5,7 @@ import { asRecord } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -223,6 +224,7 @@ export const diplomacyRouter = router({
nationColor: destNation.color,
},
};
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
const created = await ctx.db.diplomacyLetter.create({
data: {
@@ -232,6 +234,7 @@ export const diplomacyRouter = router({
state: 'PROPOSED',
textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail),
date: letterDate,
srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue,
},
+140 -123
View File
@@ -26,7 +26,8 @@ import {
resolveRegionName,
sanitizeInternalDisplayCode,
} from '../../services/gameDisplayNames.js';
import { getMyGeneral } from '../shared/general.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
import {
loadTraitNames,
resolveNationBill,
@@ -299,88 +300,97 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
const metaRecord = asRecord(general.meta);
const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0);
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog, rankRows] =
await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
where: { id: general.cityId },
select: {
id: true,
name: true,
level: true,
nationId: true,
population: true,
populationMax: true,
agriculture: true,
agricultureMax: true,
commerce: true,
commerceMax: true,
security: true,
securityMax: true,
trust: true,
trade: true,
defence: true,
defenceMax: true,
wall: true,
wallMax: true,
region: true,
supplyState: true,
frontState: true,
},
})
: null,
general.nationId > 0
? ctx.db.nation.findUnique({
where: { id: general.nationId },
select: {
id: true,
name: true,
color: true,
level: true,
gold: true,
rice: true,
tech: true,
typeCode: true,
capitalCityId: true,
meta: true,
},
})
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
ctx.db.worldState.findFirst({
select: {
currentYear: true,
currentMonth: true,
tickSeconds: true,
lastTurnTick: true,
config: true,
meta: true,
},
}),
officerCityId > 0
? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } })
: Promise.resolve(null),
general.troopId > 0
? ctx.db.troop.findUnique({ where: { troopLeaderId: general.troopId }, select: { name: true } })
: Promise.resolve(null),
general.troopId > 0
? ctx.db.general.findUnique({ where: { id: general.troopId }, select: { cityId: true } })
: Promise.resolve(null),
general.troopId > 0
? ctx.db.generalTurn.findFirst({
where: { generalId: general.troopId },
orderBy: { turnIdx: 'asc' },
select: { actionCode: true },
})
: Promise.resolve(null),
ctx.db.generalAccessLog.findUnique({
where: { generalId: general.id },
select: { refreshScore: true, refreshScoreTotal: true },
}),
ctx.db.rankData.findMany({
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true },
}),
]);
const [
city,
queriedNation,
worldState,
officerCity,
troop,
troopLeader,
troopLeaderFirstTurn,
accessLog,
rankRows,
] = await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
where: { id: general.cityId },
select: {
id: true,
name: true,
level: true,
nationId: true,
population: true,
populationMax: true,
agriculture: true,
agricultureMax: true,
commerce: true,
commerceMax: true,
security: true,
securityMax: true,
trust: true,
trade: true,
defence: true,
defenceMax: true,
wall: true,
wallMax: true,
region: true,
supplyState: true,
frontState: true,
},
})
: null,
general.nationId > 0
? ctx.db.nation.findUnique({
where: { id: general.nationId },
select: {
id: true,
name: true,
color: true,
level: true,
gold: true,
rice: true,
tech: true,
typeCode: true,
capitalCityId: true,
meta: true,
},
})
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
ctx.db.worldState.findFirst({
select: {
currentYear: true,
currentMonth: true,
tickSeconds: true,
lastTurnTick: true,
config: true,
meta: true,
},
}),
officerCityId > 0
? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } })
: Promise.resolve(null),
general.troopId > 0
? ctx.db.troop.findUnique({ where: { troopLeaderId: general.troopId }, select: { name: true } })
: Promise.resolve(null),
general.troopId > 0
? ctx.db.general.findUnique({ where: { id: general.troopId }, select: { cityId: true } })
: Promise.resolve(null),
general.troopId > 0
? ctx.db.generalTurn.findFirst({
where: { generalId: general.troopId },
orderBy: { turnIdx: 'asc' },
select: { actionCode: true },
})
: Promise.resolve(null),
ctx.db.generalAccessLog.findUnique({
where: { generalId: general.id },
select: { refreshScore: true, refreshScoreTotal: true },
}),
ctx.db.rankData.findMany({
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true },
}),
]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, chiefAndCityOfficerRows] =
@@ -450,14 +460,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
}
}
}
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] = await Promise.all([
loadTraitNames([general.personalCode], 'personality'),
loadTraitNames([general.specialCode], 'domestic'),
loadTraitNames([general.special2Code], 'war'),
loadTraitNames([nation.typeCode], 'nation'),
loadCrewTypeDisplayDetails(worldState, ctx.profile.id),
loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
]);
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] =
await Promise.all([
loadTraitNames([general.personalCode], 'personality'),
loadTraitNames([general.specialCode], 'domestic'),
loadTraitNames([general.special2Code], 'war'),
loadTraitNames([nation.typeCode], 'nation'),
loadCrewTypeDisplayDetails(worldState, ctx.profile.id),
loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
]);
const worldConfig = asRecord(worldState?.config);
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
@@ -711,15 +722,15 @@ export const generalRouter = router({
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
const resetToDefault = input?.resetToDefault === true;
if (resetToDefault && input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.',
});
}
if (!resetToDefault && !input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' });
}
if (
resetToDefault &&
(ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)
) {
if (resetToDefault && (ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' });
}
if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
@@ -727,7 +738,10 @@ export const generalRouter = router({
}
const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt);
if (!iconRevision) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' });
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '계정 아이콘 변경 시각을 확인할 수 없습니다.',
});
}
const projection = resetToDefault
? {
@@ -740,13 +754,7 @@ export const generalRouter = router({
imageServer: selected!.imageServer,
revision: iconRevision,
};
return adjustAccountIconForUser(
ctx,
userId,
projection,
true,
input?.clientRequestId ?? ctx.requestId
);
return adjustAccountIconForUser(ctx, userId, projection, true, input?.clientRequestId ?? ctx.requestId);
}),
me: authedProcedure.query(({ ctx }) => getGeneralContext(ctx)),
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
@@ -795,12 +803,15 @@ export const generalRouter = router({
requestImmediateAction(ctx, input, 'instantRetreat')
),
vacation: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = getAuthenticatedUserId(ctx);
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'vacation',
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.vacation:engine:0:vacation` } : {}),
userId,
generalId: general.id,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'vacation') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -810,15 +821,16 @@ export const generalRouter = router({
return { ok: true };
}),
setMySetting: accessEngineAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'setMySetting',
...(ctx.requestId
? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` }
: {}),
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` } : {}),
userId,
generalId: general.id,
settings: input,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'setMySetting') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -828,22 +840,27 @@ export const generalRouter = router({
return { ok: true };
}),
dropItem: engineAuthedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'dropItem',
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.dropItem:engine:0:dropItem` } : {}),
generalId: general.id,
itemType: input.itemType,
});
if (!result || result.type !== 'dropItem') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
dropItem: engineAuthedProcedure
.input(z.object({ itemType: z.enum(['horse', 'weapon', 'book', 'item']) }))
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'dropItem',
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.dropItem:engine:0:dropItem` } : {}),
userId,
generalId: general.id,
itemType: input.itemType,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'dropItem') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
getMyLog: authedProcedure
.input(
z.object({
+1
View File
@@ -416,6 +416,7 @@ export const inheritRouter = router({
}
const result = await openAuctionWithDaemon(
ctx,
userId,
general.id,
{
auctionType: 'UNIQUE_ITEM',
@@ -2,7 +2,8 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
export const appoint = engineAuthedProcedure
.input(
@@ -13,15 +14,18 @@ export const appoint = engineAuthedProcedure
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'appoint',
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.appoint:engine:0:appoint` } : {}),
userId,
generalId: general.id,
destGeneralId: input.destGeneralId,
destCityId: input.destCityId,
officerLevel: input.officerLevel,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'appoint') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -2,7 +2,8 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
export const changePermission = engineAuthedProcedure
.input(
@@ -15,16 +16,19 @@ export const changePermission = engineAuthedProcedure
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'changePermission',
...(ctx.requestId
? { requestId: `${ctx.requestId}:nation.changePermission:engine:0:changePermission` }
: {}),
userId,
generalId: general.id,
isAmbassador: input.isAmbassador,
targetGeneralIds: input.targetGeneralIds,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'changePermission') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -2,18 +2,22 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
export const kick = engineAuthedProcedure
.input(z.object({ destGeneralId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'kick',
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.kick:engine:0:kick` } : {}),
userId,
generalId: general.id,
destGeneralId: input.destGeneralId,
});
throwIfCommandRejected(result);
if (!result || result.type !== 'kick') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
@@ -1,19 +1,18 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
export const setBill = authedProcedure
export const setBill = engineAuthedProcedure
.input(
z.object({
amount: z.number().int().min(20).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -24,14 +23,6 @@ export const setBill = authedProcedure
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
assertNationEditable(me, nation.meta);
const nationMeta = asRecord(nation.meta);
await updateNationMeta(
ctx,
me.nationId,
{
bill: input.amount,
},
nationMeta
);
await updateNationSetting(ctx, userId, me, 'setBill', { kind: 'bill', amount: input.amount });
return { ok: true };
});
@@ -1,49 +1,31 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
export const setBlockScout = authedProcedure
export const setBlockScout = engineAuthedProcedure
.input(
z.object({
value: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, worldState] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
}),
ctx.db.worldState.findFirst({
select: { meta: true },
}),
]);
const nation = await ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
assertNationEditable(me, nation.meta);
const worldMeta = asRecord(worldState?.meta);
if (worldMeta.block_change_scout === true) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
});
}
const nationMeta = asRecord(nation.meta);
await updateNationMeta(
ctx,
me.nationId,
{
scout: input.value ? 1 : 0,
},
nationMeta
);
await updateNationSetting(ctx, userId, me, 'setBlockScout', {
kind: 'blockScout',
value: input.value,
});
return { ok: true };
});
@@ -1,19 +1,18 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, resolveWarSettingRemain, updateNationMeta } from '../shared.js';
export const setBlockWar = authedProcedure
export const setBlockWar = engineAuthedProcedure
.input(
z.object({
value: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -25,20 +24,9 @@ export const setBlockWar = authedProcedure
}
assertNationEditable(me, nation.meta);
const meta = asRecord(nation.meta);
const remain = resolveWarSettingRemain(meta);
if (remain <= 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '잔여 횟수가 부족합니다.' });
}
const nextRemain = Math.max(0, remain - 1);
await updateNationMeta(
ctx,
me.nationId,
{
war: input.value ? 1 : 0,
available_war_setting_cnt: nextRemain,
},
meta
);
return { availableCnt: nextRemain };
const result = await updateNationSetting(ctx, userId, me, 'setBlockWar', {
kind: 'blockWar',
value: input.value,
});
return { availableCnt: result.availableCnt ?? 0 };
});
@@ -1,20 +1,16 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { purifyNationHtml } from '../../../security/nationHtml.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { legacyRequiredText } from '../settingInput.js';
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
export const setNotice = authedProcedure
.input(
z.object({
msg: z.string().min(1).max(16384),
})
)
export const setNotice = engineAuthedProcedure
.input(z.object({ msg: legacyRequiredText(16_384) }))
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -25,16 +21,7 @@ export const setNotice = authedProcedure
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
assertNationEditable(me, nation.meta);
const nationMeta = asRecord(nation.meta);
const msg = purifyNationHtml(input.msg);
await updateNationMeta(
ctx,
me.nationId,
{
notice: msg,
},
nationMeta
);
ctx.changeJournal?.mark('front.nation', me.nationId);
await updateNationSetting(ctx, userId, me, 'setNotice', { kind: 'notice', message: msg });
return { ok: true, msg };
});
@@ -1,19 +1,18 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
export const setRate = authedProcedure
export const setRate = engineAuthedProcedure
.input(
z.object({
amount: z.number().int().min(5).max(30),
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -24,14 +23,6 @@ export const setRate = authedProcedure
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
assertNationEditable(me, nation.meta);
const nationMeta = asRecord(nation.meta);
await updateNationMeta(
ctx,
me.nationId,
{
rate: input.amount,
},
nationMeta
);
await updateNationSetting(ctx, userId, me, 'setRate', { kind: 'rate', amount: input.amount });
return { ok: true };
});
@@ -1,20 +1,16 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { purifyNationHtml } from '../../../security/nationHtml.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { legacyRequiredText } from '../settingInput.js';
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
export const setScoutMsg = authedProcedure
.input(
z.object({
msg: z.string().min(1).max(1000),
})
)
export const setScoutMsg = engineAuthedProcedure
.input(z.object({ msg: legacyRequiredText(1_000) }))
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -25,15 +21,7 @@ export const setScoutMsg = authedProcedure
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
assertNationEditable(me, nation.meta);
const nationMeta = asRecord(nation.meta);
const msg = purifyNationHtml(input.msg);
await updateNationMeta(
ctx,
me.nationId,
{
infoText: msg,
},
nationMeta
);
await updateNationSetting(ctx, userId, me, 'setScoutMsg', { kind: 'scoutMessage', message: msg });
return { ok: true, msg };
});
@@ -1,19 +1,18 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
export const setSecretLimit = authedProcedure
export const setSecretLimit = engineAuthedProcedure
.input(
z.object({
amount: z.number().int().min(1).max(99),
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -24,14 +23,9 @@ export const setSecretLimit = authedProcedure
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
assertNationEditable(me, nation.meta);
const nationMeta = asRecord(nation.meta);
await updateNationMeta(
ctx,
me.nationId,
{
secretlimit: input.amount,
},
nationMeta
);
await updateNationSetting(ctx, userId, me, 'setSecretLimit', {
kind: 'secretLimit',
amount: input.amount,
});
return { ok: true };
});
@@ -0,0 +1,12 @@
import { z } from 'zod';
// PHP trim() removes only this default ASCII character list. JavaScript
// String#trim also removes Unicode spaces such as U+3000 and would reject a
// value that Ref accepts.
const hasLegacyTrimmedContent = (value: string): boolean => /[^ \t\n\r\0\v]/u.test(value);
export const legacyRequiredText = (maximumCodePoints: number) =>
z
.string()
.refine(hasLegacyTrimmedContent, '필수 입력입니다.')
.refine((value) => Array.from(value).length <= maximumCodePoints, `최대 ${maximumCodePoints}자까지 입력할 수 있습니다.`);
+24 -30
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asNumber, asRecord } from '@sammo-ts/common';
import { asNumber, asRecord, type TurnDaemonCommand } from '@sammo-ts/common';
import {
createIncomeActionContext,
DomesticTraitLoader,
@@ -26,7 +26,7 @@ import {
type TriggerValue,
} from '@sammo-ts/logic';
import type { GameApiContext, InputJsonValue, WorldStateRow } from '../../context.js';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { purifyNationHtml } from '../../security/nationHtml.js';
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
@@ -277,8 +277,10 @@ export const resolveNationScoutMessage = (meta: Record<string, unknown>): string
export const resolveWarSettingRemain = (meta: Record<string, unknown>): number => {
const legacy = readMetaNumber(meta, 'available_war_setting_cnt', -1);
const fallback =
legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT);
// Ref treats an absent counter as zero. The monthly refill is responsible
// for creating or replenishing it; a missing migration value must not grant
// ten free policy changes.
const fallback = legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', 0);
return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_CNT, fallback));
};
@@ -428,38 +430,30 @@ export const assertNationEditable = (
}
};
export const updateNationMeta = async (
ctx: Pick<GameApiContext, 'turnDaemon' | 'changeJournal'>,
nationId: number,
updates: Record<string, unknown>,
currentMeta: Record<string, unknown>
): Promise<InputJsonValue> => {
const expectedUpdatedAt = typeof currentMeta._updatedAt === 'string' ? currentMeta._updatedAt : undefined;
type NationSettingMutation = Extract<TurnDaemonCommand, { type: 'setNationSetting' }>['mutation'];
export const updateNationSetting = async (
ctx: Pick<GameApiContext, 'turnDaemon' | 'requestId'>,
userId: string,
general: { id: number; nationId: number },
endpoint: string,
mutation: NationSettingMutation
) => {
const result = await ctx.turnDaemon.requestCommand({
type: 'setNationMeta',
nationId,
updates,
expectedUpdatedAt,
type: 'setNationSetting',
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.${endpoint}:engine:0:setNationSetting` } : {}),
userId,
generalId: general.id,
nationId: general.nationId,
mutation,
});
if (!result || result.type !== 'setNationMeta') {
if (!result || result.type !== 'setNationSetting') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
if (result.reason === 'CONFLICT') {
throw new TRPCError({
code: 'CONFLICT',
message: '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.',
});
}
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
throw new TRPCError({ code: result.code, message: result.reason });
}
ctx.changeJournal?.mark('nation.content', nationId);
ctx.changeJournal?.mark('dashboard.global');
return {
...currentMeta,
...updates,
_updatedAt: result.updatedAt,
} as InputJsonValue;
return result;
};
export const mapGeneralList = async (
+59 -414
View File
@@ -3,137 +3,24 @@ import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import {
DEFAULT_GENERAL_PRIORITY,
DEFAULT_NATION_POLICY,
DEFAULT_NATION_PRIORITY,
type NationPolicy,
} from '@sammo-ts/game-engine/turn/npcPolicyMutation.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { accessEngineAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import type { GameApiContext } from '../../context.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
type NationPolicy = {
reqNationGold: number;
reqNationRice: number;
CombatForce: Record<number, [number, number]>;
SupportForce: number[];
DevelopForce: number[];
reqHumanWarUrgentGold: number;
reqHumanWarUrgentRice: number;
reqHumanWarRecommandGold: number;
reqHumanWarRecommandRice: number;
reqHumanDevelGold: number;
reqHumanDevelRice: number;
reqNPCWarGold: number;
reqNPCWarRice: number;
reqNPCDevelGold: number;
reqNPCDevelRice: number;
minimumResourceActionAmount: number;
maximumResourceActionAmount: number;
minNPCWarLeadership: number;
minWarCrew: number;
minNPCRecruitCityPopulation: number;
safeRecruitCityPopulationRatio: number;
properWarTrainAtmos: number;
cureThreshold: number;
};
type SetterInfo = {
setter: string | null;
date: string | null;
};
const updateNationMeta = async (
ctx: Pick<GameApiContext, 'turnDaemon'>,
nationId: number,
updates: Record<string, unknown>,
currentMeta: Record<string, unknown>
): Promise<void> => {
const expectedUpdatedAt = typeof currentMeta._updatedAt === 'string' ? currentMeta._updatedAt : undefined;
const result = await ctx.turnDaemon.requestCommand({
type: 'setNationMeta',
nationId,
updates,
expectedUpdatedAt,
});
if (!result || result.type !== 'setNationMeta') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
if (result.reason === 'CONFLICT') {
throw new TRPCError({
code: 'CONFLICT',
message: '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.',
});
}
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
};
const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
const DEFAULT_NATION_POLICY: NationPolicy = {
reqNationGold: 10000,
reqNationRice: 12000,
CombatForce: {},
SupportForce: [],
DevelopForce: [],
reqHumanWarUrgentGold: 0,
reqHumanWarUrgentRice: 0,
reqHumanWarRecommandGold: 0,
reqHumanWarRecommandRice: 0,
reqHumanDevelGold: 10000,
reqHumanDevelRice: 10000,
reqNPCWarGold: 0,
reqNPCWarRice: 0,
reqNPCDevelGold: 0,
reqNPCDevelRice: 500,
minimumResourceActionAmount: 1000,
maximumResourceActionAmount: 10000,
minNPCWarLeadership: 40,
minWarCrew: 1500,
minNPCRecruitCityPopulation: 50000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
cureThreshold: 10,
};
const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>);
const INTEGER_POLICY_KEYS = [
@@ -310,7 +197,7 @@ const applyPolicyValues = (base: NationPolicy, values: Record<string, unknown>):
const floatKey = key as FloatPolicyKey;
if (FLOAT_POLICY_KEYS.includes(floatKey)) {
const value = readNumber(rawValue, next.safeRecruitCityPopulationRatio);
next.safeRecruitCityPopulationRatio = Math.max(0, value);
next.safeRecruitCityPopulationRatio = value;
continue;
}
if (key === 'CombatForce' && isRecord(rawValue)) {
@@ -415,34 +302,51 @@ const resolveSetterInfo = (policy: Record<string, unknown>, kind: 'value' | 'pri
};
};
const validateGeneralPriority = (priority: string[]): string | null => {
const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
const mustHave = new Set(['출병', '일반내정']);
const orderMap = new Map<string, number>();
for (const [idx, item] of priority.entries()) {
if (!DEFAULT_GENERAL_PRIORITY.includes(item as (typeof DEFAULT_GENERAL_PRIORITY)[number])) {
return `${item}은 올바른 명령이 아닙니다.`;
}
orderMap.set(item, idx);
mustHave.delete(item);
const requestNpcPolicyMutation = async (
ctx: Pick<GameApiContext, 'auth' | 'db' | 'requestId' | 'turnDaemon'>,
endpoint: 'setNationPolicy' | 'setNationPriority' | 'setGeneralPriority',
mutation:
| { kind: 'nationPolicy'; values: Record<string, unknown> }
| { kind: 'nationPriority'; priority: string[] }
| { kind: 'generalPriority'; priority: string[] }
): Promise<{ ok: true }> => {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
}
for (const [pre, post] of orderRequired) {
const preIdx = orderMap.get(pre);
const postIdx = orderMap.get(post);
if (preIdx === undefined || postIdx === undefined) {
continue;
}
if (preIdx > postIdx) {
return `${pre} 명령은 ${post} 명령보다 먼저여야 합니다.`;
}
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어있지 않습니다.' });
}
if (mustHave.size > 0) {
return `${Array.from(mustHave)[0]}은 항상 사용해야 합니다.`;
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
}
return null;
const nationMeta = asRecord(nation.meta);
const expectedUpdatedAt =
typeof nationMeta._npcPolicyUpdatedAt === 'string'
? nationMeta._npcPolicyUpdatedAt
: typeof nationMeta._updatedAt === 'string'
? nationMeta._updatedAt
: null;
const result = await ctx.turnDaemon.requestCommand({
type: 'setNpcPolicy',
...(ctx.requestId ? { requestId: `${ctx.requestId}:npc.${endpoint}:engine:0:setNpcPolicy` } : {}),
userId: ctx.auth.user.id,
generalId: general.id,
nationId: nation.id,
expectedUpdatedAt,
mutation,
});
if (!result || result.type !== 'setNpcPolicy') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: result.code, message: result.reason });
}
return { ok: true };
};
export const npcRouter = router({
@@ -542,272 +446,13 @@ export const npcRouter = router({
permissionLevel,
};
}),
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.' });
}
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const keys = Object.keys(input);
for (const key of keys) {
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
}
const troopRows = await ctx.db.troop.findMany({
where: { nationId: general.nationId },
select: { troopLeaderId: true },
});
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
const citySet = new Set(cityRows.map((row) => row.id));
const assigned = new Set<number>();
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
for (const key of INTEGER_POLICY_KEYS) {
if (!(key in input)) {
continue;
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
}
nextValues[key] = Math.max(0, value);
}
for (const key of FLOAT_POLICY_KEYS) {
if (!(key in input)) {
continue;
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
}
nextValues[key] = value;
}
if ('CombatForce' in input) {
const rawCombat = input.CombatForce;
if (!isRecord(rawCombat)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
}
const combatForce: Record<number, [number, number]> = {};
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
const leaderId = Number(rawKey);
if (!Number.isFinite(leaderId)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
}
if (!troopSet.has(leaderId)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
}
if (assigned.has(leaderId)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
});
}
if (!Array.isArray(rawValue) || rawValue.length < 2) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${leaderId}의 입력양식이 올바르지 않습니다.`,
});
}
const fromCity = Number(rawValue[0]);
const toCity = Number(rawValue[1]);
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
});
}
combatForce[leaderId] = [fromCity, toCity];
assigned.add(leaderId);
}
nextValues.CombatForce = combatForce;
}
for (const key of ['SupportForce', 'DevelopForce'] as const) {
if (!(key in input)) {
continue;
}
const rawList = input[key];
if (!Array.isArray(rawList)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
const list: number[] = [];
for (const rawValue of rawList) {
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
if (!troopSet.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${rawValue}는 국가의 부대가 아닙니다.`,
});
}
if (assigned.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
});
}
assigned.add(rawValue);
list.push(rawValue);
}
if (key === 'SupportForce') {
nextValues.SupportForce = list;
} else {
nextValues.DevelopForce = list;
}
}
const nextPolicyRoot = {
...policyRoot,
values: nextValues,
valueSetter: general.name,
valueSetTime: new Date().toISOString(),
};
await updateNationMeta(
ctx,
nation.id,
{
npc_nation_policy: nextPolicyRoot,
},
nationMeta
);
return { ok: true };
}),
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.' });
}
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
for (const item of input) {
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
}
}
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextPolicyRoot = {
...policyRoot,
priority: input,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
await updateNationMeta(
ctx,
nation.id,
{
npc_nation_policy: nextPolicyRoot,
},
nationMeta
);
return { ok: true };
}),
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.' });
}
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const validationError = validateGeneralPriority(input);
if (validationError) {
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
}
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_general_policy);
const nextPolicyRoot = {
...policyRoot,
priority: input,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
await updateNationMeta(
ctx,
nation.id,
{
npc_general_policy: nextPolicyRoot,
},
nationMeta
);
return { ok: true };
}),
setNationPolicy: accessEngineAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(({ ctx, input }) =>
requestNpcPolicyMutation(ctx, 'setNationPolicy', { kind: 'nationPolicy', values: input })
),
setNationPriority: accessEngineAuthedInputProcedure(z.array(z.string())).mutation(({ ctx, input }) =>
requestNpcPolicyMutation(ctx, 'setNationPriority', { kind: 'nationPriority', priority: input })
),
setGeneralPriority: accessEngineAuthedInputProcedure(z.array(z.string())).mutation(({ ctx, input }) =>
requestNpcPolicyMutation(ctx, 'setGeneralPriority', { kind: 'generalPriority', priority: input })
),
});
+12 -11
View File
@@ -1,12 +1,18 @@
import { TRPCError } from '@trpc/server';
import type { GameApiContext } from '../../context.js';
export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) => {
if (!ctx.auth?.user.id) {
export const getAuthenticatedUserId = (ctx: Pick<GameApiContext, 'auth'>): string => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return userId;
};
export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) => {
const userId = getAuthenticatedUserId(ctx);
const general = await ctx.db.general.findFirst({
where: { userId: ctx.auth.user.id },
where: { userId },
});
if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
@@ -14,20 +20,15 @@ export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) =>
return general;
};
export const getOwnedGeneral = async (
ctx: Pick<GameApiContext, 'db' | 'auth'>,
generalId: number
) => {
if (!ctx.auth?.user.id) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
export const getOwnedGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>, generalId: number) => {
const userId = getAuthenticatedUserId(ctx);
const general = await ctx.db.general.findUnique({
where: { id: generalId },
});
if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found.' });
}
if (general.userId !== ctx.auth.user.id) {
if (general.userId !== userId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'General is not owned by the authenticated user.',
@@ -0,0 +1,10 @@
import { TRPCError } from '@trpc/server';
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
export const throwIfCommandRejected = (result: TurnDaemonCommandResult | null): void => {
if (result?.type !== 'commandRejected') {
return;
}
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
};
+13 -1
View File
@@ -24,7 +24,8 @@ import {
resolveRemainingMinutes,
} from '../../services/generalBasicCardProjection.js';
import { loadTraitNames } from '../nation/shared.js';
import { getMyGeneral } from '../shared/general.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
const TROOP_PANEL_RECORD_TYPES = [
'firenum',
@@ -54,6 +55,7 @@ const assertCommandResult = <T extends 'troopCreate' | 'troopJoin' | 'troopExit'
result: TurnDaemonCommandResult | null,
expectedType: T
): never => {
throwIfCommandRejected(result);
if (!result) {
throw new TRPCError({ code: 'TIMEOUT', message: 'Turn daemon did not respond.' });
}
@@ -427,6 +429,7 @@ export const troopRouter = router({
};
}),
create: engineAuthedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
const troopName = normalizeRequiredTroopName(input.troopName);
if (me.troopId !== 0) {
@@ -444,6 +447,7 @@ export const troopRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'troopCreate',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.create:engine:0:troopCreate` } : {}),
userId,
generalId: me.id,
troopName,
});
@@ -458,10 +462,12 @@ export const troopRouter = router({
join: engineAuthedProcedure
.input(z.object({ troopId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.join:engine:0:troopJoin` } : {}),
userId,
generalId: me.id,
troopId: input.troopId,
});
@@ -474,10 +480,12 @@ export const troopRouter = router({
return { ok: true };
}),
exit: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopExit',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.exit:engine:0:troopExit` } : {}),
userId,
generalId: me.id,
});
if (!result || result.type !== 'troopExit') {
@@ -496,6 +504,7 @@ export const troopRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
if (me.id !== input.troopId || me.troopId !== me.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
@@ -520,6 +529,7 @@ export const troopRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'troopKick',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.kick:engine:0:troopKick` } : {}),
userId,
generalId: me.id,
troopId: input.troopId,
targetGeneralId: input.targetGeneralId,
@@ -541,6 +551,7 @@ export const troopRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const userId = getAuthenticatedUserId(ctx);
const me = await getMyGeneral(ctx);
const troopName = normalizeRequiredTroopName(input.troopName);
const nation = await ctx.db.nation.findUnique({
@@ -562,6 +573,7 @@ export const troopRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'troopRename',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.rename:engine:0:troopRename` } : {}),
userId,
generalId: me.id,
troopId: input.troopId,
troopName,
+6 -1
View File
@@ -5,8 +5,9 @@ import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -347,15 +348,19 @@ export const voteRouter = router({
if (new Set(sortedSelection).size !== sortedSelection.length) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' });
}
const userId = getAuthenticatedUserId(ctx);
const general = await getMyGeneral(ctx);
const rewardResult = await ctx.turnDaemon.requestCommand({
type: 'voteReward',
...(ctx.requestId ? { requestId: `${ctx.requestId}:vote.submitVote:engine:0:voteReward` } : {}),
userId,
voteId: input.voteId,
generalId: general.id,
selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
throwIfCommandRejected(rewardResult);
if (!rewardResult || rewardResult.type !== 'voteReward') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
+9 -4
View File
@@ -47,6 +47,7 @@ import { RemoteContentImageStore } from './services/remoteContentImageStore.js';
import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
import { scopeHttpIdempotencyKey } from './requestId.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) {
@@ -247,11 +248,15 @@ export const createGameApiServer = async () => {
createContext: async ({ req }: { req: FastifyRequest }) => {
const token = extractBearerToken(req.headers.authorization);
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
? req.headers['idempotency-key'][0]
: req.headers['idempotency-key'];
return createGameApiContext({
requestId:
(Array.isArray(req.headers['idempotency-key'])
? req.headers['idempotency-key'][0]
: req.headers['idempotency-key']) || undefined,
requestId: scopeHttpIdempotencyKey({
rawKey: rawIdempotencyKey,
profileId: config.profile,
userId: auth?.user.id ?? null,
}),
db: postgres.prisma,
redis: redis.client,
turnDaemon,
+131 -8
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common';
import { z } from 'zod';
import type { TournamentKeys } from './keys.js';
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
@@ -19,15 +20,115 @@ interface RedisClientLike {
publish?(channel: string, message: string): Promise<unknown>;
}
const safeJsonParse = <T>(raw: string | null): T | null => {
if (!raw) {
export class CorruptTournamentProjectionError extends Error {
constructor(readonly key: string) {
super(`Tournament projection is malformed: ${key}`);
this.name = 'CorruptTournamentProjectionError';
}
}
const zTournamentState = z
.object({
stage: z.number().int(),
phase: z.number().int(),
type: z.number().int(),
auto: z.boolean(),
openYear: z.number().int(),
openMonth: z.number().int(),
termSeconds: z.number(),
nextAt: z.string(),
bettingId: z.number().int().optional(),
bettingCloseAt: z.string().optional(),
winnerId: z.number().int().optional(),
bettingSettled: z.boolean().optional(),
rewardSettled: z.boolean().optional(),
participantsLockedAt: z.string().optional(),
lastError: z.string().optional(),
lastErrorAt: z.string().optional(),
})
.passthrough();
const zTournamentParticipant = z
.object({
id: z.number().int(),
name: z.string(),
leadership: z.number(),
strength: z.number(),
intel: z.number(),
level: z.number(),
groupId: z.number().int().optional(),
groupNo: z.number().int().optional(),
win: z.number().int().optional(),
draw: z.number().int().optional(),
lose: z.number().int().optional(),
gl: z.number().int().optional(),
seedRank: z.number().int().optional(),
finalRank: z.number().int().optional(),
preliminaryGroupId: z.number().int().optional(),
preliminaryGroupNo: z.number().int().optional(),
preliminaryRank: z.number().int().optional(),
preliminaryWin: z.number().int().optional(),
preliminaryDraw: z.number().int().optional(),
preliminaryLose: z.number().int().optional(),
preliminaryGl: z.number().int().optional(),
})
.passthrough();
const zTournamentLogEntry = z
.object({
phase: z.number().int(),
attackerEnergy: z.number(),
defenderEnergy: z.number(),
attackerDamage: z.number(),
defenderDamage: z.number(),
text: z.string(),
})
.passthrough();
const zTournamentMatch = z
.object({
id: z.number().int(),
stage: z.number().int(),
roundIndex: z.number().int(),
attackerId: z.number().int(),
defenderId: z.number().int(),
groupId: z.number().int().optional(),
winnerId: z.number().int().optional(),
log: z.array(z.string()).optional(),
logEntries: z.array(zTournamentLogEntry).optional(),
lastEnergy: z
.object({
attacker: z.number(),
defender: z.number(),
})
.passthrough()
.optional(),
})
.passthrough();
const zTournamentBet = z
.object({
generalId: z.number().int(),
targetId: z.number().int(),
amount: z.number(),
})
.passthrough();
const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T>): T | null => {
if (raw === null) {
return null;
}
let value: unknown;
try {
return JSON.parse(raw) as T;
value = JSON.parse(raw) as unknown;
} catch {
return null;
throw new CorruptTournamentProjectionError(key);
}
const parsed = schema.safeParse(value);
if (!parsed.success) {
throw new CorruptTournamentProjectionError(key);
}
return parsed.data;
};
export class TournamentStore {
@@ -61,7 +162,11 @@ export class TournamentStore {
}
async getState(): Promise<TournamentState | null> {
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
return parseProjection(
await this.redis.get(this.keys.stateKey),
this.keys.stateKey,
zTournamentState
) as TournamentState | null;
}
async getSourceRevision(): Promise<string | null> {
@@ -77,7 +182,13 @@ export class TournamentStore {
}
async getParticipants(): Promise<TournamentParticipantEntry[]> {
return safeJsonParse<TournamentParticipantEntry[]>(await this.redis.get(this.keys.participantsKey)) ?? [];
return (
parseProjection(
await this.redis.get(this.keys.participantsKey),
this.keys.participantsKey,
z.array(zTournamentParticipant)
) ?? []
);
}
async setParticipants(participants: TournamentParticipantEntry[]): Promise<string> {
@@ -85,7 +196,13 @@ export class TournamentStore {
}
async getMatches(): Promise<TournamentMatchEntry[]> {
return safeJsonParse<TournamentMatchEntry[]>(await this.redis.get(this.keys.matchesKey)) ?? [];
return (
parseProjection(
await this.redis.get(this.keys.matchesKey),
this.keys.matchesKey,
z.array(zTournamentMatch)
) ?? []
);
}
async setMatches(matches: TournamentMatchEntry[]): Promise<string> {
@@ -93,7 +210,13 @@ export class TournamentStore {
}
async getBettingEntries(): Promise<TournamentBetEntry[]> {
return safeJsonParse<TournamentBetEntry[]>(await this.redis.get(this.keys.bettingKey)) ?? [];
return (
parseProjection(
await this.redis.get(this.keys.bettingKey),
this.keys.bettingKey,
z.array(zTournamentBet)
) ?? []
);
}
async setBettingEntries(entries: TournamentBetEntry[]): Promise<string> {
+3
View File
@@ -241,6 +241,7 @@ describe('auction router actor and permission boundaries', () => {
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionOpen',
auctionType: 'BUY_RICE',
userId: 'user-1',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
@@ -269,6 +270,7 @@ describe('auction router actor and permission boundaries', () => {
type: 'auctionOpen',
requestId: 'http-auction-open:auction.openBuyRice:engine:0:auctionOpen',
auctionType: 'BUY_RICE',
userId: 'user-1',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
@@ -381,6 +383,7 @@ describe('auction router actor and permission boundaries', () => {
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionBid',
userId: 'user-1',
auctionId: 31,
generalId: 7,
amount: 110,
+10
View File
@@ -108,6 +108,15 @@ const buildContext = (officerLevel = 12, letter: Record<string, unknown> = store
findFirst: vi.fn(async () => null),
create,
},
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
clockTick: 6n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-31T00:00:00.000Z'),
tickSeconds: 600,
})),
},
};
const redis = {
get: async () => null,
@@ -146,6 +155,7 @@ describe('diplomacy HTML API boundary', () => {
textBrief: '<p><strong>공개</strong></p>',
textDetail:
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
date: new Date('0185-01-01T00:00:00.000Z'),
}),
});
});
@@ -10,16 +10,6 @@ const classifications = {
'messages.delete',
'messages.respond',
'messages.send',
'nation.setBill',
'nation.setBlockScout',
'nation.setBlockWar',
'nation.setNotice',
'nation.setRate',
'nation.setScoutMsg',
'nation.setSecretLimit',
'npc.setGeneralPriority',
'npc.setNationPolicy',
'npc.setNationPriority',
'turns.repeatGeneral',
'turns.setGeneral',
'turns.setGeneralBulk',
@@ -69,6 +59,16 @@ const classifications = {
'nation.appoint',
'nation.changePermission',
'nation.kick',
'nation.setBill',
'nation.setBlockScout',
'nation.setBlockWar',
'nation.setNotice',
'nation.setRate',
'nation.setScoutMsg',
'nation.setSecretLimit',
'npc.setGeneralPriority',
'npc.setNationPolicy',
'npc.setNationPriority',
'troop.create',
'troop.exit',
'troop.join',
@@ -14,9 +14,9 @@ describe('IdempotentTurnDaemonTransport', () => {
const firstAttempt = new IdempotentTurnDaemonTransport(inner, 'api-event');
const retry = new IdempotentTurnDaemonTransport(inner, 'api-event');
await firstAttempt.sendCommand({ type: 'vacation', generalId: 7 });
await firstAttempt.sendCommand({ type: 'dropItem', generalId: 7, itemType: 'weapon' });
await retry.sendCommand({ type: 'vacation', generalId: 7 });
await firstAttempt.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 });
await firstAttempt.sendCommand({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' });
await retry.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 });
expect(inner.commands.map((entry) => entry.requestId)).toEqual([
'api-event:engine:0:vacation',
@@ -29,6 +29,7 @@ describe('IdempotentTurnDaemonTransport', () => {
const auctionBid = {
type: 'auctionBid',
requestId: 'auction-bid',
userId: 'user-7',
auctionId: 31,
generalId: 7,
amount: 500,
@@ -40,6 +41,7 @@ describe('IdempotentTurnDaemonTransport', () => {
const voteReward = {
type: 'voteReward',
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
@@ -93,6 +95,7 @@ describe('IdempotentTurnDaemonTransport', () => {
const persistedPayload = {
type: 'voteReward' as const,
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
@@ -595,6 +595,7 @@ describe('in-game my information ownership', () => {
await caller.general.setMySetting({ tnmt: 1, defence_train: 999 });
expect(requestCommand).toHaveBeenCalledWith({
type: 'setMySetting',
userId: 'user-7',
generalId: 7,
settings: { tnmt: 1, defence_train: 999 },
});
@@ -622,18 +623,51 @@ describe('in-game my information ownership', () => {
requestCommand,
});
await expect(
appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })
).resolves.toEqual({ ok: true });
await expect(appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })).resolves.toEqual({
ok: true,
});
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'setMySetting',
requestId: 'http-general-setting:general.setMySetting:engine:0:setMySetting',
userId: 'user-7',
generalId: 7,
settings: { tnmt: 1 },
});
});
it.each(['horse', 'weapon', 'book', 'item'] as const)(
'dispatches the authenticated dropItem command for the %s slot',
async (itemType) => {
const requestCommand = vi.fn(async () => ({
type: 'dropItem' as const,
ok: true as const,
generalId: 7,
}));
const fixture = createContext({ requestCommand });
await expect(appRouter.createCaller(fixture.context).general.dropItem({ itemType })).resolves.toEqual({
ok: true,
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'dropItem',
userId: 'user-7',
generalId: 7,
itemType,
});
}
);
it('rejects an unknown dropItem slot before dispatching it to ENGINE', async () => {
const requestCommand = vi.fn(async () => ({ type: 'dropItem' as const, ok: true as const, generalId: 7 }));
const fixture = createContext({ requestCommand });
await expect(
appRouter.createCaller(fixture.context).general.dropItem({ itemType: 'armor' as never })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(requestCommand).not.toHaveBeenCalled();
});
it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => {
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
@@ -32,10 +32,7 @@ const payloadHasGeneral = (payload: unknown, generalId: number): boolean => {
const changes = (payload as { changes?: unknown }).changes;
return (
Array.isArray(changes) &&
changes.some(
(change) =>
Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId
)
changes.some((change) => Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId)
);
};
@@ -134,10 +131,7 @@ integration('API input event boundary', () => {
).resolves.toEqual({ ok: true });
expect(wakeSnapshot).toBeDefined();
const [event, revision] = (await wakeSnapshot) as [
{ status: string },
{ revision: bigint },
];
const [event, revision] = (await wakeSnapshot) as [{ status: string }, { revision: bigint }];
expect(event.status).toBe('SUCCEEDED');
expect(revision.revision).toBe(1n);
expect(redisPublish).not.toHaveBeenCalled();
@@ -158,9 +152,7 @@ integration('API input event boundary', () => {
} as unknown as GameApiContext;
await expect(
journalBoundaryRouter
.createCaller(context)
.mutate({ generalId: journalGeneralIds[1], fail: true })
journalBoundaryRouter.createCaller(context).mutate({ generalId: journalGeneralIds[1], fail: true })
).rejects.toThrow('injected journal rollback');
await expect(
@@ -257,21 +249,24 @@ integration('API input event boundary', () => {
const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-child';
const acceptedWindowStart = Date.now();
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 });
await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 });
const acceptedWindowEnd = Date.now();
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
expect(event.actorUserId).toBe('user-7');
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
ConflictingTurnDaemonCommandError
);
await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
).resolves.toBe(requestId);
await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 8 })
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
});
it('distinguishes a stored terminal engine failure from a result timeout', async () => {
const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-failed';
const command = { type: 'vacation' as const, requestId, generalId: 7 };
const command = { type: 'vacation' as const, requestId, userId: 'user-7', generalId: 7 };
await transport.sendCommand(command);
await db.inputEvent.update({
where: { requestId },
+47 -15
View File
@@ -73,8 +73,9 @@ const auth: GameSessionTokenPayload = {
const buildContext = () => {
const changeJournal = new ChangeJournal();
const requestCommand = vi.fn(async (command: unknown) => ({
type: 'setNationMeta',
type: 'setNationSetting',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:00:01.000Z',
command,
}));
@@ -97,6 +98,7 @@ const buildContext = () => {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
requestId: 'http-nation-html',
changeJournal,
uploadDir: 'uploads',
uploadPath: '/uploads',
@@ -131,29 +133,59 @@ describe('nation HTML API boundary', () => {
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
type: 'setNationSetting',
requestId: `http-nation-html:nation.${procedure}:engine:0:setNationSetting`,
userId: 'user-1',
generalId: 1,
nationId: 1,
updates: {
[metaKey]: msg,
},
expectedUpdatedAt: undefined,
mutation: metaKey === 'notice' ? { kind: 'notice', message: msg } : { kind: 'scoutMessage', message: msg },
});
expect(fixture.changeJournal.snapshot()).toEqual([
{ domain: 'dashboard.global', entityId: 0 },
...(procedure === 'setNotice' ? [{ domain: 'front.nation' as const, entityId: 1 }] : []),
{ domain: 'nation.content', entityId: 1 },
]);
expect(fixture.changeJournal.snapshot()).toEqual([]);
});
it.each(['setNotice', 'setScoutMsg'] as const)(
'rejects an empty $procedure value like Ref required validation',
'rejects empty and whitespace-only $procedure values like Ref required validation',
async (procedure) => {
await expect(buildContext().caller.nation[procedure]({ msg: '' })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
for (const msg of ['', ' \t\n\v\0']) {
const fixture = buildContext();
await expect(fixture.caller.nation[procedure]({ msg })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
}
);
it.each(['setNotice', 'setScoutMsg'] as const)(
'keeps PHP trim semantics for a Unicode-only $procedure value',
async (procedure) => {
const fixture = buildContext();
await expect(fixture.caller.nation[procedure]({ msg: ' ' })).resolves.toMatchObject({
ok: true,
msg: ' ',
});
expect(fixture.requestCommand).toHaveBeenCalledOnce();
}
);
it.each([
['setNotice', 'notice'],
['setScoutMsg', 'scoutMessage'],
] as const)('dispatches unsafe-only $procedure HTML as the empty string Ref persists', async (procedure, kind) => {
const fixture = buildContext();
await expect(fixture.caller.nation[procedure]({ msg: '<script></script>' })).resolves.toEqual({
ok: true,
msg: '',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'setNationSetting',
mutation: { kind, message: '' },
})
);
});
it('purifies legacy stored values on every read resolver', () => {
expect(
resolveNationNotice({
+34 -11
View File
@@ -140,9 +140,26 @@ describe('nation personnel router', () => {
await caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [9] });
expect(requestCommand.mock.calls).toEqual([
[{ type: 'appoint', generalId: 22, destGeneralId: 7, destCityId: 1, officerLevel: 4 }],
[{ type: 'kick', generalId: 22, destGeneralId: 8 }],
[{ type: 'changePermission', generalId: 22, isAmbassador: true, targetGeneralIds: [9] }],
[
{
type: 'appoint',
userId: 'user-22',
generalId: 22,
destGeneralId: 7,
destCityId: 1,
officerLevel: 4,
},
],
[{ type: 'kick', userId: 'user-22', generalId: 22, destGeneralId: 8 }],
[
{
type: 'changePermission',
userId: 'user-22',
generalId: 22,
isAmbassador: true,
targetGeneralIds: [9],
},
],
]);
});
@@ -160,6 +177,7 @@ describe('nation personnel router', () => {
expect(requestCommand).toHaveBeenCalledWith({
type: 'kick',
requestId: 'http-nation-kick:nation.kick:engine:0:kick',
userId: 'user-22',
generalId: 22,
destGeneralId: 8,
});
@@ -277,7 +295,7 @@ describe('nation personnel router', () => {
};
const makeCommand = () =>
vi.fn(async () => ({
type: 'setNationMeta',
type: 'setNationSetting',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:01:00.000Z',
@@ -291,15 +309,13 @@ describe('nation personnel router', () => {
.nation.setRate({ amount: 20 })
).resolves.toEqual({ ok: true });
expect(headCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
type: 'setNationSetting',
userId: 'user-22',
generalId: 22,
nationId: 1,
updates: { rate: 20 },
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: { kind: 'rate', amount: 20 },
});
expect(changeJournal.snapshot()).toEqual([
{ domain: 'dashboard.global', entityId: 0 },
{ domain: 'nation.content', entityId: 1 },
]);
expect(changeJournal.snapshot()).toEqual([]);
const ambassadorCommand = makeCommand();
const ambassador = {
@@ -312,6 +328,13 @@ describe('nation personnel router', () => {
.createCaller(createContext({ me: ambassador, db: nationDb, requestCommand: ambassadorCommand }))
.nation.setRate({ amount: 25 })
).resolves.toEqual({ ok: true });
expect(ambassadorCommand).toHaveBeenCalledWith({
type: 'setNationSetting',
userId: 'user-22',
generalId: 22,
nationId: 1,
mutation: { kind: 'rate', amount: 25 },
});
const memberCommand = makeCommand();
const member = { ...baseGeneral, officerLevel: 1 };
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const general: GeneralRow = {
id: 71,
userId: 'authenticated-user',
name: '국가설정담당',
nationId: 3,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 5,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-01-01T00:00:00.000Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-02T00:00:00.000Z',
sessionId: 'session-setting',
user: {
id: 'authenticated-user',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
const buildContext = (authenticated = true) => {
const requestCommand = vi.fn(async (command: unknown) => {
const kind = (command as { mutation?: { kind?: string } }).mutation?.kind;
return {
type: 'setNationSetting' as const,
ok: true as const,
nationId: 3,
updatedAt: '2026-01-01T00:00:01.000Z',
...(kind === 'blockWar' ? { availableCnt: 7 } : {}),
};
});
const transaction = vi.fn(async () => {
throw new Error('nation settings must not use an API input-event transaction');
});
const db = {
$transaction: transaction,
general: {
findFirst: vi.fn(async () => general),
},
nation: {
findUnique: vi.fn(async () => ({ meta: {} })),
},
};
const redisClient = {
get: async () => null,
set: async () => null,
};
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: authenticated ? auth : null,
requestId: 'http-setting-boundary',
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, transaction };
};
describe('nation setting router engine boundary', () => {
it('binds all semantic setting commands to the authenticated actor and a stable request id', async () => {
const fixture = buildContext();
const caller = appRouter.createCaller(fixture.context);
await expect(caller.nation.setNotice({ msg: '<strong>방침</strong>' })).resolves.toEqual({
ok: true,
msg: '<strong>방침</strong>',
});
await expect(caller.nation.setScoutMsg({ msg: '<em>등용문</em>' })).resolves.toEqual({
ok: true,
msg: '<em>등용문</em>',
});
await expect(caller.nation.setRate({ amount: 30 })).resolves.toEqual({ ok: true });
await expect(caller.nation.setBill({ amount: 200 })).resolves.toEqual({ ok: true });
await expect(caller.nation.setSecretLimit({ amount: 99 })).resolves.toEqual({ ok: true });
await expect(caller.nation.setBlockWar({ value: false })).resolves.toEqual({ availableCnt: 7 });
await expect(caller.nation.setBlockScout({ value: true })).resolves.toEqual({ ok: true });
expect(fixture.requestCommand.mock.calls.map(([command]) => command)).toEqual([
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setNotice:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'notice', message: '<strong>방침</strong>' },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setScoutMsg:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'scoutMessage', message: '<em>등용문</em>' },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setRate:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'rate', amount: 30 },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setBill:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'bill', amount: 200 },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setSecretLimit:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'secretLimit', amount: 99 },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setBlockWar:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'blockWar', value: false },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setBlockScout:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'blockScout', value: true },
},
]);
expect(fixture.transaction).not.toHaveBeenCalled();
});
it('rejects unauthenticated calls before querying or dispatching a setting command', async () => {
const fixture = buildContext(false);
await expect(appRouter.createCaller(fixture.context).nation.setBill({ amount: 20 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(fixture.context.db.general.findFirst).not.toHaveBeenCalled();
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.transaction).not.toHaveBeenCalled();
});
});
+30 -46
View File
@@ -105,7 +105,7 @@ const createContext = (
const requestCommand =
options.requestCommand ??
vi.fn(async () => ({
type: 'setNationMeta',
type: 'setNpcPolicy',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:01:00.000Z',
@@ -161,46 +161,43 @@ describe('NPC policy router', () => {
});
});
it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => {
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
const reader = { ...baseGeneral, officerLevel: 2 };
const fixture = createContext({ me: reader });
const requestCommand = vi.fn(async () => ({
type: 'setNpcPolicy' as const,
ok: false as const,
code: 'FORBIDDEN' as const,
reason: '권한이 부족합니다.',
nationId: 1,
}));
const fixture = createContext({ me: reader, requestCommand });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 });
await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledOnce();
});
it.each([
['군주', { ...baseGeneral, officerLevel: 12 }],
['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }],
['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }],
])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => {
])('%s dispatches actor-bound policy intent to the daemon', async (_label, me) => {
const fixture = createContext({ me });
await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({
ok: true,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
type: 'setNpcPolicy',
userId: 'user-22',
generalId: 22,
nationId: 1,
updates: {
npc_nation_policy: expect.objectContaining({
priority: ['천도', '천도'],
prioritySetter: '정책담당',
prioritySetTime: expect.any(String),
}),
},
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: { kind: 'nationPriority', priority: ['천도', '천도'] },
});
});
it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => {
it('forwards raw policy intent so ENGINE can validate current troop and city state atomically', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
@@ -211,43 +208,29 @@ describe('NPC policy router', () => {
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_nation_policy: expect.objectContaining({
values: expect.objectContaining({
reqNationGold: 0,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
}),
}),
type: 'setNpcPolicy',
mutation: {
kind: 'nationPolicy',
values: {
reqNationGold: -100,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
},
},
})
);
fixture.requestCommand.mockClear();
await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => {
it('preserves duplicate priority entries in the dispatched intent', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']);
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_general_policy: expect.objectContaining({
priority: ['출병', '출병', '일반내정'],
}),
},
mutation: { kind: 'generalPriority', priority: ['출병', '출병', '일반내정'] },
})
);
await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => {
@@ -262,10 +245,11 @@ describe('NPC policy router', () => {
});
const staleCommand = vi.fn(async () => ({
type: 'setNationMeta',
type: 'setNpcPolicy',
ok: false,
code: 'CONFLICT',
nationId: 1,
reason: 'CONFLICT',
reason: '다른 사용자가 정책을 변경했습니다.',
}));
const stale = createContext({ requestCommand: staleCommand });
await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
describe('HTTP idempotency request IDs', () => {
it('is stable for one principal and isolated across users and profiles', () => {
const first = scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' });
expect(first).toBe(scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' }));
expect(first).not.toBe(
scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-b' })
);
expect(first).not.toBe(
scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'che', userId: 'user-a' })
);
});
it('bounds and neutralizes untrusted header content while omitting blank keys', () => {
expect(scopeHttpIdempotencyKey({ rawKey: ' \n\t ', profileId: 'hwe', userId: 'user-a' })).toBeUndefined();
const scoped = scopeHttpIdempotencyKey({
rawKey: `${'x'.repeat(10_000)}:../../unexpected`,
profileId: 'hwe',
userId: null,
});
expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u);
expect(scoped).toHaveLength(69);
});
});
+25 -12
View File
@@ -67,8 +67,14 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const buildContext = (blockChangeScout: boolean) => {
const requestCommand = vi.fn();
const buildContext = () => {
const requestCommand = vi.fn(async () => ({
type: 'setNationSetting' as const,
ok: false as const,
code: 'FORBIDDEN' as const,
nationId: 1,
reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
}));
const db = {
general: {
findFirst: vi.fn(async () => general),
@@ -77,7 +83,7 @@ const buildContext = (blockChangeScout: boolean) => {
findUnique: vi.fn(async () => ({ meta: {} })),
},
worldState: {
findFirst: vi.fn(async () => ({ meta: { block_change_scout: blockChangeScout } })),
findFirst: vi.fn(async () => ({ meta: { block_change_scout: true } })),
},
};
const redisClient = {
@@ -102,14 +108,21 @@ const buildContext = (blockChangeScout: boolean) => {
};
describe('nation scout policy lock', () => {
it('rejects policy changes before daemon dispatch while the scenario lock is enabled', async () => {
const fixture = buildContext(true);
await expect(appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })).rejects.toMatchObject(
{
code: 'FORBIDDEN',
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
}
);
expect(fixture.requestCommand).not.toHaveBeenCalled();
it('lets the engine recheck the scenario lock and maps its rejection', async () => {
const fixture = buildContext();
await expect(
appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationSetting',
userId: 'user-1',
generalId: 1,
nationId: 1,
mutation: { kind: 'blockScout', value: false },
});
expect(fixture.context.db.worldState.findFirst).not.toHaveBeenCalled();
});
});
@@ -16,6 +16,7 @@ import {
} from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
import { createGameApiServer } from '../src/server.js';
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
@@ -41,6 +42,12 @@ const foreignNationId = 99_002;
const fixtureNationIds = [ownerNationId, foreignNationId];
const fixtureWorldId = 990_001;
const mutationRequestPrefix = `security-http-matrix-${process.pid}-`;
const matrixApiEventTypes = [
'messages.send',
'turns.reserved.setGeneral',
'turns.reserved.setNation',
] as const;
const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId];
const secret = 'security-http-e2e-secret';
const redisPrefix = `sammo:security-http:${process.pid}`;
const envKeys = [
@@ -303,7 +310,7 @@ const readReservedMutationState = async () => ({
orderBy: { id: 'asc' },
}),
engineInputEvents: await db.inputEvent.findMany({
where: { target: 'ENGINE', requestId: { startsWith: mutationRequestPrefix } },
where: { target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } },
select: { requestId: true, eventType: true, status: true, actorUserId: true },
orderBy: { sequence: 'asc' },
}),
@@ -315,6 +322,31 @@ const readReservedMutationState = async () => ({
const quotePostgresIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`;
const isMatrixApiInputEvent = (rowJson: string): boolean => {
const row = JSON.parse(rowJson) as { target?: unknown; event_type?: unknown };
return row.target === 'API' && matrixApiEventTypes.includes(row.event_type as (typeof matrixApiEventTypes)[number]);
};
const deleteMatrixInputEvents = async (): Promise<void> => {
await db.inputEvent.deleteMany({
where: {
OR: [
{ requestId: { startsWith: mutationRequestPrefix } },
{ target: 'API', eventType: { in: [...matrixApiEventTypes] } },
{ target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } },
],
},
});
};
const resolveScopedApiRequestId = (idempotencyKey: string, procedure: string, actorUserId: string): string => {
const scopedRequestId = scopeHttpIdempotencyKey({ rawKey: idempotencyKey, profileId, userId: actorUserId });
if (!scopedRequestId) {
throw new Error('matrix idempotency key unexpectedly resolved to an empty request ID');
}
return `${scopedRequestId}:${procedure}`;
};
const readDurableSchemaStateExcludingMatrixApiJournal = async () => {
const tables = await db.$queryRawUnsafe<Array<{ tableName: string }>>(
`SELECT table_name AS "tableName"
@@ -326,16 +358,17 @@ const readDurableSchemaStateExcludingMatrixApiJournal = async () => {
return Promise.all(
tables.map(async ({ tableName }) => {
const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`;
const matrixApiFilter =
tableName === 'input_event' ? `WHERE NOT (target = 'API' AND request_id LIKE $1)` : '';
const rows = await db.$queryRawUnsafe<Array<{ rowJson: string }>>(
`SELECT to_jsonb(snapshot_row)::text AS "rowJson"
FROM ${qualifiedTable} AS snapshot_row
${matrixApiFilter}
ORDER BY to_jsonb(snapshot_row)::text`,
...(matrixApiFilter ? [`${mutationRequestPrefix}%`] : [])
ORDER BY to_jsonb(snapshot_row)::text`
);
return { tableName, rows: rows.map(({ rowJson }) => rowJson) };
return {
tableName,
rows: rows
.map(({ rowJson }) => rowJson)
.filter((rowJson) => tableName !== 'input_event' || !isMatrixApiInputEvent(rowJson)),
};
})
);
};
@@ -389,12 +422,11 @@ const expectApiInputEvent = async (
procedure: string,
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null
): Promise<void> => {
const requestId = `${idempotencyKey}:${procedure}`;
const events = await db.inputEvent.findMany({
// beforeEach removes the whole matrix prefix. Query that complete
// namespace so an extra/rewritten API journal row cannot hide behind
// the full-schema snapshot's one explicitly allowed exclusion.
where: { target: 'API', requestId: { startsWith: mutationRequestPrefix } },
// The HTTP boundary hashes the raw client key together with profile and
// actor. Query the whole procedure matrix so an unexpected extra row
// cannot hide behind the full-schema snapshot's explicit exclusion.
where: { target: 'API', eventType: { in: [...matrixApiEventTypes] } },
select: {
requestId: true,
target: true,
@@ -417,6 +449,7 @@ const expectApiInputEvent = async (
expect(events).toEqual([]);
return;
}
const requestId = resolveScopedApiRequestId(idempotencyKey, procedure, expected.actorUserId);
expect(events).toEqual([
{
requestId,
@@ -638,7 +671,7 @@ integration('game API security over HTTP transport', () => {
afterAll(async () => {
await server?.app.close();
await closeGatewayStatusStub();
await db?.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
if (db) await deleteMatrixInputEvents();
await db?.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
await db?.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
await db?.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
@@ -670,7 +703,7 @@ integration('game API security over HTTP transport', () => {
}, 30_000);
beforeEach(async () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
await deleteMatrixInputEvents();
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
@@ -1055,7 +1088,7 @@ integration('game API security over HTTP transport', () => {
});
expect(
await db.inputEvent.count({
where: { target: 'ENGINE', requestId: { startsWith: idempotencyKey } },
where: { target: 'ENGINE', actorUserId: userId },
})
).toBe(0);
await expect.poll(() => db.readModelOutbox.count()).toBe(1);
@@ -1513,8 +1546,9 @@ integration('game API security over HTTP transport', () => {
expect(await readRealtimeRedisState()).toEqual(redisBefore);
const replayDurableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
const replayRedisBefore = await readRealtimeRedisState();
const replayRequestId = resolveScopedApiRequestId(idempotencyKey, 'turns.reserved.setNation', userId);
const replayJournalBefore = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
where: { requestId: replayRequestId },
});
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
expect(replay.response.status).toBe(409);
@@ -1524,12 +1558,10 @@ integration('game API security over HTTP transport', () => {
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
where: { requestId: replayRequestId },
})
).toEqual(replayJournalBefore);
expect(await db.inputEvent.count({ where: { requestId: `${idempotencyKey}:turns.reserved.setNation` } })).toBe(
1
);
expect(await db.inputEvent.count({ where: { requestId: replayRequestId } })).toBe(1);
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
actorUserId: userId,
status: 'SUCCEEDED',
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import { TournamentStore } from '../src/tournament/store.js';
import { CorruptTournamentProjectionError, TournamentStore } from '../src/tournament/store.js';
class AtomicMemoryRedis {
readonly events: string[] = [];
@@ -130,4 +130,114 @@ describe('TournamentStore source revision', () => {
await expect(store.getSourceRevision()).resolves.toBe('50');
expect(redis.published).toHaveLength(100);
});
it('distinguishes missing projections from malformed JSON and invalid shapes', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('corrupt:default');
const store = new TournamentStore(redis, keys);
await expect(store.getParticipants()).resolves.toEqual([]);
await redis.set(keys.participantsKey, '{broken');
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
await redis.set(keys.participantsKey, JSON.stringify([{ id: 'not-a-number' }]));
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
});
it('rejects corrupt settlement flags instead of silently skipping tournament settlement', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('corrupt-settlement:default');
const store = new TournamentStore(redis, keys);
const canonicalState = {
stage: 0,
phase: 0,
type: 0,
auto: false,
openYear: 185,
openMonth: 2,
termSeconds: 300,
nextAt: '2026-08-17T00:00:00.000Z',
bettingId: 123,
bettingCloseAt: '2026-08-17T00:05:00.000Z',
winnerId: 7,
bettingSettled: false,
rewardSettled: false,
participantsLockedAt: '2026-08-17T00:01:00.000Z',
lastError: 'retryable fixture',
lastErrorAt: '2026-08-17T00:02:00.000Z',
};
await redis.set(keys.stateKey, JSON.stringify(canonicalState));
await expect(store.getState()).resolves.toEqual(canonicalState);
for (const [field, value] of [
['winnerId', '7'],
['bettingId', '123'],
['rewardSettled', 'yes'],
['bettingSettled', 1],
] as const) {
await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value }));
await expect(store.getState(), field).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
}
});
it('validates known optional participant and match projection fields', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('corrupt-optional:default');
const store = new TournamentStore(redis, keys);
const participant = {
id: 7,
name: '관우',
leadership: 90,
strength: 97,
intel: 75,
level: 5,
groupId: 0,
groupNo: 1,
win: 2,
draw: 1,
lose: 0,
gl: 10,
seedRank: 1,
finalRank: 2,
preliminaryGroupId: 0,
preliminaryGroupNo: 1,
preliminaryRank: 2,
preliminaryWin: 2,
preliminaryDraw: 1,
preliminaryLose: 0,
preliminaryGl: 10,
};
const match = {
id: 1,
stage: 7,
roundIndex: 0,
groupId: 0,
attackerId: 7,
defenderId: 8,
winnerId: 7,
log: ['결과'],
logEntries: [
{
phase: 1,
attackerEnergy: 90,
defenderEnergy: 0,
attackerDamage: 10,
defenderDamage: 100,
text: '결과',
},
],
lastEnergy: { attacker: 90, defender: 0 },
};
await redis.set(keys.participantsKey, JSON.stringify([participant]));
await redis.set(keys.matchesKey, JSON.stringify([match]));
await expect(store.getParticipants()).resolves.toEqual([participant]);
await expect(store.getMatches()).resolves.toEqual([match]);
await redis.set(keys.participantsKey, JSON.stringify([{ ...participant, win: '2' }]));
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
await redis.set(keys.matchesKey, JSON.stringify([{ ...match, lastEnergy: { attacker: '90', defender: 0 } }]));
await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
});
});
+21
View File
@@ -295,6 +295,7 @@ describe('troop router permissions and mutations', () => {
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'troopCreate',
userId: 'user-1',
generalId: 1,
troopName: '백마대',
});
@@ -317,11 +318,30 @@ describe('troop router permissions and mutations', () => {
expect(requestCommand).toHaveBeenCalledWith({
type: 'troopCreate',
requestId: 'http-troop-create:troop.create:engine:0:troopCreate',
userId: 'user-1',
generalId: 1,
troopName: '백마대',
});
});
it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => {
const fixture = buildContext({
result: {
type: 'commandRejected',
ok: false,
commandType: 'troopCreate',
reason: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.',
},
});
await expect(
appRouter.createCaller(fixture.context).troop.create({ troopName: '백마대' })
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.',
});
});
it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => {
const assigned = buildContext({
me: buildGeneral({ troopId: 9 }),
@@ -372,6 +392,7 @@ describe('troop router permissions and mutations', () => {
).resolves.toEqual({ ok: true });
expect(authorized.requestCommand).toHaveBeenCalledWith({
type: 'troopKick',
userId: 'user-1',
generalId: 1,
troopId: 1,
targetGeneralId: 3,
+5 -1
View File
@@ -97,6 +97,7 @@ const buildContext = (options: {
metaDevelCost?: number;
auctionTargets?: string[];
clockTick?: number;
requestId?: string;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -202,6 +203,7 @@ const buildContext = (options: {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -247,13 +249,15 @@ describe('vote router actor and permission boundaries', () => {
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
const fixture = buildContext({ general: owned, clockTick: 100 });
const fixture = buildContext({ general: owned, clockTick: 100, requestId: 'http-vote-submit' });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'voteReward',
requestId: 'http-vote-submit:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
voteId: 1,
generalId: 7,
selection: [0],