perf: 데몬 소유 명령의 중첩 transaction을 제거

This commit is contained in:
2026-08-16 18:04:57 +00:00
parent 86733b99b7
commit eb47de0e76
13 changed files with 273 additions and 52 deletions
+3 -1
View File
@@ -22,10 +22,12 @@ export type OpenAuctionInput =
export const openAuctionWithDaemon = async (
ctx: GameApiContext,
generalId: number,
input: OpenAuctionInput
input: OpenAuctionInput,
requestId?: string
): Promise<{ auctionId: number; closeAt: string }> => {
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionOpen',
...(requestId ? { requestId } : {}),
generalId,
...input,
});
+32 -14
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../../context.js';
import { buildAuctionTimerKeys } from '../../auction/keys.js';
import { GamePrisma } from '@sammo-ts/infra';
@@ -322,29 +322,44 @@ export const auctionRouter = router({
remainPoint: point?.value ?? 0,
};
}),
openBuyRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
openBuyRice: engineAuthedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
const auth = requireAuth(ctx);
const general = await ensureGeneral(ctx.db, auth.user.id);
await ensureAuctionSeasonActive(ctx.db);
return openAuctionWithDaemon(ctx, general.id, { auctionType: 'BUY_RICE', ...input });
return openAuctionWithDaemon(
ctx,
general.id,
{ auctionType: 'BUY_RICE', ...input },
ctx.requestId ? `${ctx.requestId}:auction.openBuyRice:engine:0:auctionOpen` : undefined
);
}),
openSellRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
openSellRice: engineAuthedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
const auth = requireAuth(ctx);
const general = await ensureGeneral(ctx.db, auth.user.id);
await ensureAuctionSeasonActive(ctx.db);
return openAuctionWithDaemon(ctx, general.id, { auctionType: 'SELL_RICE', ...input });
return openAuctionWithDaemon(
ctx,
general.id,
{ auctionType: 'SELL_RICE', ...input },
ctx.requestId ? `${ctx.requestId}:auction.openSellRice:engine:0:auctionOpen` : undefined
);
}),
openUnique: authedProcedure.input(zOpenUniqueInput).mutation(async ({ ctx, input }) => {
openUnique: engineAuthedProcedure.input(zOpenUniqueInput).mutation(async ({ ctx, input }) => {
const auth = requireAuth(ctx);
const general = await ensureGeneral(ctx.db, auth.user.id);
await ensureAuctionSeasonActive(ctx.db);
return openAuctionWithDaemon(ctx, general.id, {
auctionType: 'UNIQUE_ITEM',
itemKey: input.itemKey,
amount: input.amount,
});
return openAuctionWithDaemon(
ctx,
general.id,
{
auctionType: 'UNIQUE_ITEM',
itemKey: input.itemKey,
amount: input.amount,
},
ctx.requestId ? `${ctx.requestId}:auction.openUnique:engine:0:auctionOpen` : undefined
);
}),
bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
bidBuyRice: engineAuthedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
const auth = requireAuth(ctx);
const general = await ensureGeneral(ctx.db, auth.user.id);
await ensureAuctionSeasonActive(ctx.db);
@@ -398,6 +413,7 @@ export const auctionRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionBid',
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidBuyRice:engine:0:auctionBid` } : {}),
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
@@ -419,7 +435,7 @@ export const auctionRouter = router({
return { ok: true };
}),
bidSellRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
bidSellRice: engineAuthedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
const auth = requireAuth(ctx);
const general = await ensureGeneral(ctx.db, auth.user.id);
await ensureAuctionSeasonActive(ctx.db);
@@ -473,6 +489,7 @@ export const auctionRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionBid',
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidSellRice:engine:0:auctionBid` } : {}),
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
@@ -494,7 +511,7 @@ export const auctionRouter = router({
return { ok: true };
}),
bidUnique: authedProcedure.input(zUniqueBidInput).mutation(async ({ ctx, input }) => {
bidUnique: engineAuthedProcedure.input(zUniqueBidInput).mutation(async ({ ctx, input }) => {
const auth = requireAuth(ctx);
const general = await ensureGeneral(ctx.db, auth.user.id);
await ensureAuctionSeasonActive(ctx.db);
@@ -609,6 +626,7 @@ export const auctionRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionBid',
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidUnique:engine:0:auctionBid` } : {}),
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
+8 -4
View File
@@ -7,7 +7,6 @@ import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import {
accessAuthedProcedure,
accessAuthedInputProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
@@ -655,10 +654,11 @@ export const generalRouter = router({
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
requestImmediateAction(ctx, input, 'instantRetreat')
),
vacation: authedProcedure.mutation(async ({ ctx }) => {
vacation: engineAuthedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'vacation',
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.vacation:engine:0:vacation` } : {}),
generalId: general.id,
});
if (!result || result.type !== 'vacation') {
@@ -669,10 +669,13 @@ export const generalRouter = router({
}
return { ok: true };
}),
setMySetting: accessAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
setMySetting: accessEngineAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'setMySetting',
...(ctx.requestId
? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` }
: {}),
generalId: general.id,
settings: input,
});
@@ -685,10 +688,11 @@ export const generalRouter = router({
return { ok: true };
}),
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
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,
});
+12 -7
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
import {
ItemLoader,
@@ -791,7 +791,7 @@ export const inheritRouter = router({
);
return { ok: true };
}),
openUniqueAuction: authedProcedure
openUniqueAuction: engineAuthedProcedure
.input(
z.object({
itemId: z.string(),
@@ -819,11 +819,16 @@ export const inheritRouter = router({
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
const result = await openAuctionWithDaemon(ctx, general.id, {
auctionType: 'UNIQUE_ITEM',
itemKey: input.itemId,
amount: input.amount,
});
const result = await openAuctionWithDaemon(
ctx,
general.id,
{
auctionType: 'UNIQUE_ITEM',
itemKey: input.itemId,
amount: input.amount,
},
ctx.requestId ? `${ctx.requestId}:inherit.openUniqueAuction:engine:0:auctionOpen` : undefined
);
return { ok: true, ...result };
}),
checkOwner: authedProcedure
@@ -1,10 +1,10 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure } from '../../../trpc.js';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
export const appoint = authedProcedure
export const appoint = engineAuthedProcedure
.input(
z.object({
destGeneralId: z.number().int().nonnegative(),
@@ -16,6 +16,7 @@ export const appoint = authedProcedure
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'appoint',
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.appoint:engine:0:appoint` } : {}),
generalId: general.id,
destGeneralId: input.destGeneralId,
destCityId: input.destCityId,
@@ -1,10 +1,10 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure } from '../../../trpc.js';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
export const changePermission = authedProcedure
export const changePermission = engineAuthedProcedure
.input(
z.object({
isAmbassador: z.boolean(),
@@ -18,6 +18,9 @@ export const changePermission = authedProcedure
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'changePermission',
...(ctx.requestId
? { requestId: `${ctx.requestId}:nation.changePermission:engine:0:changePermission` }
: {}),
generalId: general.id,
isAmbassador: input.isAmbassador,
targetGeneralIds: input.targetGeneralIds,
@@ -1,15 +1,16 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure } from '../../../trpc.js';
import { engineAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
export const kick = authedProcedure
export const kick = engineAuthedProcedure
.input(z.object({ destGeneralId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'kick',
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.kick:engine:0:kick` } : {}),
generalId: general.id,
destGeneralId: input.destGeneralId,
});
+27 -20
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { accessAuthedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
const troopNameSchema = z
@@ -162,7 +162,7 @@ export const troopRouter = router({
troops: mappedTroops,
};
}),
create: authedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => {
create: engineAuthedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const troopName = normalizeRequiredTroopName(input.troopName);
if (me.troopId !== 0) {
@@ -179,6 +179,7 @@ export const troopRouter = router({
}
const result = await ctx.turnDaemon.requestCommand({
type: 'troopCreate',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.create:engine:0:troopCreate` } : {}),
generalId: me.id,
troopName,
});
@@ -190,25 +191,29 @@ export const troopRouter = router({
}
return { ok: true, troopId: result.troopId, troopName: result.troopName };
}),
join: authedProcedure.input(z.object({ troopId: z.number().int().positive() })).mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
generalId: me.id,
troopId: input.troopId,
});
if (!result || result.type !== 'troopJoin') {
return assertCommandResult(result, 'troopJoin');
}
if (!result.ok) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason });
}
return { ok: true };
}),
exit: authedProcedure.mutation(async ({ ctx }) => {
join: engineAuthedProcedure
.input(z.object({ troopId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.join:engine:0:troopJoin` } : {}),
generalId: me.id,
troopId: input.troopId,
});
if (!result || result.type !== 'troopJoin') {
return assertCommandResult(result, 'troopJoin');
}
if (!result.ok) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason });
}
return { ok: true };
}),
exit: engineAuthedProcedure.mutation(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopExit',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.exit:engine:0:troopExit` } : {}),
generalId: me.id,
});
if (!result || result.type !== 'troopExit') {
@@ -219,7 +224,7 @@ export const troopRouter = router({
}
return { ok: true, wasLeader: result.wasLeader };
}),
kick: authedProcedure
kick: engineAuthedProcedure
.input(
z.object({
troopId: z.number().int().positive(),
@@ -250,6 +255,7 @@ export const troopRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'troopKick',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.kick:engine:0:troopKick` } : {}),
generalId: me.id,
troopId: input.troopId,
targetGeneralId: input.targetGeneralId,
@@ -263,7 +269,7 @@ export const troopRouter = router({
}
return { ok: true };
}),
rename: authedProcedure
rename: engineAuthedProcedure
.input(
z.object({
troopId: z.number().int().positive(),
@@ -291,6 +297,7 @@ export const troopRouter = router({
const result = await ctx.turnDaemon.requestCommand({
type: 'troopRename',
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.rename:engine:0:troopRename` } : {}),
generalId: me.id,
troopId: input.troopId,
troopName,
+32
View File
@@ -78,6 +78,8 @@ const buildContext = (options: {
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
isUnited?: number;
isunited?: number;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -114,6 +116,7 @@ const buildContext = (options: {
updatedAt: new Date('2026-07-26T00:00:00Z'),
};
const db = {
...(options.transaction ? { $transaction: options.transaction } : {}),
$queryRaw: queryRaw,
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
@@ -154,6 +157,7 @@ const buildContext = (options: {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -221,6 +225,34 @@ describe('auction router actor and permission boundaries', () => {
});
});
it('opens an auction without an API input-event transaction and preserves the ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({ requestId: 'http-auction-open', transaction });
await expect(
appRouter.createCaller(fixture.context).auction.openBuyRice({
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
})
).resolves.toMatchObject({ auctionId: 91 });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionOpen',
requestId: 'http-auction-open:auction.openBuyRice:engine:0:auctionOpen',
auctionType: 'BUY_RICE',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
});
});
it('rejects auction mutations after unification before sending a daemon command', async () => {
const fixture = buildContext({ isUnited: 0, isunited: 2 });
const caller = appRouter.createCaller(fixture.context);
@@ -85,6 +85,8 @@ const createContext = (options: {
troopLeaderAction?: string | null;
refreshScore?: number;
refreshScoreTotal?: number;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
}) => {
const me = options.me === undefined ? buildGeneral() : options.me;
const targets = options.targets ?? (me ? [me] : []);
@@ -94,6 +96,7 @@ const createContext = (options: {
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
);
const db = {
...(options.transaction ? { $transaction: options.transaction } : {}),
general: {
findFirst: vi.fn(async () => me),
findUnique: generalFindUnique,
@@ -190,6 +193,7 @@ const createContext = (options: {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -446,6 +450,29 @@ describe('in-game my information ownership', () => {
expect(fixture.db.general.update).not.toHaveBeenCalled();
});
it('sends settings directly to ENGINE without creating an API input event', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({
requestId: 'http-general-setting',
transaction,
requestCommand,
});
await expect(
appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })
).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'setMySetting',
requestId: 'http-general-setting:general.setMySetting:engine:0:setMySetting',
generalId: 7,
settings: { tnmt: 1 },
});
});
it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => {
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
@@ -68,11 +68,14 @@ const createContext = (
me?: GeneralRow;
db?: Record<string, unknown>;
requestCommand?: ReturnType<typeof vi.fn>;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
} = {}
): GameApiContext => {
const requestCommand = options.requestCommand ?? vi.fn();
const redisClient = { get: async () => null, set: async () => null };
const db = {
...(options.transaction ? { $transaction: options.transaction } : {}),
general: { findFirst: vi.fn(async () => options.me ?? baseGeneral) },
...options.db,
};
@@ -83,6 +86,7 @@ const createContext = (
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -139,6 +143,25 @@ describe('nation personnel router', () => {
]);
});
it('keeps nation personnel commands out of the API transaction and gives ENGINE a stable request id', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const requestCommand = vi.fn(async () => ({ type: 'kick', ok: true, generalId: 22 }));
const caller = appRouter.createCaller(
createContext({ requestId: 'http-nation-kick', transaction, requestCommand })
);
await expect(caller.nation.kick({ destGeneralId: 8 })).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'kick',
requestId: 'http-nation-kick:nation.kick:engine:0:kick',
generalId: 22,
destGeneralId: 8,
});
});
it('rejects oversized and duplicate permission selections before daemon dispatch', async () => {
const requestCommand = vi.fn();
const caller = appRouter.createCaller(createContext({ requestCommand }));
+26
View File
@@ -75,11 +75,14 @@ const buildContext = (options: {
troop?: { troopLeaderId: number; nationId: number; name: string } | null;
nationMeta?: Record<string, unknown>;
auth?: GameSessionTokenPayload | null;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
}) => {
const me = options.me ?? buildGeneral();
const requestCommand = vi.fn(async () => options.result);
const db = {
...(options.transaction ? { $transaction: options.transaction } : {}),
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
me.userId === where.userId ? me : null
@@ -123,6 +126,7 @@ const buildContext = (options: {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: options.auth === undefined ? auth : options.auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -189,6 +193,28 @@ describe('troop router permissions and mutations', () => {
});
});
it('dispatches a stable ENGINE request without opening an API input-event transaction', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const { context, requestCommand } = buildContext({
requestId: 'http-troop-create',
transaction,
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
});
await expect(appRouter.createCaller(context).troop.create({ troopName: '백마대' })).resolves.toMatchObject({
ok: true,
});
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'troopCreate',
requestId: 'http-troop-create:troop.create:engine:0:troopCreate',
generalId: 1,
troopName: '백마대',
});
});
it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => {
const assigned = buildContext({
me: buildGeneral({ troopId: 9 }),