fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다

This commit is contained in:
2026-10-10 01:56:31 +09:00
parent 5771701007
commit 4e668b9fb6
31 changed files with 889 additions and 238 deletions
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true },
select: {
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
clockTick: true,
meta: true,
},
});
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
OR (
@@ -269,6 +269,21 @@ const zSetNpcPolicy = z.object({
]),
});
const zTournamentAdjustGeneral = z.object({
type: z.literal('tournamentAdjustGeneral'),
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
userId: z.string().min(1),
generalId: zSafeInteger.positive(),
goldDelta: zFiniteNumber,
betGoldDelta: zFiniteNumber.optional(),
minGoldAfter: zFiniteNumber.nonnegative(),
});
const zTournamentSeedNpcBets = z.object({
type: z.literal('tournamentSeedNpcBets'),
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
});
const zAdjustGeneralResources = z.object({
type: z.literal('adjustGeneralResources'),
reason: z.string().optional(),
@@ -737,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
const command = parseWith(zAdjustGeneralResources, envelope.command);
if (!command) {
@@ -905,6 +930,8 @@ const normalizers: CommandNormalizerMap = {
voteReward: normalizeVoteReward,
setNationSetting: normalizeSetNationSetting,
setNpcPolicy: normalizeSetNpcPolicy,
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
adjustGeneralResources: normalizeAdjustGeneralResources,
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
@@ -175,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
};
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'tournamentAdjustGeneral',
'troopCreate',
'troopJoin',
'troopExit',
@@ -627,11 +628,84 @@ async function handleSetNpcPolicy(
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
}
async function handleTournamentAdjustGeneral(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
): Promise<TurnDaemonCommandResult> {
const general = ctx.world.getGeneralById(command.generalId);
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
if (!general || general.userId !== command.userId) {
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
}
const gold = general.gold + command.goldDelta;
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
}
const meta = { ...general.meta };
if (command.betGoldDelta !== undefined) {
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
const betgold = current + command.betGoldDelta;
if (!Number.isFinite(betgold) || betgold < 0) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
}
meta.betgold = betgold;
}
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
ctx.world.updateGeneral(command.generalId, { gold, meta });
return { type: command.type, ok: true, generalId: command.generalId };
}
async function handleTournamentSeedNpcBets(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
): Promise<TurnDaemonCommandResult> {
if (ctx.commandDb) {
const event = command.requestId
? await ctx.commandDb.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { actorUserId: true, target: true, eventType: true },
})
: null;
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
}
}
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
}
const processedGeneralIds: number[] = [];
const skippedGeneralIds: number[] = [];
for (const bet of command.bets) {
const general = ctx.world.getGeneralById(bet.generalId);
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
skippedGeneralIds.push(bet.generalId);
continue;
}
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
ctx.world.updateGeneral(bet.generalId, {
gold: general.gold - bet.amount,
meta: { ...general.meta, betgold: current + bet.amount },
});
processedGeneralIds.push(bet.generalId);
}
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
}
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
reason !== undefined &&
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
reason
);
async function handleAdjustGeneralResources(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
}
@@ -688,6 +762,9 @@ async function handleAdjustGeneralMeta(
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) {
return {
type: 'adjustGeneralMeta',
@@ -3267,6 +3344,13 @@ export const createTurnDaemonCommandHandler = (options: {
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
setNpcPolicy: (command) =>
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
tournamentAdjustGeneral: (command) =>
handleTournamentAdjustGeneral(
ctx,
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
),
tournamentSeedNpcBets: (command) =>
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
adjustGeneralResources: (command) =>
handleAdjustGeneralResources(
ctx,
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
{
type: 'tournamentAdjustGeneral',
reason: 'tournamentBet',
requestId: 'tournamentAdjustGeneral',
userId,
generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
@@ -274,69 +274,79 @@ integration('database command queue', () => {
});
});
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
const requestId = 'integration:engine:stale-owner-replay';
const command: TurnDaemonCommand = {
type: 'vacation',
requestId,
userId: 'old-owner',
generalId: 7,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
'stores a stale-owner %s rejection once and never redispatches it',
async (type) => {
const requestId = `integration:engine:stale-owner-replay:${type}`;
const command: TurnDaemonCommand =
type === 'vacation'
? { type, requestId, userId: 'old-owner', generalId: 7 }
: {
type,
reason: 'tournamentBet',
requestId,
userId: 'old-owner',
generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue,
},
});
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue,
},
});
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId,
eventType: command.type,
payload: command,
result,
lockedBy: null,
leaseUntil: null,
});
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
});
eventType: command.type,
payload: command,
result,
lockedBy: null,
leaseUntil: null,
});
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
}
);
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'handles pre-opening user commands in %s without treating them as scheduled turns',
@@ -630,6 +640,8 @@ integration('database command queue', () => {
const metaId = 'integration:engine:suspended-tournament-bet-meta';
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
const atomicBetId = 'integration:engine:suspended-atomic-bet';
const atomicJoinId = 'integration:engine:suspended-atomic-join';
await db.inputEvent.createMany({
data: [
{
@@ -679,6 +691,41 @@ integration('database command queue', () => {
],
});
await db.inputEvent.createMany({
data: [
{
requestId: atomicBetId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentBet',
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
},
{
requestId: atomicJoinId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicJoinId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentJoin',
goldDelta: -200,
minGoldAfter: 0,
},
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
@@ -688,7 +735,15 @@ integration('database command queue', () => {
requestId: rollbackId,
reason: 'tournamentBetRollback',
}),
expect.objectContaining({
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
reason: 'tournamentBet',
}),
]);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
status: 'PENDING',
});
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
status: 'PROCESSING',
processingGameTick: 321n,
@@ -79,6 +79,83 @@ const buildWorld = (
return new InMemoryTurnWorld(state, snapshot, { schedule });
};
describe('tournament mutation ownership', () => {
it('checks the current owner and commits gold plus bet statistics together', async () => {
const general = { ...buildGeneral(1), userId: 'owner' };
const world = buildWorld([general]);
const handler = createTurnDaemonCommandHandler({ world });
const command = {
type: 'tournamentAdjustGeneral' as const,
reason: 'tournamentBet' as const,
userId: 'owner',
generalId: 1,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
const before = structuredClone(world.getGeneralById(1));
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
ok: false,
code: 'BAD_REQUEST',
});
expect(world.getGeneralById(1)).toEqual(before);
world.updateGeneral(1, { userId: 'new-owner' });
const transferred = structuredClone(world.getGeneralById(1));
for (const goldDelta of [-100, 100]) {
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
ok: false,
code: 'FORBIDDEN',
});
expect(world.getGeneralById(1)).toEqual(transferred);
}
});
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
const world = buildWorld([
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
buildGeneral(2, {}, 2),
{ ...buildGeneral(3, {}, 2), gold: 509 },
]);
const handler = createTurnDaemonCommandHandler({ world });
const possessed = structuredClone(world.getGeneralById(1));
await expect(
handler.handle({
type: 'tournamentSeedNpcBets',
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
})
).resolves.toEqual({
type: 'tournamentSeedNpcBets',
ok: true,
processedGeneralIds: [2],
skippedGeneralIds: [1, 3],
});
expect(world.getGeneralById(1)).toEqual(possessed);
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
});
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
'rejects actorless legacy %s commands before mutation',
async (reason) => {
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
const before = structuredClone(world.getGeneralById(1));
const handler = createTurnDaemonCommandHandler({ world });
for (const command of [
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
{
type: 'adjustGeneralMeta' as const,
reason,
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
},
])
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
expect(world.getGeneralById(1)).toEqual(before);
}
);
});
describe('tournament world commands', () => {
it('uses the current world develcost for Ref-compatible tournament prizes', () => {
const currentWorld = buildWorld([], {
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
});
it('records all four tournament types and NPC betting for at least ten generals', async () => {
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1));
const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
const world = buildWorld(generals);
const handler = createTurnDaemonCommandHandler({ world });
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
}
}
await handler.handle({
type: 'adjustGeneralMeta',
reason: 'tournamentNpcBet',
adjustments: generals.map((general) => ({
generalId: general.id,
metaDelta: { betgold: 1_000 },
})),
type: 'tournamentSeedNpcBets',
bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
});
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
await handler.handle({
type: 'tournamentBettingPayout',
bettingId: 1,