Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s

This commit is contained in:
2026-10-18 11:44:04 +05:00
parent ad73132f15
commit a7085e4e80
50 changed files with 4790 additions and 703 deletions

188
lib/tournament-api.ts Normal file
View File

@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/shared/database";
import {
tournamentEdit,
tournamentSettings,
type TournamentView,
} from "@/shared/tournaments";
import {
datasetSchema,
faces,
panoramaPath,
viewpointSchema,
} from "@/shared/protocol";
import { roomGuessSchema, roomRoundSchema } from "@/shared/multiplayer";
import { ServiceError, checkServiceError } from "./service-error";
type Dependencies = {
db: SupabaseClient<Database>;
account: () => Promise<{ id: string } | null>;
jsonBody: (request: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
mediaUrls: (paths: (string | null)[]) => Promise<(string | null)[]>;
announce: (target?: string) => void;
serverTimezone: string;
};
function unwrap<T>(result: {
data: T;
error: { message: string; code?: string } | null;
}): NonNullable<T> {
checkServiceError(result.error);
if (result.data == null) throw new ServiceError("Tournament not found", 404);
return result.data;
}
export async function tournamentApi(
request: NextRequest,
path: string[],
deps: Dependencies,
) {
const { db } = deps;
const respond = (value: unknown) =>
NextResponse.json(value, { headers: { "Cache-Control": "no-store" } });
if (path.length === 1 && request.method === "GET") {
const tournaments = unwrap(
await db
.from("tournaments")
.select(
"id,name,owner_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,status,announce_discord,finished_at,winner_id",
)
.order("starts_at", { ascending: false })
.limit(100),
);
return respond({
tournaments,
serverNow: Date.now(),
serverTimezone: deps.serverTimezone,
});
}
if (path.length === 1 && request.method === "POST") {
const account = await deps.account();
if (!account) throw new ServiceError("Sign in to create a tournament", 401);
const body = tournamentSettings.parse(await deps.jsonBody(request));
const start = Date.parse(body.startsAt),
now = Date.now();
if (start <= now || start > now + 90 * 86400000)
throw new ServiceError(
"Schedule the tournament within the next 90 days",
400,
);
await deps.limit("tournaments:create:" + account.id, 5, 86400);
const id = unwrap(
await db.rpc("create_tournament", {
p_owner: account.id,
p_payload: { ...body, serverTimezone: deps.serverTimezone },
}),
);
deps.announce(String(id));
return respond({ id });
}
if (path.length < 2 || path.length > 3)
throw new ServiceError("Not found", 404);
const id = z.string().uuid().parse(path[1]);
const action = path[2] ?? "view";
const read =
request.method === "GET" && (action === "view" || action === "media");
const write =
request.method === "POST" &&
[
"join",
"leave",
"cancel",
"edit",
"start",
"ready",
"guess",
"next",
].includes(action);
if (!read && !write) throw new ServiceError("Not found", 404);
const account = await deps.account();
if (!account && (write || action === "media"))
throw new ServiceError("Sign in to join or play a tournament", 401);
let payload = {};
if (write) {
const body = await deps.jsonBody(request);
payload =
action === "guess"
? roomGuessSchema
.extend({ tournamentRound: z.number().int().min(0).max(9) })
.strict()
.parse(body)
: ["ready", "next"].includes(action)
? roomRoundSchema
.extend({ tournamentRound: z.number().int().min(0).max(9) })
.strict()
.parse(body)
: action === "edit"
? tournamentEdit.parse(body)
: z.object({}).strict().parse(body);
await deps.limit(
`tournaments:${action}:${account!.id}`,
action === "guess" || action === "ready" ? 180 : 30,
60,
);
}
const view = unwrap(
await db.rpc("tournament_action", {
p_tournament: id,
p_account: account?.id ?? null,
p_action: read ? "view" : action,
p_payload: payload,
}),
) as unknown as TournamentView;
if (write && action === "edit") deps.announce(id);
if (action !== "media") return respond(view);
const requestedRound = Number(
z
.string()
.regex(/^[0-9]$/)
.parse(request.nextUrl.searchParams.get("tournamentRound") ?? undefined),
);
if (requestedRound !== view.currentRound)
throw new ServiceError(
"The tournament advanced. Reconnect to load the current round.",
409,
);
const room = view.currentRoom;
if (
!room?.you ||
!room.id ||
!["loading", "playing", "results", "finished"].includes(room.status)
)
throw new ServiceError("This round is not available yet", 409);
const [record, point] = await Promise.all([
db
.from("multiplayer_rooms")
.select("revision_id")
.eq("id", room.id)
.single(),
db
.from("multiplayer_rounds")
.select("viewpoint")
.eq("room_id", room.id)
.eq("round_number", room.round)
.single(),
]);
const revision = unwrap(
await db
.from("revisions")
.select("asset_root,manifest")
.eq("id", unwrap(record).revision_id)
.single(),
);
const manifest = datasetSchema.parse(revision.manifest);
const viewpoint = viewpointSchema.parse(unwrap(point).viewpoint);
const signed = await deps.mediaUrls([
manifest.mapPath ?? `${revision.asset_root}/map.webp`,
...faces.map((face) =>
panoramaPath(revision.asset_root, viewpoint, `${face}.webp`),
),
]);
return respond({
round: room.round,
map: signed[0],
bounds: manifest.bounds,
faces: signed.slice(1),
});
}