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

View File

@@ -24,6 +24,8 @@ import { jsonBody } from "./json-body";
import { accountApi } from "./account-api";
import { accountProfile } from "./profile";
import { communityApi } from "./community-api";
import { tournamentApi } from "./tournament-api";
import { tournamentDiscordAfterResponse } from "./tournament-discord-server";
import { multiplayerApi } from "./multiplayer-api";
import { bugReportApi } from "./bug-report-api";
import { moderatorUsersApi } from "./moderator-users-api";
@@ -155,6 +157,20 @@ export async function handle(request: NextRequest, path: string[]) {
return accountProfile(account);
},
});
if (path[0] === "tournaments")
return await tournamentApi(request, path, {
db: admin(),
account: async () => {
const account = await user();
if (account) await accountProfile(account);
return account;
},
jsonBody,
limit,
mediaUrls,
announce: tournamentDiscordAfterResponse,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
if (path[0] === "bug-reports")
@@ -171,10 +187,17 @@ export async function handle(request: NextRequest, path: string[]) {
const presets = required(
await client
.from("difficulty_presets")
.select("id,name,seconds,zoom")
.select(
"id,name,seconds,zoom,pan,compass,coordinates,map_zoom,scoring",
)
.order("name", { ascending: true }),
);
return NextResponse.json({ presets });
return NextResponse.json({
presets: presets.map(({ map_zoom, ...preset }) => ({
...preset,
mapZoom: map_zoom,
})),
});
}
if (method === "POST") {
const body = customDifficultySchema.parse(await jsonBody(request));
@@ -182,13 +205,18 @@ export async function handle(request: NextRequest, path: string[]) {
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
await limit("difficulty-presets:" + hash(address), 8, 3600);
const { mapZoom, ...fields } = body;
const { data, error } = await client
.from("difficulty_presets")
.insert(body)
.select("id,name,seconds,zoom")
.insert({ ...fields, map_zoom: mapZoom })
.select(
"id,name,seconds,zoom,pan,compass,coordinates,map_zoom,scoring",
)
.single();
checkDifficultyPresetError(error);
return NextResponse.json({ preset: data });
return NextResponse.json({
preset: data ? { ...data, mapZoom: data.map_zoom } : null,
});
}
}
if (path[0] === "health" && path.length === 1 && method === "GET") {

View File

@@ -46,7 +46,9 @@ export type DiscordMessage = {
fields: { name: string; value: string; inline: boolean }[];
footer: { text: string };
}[];
allowed_mentions: { parse: never[] };
allowed_mentions:
| { parse: never[] }
| { roles: string[]; users: never[]; replied_user: false };
nonce: string;
enforce_nonce: true;
};

View File

@@ -81,6 +81,17 @@ export async function multiplayerApi(
return respond({ id });
}
const id = z.string().uuid().parse(path[1]);
const linked = await client
.from("multiplayer_rooms")
.select("tournament_id")
.eq("id", id)
.maybeSingle();
if (linked.error) required(linked);
if (linked.data?.tournament_id)
throw new ServiceError(
"Use the tournament page to join or play this event",
409,
);
const action = path[2] ?? "view";
if (action === "discord" && path.length === 3 && request.method === "POST") {
const view = required(

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),
});
}

View File

@@ -0,0 +1,18 @@
import "server-only";
import { after } from "next/server";
import { admin } from "./supabase";
import { discordConfiguration } from "./discord-client";
import { dispatchTournamentAnnouncements } from "./tournament-discord";
export function tournamentDiscordAfterResponse(target?: string) {
if (!discordConfiguration(process.env).token) return;
after(async () => {
try {
await dispatchTournamentAnnouncements(admin(), process.env, target);
} catch {
console.error(
"Tournament announcement delivery paused; saved announcements will retry.",
);
}
});
}

260
lib/tournament-discord.ts Normal file
View File

@@ -0,0 +1,260 @@
import { createHash, randomUUID } from "node:crypto";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../shared/database";
import {
DiscordClient,
DiscordDeliveryError,
discordConfiguration,
discordText,
type DiscordMessage,
} from "./discord-client";
export const TOURNAMENT_NEWS_CHANNEL = "1558048830770122772";
export const TOURNAMENT_EVENT_ROLES = [
"1558053709554909205",
"1558052962817933322",
] as const;
export const TOURNAMENT_ANNOUNCEMENT_LEAD_MS = 15 * 60 * 1000;
export type TournamentAnnouncementSource = {
id: string;
name: string;
owner: string;
starts_at: string;
server_timezone: string;
player_limit: number;
round_count: number;
round_time_seconds: number;
timer_mode: "shared" | "perMini";
status: string;
announce_discord: boolean;
};
export type TournamentAnnouncementJob = {
id: string;
tournament_id: string;
state: "pending" | "sending" | "sent" | "skipped" | "failed";
attempts: number;
next_attempt_at: string;
lease_owner: string | null;
lease_until: string | null;
channel_id: string | null;
message_id: string | null;
sent_at: string | null;
last_error: string | null;
};
export function tournamentAnnouncementMessage(
event: string,
source: TournamentAnnouncementSource,
): DiscordMessage {
if (!/^[a-zA-Z0-9-]{1,80}$/.test(source.id))
throw new Error("Invalid tournament link.");
const timestamp = Date.parse(source.starts_at);
if (!Number.isFinite(timestamp))
throw new Error("Invalid tournament start time.");
const serverTime = new Intl.DateTimeFormat("en-GB", {
dateStyle: "full",
timeStyle: "short",
timeZone: source.server_timezone,
}).format(timestamp);
const link = `https://minegeo.site/tournaments/${source.id}`;
return {
content: `${TOURNAMENT_EVENT_ROLES.map((role) => `<@&${role}>`).join(", ")}.\n\n## ${discordText(source.name, 120)} will starting soon!\n\nAccess it by this link [${link}](${link})\n\n-------`,
allowed_mentions: {
roles: [...TOURNAMENT_EVENT_ROLES],
users: [],
replied_user: false,
},
nonce: createHash("sha256")
.update(`tournament:${event}`)
.digest("hex")
.slice(0, 24),
enforce_nonce: true,
embeds: [
{
title: "A community expedition is about to begin!",
url: link,
description:
"Join the lobby before the scheduled start. Play the same locations, climb the final standings, and compete for the champion spotlight on the global leaderboard for seven days.",
color: 0x55d6ad,
thumbnail: { url: "https://minegeo.site/images/minegeo-mark.webp" },
fields: [
{
name: "Organizer",
value: discordText(source.owner, 80),
inline: true,
},
{
name: "Players",
value: `Up to ${source.player_limit}`,
inline: true,
},
{
name: "Format",
value: `${source.round_count} tournament round${source.round_count === 1 ? "" : "s"} · 5 mini-rounds each`,
inline: false,
},
{
name: "Round timer",
value:
source.timer_mode === "shared"
? `${source.round_time_seconds} seconds shared across all 5 mini-rounds`
: `${source.round_time_seconds} seconds per mini-round`,
inline: false,
},
{
name: "Starts · server time",
value: `${serverTime} (${discordText(source.server_timezone, 80)})`,
inline: false,
},
{
name: "Starts · your local time",
value: `<t:${Math.floor(timestamp / 1000)}:F> · <t:${Math.floor(timestamp / 1000)}:R>`,
inline: false,
},
],
footer: { text: "MineGeo · Community tournaments" },
},
],
};
}
export async function deliverTournamentAnnouncement(
job: TournamentAnnouncementJob,
dependencies: {
client: Pick<DiscordClient, "send">;
load: () => Promise<TournamentAnnouncementSource | null>;
finish: (patch: Partial<TournamentAnnouncementJob>) => Promise<void>;
now?: () => number;
},
) {
const { client, load, finish, now = Date.now } = dependencies;
const released = { lease_owner: null, lease_until: null };
try {
const source = await load();
if (
!source ||
source.id !== job.tournament_id ||
!source.announce_discord ||
source.status !== "scheduled" ||
Date.parse(source.starts_at) <= now()
) {
await finish({ ...released, state: "skipped", last_error: null });
return;
}
const due = Date.parse(source.starts_at) - TOURNAMENT_ANNOUNCEMENT_LEAD_MS;
// Rescheduling must never produce an early announcement, even with an old lease.
if (due > now()) {
await finish({
...released,
state: "pending",
next_attempt_at: new Date(due).toISOString(),
last_error: null,
});
return;
}
const message = await client.send(
TOURNAMENT_NEWS_CHANNEL,
tournamentAnnouncementMessage(job.id, source),
);
await finish({
...released,
state: "sent",
channel_id: TOURNAMENT_NEWS_CHANNEL,
message_id: message,
sent_at: new Date(now()).toISOString(),
last_error: null,
});
} catch (failure) {
const error =
failure instanceof DiscordDeliveryError
? failure
: new DiscordDeliveryError(
"Tournament announcement could not be confirmed. Delivery will retry.",
);
await finish({
...released,
state: error.permanent || job.attempts >= 8 ? "failed" : "pending",
last_error: error.message,
next_attempt_at: new Date(
now() +
Math.max(
error.retryAfter,
Math.min(3600, 30 * 2 ** (job.attempts - 1)),
) *
1000,
).toISOString(),
});
}
}
export async function dispatchTournamentAnnouncements(
db: SupabaseClient<Database>,
env: Record<string, string | undefined> = process.env,
target?: string,
) {
const config = discordConfiguration(env);
if (!config.token) return;
const worker = randomUUID();
const claimed = await db
.rpc("claim_tournament_announcements", {
p_worker: worker,
p_target: target ?? null,
})
.abortSignal(AbortSignal.timeout(10000));
if (claimed.error)
throw new Error("Could not claim saved tournament announcements.");
const client = new DiscordClient(config.token);
// Claiming a batch must not make its last job wait through every other send:
// the durable leases last two minutes; rate-limited sends return to the queue.
const deliveries = await Promise.allSettled(
(claimed.data ?? []).map((job) =>
deliverTournamentAnnouncement(job, {
client,
load: async () => {
const tournament = 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",
)
.eq("id", job.tournament_id)
.abortSignal(AbortSignal.timeout(10000))
.maybeSingle();
if (tournament.error)
throw new Error("Could not load tournament announcement.");
if (!tournament.data) return null;
const owner = tournament.data.owner_id
? await db
.from("profiles")
.select("display_name")
.eq("id", tournament.data.owner_id)
.abortSignal(AbortSignal.timeout(10000))
.maybeSingle()
: { data: null, error: null };
if (owner.error)
throw new Error("Could not load tournament organizer.");
return {
...tournament.data,
owner: owner.data?.display_name ?? "Explorer",
};
},
finish: async (patch) => {
const result = await db
.from("tournament_announcements")
.update(patch)
.eq("id", job.id)
.eq("lease_owner", worker)
.select("id")
.abortSignal(AbortSignal.timeout(10000))
.single();
if (result.error)
throw new Error("Could not save tournament announcement delivery.");
},
}),
),
);
if (deliveries.some((result) => result.status === "rejected"))
throw new Error(
"Some tournament deliveries could not be saved; their leases will expire for retry.",
);
}