feat: send dashboard read-model deltas
This commit is contained in:
@@ -25,6 +25,7 @@ import { dynastyRouter } from './router/dynasty/index.js';
|
|||||||
import { voteRouter } from './router/vote/index.js';
|
import { voteRouter } from './router/vote/index.js';
|
||||||
import { bettingRouter } from './router/betting/index.js';
|
import { bettingRouter } from './router/betting/index.js';
|
||||||
import { archiveRouter } from './router/archive/index.js';
|
import { archiveRouter } from './router/archive/index.js';
|
||||||
|
import { dashboardRouter } from './router/dashboard/index.js';
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: healthRouter,
|
health: healthRouter,
|
||||||
@@ -52,6 +53,7 @@ export const appRouter = router({
|
|||||||
vote: voteRouter,
|
vote: voteRouter,
|
||||||
betting: bettingRouter,
|
betting: bettingRouter,
|
||||||
archive: archiveRouter,
|
archive: archiveRouter,
|
||||||
|
dashboard: dashboardRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ const getBoardActor = async (ctx: Parameters<typeof getMyGeneral>[0]) => {
|
|||||||
return { general, permission };
|
return { general, permission };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBoardAccess = async (ctx: Parameters<typeof getMyGeneral>[0]) => {
|
||||||
|
const { permission } = await getBoardActor(ctx);
|
||||||
|
return {
|
||||||
|
permission,
|
||||||
|
canMeeting: permission >= 0,
|
||||||
|
canSecret: permission >= 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const parseDataUrl = (dataUrl: string): Buffer => {
|
const parseDataUrl = (dataUrl: string): Buffer => {
|
||||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -82,14 +91,7 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const boardRouter = router({
|
export const boardRouter = router({
|
||||||
getAccess: authedProcedure.query(async ({ ctx }) => {
|
getAccess: authedProcedure.query(({ ctx }) => getBoardAccess(ctx)),
|
||||||
const { permission } = await getBoardActor(ctx);
|
|
||||||
return {
|
|
||||||
permission,
|
|
||||||
canMeeting: permission >= 0,
|
|
||||||
canSecret: permission >= 2,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
|
getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
|
||||||
const { general, permission } = await getBoardActor(ctx);
|
const { general, permission } = await getBoardActor(ctx);
|
||||||
assertBoardAccess(permission, input.isSecret);
|
assertBoardAccess(permission, input.isSecret);
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
|
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
|
||||||
|
import { getBoardAccess } from '../board/index.js';
|
||||||
|
import { getGeneralContext } from '../general/index.js';
|
||||||
|
import { getTurnCommandTable } from '../turns/index.js';
|
||||||
|
|
||||||
|
const zRevision = z.string().regex(/^[A-Za-z0-9_-]{22}$/u);
|
||||||
|
|
||||||
|
const zContextBundleInput = z
|
||||||
|
.object({
|
||||||
|
include: z.object({
|
||||||
|
context: z.boolean(),
|
||||||
|
commandTable: z.boolean(),
|
||||||
|
boardAccess: z.boolean(),
|
||||||
|
}),
|
||||||
|
known: z
|
||||||
|
.object({
|
||||||
|
context: zRevision.optional(),
|
||||||
|
commandTable: zRevision.optional(),
|
||||||
|
boardAccess: zRevision.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
forceSnapshot: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine((input) => Object.values(input.include).some(Boolean), {
|
||||||
|
message: 'At least one dashboard context slice must be requested.',
|
||||||
|
path: ['include'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardRouter = router({
|
||||||
|
getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
||||||
|
const viewerId = ctx.auth?.user.id;
|
||||||
|
if (!viewerId) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContext = await getGeneralContext(ctx);
|
||||||
|
const generalId = currentContext?.general.id ?? null;
|
||||||
|
const [commandTable, boardAccess] = await Promise.all([
|
||||||
|
input.include.commandTable && generalId ? getTurnCommandTable(ctx, generalId) : Promise.resolve(undefined),
|
||||||
|
input.include.boardAccess && generalId ? getBoardAccess(ctx) : Promise.resolve(undefined),
|
||||||
|
]);
|
||||||
|
const context = input.include.context ? currentContext : undefined;
|
||||||
|
|
||||||
|
const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([
|
||||||
|
context === undefined
|
||||||
|
? Promise.resolve(undefined)
|
||||||
|
: createReadModelDelta({
|
||||||
|
store: ctx.redis,
|
||||||
|
profile: ctx.profile.name,
|
||||||
|
viewerId,
|
||||||
|
slice: `main-context:${generalId ?? 'none'}`,
|
||||||
|
value: context,
|
||||||
|
knownRevision: input.known?.context,
|
||||||
|
forceSnapshot: input.forceSnapshot,
|
||||||
|
}),
|
||||||
|
commandTable === undefined
|
||||||
|
? Promise.resolve(undefined)
|
||||||
|
: createReadModelDelta({
|
||||||
|
store: ctx.redis,
|
||||||
|
profile: ctx.profile.name,
|
||||||
|
viewerId,
|
||||||
|
slice: `main-command-table:${generalId}`,
|
||||||
|
value: commandTable,
|
||||||
|
knownRevision: input.known?.commandTable,
|
||||||
|
forceSnapshot: input.forceSnapshot,
|
||||||
|
}),
|
||||||
|
boardAccess === undefined
|
||||||
|
? Promise.resolve(undefined)
|
||||||
|
: createReadModelDelta({
|
||||||
|
store: ctx.redis,
|
||||||
|
profile: ctx.profile.name,
|
||||||
|
viewerId,
|
||||||
|
slice: `main-board-access:${generalId}`,
|
||||||
|
value: boardAccess,
|
||||||
|
knownRevision: input.known?.boardAccess,
|
||||||
|
forceSnapshot: input.forceSnapshot,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: contextDelta,
|
||||||
|
commandTable: commandTableDelta,
|
||||||
|
boardAccess: boardAccessDelta,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -173,6 +173,171 @@ const resolvePenalty = (penalty: unknown): Record<string, number> => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||||
|
const userId = ctx.auth?.user.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const general = await ctx.db.general.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
npcState: true,
|
||||||
|
nationId: true,
|
||||||
|
cityId: true,
|
||||||
|
troopId: true,
|
||||||
|
picture: true,
|
||||||
|
imageServer: true,
|
||||||
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
|
officerLevel: true,
|
||||||
|
gold: true,
|
||||||
|
rice: true,
|
||||||
|
crew: true,
|
||||||
|
train: true,
|
||||||
|
atmos: true,
|
||||||
|
injury: true,
|
||||||
|
experience: true,
|
||||||
|
dedication: true,
|
||||||
|
age: true,
|
||||||
|
turnTime: true,
|
||||||
|
crewTypeId: true,
|
||||||
|
personalCode: true,
|
||||||
|
specialCode: true,
|
||||||
|
special2Code: true,
|
||||||
|
weaponCode: true,
|
||||||
|
horseCode: true,
|
||||||
|
bookCode: true,
|
||||||
|
itemCode: true,
|
||||||
|
meta: true,
|
||||||
|
penalty: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!general) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [city, nation, worldState] = await Promise.all([
|
||||||
|
general.cityId > 0
|
||||||
|
? ctx.db.city.findUnique({
|
||||||
|
where: { id: general.cityId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
level: true,
|
||||||
|
nationId: true,
|
||||||
|
population: true,
|
||||||
|
populationMax: true,
|
||||||
|
agriculture: true,
|
||||||
|
agricultureMax: true,
|
||||||
|
commerce: true,
|
||||||
|
commerceMax: true,
|
||||||
|
security: true,
|
||||||
|
securityMax: true,
|
||||||
|
trust: true,
|
||||||
|
trade: true,
|
||||||
|
defence: true,
|
||||||
|
defenceMax: true,
|
||||||
|
wall: true,
|
||||||
|
wallMax: true,
|
||||||
|
region: true,
|
||||||
|
supplyState: true,
|
||||||
|
frontState: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
general.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({
|
||||||
|
where: { id: general.nationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
level: true,
|
||||||
|
gold: true,
|
||||||
|
rice: true,
|
||||||
|
tech: true,
|
||||||
|
typeCode: true,
|
||||||
|
capitalCityId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const metaRecord = asRecord(general.meta);
|
||||||
|
const worldConfig = asRecord(worldState?.config);
|
||||||
|
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||||
|
const settings = resolveUserSettings(metaRecord);
|
||||||
|
const penalties = resolvePenalty(general.penalty);
|
||||||
|
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
npcState: general.npcState,
|
||||||
|
nationId: general.nationId,
|
||||||
|
cityId: general.cityId,
|
||||||
|
troopId: general.troopId,
|
||||||
|
picture: general.picture,
|
||||||
|
imageServer: general.imageServer,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
stats: {
|
||||||
|
leadership: general.leadership,
|
||||||
|
strength: general.strength,
|
||||||
|
intelligence: general.intel,
|
||||||
|
},
|
||||||
|
gold: general.gold,
|
||||||
|
rice: general.rice,
|
||||||
|
crew: general.crew,
|
||||||
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
|
injury: general.injury,
|
||||||
|
experience: general.experience,
|
||||||
|
dedication: general.dedication,
|
||||||
|
age: general.age,
|
||||||
|
turnTime: general.turnTime.toISOString(),
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
traits: {
|
||||||
|
personal: general.personalCode,
|
||||||
|
specialWar: general.specialCode,
|
||||||
|
specialDomestic: general.special2Code,
|
||||||
|
},
|
||||||
|
progression: {
|
||||||
|
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||||
|
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
||||||
|
statExperience: {
|
||||||
|
leadership: readNumber(metaRecord.leadership_exp, 0),
|
||||||
|
strength: readNumber(metaRecord.strength_exp, 0),
|
||||||
|
intelligence: readNumber(metaRecord.intel_exp, 0),
|
||||||
|
},
|
||||||
|
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
||||||
|
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
horse: normalizeItemCode(general.horseCode),
|
||||||
|
weapon: normalizeItemCode(general.weaponCode),
|
||||||
|
book: normalizeItemCode(general.bookCode),
|
||||||
|
item: normalizeItemCode(general.itemCode),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
|
||||||
|
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
||||||
|
iconChangeAvailableAt:
|
||||||
|
typeof metaRecord.generalIconChangedAt === 'string'
|
||||||
|
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
: null,
|
||||||
|
city,
|
||||||
|
nation,
|
||||||
|
settings,
|
||||||
|
penalties,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const generalRouter = router({
|
export const generalRouter = router({
|
||||||
adjustIcon: engineAuthedProcedure
|
adjustIcon: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -201,170 +366,7 @@ export const generalRouter = router({
|
|||||||
input?.clientRequestId ?? ctx.requestId
|
input?.clientRequestId ?? ctx.requestId
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
me: authedProcedure.query(async ({ ctx }) => {
|
me: authedProcedure.query(({ ctx }) => getGeneralContext(ctx)),
|
||||||
const userId = ctx.auth?.user.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const general = await ctx.db.general.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
npcState: true,
|
|
||||||
nationId: true,
|
|
||||||
cityId: true,
|
|
||||||
troopId: true,
|
|
||||||
picture: true,
|
|
||||||
imageServer: true,
|
|
||||||
leadership: true,
|
|
||||||
strength: true,
|
|
||||||
intel: true,
|
|
||||||
officerLevel: true,
|
|
||||||
gold: true,
|
|
||||||
rice: true,
|
|
||||||
crew: true,
|
|
||||||
train: true,
|
|
||||||
atmos: true,
|
|
||||||
injury: true,
|
|
||||||
experience: true,
|
|
||||||
dedication: true,
|
|
||||||
age: true,
|
|
||||||
turnTime: true,
|
|
||||||
crewTypeId: true,
|
|
||||||
personalCode: true,
|
|
||||||
specialCode: true,
|
|
||||||
special2Code: true,
|
|
||||||
weaponCode: true,
|
|
||||||
horseCode: true,
|
|
||||||
bookCode: true,
|
|
||||||
itemCode: true,
|
|
||||||
meta: true,
|
|
||||||
penalty: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!general) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [city, nation, worldState] = await Promise.all([
|
|
||||||
general.cityId > 0
|
|
||||||
? ctx.db.city.findUnique({
|
|
||||||
where: { id: general.cityId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
level: true,
|
|
||||||
nationId: true,
|
|
||||||
population: true,
|
|
||||||
populationMax: true,
|
|
||||||
agriculture: true,
|
|
||||||
agricultureMax: true,
|
|
||||||
commerce: true,
|
|
||||||
commerceMax: true,
|
|
||||||
security: true,
|
|
||||||
securityMax: true,
|
|
||||||
trust: true,
|
|
||||||
trade: true,
|
|
||||||
defence: true,
|
|
||||||
defenceMax: true,
|
|
||||||
wall: true,
|
|
||||||
wallMax: true,
|
|
||||||
region: true,
|
|
||||||
supplyState: true,
|
|
||||||
frontState: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
general.nationId > 0
|
|
||||||
? ctx.db.nation.findUnique({
|
|
||||||
where: { id: general.nationId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
color: true,
|
|
||||||
level: true,
|
|
||||||
gold: true,
|
|
||||||
rice: true,
|
|
||||||
tech: true,
|
|
||||||
typeCode: true,
|
|
||||||
capitalCityId: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const metaRecord = asRecord(general.meta);
|
|
||||||
const worldConfig = asRecord(worldState?.config);
|
|
||||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
|
||||||
const settings = resolveUserSettings(metaRecord);
|
|
||||||
const penalties = resolvePenalty(general.penalty);
|
|
||||||
|
|
||||||
return {
|
|
||||||
general: {
|
|
||||||
id: general.id,
|
|
||||||
name: general.name,
|
|
||||||
npcState: general.npcState,
|
|
||||||
nationId: general.nationId,
|
|
||||||
cityId: general.cityId,
|
|
||||||
troopId: general.troopId,
|
|
||||||
picture: general.picture,
|
|
||||||
imageServer: general.imageServer,
|
|
||||||
officerLevel: general.officerLevel,
|
|
||||||
stats: {
|
|
||||||
leadership: general.leadership,
|
|
||||||
strength: general.strength,
|
|
||||||
intelligence: general.intel,
|
|
||||||
},
|
|
||||||
gold: general.gold,
|
|
||||||
rice: general.rice,
|
|
||||||
crew: general.crew,
|
|
||||||
train: general.train,
|
|
||||||
atmos: general.atmos,
|
|
||||||
injury: general.injury,
|
|
||||||
experience: general.experience,
|
|
||||||
dedication: general.dedication,
|
|
||||||
age: general.age,
|
|
||||||
turnTime: general.turnTime.toISOString(),
|
|
||||||
crewTypeId: general.crewTypeId,
|
|
||||||
traits: {
|
|
||||||
personal: general.personalCode,
|
|
||||||
specialWar: general.specialCode,
|
|
||||||
specialDomestic: general.special2Code,
|
|
||||||
},
|
|
||||||
progression: {
|
|
||||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
|
||||||
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
|
||||||
statExperience: {
|
|
||||||
leadership: readNumber(metaRecord.leadership_exp, 0),
|
|
||||||
strength: readNumber(metaRecord.strength_exp, 0),
|
|
||||||
intelligence: readNumber(metaRecord.intel_exp, 0),
|
|
||||||
},
|
|
||||||
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
|
||||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
horse: normalizeItemCode(general.horseCode),
|
|
||||||
weapon: normalizeItemCode(general.weaponCode),
|
|
||||||
book: normalizeItemCode(general.bookCode),
|
|
||||||
item: normalizeItemCode(general.itemCode),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
|
|
||||||
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
|
||||||
iconChangeAvailableAt:
|
|
||||||
typeof metaRecord.generalIconChangedAt === 'string'
|
|
||||||
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
|
||||||
: null,
|
|
||||||
city,
|
|
||||||
nation,
|
|
||||||
settings,
|
|
||||||
penalties,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
|
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -42,17 +42,14 @@ const zPushAmount = z
|
|||||||
const zRepeatAmount = z.number().int().min(1).max(12);
|
const zRepeatAmount = z.number().int().min(1).max(12);
|
||||||
|
|
||||||
const buildTurnListSchema = (minimum: number, maximum: number) =>
|
const buildTurnListSchema = (minimum: number, maximum: number) =>
|
||||||
z
|
z.array(z.number().int().min(minimum).max(maximum)).min(1);
|
||||||
.array(z.number().int().min(minimum).max(maximum))
|
|
||||||
.min(1);
|
|
||||||
|
|
||||||
const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
||||||
z
|
z.object({
|
||||||
.object({
|
turnList,
|
||||||
turnList,
|
action: z.string().min(1),
|
||||||
action: z.string().min(1),
|
args: z.unknown().optional(),
|
||||||
args: z.unknown().optional(),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
||||||
try {
|
try {
|
||||||
@@ -112,9 +109,107 @@ const assertReservedTurnPermission = async (
|
|||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message:
|
message:
|
||||||
result.kind === 'deny'
|
result.kind === 'deny' ? `예약 불가능한 커맨드 :${result.reason}` : '예약 권한을 확인할 정보가 부족합니다.',
|
||||||
? `예약 불가능한 커맨드 :${result.reason}`
|
});
|
||||||
: '예약 권한을 확인할 정보가 부족합니다.',
|
};
|
||||||
|
|
||||||
|
export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number) => {
|
||||||
|
const [worldState, general] = await Promise.all([ctx.db.worldState.findFirst(), getOwnedGeneral(ctx, generalId)]);
|
||||||
|
|
||||||
|
if (!worldState) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'World state is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] =
|
||||||
|
await Promise.all([
|
||||||
|
general.cityId > 0
|
||||||
|
? ctx.db.city.findUnique({
|
||||||
|
where: { id: general.cityId },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
general.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({
|
||||||
|
where: { id: general.nationId },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
general.nationId > 0
|
||||||
|
? ctx.db.general.findMany({
|
||||||
|
where: { nationId: general.nationId },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
ctx.db.city.findMany({
|
||||||
|
select: { id: true, name: true, nationId: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
ctx.db.nation.findMany({
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
ctx.db.general.findMany({
|
||||||
|
where: { npcState: { lt: 2 } },
|
||||||
|
select: { id: true, name: true, nationId: true, cityId: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
buildBattleSimEnvironment(worldState, ctx.profile.id),
|
||||||
|
loadBattleSimTraitOptions(),
|
||||||
|
loadItemModules([...ITEM_KEYS]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||||
|
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
||||||
|
const items: TurnCommandInputOptions['items'] = {
|
||||||
|
horse: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
book: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
item: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
};
|
||||||
|
for (const item of itemModules) {
|
||||||
|
if (item.buyable) {
|
||||||
|
items[item.slot].push({ value: item.key, label: item.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
|
cities: cities.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
||||||
|
})),
|
||||||
|
nations: nations.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: entry.name,
|
||||||
|
color: entry.color,
|
||||||
|
})),
|
||||||
|
generals: generals.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
|
||||||
|
cityById.get(entry.cityId)?.name ?? '재야'
|
||||||
|
})`,
|
||||||
|
})),
|
||||||
|
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||||
|
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||||
|
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||||
|
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
||||||
|
value: Number(value),
|
||||||
|
label,
|
||||||
|
})),
|
||||||
|
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
||||||
|
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
||||||
|
value: index,
|
||||||
|
label: `색상 ${index + 1}`,
|
||||||
|
color,
|
||||||
|
})),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
|
||||||
|
return buildTurnCommandTable({
|
||||||
|
worldState,
|
||||||
|
general,
|
||||||
|
city,
|
||||||
|
nation,
|
||||||
|
nationGenerals,
|
||||||
|
inputOptions,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,108 +220,7 @@ export const turnsRouter = router({
|
|||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
|
||||||
const [worldState, general] = await Promise.all([
|
|
||||||
ctx.db.worldState.findFirst(),
|
|
||||||
getOwnedGeneral(ctx, input.generalId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!worldState) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'World state is not initialized.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] =
|
|
||||||
await Promise.all([
|
|
||||||
general.cityId > 0
|
|
||||||
? ctx.db.city.findUnique({
|
|
||||||
where: { id: general.cityId },
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
general.nationId > 0
|
|
||||||
? ctx.db.nation.findUnique({
|
|
||||||
where: { id: general.nationId },
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
general.nationId > 0
|
|
||||||
? ctx.db.general.findMany({
|
|
||||||
where: { nationId: general.nationId },
|
|
||||||
})
|
|
||||||
: Promise.resolve(null),
|
|
||||||
ctx.db.city.findMany({
|
|
||||||
select: { id: true, name: true, nationId: true },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
}),
|
|
||||||
ctx.db.nation.findMany({
|
|
||||||
select: { id: true, name: true, color: true },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
}),
|
|
||||||
ctx.db.general.findMany({
|
|
||||||
where: { npcState: { lt: 2 } },
|
|
||||||
select: { id: true, name: true, nationId: true, cityId: true },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
}),
|
|
||||||
buildBattleSimEnvironment(worldState, ctx.profile.id),
|
|
||||||
loadBattleSimTraitOptions(),
|
|
||||||
loadItemModules([...ITEM_KEYS]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
|
||||||
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
|
||||||
const items: TurnCommandInputOptions['items'] = {
|
|
||||||
horse: [{ value: 'None', label: '판매/해제' }],
|
|
||||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
|
||||||
book: [{ value: 'None', label: '판매/해제' }],
|
|
||||||
item: [{ value: 'None', label: '판매/해제' }],
|
|
||||||
};
|
|
||||||
for (const item of itemModules) {
|
|
||||||
if (item.buyable) {
|
|
||||||
items[item.slot].push({ value: item.key, label: item.name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const inputOptions: TurnCommandInputOptions = {
|
|
||||||
cities: cities.map((entry) => ({
|
|
||||||
value: entry.id,
|
|
||||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
|
||||||
})),
|
|
||||||
nations: nations.map((entry) => ({
|
|
||||||
value: entry.id,
|
|
||||||
label: entry.name,
|
|
||||||
color: entry.color,
|
|
||||||
})),
|
|
||||||
generals: generals.map((entry) => ({
|
|
||||||
value: entry.id,
|
|
||||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
|
|
||||||
cityById.get(entry.cityId)?.name ?? '재야'
|
|
||||||
})`,
|
|
||||||
})),
|
|
||||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
|
||||||
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
|
||||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
|
||||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
|
||||||
value: Number(value),
|
|
||||||
label,
|
|
||||||
})),
|
|
||||||
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
|
||||||
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
|
||||||
value: index,
|
|
||||||
label: `색상 ${index + 1}`,
|
|
||||||
color,
|
|
||||||
})),
|
|
||||||
items,
|
|
||||||
};
|
|
||||||
|
|
||||||
return buildTurnCommandTable({
|
|
||||||
worldState,
|
|
||||||
general,
|
|
||||||
city,
|
|
||||||
nation,
|
|
||||||
nationGenerals,
|
|
||||||
inputOptions,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
reserved: router({
|
reserved: router({
|
||||||
getGeneral: authedProcedure
|
getGeneral: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -322,13 +316,7 @@ export const turnsRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
entries: z
|
entries: z.array(buildBulkEntrySchema(buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1))).min(1),
|
||||||
.array(
|
|
||||||
buildBulkEntrySchema(
|
|
||||||
buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -343,13 +331,7 @@ export const turnsRouter = router({
|
|||||||
);
|
);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
await assertReservedTurnPermission(
|
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
||||||
worldState,
|
|
||||||
general,
|
|
||||||
'general',
|
|
||||||
update.action,
|
|
||||||
update.args
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
||||||
@@ -472,13 +454,7 @@ export const turnsRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
entries: z
|
entries: z.array(buildBulkEntrySchema(buildTurnListSchema(0, MAX_NATION_TURNS - 1))).min(1),
|
||||||
.array(
|
|
||||||
buildBulkEntrySchema(
|
|
||||||
buildTurnListSchema(0, MAX_NATION_TURNS - 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -505,22 +481,10 @@ export const turnsRouter = router({
|
|||||||
);
|
);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
await assertReservedTurnPermission(
|
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||||
worldState,
|
|
||||||
general,
|
|
||||||
'nation',
|
|
||||||
update.action,
|
|
||||||
update.args
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setNationTurns(
|
setNationTurns(ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision)
|
||||||
ctx.db,
|
|
||||||
general.nationId,
|
|
||||||
general.officerLevel,
|
|
||||||
updates,
|
|
||||||
input.expectedRevision
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createJsonPatch, type ReadModelDelta } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
const CACHE_TTL_SECONDS = 15 * 60;
|
||||||
|
const REVISION_LENGTH = 22;
|
||||||
|
|
||||||
|
export interface ReadModelDeltaCacheStore {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
set(key: string, value: string, options: { EX: number }): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadModelDeltaRequest<T> {
|
||||||
|
store: ReadModelDeltaCacheStore;
|
||||||
|
profile: string;
|
||||||
|
viewerId: string;
|
||||||
|
slice: string;
|
||||||
|
value: T;
|
||||||
|
knownRevision?: string;
|
||||||
|
forceSnapshot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalize = (value: unknown): unknown => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(canonicalize);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, item]) => [key, canonicalize(item)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const digest = (value: string): string =>
|
||||||
|
createHash('sha256').update(value).digest('base64url').slice(0, REVISION_LENGTH);
|
||||||
|
|
||||||
|
const buildScope = (profile: string, viewerId: string, slice: string): string =>
|
||||||
|
digest(`${profile}\0${viewerId}\0${slice}`);
|
||||||
|
|
||||||
|
export const buildReadModelDeltaCacheKey = (
|
||||||
|
profile: string,
|
||||||
|
viewerId: string,
|
||||||
|
slice: string,
|
||||||
|
revision: string
|
||||||
|
): string => `sammo:${profile}:private-read-model:${buildScope(profile, viewerId, slice)}:${revision}`;
|
||||||
|
|
||||||
|
const canPatch = (value: unknown): value is Record<string, unknown> | unknown[] =>
|
||||||
|
value !== null && typeof value === 'object';
|
||||||
|
|
||||||
|
const storeSnapshot = async (store: ReadModelDeltaCacheStore, key: string, serialized: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await store.set(key, serialized, { EX: CACHE_TTL_SECONDS });
|
||||||
|
} catch {
|
||||||
|
// Redis is a best-effort optimization. The caller still receives a full snapshot.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createReadModelDelta = async <T>(request: ReadModelDeltaRequest<T>): Promise<ReadModelDelta<T>> => {
|
||||||
|
const canonicalValue = canonicalize(request.value) as T;
|
||||||
|
const serialized = JSON.stringify(canonicalValue);
|
||||||
|
const revision = digest(serialized);
|
||||||
|
const currentKey = buildReadModelDeltaCacheKey(request.profile, request.viewerId, request.slice, revision);
|
||||||
|
|
||||||
|
if (!request.forceSnapshot && request.knownRevision === revision) {
|
||||||
|
return { kind: 'unchanged', revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.forceSnapshot && request.knownRevision) {
|
||||||
|
const baselineKey = buildReadModelDeltaCacheKey(
|
||||||
|
request.profile,
|
||||||
|
request.viewerId,
|
||||||
|
request.slice,
|
||||||
|
request.knownRevision
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const baselineSerialized = await request.store.get(baselineKey);
|
||||||
|
if (baselineSerialized) {
|
||||||
|
const baseline = JSON.parse(baselineSerialized) as unknown;
|
||||||
|
if (canPatch(baseline) && canPatch(canonicalValue)) {
|
||||||
|
const operations = createJsonPatch(baseline, canonicalValue);
|
||||||
|
const patch = {
|
||||||
|
kind: 'patch' as const,
|
||||||
|
baseRevision: request.knownRevision,
|
||||||
|
revision,
|
||||||
|
operations,
|
||||||
|
};
|
||||||
|
const snapshot = { kind: 'snapshot' as const, revision, data: canonicalValue };
|
||||||
|
await storeSnapshot(request.store, currentKey, serialized);
|
||||||
|
return Buffer.byteLength(JSON.stringify(patch)) < Buffer.byteLength(JSON.stringify(snapshot))
|
||||||
|
? patch
|
||||||
|
: snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt/missing cache data and Redis failures recover with a full snapshot.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeSnapshot(request.store, currentKey, serialized);
|
||||||
|
return {
|
||||||
|
kind: 'snapshot',
|
||||||
|
revision,
|
||||||
|
data: canonicalValue,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { applyReadModelDelta } from '@sammo-ts/common';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../src/context.js';
|
||||||
|
import { dashboardRouter } from '../src/router/dashboard/index.js';
|
||||||
|
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: 'hwe:default',
|
||||||
|
issuedAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-08-12T00:00:00.000Z',
|
||||||
|
sessionId: 'dashboard-delta-session',
|
||||||
|
user: {
|
||||||
|
id: 'viewer-1',
|
||||||
|
username: 'dashboard-viewer',
|
||||||
|
displayName: '대시보드 사용자',
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = (authenticated: boolean) => {
|
||||||
|
let generalName = '초기 장수';
|
||||||
|
const redisValues = new Map<string, string>();
|
||||||
|
const context = {
|
||||||
|
auth: authenticated ? auth : null,
|
||||||
|
profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' },
|
||||||
|
redis: {
|
||||||
|
get: async (key: string) => redisValues.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
redisValues.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
db: {
|
||||||
|
general: {
|
||||||
|
findFirst: async () => ({
|
||||||
|
id: 7,
|
||||||
|
name: generalName,
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 0,
|
||||||
|
cityId: 0,
|
||||||
|
troopId: 0,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 60,
|
||||||
|
intel: 50,
|
||||||
|
officerLevel: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 2_000,
|
||||||
|
crew: 300,
|
||||||
|
train: 80,
|
||||||
|
atmos: 90,
|
||||||
|
injury: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 200,
|
||||||
|
age: 20,
|
||||||
|
turnTime: new Date('2026-08-11T00:00:00.000Z'),
|
||||||
|
crewTypeId: 0,
|
||||||
|
personalCode: 'None',
|
||||||
|
specialCode: 'None',
|
||||||
|
special2Code: 'None',
|
||||||
|
weaponCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
meta: {},
|
||||||
|
penalty: {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
city: { findUnique: async () => null },
|
||||||
|
nation: { findUnique: async () => null },
|
||||||
|
worldState: { findFirst: async () => ({ config: { const: {} } }) },
|
||||||
|
},
|
||||||
|
} as unknown as GameApiContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
rename: (name: string) => {
|
||||||
|
generalName = name;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextOnly = {
|
||||||
|
include: { context: true, commandTable: false, boardAccess: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('dashboardRouter.getContextBundleDelta', () => {
|
||||||
|
it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => {
|
||||||
|
const fixture = buildContext(true);
|
||||||
|
const caller = dashboardRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
|
||||||
|
expect(initial.context?.kind).toBe('snapshot');
|
||||||
|
if (!initial.context || initial.context.kind !== 'snapshot') throw new Error('initial snapshot missing');
|
||||||
|
if (!initial.context.data) throw new Error('initial general context missing');
|
||||||
|
const initialData = initial.context.data;
|
||||||
|
const initialRevision = initial.context.revision;
|
||||||
|
|
||||||
|
const unchanged = await caller.getContextBundleDelta({
|
||||||
|
...contextOnly,
|
||||||
|
known: { context: initialRevision },
|
||||||
|
});
|
||||||
|
expect(unchanged.context).toEqual({ kind: 'unchanged', revision: initialRevision });
|
||||||
|
|
||||||
|
fixture.rename('갱신된 장수');
|
||||||
|
const changed = await caller.getContextBundleDelta({
|
||||||
|
...contextOnly,
|
||||||
|
known: { context: initialRevision },
|
||||||
|
});
|
||||||
|
expect(changed.context?.kind).toBe('patch');
|
||||||
|
if (!changed.context) throw new Error('context delta missing');
|
||||||
|
const applied = applyReadModelDelta(initialData, initialRevision, changed.context).data;
|
||||||
|
if (!applied) throw new Error('patched general context missing');
|
||||||
|
expect(applied.general.name).toBe('갱신된 장수');
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anonymous requests before reading dashboard data', async () => {
|
||||||
|
const fixture = buildContext(false);
|
||||||
|
await expect(
|
||||||
|
dashboardRouter.createCaller(fixture.context).getContextBundleDelta(contextOnly)
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty bundle request', async () => {
|
||||||
|
const fixture = buildContext(true);
|
||||||
|
await expect(
|
||||||
|
dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
|
||||||
|
include: { context: false, commandTable: false, boardAccess: false },
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { applyReadModelDelta } from '@sammo-ts/common';
|
||||||
|
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildReadModelDeltaCacheKey, createReadModelDelta } from '../src/services/readModelDeltaCache.js';
|
||||||
|
|
||||||
|
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
|
||||||
|
|
||||||
|
liveDescribe('read-model delta cache with live Redis', () => {
|
||||||
|
it('stores a private expiring baseline and serves an applicable patch', async () => {
|
||||||
|
const connector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
await connector.connect();
|
||||||
|
|
||||||
|
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? randomUUID();
|
||||||
|
const profile = `hwe:dashboard-delta-${runId}`;
|
||||||
|
const viewerId = `viewer-${randomUUID()}`;
|
||||||
|
const slice = 'main-command-table:7';
|
||||||
|
const initialValue = {
|
||||||
|
general: Array.from({ length: 48 }, (_, index) => ({
|
||||||
|
key: `command-${index}`,
|
||||||
|
name: `명령 ${index}`,
|
||||||
|
possible: true,
|
||||||
|
inputFields: [{ key: 'amount', kind: 'number', required: true }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const keys: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initial = await createReadModelDelta({
|
||||||
|
store: connector.client,
|
||||||
|
profile,
|
||||||
|
viewerId,
|
||||||
|
slice,
|
||||||
|
value: initialValue,
|
||||||
|
forceSnapshot: true,
|
||||||
|
});
|
||||||
|
const initialKey = buildReadModelDeltaCacheKey(profile, viewerId, slice, initial.revision);
|
||||||
|
keys.push(initialKey);
|
||||||
|
expect(await connector.client.get(initialKey)).not.toBeNull();
|
||||||
|
expect(await connector.client.ttl(initialKey)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const nextValue = structuredClone(initialValue);
|
||||||
|
const first = nextValue.general[0];
|
||||||
|
if (!first) throw new Error('command fixture is empty');
|
||||||
|
first.possible = false;
|
||||||
|
const changed = await createReadModelDelta({
|
||||||
|
store: connector.client,
|
||||||
|
profile,
|
||||||
|
viewerId,
|
||||||
|
slice,
|
||||||
|
value: nextValue,
|
||||||
|
knownRevision: initial.revision,
|
||||||
|
});
|
||||||
|
expect(changed.kind).toBe('patch');
|
||||||
|
expect(applyReadModelDelta(initialValue, initial.revision, changed).data).toEqual(nextValue);
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000);
|
||||||
|
|
||||||
|
const changedKey = buildReadModelDeltaCacheKey(profile, viewerId, slice, changed.revision);
|
||||||
|
keys.push(changedKey);
|
||||||
|
expect(await connector.client.get(changedKey)).not.toBeNull();
|
||||||
|
} finally {
|
||||||
|
if (keys.length > 0) await connector.client.del(keys);
|
||||||
|
await connector.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { applyReadModelDelta } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildReadModelDeltaCacheKey,
|
||||||
|
createReadModelDelta,
|
||||||
|
type ReadModelDeltaCacheStore,
|
||||||
|
} from '../src/services/readModelDeltaCache.js';
|
||||||
|
|
||||||
|
class MemoryStore implements ReadModelDeltaCacheStore {
|
||||||
|
readonly values = new Map<string, string>();
|
||||||
|
setCalls = 0;
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
return this.values.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string): Promise<string> {
|
||||||
|
this.setCalls += 1;
|
||||||
|
this.values.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeCommandTable = () => ({
|
||||||
|
general: Array.from({ length: 48 }, (_, index) => ({
|
||||||
|
key: `command-${index}`,
|
||||||
|
name: `명령 ${index}`,
|
||||||
|
reqArg: index % 2 === 0,
|
||||||
|
possible: true,
|
||||||
|
status: 'available',
|
||||||
|
inputFields: [{ key: 'amount', label: '수량', type: 'number' }],
|
||||||
|
})),
|
||||||
|
nation: [],
|
||||||
|
inputOptions: {
|
||||||
|
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createReadModelDelta', () => {
|
||||||
|
it('reduces unchanged and one-field updates below one kilobyte', async () => {
|
||||||
|
const store = new MemoryStore();
|
||||||
|
const initialValue = largeCommandTable();
|
||||||
|
const initial = await createReadModelDelta({
|
||||||
|
store,
|
||||||
|
profile: 'hwe:default',
|
||||||
|
viewerId: 'user-1',
|
||||||
|
slice: 'command-table',
|
||||||
|
value: initialValue,
|
||||||
|
forceSnapshot: true,
|
||||||
|
});
|
||||||
|
expect(initial.kind).toBe('snapshot');
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(initial))).toBeGreaterThan(5_000);
|
||||||
|
|
||||||
|
const unchanged = await createReadModelDelta({
|
||||||
|
store,
|
||||||
|
profile: 'hwe:default',
|
||||||
|
viewerId: 'user-1',
|
||||||
|
slice: 'command-table',
|
||||||
|
value: initialValue,
|
||||||
|
knownRevision: initial.revision,
|
||||||
|
});
|
||||||
|
expect(unchanged.kind).toBe('unchanged');
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(unchanged))).toBeLessThan(1_000);
|
||||||
|
expect(store.setCalls).toBe(1);
|
||||||
|
|
||||||
|
const nextValue = structuredClone(initialValue);
|
||||||
|
const firstCommand = nextValue.general[0];
|
||||||
|
if (!firstCommand) throw new Error('command fixture is empty');
|
||||||
|
firstCommand.possible = false;
|
||||||
|
firstCommand.status = 'blocked';
|
||||||
|
const changed = await createReadModelDelta({
|
||||||
|
store,
|
||||||
|
profile: 'hwe:default',
|
||||||
|
viewerId: 'user-1',
|
||||||
|
slice: 'command-table',
|
||||||
|
value: nextValue,
|
||||||
|
knownRevision: initial.revision,
|
||||||
|
});
|
||||||
|
expect(changed.kind).toBe('patch');
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000);
|
||||||
|
expect(applyReadModelDelta(initialValue, initial.revision, changed).data).toEqual(nextValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps private baselines in viewer-scoped keys', () => {
|
||||||
|
expect(buildReadModelDeltaCacheKey('hwe:default', 'user-1', 'context', 'revision')).not.toBe(
|
||||||
|
buildReadModelDeltaCacheKey('hwe:default', 'user-2', 'context', 'revision')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a snapshot when Redis is unavailable', async () => {
|
||||||
|
const store: ReadModelDeltaCacheStore = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error('redis unavailable');
|
||||||
|
},
|
||||||
|
set: async () => {
|
||||||
|
throw new Error('redis unavailable');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const delta = await createReadModelDelta({
|
||||||
|
store,
|
||||||
|
profile: 'hwe:default',
|
||||||
|
viewerId: 'user-1',
|
||||||
|
slice: 'context',
|
||||||
|
value: { general: { id: 1 } },
|
||||||
|
knownRevision: 'old-revision',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(delta).toMatchObject({ kind: 'snapshot', data: { general: { id: 1 } } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { expect, test, type Page, type Route } from '@playwright/test';
|
|||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
||||||
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
||||||
|
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||||
const operationNames = (route: Route) =>
|
const operationNames = (route: Route) =>
|
||||||
@@ -20,6 +21,95 @@ type NavigationFixture = {
|
|||||||
operations: string[];
|
operations: string[];
|
||||||
generalName?: string;
|
generalName?: string;
|
||||||
refreshDelayMs?: number;
|
refreshDelayMs?: number;
|
||||||
|
largeCommandTable?: boolean;
|
||||||
|
dashboardResponses?: Array<{
|
||||||
|
bytes: number;
|
||||||
|
contextKind: string | null;
|
||||||
|
commandTableKind: string | null;
|
||||||
|
boardAccessKind: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardBundleInput = {
|
||||||
|
include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean };
|
||||||
|
known?: { context?: string; commandTable?: string; boardAccess?: string };
|
||||||
|
forceSnapshot?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const operationInput = (route: Route, index: number): DashboardBundleInput => {
|
||||||
|
const input = new URL(route.request().url()).searchParams.get('input');
|
||||||
|
if (!input) return {};
|
||||||
|
const parsed = JSON.parse(input) as Record<string, unknown>;
|
||||||
|
const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput };
|
||||||
|
return entry.json ?? (entry as DashboardBundleInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandTableFixture = (large: boolean) => ({
|
||||||
|
general: large
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
category: '일반',
|
||||||
|
values: Array.from({ length: 48 }, (_, index) => ({
|
||||||
|
key: `command-${index}`,
|
||||||
|
name: `명령 ${index}`,
|
||||||
|
reqArg: index % 2 === 0,
|
||||||
|
possible: true,
|
||||||
|
status: 'available',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
label: '수량',
|
||||||
|
kind: 'number',
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
nation: [],
|
||||||
|
inputOptions: {
|
||||||
|
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
||||||
|
nations: [],
|
||||||
|
generals: [],
|
||||||
|
crewTypes: [],
|
||||||
|
armTypes: [],
|
||||||
|
nationTypes: [],
|
||||||
|
colors: [],
|
||||||
|
items: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const CONTEXT_INITIAL_REVISION = 'AAAAAAAAAAAAAAAAAAAAAA';
|
||||||
|
const COMMAND_TABLE_REVISION = 'CCCCCCCCCCCCCCCCCCCCCC';
|
||||||
|
const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD';
|
||||||
|
|
||||||
|
const contextRevision = (state: NavigationFixture) => {
|
||||||
|
const name = state.generalName ?? '메뉴검증장수';
|
||||||
|
if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION;
|
||||||
|
if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE';
|
||||||
|
if (name === '탭공유갱신장수') return 'FFFFFFFFFFFFFFFFFFFFFF';
|
||||||
|
if (name === '리더만갱신장수') return 'GGGGGGGGGGGGGGGGGGGGGG';
|
||||||
|
return 'HHHHHHHHHHHHHHHHHHHHHH';
|
||||||
|
};
|
||||||
|
|
||||||
|
const deltaSlice = <T>(value: T, revision: string, known: string | undefined, forceSnapshot: boolean) => {
|
||||||
|
if (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value };
|
||||||
|
if (known === revision) return { kind: 'unchanged' as const, revision };
|
||||||
|
return {
|
||||||
|
kind: 'patch' as const,
|
||||||
|
baseRevision: known,
|
||||||
|
revision,
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
op: 'replace' as const,
|
||||||
|
path: '/general/name',
|
||||||
|
value: (value as ReturnType<typeof generalContext>).general.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyMessages = (permission: number) => ({
|
const emptyMessages = (permission: number) => ({
|
||||||
@@ -134,14 +224,51 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||||
const operations = operationNames(route);
|
const operations = operationNames(route);
|
||||||
state.operations.push(...operations);
|
state.operations.push(...operations);
|
||||||
if (operations.includes('general.me') && state.generalMeCalls > 0 && state.refreshDelayMs) {
|
if (
|
||||||
|
operations.some((operation) => ['general.me', 'dashboard.getContextBundleDelta'].includes(operation)) &&
|
||||||
|
state.generalMeCalls > 0 &&
|
||||||
|
state.refreshDelayMs
|
||||||
|
) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, state.refreshDelayMs));
|
await new Promise((resolve) => setTimeout(resolve, state.refreshDelayMs));
|
||||||
}
|
}
|
||||||
const results = operations.map((operation) => {
|
const results = operations.map((operation, index) => {
|
||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
if (operation === 'lobby.info') {
|
if (operation === 'lobby.info') {
|
||||||
return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, year: 185, month: 1, turnTerm: 10 });
|
return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, year: 185, month: 1, turnTerm: 10 });
|
||||||
}
|
}
|
||||||
|
if (operation === 'dashboard.getContextBundleDelta') {
|
||||||
|
state.generalMeCalls += 1;
|
||||||
|
const input = operationInput(route, index);
|
||||||
|
const include = input.include ?? {};
|
||||||
|
const forceSnapshot = input.forceSnapshot === true;
|
||||||
|
const revision = contextRevision(state);
|
||||||
|
const context = include.context
|
||||||
|
? deltaSlice(generalContext(state), revision, input.known?.context, forceSnapshot)
|
||||||
|
: undefined;
|
||||||
|
const commandTable = include.commandTable
|
||||||
|
? forceSnapshot || !input.known?.commandTable
|
||||||
|
? {
|
||||||
|
kind: 'snapshot' as const,
|
||||||
|
revision: COMMAND_TABLE_REVISION,
|
||||||
|
data: commandTableFixture(state.largeCommandTable === true),
|
||||||
|
}
|
||||||
|
: { kind: 'unchanged' as const, revision: COMMAND_TABLE_REVISION }
|
||||||
|
: undefined;
|
||||||
|
const boardAccess = include.boardAccess
|
||||||
|
? forceSnapshot || !input.known?.boardAccess
|
||||||
|
? {
|
||||||
|
kind: 'snapshot' as const,
|
||||||
|
revision: BOARD_ACCESS_REVISION,
|
||||||
|
data: {
|
||||||
|
permission: state.permission,
|
||||||
|
canMeeting: state.officerLevel >= 1,
|
||||||
|
canSecret: state.permission >= 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { kind: 'unchanged' as const, revision: BOARD_ACCESS_REVISION }
|
||||||
|
: undefined;
|
||||||
|
return response({ context, commandTable, boardAccess });
|
||||||
|
}
|
||||||
if (operation === 'general.me') {
|
if (operation === 'general.me') {
|
||||||
state.generalMeCalls += 1;
|
state.generalMeCalls += 1;
|
||||||
return response(generalContext(state));
|
return response(generalContext(state));
|
||||||
@@ -211,6 +338,22 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
if (operation === 'tournament.getState') return response({ stage: state.stage });
|
if (operation === 'tournament.getState') return response({ stage: state.stage });
|
||||||
return response({ ok: true });
|
return response({ ok: true });
|
||||||
});
|
});
|
||||||
|
operations.forEach((operation, index) => {
|
||||||
|
if (operation !== 'dashboard.getContextBundleDelta') return;
|
||||||
|
const item = results[index];
|
||||||
|
if (!item) return;
|
||||||
|
const data = item.result.data as {
|
||||||
|
context?: { kind: string };
|
||||||
|
commandTable?: { kind: string };
|
||||||
|
boardAccess?: { kind: string };
|
||||||
|
};
|
||||||
|
(state.dashboardResponses ??= []).push({
|
||||||
|
bytes: Buffer.byteLength(JSON.stringify(item)),
|
||||||
|
contextKind: data.context?.kind ?? null,
|
||||||
|
commandTableKind: data.commandTable?.kind ?? null,
|
||||||
|
boardAccessKind: data.boardAccess?.kind ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
@@ -638,6 +781,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
refreshDelayMs: 300,
|
refreshDelayMs: 300,
|
||||||
|
largeCommandTable: true,
|
||||||
};
|
};
|
||||||
await installRealtimeHarness(page);
|
await installRealtimeHarness(page);
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
@@ -739,11 +883,12 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
expect(state.generalMeCalls).toBe(callsBeforeRefresh + 1);
|
expect(state.generalMeCalls).toBe(callsBeforeRefresh + 1);
|
||||||
await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수');
|
await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수');
|
||||||
const changedOperations = state.operations.slice(operationsBeforeChangedBurst);
|
const changedOperations = state.operations.slice(operationsBeforeChangedBurst);
|
||||||
expect(changedOperations).toEqual(
|
expect(changedOperations).toEqual(['dashboard.getContextBundleDelta']);
|
||||||
expect.arrayContaining(['general.me', 'turns.getCommandTable', 'board.getAccess'])
|
|
||||||
);
|
|
||||||
expect(changedOperations).not.toEqual(
|
expect(changedOperations).not.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
|
'general.me',
|
||||||
|
'turns.getCommandTable',
|
||||||
|
'board.getAccess',
|
||||||
'lobby.info',
|
'lobby.info',
|
||||||
'world.getMap',
|
'world.getMap',
|
||||||
'messages.getRecent',
|
'messages.getRecent',
|
||||||
@@ -753,6 +898,19 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
'turns.reserved.getGeneral',
|
'turns.reserved.getGeneral',
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
const initialBundle = state.dashboardResponses?.find(
|
||||||
|
(entry) => entry.contextKind === 'snapshot' && entry.commandTableKind === 'snapshot'
|
||||||
|
);
|
||||||
|
const realtimeBundle = state.dashboardResponses?.find(
|
||||||
|
(entry) => entry.contextKind === 'patch' && entry.commandTableKind === 'unchanged'
|
||||||
|
);
|
||||||
|
expect(initialBundle?.bytes).toBeGreaterThan(5_000);
|
||||||
|
expect(realtimeBundle).toMatchObject({
|
||||||
|
contextKind: 'patch',
|
||||||
|
commandTableKind: 'unchanged',
|
||||||
|
boardAccessKind: 'unchanged',
|
||||||
|
});
|
||||||
|
expect(realtimeBundle?.bytes).toBeLessThan(1_000);
|
||||||
|
|
||||||
const operationsBeforeSurvey = state.operations.length;
|
const operationsBeforeSurvey = state.operations.length;
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -812,8 +970,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
expect(profile.cityMounted).toBe(true);
|
expect(profile.cityMounted).toBe(true);
|
||||||
expect(profile.generalMutations).toBeGreaterThan(0);
|
expect(profile.generalMutations).toBeGreaterThan(0);
|
||||||
expect(profile.cityMutations).toBe(0);
|
expect(profile.cityMutations).toBe(0);
|
||||||
expect(profile.vueMeasures.some((name) => name.includes('GeneralBasicCard'))).toBe(true);
|
if (!productionBundle) {
|
||||||
expect(profile.vueMeasures.some((name) => name.includes('CityBasicCard'))).toBe(false);
|
expect(profile.vueMeasures.some((name) => name.includes('GeneralBasicCard'))).toBe(true);
|
||||||
|
expect(profile.vueMeasures.some((name) => name.includes('CityBasicCard'))).toBe(false);
|
||||||
|
}
|
||||||
if (autoRefreshArtifactRoot) {
|
if (autoRefreshArtifactRoot) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-complete.png'), fullPage: true }),
|
page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-complete.png'), fullPage: true }),
|
||||||
@@ -823,6 +983,15 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
{
|
{
|
||||||
emittedTurnEvents: 100,
|
emittedTurnEvents: 100,
|
||||||
selectiveGeneralRefreshes: state.generalMeCalls - callsBeforeRefresh,
|
selectiveGeneralRefreshes: state.generalMeCalls - callsBeforeRefresh,
|
||||||
|
responseBytes: {
|
||||||
|
initialSnapshot: initialBundle?.bytes ?? null,
|
||||||
|
realtimeDelta: realtimeBundle?.bytes ?? null,
|
||||||
|
reductionPercent:
|
||||||
|
initialBundle && realtimeBundle
|
||||||
|
? Number(((1 - realtimeBundle.bytes / initialBundle.bytes) * 100).toFixed(2))
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
responseKinds: realtimeBundle ?? null,
|
||||||
inFlightSkeletons: { general: 0, city: 0 },
|
inFlightSkeletons: { general: 0, city: 0 },
|
||||||
...profile,
|
...profile,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
|||||||
const baseURL = `http://127.0.0.1:${port}${basePath}/`;
|
const baseURL = `http://127.0.0.1:${port}${basePath}/`;
|
||||||
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`;
|
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`;
|
||||||
const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
|
const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
|
||||||
|
const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||||
|
const frontendEnv =
|
||||||
|
`VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` +
|
||||||
|
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
@@ -55,7 +59,9 @@ export default defineConfig({
|
|||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
|
command: useProductionBundle
|
||||||
|
? `${frontendEnv} pnpm --filter @sammo-ts/game-frontend build && ${frontendEnv} pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port ${port}`
|
||||||
|
: `${frontendEnv} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
|
||||||
cwd: repositoryRoot,
|
cwd: repositoryRoot,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { computed, ref, toRaw, watch } from 'vue';
|
import { computed, ref, toRaw, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
||||||
import type { RealtimeEvent, RealtimeReadModelChanges } from '@sammo-ts/common';
|
import {
|
||||||
|
applyReadModelDelta,
|
||||||
|
ReadModelDeltaMismatchError,
|
||||||
|
type RealtimeEvent,
|
||||||
|
type RealtimeReadModelChanges,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { useMapViewerStore } from './mapViewer';
|
import { useMapViewerStore } from './mapViewer';
|
||||||
import { useSessionStore } from './session';
|
import { useSessionStore } from './session';
|
||||||
@@ -36,7 +41,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][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>>;
|
||||||
|
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
|
||||||
|
type ContextBundleInclude = {
|
||||||
|
context: boolean;
|
||||||
|
commandTable: boolean;
|
||||||
|
boardAccess: boolean;
|
||||||
|
};
|
||||||
type DashboardReadModelPatch = {
|
type DashboardReadModelPatch = {
|
||||||
|
contextSnapshot?: GeneralContext;
|
||||||
|
contextRevision?: string | null;
|
||||||
|
commandTableRevision?: string | null;
|
||||||
|
boardAccessRevision?: string | null;
|
||||||
general?: PresentGeneralContext['general'] | null;
|
general?: PresentGeneralContext['general'] | null;
|
||||||
city?: PresentGeneralContext['city'] | null;
|
city?: PresentGeneralContext['city'] | null;
|
||||||
nation?: PresentGeneralContext['nation'] | null;
|
nation?: PresentGeneralContext['nation'] | null;
|
||||||
@@ -87,6 +102,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
let lastWorldHistoryId = 0;
|
let lastWorldHistoryId = 0;
|
||||||
let recordGeneralId: number | null = null;
|
let recordGeneralId: number | null = null;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
let contextSnapshot: GeneralContext | undefined;
|
||||||
|
let contextRevision: string | null = null;
|
||||||
|
let commandTableRevision: string | null = null;
|
||||||
|
let boardAccessRevision: string | null = null;
|
||||||
|
|
||||||
const messageDraftText = ref('');
|
const messageDraftText = ref('');
|
||||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||||
@@ -298,14 +317,37 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyDashboardPatch = (patch: DashboardReadModelPatch) => {
|
const applyDashboardPatch = (patch: DashboardReadModelPatch) => {
|
||||||
if (patch.general === null) {
|
if (patch.contextSnapshot === null) {
|
||||||
|
contextSnapshot = null;
|
||||||
general.value = null;
|
general.value = null;
|
||||||
city.value = null;
|
city.value = null;
|
||||||
nation.value = null;
|
nation.value = null;
|
||||||
|
commandTable.value = null;
|
||||||
|
boardAccess.value = null;
|
||||||
reservedGeneralTurns.value = null;
|
reservedGeneralTurns.value = null;
|
||||||
reservedGeneralRevision.value = 0;
|
reservedGeneralRevision.value = 0;
|
||||||
boardAccess.value = null;
|
|
||||||
resetRecentRecords(null);
|
resetRecentRecords(null);
|
||||||
|
commandTableRevision = null;
|
||||||
|
boardAccessRevision = null;
|
||||||
|
} else if (patch.contextSnapshot !== undefined) {
|
||||||
|
contextSnapshot = patch.contextSnapshot;
|
||||||
|
general.value = structurallyShare(general.value, patch.contextSnapshot.general);
|
||||||
|
city.value = structurallyShare(city.value, patch.contextSnapshot.city);
|
||||||
|
nation.value = structurallyShare(nation.value, patch.contextSnapshot.nation);
|
||||||
|
}
|
||||||
|
if (patch.general === null) {
|
||||||
|
contextSnapshot = null;
|
||||||
|
general.value = null;
|
||||||
|
city.value = null;
|
||||||
|
nation.value = null;
|
||||||
|
commandTable.value = null;
|
||||||
|
boardAccess.value = null;
|
||||||
|
reservedGeneralTurns.value = null;
|
||||||
|
reservedGeneralRevision.value = 0;
|
||||||
|
resetRecentRecords(null);
|
||||||
|
contextRevision = null;
|
||||||
|
commandTableRevision = null;
|
||||||
|
boardAccessRevision = null;
|
||||||
} else if (patch.general !== undefined) {
|
} else if (patch.general !== undefined) {
|
||||||
general.value = structurallyShare(general.value, patch.general);
|
general.value = structurallyShare(general.value, patch.general);
|
||||||
}
|
}
|
||||||
@@ -350,10 +392,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
} else if (patch.frontStatus !== undefined) {
|
} else if (patch.frontStatus !== undefined) {
|
||||||
updateFrontStatus(patch.frontStatus);
|
updateFrontStatus(patch.frontStatus);
|
||||||
}
|
}
|
||||||
|
if (patch.contextRevision !== undefined) contextRevision = patch.contextRevision;
|
||||||
|
if (patch.commandTableRevision !== undefined) commandTableRevision = patch.commandTableRevision;
|
||||||
|
if (patch.boardAccessRevision !== undefined) boardAccessRevision = patch.boardAccessRevision;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDashboardPatch = (): DashboardReadModelPatch => {
|
const currentDashboardPatch = (): DashboardReadModelPatch => {
|
||||||
const patch: DashboardReadModelPatch = {};
|
const patch: DashboardReadModelPatch = {};
|
||||||
|
patch.contextSnapshot = toRaw(contextSnapshot);
|
||||||
|
patch.contextRevision = contextRevision;
|
||||||
|
patch.commandTableRevision = commandTableRevision;
|
||||||
|
patch.boardAccessRevision = boardAccessRevision;
|
||||||
patch.general = toRaw(general.value);
|
patch.general = toRaw(general.value);
|
||||||
patch.city = toRaw(city.value);
|
patch.city = toRaw(city.value);
|
||||||
patch.nation = toRaw(nation.value);
|
patch.nation = toRaw(nation.value);
|
||||||
@@ -373,6 +422,64 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
return patch;
|
return patch;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveContextBundlePatch = (bundle: ContextBundleDelta): DashboardReadModelPatch => {
|
||||||
|
const patch: DashboardReadModelPatch = {};
|
||||||
|
|
||||||
|
if (bundle.context) {
|
||||||
|
const applied = applyReadModelDelta(contextSnapshot, contextRevision, bundle.context);
|
||||||
|
patch.contextRevision = applied.revision;
|
||||||
|
if (bundle.context.kind !== 'unchanged') {
|
||||||
|
patch.contextSnapshot = applied.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bundle.commandTable) {
|
||||||
|
const current = commandTable.value === null ? undefined : toRaw(commandTable.value);
|
||||||
|
const applied = applyReadModelDelta(current, commandTableRevision, bundle.commandTable);
|
||||||
|
patch.commandTableRevision = applied.revision;
|
||||||
|
if (bundle.commandTable.kind !== 'unchanged') {
|
||||||
|
patch.commandTable = applied.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bundle.boardAccess) {
|
||||||
|
const current = boardAccess.value === null ? undefined : toRaw(boardAccess.value);
|
||||||
|
const applied = applyReadModelDelta(current, boardAccessRevision, bundle.boardAccess);
|
||||||
|
patch.boardAccessRevision = applied.revision;
|
||||||
|
if (bundle.boardAccess.kind !== 'unchanged') {
|
||||||
|
patch.boardAccess = applied.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchContextBundlePatch = async (
|
||||||
|
include: ContextBundleInclude,
|
||||||
|
forceSnapshot = false
|
||||||
|
): Promise<DashboardReadModelPatch> => {
|
||||||
|
const request = (force: boolean) =>
|
||||||
|
trpc.dashboard.getContextBundleDelta.query({
|
||||||
|
include,
|
||||||
|
known: force
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
...(contextRevision ? { context: contextRevision } : {}),
|
||||||
|
...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
|
||||||
|
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
|
||||||
|
},
|
||||||
|
forceSnapshot: force || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bundle = await request(forceSnapshot);
|
||||||
|
try {
|
||||||
|
return resolveContextBundlePatch(bundle);
|
||||||
|
} catch (error) {
|
||||||
|
if (forceSnapshot || !(error instanceof ReadModelDeltaMismatchError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return resolveContextBundlePatch(await request(true));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshMainData = async () => {
|
const refreshMainData = async () => {
|
||||||
const isInitialLoad = !initialized;
|
const isInitialLoad = !initialized;
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
@@ -385,16 +492,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
frontStatusError.value = null;
|
frontStatusError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await trpc.general.me.query();
|
const contextPatch = await fetchContextBundlePatch(
|
||||||
|
{ context: true, commandTable: true, boardAccess: true },
|
||||||
|
true
|
||||||
|
);
|
||||||
|
applyDashboardPatch(contextPatch);
|
||||||
|
const context = contextSnapshot;
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
general.value = null;
|
|
||||||
city.value = null;
|
|
||||||
nation.value = null;
|
|
||||||
reservedGeneralTurns.value = null;
|
|
||||||
reservedGeneralRevision.value = 0;
|
|
||||||
boardAccess.value = null;
|
|
||||||
resetRecentRecords(null);
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -418,29 +523,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
frontStatusError.value = resolveErrorMessage(err);
|
frontStatusError.value = resolveErrorMessage(err);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const [
|
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] =
|
||||||
layout,
|
await Promise.all([
|
||||||
lobby,
|
layoutPromise,
|
||||||
map,
|
trpc.lobby.info.query(),
|
||||||
commands,
|
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||||
messageData,
|
trpc.messages.getRecent.query({ generalId: id }),
|
||||||
contacts,
|
trpc.messages.getContacts.query({ generalId: id }),
|
||||||
access,
|
generalTurnsPromise,
|
||||||
generalTurns,
|
recordsPromise,
|
||||||
records,
|
frontStatusPromise,
|
||||||
nextFrontStatus,
|
]);
|
||||||
] = await Promise.all([
|
|
||||||
layoutPromise,
|
|
||||||
trpc.lobby.info.query(),
|
|
||||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
|
||||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
|
||||||
trpc.messages.getRecent.query({ generalId: id }),
|
|
||||||
trpc.messages.getContacts.query({ generalId: id }),
|
|
||||||
trpc.board.getAccess.query(),
|
|
||||||
generalTurnsPromise,
|
|
||||||
recordsPromise,
|
|
||||||
frontStatusPromise,
|
|
||||||
]);
|
|
||||||
|
|
||||||
general.value = structurallyShare(general.value, context.general);
|
general.value = structurallyShare(general.value, context.general);
|
||||||
city.value = structurallyShare(city.value, context.city);
|
city.value = structurallyShare(city.value, context.city);
|
||||||
@@ -448,10 +541,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
mapLayout.value = structurallyShare(mapLayout.value, layout);
|
mapLayout.value = structurallyShare(mapLayout.value, layout);
|
||||||
lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby);
|
lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby);
|
||||||
worldMap.value = structurallyShare(worldMap.value, map);
|
worldMap.value = structurallyShare(worldMap.value, map);
|
||||||
commandTable.value = structurallyShare(commandTable.value, commands);
|
|
||||||
messages.value = structurallyShare(messages.value, messageData);
|
messages.value = structurallyShare(messages.value, messageData);
|
||||||
messageContacts.value = structurallyShare(messageContacts.value, contacts);
|
messageContacts.value = structurallyShare(messageContacts.value, contacts);
|
||||||
boardAccess.value = structurallyShare(boardAccess.value, access);
|
|
||||||
reservedGeneralTurns.value = structurallyShare<unknown>(
|
reservedGeneralTurns.value = structurallyShare<unknown>(
|
||||||
reservedGeneralTurns.value,
|
reservedGeneralTurns.value,
|
||||||
generalTurns.turns
|
generalTurns.turns
|
||||||
@@ -517,20 +608,21 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
if (plan.records) recordsError.value = null;
|
if (plan.records) recordsError.value = null;
|
||||||
if (plan.frontStatus) frontStatusError.value = null;
|
if (plan.frontStatus) frontStatusError.value = null;
|
||||||
try {
|
try {
|
||||||
const contextPromise = plan.context
|
const contextBundlePromise =
|
||||||
? trpc.general.me.query()
|
plan.context || plan.commands || plan.boardAccess
|
||||||
: Promise.resolve(undefined as GeneralContext | undefined);
|
? fetchContextBundlePatch({
|
||||||
|
context: plan.context,
|
||||||
|
commandTable: plan.commands,
|
||||||
|
boardAccess: plan.boardAccess,
|
||||||
|
})
|
||||||
|
: Promise.resolve(undefined);
|
||||||
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
||||||
const mapPromise = plan.map
|
const mapPromise = plan.map
|
||||||
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
|
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
const commandsPromise = plan.commands
|
|
||||||
? trpc.turns.getCommandTable.query({ generalId: id })
|
|
||||||
: Promise.resolve(undefined);
|
|
||||||
const contactsPromise = plan.contacts
|
const contactsPromise = plan.contacts
|
||||||
? trpc.messages.getContacts.query({ generalId: id })
|
? trpc.messages.getContacts.query({ generalId: id })
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
const boardPromise = plan.boardAccess ? trpc.board.getAccess.query() : Promise.resolve(undefined);
|
|
||||||
const reservedPromise = plan.reservedTurns
|
const reservedPromise = plan.reservedTurns
|
||||||
? trpc.turns.reserved.getGeneral.query({ generalId: id })
|
? trpc.turns.reserved.getGeneral.query({ generalId: id })
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
@@ -549,32 +641,20 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
})
|
})
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
|
|
||||||
const [context, lobby, map, commands, contacts, access, generalTurns, records, nextFrontStatus] =
|
const [contextPatch, lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
||||||
await Promise.all([
|
contextBundlePromise,
|
||||||
contextPromise,
|
lobbyPromise,
|
||||||
lobbyPromise,
|
mapPromise,
|
||||||
mapPromise,
|
contactsPromise,
|
||||||
commandsPromise,
|
reservedPromise,
|
||||||
contactsPromise,
|
recordsPromise,
|
||||||
boardPromise,
|
frontPromise,
|
||||||
reservedPromise,
|
]);
|
||||||
recordsPromise,
|
|
||||||
frontPromise,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const patch: DashboardReadModelPatch = {};
|
const patch: DashboardReadModelPatch = contextPatch ? { ...contextPatch } : {};
|
||||||
if (context === null) {
|
|
||||||
patch.general = null;
|
|
||||||
} else if (context !== undefined) {
|
|
||||||
patch.general = context.general;
|
|
||||||
patch.city = context.city;
|
|
||||||
patch.nation = context.nation;
|
|
||||||
}
|
|
||||||
if (lobby !== undefined) patch.lobbyInfo = lobby;
|
if (lobby !== undefined) patch.lobbyInfo = lobby;
|
||||||
if (map !== undefined) patch.worldMap = map;
|
if (map !== undefined) patch.worldMap = map;
|
||||||
if (commands !== undefined) patch.commandTable = commands;
|
|
||||||
if (contacts !== undefined) patch.messageContacts = contacts;
|
if (contacts !== undefined) patch.messageContacts = contacts;
|
||||||
if (access !== undefined) patch.boardAccess = access;
|
|
||||||
if (generalTurns !== undefined) {
|
if (generalTurns !== undefined) {
|
||||||
patch.reservedGeneralTurns = generalTurns.turns;
|
patch.reservedGeneralTurns = generalTurns.turns;
|
||||||
patch.reservedGeneralRevision = generalTurns.revision;
|
patch.reservedGeneralRevision = generalTurns.revision;
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"es-toolkit": "^1.43.0"
|
"es-toolkit": "^1.43.0",
|
||||||
|
"rfc6902": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsdown": "^0.22.14",
|
"tsdown": "^0.22.14",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export * from './tournament/autoStart.js';
|
|||||||
export * from './turnDaemon/types.js';
|
export * from './turnDaemon/types.js';
|
||||||
export * from './realtime/keys.js';
|
export * from './realtime/keys.js';
|
||||||
export * from './realtime/types.js';
|
export * from './realtime/types.js';
|
||||||
|
export * from './realtime/delta.js';
|
||||||
export * from './ranking/types.js';
|
export * from './ranking/types.js';
|
||||||
export * from './ranking/legacyColor.js';
|
export * from './ranking/legacyColor.js';
|
||||||
export * from './auth/accountIconProjection.js';
|
export * from './auth/accountIconProjection.js';
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { applyPatch, createPatch, type Operation } from 'rfc6902';
|
||||||
|
|
||||||
|
export interface JsonPatchOperation {
|
||||||
|
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
|
||||||
|
path: string;
|
||||||
|
from?: string;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReadModelDelta<T> =
|
||||||
|
| {
|
||||||
|
kind: 'snapshot';
|
||||||
|
revision: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'unchanged';
|
||||||
|
revision: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'patch';
|
||||||
|
baseRevision: string;
|
||||||
|
revision: string;
|
||||||
|
operations: JsonPatchOperation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ReadModelDeltaMismatchError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ReadModelDeltaMismatchError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppliedReadModelDelta<T> {
|
||||||
|
data: T;
|
||||||
|
revision: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneJsonValue = <T>(value: T): T => structuredClone(value);
|
||||||
|
|
||||||
|
export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => createPatch(current, next);
|
||||||
|
|
||||||
|
export const applyReadModelDelta = <T>(
|
||||||
|
current: T | undefined,
|
||||||
|
currentRevision: string | null,
|
||||||
|
delta: ReadModelDelta<T>
|
||||||
|
): AppliedReadModelDelta<T> => {
|
||||||
|
if (delta.kind === 'snapshot') {
|
||||||
|
return {
|
||||||
|
data: delta.data,
|
||||||
|
revision: delta.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current === undefined || currentRevision === null) {
|
||||||
|
throw new ReadModelDeltaMismatchError('A delta cannot be applied before the initial snapshot.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta.kind === 'unchanged') {
|
||||||
|
if (currentRevision !== delta.revision) {
|
||||||
|
throw new ReadModelDeltaMismatchError(
|
||||||
|
`Unchanged revision mismatch: have ${currentRevision}, received ${delta.revision}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: current,
|
||||||
|
revision: currentRevision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRevision !== delta.baseRevision) {
|
||||||
|
throw new ReadModelDeltaMismatchError(
|
||||||
|
`Patch base revision mismatch: have ${currentRevision}, expected ${delta.baseRevision}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = cloneJsonValue(current);
|
||||||
|
const errors = applyPatch(next, delta.operations as Operation[]);
|
||||||
|
const failure = errors.find((error) => error !== null);
|
||||||
|
if (failure) {
|
||||||
|
throw new ReadModelDeltaMismatchError(`JSON Patch application failed: ${failure.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: next,
|
||||||
|
revision: delta.revision,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { applyReadModelDelta, ReadModelDeltaMismatchError } from '../src/realtime/delta.js';
|
||||||
|
|
||||||
|
describe('applyReadModelDelta', () => {
|
||||||
|
it('applies a JSON Patch without mutating the previous snapshot', () => {
|
||||||
|
const current = {
|
||||||
|
general: [{ key: '휴식', possible: true, status: 'available' }],
|
||||||
|
inputOptions: { cities: [{ value: 1, label: '업' }] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const applied = applyReadModelDelta(current, 'revision-1', {
|
||||||
|
kind: 'patch',
|
||||||
|
baseRevision: 'revision-1',
|
||||||
|
revision: 'revision-2',
|
||||||
|
operations: [{ op: 'replace', path: '/general/0/possible', value: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toEqual({
|
||||||
|
revision: 'revision-2',
|
||||||
|
data: {
|
||||||
|
general: [{ key: '휴식', possible: false, status: 'available' }],
|
||||||
|
inputOptions: { cities: [{ value: 1, label: '업' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(current.general[0]?.possible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the current object for an unchanged revision', () => {
|
||||||
|
const current = { value: 1 };
|
||||||
|
const applied = applyReadModelDelta(current, 'revision-1', {
|
||||||
|
kind: 'unchanged',
|
||||||
|
revision: 'revision-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied.data).toBe(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a patch based on a different snapshot', () => {
|
||||||
|
expect(() =>
|
||||||
|
applyReadModelDelta({ value: 1 }, 'revision-2', {
|
||||||
|
kind: 'patch',
|
||||||
|
baseRevision: 'revision-1',
|
||||||
|
revision: 'revision-3',
|
||||||
|
operations: [{ op: 'replace', path: '/value', value: 2 }],
|
||||||
|
})
|
||||||
|
).toThrow(ReadModelDeltaMismatchError);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+8
@@ -420,6 +420,9 @@ importers:
|
|||||||
es-toolkit:
|
es-toolkit:
|
||||||
specifier: ^1.43.0
|
specifier: ^1.43.0
|
||||||
version: 1.43.0
|
version: 1.43.0
|
||||||
|
rfc6902:
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
tsdown:
|
tsdown:
|
||||||
specifier: ^0.22.14
|
specifier: ^0.22.14
|
||||||
@@ -4186,6 +4189,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rfc6902@5.3.0:
|
||||||
|
resolution: {integrity: sha512-8x9uqKB5FeC3jhdtmBtI2Z2GR0+VE1VLbQ7Luy5RcnCxUa4+Z1n8w2G1D+GevkRUT33nbbfpAK5O4LhRbnaIPQ==}
|
||||||
|
|
||||||
rfdc@1.4.1:
|
rfdc@1.4.1:
|
||||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
@@ -8276,6 +8282,8 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rfc6902@5.3.0: {}
|
||||||
|
|
||||||
rfdc@1.4.1: {}
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
rolldown-plugin-dts@0.27.13(@volar/typescript@2.4.27)(rolldown@1.2.0)(typescript@6.0.2)(vue-tsc@3.2.2(typescript@6.0.2)):
|
rolldown-plugin-dts@0.27.13(@volar/typescript@2.4.27)(rolldown@1.2.0)(typescript@6.0.2)(vue-tsc@3.2.2(typescript@6.0.2)):
|
||||||
|
|||||||
Reference in New Issue
Block a user