fix reserved turn queue concurrency

This commit is contained in:
2026-07-26 18:32:52 +00:00
parent ab6ed3553a
commit 3cd1ad3b7f
18 changed files with 613 additions and 106 deletions
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { resolveSecretPermission } from '../../shared/secretPermission.js';
import { MAX_NATION_TURNS, listNationTurns } from '../../../turns/reservedTurns.js';
import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js';
import { assertNationAccess } from '../shared.js';
export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
@@ -56,9 +56,7 @@ export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
const chiefLevels = [12, 10, 8, 6, 11, 9, 7, 5];
const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general]));
const turnsByLevel = await Promise.all(
chiefLevels.map((level) => listNationTurns(ctx.db, nation.id, level))
);
const turnsByLevel = await Promise.all(chiefLevels.map((level) => getNationTurnSnapshot(ctx.db, nation.id, level)));
const chiefs = chiefLevels.map((level, idx) => {
const entry = generalByLevel.get(level);
@@ -67,7 +65,8 @@ export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
name: entry?.name ?? null,
npcState: entry?.npcState ?? null,
turnTime: entry?.turnTime ? entry.turnTime.toISOString() : null,
turns: turnsByLevel[idx],
revision: turnsByLevel[idx]?.revision ?? 0,
turns: turnsByLevel[idx]?.turns ?? [],
};
});
+52 -23
View File
@@ -14,8 +14,9 @@ import {
import {
MAX_GENERAL_TURNS,
MAX_NATION_TURNS,
listGeneralTurns,
listNationTurns,
ReservedTurnRevisionConflictError,
getGeneralTurnSnapshot,
getNationTurnSnapshot,
setGeneralTurn,
setNationTurn,
shiftGeneralTurns,
@@ -45,6 +46,21 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg
}
};
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
try {
return await mutation();
} catch (error) {
if (error instanceof ReservedTurnRevisionConflictError) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Reserved turn queue changed. Reload and retry.',
cause: error,
});
}
throw error;
}
};
export const turnsRouter = router({
getCommandTable: authedProcedure
.input(
@@ -164,7 +180,7 @@ export const turnsRouter = router({
.query(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
return listGeneralTurns(ctx.db, input.generalId);
return getGeneralTurnSnapshot(ctx.db, input.generalId);
}),
getNation: authedProcedure
.input(
@@ -187,7 +203,7 @@ export const turnsRouter = router({
});
}
return listNationTurns(ctx.db, general.nationId, general.officerLevel);
return getNationTurnSnapshot(ctx.db, general.nationId, general.officerLevel);
}),
setGeneral: authedProcedure
.input(
@@ -200,33 +216,33 @@ export const turnsRouter = router({
.max(MAX_GENERAL_TURNS - 1),
action: z.string().min(1),
args: z.unknown().optional(),
expectedRevision: z.number().int().nonnegative(),
})
)
.mutation(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
const args = await parseCommandArgs('general', input.action, input.args);
const turns = await setGeneralTurn(
ctx.db,
input.generalId,
input.turnIndex,
input.action,
args
const snapshot = await mutateReservedTurns(() =>
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
);
return { ok: true, turns };
return { ok: true, ...snapshot };
}),
shiftGeneral: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
amount: buildShiftAmountSchema(MAX_GENERAL_TURNS),
expectedRevision: z.number().int().nonnegative(),
})
)
.mutation(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount);
return { ok: true, turns };
const snapshot = await mutateReservedTurns(() =>
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
);
return { ok: true, ...snapshot };
}),
setNation: authedProcedure
.input(
@@ -239,6 +255,7 @@ export const turnsRouter = router({
.max(MAX_NATION_TURNS - 1),
action: z.string().min(1),
args: z.unknown().optional(),
expectedRevision: z.number().int().nonnegative(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -257,21 +274,25 @@ export const turnsRouter = router({
}
const args = await parseCommandArgs('nation', input.action, input.args);
const turns = await setNationTurn(
ctx.db,
general.nationId,
general.officerLevel,
input.turnIndex,
input.action,
args
const snapshot = await mutateReservedTurns(() =>
setNationTurn(
ctx.db,
general.nationId,
general.officerLevel,
input.turnIndex,
input.action,
args,
input.expectedRevision
)
);
return { ok: true, turns };
return { ok: true, ...snapshot };
}),
shiftNation: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
amount: buildShiftAmountSchema(MAX_NATION_TURNS),
expectedRevision: z.number().int().nonnegative(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -289,8 +310,16 @@ export const turnsRouter = router({
});
}
const turns = await shiftNationTurns(ctx.db, general.nationId, general.officerLevel, input.amount);
return { ok: true, turns };
const snapshot = await mutateReservedTurns(() =>
shiftNationTurns(
ctx.db,
general.nationId,
general.officerLevel,
input.amount,
input.expectedRevision
)
);
return { ok: true, ...snapshot };
}),
}),
});
+122 -12
View File
@@ -16,6 +16,21 @@ export interface ReservedTurnView {
args: InputJsonValue;
}
export interface ReservedTurnSnapshot {
revision: number;
turns: ReservedTurnView[];
}
export class ReservedTurnRevisionConflictError extends Error {
constructor(
readonly expectedRevision: number,
readonly currentRevision: number
) {
super(`Reserved turn queue revision conflict: expected ${expectedRevision}, current ${currentRevision}.`);
this.name = 'ReservedTurnRevisionConflictError';
}
}
const normalizeAction = (action: string | null | undefined): string =>
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
@@ -111,6 +126,17 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P
return serializeTurnList(turns);
};
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
const [turns, revisionRow] = await Promise.all([
loadGeneralTurns(db, generalId),
db.generalTurnRevision.findUnique({ where: { generalId } }),
]);
return {
revision: revisionRow?.revision ?? 0,
turns: serializeTurnList(turns),
};
};
export const loadNationTurns = async (
db: DatabaseClient,
nationId: number,
@@ -132,31 +158,111 @@ export const listNationTurns = async (
return serializeTurnList(turns);
};
export const getNationTurnSnapshot = async (
db: DatabaseClient,
nationId: number,
officerLevel: number
): Promise<ReservedTurnSnapshot> => {
const [turns, revisionRow] = await Promise.all([
loadNationTurns(db, nationId, officerLevel),
db.nationTurnRevision.findUnique({
where: {
nationId_officerLevel: {
nationId,
officerLevel,
},
},
}),
]);
return {
revision: revisionRow?.revision ?? 0,
turns: serializeTurnList(turns),
};
};
const claimGeneralRevision = async (
db: DatabaseClient,
generalId: number,
expectedRevision: number
): Promise<number> => {
const nextRevision = expectedRevision + 1;
const claimed =
expectedRevision === 0
? await db.generalTurnRevision.createMany({
data: [{ generalId, revision: nextRevision }],
skipDuplicates: true,
})
: await db.generalTurnRevision.updateMany({
where: { generalId, revision: expectedRevision },
data: { revision: nextRevision },
});
if (claimed.count === 1) {
return nextRevision;
}
const current = await db.generalTurnRevision.findUnique({ where: { generalId } });
throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0);
};
const claimNationRevision = async (
db: DatabaseClient,
nationId: number,
officerLevel: number,
expectedRevision: number
): Promise<number> => {
const nextRevision = expectedRevision + 1;
const claimed =
expectedRevision === 0
? await db.nationTurnRevision.createMany({
data: [{ nationId, officerLevel, revision: nextRevision }],
skipDuplicates: true,
})
: await db.nationTurnRevision.updateMany({
where: { nationId, officerLevel, revision: expectedRevision },
data: { revision: nextRevision },
});
if (claimed.count === 1) {
return nextRevision;
}
const current = await db.nationTurnRevision.findUnique({
where: {
nationId_officerLevel: {
nationId,
officerLevel,
},
},
});
throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0);
};
export const setGeneralTurn = async (
db: DatabaseClient,
generalId: number,
turnIndex: number,
action: string,
args: unknown
): Promise<ReservedTurnView[]> => {
args: unknown,
expectedRevision: number
): Promise<ReservedTurnSnapshot> => {
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
const turns = await loadGeneralTurns(db, generalId);
turns[turnIndex] = {
action: normalizeAction(action),
args: normalizeArgs(args),
};
await persistGeneralTurns(db, generalId, turns);
return serializeTurnList(turns);
return { revision, turns: serializeTurnList(turns) };
};
export const shiftGeneralTurns = async (
db: DatabaseClient,
generalId: number,
amount: number
): Promise<ReservedTurnView[]> => {
amount: number,
expectedRevision: number
): Promise<ReservedTurnSnapshot> => {
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
const turns = await loadGeneralTurns(db, generalId);
const shifted = applyShift(turns, amount);
await persistGeneralTurns(db, generalId, shifted);
return serializeTurnList(shifted);
return { revision, turns: serializeTurnList(shifted) };
};
export const setNationTurn = async (
@@ -165,25 +271,29 @@ export const setNationTurn = async (
officerLevel: number,
turnIndex: number,
action: string,
args: unknown
): Promise<ReservedTurnView[]> => {
args: unknown,
expectedRevision: number
): Promise<ReservedTurnSnapshot> => {
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
const turns = await loadNationTurns(db, nationId, officerLevel);
turns[turnIndex] = {
action: normalizeAction(action),
args: normalizeArgs(args),
};
await persistNationTurns(db, nationId, officerLevel, turns);
return serializeTurnList(turns);
return { revision, turns: serializeTurnList(turns) };
};
export const shiftNationTurns = async (
db: DatabaseClient,
nationId: number,
officerLevel: number,
amount: number
): Promise<ReservedTurnView[]> => {
amount: number,
expectedRevision: number
): Promise<ReservedTurnSnapshot> => {
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
const turns = await loadNationTurns(db, nationId, officerLevel);
const shifted = applyShift(turns, amount);
await persistNationTurns(db, nationId, officerLevel, shifted);
return serializeTurnList(shifted);
return { revision, turns: serializeTurnList(shifted) };
};