Files
core2026/app/game-api/src/router.ts
T
Hide_D 6fe6d54432 feat: add GeneralTurn and NationTurn models with CRUD operations
- 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.
2025-12-30 04:45:07 +00:00

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;