fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}자까지 입력할 수 있습니다.`);
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 })
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user