1
0
Fork 0

feat(wiki): add Documentation category restricted to admins and developers

New in-depth, non-developer guides for the core systems: logistics and storage rules, banking, market and negotiation, shipments, base management, and the map. Read stays open to any logged-in user; create and edit require admin or developer roles (enforced in the collection access functions and the wiki server actions, since the service layer runs with overrideAccess). Category escalation via update is blocked too. Ships the enum migration, regenerated schema/types, and the idempotent seed script with its markdown sources.
This commit is contained in:
Jason Fraley 2026-09-30 18:41:07 -04:00
parent 4b9184ce23
commit c9a3d23f96
18 changed files with 35668 additions and 9 deletions

View file

@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { LockdownBanner } from "@/components/frontend/wiki/LockdownBanner"; import { LockdownBanner } from "@/components/frontend/wiki/LockdownBanner";
import { WikiEditor } from "@/components/frontend/wiki/WikiEditor"; import { WikiEditor } from "@/components/frontend/wiki/WikiEditor";
import { listTemplates, templateMap } from "@/lib/wiki/service"; import { listTemplates, templateMap } from "@/lib/wiki/service";
import hasRoles from "@/utils/access-control/hasRoles";
export const metadata = { export const metadata = {
title: "Edit wiki page: Polaris Task Force", title: "Edit wiki page: Polaris Task Force",
@ -74,6 +75,31 @@ export default async function EditWikiPage({
); );
} }
const canEditDocumentation = hasRoles(["admin"], user);
if (page.category === "Documentation" && !canEditDocumentation) {
return (
<div className="flex flex-col gap-6 p-5">
<Link
href={`/wiki/${page.slug}`}
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Back to page
</Link>
<div className="flex items-center gap-2">
<PencilIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Edit {page.title}</h1>
</div>
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
Documentation pages are maintained by admins and developers. If something here is out
of date, contact the staff team.
</CardContent>
</Card>
</div>
);
}
const [templates, pagesResult, hasEditedRevisions] = await Promise.all([ const [templates, pagesResult, hasEditedRevisions] = await Promise.all([
listTemplates(payload), listTemplates(payload),
payload.find({ payload.find({
@ -132,6 +158,7 @@ export default async function EditWikiPage({
templateHtmlMap={templateHtmlMap(templates)} templateHtmlMap={templateHtmlMap(templates)}
existingPages={existingPages} existingPages={existingPages}
isFirstEdit={!hasEditedRevisions} isFirstEdit={!hasEditedRevisions}
canEditDocumentation={canEditDocumentation}
/> />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -5,6 +5,7 @@ import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { User, WikiPage } from "@/payload-types"; import type { User, WikiPage } from "@/payload-types";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import {
@ -21,6 +22,17 @@ interface ActionResult<T = undefined> {
data?: T; data?: T;
} }
/**
* Documentation-category pages are curated admin/developer content. The wiki
* service layer runs with overrideAccess, so this rule must be enforced here
* in addition to the collection access functions.
*/
function documentationEditError(user: User, effectiveCategory: string | undefined): string | null {
if (effectiveCategory !== "Documentation") return null;
if (hasRoles(["admin"], user)) return null;
return "Only admins and developers can create or edit Documentation pages.";
}
async function authenticate() { async function authenticate() {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
@ -70,6 +82,8 @@ export async function createWikiPage(input: {
}): Promise<ActionResult<{ id: number; slug: string }>> { }): Promise<ActionResult<{ id: number; slug: string }>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const docError = documentationEditError(user, input.category);
if (docError) return { success: false, error: docError };
const page = await createPage(payload, user, input); const page = await createPage(payload, user, input);
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.WikiPageCreate, type: EventTypes.WikiPageCreate,
@ -101,6 +115,8 @@ export async function updateWikiPage(input: {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const page = await fetchPage(payload, input.id); const page = await fetchPage(payload, input.id);
if (!page) return { success: false, error: "Wiki page not found." }; if (!page) return { success: false, error: "Wiki page not found." };
const docError = documentationEditError(user, input.category ?? page.category);
if (docError) return { success: false, error: docError };
const updated = await updatePage(payload, user, page, input); const updated = await updatePage(payload, user, page, input);
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.WikiPageEdit, type: EventTypes.WikiPageEdit,

View file

@ -5,6 +5,7 @@ import { getPayload } from "payload";
import { ArrowLeftIcon, FilePlus2Icon } from "lucide-react"; import { ArrowLeftIcon, FilePlus2Icon } from "lucide-react";
import { WikiEditor } from "@/components/frontend/wiki/WikiEditor"; import { WikiEditor } from "@/components/frontend/wiki/WikiEditor";
import { listTemplates, templateMap } from "@/lib/wiki/service"; import { listTemplates, templateMap } from "@/lib/wiki/service";
import hasRoles from "@/utils/access-control/hasRoles";
export const metadata = { export const metadata = {
title: "New wiki page (Polaris Task Force)", title: "New wiki page (Polaris Task Force)",
@ -90,6 +91,7 @@ export default async function NewWikiPage({
templateHtmlMap={templateHtmlMap(templates)} templateHtmlMap={templateHtmlMap(templates)}
existingPages={existingPages} existingPages={existingPages}
isFirstEdit={!hasEditedRevisions} isFirstEdit={!hasEditedRevisions}
canEditDocumentation={hasRoles(["admin"], user)}
/> />
</div> </div>
); );

View file

@ -1,5 +1,20 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import hasRoles from "@/utils/access-control/hasRoles";
/**
* Documentation-category pages are curated admin/developer content (in-depth
* system docs), unlike the rest of the wiki where any logged-in user can
* write. Read stays open to all logged-in users; delete/lock remain under the
* existing wiki moderator rules (intelligence qualification).
*/
function canEditDocumentationCategory(user: Parameters<typeof hasRoles>[1]): boolean {
return hasRoles(["admin"], user);
}
function isDocumentationCategory(category: unknown): boolean {
return typeof category === "string" && category === "Documentation";
}
export const WikiPages: CollectionConfig = { export const WikiPages: CollectionConfig = {
slug: "wiki-pages", slug: "wiki-pages",
@ -12,8 +27,39 @@ export const WikiPages: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user, create: ({ req, data }) => {
update: ({ req }) => !!req.user, if (!req.user) return false;
if (isDocumentationCategory(data?.category)) {
return canEditDocumentationCategory(req.user);
}
return true;
},
update: async ({ req, id, data }) => {
if (!req.user) return false;
// Check BOTH the incoming category and the existing doc's category: a
// plain user must not edit an existing Documentation page, and must not
// escalate their own page into Documentation either.
if (isDocumentationCategory(data?.category)) {
return canEditDocumentationCategory(req.user);
}
if (typeof id === "number") {
try {
const existing = (await req.payload.findByID({
collection: "wiki-pages",
id,
depth: 0,
overrideAccess: true,
})) as unknown as { category?: string } | null;
if (isDocumentationCategory(existing?.category)) {
return canEditDocumentationCategory(req.user);
}
} catch {
// Unknown id: let the operation proceed and fail validation later.
return true;
}
}
return true;
},
delete: async ({ req }) => { delete: async ({ req }) => {
return await hasIntelligenceQualification(req.payload, req.user); return await hasIntelligenceQualification(req.payload, req.user);
}, },
@ -47,6 +93,7 @@ export const WikiPages: CollectionConfig = {
{ label: "Media", value: "Media" }, { label: "Media", value: "Media" },
{ label: "Guides", value: "Guides" }, { label: "Guides", value: "Guides" },
{ label: "Meta", value: "Meta" }, { label: "Meta", value: "Meta" },
{ label: "Documentation", value: "Documentation" },
], ],
index: true, index: true,
}, },

View file

@ -44,6 +44,12 @@ interface WikiEditorProps {
readonly templateHtmlMap?: Record<string, string>; readonly templateHtmlMap?: Record<string, string>;
readonly existingPages: readonly { title: string }[]; readonly existingPages: readonly { title: string }[];
readonly isFirstEdit?: boolean; readonly isFirstEdit?: boolean;
/**
* Whether the current user may use the Documentation category (admin and
* developer roles). The server enforces this too; this only shapes the
* category picker.
*/
readonly canEditDocumentation?: boolean;
} }
function isWikiCategory(value: string): value is WikiCategory { function isWikiCategory(value: string): value is WikiCategory {
@ -70,6 +76,7 @@ export function WikiEditor({
templateHtmlMap = {}, templateHtmlMap = {},
existingPages, existingPages,
isFirstEdit = false, isFirstEdit = false,
canEditDocumentation = false,
}: WikiEditorProps) { }: WikiEditorProps) {
const router = useRouter(); const router = useRouter();
const bodyRef = useRef<HTMLTextAreaElement>(null); const bodyRef = useRef<HTMLTextAreaElement>(null);
@ -173,11 +180,18 @@ export function WikiEditor({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{WIKI_CATEGORIES.map((option) => ( {WIKI_CATEGORIES.map((option) => {
<SelectItem key={option} value={option}> if (option === "Documentation" && !canEditDocumentation) {
{option} // Keep the option visible-but-disabled when the page being
</SelectItem> // edited is already Documentation, so the value renders.
))} if (category !== "Documentation") return null;
}
return (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
);
})}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View file

@ -7,6 +7,7 @@ export const WIKI_CATEGORIES = [
"Media", "Media",
"Guides", "Guides",
"Meta", "Meta",
"Documentation",
] as const; ] as const;
export type WikiCategory = (typeof WIKI_CATEGORIES)[number]; export type WikiCategory = (typeof WIKI_CATEGORIES)[number];

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_wiki_pages_category" ADD VALUE 'Documentation';`);
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// PostgreSQL cannot remove enum values; the down migration is a no-op.
await db.execute(sql``);
}

View file

@ -94,6 +94,7 @@ import * as migration_20260925_235150_add_mission_role_signups from './20260925_
import * as migration_20260926_030859_add_mission_role_description from './20260926_030859_add_mission_role_description'; import * as migration_20260926_030859_add_mission_role_description from './20260926_030859_add_mission_role_description';
import * as migration_20260926_044319_add_mission_role_description_richtext from './20260926_044319_add_mission_role_description_richtext'; import * as migration_20260926_044319_add_mission_role_description_richtext from './20260926_044319_add_mission_role_description_richtext';
import * as migration_20260928_021138_change_ai_model from './20260928_021138_change_ai_model'; import * as migration_20260928_021138_change_ai_model from './20260928_021138_change_ai_model';
import * as migration_20260930_162200_add_documentation_wiki_category from './20260930_162200_add_documentation_wiki_category';
export const migrations = [ export const migrations = [
{ {
@ -576,4 +577,9 @@ export const migrations = [
down: migration_20260928_021138_change_ai_model.down, down: migration_20260928_021138_change_ai_model.down,
name: '20260928_021138_change_ai_model' name: '20260928_021138_change_ai_model'
}, },
{
up: migration_20260930_162200_add_documentation_wiki_category.up,
down: migration_20260930_162200_add_documentation_wiki_category.down,
name: '20260930_162200_add_documentation_wiki_category'
},
]; ];

View file

@ -902,6 +902,7 @@ export const enum_wiki_pages_category = pgEnum("enum_wiki_pages_category", [
"Media", "Media",
"Guides", "Guides",
"Meta", "Meta",
"Documentation",
]); ]);
export const enum_wiki_revisions_type = pgEnum("enum_wiki_revisions_type", [ export const enum_wiki_revisions_type = pgEnum("enum_wiki_revisions_type", [
"create", "create",

View file

@ -4080,7 +4080,7 @@ export interface WikiPage {
* URL slug. Generated by the service layer from the title. * URL slug. Generated by the service layer from the title.
*/ */
slug: string; slug: string;
category: 'Campaign' | 'World' | 'Lore' | 'Characters' | 'Plot' | 'Media' | 'Guides' | 'Meta'; category: 'Campaign' | 'World' | 'Lore' | 'Characters' | 'Plot' | 'Media' | 'Guides' | 'Meta' | 'Documentation';
tags?: string[] | null; tags?: string[] | null;
/** /**
* Markdown source. Rendered by the frontend. * Markdown source. Rendered by the frontend.

View file

@ -0,0 +1,60 @@
# Banking
The banking system handles money for people, factions, and the unit itself. You find it under **Logistics**, then **Banking**, in the sidebar. All accounts are held in the unit's main currency, configured by command in Game Rules, so the name and display labels you see on amounts follow that setup.
## Account types
There are three kinds of accounts:
- **Personal**: your own wallet. Every player can have one, and one is created automatically the first time you need it (for example when you receive your first payment). It starts at zero.
- **Treasury**: the unit's shared pool. Market sales of vendor stock flow into it, and staff salaries and structure maintenance are paid out of it (see [[Base Management]]).
- **Faction**: a shared pool owned by a specific faction rather than the unit as a whole.
## Who can create and manage accounts
- You can always create your **own personal account** from the Banking page. You cannot create personal accounts for other people.
- **Treasury and faction accounts require a manager** to create or touch. A manager is an admin, a developer, or a logistics-qualified member.
- Personal accounts are managed by their **owner or a manager**. Nobody else can deposit, withdraw, or transfer from your account.
## Everyday operations
Three actions cover daily use, all available from an account's detail page:
- **Deposit**: move funds from outside the system into an account.
- **Withdraw**: take funds out of an account.
- **Transfer**: move funds between two accounts in one step.
Transfers require both a source and a destination account, amounts must be positive, and both accounts must be open. A **frozen** or **closed** account rejects every transaction, and a source account must hold enough to cover the amount plus any fee.
## Transaction numbers
Every completed transaction gets a unique **transaction number** automatically, something like `TXN-MD3A9F-K7Q2`. You will see it on the transaction record alongside the type (deposit, withdrawal, transfer, payment, fee, salary, or adjustment), the accounts involved, the amount, any fee, and a memo. Quote this number whenever you need to point at a specific payment, for example when asking staff to trace a purchase from the [[Market and Negotiation]] or a shipment expense from [[Shipments]].
## Ledger entries
Behind each transaction, the system writes **one ledger entry per affected account**. Each entry records:
- the account it belongs to,
- a **signed amount**: positive for money in (a credit), negative for money out (a debit),
- the resulting balance after the entry,
- the transaction it belongs to and the time it happened.
So a 500 credit transfer from Alice to Bob produces two ledger entries: one at Alice's account for -500 and one at Bob's account for +500, both referencing the same transaction number. The ledger is the audit trail; balances are never adjusted without one.
## Finding your wallet
Open **Logistics**, then **Banking**. Your personal account appears as your wallet card at the top of the overview, showing your balance and quick actions. If you have never had one, opening the page creates it for you on the spot. From the same page you can open any account you are allowed to see, review its transaction history, and read its ledger table.
:::note
Balances are maintained by the transaction engine itself. Nobody edits a balance by hand; every change is a transaction with ledger entries, which is what makes the numbers trustworthy.
:::
## Quick reference
| Account type | Who can create it | Who can manage it |
| --- | --- | --- |
| Personal | Its owner only | Owner or a manager |
| Treasury | A manager | Managers only |
| Faction | A manager | Managers only |
Manager here means admin, developer, or logistics-qualified member.

View file

@ -0,0 +1,62 @@
# Base Management
Structures are not self-running. They need staff, they cost money, they eat upkeep materials, and they can be improved. All of that lives under the umbrella of base management, on the page of each structure (open it from **Logistics**, then **Structures**). Money side effects land in [[Banking]] accounts, and materials move through [[Logistics and Storage Rules]].
## Staffing
Two kinds of workers keep a structure running:
- **Named NPC staff** fill specific positions (administrative, technical, and other slots, each with its own cap per structure). You hire a particular NPC into a titled role; they can hold only one job at a time.
- **Labor headcount** blocks of workers by trade (for example builders or loaders). Instead of individuals, you set a headcount number per labor classification, capped by what the structure accepts and by how many workers of that trade are available. Paying above the market rate widens the pool you can draw from.
## The hiring gate
Staff hiring can be switched off globally by command through a flag in Game Rules. When it is off, every hire and headcount change is refused with a clear message, usually with a reason attached. This is deliberate: hiring is frozen between turns or during events, not broken.
## Salaries and the base tick
A periodic job called the **base tick** runs in the background. Each pass it pays every active salary out of the unit's **treasury account**, one transaction per staffing record, with the memo showing who or what was paid.
When the treasury cannot cover a salary, nothing is silently dropped: the payment is recorded as a **salary unpaid** event on the structure's event log, so commanders can see exactly which workers went unpaid and why. The same applies to maintenance below.
## Maintenance and upkeep
- **Maintenance cost**: every structure with a maintenance cost has it charged from the treasury each tick. Failure to pay is logged as a maintenance unpaid event.
- **Upkeep materials**: blueprints can require resources (fuel, parts, rations). Each tick the structure consumes what it needs from its storage (grid plus void). If storage falls short, the shortfall is flagged with an **upkeep shortage** event naming the resource, what it has, and what it needs.
:::warning
Upkeep draws from stored materials, not from the treasury. A full bank account does not save a depot that ran out of spare parts; keep the supply line running with [[Shipments]].
:::
## Upgrades
Many blueprints list a structure they **upgrade into**. Upgrading requires the listed materials to be present in the structure's storage. When you trigger the upgrade, the materials are consumed and the structure's type is swapped to the upgraded blueprint. The structure keeps its identity and location; it just becomes the better version.
## Modules
Structures can install **modules**: add-ons such as extra storage capacity or production boosts. Installing a module consumes its listed materials from the structure's storage. Removing one does **not** refund materials, so treat uninstalling as a last resort. Active modules add effects such as:
- extra storage capacity in kilograms,
- a multiplier on production output,
- extra grid storage slots.
## Construction
Newly placed structures (and some upgrades) do not start fully built. Delivering the blueprint's required materials to the site **starts a timed construction**: the site shows a completion timestamp, and when it passes, the structure flips to operational. Crew matters: the blueprint lists a required crew, and the size of your labor workforce (weighted by the trades' efficiency) scales how fast the clock runs. An understaffed site still builds, but slower.
You can watch construction progress on [[The Map]], where building sites show a progress bar.
## Compound hubs
Structures can be attached to a **compound hub** as child buildings. Operational children pool their storage into the hub, so a compound acts as one big warehouse. The map folds child buildings into their hub marker and the hub shows how many buildings it contains.
## Quick reference
| Topic | Where the resources come from |
| --- | --- |
| Salaries | Treasury account, paid by the base tick |
| Maintenance | Treasury account, per structure per tick |
| Upkeep | Structure storage (grid + void) |
| Upgrades and modules | Structure storage, consumed on install |
| Construction | Delivered materials, then a timed build |
| Compound storage | Pooled into the hub |

View file

@ -0,0 +1,48 @@
# Logistics and Storage Rules
Every base, depot, and outpost in the game world is a **structure**. Structures hold resources (fuel, ammo, building materials, food, and so on) in their storage, and nearly everything else in the logistics loop depends on them: shipments deliver into them, construction and upgrades consume from them, and upkeep is drawn from them each cycle.
You can view and manage a structure's storage by opening its page under **Logistics** in the sidebar. From there you can deposit resources you are carrying, withdraw them back out, and transfer stock between structures.
## How deposits work
When you deposit a resource, the system checks it against the structure's storage rules before anything moves. Storage comes in two flavors, and both count the same way:
- **Grid storage**: items placed in visible storage slots.
- **Void storage**: overflow stock tracked without a grid position.
When the rules calculate how much of a resource is "stored here", they add grid and void together. Hiding items in void storage does not dodge a cap.
## The three storage rules
Structures (defined by their blueprint type) can have three kinds of restrictions. They are always checked in the same order, and the first rule that fails rejects the deposit:
1. **Prohibited items.** The blueprint lists specific resources that may never be stored here. If your item is on the prohibited list, it is refused outright, no matter what.
2. **Whitelist mode.** If the structure's "restrict to allowed" switch is on, only resources explicitly on its allowed list can be stored. Anything not on the list is refused. You will see an amber "whitelist mode" banner on the structure page when this is active, and the storage dialog simply filters out items you cannot deposit.
3. **Per-item caps.** Each entry on the allowed list can carry a maximum quantity. If the structure already holds that much (grid plus void combined), further deposits of that item are refused until some is consumed or shipped out.
## When a deposit is rejected
A rejected deposit simply does not happen: nothing is lost and nothing moves. You get a clear message explaining why, for example:
```text
This resource is prohibited from storage at this structure.
This structure only accepts items on its allowed storage list: "Diesel" is not permitted.
Diesel is limited to 5,000 units here. You can add at most 1,200 more.
```
The same checks apply when transferring resources between structures, and again when a shipment tries to deliver (see [[Shipments]]). The storage dialog caps your deposit to whatever fits, so you usually will not hit these errors by accident; they mostly appear when the rules changed after stock was already en route.
## Where to see caps
Open a structure page and open its storage dialog. Each item shows how much is stored and, where a cap is defined, what that cap is. Items that are not permitted are filtered out or marked when whitelist mode is on. Structure detail pages also show the amber whitelist banner mentioned above.
Storage rules matter well beyond hauling crates: construction, upgrades, and modules all consume materials from a structure's storage (see [[Base Management]]), and market purchases land in your personal locker rather than a structure (see [[Market and Negotiation]]). Keeping the right stock in the right place is the core loop of [[Logistics and Storage Rules]] as a whole.
## Quick reference
| Rule | Checked | Effect |
| --- | --- | --- |
| Prohibited list | First | Listed items can never be stored here |
| Whitelist mode | Second | Only allowed-list items pass when restrict-to-allowed is on |
| Per-item cap | Last | Stored amount (grid + void) may not exceed the listed cap |

View file

@ -0,0 +1,62 @@
# Market and Negotiation
The market is the unit's flea market: a board of fixed-price listings where players sell gear from their personal locker and NPC vendors restock consumables and equipment. Prices are paid through the banking system, and purchases land directly in your locker. Open it under **Logistics**, then **Market**.
## Listing items from your locker
To sell something, open **Create listing** on the market page. Three conditions must hold:
1. The item type must be marked **tradeable**.
2. It must be **approved and live** (item types still going through review cannot be listed; the dialog disables them with a "not approved for trading yet" hint).
3. The locker entry must be **clean**: no attached equipment, no applied skin. Selling an entry with attachments would destroy them, so those entries are not eligible.
You set an asking price and optionally a minimum price. If your minimum is below your asking price, the listing is **negotiable**: buyers can make offers and you decide. Your listing stays up for **30 days**, after which it expires and the stock returns to your locker automatically.
## Buying outright
For any listing, hit **Buy** to pay the asking price. The funds move through banking (see [[Banking]]), the item is credited to your locker, and the listing closes. If a seller has several units in stock, you can pick a **partial quantity**; the listing stays active until the last unit sells.
## Making offers
For negotiable listings, use **Make offer** to propose a lower per-unit price. This works the same way against players and NPC vendors, but the replies differ:
- **Player sellers** get a notification and accept, reject, or counter at their leisure.
- **NPC vendors** answer immediately, in character, through a chat panel.
## How NPC haggling works
From your side of the table, vendor haggling follows predictable rules:
- Vendors **counter-offer** rather than accept lowball numbers outright. Their counters come down in steps as they concede toward your price.
- A vendor **never goes below their floor**. Every listing has a hidden minimum, and counters stop there no matter how long you haggle.
- **Your offers must keep rising.** Repeating an amount, or offering less than before, is rejected outright. Tiny increases (offering one credit more on an expensive item) are also treated as time-wasting.
- Vendors have a **patience meter**, visible while you bargain. Lowball offers and stall attempts push it up quickly, and reasonable offers let it relax slightly. If the meter fills, the negotiation thread **closes**, and from that point your only option is to buy at the full asking price.
The practical takeaway: open with a serious number, raise your offers in meaningful steps, and stop before the patience bar runs out.
## Expirations
Listings do not live forever:
- **Player listings** expire after 30 days and the stock returns to the seller's locker.
- **Auto-generated vendor listings** expire after 7 days and are replaced by fresh vendor stock on the next market cycle.
When a listing sells, expires, or is cancelled, any open negotiations on it are closed and the interested buyers are notified.
## Notifications
Negotiations keep you informed through the in-app notification bell: offers received, counters, acceptances, rejections, withdrawals, sales, and expirations all generate a notification with a link back to the listing. Check it regularly while a haggle is live, because a counter from a player seller waits for your answer.
:::note
Payment always flows through your bank account. Make sure your personal wallet (see [[Banking]]) holds the funds before you offer; purchases of vendor stock are paid into the unit treasury, which in turn funds salaries and maintenance (see [[Base Management]]).
:::
## Quick reference
| Topic | Value |
| --- | --- |
| Player listing duration | 30 days |
| Vendor listing duration | 7 days |
| Eligible items | Tradeable, approved and live, clean (no attachments or skins) |
| Offer rules | Strictly rising offers; stalls and lowballs rejected |
| Patience meter full | Thread closes; only full-price purchase remains |

View file

@ -0,0 +1,66 @@
# Shipments
Shipments move resources between structures. A truck of diesel from the fuel depot to a forward outpost, a load of timber to a construction site: that is a shipment. You create and track them under **Logistics**, then **Shipments**.
## Creating a shipment
A shipment needs four things:
1. **Origin**: a structure that stores the cargo (see [[Logistics and Storage Rules]]).
2. **Destination**: another structure on the same map.
3. **Cargo**: one or more resource or asset entries with amounts.
4. **Transport vehicle**: a deployed game vehicle to do the hauling.
When you confirm, the cargo is reserved, the vehicle is committed, and the shipment enters the queue.
## Distance, fuel, and time
For ground vehicles, the route follows the **road network** drawn on the map: the system finds the best road path from origin to destination, preferring faster roads (each road has a speed multiplier, so highways beat dirt tracks). The **distance** is the length of that road path. If the origin or destination cannot reach the road network, a ground shipment is refused with a clear error rather than guessed.
Air and sea vehicles travel in a straight line instead.
**Fuel cost** comes from the distance multiplied by the vehicle's consumption rate, and the **travel time** comes from the distance and the vehicle's speed. The vehicle's tank is drained continuously as it drives, so a long trip visibly consumes fuel while it runs.
## Shipment statuses
| Status | Meaning |
| --- | --- |
| Pending | Created but not yet dispatched |
| Dispatched | The vehicle has set off |
| In transit | Underway; fuel is burning and progress is tracked |
| Arrived | At the destination, delivery being processed |
| Completed | Cargo delivered successfully |
| Cancelled | Called off before delivery |
| Failed | Delivery refused; cargo returned to origin |
| Stranded | The vehicle ran out of fuel mid-route |
## Live progress
Open **The Map** (see [[The Map]]) and enable the shipments layer: every active shipment shows as a marker moving along its route between dispatch time and estimated arrival. The shipment detail page also shows progress, and it refreshes automatically through the realtime feed.
## Arrival checks and bounced cargo
Delivery is **not guaranteed**. When a shipment arrives, the destination's storage rules are checked again. If the destination prohibits the item, is in whitelist mode without your item on the list, or the per-item cap has no room, the affected cargo **bounces back to the origin structure** and the shipment is marked **failed**, with the reason recorded. Nothing is destroyed, but the trip (and its fuel) is wasted, so check the destination's rules before you dispatch.
:::warning
Storage caps count everything a structure holds, including stock that arrived while you were loading. A shipment that fit when planned can still bounce if the destination filled up in the meantime.
:::
## Auto-return
When creating a shipment you can tick **auto-return**. Once delivery completes, the vehicle automatically heads back to the origin as an empty return trip (fuel permitting), so it is back in position for the next job without a manual step. The return leg appears as its own shipment named after the original.
## Stranded shipments
If a vehicle's tank cannot cover the burn for the distance remaining, the shipment is marked **stranded** and the vehicle is flagged as damaged. Cargo stays where it stopped. Stranding usually means someone dispatched a vehicle without enough fuel for the round trip; refuel and recover the vehicle, then re-create the shipment.
## Quick reference
| Item | Detail |
| --- | --- |
| Ground route | Along roads, faster roads preferred |
| Air / sea route | Straight line |
| Fuel cost | Distance times vehicle consumption rate |
| Storage rules | Re-checked on arrival; refusals bounce cargo to origin |
| Auto-return | Empty trip back to origin after delivery |
| Stranded | Out of fuel mid-route; vehicle flagged damaged |

View file

@ -0,0 +1,72 @@
# The Map
The map screen is the unit's picture of the theater: where structures stand, what resources sit nearby, how roads connect them, and where shipments currently are. Open it from the sidebar entry **Map**. You first pick a map from a grid of available maps (the unit's world map plus mission-specific maps), then the map view itself opens.
## Layers
The toolbar lets you toggle each layer on and off:
- **Structures**: bases, depots, outposts.
- **Resource nodes**: places where resources can be gathered or extracted.
- **Roads**: the network ground vehicles actually drive along.
- **Zones**: colored areas such as water, territory borders, or named compounds.
- **Shipments**: cargo currently in transit (see [[Shipments]]).
## Coordinates are meters
The map uses a meter-based grid per map, not latitude and longitude. The origin sits at the top-left corner, and the y axis runs **southward**, matching Arma's convention. Any distance you see (placement ranges, road lengths, aura radii) is plain meters on that grid.
## Structure pins
Structures render as status-colored pins:
- **Emerald building icon**: operational.
- **Blue hammer**: under construction, with a progress bar.
- **Amber package**: awaiting materials before construction can start.
Popovers on each pin link straight to the structure's detail page, where staffing and storage live (see [[Base Management]] and [[Logistics and Storage Rules]]).
## Placing new structures
Members with the logistics qualification can enter **placement mode** and click the map to site a new structure. A dialog collects the blueprint, name, and faction. The server validates every placement:
- **Water rules**: land blueprints cannot sit in water zones, and water blueprints (docks and the like) need a water zone. With no zones drawn on the map, everything is allowed.
- **Proximity**: blueprints that depend on resources must sit within the configured range of a matching resource node, and some blueprints must keep their distance from other structures.
Placement is refused with a clear error when a rule fails, so experimenting on the map is safe.
## Roads and travel speed
Roads are drawn as lines and carry a **speed multiplier** that says how fast vehicles move along them. Ground shipments route along these roads, and faster roads win when the system picks a route. That same routing produces the distance and fuel figures on [[Shipments]].
## Zones are informational
Zones mostly exist to describe the world: water, land, territory and border lines, or generic areas like named compounds. **Only water blocks placement**; territory and border zones never restrict anything, they inform.
## Compounds
A structure can be attached as a **child** of a compound hub. On the map, children fold into their hub: their labels are hidden and the hub marker reports the building count. The hub page shows the pooled storage of all operational children.
## Shipment markers
Active shipments appear as markers that **move along their routes** between dispatch time and estimated arrival, so you can watch a convoy make progress without opening the shipment page. The layer refreshes automatically as the game tick runs.
## Labels
The map draws text labels so you are not hovering constantly:
- **Structure names** persist, with a priority system that hides weaker labels in crowded clusters; zoom in and the suppressed ones appear.
- **Road names** follow the road's curve. Paved roads are always labeled, while dirt roads and trails only get labels once you are zoomed in close.
- **Zone names** render near the center of each polygon.
## Quick reference
| Feature | Detail |
| --- | --- |
| Coordinates | Meters per map, origin top-left, y runs south |
| Operational structure | Emerald building pin |
| Under construction | Blue hammer pin with progress bar |
| Awaiting materials | Amber package pin |
| Placement | Logistics-qualified users; water and proximity rules enforced |
| Zones | Informational, except water which blocks land placement |
| Ground routing | Along roads, weighted by speed multiplier |

View file

@ -0,0 +1,97 @@
import { readdir, readFile } from "node:fs/promises";
import { getPayload } from "payload";
import config from "@payload-config";
import { slugify } from "@/lib/wiki/slugify";
/**
* Seeds the Documentation wiki category with in-depth, non-developer guides
* for the app's core systems. Idempotent: pages are keyed by slug; existing
* pages get their body updated when the markdown changed, missing pages are
* created. Run with: bun run src/tools/seed/seedDocumentation.ts
*
* Content lives as plain markdown files in ./documentation/ next to this
* script; the first H1 line of each file becomes the page title.
*/
interface DocFile {
title: string;
slug: string;
body: string;
}
function parseDocFile(fileName: string, raw: string): DocFile {
const normalized = raw.replace(/\r\n/g, "\n").trim();
const match = /^# (.+)$/m.exec(normalized);
if (!match) {
throw new Error(`${fileName}: first heading (H1) is missing; cannot derive a page title.`);
}
const title = match[1].trim();
const body = normalized.slice(normalized.indexOf("\n") + 1).trim();
return { title, slug: slugify(title), body };
}
const payload = await getPayload({ config });
const docsDir = new URL("./documentation/", import.meta.url);
const fileNames = (await readdir(docsDir)).filter((name) => name.endsWith(".md")).sort();
let created = 0;
let updated = 0;
let unchanged = 0;
for (const fileName of fileNames) {
const raw = await readFile(new URL(fileName, docsDir), "utf8");
const doc = parseDocFile(fileName, raw);
const existing = await payload.find({
collection: "wiki-pages",
where: { slug: { equals: doc.slug } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const current = existing.docs[0];
if (current) {
if (current.title === doc.title && current.body === doc.body) {
unchanged++;
continue;
}
await payload.update({
collection: "wiki-pages",
id: current.id,
data: {
title: doc.title,
body: doc.body,
category: "Documentation",
},
overrideAccess: true,
depth: 0,
});
updated++;
payload.logger.info(`[seed-documentation] updated: ${doc.title}`);
} else {
await payload.create({
collection: "wiki-pages",
data: {
title: doc.title,
slug: doc.slug,
category: "Documentation",
tags: ["documentation"],
body: doc.body,
},
overrideAccess: true,
depth: 0,
});
created++;
payload.logger.info(`[seed-documentation] created: ${doc.title}`);
}
}
payload.logger.info(
`[seed-documentation] done: ${created} created, ${updated} updated, ${unchanged} unchanged (${fileNames.length} files).`,
);
// CLI script: the Payload pool keeps the event loop alive after completion;
// exit explicitly (same convention as the other seed tools and bins).
process.exit(0);