Add MineGeo gameplay systems and interface
This commit is contained in:
226
lib/discord-announcements.ts
Normal file
226
lib/discord-announcements.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { after } from "next/server";
|
||||
import { admin, required } from "./supabase";
|
||||
import {
|
||||
announcementMessage,
|
||||
discordConfiguration,
|
||||
DiscordClient,
|
||||
} from "./discord-client";
|
||||
import { deliverDiscordAnnouncement } from "./discord-delivery";
|
||||
import { scheduleDiscordCleanup } from "./discord-cleanup-scheduler";
|
||||
import type {
|
||||
DiscordAnnouncement,
|
||||
DiscordKind,
|
||||
DiscordStatus,
|
||||
PartyAnnouncement,
|
||||
} from "@/shared/discord";
|
||||
import type { RoomView } from "@/shared/multiplayer";
|
||||
const configuration = () => discordConfiguration(process.env);
|
||||
export function discordPartyReady() {
|
||||
const config = configuration();
|
||||
return !!(config.token && config.origin && config.partyChannel);
|
||||
}
|
||||
async function announcementSource(
|
||||
job: DiscordAnnouncement,
|
||||
): Promise<Parameters<typeof announcementMessage>[3] | null> {
|
||||
const db = admin();
|
||||
if (job.kind === "map") {
|
||||
const row = await db
|
||||
.from("datasets")
|
||||
.select("id,name,owner_id,panorama_count")
|
||||
.eq("id", job.target_id)
|
||||
.eq("state", "published")
|
||||
.is("parent_dataset_id", null)
|
||||
.is("imported_from_id", null)
|
||||
.maybeSingle();
|
||||
if (row.error) required(row);
|
||||
if (!row.data) return null;
|
||||
const owner = await db
|
||||
.from("profiles")
|
||||
.select("display_name")
|
||||
.eq("id", row.data.owner_id)
|
||||
.maybeSingle();
|
||||
if (owner.error) required(owner);
|
||||
return {
|
||||
id: row.data.id,
|
||||
name: row.data.name,
|
||||
owner: owner.data?.display_name ?? "Explorer",
|
||||
locations: row.data.panorama_count,
|
||||
};
|
||||
}
|
||||
const found = await db
|
||||
.from("multiplayer_rooms")
|
||||
.select("*")
|
||||
.eq("id", job.target_id)
|
||||
.maybeSingle();
|
||||
if (found.error) required(found);
|
||||
const room = found.data;
|
||||
if (
|
||||
!room ||
|
||||
room.status !== "lobby" ||
|
||||
Date.parse(room.created_at) < Date.now() - 86400000 ||
|
||||
Date.parse(job.created_at) < Date.now() - 3600000
|
||||
)
|
||||
return null;
|
||||
const revision = required(
|
||||
await db
|
||||
.from("revisions")
|
||||
.select("dataset_id")
|
||||
.eq("id", room.revision_id)
|
||||
.single(),
|
||||
);
|
||||
const map = await db
|
||||
.from("datasets")
|
||||
.select("name")
|
||||
.eq("id", revision.dataset_id)
|
||||
.eq("state", "published")
|
||||
.is("parent_dataset_id", null)
|
||||
.is("imported_from_id", null)
|
||||
.maybeSingle();
|
||||
if (map.error) required(map);
|
||||
if (!map.data) return null;
|
||||
const players = required(
|
||||
await db
|
||||
.from("multiplayer_players")
|
||||
.select("id,display_name")
|
||||
.eq("room_id", room.id)
|
||||
.eq("has_left", false),
|
||||
);
|
||||
const host = players.find((p) => p.id === room.host_player_id);
|
||||
if (!host || players.length >= room.player_limit) return null;
|
||||
return {
|
||||
id: room.id,
|
||||
name: map.data.name,
|
||||
owner: host.display_name,
|
||||
difficulty: room.difficulty,
|
||||
players: players.length,
|
||||
limit: room.player_limit,
|
||||
};
|
||||
}
|
||||
export async function dispatchDiscordAnnouncements(target?: string) {
|
||||
const config = configuration();
|
||||
if (!config.token || !config.origin) return;
|
||||
await scheduleDiscordCleanup(target);
|
||||
const kinds: DiscordKind[] = [];
|
||||
if (config.mapChannel) kinds.push("map");
|
||||
if (config.partyChannel) kinds.push("party");
|
||||
if (!kinds.length) return;
|
||||
const db = admin(),
|
||||
worker = randomUUID();
|
||||
const jobs = required(
|
||||
await db.rpc("claim_discord_announcements", {
|
||||
p_worker: worker,
|
||||
p_kinds: kinds,
|
||||
p_target: target ?? null,
|
||||
}),
|
||||
);
|
||||
const client = new DiscordClient(config.token);
|
||||
for (const job of jobs) {
|
||||
await deliverDiscordAnnouncement(job, {
|
||||
origin: config.origin,
|
||||
channel: job.kind === "map" ? config.mapChannel! : config.partyChannel!,
|
||||
client,
|
||||
load: () => announcementSource(job),
|
||||
finish: async (patch) => {
|
||||
required(
|
||||
await db
|
||||
.from("discord_announcements")
|
||||
.update(patch)
|
||||
.eq("id", job.id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
await scheduleDiscordCleanup(target);
|
||||
}
|
||||
export function discordAfterResponse(target?: string) {
|
||||
const c = configuration();
|
||||
if (!c.token || !c.origin || (!c.mapChannel && !c.partyChannel)) return;
|
||||
after(async () => {
|
||||
try {
|
||||
await dispatchDiscordAnnouncements(target);
|
||||
} catch {
|
||||
console.error(
|
||||
"Discord delivery paused; saved announcements remain available for retry.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
export async function partyAnnouncement(
|
||||
room: RoomView,
|
||||
): Promise<PartyAnnouncement | undefined> {
|
||||
if (!room.you || room.you !== room.hostId) return undefined;
|
||||
const db = admin();
|
||||
const result = await db
|
||||
.from("discord_announcements")
|
||||
.select("state,next_attempt_at,lease_until")
|
||||
.eq("kind", "party")
|
||||
.eq("target_id", room.id)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
const map = await db
|
||||
.from("datasets")
|
||||
.select("state,imported_from_id,parent_dataset_id")
|
||||
.eq("id", room.datasetId)
|
||||
.maybeSingle();
|
||||
if (map.error) required(map);
|
||||
const publicMap =
|
||||
map.data?.state === "published" &&
|
||||
!map.data.imported_from_id &&
|
||||
!map.data.parent_dataset_id;
|
||||
if (
|
||||
result.data &&
|
||||
["pending", "sending"].includes(result.data.state) &&
|
||||
Date.parse(result.data.next_attempt_at) <= Date.now() &&
|
||||
(!result.data.lease_until ||
|
||||
Date.parse(result.data.lease_until) <= Date.now())
|
||||
)
|
||||
discordAfterResponse(room.id);
|
||||
return {
|
||||
available: discordPartyReady() && !!publicMap,
|
||||
state: result.data?.state ?? null,
|
||||
};
|
||||
}
|
||||
export async function discordStatus(): Promise<DiscordStatus> {
|
||||
const c = configuration();
|
||||
const count = async (states: DiscordAnnouncement["state"][]) => {
|
||||
let query = admin()
|
||||
.from("discord_announcements")
|
||||
.select("id", { count: "exact", head: true });
|
||||
query = states.includes("failed")
|
||||
? query.or("state.eq.failed,cleanup_error.not.is.null")
|
||||
: query.in("state", states);
|
||||
const r = await query;
|
||||
if (r.error) required(r);
|
||||
return r.count ?? 0;
|
||||
};
|
||||
const [pending, failed, sent] = await Promise.all([
|
||||
count(["pending", "sending"]),
|
||||
count(["failed"]),
|
||||
count(["sent"]),
|
||||
]);
|
||||
discordAfterResponse();
|
||||
return {
|
||||
botConfigured: !!c.token,
|
||||
publicUrlConfigured: !!c.origin,
|
||||
mapsConfigured: !!c.mapChannel,
|
||||
partiesConfigured: !!c.partyChannel,
|
||||
pending,
|
||||
failed,
|
||||
sent,
|
||||
};
|
||||
}
|
||||
export async function checkDiscordSetup() {
|
||||
const c = configuration();
|
||||
if (!c.token || !c.origin || (!c.mapChannel && !c.partyChannel))
|
||||
throw new Error(
|
||||
"Configure the Discord bot token, public site URL, and at least one channel ID.",
|
||||
);
|
||||
return new DiscordClient(c.token).check(
|
||||
[c.mapChannel, c.partyChannel].filter((v): v is string => !!v),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user