perf: 데몬 소유 명령의 중첩 transaction을 제거
This commit is contained in:
@@ -22,10 +22,12 @@ export type OpenAuctionInput =
|
|||||||
export const openAuctionWithDaemon = async (
|
export const openAuctionWithDaemon = async (
|
||||||
ctx: GameApiContext,
|
ctx: GameApiContext,
|
||||||
generalId: number,
|
generalId: number,
|
||||||
input: OpenAuctionInput
|
input: OpenAuctionInput,
|
||||||
|
requestId?: string
|
||||||
): Promise<{ auctionId: number; closeAt: string }> => {
|
): Promise<{ auctionId: number; closeAt: string }> => {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'auctionOpen',
|
type: 'auctionOpen',
|
||||||
|
...(requestId ? { requestId } : {}),
|
||||||
generalId,
|
generalId,
|
||||||
...input,
|
...input,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
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 type { DatabaseClient, GameApiContext, GeneralRow } from '../../context.js';
|
||||||
import { buildAuctionTimerKeys } from '../../auction/keys.js';
|
import { buildAuctionTimerKeys } from '../../auction/keys.js';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
@@ -322,29 +322,44 @@ export const auctionRouter = router({
|
|||||||
remainPoint: point?.value ?? 0,
|
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 auth = requireAuth(ctx);
|
||||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
await ensureAuctionSeasonActive(ctx.db);
|
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 auth = requireAuth(ctx);
|
||||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
await ensureAuctionSeasonActive(ctx.db);
|
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 auth = requireAuth(ctx);
|
||||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
await ensureAuctionSeasonActive(ctx.db);
|
await ensureAuctionSeasonActive(ctx.db);
|
||||||
return openAuctionWithDaemon(ctx, general.id, {
|
return openAuctionWithDaemon(
|
||||||
auctionType: 'UNIQUE_ITEM',
|
ctx,
|
||||||
itemKey: input.itemKey,
|
general.id,
|
||||||
amount: input.amount,
|
{
|
||||||
});
|
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 auth = requireAuth(ctx);
|
||||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
await ensureAuctionSeasonActive(ctx.db);
|
await ensureAuctionSeasonActive(ctx.db);
|
||||||
@@ -398,6 +413,7 @@ export const auctionRouter = router({
|
|||||||
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'auctionBid',
|
type: 'auctionBid',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidBuyRice:engine:0:auctionBid` } : {}),
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
@@ -419,7 +435,7 @@ export const auctionRouter = router({
|
|||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
bidSellRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
|
bidSellRice: engineAuthedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
|
||||||
const auth = requireAuth(ctx);
|
const auth = requireAuth(ctx);
|
||||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
await ensureAuctionSeasonActive(ctx.db);
|
await ensureAuctionSeasonActive(ctx.db);
|
||||||
@@ -473,6 +489,7 @@ export const auctionRouter = router({
|
|||||||
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'auctionBid',
|
type: 'auctionBid',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidSellRice:engine:0:auctionBid` } : {}),
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
@@ -494,7 +511,7 @@ export const auctionRouter = router({
|
|||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
bidUnique: authedProcedure.input(zUniqueBidInput).mutation(async ({ ctx, input }) => {
|
bidUnique: engineAuthedProcedure.input(zUniqueBidInput).mutation(async ({ ctx, input }) => {
|
||||||
const auth = requireAuth(ctx);
|
const auth = requireAuth(ctx);
|
||||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
await ensureAuctionSeasonActive(ctx.db);
|
await ensureAuctionSeasonActive(ctx.db);
|
||||||
@@ -609,6 +626,7 @@ export const auctionRouter = router({
|
|||||||
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'auctionBid',
|
type: 'auctionBid',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidUnique:engine:0:auctionBid` } : {}),
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { asRecord } from '@sammo-ts/common';
|
|||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
import {
|
import {
|
||||||
accessAuthedProcedure,
|
accessAuthedProcedure,
|
||||||
accessAuthedInputProcedure,
|
|
||||||
accessEngineAuthedProcedure,
|
accessEngineAuthedProcedure,
|
||||||
accessEngineAuthedInputProcedure,
|
accessEngineAuthedInputProcedure,
|
||||||
accessLimitAuthedProcedure,
|
accessLimitAuthedProcedure,
|
||||||
@@ -655,10 +654,11 @@ export const generalRouter = router({
|
|||||||
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
|
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
|
||||||
requestImmediateAction(ctx, input, 'instantRetreat')
|
requestImmediateAction(ctx, input, 'instantRetreat')
|
||||||
),
|
),
|
||||||
vacation: authedProcedure.mutation(async ({ ctx }) => {
|
vacation: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'vacation',
|
type: 'vacation',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.vacation:engine:0:vacation` } : {}),
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
});
|
});
|
||||||
if (!result || result.type !== 'vacation') {
|
if (!result || result.type !== 'vacation') {
|
||||||
@@ -669,10 +669,13 @@ export const generalRouter = router({
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
setMySetting: accessAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
|
setMySetting: accessEngineAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'setMySetting',
|
type: 'setMySetting',
|
||||||
|
...(ctx.requestId
|
||||||
|
? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` }
|
||||||
|
: {}),
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
settings: input,
|
settings: input,
|
||||||
});
|
});
|
||||||
@@ -685,10 +688,11 @@ export const generalRouter = router({
|
|||||||
|
|
||||||
return { ok: true };
|
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 general = await getMyGeneral(ctx);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'dropItem',
|
type: 'dropItem',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.dropItem:engine:0:dropItem` } : {}),
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
itemType: input.itemType,
|
itemType: input.itemType,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
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 { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
ItemLoader,
|
ItemLoader,
|
||||||
@@ -791,7 +791,7 @@ export const inheritRouter = router({
|
|||||||
);
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
openUniqueAuction: authedProcedure
|
openUniqueAuction: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
itemId: z.string(),
|
itemId: z.string(),
|
||||||
@@ -819,11 +819,16 @@ export const inheritRouter = router({
|
|||||||
if (!general) {
|
if (!general) {
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||||
}
|
}
|
||||||
const result = await openAuctionWithDaemon(ctx, general.id, {
|
const result = await openAuctionWithDaemon(
|
||||||
auctionType: 'UNIQUE_ITEM',
|
ctx,
|
||||||
itemKey: input.itemId,
|
general.id,
|
||||||
amount: input.amount,
|
{
|
||||||
});
|
auctionType: 'UNIQUE_ITEM',
|
||||||
|
itemKey: input.itemId,
|
||||||
|
amount: input.amount,
|
||||||
|
},
|
||||||
|
ctx.requestId ? `${ctx.requestId}:inherit.openUniqueAuction:engine:0:auctionOpen` : undefined
|
||||||
|
);
|
||||||
return { ok: true, ...result };
|
return { ok: true, ...result };
|
||||||
}),
|
}),
|
||||||
checkOwner: authedProcedure
|
checkOwner: authedProcedure
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authedProcedure } from '../../../trpc.js';
|
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||||
import { getMyGeneral } from '../../shared/general.js';
|
import { getMyGeneral } from '../../shared/general.js';
|
||||||
|
|
||||||
export const appoint = authedProcedure
|
export const appoint = engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
destGeneralId: z.number().int().nonnegative(),
|
destGeneralId: z.number().int().nonnegative(),
|
||||||
@@ -16,6 +16,7 @@ export const appoint = authedProcedure
|
|||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'appoint',
|
type: 'appoint',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.appoint:engine:0:appoint` } : {}),
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
destGeneralId: input.destGeneralId,
|
destGeneralId: input.destGeneralId,
|
||||||
destCityId: input.destCityId,
|
destCityId: input.destCityId,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authedProcedure } from '../../../trpc.js';
|
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||||
import { getMyGeneral } from '../../shared/general.js';
|
import { getMyGeneral } from '../../shared/general.js';
|
||||||
|
|
||||||
export const changePermission = authedProcedure
|
export const changePermission = engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
isAmbassador: z.boolean(),
|
isAmbassador: z.boolean(),
|
||||||
@@ -18,6 +18,9 @@ export const changePermission = authedProcedure
|
|||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'changePermission',
|
type: 'changePermission',
|
||||||
|
...(ctx.requestId
|
||||||
|
? { requestId: `${ctx.requestId}:nation.changePermission:engine:0:changePermission` }
|
||||||
|
: {}),
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
isAmbassador: input.isAmbassador,
|
isAmbassador: input.isAmbassador,
|
||||||
targetGeneralIds: input.targetGeneralIds,
|
targetGeneralIds: input.targetGeneralIds,
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authedProcedure } from '../../../trpc.js';
|
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||||
import { getMyGeneral } from '../../shared/general.js';
|
import { getMyGeneral } from '../../shared/general.js';
|
||||||
|
|
||||||
export const kick = authedProcedure
|
export const kick = engineAuthedProcedure
|
||||||
.input(z.object({ destGeneralId: z.number().int().positive() }))
|
.input(z.object({ destGeneralId: z.number().int().positive() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'kick',
|
type: 'kick',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.kick:engine:0:kick` } : {}),
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
destGeneralId: input.destGeneralId,
|
destGeneralId: input.destGeneralId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
|
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';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
|
|
||||||
const troopNameSchema = z
|
const troopNameSchema = z
|
||||||
@@ -162,7 +162,7 @@ export const troopRouter = router({
|
|||||||
troops: mappedTroops,
|
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 me = await getMyGeneral(ctx);
|
||||||
const troopName = normalizeRequiredTroopName(input.troopName);
|
const troopName = normalizeRequiredTroopName(input.troopName);
|
||||||
if (me.troopId !== 0) {
|
if (me.troopId !== 0) {
|
||||||
@@ -179,6 +179,7 @@ export const troopRouter = router({
|
|||||||
}
|
}
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'troopCreate',
|
type: 'troopCreate',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.create:engine:0:troopCreate` } : {}),
|
||||||
generalId: me.id,
|
generalId: me.id,
|
||||||
troopName,
|
troopName,
|
||||||
});
|
});
|
||||||
@@ -190,25 +191,29 @@ export const troopRouter = router({
|
|||||||
}
|
}
|
||||||
return { ok: true, troopId: result.troopId, troopName: result.troopName };
|
return { ok: true, troopId: result.troopId, troopName: result.troopName };
|
||||||
}),
|
}),
|
||||||
join: authedProcedure.input(z.object({ troopId: z.number().int().positive() })).mutation(async ({ ctx, input }) => {
|
join: engineAuthedProcedure
|
||||||
const me = await getMyGeneral(ctx);
|
.input(z.object({ troopId: z.number().int().positive() }))
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
.mutation(async ({ ctx, input }) => {
|
||||||
type: 'troopJoin',
|
const me = await getMyGeneral(ctx);
|
||||||
generalId: me.id,
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
troopId: input.troopId,
|
type: 'troopJoin',
|
||||||
});
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.join:engine:0:troopJoin` } : {}),
|
||||||
if (!result || result.type !== 'troopJoin') {
|
generalId: me.id,
|
||||||
return assertCommandResult(result, 'troopJoin');
|
troopId: input.troopId,
|
||||||
}
|
});
|
||||||
if (!result.ok) {
|
if (!result || result.type !== 'troopJoin') {
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason });
|
return assertCommandResult(result, 'troopJoin');
|
||||||
}
|
}
|
||||||
return { ok: true };
|
if (!result.ok) {
|
||||||
}),
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason });
|
||||||
exit: authedProcedure.mutation(async ({ ctx }) => {
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
exit: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const me = await getMyGeneral(ctx);
|
const me = await getMyGeneral(ctx);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'troopExit',
|
type: 'troopExit',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.exit:engine:0:troopExit` } : {}),
|
||||||
generalId: me.id,
|
generalId: me.id,
|
||||||
});
|
});
|
||||||
if (!result || result.type !== 'troopExit') {
|
if (!result || result.type !== 'troopExit') {
|
||||||
@@ -219,7 +224,7 @@ export const troopRouter = router({
|
|||||||
}
|
}
|
||||||
return { ok: true, wasLeader: result.wasLeader };
|
return { ok: true, wasLeader: result.wasLeader };
|
||||||
}),
|
}),
|
||||||
kick: authedProcedure
|
kick: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
troopId: z.number().int().positive(),
|
troopId: z.number().int().positive(),
|
||||||
@@ -250,6 +255,7 @@ export const troopRouter = router({
|
|||||||
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'troopKick',
|
type: 'troopKick',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.kick:engine:0:troopKick` } : {}),
|
||||||
generalId: me.id,
|
generalId: me.id,
|
||||||
troopId: input.troopId,
|
troopId: input.troopId,
|
||||||
targetGeneralId: input.targetGeneralId,
|
targetGeneralId: input.targetGeneralId,
|
||||||
@@ -263,7 +269,7 @@ export const troopRouter = router({
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
rename: authedProcedure
|
rename: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
troopId: z.number().int().positive(),
|
troopId: z.number().int().positive(),
|
||||||
@@ -291,6 +297,7 @@ export const troopRouter = router({
|
|||||||
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'troopRename',
|
type: 'troopRename',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.rename:engine:0:troopRename` } : {}),
|
||||||
generalId: me.id,
|
generalId: me.id,
|
||||||
troopId: input.troopId,
|
troopId: input.troopId,
|
||||||
troopName,
|
troopName,
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ const buildContext = (options: {
|
|||||||
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
|
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
|
||||||
isUnited?: number;
|
isUnited?: number;
|
||||||
isunited?: number;
|
isunited?: number;
|
||||||
|
requestId?: string;
|
||||||
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
}) => {
|
}) => {
|
||||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
@@ -114,6 +116,7 @@ const buildContext = (options: {
|
|||||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
};
|
};
|
||||||
const db = {
|
const db = {
|
||||||
|
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
@@ -154,6 +157,7 @@ const buildContext = (options: {
|
|||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth,
|
auth,
|
||||||
|
...(options.requestId ? { requestId: options.requestId } : {}),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
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 () => {
|
it('rejects auction mutations after unification before sending a daemon command', async () => {
|
||||||
const fixture = buildContext({ isUnited: 0, isunited: 2 });
|
const fixture = buildContext({ isUnited: 0, isunited: 2 });
|
||||||
const caller = appRouter.createCaller(fixture.context);
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ const createContext = (options: {
|
|||||||
troopLeaderAction?: string | null;
|
troopLeaderAction?: string | null;
|
||||||
refreshScore?: number;
|
refreshScore?: number;
|
||||||
refreshScoreTotal?: number;
|
refreshScoreTotal?: number;
|
||||||
|
requestId?: string;
|
||||||
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
}) => {
|
}) => {
|
||||||
const me = options.me === undefined ? buildGeneral() : options.me;
|
const me = options.me === undefined ? buildGeneral() : options.me;
|
||||||
const targets = options.targets ?? (me ? [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
|
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
|
||||||
);
|
);
|
||||||
const db = {
|
const db = {
|
||||||
|
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async () => me),
|
findFirst: vi.fn(async () => me),
|
||||||
findUnique: generalFindUnique,
|
findUnique: generalFindUnique,
|
||||||
@@ -190,6 +193,7 @@ const createContext = (options: {
|
|||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth,
|
auth,
|
||||||
|
...(options.requestId ? { requestId: options.requestId } : {}),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
@@ -446,6 +450,29 @@ describe('in-game my information ownership', () => {
|
|||||||
expect(fixture.db.general.update).not.toHaveBeenCalled();
|
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 () => {
|
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 otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
|
||||||
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
|
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
|
||||||
|
|||||||
@@ -68,11 +68,14 @@ const createContext = (
|
|||||||
me?: GeneralRow;
|
me?: GeneralRow;
|
||||||
db?: Record<string, unknown>;
|
db?: Record<string, unknown>;
|
||||||
requestCommand?: ReturnType<typeof vi.fn>;
|
requestCommand?: ReturnType<typeof vi.fn>;
|
||||||
|
requestId?: string;
|
||||||
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
} = {}
|
} = {}
|
||||||
): GameApiContext => {
|
): GameApiContext => {
|
||||||
const requestCommand = options.requestCommand ?? vi.fn();
|
const requestCommand = options.requestCommand ?? vi.fn();
|
||||||
const redisClient = { get: async () => null, set: async () => null };
|
const redisClient = { get: async () => null, set: async () => null };
|
||||||
const db = {
|
const db = {
|
||||||
|
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||||
general: { findFirst: vi.fn(async () => options.me ?? baseGeneral) },
|
general: { findFirst: vi.fn(async () => options.me ?? baseGeneral) },
|
||||||
...options.db,
|
...options.db,
|
||||||
};
|
};
|
||||||
@@ -83,6 +86,7 @@ const createContext = (
|
|||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth,
|
auth,
|
||||||
|
...(options.requestId ? { requestId: options.requestId } : {}),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
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 () => {
|
it('rejects oversized and duplicate permission selections before daemon dispatch', async () => {
|
||||||
const requestCommand = vi.fn();
|
const requestCommand = vi.fn();
|
||||||
const caller = appRouter.createCaller(createContext({ requestCommand }));
|
const caller = appRouter.createCaller(createContext({ requestCommand }));
|
||||||
|
|||||||
@@ -75,11 +75,14 @@ const buildContext = (options: {
|
|||||||
troop?: { troopLeaderId: number; nationId: number; name: string } | null;
|
troop?: { troopLeaderId: number; nationId: number; name: string } | null;
|
||||||
nationMeta?: Record<string, unknown>;
|
nationMeta?: Record<string, unknown>;
|
||||||
auth?: GameSessionTokenPayload | null;
|
auth?: GameSessionTokenPayload | null;
|
||||||
|
requestId?: string;
|
||||||
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
||||||
}) => {
|
}) => {
|
||||||
const me = options.me ?? buildGeneral();
|
const me = options.me ?? buildGeneral();
|
||||||
const requestCommand = vi.fn(async () => options.result);
|
const requestCommand = vi.fn(async () => options.result);
|
||||||
const db = {
|
const db = {
|
||||||
|
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
me.userId === where.userId ? me : null
|
me.userId === where.userId ? me : null
|
||||||
@@ -123,6 +126,7 @@ const buildContext = (options: {
|
|||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth: options.auth === undefined ? auth : options.auth,
|
auth: options.auth === undefined ? auth : options.auth,
|
||||||
|
...(options.requestId ? { requestId: options.requestId } : {}),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
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 () => {
|
it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => {
|
||||||
const assigned = buildContext({
|
const assigned = buildContext({
|
||||||
me: buildGeneral({ troopId: 9 }),
|
me: buildGeneral({ troopId: 9 }),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# game-api 턴 데몬 procedure/transaction inventory
|
||||||
|
|
||||||
|
## 범위와 판정 기준
|
||||||
|
|
||||||
|
`app/game-api/src/router/**`의 mutation에서 직접 또는 router 전용 helper를 거쳐
|
||||||
|
`ctx.turnDaemon.requestCommand()`를 호출하는 46개 route를 조사했다. `authedProcedure`,
|
||||||
|
`accessAuthedProcedure`, `accessAuthedInputProcedure`는 mutation일 때
|
||||||
|
`app/game-api/src/trpc.ts:42-75`의 API `input_event` transaction을 만든다.
|
||||||
|
`engineAuthedProcedure`, `accessEngineAuthedProcedure`,
|
||||||
|
`accessEngineAuthedInputProcedure`는 인증/접속 계측만 수행하며 API outer transaction을
|
||||||
|
만들지 않는다(`app/game-api/src/trpc.ts:150-175`).
|
||||||
|
|
||||||
|
판정은 다음과 같다.
|
||||||
|
|
||||||
|
- **ENGINE 전환**: API는 actor/입력과 조기 오류를 읽을 뿐 durable DB 변경은 ENGINE
|
||||||
|
transaction이 소유하고, ENGINE handler가 mutation 직전 mutable state를 다시 검증한다.
|
||||||
|
- **혼합/saga 필요**: API DB write, Redis 원본 상태, 보상 명령 또는 API snapshot에서만
|
||||||
|
수행하는 권한/값 합성이 ENGINE 변경과 결합한다. procedure만 바꾸지 않는다.
|
||||||
|
- **기존 정상**: 이미 ENGINE procedure이고 API outer input event가 없다.
|
||||||
|
|
||||||
|
ENGINE 전환 route의 명시적 `requestId`는 기존 middleware가 만든 child identity
|
||||||
|
`<http request id>:<trpc path>:engine:0:<command type>`를 유지한다. 따라서 deploy 전후
|
||||||
|
동일 HTTP request identity의 ENGINE event가 달라지지 않는다.
|
||||||
|
|
||||||
|
## 이번에 ENGINE procedure로 전환
|
||||||
|
|
||||||
|
| route | 이전 outer transaction | API-side 작업 | ENGINE 소유 근거 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `general.vacation`, `general.setMySetting`, `general.dropItem` | 있음 | session-owned general 조회만 수행 | route `app/game-api/src/router/general/index.ts:657-706`; ENGINE이 general 존재/현재 설정/보유 item을 다시 검사하고 변경 `app/game-engine/src/turn/worldCommandHandler.ts:1476`, `:1514`, `:1571` |
|
||||||
|
| `nation.appoint`, `nation.changePermission`, `nation.kick` | 있음 | session actor 조회만 수행 | route `app/game-api/src/router/nation/endpoints/appoint.ts:7`, `changePermission.ts:7`, `kick.ts:7`; ENGINE이 actor 직위, 국가, 대상/도시를 다시 검사 `app/game-engine/src/turn/worldCommandHandler.ts:1648`, `:1718`, `:1888` |
|
||||||
|
| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | 있음 | actor 및 조기 권한/대상 조회; API DB write 없음 | route `app/game-api/src/router/troop/index.ts:165-314`; 동일 membership/leader/nation/name 검증과 mutation은 ENGINE `app/game-engine/src/turn/worldCommandHandler.ts:924`, `:1012`, `:1079`, `:1128`, `:1180` |
|
||||||
|
| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | 있음 | auction/general/world 조기 validation; commit 뒤 Redis timer index 갱신 | route `app/game-api/src/router/auction/index.ts:325-649`; auction open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction `app/game-engine/src/turn/worldCommandHandler.ts:1613`, `app/game-engine/src/auction/bidder.ts:183-550`. Redis zset은 durable auction row에서 재구성 가능한 scheduler index이며 API DB transaction의 일부가 아니었다. |
|
||||||
|
| `inherit.openUniqueAuction` | 있음 | world/general/minimum bid 조기 validation; inheritance point 차감 없음 | route `app/game-api/src/router/inherit/index.ts:794-835`; 공통 `auctionOpen` ENGINE handler와 Redis timer index만 사용 |
|
||||||
|
|
||||||
|
합계 18개 route다. 모든 전환은 procedure 변경과 stable ENGINE request ID만 포함하며
|
||||||
|
ENGINE handler, DB schema, journal/publisher foundation은 변경하지 않았다.
|
||||||
|
|
||||||
|
## 혼합 또는 validation 이관이 먼저 필요한 route
|
||||||
|
|
||||||
|
| route | 현재 procedure / outer transaction | 보류 근거 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique` | `authedProcedure`, 있음 (`app/game-api/src/router/inherit/index.ts:343`, `:405`, `:486`, `:542`, `:599`, `:746`) | ENGINE `patchGeneral` 뒤 API transaction이 inheritance point, inheritance log, 일부 user-state를 쓴다(`:388-402`, `:464-483`, `:523-539`, `:581-596`, `:700-742`, `:777-790`). 현재 outer transaction도 먼저 commit된 ENGINE 변경을 rollback하지 못한다. 한 ENGINE command로 합치거나 durable saga가 필요하다. |
|
||||||
|
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. ENGINE `setNationMeta`는 `_updatedAt` CAS만 검사하고 actor 권한을 알지 못한다(`app/game-engine/src/turn/worldCommandHandler.ts:430-472`). actor/permission을 command와 ENGINE validation으로 옮긴 뒤 전환한다. |
|
||||||
|
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, 있음 (`app/game-api/src/router/npc/index.ts:540`, `:703`, `:756`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 같은 `setNationMeta` CAS를 사용한다(`:540-702`, `:703-755`, `:756-807`). ENGINE이 권한/합성 의미를 소유하지 않는다. |
|
||||||
|
| `tournament.join`, `tournament.placeBet` | `authedProcedure`, 있음 (`app/game-api/src/router/tournament/index.ts:376`, `:523`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다(`:376-463`, `:523-628`). 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. |
|
||||||
|
| `vote.submitVote` | `authedProcedure`, 있음 (`app/game-api/src/router/vote/index.ts:349-528`) | API transaction이 vote row를 insert한 뒤 ENGINE `voteReward`를 기다리고 commit 뒤 front-status publish를 수행한다. vote/reward 단일 소유 command 또는 idempotent saga 없이는 분리할 수 없다. 이 작업에서는 vote journal/publisher를 수정하지 않았다. |
|
||||||
|
|
||||||
|
합계 19개 route다. 특히 inheritance/vote의 현재 outer transaction은 API 절반만
|
||||||
|
rollback하므로 “원자적”이라고 간주하면 안 된다.
|
||||||
|
|
||||||
|
## 이미 API outer transaction이 없는 정상 route
|
||||||
|
|
||||||
|
| route | 근거 |
|
||||||
|
| --- | --- |
|
||||||
|
| `general.adjustIcon` | `engineAuthedProcedure`; `app/game-api/src/router/general/index.ts:584-610`. helper가 stable account-icon request ID로 ENGINE command를 보냄. |
|
||||||
|
| `general.ensureDieOnPrestartStatus`, `general.dieOnPrestart`, `general.buildNationCandidate`, `general.instantRetreat` | `accessEngineAuthedProcedure`/`accessEngineAuthedInputProcedure`; `app/game-api/src/router/general/index.ts:612-656`. user/general 조회는 outer transaction 밖이고 command마다 stable request ID가 있다. |
|
||||||
|
| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; `app/game-api/src/router/join/index.ts:390`, `:434`, `:462`, `:585`. client request ID가 있으면 user-scoped durable ENGINE identity를 사용한다. |
|
||||||
|
|
||||||
|
합계 9개 route다.
|
||||||
|
|
||||||
|
## 검증 계약
|
||||||
|
|
||||||
|
- `app/game-api/test/inGameMenuPermissions.test.ts`: converted access-engine route가 mock
|
||||||
|
`$transaction`을 호출하지 않고 기존 형식의 stable ENGINE request ID를 전달한다.
|
||||||
|
- `app/game-api/test/nationPersonnelRouter.test.ts`: nation personnel route의 동일 계약을
|
||||||
|
검증한다.
|
||||||
|
- `app/game-api/test/troopRouter.test.ts`: troop mutation의 동일 계약을 검증한다.
|
||||||
|
- `app/game-api/test/auctionRouter.test.ts`: auction mutation이 API transaction 없이
|
||||||
|
daemon command와 Redis timer projection을 완료하는 계약을 검증한다.
|
||||||
|
- raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router`와
|
||||||
|
`openAuctionWithDaemon`, `patchGeneral`, `updateNationMeta`,
|
||||||
|
`adjustAccountIconForUser` caller 검색을 함께 실행해야 helper 경유 route를 놓치지 않는다.
|
||||||
Reference in New Issue
Block a user