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 { authedProcedure } from '../../../trpc.js';
|
||||||
import { getMyGeneral } from '../../shared/general.js';
|
import { getMyGeneral } from '../../shared/general.js';
|
||||||
import { resolveSecretPermission } from '../../shared/secretPermission.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';
|
import { assertNationAccess } from '../shared.js';
|
||||||
|
|
||||||
export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
|
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 chiefLevels = [12, 10, 8, 6, 11, 9, 7, 5];
|
||||||
const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general]));
|
const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general]));
|
||||||
|
|
||||||
const turnsByLevel = await Promise.all(
|
const turnsByLevel = await Promise.all(chiefLevels.map((level) => getNationTurnSnapshot(ctx.db, nation.id, level)));
|
||||||
chiefLevels.map((level) => listNationTurns(ctx.db, nation.id, level))
|
|
||||||
);
|
|
||||||
|
|
||||||
const chiefs = chiefLevels.map((level, idx) => {
|
const chiefs = chiefLevels.map((level, idx) => {
|
||||||
const entry = generalByLevel.get(level);
|
const entry = generalByLevel.get(level);
|
||||||
@@ -67,7 +65,8 @@ export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
|
|||||||
name: entry?.name ?? null,
|
name: entry?.name ?? null,
|
||||||
npcState: entry?.npcState ?? null,
|
npcState: entry?.npcState ?? null,
|
||||||
turnTime: entry?.turnTime ? entry.turnTime.toISOString() : 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 {
|
import {
|
||||||
MAX_GENERAL_TURNS,
|
MAX_GENERAL_TURNS,
|
||||||
MAX_NATION_TURNS,
|
MAX_NATION_TURNS,
|
||||||
listGeneralTurns,
|
ReservedTurnRevisionConflictError,
|
||||||
listNationTurns,
|
getGeneralTurnSnapshot,
|
||||||
|
getNationTurnSnapshot,
|
||||||
setGeneralTurn,
|
setGeneralTurn,
|
||||||
setNationTurn,
|
setNationTurn,
|
||||||
shiftGeneralTurns,
|
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({
|
export const turnsRouter = router({
|
||||||
getCommandTable: authedProcedure
|
getCommandTable: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -164,7 +180,7 @@ export const turnsRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
await getOwnedGeneral(ctx, input.generalId);
|
await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
|
||||||
return listGeneralTurns(ctx.db, input.generalId);
|
return getGeneralTurnSnapshot(ctx.db, input.generalId);
|
||||||
}),
|
}),
|
||||||
getNation: authedProcedure
|
getNation: authedProcedure
|
||||||
.input(
|
.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
|
setGeneral: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -200,33 +216,33 @@ export const turnsRouter = router({
|
|||||||
.max(MAX_GENERAL_TURNS - 1),
|
.max(MAX_GENERAL_TURNS - 1),
|
||||||
action: z.string().min(1),
|
action: z.string().min(1),
|
||||||
args: z.unknown().optional(),
|
args: z.unknown().optional(),
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await getOwnedGeneral(ctx, input.generalId);
|
await getOwnedGeneral(ctx, input.generalId);
|
||||||
const args = await parseCommandArgs('general', input.action, input.args);
|
const args = await parseCommandArgs('general', input.action, input.args);
|
||||||
|
|
||||||
const turns = await setGeneralTurn(
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
ctx.db,
|
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
||||||
input.generalId,
|
|
||||||
input.turnIndex,
|
|
||||||
input.action,
|
|
||||||
args
|
|
||||||
);
|
);
|
||||||
return { ok: true, turns };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
shiftGeneral: authedProcedure
|
shiftGeneral: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
amount: buildShiftAmountSchema(MAX_GENERAL_TURNS),
|
amount: buildShiftAmountSchema(MAX_GENERAL_TURNS),
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await getOwnedGeneral(ctx, input.generalId);
|
await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
|
||||||
const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount);
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
return { ok: true, turns };
|
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||||
|
);
|
||||||
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
setNation: authedProcedure
|
setNation: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -239,6 +255,7 @@ export const turnsRouter = router({
|
|||||||
.max(MAX_NATION_TURNS - 1),
|
.max(MAX_NATION_TURNS - 1),
|
||||||
action: z.string().min(1),
|
action: z.string().min(1),
|
||||||
args: z.unknown().optional(),
|
args: z.unknown().optional(),
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -257,21 +274,25 @@ export const turnsRouter = router({
|
|||||||
}
|
}
|
||||||
const args = await parseCommandArgs('nation', input.action, input.args);
|
const args = await parseCommandArgs('nation', input.action, input.args);
|
||||||
|
|
||||||
const turns = await setNationTurn(
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
ctx.db,
|
setNationTurn(
|
||||||
general.nationId,
|
ctx.db,
|
||||||
general.officerLevel,
|
general.nationId,
|
||||||
input.turnIndex,
|
general.officerLevel,
|
||||||
input.action,
|
input.turnIndex,
|
||||||
args
|
input.action,
|
||||||
|
args,
|
||||||
|
input.expectedRevision
|
||||||
|
)
|
||||||
);
|
);
|
||||||
return { ok: true, turns };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
shiftNation: authedProcedure
|
shiftNation: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
amount: buildShiftAmountSchema(MAX_NATION_TURNS),
|
amount: buildShiftAmountSchema(MAX_NATION_TURNS),
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -289,8 +310,16 @@ export const turnsRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const turns = await shiftNationTurns(ctx.db, general.nationId, general.officerLevel, input.amount);
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
return { ok: true, turns };
|
shiftNationTurns(
|
||||||
|
ctx.db,
|
||||||
|
general.nationId,
|
||||||
|
general.officerLevel,
|
||||||
|
input.amount,
|
||||||
|
input.expectedRevision
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ export interface ReservedTurnView {
|
|||||||
args: InputJsonValue;
|
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 =>
|
const normalizeAction = (action: string | null | undefined): string =>
|
||||||
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
|
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
|
||||||
|
|
||||||
@@ -111,6 +126,17 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P
|
|||||||
return serializeTurnList(turns);
|
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 (
|
export const loadNationTurns = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
@@ -132,31 +158,111 @@ export const listNationTurns = async (
|
|||||||
return serializeTurnList(turns);
|
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 (
|
export const setGeneralTurn = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
generalId: number,
|
generalId: number,
|
||||||
turnIndex: number,
|
turnIndex: number,
|
||||||
action: string,
|
action: string,
|
||||||
args: unknown
|
args: unknown,
|
||||||
): Promise<ReservedTurnView[]> => {
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
||||||
const turns = await loadGeneralTurns(db, generalId);
|
const turns = await loadGeneralTurns(db, generalId);
|
||||||
turns[turnIndex] = {
|
turns[turnIndex] = {
|
||||||
action: normalizeAction(action),
|
action: normalizeAction(action),
|
||||||
args: normalizeArgs(args),
|
args: normalizeArgs(args),
|
||||||
};
|
};
|
||||||
await persistGeneralTurns(db, generalId, turns);
|
await persistGeneralTurns(db, generalId, turns);
|
||||||
return serializeTurnList(turns);
|
return { revision, turns: serializeTurnList(turns) };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shiftGeneralTurns = async (
|
export const shiftGeneralTurns = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
generalId: number,
|
generalId: number,
|
||||||
amount: number
|
amount: number,
|
||||||
): Promise<ReservedTurnView[]> => {
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
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);
|
||||||
await persistGeneralTurns(db, generalId, shifted);
|
await persistGeneralTurns(db, generalId, shifted);
|
||||||
return serializeTurnList(shifted);
|
return { revision, turns: serializeTurnList(shifted) };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setNationTurn = async (
|
export const setNationTurn = async (
|
||||||
@@ -165,25 +271,29 @@ export const setNationTurn = async (
|
|||||||
officerLevel: number,
|
officerLevel: number,
|
||||||
turnIndex: number,
|
turnIndex: number,
|
||||||
action: string,
|
action: string,
|
||||||
args: unknown
|
args: unknown,
|
||||||
): Promise<ReservedTurnView[]> => {
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||||
const turns = await loadNationTurns(db, nationId, officerLevel);
|
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||||
turns[turnIndex] = {
|
turns[turnIndex] = {
|
||||||
action: normalizeAction(action),
|
action: normalizeAction(action),
|
||||||
args: normalizeArgs(args),
|
args: normalizeArgs(args),
|
||||||
};
|
};
|
||||||
await persistNationTurns(db, nationId, officerLevel, turns);
|
await persistNationTurns(db, nationId, officerLevel, turns);
|
||||||
return serializeTurnList(turns);
|
return { revision, turns: serializeTurnList(turns) };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shiftNationTurns = async (
|
export const shiftNationTurns = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
officerLevel: number,
|
officerLevel: number,
|
||||||
amount: number
|
amount: number,
|
||||||
): Promise<ReservedTurnView[]> => {
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
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 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,
|
setNationTurn,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
shiftNationTurns,
|
shiftNationTurns,
|
||||||
|
ReservedTurnRevisionConflictError,
|
||||||
} from '../src/turns/reservedTurns.js';
|
} from '../src/turns/reservedTurns.js';
|
||||||
|
|
||||||
const buildDb = () => {
|
const buildDb = () => {
|
||||||
const generalTurns = new Map<number, GeneralTurnRow[]>();
|
const generalTurns = new Map<number, GeneralTurnRow[]>();
|
||||||
const nationTurns = new Map<string, NationTurnRow[]>();
|
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 GeneralTurnFindManyArgs = Parameters<DatabaseClient['generalTurn']['findMany']>[0];
|
||||||
type GeneralTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['generalTurn']['deleteMany']>[0]>;
|
type GeneralTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['generalTurn']['deleteMany']>[0]>;
|
||||||
@@ -21,6 +24,16 @@ const buildDb = () => {
|
|||||||
type NationTurnFindManyArgs = Parameters<DatabaseClient['nationTurn']['findMany']>[0];
|
type NationTurnFindManyArgs = Parameters<DatabaseClient['nationTurn']['findMany']>[0];
|
||||||
type NationTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['deleteMany']>[0]>;
|
type NationTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['deleteMany']>[0]>;
|
||||||
type NationTurnCreateManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['createMany']>[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 = {
|
const db = {
|
||||||
worldState: {
|
worldState: {
|
||||||
@@ -64,6 +77,39 @@ const buildDb = () => {
|
|||||||
return {};
|
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: {
|
nationTurn: {
|
||||||
findMany: async (args?: NationTurnFindManyArgs) => {
|
findMany: async (args?: NationTurnFindManyArgs) => {
|
||||||
const nationId = typeof args?.where?.nationId === 'number' ? args.where.nationId : undefined;
|
const nationId = typeof args?.where?.nationId === 'number' ? args.where.nationId : undefined;
|
||||||
@@ -100,6 +146,48 @@ const buildDb = () => {
|
|||||||
return {};
|
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;
|
} as unknown as DatabaseClient;
|
||||||
|
|
||||||
return { db };
|
return { db };
|
||||||
@@ -109,30 +197,45 @@ describe('reservedTurns', () => {
|
|||||||
it('sets and shifts general turns', async () => {
|
it('sets and shifts general turns', async () => {
|
||||||
const { db } = buildDb();
|
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.revision).toBe(1);
|
||||||
expect(initial[0]?.action).toBe('che_화계');
|
expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS);
|
||||||
|
expect(initial.turns[0]?.action).toBe('che_화계');
|
||||||
|
|
||||||
const pushed = await shiftGeneralTurns(db, 1, 1);
|
const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision);
|
||||||
expect(pushed[0]?.action).toBe('휴식');
|
expect(pushed.revision).toBe(2);
|
||||||
expect(pushed[1]?.action).toBe('che_화계');
|
expect(pushed.turns[0]?.action).toBe('휴식');
|
||||||
|
expect(pushed.turns[1]?.action).toBe('che_화계');
|
||||||
|
|
||||||
const pulled = await shiftGeneralTurns(db, 1, -1);
|
const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision);
|
||||||
expect(pulled[0]?.action).toBe('che_화계');
|
expect(pulled.turns[0]?.action).toBe('che_화계');
|
||||||
expect(pulled[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식');
|
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 () => {
|
it('sets and shifts nation turns', async () => {
|
||||||
const { db } = buildDb();
|
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.revision).toBe(1);
|
||||||
expect(initial[0]?.action).toBe('che_포상');
|
expect(initial.turns).toHaveLength(MAX_NATION_TURNS);
|
||||||
|
expect(initial.turns[0]?.action).toBe('che_포상');
|
||||||
|
|
||||||
const pushed = await shiftNationTurns(db, 2, 5, 1);
|
const pushed = await shiftNationTurns(db, 2, 5, 1, initial.revision);
|
||||||
expect(pushed[0]?.action).toBe('휴식');
|
expect(pushed.turns[0]?.action).toBe('휴식');
|
||||||
expect(pushed[1]?.action).toBe('che_포상');
|
expect(pushed.turns[1]?.action).toBe('che_포상');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ const buildContext = (options?: {
|
|||||||
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
|
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
|
||||||
const generalTurns = options?.generalTurns ?? [];
|
const generalTurns = options?.generalTurns ?? [];
|
||||||
const nationTurns = options?.nationTurns ?? [];
|
const nationTurns = options?.nationTurns ?? [];
|
||||||
|
let generalTurnRevision: number | undefined;
|
||||||
|
let nationTurnRevision: number | undefined;
|
||||||
const db = {
|
const db = {
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => {
|
findFirst: async () => {
|
||||||
@@ -133,17 +135,60 @@ const buildContext = (options?: {
|
|||||||
return {};
|
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: {
|
nationTurn: {
|
||||||
findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) =>
|
findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) =>
|
||||||
nationTurns.filter(
|
nationTurns.filter((row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel),
|
||||||
(row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel
|
|
||||||
),
|
|
||||||
deleteMany: async () => ({}),
|
deleteMany: async () => ({}),
|
||||||
createMany: async (args: unknown) => {
|
createMany: async (args: unknown) => {
|
||||||
options?.nationTurnWrites?.push(args);
|
options?.nationTurnWrites?.push(args);
|
||||||
return {};
|
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(
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
{
|
{
|
||||||
@@ -370,8 +415,9 @@ describe('appRouter', () => {
|
|||||||
const caller = appRouter.createCaller(buildContext({ general, generalTurns }));
|
const caller = appRouter.createCaller(buildContext({ general, generalTurns }));
|
||||||
const response = await caller.turns.reserved.getGeneral({ generalId: 11 });
|
const response = await caller.turns.reserved.getGeneral({ generalId: 11 });
|
||||||
|
|
||||||
expect(response[0]?.action).toBe('che_화계');
|
expect(response.revision).toBe(0);
|
||||||
expect(response[0]?.index).toBe(0);
|
expect(response.turns[0]?.action).toBe('che_화계');
|
||||||
|
expect(response.turns[0]?.index).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns reserved nation turns', async () => {
|
it('returns reserved nation turns', async () => {
|
||||||
@@ -390,8 +436,9 @@ describe('appRouter', () => {
|
|||||||
const caller = appRouter.createCaller(buildContext({ general, nationTurns }));
|
const caller = appRouter.createCaller(buildContext({ general, nationTurns }));
|
||||||
const response = await caller.turns.reserved.getNation({ generalId: 12 });
|
const response = await caller.turns.reserved.getNation({ generalId: 12 });
|
||||||
|
|
||||||
expect(response[0]?.action).toBe('che_포상');
|
expect(response.revision).toBe(0);
|
||||||
expect(response[0]?.index).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 () => {
|
it('validates and persists general command arguments from the authenticated owner', async () => {
|
||||||
@@ -404,6 +451,7 @@ describe('appRouter', () => {
|
|||||||
turnIndex: 0,
|
turnIndex: 0,
|
||||||
action: 'che_화계',
|
action: 'che_화계',
|
||||||
args: { destCityId: 7 },
|
args: { destCityId: 7 },
|
||||||
|
expectedRevision: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
|
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
|
||||||
@@ -416,6 +464,16 @@ describe('appRouter', () => {
|
|||||||
actionCode: 'che_화계',
|
actionCode: 'che_화계',
|
||||||
arg: { destCityId: 7 },
|
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 () => {
|
it('rejects malformed and cross-scope arguments without writing turns', async () => {
|
||||||
@@ -432,6 +490,7 @@ describe('appRouter', () => {
|
|||||||
turnIndex: 0,
|
turnIndex: 0,
|
||||||
action: 'che_화계',
|
action: 'che_화계',
|
||||||
args: { destCityId: '7' },
|
args: { destCityId: '7' },
|
||||||
|
expectedRevision: 0,
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
await expect(
|
await expect(
|
||||||
@@ -440,6 +499,7 @@ describe('appRouter', () => {
|
|||||||
turnIndex: 0,
|
turnIndex: 0,
|
||||||
action: 'che_포상',
|
action: 'che_포상',
|
||||||
args: { isGold: true, amount: 1, destGeneralId: 7 },
|
args: { isGold: true, amount: 1, destGeneralId: 7 },
|
||||||
|
expectedRevision: 0,
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
|
||||||
@@ -465,6 +525,7 @@ describe('appRouter', () => {
|
|||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
turnIndex: 0,
|
turnIndex: 0,
|
||||||
action: '휴식',
|
action: '휴식',
|
||||||
|
expectedRevision: 0,
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
@@ -473,6 +534,7 @@ describe('appRouter', () => {
|
|||||||
caller.turns.reserved.shiftGeneral({
|
caller.turns.reserved.shiftGeneral({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: 1,
|
amount: 1,
|
||||||
|
expectedRevision: 0,
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
@@ -482,6 +544,7 @@ describe('appRouter', () => {
|
|||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
turnIndex: 0,
|
turnIndex: 0,
|
||||||
action: '휴식',
|
action: '휴식',
|
||||||
|
expectedRevision: 0,
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
@@ -490,6 +553,7 @@ describe('appRouter', () => {
|
|||||||
caller.turns.reserved.shiftNation({
|
caller.turns.reserved.shiftNation({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: 1,
|
amount: 1,
|
||||||
|
expectedRevision: 0,
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
|
|||||||
@@ -315,9 +315,7 @@ const upsertRankRows = async (
|
|||||||
await prisma.$executeRaw(GamePrisma.sql`
|
await prisma.$executeRaw(GamePrisma.sql`
|
||||||
INSERT INTO "rank_data" ("general_id", "nation_id", "type", "value")
|
INSERT INTO "rank_data" ("general_id", "nation_id", "type", "value")
|
||||||
VALUES ${GamePrisma.join(
|
VALUES ${GamePrisma.join(
|
||||||
batch.map(
|
batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})`)
|
||||||
(row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})`
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
ON CONFLICT ("general_id", "type") DO UPDATE
|
ON CONFLICT ("general_id", "type") DO UPDATE
|
||||||
SET
|
SET
|
||||||
@@ -799,6 +797,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (deletedGenerals.length > 0) {
|
if (deletedGenerals.length > 0) {
|
||||||
|
if (prisma.generalTurnRevision) {
|
||||||
|
await prisma.generalTurnRevision.deleteMany({
|
||||||
|
where: { generalId: { in: deletedGenerals } },
|
||||||
|
});
|
||||||
|
}
|
||||||
await prisma.generalTurn.deleteMany({
|
await prisma.generalTurn.deleteMany({
|
||||||
where: { generalId: { in: deletedGenerals } },
|
where: { generalId: { in: deletedGenerals } },
|
||||||
});
|
});
|
||||||
@@ -816,6 +819,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
OR: [{ srcNationId: { in: deletedNations } }, { destNationId: { in: deletedNations } }],
|
OR: [{ srcNationId: { in: deletedNations } }, { destNationId: { in: deletedNations } }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (prisma.nationTurnRevision) {
|
||||||
|
await prisma.nationTurnRevision.deleteMany({
|
||||||
|
where: { nationId: { in: deletedNations } },
|
||||||
|
});
|
||||||
|
}
|
||||||
await prisma.nationTurn.deleteMany({
|
await prisma.nationTurn.deleteMany({
|
||||||
where: { nationId: { in: deletedNations } },
|
where: { nationId: { in: deletedNations } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ const buildTurnListFromRows = (
|
|||||||
|
|
||||||
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
|
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
|
||||||
|
|
||||||
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'>;
|
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
|
||||||
|
generalTurnRevision?: Pick<NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>, 'upsert'>;
|
||||||
|
nationTurnRevision?: Pick<NonNullable<TurnEngineDatabaseClient['nationTurnRevision']>, 'upsert'>;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ReservedTurnChanges {
|
export interface ReservedTurnChanges {
|
||||||
generalIds: number[];
|
generalIds: number[];
|
||||||
@@ -281,6 +284,13 @@ export class InMemoryReservedTurnStore {
|
|||||||
arg: asJson(normalizeArgs(entry.args)),
|
arg: asJson(normalizeArgs(entry.args)),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
if (prisma.generalTurnRevision) {
|
||||||
|
await prisma.generalTurnRevision.upsert({
|
||||||
|
where: { generalId },
|
||||||
|
create: { generalId, revision: 1 },
|
||||||
|
update: { revision: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const generalId of changes.generalInitializationIds) {
|
for (const generalId of changes.generalInitializationIds) {
|
||||||
@@ -316,6 +326,18 @@ export class InMemoryReservedTurnStore {
|
|||||||
arg: asJson(normalizeArgs(entry.args)),
|
arg: asJson(normalizeArgs(entry.args)),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
if (prisma.nationTurnRevision) {
|
||||||
|
await prisma.nationTurnRevision.upsert({
|
||||||
|
where: {
|
||||||
|
nationId_officerLevel: {
|
||||||
|
nationId,
|
||||||
|
officerLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { nationId, officerLevel, revision: 1 },
|
||||||
|
update: { revision: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of changes.nationInitializationKeys) {
|
for (const key of changes.nationInitializationKeys) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const processor: TurnProcessor = {
|
|||||||
describe('input event atomicity', () => {
|
describe('input event atomicity', () => {
|
||||||
it('keeps reserved-turn dirty state when persistence fails', async () => {
|
it('keeps reserved-turn dirty state when persistence fails', async () => {
|
||||||
let failCreate = true;
|
let failCreate = true;
|
||||||
|
const revisionUpsert = vi.fn(async () => ({ revision: 1 }));
|
||||||
const prisma = {
|
const prisma = {
|
||||||
generalTurn: {
|
generalTurn: {
|
||||||
findMany: vi.fn(async () => []),
|
findMany: vi.fn(async () => []),
|
||||||
@@ -38,6 +39,9 @@ describe('input event atomicity', () => {
|
|||||||
return { count: 1 };
|
return { count: 1 };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
generalTurnRevision: {
|
||||||
|
upsert: revisionUpsert,
|
||||||
|
},
|
||||||
nationTurn: {
|
nationTurn: {
|
||||||
findMany: vi.fn(async () => []),
|
findMany: vi.fn(async () => []),
|
||||||
deleteMany: vi.fn(async () => ({ count: 0 })),
|
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||||
@@ -61,6 +65,12 @@ describe('input event atomicity', () => {
|
|||||||
|
|
||||||
failCreate = false;
|
failCreate = false;
|
||||||
await store.flushChanges();
|
await store.flushChanges();
|
||||||
|
expect(revisionUpsert).toHaveBeenCalledOnce();
|
||||||
|
expect(revisionUpsert).toHaveBeenCalledWith({
|
||||||
|
where: { generalId: 7 },
|
||||||
|
create: { generalId: 7, revision: 1 },
|
||||||
|
update: { revision: { increment: 1 } },
|
||||||
|
});
|
||||||
expect(store.peekDirtyState()).toEqual({
|
expect(store.peekDirtyState()).toEqual({
|
||||||
generalIds: [],
|
generalIds: [],
|
||||||
generalInitializationIds: [],
|
generalInitializationIds: [],
|
||||||
|
|||||||
@@ -48,12 +48,10 @@ test('reserves an argument command in the real game API and reads it back from P
|
|||||||
const context = await game.general.me.query();
|
const context = await game.general.me.query();
|
||||||
if (!context) throw new Error('demo1 general is missing');
|
if (!context) throw new Error('demo1 general is missing');
|
||||||
const generalId = context.general.id;
|
const generalId = context.general.id;
|
||||||
const original = (
|
const originalGeneralSnapshot = await game.turns.reserved.getGeneral.query({ generalId });
|
||||||
(await game.turns.reserved.getGeneral.query({ generalId })) as unknown as PlainTurn[]
|
const original = (originalGeneralSnapshot.turns as unknown as PlainTurn[])[29];
|
||||||
)[29];
|
const originalNationSnapshot = await game.turns.reserved.getNation.query({ generalId });
|
||||||
const originalNation = (
|
const originalNation = (originalNationSnapshot.turns as unknown as PlainTurn[])[11];
|
||||||
(await game.turns.reserved.getNation.query({ generalId })) as unknown as PlainTurn[]
|
|
||||||
)[11];
|
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ token }) => {
|
({ token }) => {
|
||||||
@@ -71,9 +69,9 @@ test('reserves an argument command in the real game API and reads it back from P
|
|||||||
const form = page.getByTestId('command-argument-form');
|
const form = page.getByTestId('command-argument-form');
|
||||||
await expect(form).toBeVisible();
|
await expect(form).toBeVisible();
|
||||||
const citySelect = form.locator('select');
|
const citySelect = form.locator('select');
|
||||||
const optionValues = await citySelect.locator('option').evaluateAll((options) =>
|
const optionValues = await citySelect
|
||||||
options.map((option) => (option as HTMLOptionElement).value)
|
.locator('option')
|
||||||
);
|
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
|
||||||
const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId));
|
const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId));
|
||||||
expect(targetCityId).toBeGreaterThan(0);
|
expect(targetCityId).toBeGreaterThan(0);
|
||||||
await citySelect.selectOption(String(targetCityId));
|
await citySelect.selectOption(String(targetCityId));
|
||||||
@@ -83,7 +81,7 @@ test('reserves an argument command in the real game API and reads it back from P
|
|||||||
await lastTurn.getByRole('button', { name: '배치' }).click();
|
await lastTurn.getByRole('button', { name: '배치' }).click();
|
||||||
await expect(lastTurn.locator('.turn-action')).toHaveText('che_화계');
|
await expect(lastTurn.locator('.turn-action')).toHaveText('che_화계');
|
||||||
|
|
||||||
const persisted = (await game.turns.reserved.getGeneral.query({ generalId }))[29];
|
const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29];
|
||||||
expect(persisted).toEqual({
|
expect(persisted).toEqual({
|
||||||
index: 29,
|
index: 29,
|
||||||
action: 'che_화계',
|
action: 'che_화계',
|
||||||
@@ -95,9 +93,9 @@ test('reserves an argument command in the real game API and reads it back from P
|
|||||||
await form.getByRole('button', { name: '쌀', exact: true }).click();
|
await form.getByRole('button', { name: '쌀', exact: true }).click();
|
||||||
await form.locator('input[type=number]').fill('1');
|
await form.locator('input[type=number]').fill('1');
|
||||||
const generalSelect = form.locator('select');
|
const generalSelect = form.locator('select');
|
||||||
const generalValues = await generalSelect.locator('option').evaluateAll((options) =>
|
const generalValues = await generalSelect
|
||||||
options.map((option) => (option as HTMLOptionElement).value)
|
.locator('option')
|
||||||
);
|
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
|
||||||
const targetGeneralId = Number(generalValues.find((value) => Number(value) !== generalId));
|
const targetGeneralId = Number(generalValues.find((value) => Number(value) !== generalId));
|
||||||
expect(targetGeneralId).toBeGreaterThan(0);
|
expect(targetGeneralId).toBeGreaterThan(0);
|
||||||
await generalSelect.selectOption(String(targetGeneralId));
|
await generalSelect.selectOption(String(targetGeneralId));
|
||||||
@@ -105,7 +103,7 @@ test('reserves an argument command in the real game API and reads it back from P
|
|||||||
const lastNationTurn = nationSection.locator('.reserved-item').nth(11);
|
const lastNationTurn = nationSection.locator('.reserved-item').nth(11);
|
||||||
await lastNationTurn.getByRole('button', { name: '배치' }).click();
|
await lastNationTurn.getByRole('button', { name: '배치' }).click();
|
||||||
await expect(lastNationTurn.locator('.turn-action')).toHaveText('che_포상');
|
await expect(lastNationTurn.locator('.turn-action')).toHaveText('che_포상');
|
||||||
const persistedNation = (await game.turns.reserved.getNation.query({ generalId }))[11];
|
const persistedNation = (await game.turns.reserved.getNation.query({ generalId })).turns[11];
|
||||||
expect(persistedNation).toEqual({
|
expect(persistedNation).toEqual({
|
||||||
index: 11,
|
index: 11,
|
||||||
action: 'che_포상',
|
action: 'che_포상',
|
||||||
@@ -116,17 +114,21 @@ test('reserves an argument command in the real game API and reads it back from P
|
|||||||
fullPage: true,
|
fullPage: true,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
const currentGeneral = await game.turns.reserved.getGeneral.query({ generalId });
|
||||||
await game.turns.reserved.setGeneral.mutate({
|
await game.turns.reserved.setGeneral.mutate({
|
||||||
generalId,
|
generalId,
|
||||||
turnIndex: 29,
|
turnIndex: 29,
|
||||||
action: original?.action ?? '휴식',
|
action: original?.action ?? '휴식',
|
||||||
args: original?.args ?? {},
|
args: original?.args ?? {},
|
||||||
|
expectedRevision: currentGeneral.revision,
|
||||||
});
|
});
|
||||||
|
const currentNation = await game.turns.reserved.getNation.query({ generalId });
|
||||||
await game.turns.reserved.setNation.mutate({
|
await game.turns.reserved.setNation.mutate({
|
||||||
generalId,
|
generalId,
|
||||||
turnIndex: 11,
|
turnIndex: 11,
|
||||||
action: originalNation?.action ?? '휴식',
|
action: originalNation?.action ?? '휴식',
|
||||||
args: originalNation?.args ?? {},
|
args: originalNation?.args ?? {},
|
||||||
|
expectedRevision: currentNation.revision,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
||||||
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
||||||
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
||||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
|
||||||
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||||
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||||
|
|
||||||
@@ -46,6 +46,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const boardAccess = ref<BoardAccess | null>(null);
|
const boardAccess = ref<BoardAccess | null>(null);
|
||||||
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
|
const reservedGeneralRevision = ref(0);
|
||||||
|
const reservedNationRevision = ref(0);
|
||||||
const globalRecords = ref<RecentRecord[]>([]);
|
const globalRecords = ref<RecentRecord[]>([]);
|
||||||
const generalRecords = ref<RecentRecord[]>([]);
|
const generalRecords = ref<RecentRecord[]>([]);
|
||||||
const worldHistory = ref<RecentRecord[]>([]);
|
const worldHistory = ref<RecentRecord[]>([]);
|
||||||
@@ -260,6 +262,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
if (!context) {
|
if (!context) {
|
||||||
reservedGeneralTurns.value = null;
|
reservedGeneralTurns.value = null;
|
||||||
reservedNationTurns.value = null;
|
reservedNationTurns.value = null;
|
||||||
|
reservedGeneralRevision.value = 0;
|
||||||
|
reservedNationRevision.value = 0;
|
||||||
boardAccess.value = null;
|
boardAccess.value = null;
|
||||||
resetRecentRecords(null);
|
resetRecentRecords(null);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -322,8 +326,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
messages.value = messageData;
|
messages.value = messageData;
|
||||||
messageContacts.value = contacts;
|
messageContacts.value = contacts;
|
||||||
boardAccess.value = access;
|
boardAccess.value = access;
|
||||||
reservedGeneralTurns.value = generalTurns;
|
reservedGeneralTurns.value = generalTurns.turns;
|
||||||
reservedNationTurns.value = nationTurns;
|
reservedGeneralRevision.value = generalTurns.revision;
|
||||||
|
reservedNationTurns.value = nationTurns?.turns ?? null;
|
||||||
|
reservedNationRevision.value = nationTurns?.revision ?? 0;
|
||||||
if (records) {
|
if (records) {
|
||||||
globalRecords.value = mergeRecentRecords(globalRecords.value, records.global);
|
globalRecords.value = mergeRecentRecords(globalRecords.value, records.global);
|
||||||
generalRecords.value = mergeRecentRecords(generalRecords.value, records.general);
|
generalRecords.value = mergeRecentRecords(generalRecords.value, records.general);
|
||||||
@@ -485,10 +491,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
turnIndex,
|
turnIndex,
|
||||||
action,
|
action,
|
||||||
args,
|
args,
|
||||||
|
expectedRevision: reservedGeneralRevision.value,
|
||||||
});
|
});
|
||||||
reservedGeneralTurns.value = result.turns;
|
reservedGeneralTurns.value = result.turns;
|
||||||
|
reservedGeneralRevision.value = result.revision;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
|
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
||||||
|
if (snapshot) {
|
||||||
|
reservedGeneralTurns.value = snapshot.turns;
|
||||||
|
reservedGeneralRevision.value = snapshot.revision;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -501,10 +514,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const result = await trpc.turns.reserved.shiftGeneral.mutate({
|
const result = await trpc.turns.reserved.shiftGeneral.mutate({
|
||||||
generalId: id,
|
generalId: id,
|
||||||
amount,
|
amount,
|
||||||
|
expectedRevision: reservedGeneralRevision.value,
|
||||||
});
|
});
|
||||||
reservedGeneralTurns.value = result.turns;
|
reservedGeneralTurns.value = result.turns;
|
||||||
|
reservedGeneralRevision.value = result.revision;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
|
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
||||||
|
if (snapshot) {
|
||||||
|
reservedGeneralTurns.value = snapshot.turns;
|
||||||
|
reservedGeneralRevision.value = snapshot.revision;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -523,10 +543,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
turnIndex,
|
turnIndex,
|
||||||
action,
|
action,
|
||||||
args,
|
args,
|
||||||
|
expectedRevision: reservedNationRevision.value,
|
||||||
});
|
});
|
||||||
reservedNationTurns.value = result.turns;
|
reservedNationTurns.value = result.turns;
|
||||||
|
reservedNationRevision.value = result.revision;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
|
const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null);
|
||||||
|
if (snapshot) {
|
||||||
|
reservedNationTurns.value = snapshot.turns;
|
||||||
|
reservedNationRevision.value = snapshot.revision;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -543,10 +570,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const result = await trpc.turns.reserved.shiftNation.mutate({
|
const result = await trpc.turns.reserved.shiftNation.mutate({
|
||||||
generalId: id,
|
generalId: id,
|
||||||
amount,
|
amount,
|
||||||
|
expectedRevision: reservedNationRevision.value,
|
||||||
});
|
});
|
||||||
reservedNationTurns.value = result.turns;
|
reservedNationTurns.value = result.turns;
|
||||||
|
reservedNationRevision.value = result.revision;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
|
const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null);
|
||||||
|
if (snapshot) {
|
||||||
|
reservedNationTurns.value = snapshot.turns;
|
||||||
|
reservedNationRevision.value = snapshot.revision;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type ChiefEntry = {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
npcState: number | null;
|
npcState: number | null;
|
||||||
turnTime: string | null;
|
turnTime: string | null;
|
||||||
|
revision: number;
|
||||||
turns: ChiefTurn[];
|
turns: ChiefTurn[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,7 +60,8 @@ type CommandAvailability = {
|
|||||||
step?: number;
|
step?: number;
|
||||||
constValue?: string | number;
|
constValue?: string | number;
|
||||||
options?: Array<{ value: string | number; label: string; color?: string }>;
|
options?: Array<{ value: string | number; label: string; color?: string }>;
|
||||||
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
optionSource?:
|
||||||
|
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
||||||
tupleLabels?: string[];
|
tupleLabels?: string[];
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -181,9 +183,7 @@ watch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preferred =
|
const preferred =
|
||||||
snapshot.me.officerLevel >= 5
|
snapshot.me.officerLevel >= 5 ? snapshot.me.officerLevel : (snapshot.chiefs[0]?.officerLevel ?? null);
|
||||||
? snapshot.me.officerLevel
|
|
||||||
: snapshot.chiefs[0]?.officerLevel ?? null;
|
|
||||||
selectedChiefLevel.value = preferred;
|
selectedChiefLevel.value = preferred;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -315,7 +315,7 @@ const selectedChiefRows = computed(() => {
|
|||||||
return buildTurnRows(selectedChief.value);
|
return buildTurnRows(selectedChief.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMyTurns = (turns: ChiefEntry['turns']) => {
|
const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => {
|
||||||
if (!data.value) {
|
if (!data.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -325,6 +325,7 @@ const updateMyTurns = (turns: ChiefEntry['turns']) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
entry.turns = turns;
|
entry.turns = turns;
|
||||||
|
entry.revision = revision;
|
||||||
};
|
};
|
||||||
|
|
||||||
const reserveTurn = async (turnIndex: number) => {
|
const reserveTurn = async (turnIndex: number) => {
|
||||||
@@ -340,9 +341,11 @@ const reserveTurn = async (turnIndex: number) => {
|
|||||||
turnIndex,
|
turnIndex,
|
||||||
action: selectedCommand.value.key,
|
action: selectedCommand.value.key,
|
||||||
args: commandArgs.value,
|
args: commandArgs.value,
|
||||||
|
expectedRevision: selectedChief.value?.revision ?? 0,
|
||||||
});
|
});
|
||||||
updateMyTurns(result.turns);
|
updateMyTurns(result.turns, result.revision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await loadChiefCenter();
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -357,9 +360,11 @@ const clearTurn = async (turnIndex: number) => {
|
|||||||
turnIndex,
|
turnIndex,
|
||||||
action: '휴식',
|
action: '휴식',
|
||||||
args: {},
|
args: {},
|
||||||
|
expectedRevision: selectedChief.value?.revision ?? 0,
|
||||||
});
|
});
|
||||||
updateMyTurns(result.turns);
|
updateMyTurns(result.turns, result.revision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await loadChiefCenter();
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -372,9 +377,11 @@ const shiftTurns = async (amount: number) => {
|
|||||||
const result = await trpc.turns.reserved.shiftNation.mutate({
|
const result = await trpc.turns.reserved.shiftNation.mutate({
|
||||||
generalId: data.value.me.id,
|
generalId: data.value.me.id,
|
||||||
amount,
|
amount,
|
||||||
|
expectedRevision: selectedChief.value?.revision ?? 0,
|
||||||
});
|
});
|
||||||
updateMyTurns(result.turns);
|
updateMyTurns(result.turns, result.revision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await loadChiefCenter();
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -496,9 +503,7 @@ const shiftTurns = async (amount: number) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="chief-side">
|
<div class="chief-side">
|
||||||
<PanelCard title="사령부 편집" subtitle="선택 명령을 배치하세요">
|
<PanelCard title="사령부 편집" subtitle="선택 명령을 배치하세요">
|
||||||
<div v-if="!isEditingAllowed" class="muted">
|
<div v-if="!isEditingAllowed" class="muted">사령부 편집은 본인 관직에서만 가능합니다.</div>
|
||||||
사령부 편집은 본인 관직에서만 가능합니다.
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<CommandSelectForm
|
<CommandSelectForm
|
||||||
:command-table="chiefCommandTable"
|
:command-table="chiefCommandTable"
|
||||||
|
|||||||
@@ -368,6 +368,14 @@ model GeneralTurn {
|
|||||||
@@map("general_turn")
|
@@map("general_turn")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model GeneralTurnRevision {
|
||||||
|
generalId Int @id @map("general_id")
|
||||||
|
revision Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("general_turn_revision")
|
||||||
|
}
|
||||||
|
|
||||||
model NationTurn {
|
model NationTurn {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
@@ -381,6 +389,16 @@ model NationTurn {
|
|||||||
@@map("nation_turn")
|
@@map("nation_turn")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NationTurnRevision {
|
||||||
|
nationId Int @map("nation_id")
|
||||||
|
officerLevel Int @map("officer_level")
|
||||||
|
revision Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@id([nationId, officerLevel])
|
||||||
|
@@map("nation_turn_revision")
|
||||||
|
}
|
||||||
|
|
||||||
model Diplomacy {
|
model Diplomacy {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
srcNationId Int @map("src_nation_id")
|
srcNationId Int @map("src_nation_id")
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "general_turn_revision" (
|
||||||
|
"general_id" INTEGER NOT NULL,
|
||||||
|
"revision" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "general_turn_revision_pkey" PRIMARY KEY ("general_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "nation_turn_revision" (
|
||||||
|
"nation_id" INTEGER NOT NULL,
|
||||||
|
"officer_level" INTEGER NOT NULL,
|
||||||
|
"revision" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "nation_turn_revision_pkey" PRIMARY KEY ("nation_id", "officer_level")
|
||||||
|
);
|
||||||
@@ -21,7 +21,9 @@ export interface DatabaseClient {
|
|||||||
oldGeneral: GamePrisma.OldGeneralDelegate;
|
oldGeneral: GamePrisma.OldGeneralDelegate;
|
||||||
emperor: GamePrisma.EmperorDelegate;
|
emperor: GamePrisma.EmperorDelegate;
|
||||||
generalTurn: GamePrisma.GeneralTurnDelegate;
|
generalTurn: GamePrisma.GeneralTurnDelegate;
|
||||||
|
generalTurnRevision: GamePrisma.GeneralTurnRevisionDelegate;
|
||||||
nationTurn: GamePrisma.NationTurnDelegate;
|
nationTurn: GamePrisma.NationTurnDelegate;
|
||||||
|
nationTurnRevision: GamePrisma.NationTurnRevisionDelegate;
|
||||||
troop: GamePrisma.TroopDelegate;
|
troop: GamePrisma.TroopDelegate;
|
||||||
logEntry: GamePrisma.LogEntryDelegate;
|
logEntry: GamePrisma.LogEntryDelegate;
|
||||||
auction: GamePrisma.AuctionDelegate;
|
auction: GamePrisma.AuctionDelegate;
|
||||||
|
|||||||
@@ -436,9 +436,17 @@ export interface TurnEngineDatabaseClient {
|
|||||||
deleteMany(args?: unknown): Promise<unknown>;
|
deleteMany(args?: unknown): Promise<unknown>;
|
||||||
createMany(args?: unknown): Promise<unknown>;
|
createMany(args?: unknown): Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
generalTurnRevision?: {
|
||||||
|
upsert(args: unknown): Promise<unknown>;
|
||||||
|
deleteMany(args?: unknown): Promise<unknown>;
|
||||||
|
};
|
||||||
nationTurn: {
|
nationTurn: {
|
||||||
findMany(args?: unknown): Promise<TurnEngineNationTurnRow[]>;
|
findMany(args?: unknown): Promise<TurnEngineNationTurnRow[]>;
|
||||||
deleteMany(args?: unknown): Promise<unknown>;
|
deleteMany(args?: unknown): Promise<unknown>;
|
||||||
createMany(args?: unknown): Promise<unknown>;
|
createMany(args?: unknown): Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
nationTurnRevision?: {
|
||||||
|
upsert(args: unknown): Promise<unknown>;
|
||||||
|
deleteMany(args?: unknown): Promise<unknown>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,12 +338,20 @@ describe('integration initialization flow', () => {
|
|||||||
for (const [idx, user] of userSessions.entries()) {
|
for (const [idx, user] of userSessions.entries()) {
|
||||||
const accessRef = { value: user.accessToken };
|
const accessRef = { value: user.accessToken };
|
||||||
const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef);
|
const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef);
|
||||||
if (idx < 3) {
|
let queueRevision = (
|
||||||
await userGameClient.turns.reserved.setGeneral.mutate({
|
await userGameClient.turns.reserved.getGeneral.query({
|
||||||
generalId: user.generalId,
|
generalId: user.generalId,
|
||||||
turnIndex: 0,
|
})
|
||||||
action: 'che_거병',
|
).revision;
|
||||||
});
|
if (idx < 3) {
|
||||||
|
queueRevision = (
|
||||||
|
await userGameClient.turns.reserved.setGeneral.mutate({
|
||||||
|
generalId: user.generalId,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: 'che_거병',
|
||||||
|
expectedRevision: queueRevision,
|
||||||
|
})
|
||||||
|
).revision;
|
||||||
await userGameClient.turns.reserved.setGeneral.mutate({
|
await userGameClient.turns.reserved.setGeneral.mutate({
|
||||||
generalId: user.generalId,
|
generalId: user.generalId,
|
||||||
turnIndex: 1,
|
turnIndex: 1,
|
||||||
@@ -353,20 +361,25 @@ describe('integration initialization flow', () => {
|
|||||||
nationType: 'che_def',
|
nationType: 'che_def',
|
||||||
colorType: idx,
|
colorType: idx,
|
||||||
},
|
},
|
||||||
|
expectedRevision: queueRevision,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const destNationId = joinNationIds[(idx - 3) % joinNationIds.length]!;
|
const destNationId = joinNationIds[(idx - 3) % joinNationIds.length]!;
|
||||||
await userGameClient.turns.reserved.setGeneral.mutate({
|
queueRevision = (
|
||||||
generalId: user.generalId,
|
await userGameClient.turns.reserved.setGeneral.mutate({
|
||||||
turnIndex: 0,
|
generalId: user.generalId,
|
||||||
action: 'che_임관',
|
turnIndex: 0,
|
||||||
args: { destNationId },
|
action: 'che_임관',
|
||||||
});
|
args: { destNationId },
|
||||||
|
expectedRevision: queueRevision,
|
||||||
|
})
|
||||||
|
).revision;
|
||||||
await userGameClient.turns.reserved.setGeneral.mutate({
|
await userGameClient.turns.reserved.setGeneral.mutate({
|
||||||
generalId: user.generalId,
|
generalId: user.generalId,
|
||||||
turnIndex: 1,
|
turnIndex: 1,
|
||||||
action: 'che_임관',
|
action: 'che_임관',
|
||||||
args: { destNationId },
|
args: { destNationId },
|
||||||
|
expectedRevision: queueRevision,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,10 +99,11 @@ export INPUT_EVENT_DATABASE_URL=$database_url
|
|||||||
export GENERAL_LIFECYCLE_DATABASE_URL=$database_url
|
export GENERAL_LIFECYCLE_DATABASE_URL=$database_url
|
||||||
export TURN_DAEMON_LEASE_DATABASE_URL=$database_url
|
export TURN_DAEMON_LEASE_DATABASE_URL=$database_url
|
||||||
export TURN_DIFFERENTIAL_DATABASE_URL=$database_url
|
export TURN_DIFFERENTIAL_DATABASE_URL=$database_url
|
||||||
|
export RESERVED_TURN_DATABASE_URL=$database_url
|
||||||
|
|
||||||
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
||||||
|
|
||||||
database_markers='INPUT_EVENT_DATABASE_URL|GENERAL_LIFECYCLE_DATABASE_URL|TURN_DAEMON_LEASE_DATABASE_URL'
|
database_markers='INPUT_EVENT_DATABASE_URL|GENERAL_LIFECYCLE_DATABASE_URL|TURN_DAEMON_LEASE_DATABASE_URL|RESERVED_TURN_DATABASE_URL'
|
||||||
run_marked_tests app/game-api "$database_markers" "PostgreSQL"
|
run_marked_tests app/game-api "$database_markers" "PostgreSQL"
|
||||||
run_marked_tests app/game-engine "$database_markers" "PostgreSQL"
|
run_marked_tests app/game-engine "$database_markers" "PostgreSQL"
|
||||||
run_marked_tests tools/integration-tests \
|
run_marked_tests tools/integration-tests \
|
||||||
|
|||||||
Reference in New Issue
Block a user