Files
core2026/app/game-api/src/router.ts
T
Hide_D f8159728ca feat: Implement session management and routing for general creation
- Added session initialization in main.ts to manage user sessions.
- Updated router to include a new route for joining or creating a general.
- Enhanced session store with methods for managing session tokens and user profiles.
- Introduced new components for displaying city, general, nation, and command information.
- Implemented loading states and error handling in the main view.
- Created a skeleton loader component for better user experience during data fetching.
- Updated TypeScript definitions for route metadata to include new authentication requirements.
2026-01-16 15:57:56 +00:00

1007 lines
38 KiB
TypeScript

import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { zWorldStateConfig, zWorldStateMeta } from './context.js';
import type { GameApiContext, 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';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
sendMessage,
type MessageDraft,
type MessageRecordDraft,
type MessageType,
} from '@sammo-ts/logic';
import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from './messages/targets.js';
import {
fetchMessagesFromMailbox,
fetchOldMessagesFromMailbox,
insertMessage,
type MessageView,
} from './messages/store.js';
import { buildBattleSimJobPayload } from './battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from './battleSim/schema.js';
import { loadWorldMap } from './maps/worldMap.js';
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
const zGeneralSettings = z.object({
tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(),
});
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(),
});
const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) => {
if (!ctx.auth?.user.id) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const general = await ctx.db.general.findFirst({
where: { userId: ctx.auth.user.id },
});
if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
}
return general;
};
export const appRouter = router({
health: router({
ping: procedure.query(({ ctx }) => ({
ok: true,
profile: ctx.profile.name,
now: new Date().toISOString(),
})),
}),
lobby: router({
info: procedure.query(async ({ ctx }) => {
const rawWorldState = await ctx.db.worldState.findFirst();
if (!rawWorldState) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'World state not found',
});
}
const worldState = {
...rawWorldState,
config: zWorldStateConfig.parse(rawWorldState.config),
meta: zWorldStateMeta.parse(rawWorldState.meta),
};
const userCnt = await ctx.db.general.count({ where: { npcState: 0 } });
const npcCnt = await ctx.db.general.count({ where: { npcState: { gt: 0 } } });
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
// myGeneral info if authenticated
let myGeneral = null;
if (ctx.auth?.user.id) {
const general = await ctx.db.general.findFirst({
where: { userId: ctx.auth.user.id },
select: { name: true, picture: true },
});
if (general) {
myGeneral = {
name: general.name,
picture: general.picture,
};
}
}
return {
year: worldState.currentYear,
month: worldState.currentMonth,
userCnt,
maxUserCnt: worldState.config.maxUserCnt ?? 500,
npcCnt,
nationCnt,
turnTerm: worldState.tickSeconds / 60,
fictionMode: worldState.config.fictionMode ?? '사실',
starttime: worldState.meta.starttime ?? '',
opentime: worldState.meta.opentime ?? '',
turntime: worldState.meta.turntime ?? '',
otherTextInfo: worldState.meta.otherTextInfo ?? '',
isUnited: worldState.meta.isUnited ?? 0,
myGeneral,
};
}),
}),
battle: router({
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
return ctx.battleSim.simulate(payload);
}),
getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
const result = await ctx.battleSim.getSimulationResult(input.jobId);
if (!result) {
return { status: 'queued', jobId: input.jobId };
}
return { status: 'completed', jobId: input.jobId, payload: result };
}),
}),
world: router({
getState: procedure.query(async ({ ctx }) => {
const state = await ctx.db.worldState.findFirst();
return state ? toWorldStateSnapshot(state) : null;
}),
getMap: procedure
.input(
z.object({
generalId: z.number().int().positive().optional(),
neutralView: z.boolean().optional(),
showMe: z.boolean().optional(),
useCache: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const map = await loadWorldMap(ctx, input);
if (!map) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return map;
}),
}),
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, nationGenerals] = 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),
]);
return buildTurnCommandTable({
worldState,
general,
city,
nation,
nationGenerals,
});
}),
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 };
}),
}),
}),
messages: router({
getRecent: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
sequence: z.number().int().optional(),
})
)
.query(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 sequence = input.sequence ?? -1;
const nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] = await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
]);
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: diplomacyMessages,
};
let nextSequence = sequence;
let minSequence = sequence;
let lastType: MessageType | null = null;
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
for (const message of messages) {
if (message.id > nextSequence) {
nextSequence = message.id;
}
if (message.id <= minSequence) {
minSequence = message.id;
lastType = type;
}
}
};
updateSequence('private', privateMessages);
updateSequence('public', publicMessages);
updateSequence('national', nationalMessages);
updateSequence('diplomacy', diplomacyMessages);
if (lastType === 'private' && messageBuckets.private.length > 0) {
messageBuckets.private.pop();
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
messageBuckets.public.pop();
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
messageBuckets.national.pop();
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
messageBuckets.diplomacy.pop();
}
return {
result: true,
...messageBuckets,
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
latestRead: {
diplomacy: 0,
private: 0,
},
};
}),
getOld: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
to: z.number().int().positive(),
type: zMessageType,
})
)
.query(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 nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: [],
public: [],
national: [],
diplomacy: [],
};
const messages = await fetchOldMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes[input.type],
msgType: input.type,
toSeq: input.to,
limit: 15,
});
messageBuckets[input.type] = messages;
return {
result: true,
keepRecent: true,
sequence: 0,
nationId,
generalName: general.name,
...messageBuckets,
};
}),
send: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
text: z.string().min(1),
})
)
.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 src = await buildTargetFromGeneral(ctx.db, general);
const now = new Date();
const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType;
let dest = src;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
if (destNationId <= 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid nation mailbox.',
});
}
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
} else if (input.mailbox > 0) {
const destGeneral = await ctx.db.general.findUnique({
where: { id: input.mailbox },
});
if (!destGeneral) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Destination general not found.',
});
}
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
msgType = 'private';
} else {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid mailbox.',
});
}
const draft: MessageDraft = {
msgType,
src,
dest,
text: input.text,
time: now,
validUntil,
option: {},
};
const result = await sendMessage(
{
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
},
draft
);
return { msgType, msgId: result.receiverId };
}),
}),
troop: router({
join: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
troopId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
generalId: input.generalId,
troopId: input.troopId,
});
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
}
if (result.type !== 'troopJoin') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: result.reason,
});
}
return { ok: true };
}),
exit: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const result = await ctx.turnDaemon.requestCommand({
type: 'troopExit',
generalId: input.generalId,
});
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
}
if (result.type !== 'troopExit') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: result.reason,
});
}
return { ok: true, wasLeader: result.wasLeader };
}),
}),
general: router({
me: authedProcedure.query(async ({ 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,
},
});
if (!general) {
return null;
}
const [city, nation] = 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,
agriculture: true,
commerce: true,
security: true,
defence: true,
wall: 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,
]);
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,
},
city,
nation,
};
}),
dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'dieOnPrestart',
generalId: general.id,
});
if (!result || result.type !== 'dieOnPrestart') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
buildNationCandidate: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'buildNationCandidate',
generalId: general.id,
});
if (!result || result.type !== 'buildNationCandidate') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
instantRetreat: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'instantRetreat',
generalId: general.id,
});
if (!result || result.type !== 'instantRetreat') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
vacation: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'vacation',
generalId: general.id,
});
if (!result || result.type !== 'vacation') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
setMySetting: authedProcedure.input(zGeneralSettings).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'setMySetting',
generalId: general.id,
settings: input,
});
if (!result || result.type !== 'setMySetting') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'dropItem',
generalId: general.id,
itemType: input.itemType,
});
if (!result || result.type !== 'dropItem') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
}),
nation: router({
changePermission: authedProcedure
.input(
z.object({
isAmbassador: z.boolean(),
targetGeneralIds: z.array(z.number().int().positive()),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'changePermission',
generalId: general.id,
isAmbassador: input.isAmbassador,
targetGeneralIds: input.targetGeneralIds,
});
if (!result || result.type !== 'changePermission') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
kick: authedProcedure
.input(z.object({ destGeneralId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'kick',
generalId: general.id,
destGeneralId: input.destGeneralId,
});
if (!result || result.type !== 'kick') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
appoint: authedProcedure
.input(
z.object({
destGeneralId: z.number().int().nonnegative(),
destCityId: z.number().int().nonnegative(),
officerLevel: z.number().int().nonnegative(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'appoint',
generalId: general.id,
destGeneralId: input.destGeneralId,
destCityId: input.destCityId,
officerLevel: input.officerLevel,
});
if (!result || result.type !== 'appoint') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
}),
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;