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:
parent
d88fe04b56
commit
59769076ff
10 changed files with 868 additions and 35 deletions
12
AGENTS.md
12
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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
94
src/collections/game/GameEventLogs.ts
Normal file
94
src/collections/game/GameEventLogs.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
36
src/utils/event-log/emit.ts
Normal file
36
src/utils/event-log/emit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/utils/event-log/eventTypes.ts
Normal file
25
src/utils/event-log/eventTypes.ts
Normal 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];
|
||||
Loading…
Reference in a new issue