diff --git a/.env.example b/.env.example index 566f447..51744c2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/app/api/game-tick/notify/route.ts b/src/app/api/game-tick/notify/route.ts new file mode 100644 index 0000000..36164f7 --- /dev/null +++ b/src/app/api/game-tick/notify/route.ts @@ -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 }); +} diff --git a/src/app/api/realtime/route.ts b/src/app/api/realtime/route.ts new file mode 100644 index 0000000..c280ef8 --- /dev/null +++ b/src/app/api/realtime/route.ts @@ -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 | 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", + }, + }); +} diff --git a/src/components/frontend/logistics/ShipmentToasts.tsx b/src/components/frontend/logistics/ShipmentToasts.tsx new file mode 100644 index 0000000..9fd5c38 --- /dev/null +++ b/src/components/frontend/logistics/ShipmentToasts.tsx @@ -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 = { + dispatched: "bg-blue-500/20 text-blue-400", + in_transit: "bg-amber-500/20 text-amber-400", +}; + +const statusLabel: Record = { + 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(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 ( +
+ {open && ( +
+
+
+ + Active Shipments + + {count} + +
+
+ + View all + + +
+
+
+ {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 ( + +
+ + {shipment.name ?? `SHP-${shipment.id}`} + + + {statusLabel[shipment.status] ?? shipment.status} + +
+
+ + {origin?.name ?? `Structure #${shipment.origin}`} + + + + {destination?.name ?? `Structure #${shipment.destination}`} + +
+ {vehicleType && ( +

+ {vehicleType.name} +

+ )} + {shipment.dispatchedAt && shipment.estimatedArrival && ( +
+ +
+ )} + + ); + })} +
+
+ )} + + +
+ ); +} diff --git a/src/components/frontend/realtime/GameTickRealtime.tsx b/src/components/frontend/realtime/GameTickRealtime.tsx new file mode 100644 index 0000000..df65853 --- /dev/null +++ b/src/components/frontend/realtime/GameTickRealtime.tsx @@ -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; +} diff --git a/src/components/frontend/storage/EventLedger.tsx b/src/components/frontend/storage/EventLedger.tsx index 427ba49..56548ce 100644 --- a/src/components/frontend/storage/EventLedger.tsx +++ b/src/components/frontend/storage/EventLedger.tsx @@ -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 ( diff --git a/src/hooks/useGameTick.ts b/src/hooks/useGameTick.ts new file mode 100644 index 0000000..ebef135 --- /dev/null +++ b/src/hooks/useGameTick.ts @@ -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; +} diff --git a/src/lib/realtime/bus.ts b/src/lib/realtime/bus.ts new file mode 100644 index 0000000..9096e2b --- /dev/null +++ b/src/lib/realtime/bus.ts @@ -0,0 +1,30 @@ +export interface SSEEventClient { + send: (data: string) => void; +} + +const clients = new Set(); + +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; +} diff --git a/src/scripts/gameTick.ts b/src/scripts/gameTick.ts new file mode 100644 index 0000000..37a4019 --- /dev/null +++ b/src/scripts/gameTick.ts @@ -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); +}; \ No newline at end of file diff --git a/src/utils/access-control/hasLogisticsQualification.ts b/src/utils/access-control/hasLogisticsQualification.ts new file mode 100644 index 0000000..ddb4006 --- /dev/null +++ b/src/utils/access-control/hasLogisticsQualification.ts @@ -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 { + 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; + }); +}