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> {