feat(realtime): add game-tick SSE notification pipeline
This commit is contained in:
parent
75a1c8ed9a
commit
d3afc6a0c1
10 changed files with 493 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
26
src/app/api/game-tick/notify/route.ts
Normal file
26
src/app/api/game-tick/notify/route.ts
Normal 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 });
|
||||
}
|
||||
55
src/app/api/realtime/route.ts
Normal file
55
src/app/api/realtime/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
191
src/components/frontend/logistics/ShipmentToasts.tsx
Normal file
191
src/components/frontend/logistics/ShipmentToasts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/frontend/realtime/GameTickRealtime.tsx
Normal file
34
src/components/frontend/realtime/GameTickRealtime.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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
26
src/hooks/useGameTick.ts
Normal 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
30
src/lib/realtime/bus.ts
Normal 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
81
src/scripts/gameTick.ts
Normal 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);
|
||||
};
|
||||
39
src/utils/access-control/hasLogisticsQualification.ts
Normal file
39
src/utils/access-control/hasLogisticsQualification.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue