- Introduced GeneralTurn and NationTurn models in Prisma schema. - Implemented reserved turns management in the game API, including loading, setting, and shifting turns for generals and nations. - Created unit tests for reserved turns functionality to ensure correctness. - Developed reserved turn handler to process actions based on reserved turns. - Established in-memory storage for reserved turns with persistence to the database.
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
|
|
import type { WorldStateRow } from './context.js';
|
|
import { authedProcedure, procedure, router } from './trpc.js';
|
|
import { buildTurnCommandTable } from './turns/commandTable.js';
|
|
import {
|
|
MAX_GENERAL_TURNS,
|
|
MAX_NATION_TURNS,
|
|
setGeneralTurn,
|
|
setNationTurn,
|
|
shiftGeneralTurns,
|
|
shiftNationTurns,
|
|
} from './turns/reservedTurns.js';
|
|
|
|
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
|
|
|
const zTurnRunBudget = z.object({
|
|
budgetMs: z.number().int().positive(),
|
|
maxGenerals: z.number().int().positive(),
|
|
catchUpCap: z.number().int().positive(),
|
|
});
|
|
|
|
const buildShiftAmountSchema = (maxTurns: number) =>
|
|
z.number()
|
|
.int()
|
|
.min(-(maxTurns - 1))
|
|
.max(maxTurns - 1)
|
|
.refine((value) => value !== 0, {
|
|
message: 'Amount must be non-zero.',
|
|
});
|
|
|
|
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
|
scenarioCode: row.scenarioCode,
|
|
currentYear: row.currentYear,
|
|
currentMonth: row.currentMonth,
|
|
tickSeconds: row.tickSeconds,
|
|
config: row.config,
|
|
meta: row.meta,
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
|
|
export const appRouter = router({
|
|
health: router({
|
|
ping: procedure.query(({ ctx }) => ({
|
|
ok: true,
|
|
profile: ctx.profile.name,
|
|
now: new Date().toISOString(),
|
|
})),
|
|
}),
|
|
world: router({
|
|
getState: procedure.query(async ({ ctx }) => {
|
|
const state = await ctx.db.worldState.findFirst();
|
|
return state ? toWorldStateSnapshot(state) : null;
|
|
}),
|
|
}),
|
|
turns: router({
|
|
getCommandTable: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const [worldState, general] = await Promise.all([
|
|
ctx.db.worldState.findFirst(),
|
|
ctx.db.general.findUnique({ where: { id: input.generalId } }),
|
|
]);
|
|
|
|
if (!worldState) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'World state is not initialized.',
|
|
});
|
|
}
|
|
|
|
if (!general) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'General not found.',
|
|
});
|
|
}
|
|
|
|
const [city, nation] = 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,
|
|
]);
|
|
|
|
return buildTurnCommandTable({
|
|
worldState,
|
|
general,
|
|
city,
|
|
nation,
|
|
});
|
|
}),
|
|
reserved: router({
|
|
setGeneral: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
turnIndex: z.number()
|
|
.int()
|
|
.min(0)
|
|
.max(MAX_GENERAL_TURNS - 1),
|
|
action: z.string().min(1),
|
|
args: z.unknown().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await ctx.db.general.findUnique({
|
|
where: { id: input.generalId },
|
|
});
|
|
if (!general) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'General not found.',
|
|
});
|
|
}
|
|
|
|
const turns = await setGeneralTurn(
|
|
ctx.db,
|
|
input.generalId,
|
|
input.turnIndex,
|
|
input.action,
|
|
input.args
|
|
);
|
|
return { ok: true, turns };
|
|
}),
|
|
shiftGeneral: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
amount: buildShiftAmountSchema(MAX_GENERAL_TURNS),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await ctx.db.general.findUnique({
|
|
where: { id: input.generalId },
|
|
});
|
|
if (!general) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'General not found.',
|
|
});
|
|
}
|
|
|
|
const turns = await shiftGeneralTurns(
|
|
ctx.db,
|
|
input.generalId,
|
|
input.amount
|
|
);
|
|
return { ok: true, turns };
|
|
}),
|
|
setNation: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
turnIndex: z.number()
|
|
.int()
|
|
.min(0)
|
|
.max(MAX_NATION_TURNS - 1),
|
|
action: z.string().min(1),
|
|
args: z.unknown().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await ctx.db.general.findUnique({
|
|
where: { id: input.generalId },
|
|
});
|
|
if (!general) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'General not found.',
|
|
});
|
|
}
|
|
if (general.nationId <= 0) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'General is not part of a nation.',
|
|
});
|
|
}
|
|
if (general.officerLevel < 5) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'General is not an officer.',
|
|
});
|
|
}
|
|
|
|
const turns = await setNationTurn(
|
|
ctx.db,
|
|
general.nationId,
|
|
general.officerLevel,
|
|
input.turnIndex,
|
|
input.action,
|
|
input.args
|
|
);
|
|
return { ok: true, turns };
|
|
}),
|
|
shiftNation: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
amount: buildShiftAmountSchema(MAX_NATION_TURNS),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await ctx.db.general.findUnique({
|
|
where: { id: input.generalId },
|
|
});
|
|
if (!general) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'General not found.',
|
|
});
|
|
}
|
|
if (general.nationId <= 0) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'General is not part of a nation.',
|
|
});
|
|
}
|
|
if (general.officerLevel < 5) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'General is not an officer.',
|
|
});
|
|
}
|
|
|
|
const turns = await shiftNationTurns(
|
|
ctx.db,
|
|
general.nationId,
|
|
general.officerLevel,
|
|
input.amount
|
|
);
|
|
return { ok: true, turns };
|
|
}),
|
|
}),
|
|
}),
|
|
turnDaemon: router({
|
|
run: procedure
|
|
.input(
|
|
z.object({
|
|
reason: zRunReason,
|
|
targetTime: z.string().min(1).optional(),
|
|
budget: zTurnRunBudget.optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const requestId = await ctx.turnDaemon.sendCommand({
|
|
type: 'run',
|
|
reason: input.reason,
|
|
targetTime: input.targetTime,
|
|
budget: input.budget,
|
|
});
|
|
return { accepted: true, requestId };
|
|
}),
|
|
pause: procedure
|
|
.input(
|
|
z.object({
|
|
reason: z.string().min(1).optional(),
|
|
}).optional()
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const requestId = await ctx.turnDaemon.sendCommand({
|
|
type: 'pause',
|
|
reason: input?.reason,
|
|
});
|
|
return { accepted: true, requestId };
|
|
}),
|
|
resume: procedure
|
|
.input(
|
|
z.object({
|
|
reason: z.string().min(1).optional(),
|
|
}).optional()
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const requestId = await ctx.turnDaemon.sendCommand({
|
|
type: 'resume',
|
|
reason: input?.reason,
|
|
});
|
|
return { accepted: true, requestId };
|
|
}),
|
|
status: procedure
|
|
.input(
|
|
z.object({
|
|
timeoutMs: z.number().int().positive().optional(),
|
|
}).optional()
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.turnDaemon.requestStatus(input?.timeoutMs);
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|