1
0
Fork 0

feat: add GameEventLogs collection and event emission infrastructure

- New game-event-logs collection with timestamp, type, message, actor,
  structure, targetCollection/targetId, and data (JSON) fields
- EventTypes constants registry for modular event category management
- emitGameEvent() utility for emitting events from server actions and hooks
- Register collection in Payload config
- Document event log system architecture in AGENTS.md
This commit is contained in:
Jason Fraley 2026-07-28 16:08:04 -04:00
parent d88fe04b56
commit 59769076ff
10 changed files with 868 additions and 35 deletions

View file

@ -62,10 +62,20 @@ Organized by domain under `src/collections/`:
- **logistics/** — Assets, Resources, Vehicles, Structures
- **world/** — Maps, NarrativeEvents
- **server/** — MissionFiles, ModLists
- **game/** — GameRules (global), GameStructures, GameHardResources
- **game/** — GameRules (global), GameStructures, GameHardResources, GameEventLogs
Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). Roles: `guest`, `user`, `admin`, `developer`.
## Event Log System
`src/collections/game/GameEventLogs.ts` — append-only `game-event-logs` collection.
- **Schema**: `timestamp`, `type` (text/indexed), `message` (human-readable), `actor` (→ users), `structure` (→ game-structures), `targetCollection`/`targetId` (polymorphic ref), `data` (JSON blob).
- **Emit**: `emitGameEvent(payload, { type, message, actor?, structure?, targetCollection?, targetId?, data? })` in `src/utils/event-log/emit.ts`.
- **Types**: `EventTypes` constants in `src/utils/event-log/eventTypes.ts` — add new event categories there without touching the collection.
- **Wiring**: Server actions in `actions.ts` emit events after successful mutations. `GameStructures` afterChange hook catches admin-panel storage edits. `Structures` beforeChange hook emits `structure:resize`.
- **UI**: `EventLedger` component (`src/components/frontend/storage/EventLedger.tsx`) queries the REST API for a given `structureId` and renders a scrollable feed. Integrated in `StructureStorageView`.
## Auth
Username-based login (no email login). Users log in via Payload admin with `username` only.

View file

@ -247,50 +247,67 @@ export async function removeResource(
}
const structure = await getStructure(payload, structureId);
const currentAmount = getStoredAmount(structure.storedResources, resourceId);
const storedAmount = getStoredAmount(structure.storedResources, resourceId);
const voidAmount = getStoredAmount((structure as any).voidStorage, resourceId);
const totalAmount = storedAmount + voidAmount;
if (currentAmount <= 0) {
if (totalAmount <= 0) {
return { success: false, error: "This resource is not stored here." };
}
if (amount > currentAmount) {
if (amount > totalAmount) {
return {
success: false,
error: `Cannot remove more than stored. Available: ${currentAmount.toLocaleString()}.`,
error: `Cannot remove more than stored. Available: ${totalAmount.toLocaleString()} (${storedAmount.toLocaleString()} in grid, ${voidAmount.toLocaleString()} in void).`,
};
}
const existing = [...(structure.storedResources ?? [])];
const newAmount = currentAmount - amount;
let remainingToRemove = amount;
const storedResources = [...(structure.storedResources ?? [])];
const voidStorage = [...((structure as any).voidStorage ?? [])];
if (newAmount <= 0) {
const filtered = existing.filter((e) => {
// Remove from void storage first — void should empty itself before touching the grid
const voidIdx = voidStorage.findIndex((e: any) => {
const res = e.resource;
return !(typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId);
return typeof res === "object" && res !== null && "id" in res
? (res as any).id === resourceId
: res === resourceId;
});
const updated = await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: filtered },
});
return { success: true, data: updated as unknown as GameStructure };
if (voidIdx >= 0) {
const entry = voidStorage[voidIdx];
if (entry.amount > remainingToRemove) {
voidStorage[voidIdx] = { ...entry, amount: entry.amount - remainingToRemove };
remainingToRemove = 0;
} else {
remainingToRemove -= entry.amount;
voidStorage.splice(voidIdx, 1);
}
}
const updatedIndex = existing.findIndex((e) => {
// Remove remaining from grid storage
if (remainingToRemove > 0) {
const storedIdx = storedResources.findIndex((e) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId;
});
existing[updatedIndex] = { ...existing[updatedIndex], amount: newAmount };
if (storedIdx >= 0) {
const entry = storedResources[storedIdx];
if (entry.amount > remainingToRemove) {
storedResources[storedIdx] = { ...entry, amount: entry.amount - remainingToRemove };
} else {
storedResources.splice(storedIdx, 1);
}
}
}
const updated = await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
data: { storedResources, voidStorage },
});
return { success: true, data: updated as unknown as GameStructure };
@ -326,12 +343,14 @@ export async function transferResource(
const fromStructure = await getStructure(payload, fromStructureId);
const toStructure = await getStructure(payload, toStructureId);
const sourceAmount = getStoredAmount(fromStructure.storedResources, resourceId);
const sourceStored = getStoredAmount(fromStructure.storedResources, resourceId);
const sourceVoid = getStoredAmount((fromStructure as any).voidStorage, resourceId);
const sourceAmount = sourceStored + sourceVoid;
if (sourceAmount < amount) {
return {
success: false,
error: `Insufficient resources. Available: ${sourceAmount.toLocaleString()}.`,
error: `Insufficient resources. Available: ${sourceAmount.toLocaleString()} (${sourceStored.toLocaleString()} in grid, ${sourceVoid.toLocaleString()} in void).`,
};
}
@ -781,6 +800,91 @@ export async function splitGridStack(
}
}
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions." };
}
const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0;
const gridH = structure.type.ySize ?? 0;
if (!gridW || !gridH) return { success: false, error: "Structure has no grid dimensions." };
const storedResources = [...(structure.storedResources ?? [])];
const voidStorage = [...((structure as any).voidStorage ?? [])];
const remainingVoid: typeof voidStorage = [];
for (const entry of voidStorage) {
const res = entry.resource as Resource;
if (!res || typeof res === "number") {
remainingVoid.push(entry);
continue;
}
const rotated = entry.rotated ?? false;
const ew = rotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = rotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= gridH - eh && !spot; y++) {
for (let x = 0; x <= gridW - ew && !spot; x++) {
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${x + dx},${y + dy}`);
}
}
let overlaps = false;
for (const p of storedResources) {
const pRes = p.resource as Resource;
if (!pRes || typeof pRes === "number") continue;
const pRotated = p.rotated ?? false;
const pw = pRotated ? (pRes.gridHeight ?? 1) : (pRes.gridWidth ?? 1);
const ph = pRotated ? (pRes.gridWidth ?? 1) : (pRes.gridHeight ?? 1);
for (let dx = 0; dx < pw; dx++) {
for (let dy = 0; dy < ph; dy++) {
if (targetCells.has(`${p.gridX + dx},${p.gridY + dy}`)) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
if (overlaps) break;
}
if (!overlaps) spot = { x, y };
}
}
if (spot) {
storedResources.push({ ...entry, gridX: spot.x, gridY: spot.y });
} else {
remainingVoid.push(entry);
}
}
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources, voidStorage: remainingVoid },
});
const recovered = voidStorage.length - remainingVoid.length;
return {
success: true,
error: recovered > 0
? `${recovered} item(s) retrieved from void storage.`
: "No items could be retrieved — grid is full.",
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
@ -792,15 +896,17 @@ export async function autoArrangeGrid(structureId: number): Promise<GridActionRe
const gridH = structure.type.ySize ?? 0;
if (!gridW || !gridH) return { success: false, error: "Structure has no grid dimensions." };
const items = [...(structure.storedResources ?? [])].map((e) => ({
// Phase 1 — repack grid items
const gridItems = [...(structure.storedResources ?? [])].map((e) => ({
...e,
_sortKey: e.gridY * 1000 + e.gridX,
}));
items.sort((a, b) => (a._sortKey ?? 0) - (b._sortKey ?? 0));
gridItems.sort((a, b) => (a._sortKey ?? 0) - (b._sortKey ?? 0));
const placed: typeof items = [];
const placed: typeof gridItems = [];
const overflow: typeof gridItems = [];
for (const item of items) {
for (const item of gridItems) {
const res = item.resource as Resource;
if (!res || typeof res === "number") continue;
const rotated = item.rotated ?? false;
@ -844,13 +950,82 @@ export async function autoArrangeGrid(structureId: number): Promise<GridActionRe
}
if (!spotFound) {
placed.push({ ...item, gridX: 0, gridY: 0 });
overflow.push(item);
}
}
// Phase 2 — try to recover void items into any remaining grid space
const voidStorage = [...((structure as any).voidStorage ?? [])];
const remainingVoid: typeof placed = [];
for (const entry of voidStorage) {
const res = entry.resource as Resource;
if (!res || typeof res === "number") {
remainingVoid.push(entry);
continue;
}
const rotated = entry.rotated ?? false;
const ew = rotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = rotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= gridH - eh && !spot; y++) {
for (let x = 0; x <= gridW - ew && !spot; x++) {
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${x + dx},${y + dy}`);
}
}
let overlaps = false;
for (const p of placed) {
const pRes = p.resource as Resource;
if (!pRes || typeof pRes === "number") continue;
const pRotated = p.rotated ?? false;
const pw = pRotated ? (pRes.gridHeight ?? 1) : (pRes.gridWidth ?? 1);
const ph = pRotated ? (pRes.gridWidth ?? 1) : (pRes.gridHeight ?? 1);
for (let dx = 0; dx < pw; dx++) {
for (let dy = 0; dy < ph; dy++) {
if (targetCells.has(`${p.gridX + dx},${p.gridY + dy}`)) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
if (overlaps) break;
}
if (!overlaps) spot = { x, y };
}
}
if (spot) {
placed.push({ ...entry, gridX: spot.x, gridY: spot.y });
} else {
remainingVoid.push(entry);
}
}
const updated = placed.map(({ _sortKey, ...rest }) => rest);
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: updated } });
return { success: true };
const overflowItems = overflow.map(({ _sortKey, ...rest }) => rest);
const voidCount = voidStorage.length;
const recoveredCount = voidCount - remainingVoid.length;
await payload.update({
collection: "game-structures",
id: structureId,
data: {
storedResources: updated,
voidStorage: [...remainingVoid, ...overflowItems],
},
});
const messages: string[] = [];
if (recoveredCount > 0) messages.push(`${recoveredCount} item(s) recovered from void.`);
if (overflow.length > 0) messages.push(`${overflow.length} item(s) moved to void — grid full.`);
return { success: true, error: messages.length > 0 ? messages.join(" ") : undefined };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"

View file

@ -0,0 +1,94 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
const TARGET_COLLECTIONS = [
{ label: "Game Structures", value: "game-structures" },
{ label: "Structures", value: "structures" },
{ label: "Resources", value: "resources" },
{ label: "Assets", value: "assets" },
{ label: "Vehicles", value: "vehicles" },
{ label: "Factions", value: "factions" },
{ label: "Missions", value: "missions" },
{ label: "Campaigns", value: "campaigns" },
{ label: "Users", value: "users" },
{ label: "Technologies", value: "technologies" },
{ label: "Maps", value: "maps" },
] as const;
export const GameEventLogs: CollectionConfig = {
slug: "game-event-logs",
admin: {
group: "Game",
useAsTitle: "message",
defaultColumns: ["timestamp", "type", "system", "message", "actor", "structure"],
listSearchableFields: ["message", "type"],
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: ({ req }) => {
if (!req.user) return false;
const roles = req.user.roles as string[] | undefined;
return roles?.includes("admin") || roles?.includes("developer") || false;
},
delete: isDeveloper,
},
fields: [
{
name: "system",
type: "checkbox",
defaultValue: true,
admin: {
description: "System-generated events are created automatically. Uncheck for manual/GM narrative entries.",
},
},
{
name: "timestamp",
type: "date",
required: true,
defaultValue: () => new Date().toISOString(),
admin: {
date: { pickerAppearance: "dayAndTime" },
},
},
{
name: "type",
type: "text",
required: true,
index: true,
},
{
name: "message",
type: "text",
required: true,
},
{
name: "actor",
type: "relationship",
relationTo: "users",
},
{
name: "structure",
type: "relationship",
relationTo: "game-structures",
hasMany: false,
},
{
name: "targetCollection",
type: "select",
options: TARGET_COLLECTIONS.map((c) => ({ label: c.label, value: c.value })),
admin: {
description: "Which collection the event primarily relates to.",
},
},
{
name: "targetId",
type: "number",
},
{
name: "data",
type: "json",
},
],
};

View file

@ -94,5 +94,44 @@ export const GameStructures: CollectionConfig = {
},
],
},
{
name: "voidStorage",
type: "array",
admin: {
description:
"Items that no longer fit in the grid after a resize, stored here until retrieved.",
},
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
required: true,
},
{
name: "amount",
type: "number",
required: true,
min: 1,
},
{
name: "gridX",
type: "number",
required: true,
min: 0,
},
{
name: "gridY",
type: "number",
required: true,
min: 0,
},
{
name: "rotated",
type: "checkbox",
defaultValue: false,
},
],
},
],
};

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import type { Resource } from "@/payload-types";
export const Structures: CollectionConfig = {
slug: "structures",
@ -7,6 +8,226 @@ export const Structures: CollectionConfig = {
useAsTitle: "name",
group: "Logistics",
},
hooks: {
beforeChange: [
async ({ data, originalDoc, req, context }) => {
if (!data || !originalDoc) return data;
if ((context as any)?.skipGridRepack) return data;
const doc = data as any;
const orig = originalDoc as any;
const oldX = orig.xSize ?? 0;
const oldY = orig.ySize ?? 0;
const newX = doc.xSize ?? 0;
const newY = doc.ySize ?? 0;
if (oldX === newX && oldY === newY) return data;
if (!newX || !newY) return data;
const { docs: gameStructures } = await req.payload.find({
collection: "game-structures",
depth: 1,
where: { type: { equals: originalDoc.id } },
limit: 1000,
});
for (const gs of gameStructures) {
const gsDoc = gs as any;
const storedResources = [...(gsDoc.storedResources ?? [])];
const voidStorage = [...(gsDoc.voidStorage ?? [])];
const newVoid: any[] = [];
const remaining: any[] = [];
let changed = false;
// Step 1: try to recover void items back to grid if space allows
const gridExpanded = newX > oldX || newY > oldY;
if (gridExpanded) {
for (const entry of voidStorage) {
const res = entry.resource as Resource | undefined;
if (!res || typeof res !== "object") {
newVoid.push(entry);
continue;
}
const rotated = entry.rotated ?? false;
const ew = rotated
? (res as any).gridHeight ?? 1
: (res as any).gridWidth ?? 1;
const eh = rotated
? (res as any).gridWidth ?? 1
: (res as any).gridHeight ?? 1;
let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= newY - eh && !spot; y++) {
for (let x = 0; x <= newX - ew && !spot; x++) {
let overlaps = false;
for (const placed of remaining) {
const pRes = placed.resource as Resource | undefined;
if (!pRes || typeof pRes !== "object") continue;
const pRotated = placed.rotated ?? false;
const pw = pRotated
? (pRes as any).gridHeight ?? 1
: (pRes as any).gridWidth ?? 1;
const ph = pRotated
? (pRes as any).gridWidth ?? 1
: (pRes as any).gridHeight ?? 1;
const px = placed.gridX;
const py = placed.gridY;
for (let dx = 0; dx < ew && !overlaps; dx++) {
for (let dy = 0; dy < eh && !overlaps; dy++) {
const cx = x + dx;
const cy = y + dy;
for (let pdx = 0; pdx < pw; pdx++) {
for (let pdy = 0; pdy < ph; pdy++) {
if (px + pdx === cx && py + pdy === cy) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
}
}
if (overlaps) break;
}
if (!overlaps) spot = { x, y };
}
}
if (spot) {
remaining.push({ ...entry, gridX: spot.x, gridY: spot.y });
changed = true;
} else {
newVoid.push(entry);
}
}
} else {
newVoid.push(...voidStorage);
}
// Step 2: repack stored resources for the new grid size
for (const entry of storedResources) {
const res = entry.resource as Resource | undefined;
if (!res || typeof res !== "object") {
remaining.push(entry);
continue;
}
const rotated = entry.rotated ?? false;
const ew = rotated
? (res as any).gridHeight ?? 1
: (res as any).gridWidth ?? 1;
const eh = rotated
? (res as any).gridWidth ?? 1
: (res as any).gridHeight ?? 1;
const gx = entry.gridX;
const gy = entry.gridY;
const inBounds = gx + ew <= newX && gy + eh <= newY;
if (inBounds) {
let overlaps = false;
for (const placed of remaining) {
const pRes = placed.resource as Resource | undefined;
if (!pRes || typeof pRes !== "object") continue;
const pRotated = placed.rotated ?? false;
const pw = pRotated
? (pRes as any).gridHeight ?? 1
: (pRes as any).gridWidth ?? 1;
const ph = pRotated
? (pRes as any).gridWidth ?? 1
: (pRes as any).gridHeight ?? 1;
const px = placed.gridX;
const py = placed.gridY;
for (let dx = 0; dx < ew && !overlaps; dx++) {
for (let dy = 0; dy < eh && !overlaps; dy++) {
const cx = gx + dx;
const cy = gy + dy;
for (let pdx = 0; pdx < pw; pdx++) {
for (let pdy = 0; pdy < ph; pdy++) {
if (px + pdx === cx && py + pdy === cy) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
}
}
if (overlaps) break;
}
if (!overlaps) {
remaining.push(entry);
continue;
}
}
let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= newY - eh && !spot; y++) {
for (let x = 0; x <= newX - ew && !spot; x++) {
let overlaps = false;
for (const placed of remaining) {
const pRes = placed.resource as Resource | undefined;
if (!pRes || typeof pRes !== "object") continue;
const pRotated = placed.rotated ?? false;
const pw = pRotated
? (pRes as any).gridHeight ?? 1
: (pRes as any).gridWidth ?? 1;
const ph = pRotated
? (pRes as any).gridWidth ?? 1
: (pRes as any).gridHeight ?? 1;
const px = placed.gridX;
const py = placed.gridY;
for (let dx = 0; dx < ew && !overlaps; dx++) {
for (let dy = 0; dy < eh && !overlaps; dy++) {
const cx = x + dx;
const cy = y + dy;
for (let pdx = 0; pdx < pw; pdx++) {
for (let pdy = 0; pdy < ph; pdy++) {
if (px + pdx === cx && py + pdy === cy) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
}
}
if (overlaps) break;
}
if (!overlaps) spot = { x, y };
}
}
if (spot) {
remaining.push({ ...entry, gridX: spot.x, gridY: spot.y });
if (spot.x !== gx || spot.y !== gy) changed = true;
} else {
newVoid.push(entry);
changed = true;
}
}
if (changed) {
await req.payload.update({
collection: "game-structures",
id: gsDoc.id,
data: { storedResources: remaining, voidStorage: newVoid },
context: { skipGridRepack: true },
});
}
}
return data;
},
],
},
fields: [
{
label: "Identity & Visuals",

View file

@ -7,6 +7,23 @@ import { InventoryGrid } from "./InventoryGrid";
import { GridToolbar } from "./GridToolbar";
import { ResourceList } from "./ResourceList";
import { type GridItem } from "@/lib/logistics";
import { pluralize } from "@/lib/utils";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemSeparator,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
PackageOpenIcon,
AlertTriangleIcon,
DropletIcon,
ZapIcon,
BrainIcon,
} from "lucide-react";
import {
moveGridItem,
mergeGridStacks,
@ -14,8 +31,23 @@ import {
removeGridItem,
splitGridStack,
autoArrangeGrid,
removeResource,
retrieveFromVoid,
} from "@/app/(frontend)/logistics/structures/actions";
function getResourceIcon(type: Resource["type"]) {
switch (type) {
case "fluid":
return <DropletIcon className="size-4" />;
case "energy":
return <ZapIcon className="size-4" />;
case "abstract":
return <BrainIcon className="size-4" />;
default:
return <PackageOpenIcon className="size-4" />;
}
}
interface StructureStorageViewProps {
structure: GameStructure & { type: Structure };
}
@ -97,6 +129,19 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
if (result.success) refresh();
}, [structure.id, refresh]);
const handleWithdrawFromVoid = useCallback(
async (resourceId: number, amount: number) => {
const result = await removeResource(structure.id, resourceId, amount);
if (result.success) refresh();
},
[structure.id, refresh],
);
const handleRetrieveFromVoid = useCallback(async () => {
const result = await retrieveFromVoid(structure.id);
if (result.success) refresh();
}, [structure.id, refresh]);
if (!hasGrid) {
return (
<ResourceList
@ -106,6 +151,24 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
);
}
const voidStorageRaw = (structure as any).voidStorage ?? [];
const voidStorageItems = voidStorageRaw
.filter((entry: any) => {
const res = entry.resource as Resource;
return res && typeof res === "object" && "id" in res;
})
.map((entry: any) => ({
id: entry.id ?? `${(entry.resource as Resource).id}-void`,
resourceId: (entry.resource as Resource).id,
amount: entry.amount,
gridX: entry.gridX ?? 0,
gridY: entry.gridY ?? 0,
rotated: entry.rotated ?? false,
resource: entry.resource as Resource,
}));
const hasVoidStorage = voidStorageItems.length > 0;
return (
<div className="flex flex-col gap-4">
<GridToolbar
@ -139,6 +202,82 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
/>
)}
{hasVoidStorage && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2 text-amber-400">
<AlertTriangleIcon className="size-4" />
Void Storage
</ItemTitle>
<ItemDescription className="flex items-center gap-2">
<span>
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""} — these no longer fit in the grid.
</span>
<button
type="button"
onClick={handleRetrieveFromVoid}
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
>
Retrieve All to Grid
</button>
</ItemDescription>
</ItemHeader>
<ItemContent>
<div className="flex flex-col">
{voidStorageItems.map((entry: any, index: number) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
const mass = (resource.massPerUnit ?? 0) * entry.amount;
return (
<div key={entry.id ?? index}>
<div className="flex items-center gap-3 py-2.5">
<ItemMedia variant="icon">
{resource.icon ? (
<img
src={typeof resource.icon === "object" ? resource.icon.url ?? "" : ""}
alt={resource.name}
className="size-4 object-contain"
/>
) : (
getResourceIcon(resource.type)
)}
</ItemMedia>
<ItemContent className="flex-1 min-w-0">
<ItemTitle>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: resource.color ?? "#888" }}
/>
<span>{resource.name}</span>
</ItemTitle>
</ItemContent>
<div className="text-right shrink-0">
<div className="text-sm font-medium">
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
</div>
<div className="text-xs text-muted-foreground">
{mass > 0 && `${mass.toLocaleString()} kg`}
</div>
</div>
<button
type="button"
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
title="Withdraw from structure"
>
Withdraw
</button>
</div>
{index < voidStorageItems.length - 1 && <ItemSeparator />}
</div>
);
})}
</div>
</ItemContent>
</Item>
)}
</div>
);
}

View file

@ -70,6 +70,7 @@ export interface Config {
media: Media;
'game-hard-resources': GameHardResource;
'game-structures': GameStructure;
'game-event-logs': GameEventLog;
users: User;
ranks: Rank;
profiles: Profile;
@ -106,6 +107,7 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>;
'game-hard-resources': GameHardResourcesSelect<false> | GameHardResourcesSelect<true>;
'game-structures': GameStructuresSelect<false> | GameStructuresSelect<true>;
'game-event-logs': GameEventLogsSelect<false> | GameEventLogsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
ranks: RanksSelect<false> | RanksSelect<true>;
profiles: ProfilesSelect<false> | ProfilesSelect<true>;
@ -254,6 +256,19 @@ export interface GameStructure {
id?: string | null;
}[]
| null;
/**
* Items that no longer fit in the grid after a resize, stored here until retrieved.
*/
voidStorage?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@ -950,6 +965,52 @@ export interface Campaign {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-event-logs".
*/
export interface GameEventLog {
id: number;
/**
* System-generated events are created automatically. Uncheck for manual/GM narrative entries.
*/
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: (number | null) | User;
structure?: (number | null) | GameStructure;
/**
* Which collection the event primarily relates to.
*/
targetCollection?:
| (
| 'game-structures'
| 'structures'
| 'resources'
| 'assets'
| 'vehicles'
| 'factions'
| 'missions'
| 'campaigns'
| 'users'
| 'technologies'
| 'maps'
)
| null;
targetId?: number | null;
data?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@ -1429,6 +1490,10 @@ export interface PayloadLockedDocument {
relationTo: 'game-structures';
value: number | GameStructure;
} | null)
| ({
relationTo: 'game-event-logs';
value: number | GameEventLog;
} | null)
| ({
relationTo: 'users';
value: number | User;
@ -1595,6 +1660,33 @@ export interface GameStructuresSelect<T extends boolean = true> {
rotated?: T;
id?: T;
};
voidStorage?:
| T
| {
resource?: T;
amount?: T;
gridX?: T;
gridY?: T;
rotated?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-event-logs_select".
*/
export interface GameEventLogsSelect<T extends boolean = true> {
system?: T;
timestamp?: T;
type?: T;
message?: T;
actor?: T;
structure?: T;
targetCollection?: T;
targetId?: T;
data?: T;
updatedAt?: T;
createdAt?: T;
}

View file

@ -28,6 +28,7 @@ import { Technologies } from "@/collections/intelligence/Technologies";
import { GameRules } from "@/collections/game/GameRules";
import { GameStructures } from "@/collections/game/GameStructures";
import { GameHardResources } from "@/collections/game/GameHardResources";
import { GameEventLogs } from "@/collections/game/GameEventLogs";
import { Experience } from "@/collections/users/Experience";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer";
@ -68,6 +69,7 @@ export default buildConfig({
// Game
GameHardResources,
GameStructures,
GameEventLogs,
// Users
Users,

View file

@ -0,0 +1,36 @@
import type { EventType } from "./eventTypes";
interface GameEventInput {
type: EventType;
message: string;
actor?: number;
structure?: number;
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures";
targetId?: number;
data?: Record<string, unknown>;
}
export async function emitGameEvent(
payload: Awaited<ReturnType<typeof import("payload").getPayload>>,
event: GameEventInput,
) {
try {
await payload.create({
collection: "game-event-logs",
data: {
system: true,
timestamp: new Date().toISOString(),
type: event.type,
actor: event.actor,
message: event.message,
data: event.data,
targetCollection: event.targetCollection,
targetId: event.targetId,
structure: event.structure,
},
depth: 0,
});
} catch (err) {
console.error("[EventLog] Failed to emit event:", err);
}
}

View file

@ -0,0 +1,25 @@
export const EventTypes = {
// Storage
StorageDeposit: "storage:deposit",
StorageWithdraw: "storage:withdraw",
StorageTransfer: "storage:transfer",
StorageVoid: "storage:void",
StorageRecover: "storage:recover",
// Grid
GridPlace: "grid:place",
GridMove: "grid:move",
GridRotate: "grid:rotate",
GridMerge: "grid:merge",
GridSplit: "grid:split",
GridArrange: "grid:arrange",
// Structure
StructureResize: "structure:resize",
// Finance
FinanceDeposit: "finance:deposit",
FinanceWithdraw: "finance:withdraw",
} as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];