1
0
Fork 0

feat(realtime): add game-tick SSE notification pipeline

This commit is contained in:
Jason Fraley 2026-08-02 02:03:56 -04:00
parent 75a1c8ed9a
commit d3afc6a0c1
10 changed files with 493 additions and 0 deletions

View file

@ -1,2 +1,8 @@
DATABASE_URI=mongodb://127.0.0.1/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE
# Public URL of the running app (used by the game tick script to notify clients)
APP_URL=http://localhost:3000
# Secret that guards the /api/game-tick/notify endpoint. The game tick script
# sends this header so connected clients refresh after each tick.
GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET

View file

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { broadcast } from "@/lib/realtime/bus";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const secret = process.env.GAME_TICK_NOTIFY_SECRET;
const headerSecret = req.headers.get("x-game-tick-secret");
if (secret && headerSecret !== secret) {
return NextResponse.json({ ok: false }, { status: 401 });
}
let body: { processedAt?: string } = {};
try {
body = await req.json();
} catch {
// ignore invalid JSON body
}
broadcast("game-tick", {
processedAt: body?.processedAt ?? new Date().toISOString(),
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,55 @@
import { NextRequest } from "next/server";
import {
subscribe,
unsubscribe,
type SSEEventClient,
} from "@/lib/realtime/bus";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const encoder = new TextEncoder();
let client: SSEEventClient | null = null;
let heartbeat: ReturnType<typeof setInterval> | null = null;
const cleanup = () => {
if (heartbeat) clearInterval(heartbeat);
if (client) unsubscribe(client);
client = null;
heartbeat = null;
};
const stream = new ReadableStream({
start(controller) {
client = {
send: (data) => controller.enqueue(encoder.encode(data)),
};
subscribe(client);
controller.enqueue(encoder.encode(": connected\n\n"));
heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
cleanup();
}
}, 25_000);
req.signal.addEventListener("abort", cleanup);
},
cancel() {
cleanup();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}

View file

@ -0,0 +1,191 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, TruckIcon } from "lucide-react";
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
import { useGameTick } from "@/hooks/useGameTick";
type ActiveShipment = Shipment & {
origin: GameStructure | number;
destination: GameStructure | number;
transportVehicle: GameVehicle & { type: Vehicle };
};
const statusBadge: Record<string, string> = {
dispatched: "bg-blue-500/20 text-blue-400",
in_transit: "bg-amber-500/20 text-amber-400",
};
const statusLabel: Record<string, string> = {
dispatched: "Dispatched",
in_transit: "In Transit",
};
// Module-scoped cache so the toast persists across re-mounts (page navigation
// re-mounts this client component; without this it flashes away and back).
const store: { shipments: ActiveShipment[]; open: boolean } = {
shipments: [],
open: false,
};
export function ShipmentToasts() {
const [shipments, setShipments] = useState<ActiveShipment[]>(store.shipments);
const [open, setOpenState] = useState(store.open);
const setOpen = useCallback((value: boolean | ((v: boolean) => boolean)) => {
setOpenState((prev) => {
const next = typeof value === "function" ? value(prev) : value;
store.open = next;
return next;
});
}, []);
const fetchShipments = useCallback(async () => {
const params = new URLSearchParams();
params.append("where[status][in][0]", "dispatched");
params.append("where[status][in][1]", "in_transit");
params.append("sort", "estimatedArrival");
params.append("limit", "20");
params.append("depth", "2");
try {
const r = await fetch(`/api/shipments?${params.toString()}`);
const body = await r.json();
store.shipments = body.docs ?? [];
setShipments(store.shipments);
} catch {
store.shipments = [];
setShipments([]);
}
}, []);
useEffect(() => {
fetchShipments();
const interval = setInterval(fetchShipments, 30_000);
return () => clearInterval(interval);
}, [fetchShipments]);
useGameTick(() => fetchShipments());
if (shipments.length === 0) return null;
const count = shipments.length;
const countLabel = `${count} active shipment${count !== 1 ? "s" : ""}`;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
{open && (
<div className="w-80 sm:w-96 rounded-xl border border-border bg-background/95 backdrop-blur shadow-xl overflow-hidden">
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<TruckIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">Active Shipments</span>
<span className="text-xs font-mono text-muted-foreground">
{count}
</span>
</div>
<div className="flex items-center gap-1">
<Link
href="/logistics/shipments"
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted transition-colors"
>
View all
</Link>
<button
type="button"
onClick={() => setOpen(false)}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Collapse shipment tracker"
>
<ChevronDownIcon className="size-4" />
</button>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto divide-y divide-border">
{shipments.map((shipment) => {
const origin =
typeof shipment.origin === "object" && shipment.origin !== null
? (shipment.origin as GameStructure)
: null;
const destination =
typeof shipment.destination === "object" &&
shipment.destination !== null
? (shipment.destination as GameStructure)
: null;
const vehicle =
typeof shipment.transportVehicle === "object" &&
shipment.transportVehicle !== null
? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
: null;
const vehicleType =
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
return (
<Link
key={shipment.id}
href={`/logistics/shipments/${shipment.id}`}
className="block px-4 py-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">
{shipment.name ?? `SHP-${shipment.id}`}
</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 ${
statusBadge[shipment.status] ?? "bg-muted text-muted-foreground"
}`}
>
{statusLabel[shipment.status] ?? shipment.status}
</span>
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">
{origin?.name ?? `Structure #${shipment.origin}`}
</span>
<ArrowRightIcon className="size-3 shrink-0" />
<span className="truncate">
{destination?.name ?? `Structure #${shipment.destination}`}
</span>
</div>
{vehicleType && (
<p className="mt-0.5 text-xs text-muted-foreground/80">
{vehicleType.name}
</p>
)}
{shipment.dispatchedAt && shipment.estimatedArrival && (
<div className="mt-2">
<ShipmentProgressBar
dispatchedAt={shipment.dispatchedAt}
estimatedArrival={shipment.estimatedArrival}
/>
</div>
)}
</Link>
);
})}
</div>
</div>
)}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 rounded-full border border-border bg-background/95 backdrop-blur px-3.5 py-2 text-sm shadow-lg hover:border-primary/50 hover:scale-[1.02] transition-all"
aria-label={open ? "Collapse shipment tracker" : "Show active shipments"}
>
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
<TruckIcon className="size-4 text-primary" />
<span className="font-medium">{countLabel}</span>
{open ? (
<ChevronDownIcon className="size-4 text-muted-foreground" />
) : (
<ChevronUpIcon className="size-4 text-muted-foreground" />
)}
</button>
</div>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { GAME_TICK_EVENT, type GameTickDetail } from "@/hooks/useGameTick";
/**
* Opens a Server-Sent Events connection to the game tick stream.
* When a game tick completes, refreshes server-rendered data via
* router.refresh() and dispatches a window event that client-side
* components (e.g. ShipmentToasts, EventLedger) listen to for refetch.
*/
export function GameTickRealtime() {
const router = useRouter();
useEffect(() => {
const source = new EventSource("/api/realtime");
source.addEventListener("game-tick", (event) => {
let detail: GameTickDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
router.refresh();
window.dispatchEvent(
new CustomEvent(GAME_TICK_EVENT, { detail }),
);
});
return () => source.close();
}, [router]);
return null;
}

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Item, ItemContent, ItemHeader, ItemTitle, ItemDescription } from "@/components/ui/item";
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
import { useGameTick } from "@/hooks/useGameTick";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -105,6 +106,10 @@ export function EventLedger({
return () => clearInterval(interval);
}, [fetchEntries]);
useGameTick(() => {
if (initialDone.current) fetchEntries();
});
if (loading) {
return (
<Item variant="outline">

26
src/hooks/useGameTick.ts Normal file
View file

@ -0,0 +1,26 @@
"use client";
import { useEffect, useRef, useState } from "react";
export const GAME_TICK_EVENT = "ptf:game-tick";
export interface GameTickDetail {
processedAt?: string;
}
export function useGameTick(onTick?: () => void): number {
const [count, setCount] = useState(0);
const callbackRef = useRef(onTick);
callbackRef.current = onTick;
useEffect(() => {
const handler = () => {
setCount((c) => c + 1);
callbackRef.current?.();
};
window.addEventListener(GAME_TICK_EVENT, handler);
return () => window.removeEventListener(GAME_TICK_EVENT, handler);
}, []);
return count;
}

30
src/lib/realtime/bus.ts Normal file
View file

@ -0,0 +1,30 @@
export interface SSEEventClient {
send: (data: string) => void;
}
const clients = new Set<SSEEventClient>();
export function subscribe(client: SSEEventClient): () => void {
clients.add(client);
return () => clients.delete(client);
}
export function unsubscribe(client: SSEEventClient): void {
clients.delete(client);
}
export function clientCount(): number {
return clients.size;
}
export function broadcast(event: string, data: unknown): number {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
try {
client.send(payload);
} catch {
clients.delete(client);
}
}
return clients.size;
}

81
src/scripts/gameTick.ts Normal file
View file

@ -0,0 +1,81 @@
import type { SanitizedConfig } from "payload";
import { getPayload } from "payload";
import type { Shipment, GameVehicle, GameStructure, Vehicle } from "@/payload-types";
import { processShipmentTick } from "./processShipmentTick";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
export const script = async (config: SanitizedConfig) => {
const payload = await getPayload({ config });
payload.logger.info("[Game Tick] Starting game tick...");
const activeShipments = await payload.find({
collection: "shipments",
where: {
status: { in: ["dispatched", "in_transit"] },
},
limit: 100,
depth: 2,
});
let processedCount = 0;
for (const shipment of activeShipments.docs) {
try {
await processShipmentTick(
payload,
shipment as unknown as Shipment & {
transportVehicle: GameVehicle & { type: Vehicle };
origin: GameStructure;
destination: GameStructure;
},
);
processedCount++;
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
payload.logger.error(
`[Game Tick] Error processing shipment ${shipment.id}: ${message}`,
);
}
}
await emitGameEvent(payload, {
type: EventTypes.GameTick,
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
data: {
processedCount,
totalActive: activeShipments.docs.length,
},
});
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(
/\/$/,
"",
);
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {
await fetch(`${notifyUrl}/api/game-tick/notify`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-game-tick-secret": notifySecret,
},
body: JSON.stringify({ processedAt: new Date().toISOString() }),
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(
`[Game Tick] Failed to notify app clients: ${message}`,
);
});
} else {
payload.logger.warn(
"[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification",
);
}
payload.logger.info(
`[Game Tick] Complete. Processed ${processedCount} shipments.`,
);
process.exit(0);
};

View file

@ -0,0 +1,39 @@
import type { Payload } from "payload";
/**
* Checks if a user has the Logistics qualification.
* Queries the user's profile for qualifications matching "Logistics" (case-insensitive).
* Developers and admins always pass.
*/
export async function hasLogisticsQualification(
payload: Payload,
user: { id: number | string; roles?: unknown } | null | undefined,
): Promise<boolean> {
const roles = user.roles as string[] | undefined;
if (roles?.includes("developer") || roles?.includes("admin")) {
return true;
}
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 1,
select: {
progression: {
qualifications: true,
},
},
});
const profile = profileRes.docs[0];
if (!profile) return false;
const qualifications = profile.progression?.qualifications ?? [];
return qualifications.some((q) => {
if (typeof q === "object" && q !== null && "name" in q) {
return (q.name as string).toLowerCase().includes("logistics");
}
return false;
});
}