diff --git a/AGENTS.md b/AGENTS.md index 4c247a9..081493a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/app/(frontend)/logistics/structures/actions.ts b/src/app/(frontend)/logistics/structures/actions.ts index 5e35a94..d20d135 100644 --- a/src/app/(frontend)/logistics/structures/actions.ts +++ b/src/app/(frontend)/logistics/structures/actions.ts @@ -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) => { - const res = e.resource; - return !(typeof res === "object" && res !== null && "id" in res - ? res.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 }; - } - - const updatedIndex = existing.findIndex((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 as any).id === resourceId : res === resourceId; }); - existing[updatedIndex] = { ...existing[updatedIndex], amount: newAmount }; + 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); + } + } + + // 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; + }); + + 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 { + 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(); + 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 { try { const { payload, user } = await authenticate(); @@ -792,15 +896,17 @@ export async function autoArrangeGrid(structureId: number): Promise ({ + // 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(); + 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" diff --git a/src/collections/game/GameEventLogs.ts b/src/collections/game/GameEventLogs.ts new file mode 100644 index 0000000..9bfae88 --- /dev/null +++ b/src/collections/game/GameEventLogs.ts @@ -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", + }, + ], +}; diff --git a/src/collections/game/GameStructures.ts b/src/collections/game/GameStructures.ts index afacc02..a234457 100644 --- a/src/collections/game/GameStructures.ts +++ b/src/collections/game/GameStructures.ts @@ -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, + }, + ], + }, ], }; diff --git a/src/collections/logistics/Structures.ts b/src/collections/logistics/Structures.ts index 9ca8277..49a3d52 100644 --- a/src/collections/logistics/Structures.ts +++ b/src/collections/logistics/Structures.ts @@ -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", diff --git a/src/components/frontend/storage/StructureStorageView.tsx b/src/components/frontend/storage/StructureStorageView.tsx index c8db8e1..527d4f2 100644 --- a/src/components/frontend/storage/StructureStorageView.tsx +++ b/src/components/frontend/storage/StructureStorageView.tsx @@ -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 ; + case "energy": + return ; + case "abstract": + return ; + default: + return ; + } +} + 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 ( { + 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 (
)} + {hasVoidStorage && ( + + + + + Void Storage + + + + {voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""} — these no longer fit in the grid. + + + + + +
+ {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 ( +
+
+ + {resource.icon ? ( + {resource.name} + ) : ( + getResourceIcon(resource.type) + )} + + + + + {resource.name} + + +
+
+ {entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)} +
+
+ {mass > 0 && `${mass.toLocaleString()} kg`} +
+
+ +
+ {index < voidStorageItems.length - 1 && } +
+ ); + })} +
+
+
+ )}
); } diff --git a/src/payload-types.ts b/src/payload-types.ts index 330afda..9ba57b3 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | MediaSelect; 'game-hard-resources': GameHardResourcesSelect | GameHardResourcesSelect; 'game-structures': GameStructuresSelect | GameStructuresSelect; + 'game-event-logs': GameEventLogsSelect | GameEventLogsSelect; users: UsersSelect | UsersSelect; ranks: RanksSelect | RanksSelect; profiles: ProfilesSelect | ProfilesSelect; @@ -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 { 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 { + system?: T; + timestamp?: T; + type?: T; + message?: T; + actor?: T; + structure?: T; + targetCollection?: T; + targetId?: T; + data?: T; updatedAt?: T; createdAt?: T; } diff --git a/src/payload.config.ts b/src/payload.config.ts index b34153e..eaaf339 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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, diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts new file mode 100644 index 0000000..ec0bb98 --- /dev/null +++ b/src/utils/event-log/emit.ts @@ -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; +} + +export async function emitGameEvent( + payload: Awaited>, + 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); + } +} diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts new file mode 100644 index 0000000..faac7b1 --- /dev/null +++ b/src/utils/event-log/eventTypes.ts @@ -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];