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
|
- **logistics/** — Assets, Resources, Vehicles, Structures
|
||||||
- **world/** — Maps, NarrativeEvents
|
- **world/** — Maps, NarrativeEvents
|
||||||
- **server/** — MissionFiles, ModLists
|
- **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`.
|
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
|
## Auth
|
||||||
|
|
||||||
Username-based login (no email login). Users log in via Payload admin with `username` only.
|
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 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." };
|
return { success: false, error: "This resource is not stored here." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount > currentAmount) {
|
if (amount > totalAmount) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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 ?? [])];
|
let remainingToRemove = amount;
|
||||||
const newAmount = currentAmount - amount;
|
const storedResources = [...(structure.storedResources ?? [])];
|
||||||
|
const voidStorage = [...((structure as any).voidStorage ?? [])];
|
||||||
|
|
||||||
if (newAmount <= 0) {
|
// Remove from void storage first — void should empty itself before touching the grid
|
||||||
const filtered = existing.filter((e) => {
|
const voidIdx = voidStorage.findIndex((e: any) => {
|
||||||
const res = e.resource;
|
const res = e.resource;
|
||||||
return !(typeof res === "object" && res !== null && "id" in res
|
return typeof res === "object" && res !== null && "id" in res
|
||||||
? res.id === resourceId
|
? (res as any).id === resourceId
|
||||||
: res === resourceId);
|
: res === resourceId;
|
||||||
});
|
});
|
||||||
const updated = await payload.update({
|
|
||||||
collection: "game-structures",
|
if (voidIdx >= 0) {
|
||||||
id: structureId,
|
const entry = voidStorage[voidIdx];
|
||||||
data: { storedResources: filtered },
|
if (entry.amount > remainingToRemove) {
|
||||||
});
|
voidStorage[voidIdx] = { ...entry, amount: entry.amount - remainingToRemove };
|
||||||
return { success: true, data: updated as unknown as GameStructure };
|
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;
|
const res = e.resource;
|
||||||
return typeof res === "object" && res !== null && "id" in res
|
return typeof res === "object" && res !== null && "id" in res
|
||||||
? res.id === resourceId
|
? res.id === resourceId
|
||||||
: res === 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({
|
const updated = await payload.update({
|
||||||
collection: "game-structures",
|
collection: "game-structures",
|
||||||
id: structureId,
|
id: structureId,
|
||||||
data: { storedResources: existing },
|
data: { storedResources, voidStorage },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, data: updated as unknown as GameStructure };
|
return { success: true, data: updated as unknown as GameStructure };
|
||||||
|
|
@ -326,12 +343,14 @@ export async function transferResource(
|
||||||
const fromStructure = await getStructure(payload, fromStructureId);
|
const fromStructure = await getStructure(payload, fromStructureId);
|
||||||
const toStructure = await getStructure(payload, toStructureId);
|
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) {
|
if (sourceAmount < amount) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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> {
|
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
|
|
@ -792,15 +896,17 @@ export async function autoArrangeGrid(structureId: number): Promise<GridActionRe
|
||||||
const gridH = structure.type.ySize ?? 0;
|
const gridH = structure.type.ySize ?? 0;
|
||||||
if (!gridW || !gridH) return { success: false, error: "Structure has no grid dimensions." };
|
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,
|
...e,
|
||||||
_sortKey: e.gridY * 1000 + e.gridX,
|
_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;
|
const res = item.resource as Resource;
|
||||||
if (!res || typeof res === "number") continue;
|
if (!res || typeof res === "number") continue;
|
||||||
const rotated = item.rotated ?? false;
|
const rotated = item.rotated ?? false;
|
||||||
|
|
@ -844,13 +950,82 @@ export async function autoArrangeGrid(structureId: number): Promise<GridActionRe
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!spotFound) {
|
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);
|
const updated = placed.map(({ _sortKey, ...rest }) => rest);
|
||||||
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: updated } });
|
const overflowItems = overflow.map(({ _sortKey, ...rest }) => rest);
|
||||||
return { success: true };
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
return message === "Unauthorized"
|
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 { CollectionConfig } from "payload";
|
||||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||||
|
import type { Resource } from "@/payload-types";
|
||||||
|
|
||||||
export const Structures: CollectionConfig = {
|
export const Structures: CollectionConfig = {
|
||||||
slug: "structures",
|
slug: "structures",
|
||||||
|
|
@ -7,6 +8,226 @@ export const Structures: CollectionConfig = {
|
||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
group: "Logistics",
|
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: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: "Identity & Visuals",
|
label: "Identity & Visuals",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,23 @@ import { InventoryGrid } from "./InventoryGrid";
|
||||||
import { GridToolbar } from "./GridToolbar";
|
import { GridToolbar } from "./GridToolbar";
|
||||||
import { ResourceList } from "./ResourceList";
|
import { ResourceList } from "./ResourceList";
|
||||||
import { type GridItem } from "@/lib/logistics";
|
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 {
|
import {
|
||||||
moveGridItem,
|
moveGridItem,
|
||||||
mergeGridStacks,
|
mergeGridStacks,
|
||||||
|
|
@ -14,8 +31,23 @@ import {
|
||||||
removeGridItem,
|
removeGridItem,
|
||||||
splitGridStack,
|
splitGridStack,
|
||||||
autoArrangeGrid,
|
autoArrangeGrid,
|
||||||
|
removeResource,
|
||||||
|
retrieveFromVoid,
|
||||||
} from "@/app/(frontend)/logistics/structures/actions";
|
} 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 {
|
interface StructureStorageViewProps {
|
||||||
structure: GameStructure & { type: Structure };
|
structure: GameStructure & { type: Structure };
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +129,19 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
}, [structure.id, 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) {
|
if (!hasGrid) {
|
||||||
return (
|
return (
|
||||||
<ResourceList
|
<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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<GridToolbar
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export interface Config {
|
||||||
media: Media;
|
media: Media;
|
||||||
'game-hard-resources': GameHardResource;
|
'game-hard-resources': GameHardResource;
|
||||||
'game-structures': GameStructure;
|
'game-structures': GameStructure;
|
||||||
|
'game-event-logs': GameEventLog;
|
||||||
users: User;
|
users: User;
|
||||||
ranks: Rank;
|
ranks: Rank;
|
||||||
profiles: Profile;
|
profiles: Profile;
|
||||||
|
|
@ -106,6 +107,7 @@ export interface Config {
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
media: MediaSelect<false> | MediaSelect<true>;
|
||||||
'game-hard-resources': GameHardResourcesSelect<false> | GameHardResourcesSelect<true>;
|
'game-hard-resources': GameHardResourcesSelect<false> | GameHardResourcesSelect<true>;
|
||||||
'game-structures': GameStructuresSelect<false> | GameStructuresSelect<true>;
|
'game-structures': GameStructuresSelect<false> | GameStructuresSelect<true>;
|
||||||
|
'game-event-logs': GameEventLogsSelect<false> | GameEventLogsSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
ranks: RanksSelect<false> | RanksSelect<true>;
|
ranks: RanksSelect<false> | RanksSelect<true>;
|
||||||
profiles: ProfilesSelect<false> | ProfilesSelect<true>;
|
profiles: ProfilesSelect<false> | ProfilesSelect<true>;
|
||||||
|
|
@ -254,6 +256,19 @@ export interface GameStructure {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| 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;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -950,6 +965,52 @@ export interface Campaign {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
|
|
@ -1429,6 +1490,10 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'game-structures';
|
relationTo: 'game-structures';
|
||||||
value: number | GameStructure;
|
value: number | GameStructure;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'game-event-logs';
|
||||||
|
value: number | GameEventLog;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: number | User;
|
value: number | User;
|
||||||
|
|
@ -1595,6 +1660,33 @@ export interface GameStructuresSelect<T extends boolean = true> {
|
||||||
rotated?: T;
|
rotated?: T;
|
||||||
id?: 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;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { Technologies } from "@/collections/intelligence/Technologies";
|
||||||
import { GameRules } from "@/collections/game/GameRules";
|
import { GameRules } from "@/collections/game/GameRules";
|
||||||
import { GameStructures } from "@/collections/game/GameStructures";
|
import { GameStructures } from "@/collections/game/GameStructures";
|
||||||
import { GameHardResources } from "@/collections/game/GameHardResources";
|
import { GameHardResources } from "@/collections/game/GameHardResources";
|
||||||
|
import { GameEventLogs } from "@/collections/game/GameEventLogs";
|
||||||
import { Experience } from "@/collections/users/Experience";
|
import { Experience } from "@/collections/users/Experience";
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
@ -68,6 +69,7 @@ export default buildConfig({
|
||||||
// Game
|
// Game
|
||||||
GameHardResources,
|
GameHardResources,
|
||||||
GameStructures,
|
GameStructures,
|
||||||
|
GameEventLogs,
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
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