fix reserved turn bulk and repeat parity
This commit is contained in:
@@ -15,23 +15,42 @@ import {
|
|||||||
MAX_GENERAL_TURNS,
|
MAX_GENERAL_TURNS,
|
||||||
MAX_NATION_TURNS,
|
MAX_NATION_TURNS,
|
||||||
ReservedTurnRevisionConflictError,
|
ReservedTurnRevisionConflictError,
|
||||||
|
expandGeneralTurnIndices,
|
||||||
getGeneralTurnSnapshot,
|
getGeneralTurnSnapshot,
|
||||||
getNationTurnSnapshot,
|
getNationTurnSnapshot,
|
||||||
|
repeatGeneralTurns,
|
||||||
|
repeatNationTurns,
|
||||||
setGeneralTurn,
|
setGeneralTurn,
|
||||||
|
setGeneralTurns,
|
||||||
setNationTurn,
|
setNationTurn,
|
||||||
|
setNationTurns,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
shiftNationTurns,
|
shiftNationTurns,
|
||||||
} from '../../turns/reservedTurns.js';
|
} from '../../turns/reservedTurns.js';
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
|
|
||||||
const buildShiftAmountSchema = (maxTurns: number) =>
|
const zPushAmount = z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(-12)
|
||||||
|
.max(12)
|
||||||
|
.refine((value) => value !== 0, {
|
||||||
|
message: 'Amount must be non-zero.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const zRepeatAmount = z.number().int().min(1).max(12);
|
||||||
|
|
||||||
|
const buildTurnListSchema = (minimum: number, maximum: number) =>
|
||||||
z
|
z
|
||||||
.number()
|
.array(z.number().int().min(minimum).max(maximum))
|
||||||
.int()
|
.min(1);
|
||||||
.min(-(maxTurns - 1))
|
|
||||||
.max(maxTurns - 1)
|
const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
||||||
.refine((value) => value !== 0, {
|
z
|
||||||
message: 'Amount must be non-zero.',
|
.object({
|
||||||
|
turnList,
|
||||||
|
action: z.string().min(1),
|
||||||
|
args: z.unknown().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
||||||
@@ -232,7 +251,7 @@ export const turnsRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
amount: buildShiftAmountSchema(MAX_GENERAL_TURNS),
|
amount: zPushAmount,
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -244,6 +263,49 @@ export const turnsRouter = router({
|
|||||||
);
|
);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
|
repeatGeneral: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
generalId: z.number().int().positive(),
|
||||||
|
amount: zRepeatAmount,
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
|
repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||||
|
);
|
||||||
|
return { ok: true, ...snapshot };
|
||||||
|
}),
|
||||||
|
setGeneralBulk: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
generalId: z.number().int().positive(),
|
||||||
|
entries: z
|
||||||
|
.array(
|
||||||
|
buildBulkEntrySchema(
|
||||||
|
buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
const updates = await Promise.all(
|
||||||
|
input.entries.map(async (entry) => ({
|
||||||
|
turnIndices: expandGeneralTurnIndices(entry.turnList),
|
||||||
|
action: entry.action,
|
||||||
|
args: await parseCommandArgs('general', entry.action, entry.args),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
|
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
||||||
|
);
|
||||||
|
return { ok: true, ...snapshot };
|
||||||
|
}),
|
||||||
setNation: authedProcedure
|
setNation: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -291,7 +353,7 @@ export const turnsRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
amount: buildShiftAmountSchema(MAX_NATION_TURNS),
|
amount: zPushAmount,
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -321,5 +383,84 @@ export const turnsRouter = router({
|
|||||||
);
|
);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
|
repeatNation: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
generalId: z.number().int().positive(),
|
||||||
|
amount: zRepeatAmount,
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
if (general.nationId <= 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'General is not part of a nation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (general.officerLevel < 5) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'General is not an officer.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
|
repeatNationTurns(
|
||||||
|
ctx.db,
|
||||||
|
general.nationId,
|
||||||
|
general.officerLevel,
|
||||||
|
input.amount,
|
||||||
|
input.expectedRevision
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { ok: true, ...snapshot };
|
||||||
|
}),
|
||||||
|
setNationBulk: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
generalId: z.number().int().positive(),
|
||||||
|
entries: z
|
||||||
|
.array(
|
||||||
|
buildBulkEntrySchema(
|
||||||
|
buildTurnListSchema(0, MAX_NATION_TURNS - 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
if (general.nationId <= 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'General is not part of a nation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (general.officerLevel < 5) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'General is not an officer.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updates = await Promise.all(
|
||||||
|
input.entries.map(async (entry) => ({
|
||||||
|
turnIndices: entry.turnList,
|
||||||
|
action: entry.action,
|
||||||
|
args: await parseCommandArgs('nation', entry.action, entry.args),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
|
setNationTurns(
|
||||||
|
ctx.db,
|
||||||
|
general.nationId,
|
||||||
|
general.officerLevel,
|
||||||
|
updates,
|
||||||
|
input.expectedRevision
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { ok: true, ...snapshot };
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export interface ReservedTurnSnapshot {
|
|||||||
turns: ReservedTurnView[];
|
turns: ReservedTurnView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReservedTurnUpdate {
|
||||||
|
turnIndices: readonly number[];
|
||||||
|
action: string;
|
||||||
|
args: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export class ReservedTurnRevisionConflictError extends Error {
|
export class ReservedTurnRevisionConflictError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly expectedRevision: number,
|
readonly expectedRevision: number,
|
||||||
@@ -58,6 +64,43 @@ const applyShift = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEnt
|
|||||||
return sliced.concat(padding);
|
return sliced.concat(padding);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyRepeat = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEntry[] => {
|
||||||
|
if (amount <= 0 || amount >= turns.length) {
|
||||||
|
return turns.slice();
|
||||||
|
}
|
||||||
|
const repeated = turns.map((entry) => ({
|
||||||
|
action: entry.action,
|
||||||
|
args: entry.args,
|
||||||
|
}));
|
||||||
|
const sourceCount = amount * 2 > turns.length ? turns.length - amount : amount;
|
||||||
|
for (let sourceIndex = 0; sourceIndex < sourceCount; sourceIndex += 1) {
|
||||||
|
const source = turns[sourceIndex] ?? createDefaultEntry();
|
||||||
|
for (let targetIndex = sourceIndex + amount; targetIndex < turns.length; targetIndex += amount) {
|
||||||
|
repeated[targetIndex] = {
|
||||||
|
action: source.action,
|
||||||
|
args: source.args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repeated;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expandGeneralTurnIndices = (rawTurnIndices: readonly number[]): number[] => {
|
||||||
|
const expanded = new Set<number>();
|
||||||
|
for (const turnIndex of rawTurnIndices) {
|
||||||
|
if (turnIndex >= 0) {
|
||||||
|
expanded.add(turnIndex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = turnIndex === -2 ? 1 : 0;
|
||||||
|
const step = turnIndex === -3 ? 1 : 2;
|
||||||
|
for (let index = start; index < MAX_GENERAL_TURNS; index += step) {
|
||||||
|
expanded.add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(expanded);
|
||||||
|
};
|
||||||
|
|
||||||
const buildTurnListFromRows = (rows: Array<GeneralTurnRow | NationTurnRow>, maxTurns: number): ReservedTurnEntry[] => {
|
const buildTurnListFromRows = (rows: Array<GeneralTurnRow | NationTurnRow>, maxTurns: number): ReservedTurnEntry[] => {
|
||||||
const result = buildDefaultTurns(maxTurns);
|
const result = buildDefaultTurns(maxTurns);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -234,6 +277,51 @@ const claimNationRevision = async (
|
|||||||
throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0);
|
throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assertGeneralRevision = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
generalId: number,
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
const snapshot = await getGeneralTurnSnapshot(db, generalId);
|
||||||
|
if (snapshot.revision !== expectedRevision) {
|
||||||
|
throw new ReservedTurnRevisionConflictError(expectedRevision, snapshot.revision);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertNationRevision = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number,
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
const snapshot = await getNationTurnSnapshot(db, nationId, officerLevel);
|
||||||
|
if (snapshot.revision !== expectedRevision) {
|
||||||
|
throw new ReservedTurnRevisionConflictError(expectedRevision, snapshot.revision);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setGeneralTurns = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
generalId: number,
|
||||||
|
updates: readonly ReservedTurnUpdate[],
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
||||||
|
const turns = await loadGeneralTurns(db, generalId);
|
||||||
|
for (const update of updates) {
|
||||||
|
for (const turnIndex of update.turnIndices) {
|
||||||
|
turns[turnIndex] = {
|
||||||
|
action: normalizeAction(update.action),
|
||||||
|
args: normalizeArgs(update.args),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await persistGeneralTurns(db, generalId, turns);
|
||||||
|
return { revision, turns: serializeTurnList(turns) };
|
||||||
|
};
|
||||||
|
|
||||||
export const setGeneralTurn = async (
|
export const setGeneralTurn = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
generalId: number,
|
generalId: number,
|
||||||
@@ -241,16 +329,8 @@ export const setGeneralTurn = async (
|
|||||||
action: string,
|
action: string,
|
||||||
args: unknown,
|
args: unknown,
|
||||||
expectedRevision: number
|
expectedRevision: number
|
||||||
): Promise<ReservedTurnSnapshot> => {
|
): Promise<ReservedTurnSnapshot> =>
|
||||||
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
setGeneralTurns(db, generalId, [{ turnIndices: [turnIndex], action, args }], expectedRevision);
|
||||||
const turns = await loadGeneralTurns(db, generalId);
|
|
||||||
turns[turnIndex] = {
|
|
||||||
action: normalizeAction(action),
|
|
||||||
args: normalizeArgs(args),
|
|
||||||
};
|
|
||||||
await persistGeneralTurns(db, generalId, turns);
|
|
||||||
return { revision, turns: serializeTurnList(turns) };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const shiftGeneralTurns = async (
|
export const shiftGeneralTurns = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
@@ -258,6 +338,9 @@ export const shiftGeneralTurns = async (
|
|||||||
amount: number,
|
amount: number,
|
||||||
expectedRevision: number
|
expectedRevision: number
|
||||||
): Promise<ReservedTurnSnapshot> => {
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
if (Math.abs(amount) >= MAX_GENERAL_TURNS) {
|
||||||
|
return assertGeneralRevision(db, generalId, expectedRevision);
|
||||||
|
}
|
||||||
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
||||||
const turns = await loadGeneralTurns(db, generalId);
|
const turns = await loadGeneralTurns(db, generalId);
|
||||||
const shifted = applyShift(turns, amount);
|
const shifted = applyShift(turns, amount);
|
||||||
@@ -265,6 +348,42 @@ export const shiftGeneralTurns = async (
|
|||||||
return { revision, turns: serializeTurnList(shifted) };
|
return { revision, turns: serializeTurnList(shifted) };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const repeatGeneralTurns = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
generalId: number,
|
||||||
|
amount: number,
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
if (amount >= MAX_GENERAL_TURNS) {
|
||||||
|
return assertGeneralRevision(db, generalId, expectedRevision);
|
||||||
|
}
|
||||||
|
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
||||||
|
const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount);
|
||||||
|
await persistGeneralTurns(db, generalId, turns);
|
||||||
|
return { revision, turns: serializeTurnList(turns) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setNationTurns = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number,
|
||||||
|
updates: readonly ReservedTurnUpdate[],
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||||
|
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||||
|
for (const update of updates) {
|
||||||
|
for (const turnIndex of update.turnIndices) {
|
||||||
|
turns[turnIndex] = {
|
||||||
|
action: normalizeAction(update.action),
|
||||||
|
args: normalizeArgs(update.args),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await persistNationTurns(db, nationId, officerLevel, turns);
|
||||||
|
return { revision, turns: serializeTurnList(turns) };
|
||||||
|
};
|
||||||
|
|
||||||
export const setNationTurn = async (
|
export const setNationTurn = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
@@ -273,16 +392,8 @@ export const setNationTurn = async (
|
|||||||
action: string,
|
action: string,
|
||||||
args: unknown,
|
args: unknown,
|
||||||
expectedRevision: number
|
expectedRevision: number
|
||||||
): Promise<ReservedTurnSnapshot> => {
|
): Promise<ReservedTurnSnapshot> =>
|
||||||
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision);
|
||||||
const turns = await loadNationTurns(db, nationId, officerLevel);
|
|
||||||
turns[turnIndex] = {
|
|
||||||
action: normalizeAction(action),
|
|
||||||
args: normalizeArgs(args),
|
|
||||||
};
|
|
||||||
await persistNationTurns(db, nationId, officerLevel, turns);
|
|
||||||
return { revision, turns: serializeTurnList(turns) };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const shiftNationTurns = async (
|
export const shiftNationTurns = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
@@ -291,9 +402,28 @@ export const shiftNationTurns = async (
|
|||||||
amount: number,
|
amount: number,
|
||||||
expectedRevision: number
|
expectedRevision: number
|
||||||
): Promise<ReservedTurnSnapshot> => {
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
if (Math.abs(amount) >= MAX_NATION_TURNS) {
|
||||||
|
return assertNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||||
|
}
|
||||||
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||||
const turns = await loadNationTurns(db, nationId, officerLevel);
|
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||||
const shifted = applyShift(turns, amount);
|
const shifted = applyShift(turns, amount);
|
||||||
await persistNationTurns(db, nationId, officerLevel, shifted);
|
await persistNationTurns(db, nationId, officerLevel, shifted);
|
||||||
return { revision, turns: serializeTurnList(shifted) };
|
return { revision, turns: serializeTurnList(shifted) };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const repeatNationTurns = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number,
|
||||||
|
amount: number,
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
if (amount >= MAX_NATION_TURNS) {
|
||||||
|
return assertNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||||
|
}
|
||||||
|
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||||
|
const turns = applyRepeat(await loadNationTurns(db, nationId, officerLevel), amount);
|
||||||
|
await persistNationTurns(db, nationId, officerLevel, turns);
|
||||||
|
return { revision, turns: serializeTurnList(turns) };
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import type { DatabaseClient, GeneralTurnRow, NationTurnRow } from '../src/conte
|
|||||||
import {
|
import {
|
||||||
MAX_GENERAL_TURNS,
|
MAX_GENERAL_TURNS,
|
||||||
MAX_NATION_TURNS,
|
MAX_NATION_TURNS,
|
||||||
|
expandGeneralTurnIndices,
|
||||||
|
repeatGeneralTurns,
|
||||||
|
repeatNationTurns,
|
||||||
setGeneralTurn,
|
setGeneralTurn,
|
||||||
|
setGeneralTurns,
|
||||||
setNationTurn,
|
setNationTurn,
|
||||||
|
setNationTurns,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
shiftNationTurns,
|
shiftNationTurns,
|
||||||
ReservedTurnRevisionConflictError,
|
ReservedTurnRevisionConflictError,
|
||||||
@@ -238,4 +243,99 @@ describe('reservedTurns', () => {
|
|||||||
expect(pushed.turns[0]?.action).toBe('휴식');
|
expect(pushed.turns[0]?.action).toBe('휴식');
|
||||||
expect(pushed.turns[1]?.action).toBe('che_포상');
|
expect(pushed.turns[1]?.action).toBe('che_포상');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('expands legacy general sentinel turn lists and applies bulk entries in order', async () => {
|
||||||
|
const { db } = buildDb();
|
||||||
|
|
||||||
|
expect(expandGeneralTurnIndices([-1])).toEqual(
|
||||||
|
Array.from({ length: MAX_GENERAL_TURNS / 2 }, (_, index) => index * 2)
|
||||||
|
);
|
||||||
|
expect(expandGeneralTurnIndices([-2])).toEqual(
|
||||||
|
Array.from({ length: MAX_GENERAL_TURNS / 2 }, (_, index) => index * 2 + 1)
|
||||||
|
);
|
||||||
|
expect(expandGeneralTurnIndices([-3])).toEqual(Array.from({ length: MAX_GENERAL_TURNS }, (_, index) => index));
|
||||||
|
|
||||||
|
const result = await setGeneralTurns(
|
||||||
|
db,
|
||||||
|
3,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
turnIndices: expandGeneralTurnIndices([-1]),
|
||||||
|
action: 'che_훈련',
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
turnIndices: expandGeneralTurnIndices([-2]),
|
||||||
|
action: 'che_사기진작',
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
turnIndices: [29],
|
||||||
|
action: '휴식',
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.turns[0]?.action).toBe('che_훈련');
|
||||||
|
expect(result.turns[1]?.action).toBe('che_사기진작');
|
||||||
|
expect(result.turns[28]?.action).toBe('che_훈련');
|
||||||
|
expect(result.turns[29]?.action).toBe('휴식');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeats the leading general pattern at the legacy interval', async () => {
|
||||||
|
const { db } = buildDb();
|
||||||
|
const seeded = await setGeneralTurns(
|
||||||
|
db,
|
||||||
|
4,
|
||||||
|
[
|
||||||
|
{ turnIndices: [0], action: 'che_훈련', args: {} },
|
||||||
|
{ turnIndices: [1], action: 'che_사기진작', args: {} },
|
||||||
|
{ turnIndices: [2], action: 'che_징병', args: { crewTypeId: 1100, amount: 100 } },
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const repeated = await repeatGeneralTurns(db, 4, 3, seeded.revision);
|
||||||
|
|
||||||
|
expect(repeated.turns.slice(0, 9).map((turn) => turn.action)).toEqual([
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
'che_징병',
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
'che_징병',
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
'che_징병',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => {
|
||||||
|
const { db } = buildDb();
|
||||||
|
const seeded = await setNationTurns(
|
||||||
|
db,
|
||||||
|
5,
|
||||||
|
12,
|
||||||
|
[
|
||||||
|
{ turnIndices: [0, 2], action: 'che_포상', args: { amount: 1 } },
|
||||||
|
{ turnIndices: [1], action: 'che_몰수', args: { amount: 1 } },
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const repeated = await repeatNationTurns(db, 5, 12, 3, seeded.revision);
|
||||||
|
expect(repeated.turns.slice(0, 6).map((turn) => turn.action)).toEqual([
|
||||||
|
'che_포상',
|
||||||
|
'che_몰수',
|
||||||
|
'che_포상',
|
||||||
|
'che_포상',
|
||||||
|
'che_몰수',
|
||||||
|
'che_포상',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const noOpRepeat = await repeatNationTurns(db, 5, 12, 12, repeated.revision);
|
||||||
|
expect(noOpRepeat).toEqual(repeated);
|
||||||
|
const noOpPush = await shiftNationTurns(db, 5, 12, 12, repeated.revision);
|
||||||
|
expect(noOpPush).toEqual(repeated);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -476,6 +476,113 @@ describe('appRouter', () => {
|
|||||||
expect(writes).toHaveLength(1);
|
expect(writes).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies legacy general sentinel bulk entries and repeats the leading pattern', async () => {
|
||||||
|
const general = buildGeneralRow({ id: 16 });
|
||||||
|
const bulkWrites: unknown[] = [];
|
||||||
|
const bulkCaller = appRouter.createCaller(buildContext({ general, generalTurnWrites: bulkWrites }));
|
||||||
|
|
||||||
|
const bulk = await bulkCaller.turns.reserved.setGeneralBulk({
|
||||||
|
generalId: general.id,
|
||||||
|
entries: [
|
||||||
|
{ turnList: [-1], action: 'che_훈련' },
|
||||||
|
{ turnList: [-2], action: 'che_사기진작' },
|
||||||
|
],
|
||||||
|
expectedRevision: 0,
|
||||||
|
});
|
||||||
|
expect(bulk.turns[0]?.action).toBe('che_훈련');
|
||||||
|
expect(bulk.turns[1]?.action).toBe('che_사기진작');
|
||||||
|
expect(bulk.turns[29]?.action).toBe('che_사기진작');
|
||||||
|
expect(bulkWrites).toHaveLength(1);
|
||||||
|
|
||||||
|
const repeatWrites: unknown[] = [];
|
||||||
|
const repeatCaller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
general,
|
||||||
|
generalTurns: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
generalId: general.id,
|
||||||
|
turnIdx: 0,
|
||||||
|
actionCode: 'che_훈련',
|
||||||
|
arg: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
generalId: general.id,
|
||||||
|
turnIdx: 1,
|
||||||
|
actionCode: 'che_사기진작',
|
||||||
|
arg: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generalTurnWrites: repeatWrites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const repeated = await repeatCaller.turns.reserved.repeatGeneral({
|
||||||
|
generalId: general.id,
|
||||||
|
amount: 2,
|
||||||
|
expectedRevision: 0,
|
||||||
|
});
|
||||||
|
expect(repeated.turns.slice(0, 6).map((turn) => turn.action)).toEqual([
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
]);
|
||||||
|
expect(repeatWrites).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the legacy nation amount-12 no-op without incrementing revision', async () => {
|
||||||
|
const general = buildGeneralRow({ id: 17, nationId: 3, officerLevel: 12 });
|
||||||
|
const nationWrites: unknown[] = [];
|
||||||
|
const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites }));
|
||||||
|
|
||||||
|
const shifted = await caller.turns.reserved.shiftNation({
|
||||||
|
generalId: general.id,
|
||||||
|
amount: 12,
|
||||||
|
expectedRevision: 0,
|
||||||
|
});
|
||||||
|
const repeated = await caller.turns.reserved.repeatNation({
|
||||||
|
generalId: general.id,
|
||||||
|
amount: 12,
|
||||||
|
expectedRevision: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shifted.revision).toBe(0);
|
||||||
|
expect(repeated.revision).toBe(0);
|
||||||
|
expect(nationWrites).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies nation bulk entries sequentially so later entries overwrite overlaps', async () => {
|
||||||
|
const general = buildGeneralRow({ id: 18, nationId: 3, officerLevel: 12 });
|
||||||
|
const nationWrites: unknown[] = [];
|
||||||
|
const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites }));
|
||||||
|
|
||||||
|
const response = await caller.turns.reserved.setNationBulk({
|
||||||
|
generalId: general.id,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
turnList: [0, 2],
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 1, destGeneralId: 7 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
turnList: [2],
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: false, amount: 2, destGeneralId: 8 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expectedRevision: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.turns[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 });
|
||||||
|
expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 });
|
||||||
|
expect(nationWrites).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects malformed and cross-scope arguments without writing turns', async () => {
|
it('rejects malformed and cross-scope arguments without writing turns', async () => {
|
||||||
const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 });
|
const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 });
|
||||||
const generalWrites: unknown[] = [];
|
const generalWrites: unknown[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user