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.
This commit is contained in:
@@ -48,6 +48,23 @@ export interface GeneralRow {
|
||||
meta: unknown;
|
||||
}
|
||||
|
||||
export interface GeneralTurnRow {
|
||||
id: number;
|
||||
generalId: number;
|
||||
turnIdx: number;
|
||||
actionCode: string;
|
||||
arg: unknown;
|
||||
}
|
||||
|
||||
export interface NationTurnRow {
|
||||
id: number;
|
||||
nationId: number;
|
||||
officerLevel: number;
|
||||
turnIdx: number;
|
||||
actionCode: string;
|
||||
arg: unknown;
|
||||
}
|
||||
|
||||
export interface CityRow {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -95,6 +112,39 @@ export interface DatabaseClient {
|
||||
nation: {
|
||||
findUnique(args: { where: { id: number } }): Promise<NationRow | null>;
|
||||
};
|
||||
generalTurn: {
|
||||
findMany(args: {
|
||||
where: { generalId: number };
|
||||
orderBy?: { turnIdx: 'asc' | 'desc' }[];
|
||||
}): Promise<GeneralTurnRow[]>;
|
||||
deleteMany(args: { where: { generalId: number } }): Promise<unknown>;
|
||||
createMany(args: {
|
||||
data: Array<{
|
||||
generalId: number;
|
||||
turnIdx: number;
|
||||
actionCode: string;
|
||||
arg: unknown;
|
||||
}>;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
nationTurn: {
|
||||
findMany(args: {
|
||||
where: { nationId: number; officerLevel: number };
|
||||
orderBy?: { turnIdx: 'asc' | 'desc' }[];
|
||||
}): Promise<NationTurnRow[]>;
|
||||
deleteMany(args: {
|
||||
where: { nationId: number; officerLevel: number };
|
||||
}): Promise<unknown>;
|
||||
createMany(args: {
|
||||
data: Array<{
|
||||
nationId: number;
|
||||
officerLevel: number;
|
||||
turnIdx: number;
|
||||
actionCode: string;
|
||||
arg: unknown;
|
||||
}>;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GameApiContext {
|
||||
|
||||
@@ -4,6 +4,14 @@ 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']);
|
||||
|
||||
@@ -13,6 +21,15 @@ const zTurnRunBudget = z.object({
|
||||
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,
|
||||
@@ -84,6 +101,148 @@ export const appRouter = router({
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||
import { createGameApiContext } from './context.js';
|
||||
import { createGameApiContext, type DatabaseClient } from './context.js';
|
||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||
@@ -75,7 +75,7 @@ export const createGameApiServer = async () => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = token ? tokenVerifier.verify(token) : null;
|
||||
return createGameApiContext({
|
||||
db: postgres.prisma,
|
||||
db: postgres.prisma as unknown as DatabaseClient,
|
||||
turnDaemon,
|
||||
profile: {
|
||||
id: config.profile,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
DatabaseClient,
|
||||
GeneralTurnRow,
|
||||
NationTurnRow,
|
||||
} from '../context.js';
|
||||
|
||||
export const DEFAULT_TURN_ACTION = '휴식';
|
||||
export const MAX_GENERAL_TURNS = 30;
|
||||
export const MAX_NATION_TURNS = 12;
|
||||
|
||||
export interface ReservedTurnEntry {
|
||||
action: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReservedTurnView {
|
||||
index: number;
|
||||
action: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const normalizeAction = (action: string | null | undefined): string =>
|
||||
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
|
||||
|
||||
const normalizeArgs = (args: unknown): Record<string, unknown> =>
|
||||
isRecord(args) ? args : {};
|
||||
|
||||
const createDefaultEntry = (): ReservedTurnEntry => ({
|
||||
action: DEFAULT_TURN_ACTION,
|
||||
args: {},
|
||||
});
|
||||
|
||||
const buildDefaultTurns = (length: number): ReservedTurnEntry[] =>
|
||||
Array.from({ length }, () => createDefaultEntry());
|
||||
|
||||
const applyShift = (
|
||||
turns: ReservedTurnEntry[],
|
||||
amount: number
|
||||
): ReservedTurnEntry[] => {
|
||||
if (amount === 0) {
|
||||
return turns.slice();
|
||||
}
|
||||
if (amount > 0) {
|
||||
const padding = Array.from({ length: amount }, () => createDefaultEntry());
|
||||
const sliced = turns.slice(0, Math.max(0, turns.length - amount));
|
||||
return padding.concat(sliced);
|
||||
}
|
||||
const shift = Math.min(turns.length, Math.abs(amount));
|
||||
const padding = Array.from({ length: shift }, () => createDefaultEntry());
|
||||
const sliced = turns.slice(shift);
|
||||
return sliced.concat(padding);
|
||||
};
|
||||
|
||||
const buildTurnListFromRows = (
|
||||
rows: Array<GeneralTurnRow | NationTurnRow>,
|
||||
maxTurns: number
|
||||
): ReservedTurnEntry[] => {
|
||||
const result = buildDefaultTurns(maxTurns);
|
||||
for (const row of rows) {
|
||||
if (row.turnIdx < 0 || row.turnIdx >= maxTurns) {
|
||||
continue;
|
||||
}
|
||||
result[row.turnIdx] = {
|
||||
action: normalizeAction(row.actionCode),
|
||||
args: normalizeArgs(row.arg),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const serializeTurnList = (turns: ReservedTurnEntry[]): ReservedTurnView[] =>
|
||||
turns.map((entry, index) => ({
|
||||
index,
|
||||
action: entry.action,
|
||||
args: entry.args,
|
||||
}));
|
||||
|
||||
const persistGeneralTurns = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number,
|
||||
turns: ReservedTurnEntry[]
|
||||
): Promise<void> => {
|
||||
await db.generalTurn.deleteMany({ where: { generalId } });
|
||||
await db.generalTurn.createMany({
|
||||
data: turns.map((entry, turnIdx) => ({
|
||||
generalId,
|
||||
turnIdx,
|
||||
actionCode: normalizeAction(entry.action),
|
||||
arg: normalizeArgs(entry.args),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const persistNationTurns = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number,
|
||||
turns: ReservedTurnEntry[]
|
||||
): Promise<void> => {
|
||||
await db.nationTurn.deleteMany({ where: { nationId, officerLevel } });
|
||||
await db.nationTurn.createMany({
|
||||
data: turns.map((entry, turnIdx) => ({
|
||||
nationId,
|
||||
officerLevel,
|
||||
turnIdx,
|
||||
actionCode: normalizeAction(entry.action),
|
||||
arg: normalizeArgs(entry.args),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
export const loadGeneralTurns = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number
|
||||
): Promise<ReservedTurnEntry[]> => {
|
||||
const rows = await db.generalTurn.findMany({
|
||||
where: { generalId },
|
||||
orderBy: [{ turnIdx: 'asc' }],
|
||||
});
|
||||
return buildTurnListFromRows(rows, MAX_GENERAL_TURNS);
|
||||
};
|
||||
|
||||
export const loadNationTurns = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number
|
||||
): Promise<ReservedTurnEntry[]> => {
|
||||
const rows = await db.nationTurn.findMany({
|
||||
where: { nationId, officerLevel },
|
||||
orderBy: [{ turnIdx: 'asc' }],
|
||||
});
|
||||
return buildTurnListFromRows(rows, MAX_NATION_TURNS);
|
||||
};
|
||||
|
||||
export const setGeneralTurn = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number,
|
||||
turnIndex: number,
|
||||
action: string,
|
||||
args: unknown
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
const turns = await loadGeneralTurns(db, generalId);
|
||||
turns[turnIndex] = {
|
||||
action: normalizeAction(action),
|
||||
args: normalizeArgs(args),
|
||||
};
|
||||
await persistGeneralTurns(db, generalId, turns);
|
||||
return serializeTurnList(turns);
|
||||
};
|
||||
|
||||
export const shiftGeneralTurns = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number,
|
||||
amount: number
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
const turns = await loadGeneralTurns(db, generalId);
|
||||
const shifted = applyShift(turns, amount);
|
||||
await persistGeneralTurns(db, generalId, shifted);
|
||||
return serializeTurnList(shifted);
|
||||
};
|
||||
|
||||
export const setNationTurn = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number,
|
||||
turnIndex: number,
|
||||
action: string,
|
||||
args: unknown
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||
turns[turnIndex] = {
|
||||
action: normalizeAction(action),
|
||||
args: normalizeArgs(args),
|
||||
};
|
||||
await persistNationTurns(db, nationId, officerLevel, turns);
|
||||
return serializeTurnList(turns);
|
||||
};
|
||||
|
||||
export const shiftNationTurns = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number,
|
||||
amount: number
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||
const shifted = applyShift(turns, amount);
|
||||
await persistNationTurns(db, nationId, officerLevel, shifted);
|
||||
return serializeTurnList(shifted);
|
||||
};
|
||||
Reference in New Issue
Block a user