331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
|
|
import { asRecord } from '@sammo-ts/common';
|
|
import type { TournamentType } from '@sammo-ts/logic';
|
|
|
|
import { TournamentStore } from '../../tournament/store.js';
|
|
import { buildTournamentKeys } from '../../tournament/keys.js';
|
|
import { authedProcedure, procedure, router } from '../../trpc.js';
|
|
|
|
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
|
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
|
return true;
|
|
}
|
|
return roles.some((role) => role === 'admin.tournament' || role === `admin.tournament:${profileName}`);
|
|
};
|
|
|
|
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
|
const roles = ctx.auth?.user.roles ?? [];
|
|
if (!hasAdminRole(roles, ctx.profile.name)) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
|
}
|
|
return next();
|
|
});
|
|
|
|
const zTournamentState = z.object({
|
|
stage: z.number().int().min(0),
|
|
phase: z.number().int().min(0),
|
|
type: z.number().int().min(0).max(3),
|
|
auto: z.boolean(),
|
|
openYear: z.number().int(),
|
|
openMonth: z.number().int(),
|
|
termSeconds: z.number().int().positive(),
|
|
nextAt: z.string().min(1),
|
|
bettingId: z.number().int().optional(),
|
|
bettingCloseAt: z.string().optional(),
|
|
winnerId: z.number().int().optional(),
|
|
bettingSettled: z.boolean().optional(),
|
|
rewardSettled: z.boolean().optional(),
|
|
lastError: z.string().optional(),
|
|
lastErrorAt: z.string().optional(),
|
|
});
|
|
|
|
const zParticipant = z.object({
|
|
id: z.number().int().positive(),
|
|
name: z.string().min(1),
|
|
leadership: z.number().int().min(0),
|
|
strength: z.number().int().min(0),
|
|
intel: z.number().int().min(0),
|
|
level: z.number().int().min(0),
|
|
});
|
|
|
|
const zMatch = z.object({
|
|
id: z.number().int().positive(),
|
|
stage: z.number().int().min(0),
|
|
roundIndex: z.number().int().min(0),
|
|
attackerId: z.number().int().positive(),
|
|
defenderId: z.number().int().positive(),
|
|
winnerId: z.number().int().positive().optional(),
|
|
log: z.array(z.string()).optional(),
|
|
logEntries: z
|
|
.array(
|
|
z.object({
|
|
phase: z.number().int().min(0),
|
|
attackerEnergy: z.number().int(),
|
|
defenderEnergy: z.number().int(),
|
|
attackerDamage: z.number().int(),
|
|
defenderDamage: z.number().int(),
|
|
text: z.string(),
|
|
})
|
|
)
|
|
.optional(),
|
|
lastEnergy: z
|
|
.object({
|
|
attacker: z.number().int(),
|
|
defender: z.number().int(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
const zBetEntry = z.object({
|
|
generalId: z.number().int().positive(),
|
|
targetId: z.number().int().positive(),
|
|
amount: z.number().int().positive(),
|
|
});
|
|
|
|
const zSeedParticipants = z.object({
|
|
generalIds: z.array(z.number().int().positive()).optional(),
|
|
limit: z.number().int().min(1).max(256).optional(),
|
|
includeNpc: z.boolean().optional(),
|
|
});
|
|
|
|
export const tournamentRouter = router({
|
|
getState: procedure.query(async ({ ctx }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
return store.getState();
|
|
}),
|
|
getSnapshot: procedure.query(async ({ ctx }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
const [state, participants, matches, bets] = await Promise.all([
|
|
store.getState(),
|
|
store.getParticipants(),
|
|
store.getMatches(),
|
|
store.getBettingEntries(),
|
|
]);
|
|
return { state, participants, matches, bets };
|
|
}),
|
|
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
await store.setState({
|
|
...input,
|
|
type: input.type as TournamentType,
|
|
});
|
|
return { ok: true };
|
|
}),
|
|
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
const current = await store.getState();
|
|
if (!current) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
|
}
|
|
const next = {
|
|
...current,
|
|
...input,
|
|
type: (input.type !== undefined ? input.type : current.type) as TournamentType,
|
|
};
|
|
await store.setState(next);
|
|
return { ok: true };
|
|
}),
|
|
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
await store.setParticipants(input);
|
|
return { ok: true, count: input.length };
|
|
}),
|
|
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
await store.setMatches(input);
|
|
return { ok: true, count: input.length };
|
|
}),
|
|
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
await store.setBettingEntries(input);
|
|
return { ok: true, count: input.length };
|
|
}),
|
|
seedParticipants: adminProcedure.input(zSeedParticipants).mutation(async ({ ctx, input }) => {
|
|
const limit = input.limit ?? 64;
|
|
const includeNpc = input.includeNpc ?? true;
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
|
|
const generals = input.generalIds && input.generalIds.length > 0
|
|
? await ctx.db.general.findMany({
|
|
where: { id: { in: input.generalIds } },
|
|
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
|
})
|
|
: await ctx.db.general.findMany({
|
|
where: includeNpc ? {} : { npcState: 0 },
|
|
orderBy: [
|
|
{ leadership: 'desc' },
|
|
{ strength: 'desc' },
|
|
{ intel: 'desc' },
|
|
{ id: 'asc' },
|
|
],
|
|
take: limit,
|
|
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
|
});
|
|
|
|
const participants = generals.map((general) => {
|
|
const meta = asRecord(general.meta);
|
|
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
|
return {
|
|
id: general.id,
|
|
name: general.name,
|
|
leadership: general.leadership,
|
|
strength: general.strength,
|
|
intel: general.intel,
|
|
level,
|
|
};
|
|
});
|
|
|
|
await store.setParticipants(participants);
|
|
return { ok: true, count: participants.length };
|
|
}),
|
|
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
const [state, entries, matches] = await Promise.all([
|
|
store.getState(),
|
|
store.getBettingEntries(),
|
|
store.getMatches(),
|
|
]);
|
|
|
|
if (!state || state.stage < 5) {
|
|
return { state, totals: {}, myTotals: {}, totalAmount: 0, myAmount: 0 };
|
|
}
|
|
|
|
const candidateIds = new Set<number>();
|
|
for (const match of matches) {
|
|
if (match.stage === 7) {
|
|
candidateIds.add(match.attackerId);
|
|
candidateIds.add(match.defenderId);
|
|
}
|
|
}
|
|
|
|
const totals: Record<number, number> = {};
|
|
const myTotals: Record<number, number> = {};
|
|
let totalAmount = 0;
|
|
let myAmount = 0;
|
|
const userId = ctx.auth?.user.id;
|
|
const general = userId
|
|
? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } })
|
|
: null;
|
|
|
|
for (const entry of entries) {
|
|
if (!candidateIds.has(entry.targetId)) {
|
|
continue;
|
|
}
|
|
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
|
totalAmount += entry.amount;
|
|
if (general && entry.generalId === general.id) {
|
|
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
|
myAmount += entry.amount;
|
|
}
|
|
}
|
|
|
|
return { state, totals, myTotals, totalAmount, myAmount };
|
|
}),
|
|
join: authedProcedure.mutation(async ({ ctx }) => {
|
|
const userId = ctx.auth?.user.id;
|
|
if (!userId) {
|
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
}
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
const state = await store.getState();
|
|
if (!state || state.stage !== 1) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
|
}
|
|
|
|
const participants = await store.getParticipants();
|
|
|
|
const general = await ctx.db.general.findFirst({
|
|
where: { userId },
|
|
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
|
});
|
|
if (!general) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
|
|
}
|
|
|
|
const already = participants.find((entry) => entry.id === general.id);
|
|
if (already) {
|
|
return { ok: true, count: participants.length };
|
|
}
|
|
|
|
if (participants.length >= 64) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
|
|
}
|
|
|
|
const meta = asRecord(general.meta);
|
|
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
|
const next = participants.concat({
|
|
id: general.id,
|
|
name: general.name,
|
|
leadership: general.leadership,
|
|
strength: general.strength,
|
|
intel: general.intel,
|
|
level,
|
|
});
|
|
|
|
await store.setParticipants(next);
|
|
return { ok: true, count: next.length };
|
|
}),
|
|
placeBet: authedProcedure
|
|
.input(
|
|
z.object({
|
|
targetId: z.number().int().positive(),
|
|
amount: z.number().int().positive(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.auth?.user.id;
|
|
if (!userId) {
|
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
}
|
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
const state = await store.getState();
|
|
if (!state || state.stage !== 6) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
|
}
|
|
const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0;
|
|
if (closeAt && closeAt <= Date.now()) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' });
|
|
}
|
|
|
|
const matches = await store.getMatches();
|
|
const candidateIds = new Set<number>();
|
|
for (const match of matches) {
|
|
if (match.stage === 7) {
|
|
candidateIds.add(match.attackerId);
|
|
candidateIds.add(match.defenderId);
|
|
}
|
|
}
|
|
if (!candidateIds.has(input.targetId)) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
|
}
|
|
|
|
const general = await ctx.db.general.findFirst({
|
|
where: { userId },
|
|
select: { id: true, gold: true },
|
|
});
|
|
if (!general) {
|
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
}
|
|
|
|
const minRemainGold = 500;
|
|
if (general.gold - input.amount < minRemainGold) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' });
|
|
}
|
|
|
|
await ctx.db.general.update({
|
|
where: { id: general.id },
|
|
data: { gold: general.gold - input.amount },
|
|
});
|
|
|
|
await store.appendBettingEntry({
|
|
generalId: general.id,
|
|
targetId: input.targetId,
|
|
amount: input.amount,
|
|
});
|
|
|
|
return { ok: true };
|
|
}),
|
|
});
|