764 lines
30 KiB
TypeScript
764 lines
30 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
import { loadActionModuleBundle } from '@sammo-ts/logic';
|
|
import { asRecord } from '@sammo-ts/common';
|
|
import { GamePrisma } from '@sammo-ts/infra';
|
|
|
|
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
|
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
|
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
|
|
import {
|
|
buildRecruitmentCommandInfo,
|
|
buildTurnCommandTable,
|
|
evaluateReservedTurnPermission,
|
|
} from '../../turns/commandTable.js';
|
|
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
|
import {
|
|
assertReservedTurnActionAvailable,
|
|
assertReservedTurnArgsPassLegacyBasicValidation,
|
|
buildEquipmentTradeItemOptions,
|
|
parseReservedTurnArgs,
|
|
TURN_COMMAND_NATION_COLORS,
|
|
type TurnCommandInputOptions,
|
|
} from '../../turns/commandInput.js';
|
|
import {
|
|
MAX_GENERAL_TURNS,
|
|
MAX_NATION_TURNS,
|
|
ReservedTurnRevisionConflictError,
|
|
type ReservedTurnUpdate,
|
|
expandGeneralTurnIndices,
|
|
getGeneralTurnSnapshot,
|
|
getNationTurnSnapshot,
|
|
repeatGeneralTurns,
|
|
repeatNationTurns,
|
|
setGeneralTurn,
|
|
setGeneralTurns,
|
|
setNationTurnAtCurrentPosition,
|
|
setNationTurnsAtCurrentPositions,
|
|
shiftGeneralTurns,
|
|
shiftNationTurns,
|
|
} from '../../turns/reservedTurns.js';
|
|
import { getOwnedGeneral } from '../shared/general.js';
|
|
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
|
|
import {
|
|
buildRefAmountPresets,
|
|
buildRefGeneralTargetOptions,
|
|
buildRefNationTargetOptions,
|
|
} from '../../turns/commandTargets.js';
|
|
|
|
const zPushAmount = z
|
|
.number()
|
|
.int()
|
|
.min(-12)
|
|
.max(12)
|
|
.refine((value) => value !== 0, {
|
|
message: 'Amount must be non-zero.',
|
|
});
|
|
|
|
const zRepeatAmount = z.number().int().min(1).max(12);
|
|
|
|
const buildTurnListSchema = (minimum: number, maximum: number) =>
|
|
z.array(z.number().int().min(minimum).max(maximum)).min(1);
|
|
|
|
const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
|
z.object({
|
|
turnList,
|
|
action: z.string().min(1),
|
|
args: z.unknown().optional(),
|
|
});
|
|
|
|
const parseCommandArgs = async (
|
|
scope: 'general' | 'nation',
|
|
action: string,
|
|
args: unknown,
|
|
worldState: WorldStateRow
|
|
) => {
|
|
try {
|
|
// 사용자 입력은 action별 argument schema보다 먼저 현재 scenario의
|
|
// 선택 가능 profile을 통과해야 한다. 내부 전용 명령의 parser를 외부
|
|
// 요청이 직접 호출하지 못하게 하는 첫 경계다.
|
|
return await parseReservedTurnArgs(scope, action, args, asRecord(worldState.config).const);
|
|
} catch (error) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: error instanceof Error ? error.message : 'Invalid turn command arguments.',
|
|
cause: error,
|
|
});
|
|
}
|
|
};
|
|
|
|
const preflightCommandArgs = async (
|
|
scope: 'general' | 'nation',
|
|
action: string,
|
|
args: unknown,
|
|
worldState: WorldStateRow
|
|
): Promise<void> => {
|
|
try {
|
|
// Ref checks scenario availability and common argument types before
|
|
// officer/penalty gates. Core keeps actor ownership first, then leaves
|
|
// required command-specific fields for the later parser.
|
|
await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const);
|
|
assertReservedTurnArgsPassLegacyBasicValidation(args);
|
|
} catch (error) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: error instanceof Error ? error.message : 'Invalid turn command arguments.',
|
|
cause: error,
|
|
});
|
|
}
|
|
};
|
|
|
|
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
|
|
try {
|
|
return await mutation();
|
|
} catch (error) {
|
|
if (error instanceof ReservedTurnRevisionConflictError) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'Reserved turn queue changed. Reload and retry.',
|
|
cause: error,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const getReservationWorldState = async (ctx: GameApiContext): Promise<WorldStateRow> => {
|
|
const worldState = await ctx.db.worldState.findFirst();
|
|
if (!worldState) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'World state is not initialized.',
|
|
});
|
|
}
|
|
return worldState;
|
|
};
|
|
|
|
const resolveMapName = (worldState: WorldStateRow, fallback: string): string => {
|
|
const config = asRecord(worldState.config);
|
|
const environment = asRecord(config.environment ?? config.map);
|
|
const mapName = environment.mapName;
|
|
return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback;
|
|
};
|
|
|
|
const plainLegacyInfo = (value: string): string =>
|
|
value
|
|
.replace(/<br\s*\/?>/giu, ' · ')
|
|
.replace(/<[^>]+>/gu, '')
|
|
.replace(/\s+/gu, ' ')
|
|
.trim();
|
|
|
|
const readGeneralMetaNumber = (meta: unknown, key: string): number | null => {
|
|
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
|
|
const value = (meta as Record<string, unknown>)[key];
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const assertNationTurnInputAllowed = (general: GeneralRow): void => {
|
|
if (!Object.prototype.hasOwnProperty.call(asRecord(general.penalty), 'noChiefTurnInput')) {
|
|
return;
|
|
}
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: '수뇌 턴 입력 불가능',
|
|
});
|
|
};
|
|
|
|
const refillNationTurnInputKillturn = async (
|
|
ctx: GameApiContext,
|
|
general: GeneralRow,
|
|
worldState: WorldStateRow
|
|
): Promise<boolean> => {
|
|
if (general.npcState >= 2) {
|
|
return false;
|
|
}
|
|
|
|
const worldKillturn = readGeneralMetaNumber(worldState.meta, 'killturn');
|
|
if (worldKillturn === null) {
|
|
return false;
|
|
}
|
|
const currentKillturn = readGeneralMetaNumber(general.meta, 'killturn');
|
|
const nextKillturn = Math.max(currentKillturn ?? 0, worldKillturn);
|
|
if (currentKillturn !== null && nextKillturn === currentKillturn) {
|
|
return false;
|
|
}
|
|
|
|
const updated = await ctx.db.$queryRaw<Array<{ id: number }>>(
|
|
GamePrisma.sql`
|
|
UPDATE general
|
|
SET meta = jsonb_set(
|
|
CASE
|
|
WHEN jsonb_typeof(meta) = 'object' THEN meta
|
|
ELSE '{}'::jsonb
|
|
END,
|
|
'{killturn}',
|
|
to_jsonb(
|
|
GREATEST(
|
|
CASE
|
|
WHEN jsonb_typeof(meta->'killturn') = 'number'
|
|
THEN (meta->>'killturn')::double precision
|
|
ELSE 0::double precision
|
|
END,
|
|
${nextKillturn}::double precision
|
|
)
|
|
),
|
|
true
|
|
)
|
|
WHERE id = ${general.id}
|
|
AND npc_state < 2
|
|
AND (
|
|
jsonb_typeof(meta->'killturn') IS DISTINCT FROM 'number'
|
|
OR (meta->>'killturn')::double precision < ${nextKillturn}
|
|
)
|
|
RETURNING id
|
|
`
|
|
);
|
|
return updated.length > 0;
|
|
};
|
|
|
|
const assertReservedTurnPermission = async (
|
|
worldState: WorldStateRow,
|
|
general: GeneralRow,
|
|
scope: 'general' | 'nation',
|
|
action: string,
|
|
args: Record<string, unknown>
|
|
): Promise<void> => {
|
|
const result = await evaluateReservedTurnPermission({
|
|
worldState,
|
|
general,
|
|
scope,
|
|
action,
|
|
args,
|
|
});
|
|
if (result.kind === 'allow') {
|
|
return;
|
|
}
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message:
|
|
result.kind === 'deny' ? `예약 불가능한 커맨드 :${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 environmentPromise = buildBattleSimEnvironment(worldState, ctx.profile.id);
|
|
const moduleBundlePromise = environmentPromise.then((environment) =>
|
|
loadActionModuleBundle(environment.unitSet, environment.scenarioEffect)
|
|
);
|
|
const [
|
|
city,
|
|
nation,
|
|
nationGenerals,
|
|
cities,
|
|
nations,
|
|
generals,
|
|
diplomacy,
|
|
troops,
|
|
environment,
|
|
traits,
|
|
moduleBundle,
|
|
map,
|
|
] = 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({ orderBy: { id: 'asc' } }),
|
|
ctx.db.nation.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
color: true,
|
|
capitalCityId: true,
|
|
level: true,
|
|
meta: true,
|
|
},
|
|
orderBy: { id: 'asc' },
|
|
}),
|
|
ctx.db.general.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
nationId: true,
|
|
cityId: true,
|
|
npcState: true,
|
|
officerLevel: true,
|
|
gold: true,
|
|
rice: true,
|
|
crew: true,
|
|
train: true,
|
|
atmos: true,
|
|
troopId: true,
|
|
},
|
|
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }],
|
|
}),
|
|
general.nationId > 0
|
|
? ctx.db.diplomacy.findMany({ where: { srcNationId: general.nationId } })
|
|
: Promise.resolve([]),
|
|
general.nationId > 0
|
|
? ctx.db.troop.findMany({ where: { nationId: general.nationId }, orderBy: { troopLeaderId: 'asc' } })
|
|
: Promise.resolve([]),
|
|
environmentPromise,
|
|
loadBattleSimTraitOptions(),
|
|
moduleBundlePromise,
|
|
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
|
]);
|
|
|
|
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
|
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
|
const generalCountByNation = new Map<number, number>();
|
|
for (const entry of generals) {
|
|
generalCountByNation.set(entry.nationId, (generalCountByNation.get(entry.nationId) ?? 0) + 1);
|
|
}
|
|
const cityCountByNation = new Map<number, number>();
|
|
for (const entry of cities) {
|
|
cityCountByNation.set(entry.nationId, (cityCountByNation.get(entry.nationId) ?? 0) + 1);
|
|
}
|
|
const diplomacyByNation = new Map(diplomacy.map((entry) => [entry.destNationId, entry]));
|
|
const adjacentNationIds = new Set<number>();
|
|
const actorCityIds = new Set(
|
|
cities.filter((entry) => entry.nationId === general.nationId).map((entry) => entry.id)
|
|
);
|
|
for (const mapCity of map.cities) {
|
|
if (!actorCityIds.has(mapCity.id)) continue;
|
|
for (const adjacentId of mapCity.connections) {
|
|
const adjacentNationId = cityById.get(adjacentId)?.nationId;
|
|
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
|
|
}
|
|
}
|
|
const nationTargetOptions = buildRefNationTargetOptions({
|
|
actorNationId: general.nationId,
|
|
nations: nations.map((entry) => {
|
|
const relation = diplomacyByNation.get(entry.id);
|
|
return {
|
|
id: entry.id,
|
|
name: entry.name,
|
|
color: entry.color,
|
|
capitalName: entry.capitalCityId ? (cityById.get(entry.capitalCityId)?.name ?? '-') : '-',
|
|
level: entry.level,
|
|
power: readGeneralMetaNumber(entry.meta, 'power') ?? 0,
|
|
generalCount: generalCountByNation.get(entry.id) ?? 0,
|
|
cityCount: cityCountByNation.get(entry.id) ?? 0,
|
|
diplomacyState: relation?.stateCode ?? 2,
|
|
diplomacyTerm: relation?.term ?? 0,
|
|
adjacent: adjacentNationIds.has(entry.id),
|
|
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
|
|
};
|
|
}),
|
|
});
|
|
const generalTargetOptions = buildRefGeneralTargetOptions({
|
|
actorId: general.id,
|
|
actorNationId: general.nationId,
|
|
generals,
|
|
nationNames: new Map(nations.map((entry) => [entry.id, entry.name])),
|
|
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
|
|
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
|
});
|
|
const items = buildEquipmentTradeItemOptions({
|
|
configConst: asRecord(asRecord(worldState.config).const),
|
|
itemModules: moduleBundle.itemModules,
|
|
currentSecurity: city?.security ?? 0,
|
|
generalGold: general.gold,
|
|
});
|
|
const inputOptions: TurnCommandInputOptions = {
|
|
cities: cities.map((entry) => ({
|
|
value: entry.id,
|
|
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
|
})),
|
|
nations: nationTargetOptions.nations,
|
|
nationTargets: nationTargetOptions.nationTargets,
|
|
generals: generalTargetOptions.generals,
|
|
generalTargets: generalTargetOptions.generalTargets,
|
|
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]) => {
|
|
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
|
return {
|
|
value: Number(value),
|
|
label,
|
|
...(dexterity === null ? {} : { description: `현재 숙련 ${dexterity.toLocaleString()}` }),
|
|
};
|
|
}),
|
|
nationTypes: traits.nationTypes.map((entry) => ({
|
|
value: entry.key,
|
|
label: entry.name,
|
|
description: plainLegacyInfo(entry.info),
|
|
})),
|
|
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
|
value: index,
|
|
label: `색상 ${index + 1}`,
|
|
color,
|
|
})),
|
|
items,
|
|
recruitment: buildRecruitmentCommandInfo({
|
|
worldState,
|
|
general,
|
|
city,
|
|
nation,
|
|
cities,
|
|
map,
|
|
unitSet: environment.unitSet,
|
|
generalActionModules: moduleBundle.general,
|
|
}),
|
|
amountPresets: buildRefAmountPresets(
|
|
nation?.level ?? 0,
|
|
readGeneralMetaNumber(asRecord(asRecord(worldState.config).const), 'maxResourceActionAmount') ?? 10_000
|
|
),
|
|
context: {
|
|
actorGold: general.gold,
|
|
actorRice: general.rice,
|
|
...(city ? { citySecurity: city.security } : {}),
|
|
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
|
},
|
|
};
|
|
|
|
return buildTurnCommandTable({
|
|
worldState,
|
|
general,
|
|
city,
|
|
nation,
|
|
nationGenerals,
|
|
realNationCount: nations.filter((entry) => entry.id > 0).length,
|
|
inputOptions,
|
|
generalActionModules: moduleBundle.general,
|
|
});
|
|
};
|
|
|
|
export const turnsRouter = router({
|
|
getCommandTable: accessAuthedInputProcedure(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
})
|
|
).query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
|
|
reserved: router({
|
|
getGeneral: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
await getOwnedGeneral(ctx, input.generalId);
|
|
|
|
return getGeneralTurnSnapshot(ctx.db, input.generalId);
|
|
}),
|
|
getNation: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
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.',
|
|
});
|
|
}
|
|
|
|
return getNationTurnSnapshot(ctx.db, general.nationId, general.officerLevel);
|
|
}),
|
|
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(),
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
const worldState = await getReservationWorldState(ctx);
|
|
await preflightCommandArgs('general', input.action, input.args, worldState);
|
|
const args = await parseCommandArgs('general', input.action, input.args, worldState);
|
|
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
|
|
|
|
const snapshot = await mutateReservedTurns(() =>
|
|
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
|
);
|
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
|
ctx.changeJournal?.mark('dashboard.global');
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
shiftGeneral: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
amount: zPushAmount,
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
await getOwnedGeneral(ctx, input.generalId);
|
|
|
|
const snapshot = await mutateReservedTurns(() =>
|
|
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
|
);
|
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
|
ctx.changeJournal?.mark('dashboard.global');
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
repeatGeneral: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
amount: zRepeatAmount,
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
await getOwnedGeneral(ctx, input.generalId);
|
|
const snapshot = await mutateReservedTurns(() =>
|
|
repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
|
);
|
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
|
ctx.changeJournal?.mark('dashboard.global');
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
setGeneralBulk: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
entries: z.array(buildBulkEntrySchema(buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1))).min(1),
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
const worldState = await getReservationWorldState(ctx);
|
|
const firstEntry = input.entries[0];
|
|
await preflightCommandArgs('general', firstEntry.action, firstEntry.args, worldState);
|
|
const updates: ReservedTurnUpdate[] = [];
|
|
for (const [index, entry] of input.entries.entries()) {
|
|
if (index > 0) {
|
|
await preflightCommandArgs('general', entry.action, entry.args, worldState);
|
|
}
|
|
const update = {
|
|
turnIndices: expandGeneralTurnIndices(entry.turnList),
|
|
action: entry.action,
|
|
args: await parseCommandArgs('general', entry.action, entry.args, worldState),
|
|
};
|
|
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
|
updates.push(update);
|
|
}
|
|
const snapshot = await mutateReservedTurns(() =>
|
|
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
|
);
|
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
|
ctx.changeJournal?.mark('dashboard.global');
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
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(),
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
const worldState = await getReservationWorldState(ctx);
|
|
await preflightCommandArgs('nation', input.action, input.args, worldState);
|
|
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.',
|
|
});
|
|
}
|
|
assertNationTurnInputAllowed(general);
|
|
const args = await parseCommandArgs('nation', input.action, input.args, worldState);
|
|
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
|
|
|
const snapshot = await mutateReservedTurns(() =>
|
|
setNationTurnAtCurrentPosition(
|
|
ctx.db,
|
|
general.nationId,
|
|
general.officerLevel,
|
|
input.turnIndex,
|
|
input.action,
|
|
args,
|
|
input.expectedRevision
|
|
)
|
|
);
|
|
if (await refillNationTurnInputKillturn(ctx, general, worldState)) {
|
|
ctx.changeJournal?.mark('general.content', general.id);
|
|
ctx.changeJournal?.mark('dashboard.global');
|
|
}
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
shiftNation: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
amount: zPushAmount,
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
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 snapshot = await mutateReservedTurns(() =>
|
|
shiftNationTurns(
|
|
ctx.db,
|
|
general.nationId,
|
|
general.officerLevel,
|
|
input.amount,
|
|
input.expectedRevision
|
|
)
|
|
);
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
repeatNation: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
amount: zRepeatAmount,
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
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 snapshot = await mutateReservedTurns(() =>
|
|
repeatNationTurns(
|
|
ctx.db,
|
|
general.nationId,
|
|
general.officerLevel,
|
|
input.amount,
|
|
input.expectedRevision
|
|
)
|
|
);
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
setNationBulk: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
entries: z.array(buildBulkEntrySchema(buildTurnListSchema(0, MAX_NATION_TURNS - 1))).min(1),
|
|
expectedRevision: z.number().int().nonnegative(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
const worldState = await getReservationWorldState(ctx);
|
|
const firstEntry = input.entries[0];
|
|
await preflightCommandArgs('nation', firstEntry.action, firstEntry.args, worldState);
|
|
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 updates: ReservedTurnUpdate[] = [];
|
|
for (const [index, entry] of input.entries.entries()) {
|
|
if (index > 0) {
|
|
await preflightCommandArgs('nation', entry.action, entry.args, worldState);
|
|
}
|
|
assertNationTurnInputAllowed(general);
|
|
const update = {
|
|
turnIndices: entry.turnList,
|
|
action: entry.action,
|
|
args: await parseCommandArgs('nation', entry.action, entry.args, worldState),
|
|
};
|
|
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
|
updates.push(update);
|
|
}
|
|
const snapshot = await mutateReservedTurns(() =>
|
|
setNationTurnsAtCurrentPositions(
|
|
ctx.db,
|
|
general.nationId,
|
|
general.officerLevel,
|
|
updates,
|
|
input.expectedRevision
|
|
)
|
|
);
|
|
if (await refillNationTurnInputKillturn(ctx, general, worldState)) {
|
|
ctx.changeJournal?.mark('general.content', general.id);
|
|
ctx.changeJournal?.mark('dashboard.global');
|
|
}
|
|
return { ok: true, ...snapshot };
|
|
}),
|
|
}),
|
|
});
|