fix reserved turn queue concurrency
This commit is contained in:
@@ -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 ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import {
|
||||
ReservedTurnRevisionConflictError,
|
||||
getGeneralTurnSnapshot,
|
||||
setGeneralTurn,
|
||||
} from '../src/turns/reservedTurns.js';
|
||||
|
||||
const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL;
|
||||
const describeIntegration = databaseUrl ? describe : describe.skip;
|
||||
const GENERAL_ID = 2_147_400_001;
|
||||
|
||||
describeIntegration('reserved turn queue revision integration', () => {
|
||||
const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!connector) {
|
||||
return;
|
||||
}
|
||||
await connector.connect();
|
||||
await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } });
|
||||
await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!connector) {
|
||||
return;
|
||||
}
|
||||
await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } });
|
||||
await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } });
|
||||
await connector.disconnect();
|
||||
});
|
||||
|
||||
it('allows exactly one writer for the same expected revision', async () => {
|
||||
if (!connector) {
|
||||
throw new Error('integration connector is unavailable');
|
||||
}
|
||||
|
||||
const write = (action: string) =>
|
||||
connector.prisma.$transaction((transaction) =>
|
||||
setGeneralTurn(transaction as unknown as DatabaseClient, GENERAL_ID, 0, action, {}, 0)
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled([write('che_훈련'), write('che_사기진작')]);
|
||||
const fulfilled = results.filter(
|
||||
(result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof write>>> =>
|
||||
result.status === 'fulfilled'
|
||||
);
|
||||
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(fulfilled[0]?.value.revision).toBe(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(rejected[0]?.reason).toBeInstanceOf(ReservedTurnRevisionConflictError);
|
||||
|
||||
const snapshot = await getGeneralTurnSnapshot(connector.prisma as unknown as DatabaseClient, GENERAL_ID);
|
||||
expect(snapshot.revision).toBe(1);
|
||||
expect(['che_훈련', 'che_사기진작']).toContain(snapshot.turns[0]?.action);
|
||||
expect(await connector.prisma.generalTurn.count({ where: { generalId: GENERAL_ID } })).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
setNationTurn,
|
||||
shiftGeneralTurns,
|
||||
shiftNationTurns,
|
||||
ReservedTurnRevisionConflictError,
|
||||
} from '../src/turns/reservedTurns.js';
|
||||
|
||||
const buildDb = () => {
|
||||
const generalTurns = new Map<number, GeneralTurnRow[]>();
|
||||
const nationTurns = new Map<string, NationTurnRow[]>();
|
||||
const generalRevisions = new Map<number, number>();
|
||||
const nationRevisions = new Map<string, number>();
|
||||
|
||||
type GeneralTurnFindManyArgs = Parameters<DatabaseClient['generalTurn']['findMany']>[0];
|
||||
type GeneralTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['generalTurn']['deleteMany']>[0]>;
|
||||
@@ -21,6 +24,16 @@ const buildDb = () => {
|
||||
type NationTurnFindManyArgs = Parameters<DatabaseClient['nationTurn']['findMany']>[0];
|
||||
type NationTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['deleteMany']>[0]>;
|
||||
type NationTurnCreateManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['createMany']>[0]>;
|
||||
type GeneralRevisionFindArgs = Parameters<DatabaseClient['generalTurnRevision']['findUnique']>[0];
|
||||
type GeneralRevisionCreateManyArgs = NonNullable<
|
||||
Parameters<DatabaseClient['generalTurnRevision']['createMany']>[0]
|
||||
>;
|
||||
type GeneralRevisionUpdateManyArgs = NonNullable<
|
||||
Parameters<DatabaseClient['generalTurnRevision']['updateMany']>[0]
|
||||
>;
|
||||
type NationRevisionFindArgs = Parameters<DatabaseClient['nationTurnRevision']['findUnique']>[0];
|
||||
type NationRevisionCreateManyArgs = NonNullable<Parameters<DatabaseClient['nationTurnRevision']['createMany']>[0]>;
|
||||
type NationRevisionUpdateManyArgs = NonNullable<Parameters<DatabaseClient['nationTurnRevision']['updateMany']>[0]>;
|
||||
|
||||
const db = {
|
||||
worldState: {
|
||||
@@ -64,6 +77,39 @@ const buildDb = () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
generalTurnRevision: {
|
||||
findUnique: async ({ where }: GeneralRevisionFindArgs) => {
|
||||
const generalId = where.generalId as number;
|
||||
const revision = generalRevisions.get(generalId);
|
||||
return revision === undefined
|
||||
? null
|
||||
: {
|
||||
generalId,
|
||||
revision,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
},
|
||||
createMany: async ({ data }: GeneralRevisionCreateManyArgs) => {
|
||||
const row = (Array.isArray(data) ? data[0] : data) as {
|
||||
generalId: number;
|
||||
revision?: number;
|
||||
};
|
||||
if (generalRevisions.has(row.generalId)) {
|
||||
return { count: 0 };
|
||||
}
|
||||
generalRevisions.set(row.generalId, row.revision ?? 0);
|
||||
return { count: 1 };
|
||||
},
|
||||
updateMany: async ({ where, data }: GeneralRevisionUpdateManyArgs) => {
|
||||
const generalId = typeof where?.generalId === 'number' ? where.generalId : -1;
|
||||
const expected = typeof where?.revision === 'number' ? where.revision : -1;
|
||||
if (generalRevisions.get(generalId) !== expected || typeof data.revision !== 'number') {
|
||||
return { count: 0 };
|
||||
}
|
||||
generalRevisions.set(generalId, data.revision);
|
||||
return { count: 1 };
|
||||
},
|
||||
},
|
||||
nationTurn: {
|
||||
findMany: async (args?: NationTurnFindManyArgs) => {
|
||||
const nationId = typeof args?.where?.nationId === 'number' ? args.where.nationId : undefined;
|
||||
@@ -100,6 +146,48 @@ const buildDb = () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
nationTurnRevision: {
|
||||
findUnique: async ({ where }: NationRevisionFindArgs) => {
|
||||
const compound = where.nationId_officerLevel;
|
||||
if (!compound) {
|
||||
return null;
|
||||
}
|
||||
const key = `${compound.nationId}:${compound.officerLevel}`;
|
||||
const revision = nationRevisions.get(key);
|
||||
return revision === undefined
|
||||
? null
|
||||
: {
|
||||
nationId: compound.nationId,
|
||||
officerLevel: compound.officerLevel,
|
||||
revision,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
},
|
||||
createMany: async ({ data }: NationRevisionCreateManyArgs) => {
|
||||
const row = (Array.isArray(data) ? data[0] : data) as {
|
||||
nationId: number;
|
||||
officerLevel: number;
|
||||
revision?: number;
|
||||
};
|
||||
const key = `${row.nationId}:${row.officerLevel}`;
|
||||
if (nationRevisions.has(key)) {
|
||||
return { count: 0 };
|
||||
}
|
||||
nationRevisions.set(key, row.revision ?? 0);
|
||||
return { count: 1 };
|
||||
},
|
||||
updateMany: async ({ where, data }: NationRevisionUpdateManyArgs) => {
|
||||
const nationId = typeof where?.nationId === 'number' ? where.nationId : -1;
|
||||
const officerLevel = typeof where?.officerLevel === 'number' ? where.officerLevel : -1;
|
||||
const expected = typeof where?.revision === 'number' ? where.revision : -1;
|
||||
const key = `${nationId}:${officerLevel}`;
|
||||
if (nationRevisions.get(key) !== expected || typeof data.revision !== 'number') {
|
||||
return { count: 0 };
|
||||
}
|
||||
nationRevisions.set(key, data.revision);
|
||||
return { count: 1 };
|
||||
},
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
|
||||
return { db };
|
||||
@@ -109,30 +197,45 @@ describe('reservedTurns', () => {
|
||||
it('sets and shifts general turns', async () => {
|
||||
const { db } = buildDb();
|
||||
|
||||
const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 });
|
||||
const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0);
|
||||
|
||||
expect(initial).toHaveLength(MAX_GENERAL_TURNS);
|
||||
expect(initial[0]?.action).toBe('che_화계');
|
||||
expect(initial.revision).toBe(1);
|
||||
expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS);
|
||||
expect(initial.turns[0]?.action).toBe('che_화계');
|
||||
|
||||
const pushed = await shiftGeneralTurns(db, 1, 1);
|
||||
expect(pushed[0]?.action).toBe('휴식');
|
||||
expect(pushed[1]?.action).toBe('che_화계');
|
||||
const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision);
|
||||
expect(pushed.revision).toBe(2);
|
||||
expect(pushed.turns[0]?.action).toBe('휴식');
|
||||
expect(pushed.turns[1]?.action).toBe('che_화계');
|
||||
|
||||
const pulled = await shiftGeneralTurns(db, 1, -1);
|
||||
expect(pulled[0]?.action).toBe('che_화계');
|
||||
expect(pulled[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식');
|
||||
const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision);
|
||||
expect(pulled.turns[0]?.action).toBe('che_화계');
|
||||
expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식');
|
||||
|
||||
await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf(
|
||||
ReservedTurnRevisionConflictError
|
||||
);
|
||||
});
|
||||
|
||||
it('sets and shifts nation turns', async () => {
|
||||
const { db } = buildDb();
|
||||
|
||||
const initial = await setNationTurn(db, 2, 5, 0, 'che_포상', { isGold: true, amount: 200, destGeneralId: 7 });
|
||||
const initial = await setNationTurn(
|
||||
db,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
'che_포상',
|
||||
{ isGold: true, amount: 200, destGeneralId: 7 },
|
||||
0
|
||||
);
|
||||
|
||||
expect(initial).toHaveLength(MAX_NATION_TURNS);
|
||||
expect(initial[0]?.action).toBe('che_포상');
|
||||
expect(initial.revision).toBe(1);
|
||||
expect(initial.turns).toHaveLength(MAX_NATION_TURNS);
|
||||
expect(initial.turns[0]?.action).toBe('che_포상');
|
||||
|
||||
const pushed = await shiftNationTurns(db, 2, 5, 1);
|
||||
expect(pushed[0]?.action).toBe('휴식');
|
||||
expect(pushed[1]?.action).toBe('che_포상');
|
||||
const pushed = await shiftNationTurns(db, 2, 5, 1, initial.revision);
|
||||
expect(pushed.turns[0]?.action).toBe('휴식');
|
||||
expect(pushed.turns[1]?.action).toBe('che_포상');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,8 @@ const buildContext = (options?: {
|
||||
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
|
||||
const generalTurns = options?.generalTurns ?? [];
|
||||
const nationTurns = options?.nationTurns ?? [];
|
||||
let generalTurnRevision: number | undefined;
|
||||
let nationTurnRevision: number | undefined;
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: async () => {
|
||||
@@ -133,17 +135,60 @@ const buildContext = (options?: {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
generalTurnRevision: {
|
||||
findUnique: async () =>
|
||||
generalTurnRevision === undefined
|
||||
? null
|
||||
: { generalId: options?.general?.id ?? 0, revision: generalTurnRevision, updatedAt: new Date() },
|
||||
createMany: async ({ data }: { data: Array<{ revision: number }> }) => {
|
||||
if (generalTurnRevision !== undefined) {
|
||||
return { count: 0 };
|
||||
}
|
||||
generalTurnRevision = data[0]?.revision ?? 0;
|
||||
return { count: 1 };
|
||||
},
|
||||
updateMany: async ({ where, data }: { where: { revision: number }; data: { revision: number } }) => {
|
||||
if (generalTurnRevision !== where.revision) {
|
||||
return { count: 0 };
|
||||
}
|
||||
generalTurnRevision = data.revision;
|
||||
return { count: 1 };
|
||||
},
|
||||
},
|
||||
nationTurn: {
|
||||
findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) =>
|
||||
nationTurns.filter(
|
||||
(row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel
|
||||
),
|
||||
nationTurns.filter((row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel),
|
||||
deleteMany: async () => ({}),
|
||||
createMany: async (args: unknown) => {
|
||||
options?.nationTurnWrites?.push(args);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
nationTurnRevision: {
|
||||
findUnique: async () =>
|
||||
nationTurnRevision === undefined
|
||||
? null
|
||||
: {
|
||||
nationId: options?.general?.nationId ?? 0,
|
||||
officerLevel: options?.general?.officerLevel ?? 0,
|
||||
revision: nationTurnRevision,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
createMany: async ({ data }: { data: Array<{ revision: number }> }) => {
|
||||
if (nationTurnRevision !== undefined) {
|
||||
return { count: 0 };
|
||||
}
|
||||
nationTurnRevision = data[0]?.revision ?? 0;
|
||||
return { count: 1 };
|
||||
},
|
||||
updateMany: async ({ where, data }: { where: { revision: number }; data: { revision: number } }) => {
|
||||
if (nationTurnRevision !== where.revision) {
|
||||
return { count: 0 };
|
||||
}
|
||||
nationTurnRevision = data.revision;
|
||||
return { count: 1 };
|
||||
},
|
||||
},
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
@@ -370,8 +415,9 @@ describe('appRouter', () => {
|
||||
const caller = appRouter.createCaller(buildContext({ general, generalTurns }));
|
||||
const response = await caller.turns.reserved.getGeneral({ generalId: 11 });
|
||||
|
||||
expect(response[0]?.action).toBe('che_화계');
|
||||
expect(response[0]?.index).toBe(0);
|
||||
expect(response.revision).toBe(0);
|
||||
expect(response.turns[0]?.action).toBe('che_화계');
|
||||
expect(response.turns[0]?.index).toBe(0);
|
||||
});
|
||||
|
||||
it('returns reserved nation turns', async () => {
|
||||
@@ -390,8 +436,9 @@ describe('appRouter', () => {
|
||||
const caller = appRouter.createCaller(buildContext({ general, nationTurns }));
|
||||
const response = await caller.turns.reserved.getNation({ generalId: 12 });
|
||||
|
||||
expect(response[0]?.action).toBe('che_포상');
|
||||
expect(response[0]?.index).toBe(0);
|
||||
expect(response.revision).toBe(0);
|
||||
expect(response.turns[0]?.action).toBe('che_포상');
|
||||
expect(response.turns[0]?.index).toBe(0);
|
||||
});
|
||||
|
||||
it('validates and persists general command arguments from the authenticated owner', async () => {
|
||||
@@ -404,6 +451,7 @@ describe('appRouter', () => {
|
||||
turnIndex: 0,
|
||||
action: 'che_화계',
|
||||
args: { destCityId: 7 },
|
||||
expectedRevision: 0,
|
||||
});
|
||||
|
||||
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
|
||||
@@ -416,6 +464,16 @@ describe('appRouter', () => {
|
||||
actionCode: 'che_화계',
|
||||
arg: { destCityId: 7 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.turns.reserved.setGeneral({
|
||||
generalId: 13,
|
||||
turnIndex: 1,
|
||||
action: '휴식',
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects malformed and cross-scope arguments without writing turns', async () => {
|
||||
@@ -432,6 +490,7 @@ describe('appRouter', () => {
|
||||
turnIndex: 0,
|
||||
action: 'che_화계',
|
||||
args: { destCityId: '7' },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
await expect(
|
||||
@@ -440,6 +499,7 @@ describe('appRouter', () => {
|
||||
turnIndex: 0,
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 1, destGeneralId: 7 },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
|
||||
@@ -465,6 +525,7 @@ describe('appRouter', () => {
|
||||
generalId: general.id,
|
||||
turnIndex: 0,
|
||||
action: '휴식',
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -473,6 +534,7 @@ describe('appRouter', () => {
|
||||
caller.turns.reserved.shiftGeneral({
|
||||
generalId: general.id,
|
||||
amount: 1,
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -482,6 +544,7 @@ describe('appRouter', () => {
|
||||
generalId: general.id,
|
||||
turnIndex: 0,
|
||||
action: '휴식',
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -490,6 +553,7 @@ describe('appRouter', () => {
|
||||
caller.turns.reserved.shiftNation({
|
||||
generalId: general.id,
|
||||
amount: 1,
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
|
||||
Reference in New Issue
Block a user