From fc6da6756d4a86b2280927a058294534cad29ffc Mon Sep 17 00:00:00 2001
From: pavel444-byte
Date: Sat, 10 Oct 2026 16:24:34 +0500
Subject: [PATCH] Add account based multiplayer hosting and bug report
retention
---
app/api/support/linear/route.ts | 22 ++
app/dashboard/[id]/page.tsx | 7 +-
app/servers/[id]/page.tsx | 4 +-
components/DraftEditor.tsx | 12 +-
components/StartGame.tsx | 37 ++--
components/Support.tsx | 48 ++++-
docs/bug-reports.md | 37 +++-
docs/discord-integration.md | 4 +
docs/vercel-deployment.md | 7 +-
lib/bug-cleanup-workflow.ts | 146 +++++++++++++
lib/bug-lifecycle.ts | 98 +++++++++
lib/bug-report-api.ts | 63 +++++-
lib/bug-retention.ts | 38 ++++
lib/discord-announcements.ts | 15 +-
lib/linear-support-client.ts | 66 +++++-
lib/multiplayer-api.ts | 62 +++---
lib/support-sync.ts | 11 +-
scripts/test-bug-reports-sql.mjs | 127 ++++++++++++
scripts/test-bug-retention-http.mjs | 146 +++++++++++++
scripts/test-discord-http.mjs | 7 +-
scripts/test-multiplayer-browser.mjs | 30 ++-
scripts/test-multiplayer-http.mjs | 98 ++++++++-
scripts/test-multiplayer-sql.mjs | 110 ++++++++++
scripts/test-profile-appearance-browser.mjs | 1 -
shared/bug-reports.ts | 18 +-
shared/database.ts | 39 ++++
shared/locales/ru.json | 13 +-
shared/multiplayer.ts | 13 +-
.../20261010053310_bug_report_retention.sql | 80 +++++++
...261010053429_multiplayer_account_hosts.sql | 65 ++++++
tests/bug-reports.test.ts | 2 +
tests/bug-retention.test.ts | 195 ++++++++++++++++++
tests/multiplayer.test.ts | 19 +-
33 files changed, 1538 insertions(+), 102 deletions(-)
create mode 100644 lib/bug-cleanup-workflow.ts
create mode 100644 lib/bug-lifecycle.ts
create mode 100644 lib/bug-retention.ts
create mode 100644 scripts/test-bug-retention-http.mjs
create mode 100644 supabase/migrations/20261010053310_bug_report_retention.sql
create mode 100644 supabase/migrations/20261010053429_multiplayer_account_hosts.sql
create mode 100644 tests/bug-retention.test.ts
diff --git a/app/api/support/linear/route.ts b/app/api/support/linear/route.ts
index c79f685..3a70051 100644
--- a/app/api/support/linear/route.ts
+++ b/app/api/support/linear/route.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { admin, required } from "@/lib/supabase";
import { verifySupportWebhook } from "@/lib/support-signature";
import { supportCommand } from "@/shared/bug-reports";
+import { reconcileBugStatus, scheduleBugCleanup } from "@/lib/bug-lifecycle";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
@@ -59,6 +60,17 @@ export async function POST(request: NextRequest) {
.safeParse(decoded);
if (!payload.success) return new NextResponse(null, { status: 400 });
const event = payload.data;
+ if (event.type === "Issue" && ["create", "update"].includes(event.action)) {
+ try {
+ await reconcileBugStatus(event.data.id, true);
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json(
+ { error: "Support status delivery failed" },
+ { status: 503 },
+ );
+ }
+ }
// Only explicit new staff comments are public. Internal comments and user echoes stay private.
if (
event.action !== "create" ||
@@ -71,6 +83,16 @@ export async function POST(request: NextRequest) {
const command = supportCommand(event.data.body ?? "");
if (!command) return NextResponse.json({ ok: true });
try {
+ if (command.kind === "close") {
+ required(
+ await admin().rpc("receive_bug_close", {
+ p_report: event.data.issueId,
+ p_external: event.data.id,
+ }),
+ );
+ await scheduleBugCleanup(event.data.issueId);
+ return NextResponse.json({ ok: true });
+ }
required(
await admin().rpc("receive_bug_reply", {
p_report: event.data.issueId,
diff --git a/app/dashboard/[id]/page.tsx b/app/dashboard/[id]/page.tsx
index 6e4c4ed..35332bf 100644
--- a/app/dashboard/[id]/page.tsx
+++ b/app/dashboard/[id]/page.tsx
@@ -2,6 +2,7 @@ import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { DraftEditor } from "@/components/DraftEditor";
import { user } from "@/lib/supabase";
+import { accountProfile } from "@/lib/profile";
import { redirect } from "next/navigation";
export default async function Draft({
params,
@@ -10,15 +11,17 @@ export default async function Draft({
}) {
const { t } = await getI18n();
const { id } = await params;
- if (!(await user()))
+ const account = await user();
+ if (!account)
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
+ const profile = await accountProfile(account);
return (
<>
{t("YOUR WORLD · YOUR LOCATIONS")}
{t("Map workshop")}
-
+
>
diff --git a/app/servers/[id]/page.tsx b/app/servers/[id]/page.tsx
index 34e106c..1d4e8ba 100644
--- a/app/servers/[id]/page.tsx
+++ b/app/servers/[id]/page.tsx
@@ -8,6 +8,7 @@ import { getMap } from "@/lib/catalog";
import { ContributionInstructions } from "@/components/ContributionInstructions";
import { ReportContent } from "@/components/ReportContent";
import { user } from "@/lib/supabase";
+import { accountProfile } from "@/lib/profile";
import { ExportMap } from "@/components/MapTransfer";
export default async function ServerPage({
params,
@@ -19,6 +20,7 @@ export default async function ServerPage({
const map = await getMap(id);
if (!map) notFound();
const account = await user();
+ const profile = account ? await accountProfile(account) : null;
return (
<>
@@ -63,7 +65,7 @@ export default async function ServerPage({
-
+
{map.allow_contributions && (
(null),
@@ -180,7 +186,9 @@ export function DraftEditor({ id }: { id: string }) {
"This shared map is private and playable without moderator review. Only the original owner can submit it for public review.",
)}
- {draft.state === "draft" && }
+ {draft.state === "draft" && (
+
+ )}
)}
{!draft.parent_dataset_id &&
diff --git a/components/StartGame.tsx b/components/StartGame.tsx
index 87fe4be..f77a025 100644
--- a/components/StartGame.tsx
+++ b/components/StartGame.tsx
@@ -2,16 +2,22 @@
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
import { difficulties, type Difficulty } from "@/shared/protocol";
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
-export function StartGame({ datasetId }: { datasetId: string }) {
+export function StartGame({
+ datasetId,
+ hostName,
+}: {
+ datasetId: string;
+ hostName: string | null;
+}) {
const { t } = useI18n();
const [difficulty, setDifficulty] = useState("normal"),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
- [displayName, setDisplayName] = useState(""),
[playerLimit, setPlayerLimit] = useState(4),
[loading, setLoading] = useState(false),
[error, setError] = useState("");
@@ -25,7 +31,7 @@ export function StartGame({ datasetId }: { datasetId: string }) {
{
datasetId,
difficulty,
- ...(mode === "multiplayer" ? { displayName, playerLimit } : {}),
+ ...(mode === "multiplayer" ? { playerLimit } : {}),
},
);
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
@@ -82,17 +88,16 @@ export function StartGame({ datasetId }: { datasetId: string }) {
{mode === "multiplayer" && (
-
-
setDisplayName(event.target.value)}
- minLength={2}
- maxLength={32}
- placeholder={t("Enter a player name")}
- autoComplete="nickname"
- disabled={loading}
- />
+ {hostName ? (
+
+ {t("Host display name")}: {hostName}
+
+ ) : (
+
+ {t("Log in to create a multiplayer room")}{" "}
+ {t("Log in")}
+
+ )}
@@ -118,9 +123,7 @@ export function StartGame({ datasetId }: { datasetId: string }) {
- {report.state !== "draft" && (
+ {report.state !== "draft" && report.state !== "done" && (