Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
38
lib/api.ts
38
lib/api.ts
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
188
lib/tournament-api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
18
lib/tournament-discord-server.ts
Normal file
18
lib/tournament-discord-server.ts
Normal 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
260
lib/tournament-discord.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user