# CyberNet Modding Guide — F Framework

> **Status:** v0 — server-side Lua content (chat commands, persistence,
> notifications, waypoints, NPC spawn). Bidirectional auto-bound RPC,
> hot-reload, in-game toast widget all queued for later slices.
> See [ROADMAP.md §1H](ROADMAP.md) for the full scope plan.

CyberNet servers are extensible without C++ or Redscript — you write
Lua "resources" that drop into the server's `resources/` directory and
hook into chat, player lifecycle, and persistence. This guide is the
authoring reference.

---

## Quickstart — Hello World

1. Make a folder named after your resource. The folder name is what
   shows up in logs.
   ```
   /opt/cybernet/server/cyberfloom/resources/my_first_mod/
   ```
2. Put an `init.lua` inside:
   ```lua
   -- resources/my_first_mod/init.lua
   cybernet.chat.on_command("greet", function(playerID, args, raw)
       cybernet.chat.say(playerID, "hello from my_first_mod!")
   end)

   cybernet.log.info("my_first_mod loaded")
   ```
3. Restart the server (`sudo systemctl restart cybernet-server.service`).
4. Open the admin console at `cybernet.gg/admin-server.html` or run
   `cybernet-admin` from the server shell. Type `/lua greet`. You'll
   see `[Console/reply] server: hello from my_first_mod!` in the live
   log.

That's a complete CyberNet resource. The rest of this doc covers
everything else you can plug into.

---

## Resource Anatomy

Two layouts are supported. Both work; pick the one that fits.

### Single-file (quickest)

```
resources/
└── <your_mod>/
    └── init.lua         (entry script — also accepts server.lua)
```

Good for small mods (~200 LOC). The file runs once at load
time and registers all your handlers, hooks, and timers.

### Multi-file with `manifest.lua` (anything larger)

```
resources/
└── <your_mod>/
    ├── manifest.lua     (declarative — what to load, metadata)
    ├── config.lua       (your data + constants)
    ├── handlers.lua     (your logic)
    └── ...
```

The `manifest.lua` file is plain Lua that sets top-level
globals the loader reads. Minimal example:

```lua
-- resources/my_mod/manifest.lua
version     = "1.0.0"
author      = "Your handle"
description = "What this mod does"

server_scripts = {
    "config.lua",
    "handlers.lua",
}
```

The listed files run **in order**, sharing **one Lua state**.
That means globals defined in `config.lua` are visible in
`handlers.lua` (and to any later script). It's the same model
as FiveM: a manifest lists files; the framework concatenates
them into one execution context.

**Manifest keys** (all optional except `server_scripts`):

| Key | Type | Use |
|---|---|---|
| `version` | string | Shown in `/resources` |
| `author` | string | Credit + contact |
| `description` | string | One-line summary, shown in `/resources` |
| `server_scripts` | string array | Files to run, in declared order |
| `client_scripts` | string array | Recorded, **not yet executed** — staged for when the Lua client runtime ships (bidirectional RPC slice). Safe to declare today; lights up later. |
| `shared_scripts` | string array | Recorded, not yet executed — same as above, but for code that's identical on server + client. |
| `dependencies` | string array | Names of other resources this one needs. The loader logs a warning if a dep isn't loaded; load order is **not** auto-enforced in v1 — `/start` your deps first. |

**Detection order:** the loader looks for `manifest.lua` first,
then `init.lua`, then `server.lua`. The first one it finds
wins.

**Authoring conventions:**

- The directory name **is** the resource name. The manifest
  has no `name` key — confusion-free.
- Files in `server_scripts` are paths *relative to the
  resource directory*. Subfolders are fine: `"server/main.lua"`.
- All files share one Lua state — variables you make `local`
  are local-to-the-FILE; assign without `local` (or to `_G.foo`)
  to expose them to other files in the same resource.
- Naming convention: UPPERCASE for config globals you want to
  share across files. Lowercase for local helpers.

---

## Lifecycle Hooks

Resources can register callbacks for the major server events:

```lua
-- Fires once per player just after they finish connecting.
cybernet.on_player_connect(function(playerID)
    cybernet.notify(playerID, "Welcome to Night City.", 0, 5000)
end)

-- Fires when a player drops (clean leave, timeout, kick — all paths).
cybernet.on_player_disconnect(function(playerID)
    cybernet.log.info("player " .. playerID .. " left")
end)

-- Fires roughly every 100ms (10 Hz). dt is seconds since the
-- previous tick — use it for time-based logic so your code is
-- frame-rate independent.
cybernet.on_tick(function(dt)
    -- e.g., spawn a wave of enemies if nobody is on cooldown
end)
```

Each registration **replaces** the previous one for that resource —
so calling `cybernet.on_tick(fn)` twice keeps only the last function.

> **Legacy global names** (`function onPlayerConnect(...)`,
> `function onTick(...)`, `function onPlayerDisconnect(...)`) still
> work for back-compat with the `gang_hit` example. Prefer the
> namespaced `cybernet.on_*` hooks for new resources.

---

## Chat Commands

This is the headline F framework primitive — players type a `/cmd`
in the chat HUD, your Lua handler runs server-side, you reply.

```lua
-- Signature: cybernet.chat.on_command(name, fn)
-- fn(playerID, args, raw)
--   playerID — the player who typed the command (0 = server console)
--   args     — array of whitespace-separated tokens after the verb
--   raw      — everything after the verb as one string

cybernet.chat.on_command("flip", function(playerID, args)
    local choice = math.random(2) == 1 and "heads" or "tails"
    cybernet.chat.say(playerID, "coin: " .. choice)
end)

cybernet.chat.on_command("roll", function(playerID, args)
    local sides = tonumber(args[1]) or 6
    cybernet.chat.say(playerID,
        "d" .. sides .. ": " .. tostring(math.random(sides)))
end)
```

Replying:

```lua
-- Send a chat line to a single player (appears as "server: <msg>")
cybernet.chat.say(playerID, "you got it")

-- Or to multiple
cybernet.chat.say({pid1, pid2}, "team message")
```

Command-name resolution is **first-match-wins** across loaded
resources. If two resources both register `/foo`, whichever loaded
first wins; the second is silently shadowed. Convention: prefix
commands with your resource name (`/my_mod_foo`) when you need
isolation.

The leading `/` is optional in registration — `cybernet.chat.on_command("/foo", fn)`
and `cybernet.chat.on_command("foo", fn)` register the same handler.

---

## Players

Reading player state (read-only):

```lua
-- All currently-connected player IDs
local ids = cybernet.player.list()
-- {1, 2, 7}

local n = cybernet.player.count()
-- 3

-- Full snapshot for one player (or nil if not connected)
local p = cybernet.player.get(7)
-- p = { id, name, x, y, z, yaw, vx, vy, vz, health, armor,
--        balance, xp, in_vehicle, vehicle_seat, stance,
--        is_dead, in_combat, is_real }
-- is_real == false for fake/console players

-- Lookup by display name (case-sensitive, exact match)
local id = cybernet.player.find_by_name("Selekt")
-- nil if absent
```

Mutating player state:

```lua
cybernet.player.award_eddies(playerID, 500, "tip")
local ok = cybernet.player.charge_eddies(playerID, 200, "vendor: ripper")
-- ok == false if insufficient balance; charge wasn't applied

cybernet.player.award_xp(playerID, 50)

-- Move the player to a world position (requires plugin
-- handleSpawnAtPosition wired — currently mid-session teleport
-- needs the row 1/11 Selekt rebuild)
cybernet.player.teleport(playerID, -1305.5, 1267.8, 11.2, 180)
```

### Health

Every connected player has a virtual `(current, max)` HP record
seeded to `(100, 100)` on connect, dropped on disconnect. The
state is server-authoritative — your resources own who lives
and dies. The plugin half that mirrors writes through to
CP2077's real `StatPoolsSystem` is queued for the next
Selekt rebuild; the API + on-the-wire packets are final, so
mods authored today against `damage` / `on_death` keep working
unchanged when the plugin lights up.

```lua
local current, max = cybernet.player.get_health(playerID)
-- nil if the player isn't connected

cybernet.player.set_health(playerID, 50)      -- clamped to [0, max]
cybernet.player.set_max_health(playerID, 200) -- raises max + lets heal go higher

-- Apply damage. Returns the resulting current HP. Optional
-- attackerID flows to on_damage / on_death hooks (0 = environment).
local hp = cybernet.player.damage(victimID, 25, attackerID)

-- Heal up to max. Does NOT auto-revive — heal on a 0-HP player
-- is a no-op so survival mods can implement a "downed" state.
cybernet.player.heal(playerID, 25)

cybernet.player.revive(playerID)   -- back to full HP
cybernet.player.is_alive(playerID) -- bool

-- Hooks. Multiple subscribers fire in registration order;
-- one resource's bad hook doesn't stop the others.
cybernet.player.on_damage(function(victim, attacker, amount, newHp)
    if newHp < 25 then
        cybernet.notify(victim, "Critical!", 2)
    end
end)

cybernet.player.on_death(function(victim, killer)
    if killer ~= 0 and killer ~= victim then
        cybernet.player.award_eddies(killer, 100, "PvP kill")
    end
end)
```

`damage` fires `on_damage` whenever HP changes; `set_health`,
`damage`, and `revive` all fire `on_death` exactly once when
the write crosses `current > 0 → current == 0` (re-killing a
corpse stays silent). Every state change broadcasts a
`HEALTH_UPDATE` packet so HUD widgets and ghost-side StatPool
mirrors stay in sync.

See [`resources/health_arena/`](https://gitlab.com/c3jones3/cybernet/-/tree/feat/blank-slate-world/server/cyberfloom/resources/health_arena)
for the canonical end-to-end demo — chat verbs `/hp /hurt /heal
/kill /revive /maxhp` exercise every binding against any
connected player.

### Inventory

Per-player item counts, persisted to disk
(`state/inventory/<player>.inv`). The store is **dedicated** —
not shared with `cybernet.store` — so resources can't
accidentally clobber inventory by deleting a generic K/V key.
Items survive disconnect: a player who quits with 5 MedKits
reconnects with 5 MedKits.

```lua
-- Read
local n = cybernet.player.count_item(playerID, "Items.MedKitGangerStandard")
-- 0 if absent or player unknown

local ok = cybernet.player.has_item(playerID, "Items.MedKitGangerStandard")
-- defaults to threshold = 1
local ok = cybernet.player.has_item(playerID, "Items.MedKitGangerStandard", 3)
-- true iff count >= 3

-- Snapshot of the whole inventory
local inv = cybernet.player.inventory(playerID)
-- inv = { ["Items.MedKitGangerStandard"] = 5, ["Items.Frag"] = 2 }

-- Mutate. Both broadcast INVENTORY_UPDATE when the target is connected.
cybernet.player.give_item(playerID, "Items.MedKitGangerStandard")      -- +1
cybernet.player.give_item(playerID, "Items.MedKitGangerStandard", 5)   -- +5
-- → returns the new running total (integer)

local ok = cybernet.player.take_item(playerID, "Items.Frag", 2)
-- false (no mutation) when the player has < 2
-- count→0 silently removes the entry

-- React when a player uses an item in-game (plugin half is
-- Selekt-blocked; today this fires from admin / smoke harness).
cybernet.item.on_use(function(playerID, itemRecord)
    if itemRecord == "Items.MedKitGangerStandard" then
        cybernet.notify(playerID, "+1 health (server side too!)")
    end
end)
```

The first arg can be a **connected playerID** (int) **or a
display name** (string). Display-name writes against offline
players succeed silently and surface to the plugin on next
connect — useful for "award the mission token even though
they DC'd before turn-in" flows.

See [`resources/bazaar/`](https://gitlab.com/c3jones3/cybernet/-/tree/feat/blank-slate-world/server/cyberfloom/resources/bazaar)
for a Wakako-style bazaar — chat verbs `/shop /buy /sell /inv`
exercise the full surface plus an `on_use` auto-restock demo.

---

## HUD Widgets — `cybernet.hud`

Declarative bar/text/icon widgets anchored to a screen corner
or edge. Plain primitive on purpose — CP2077 has no native CEF
overlay surface, so FiveM-style HTML-CSS NUI was rejected in
favor of three building blocks that cover the survival/economy/
quest UI cases (HP/stamina bars, mission tickers, vendor totals,
countdown clocks).

```lua
local id = cybernet.hud.register({
    type     = "bar",          -- "bar" | "text" | "icon"
    anchor   = "top-left",     -- corner / edge / center
    offset_x = 24, offset_y = 24,
    color    = 0xFF00F0F0,     -- ARGB packed; 0 = plugin default
    current  = 75, max = 100,  -- for bars
    label    = "HEALTH",       -- bar label / text content / icon name
    player   = playerID,       -- nil = visible to all
})

cybernet.hud.update(id, { current = 50, color = 0xFFF04040 })
cybernet.hud.remove(id)
```

`anchor` accepts: `top-left`, `top-center`, `top-right`,
`middle-left`, `center`, `middle-right`, `bottom-left`,
`bottom-center`, `bottom-right`. Underscores work too (`top_left`).
Aliases: `top`, `bottom`, `left`, `right` map to their `*-center`
variants.

`cybernet.hud.list()` returns every active widget across resources
— useful for admin tooling.

Widgets owned by a resource get auto-cleaned on `/restart` /
`/stop`. Per-player widgets get removed when the player
disconnects (the entry is dropped but no `HUD_REMOVE` ships since
the target is gone).

Plugin reds widget is queued for the next Selekt rebuild; wire
format final today.

See [`resources/hud_demo/`](https://gitlab.com/c3jones3/cybernet/-/tree/feat/blank-slate-world/server/cyberfloom/resources/hud_demo)
for a clock + per-player HP bar that mirrors `HealthStore`,
plus `/hudfx list|blip|remove` admin verbs.

---

## Dialogs — `cybernet.dialog`

Modal dialog primitive. Server pushes a `{title, body, choices}`
bundle to one player; player picks (or dismisses); server fires
your callback. Capped at 4 choices × 48 chars to fit one UDP
datagram with title (48) + body (192).

```lua
local id = cybernet.dialog.show(playerID, {
    title   = "Wakako",
    body    = "Got a Maelstrom problem at Totentanz. 50ed per head.",
    choices = { "I'll take it", "Pass", "Tell me more" },
    timeout = 30000,           -- ms; 0 / nil = no timeout
    on_response = function(choiceIndex, pid)
        -- choiceIndex: 1..#choices, or 0 if dismissed/timed-out
        if choiceIndex == 1 then takeJob(pid) end
    end,
})

cybernet.dialog.cancel(id)     -- silent dismiss; no on_response fires

-- Optional global hook fans across resources for analytics /
-- logging. Fires AFTER any per-dialog inline callback.
cybernet.dialog.on_response(function(playerID, dialogID, choiceIndex)
    cybernet.log.info("dialog #" .. dialogID .. " → " .. choiceIndex)
end)
```

Lifecycle gotchas:
- `cancel()` is a **silent dismiss** — `on_response` does NOT
  fire (use it for "the situation changed, close the dialog").
- `timeout` firing **does** fire `on_response` with
  `choiceIndex = 0` so resources can detect timeouts.
- Dialogs targeted at a disconnecting player get dropped + their
  inline ref released; no callback fires.
- Resource `/restart` cancels every dialog it owns; targets see
  the close.

Plugin reds widget is queued for the next Selekt rebuild; wire
format final today.

See [`resources/dialog_demo/`](https://gitlab.com/c3jones3/cybernet/-/tree/feat/blank-slate-world/server/cyberfloom/resources/dialog_demo)
for `/talk-fixer` (3-choice Wakako conversation with a "tell me
more" loop-back), `/quiz` (10s-timeout yes/no), and `/dismiss`
(programmatic cancel).

---

## Admin Commands — `cybernet.admin` + `cybernet.chat.on_admin_command`

The server ships a built-in `/admin <subverb>` chat command
implemented in native C++, plus a Lua API so resources can register
their own admin-gated verbs.

### Auth model

Admins are tracked in a persistent set at `state/admins.json`.
Seed sources:

1. **`server.cfg admin_names = Foo, Bar`** — comma-separated list
   added at boot. Idempotent — never removes anyone (runtime
   demotes survive cfg edits).
2. **Persisted promote/demote** — runtime changes via the
   `/admin promote` and `/admin demote` verbs persist to disk.
3. **Server console (playerID=0)** — the server's own stdin /
   admin socket / `cybernet-admin` CLI is implicitly admin.
   Bootstrap path when `admin_names` is empty.

Cookie/OAuth-tied auth (resolving `cn_session` against the site
admin role at CONNECT time) is v2 — for now we trust the display
name.

### Built-in admin verbs (native, not Lua)

Same surface from chat (in-game) and the server console. Both
**flat** verbs and the **`/admin <subverb>` form** route to the
same dispatcher with the same auth gate + audit logging — type
whichever you prefer. Flat verbs always win over Lua-registered
handlers of the same name, so admin verbs are unspoofable.

```
/admins                                    # alias of /admin list
/admin help                                # lists every subverb
/admin list
/promote <name>            /admin promote <name>
/demote <name>             /admin demote <name>
/kick <player> [reason]    /admin kick <player> [reason]
/tp <player> <x> <y> <z> [yaw]
/tp <player_a> <player_b>                  # move A to B's pos
/give <player> <item> [count]
/heal <player>                             # full HP
/revive <player>                           # alias for heal
/setfact <key> <value>
/time <hh:mm[:ss]> [rate]
/weather <name>                            # rain|sunny|fog|pollution|toxic|sandstorm|...
/weather 0x<hex>                           # raw CName hash for custom
/weather 0 | reset | clear                 # engine default cycle
/say <message>                             # server-prefixed broadcast
```

Every attempt — allowed OR denied — appends a line to
`state/admin.log` and mirrors to stdout/journalctl:

```
2026-05-11T18:42:09Z<TAB>Selekt<TAB>admin/give<TAB>Colin Items.MedKit 5
2026-05-11T18:42:13Z<TAB>Colin<TAB>deny:admin/promote<TAB>Foo
```

### Resource-registered admin verbs

Modders can author their own admin-gated TOP-level verbs:

```lua
cybernet.chat.on_admin_command("ban", function(playerID, args, raw)
    local target = cybernet.player.find_by_name(args[1])
    if target then
        cybernet.kick(target, "banned by admin")
    end
end)
```

Non-admin callers get "Not authorized." back + the attempt is
audit-logged. Server console (playerID=0) always passes the gate.
The verb name `admin` is reserved for the native dispatcher and
will error on registration.

### Reading admin state from Lua

```lua
cybernet.player.is_admin(playerID)         -- bool; also accepts 0 (console)
cybernet.admin.is_admin(playerID|name)     -- polymorphic
cybernet.admin.list()                      -- → {names}
cybernet.admin.promote("Foo")              -- → bool (true if newly added)
cybernet.admin.demote("Foo")               -- → bool
cybernet.admin.audit("verb", "args ...")   -- manual audit-log line
```

`cybernet.admin.audit` is useful when a resource gates an action
through its own check (e.g. raycast-distance auth) instead of via
`on_admin_command` — pipe the action through the same audit log
for compliance.

---

## Notifications & Waypoints

HUD-layer feedback for the player. Both are reliable packets.

```lua
-- One-off pop-up message
cybernet.notify(playerID, "Quest complete!", 0, 5000)
--                       severity (0=info, 1=warn, 2=error)
--                                          duration ms

-- Fan out to many
cybernet.notify({pid1, pid2}, "Squad assembled.", 0, 3000)

-- Broadcast (every connected player)
cybernet.broadcast("Server restart in 60s.", 1)
```

> **Toast styling — slice 4 (queued):** notifications currently land
> as a simple HUD pop. A proper animated toast widget — stacked,
> severity-colored, slides in from corner — ships in a future
> Selekt rebuild. The Lua API stays the same; only the visual
> changes.

```lua
-- Set a waypoint marker on the player's map + compass
cybernet.waypoint.set(playerID, -1330.0, -1450.0, 13.0,
                      "Vista del Rey")

-- Clear it
cybernet.waypoint.clear(playerID)
```

---

## Persistence (`cybernet.store`)

Per-player key/value persistence. Survives reconnects, restarts,
crashes. **Not** the same as inventory — this is opaque scalar
storage for resource state.

```lua
-- Scalars only: string, number, boolean. Tables aren't supported;
-- serialize them yourself if you need structure.

cybernet.store.set(playerID, "mission.gang_hit.completions", 3)
cybernet.store.set(playerID, "mission.gang_hit.last_dropoff", "Kabuki")
cybernet.store.set(playerID, "intro_seen", true)

-- Returns the typed value (number stays number, etc.) or nil.
local completed = cybernet.store.get(playerID, "intro_seen")
if not completed then
    cybernet.notify(playerID, "Welcome — talk to Wakako in Kabuki.")
    cybernet.store.set(playerID, "intro_seen", true)
end

-- Pass nil to delete (equivalent to cybernet.store.delete)
cybernet.store.set(playerID, "intro_seen", nil)
cybernet.store.delete(playerID, "intro_seen")

-- List all keys for this player
local keys = cybernet.store.keys(playerID)
for _, k in ipairs(keys) do print(k) end
```

### Cross-player queries

For leaderboards, mission aggregates, and any "show me every
player's X" pattern, use the cross-player primitives. Both
walk every `.kv` file on disk — they see OFFLINE players too,
not just the currently connected subset.

```lua
-- One value per player for a given key. Returns a table keyed
-- by player display name. Players that haven't set the key are
-- absent from the result.
local kills = cybernet.store.all_players("stats.kills")
for name, n in pairs(kills) do
    print(name .. ": " .. n)
end

-- Prefix-filter every key/value across every player. Returns
-- an array of {player=, key=, value=} rows. Empty prefix = all.
local rows = cybernet.store.scan("mission.")
for _, row in ipairs(rows) do
    print(row.player .. " -> " .. row.key .. " = " .. tostring(row.value))
end
```

Cost: first call after server start triggers an O(N_players)
disk read; subsequent calls hit the in-memory cache. Fine for
≤100 players. If your resource calls these on every tick,
batch through a `cybernet.timer.set_interval` instead.

**Player-name form**: `scan` and `all_players` return each player
by the on-disk *sanitized* form of their display name — lowercased,
with non-alphanumerics replaced by `_`. `store.get(sanitized, key)`
round-trips back to the same entry, so you can read/write using
whichever form the API hands you.

**Keys** are restricted to printable ASCII (no whitespace, no
tab). Convention: dot-namespace them (`mission.foo.bar`). 128-char
max.

**Values** are size-capped at 4096 bytes and can't contain
embedded newlines (line-oriented on-disk format).

**Console testing.** When dispatched from the admin console
(playerID=0), the store routes to a synthetic `_console_` bucket.
Useful for testing flows without an in-game player. Inspect on
disk at `state/kv/_console_.kv`.

---

## Reading `server.cfg` — `cybernet.config`

Pull values out of the server cfg from Lua. Return type tracks the
default argument's type — pass a string default to get a string
back, a number default to parse as a number, a boolean default for
truthy parsing.

```lua
local name    = cybernet.config("server_name",   "CyberNet")  -- string
local maxp    = cybernet.config("max_clients",   128)         -- number
local voiceOn = cybernet.config("voice_enabled", true)        -- boolean
local hex     = cybernet.config("ptt_keycode",   0x47)        -- number, 0x..
local maybe   = cybernet.config("custom_thing")               -- nil if unset
```

Boolean parsing accepts `1` / `true` / `yes` / `on` (case-insensitive
on the keywords) as truthy; everything else is false. Number
parsing honors `0x` / `0X` hex literals, matching the C++ side's
`Config::getInt`. Missing keys return the default — or `nil` if no
default was supplied.

Useful for mods that want server-side knobs without coding a
custom cfg loader, e.g. `bounty_amount = cybernet.config("bounty_amount", 50)`.

---

## NPCs

`cybernet.npc.*` has two call paths — pick based on whether the
NPC needs to be the same entity for everyone:

### Server-authoritative (preferred)

Use this when the NPC matters to gameplay (kill bounties, vendors,
quest givers, anything you want everyone to agree on).

```lua
-- spawn({record=…, pos={x=,y=,z=,yaw=}, hostile=, health=, name=}) → npcID
local id = cybernet.npc.spawn({
    record  = "Character.Maelstrom_grunt",
    pos     = { x = 720.0, y = 1248.0, z = 23.5, yaw = 180 },
    hostile = true,
    health  = 100,
    name    = "Maelstrom Scrapper",
})

cybernet.npc.set_hostile(id, false)
cybernet.npc.move_to(id, 725.0, 1250.0, 23.5)   -- navmesh walk
cybernet.npc.teleport(id, 800.0, 1200.0, 30.0, 90)
cybernet.npc.set_state(id, "chase", { target = playerID })
cybernet.npc.set_state(id, "patrol")
cybernet.npc.set_state(id, "idle")
cybernet.npc.kill(id, killerPlayerID)   -- fires on_killed hooks
cybernet.npc.despawn(id)

-- Read current state
local n = cybernet.npc.get(id)
-- n = { id, record, name, x, y, z, yaw, hostile, health, speed,
--       state, chase_target?, waypoints? }

cybernet.npc.on_killed(function(npcID, killerPlayerID)
    cybernet.player.award_eddies(killerPlayerID, 50, "bounty")
end)

cybernet.npc.on_interact(function(npcID, playerID)
    -- fires when a player triggers an interact zone bound to this NPC
end)
```

#### AI ticker (server-side, no plugin required)

`set_state("patrol")` and `set_state("chase", …)` are driven by the
server's NPC AI ticker (10Hz advance, ~5Hz NPC_UPDATE rate-limit per
NPC). Three additional bindings tune the behavior:

```lua
-- Walking speed in m/s. Default 2.0 = Walk gait on the plugin side.
-- Bump to 4.0+ to trigger Sprint when the plugin half lands.
cybernet.npc.set_speed(id, 4.0)

-- Waypoint cycle for patrol. Each entry is {x, y, z}.
cybernet.npc.set_waypoints(id, {
    { x = 720, y = 1248, z = 23.5 },
    { x = 730, y = 1248, z = 23.5 },
    { x = 730, y = 1258, z = 23.5 },
    { x = 720, y = 1258, z = 23.5 },
})

-- Convenience: set waypoints + (optional) speed + flip to patrol
-- in one call.
cybernet.npc.patrol(id, {
    { x = 720, y = 1248, z = 23.5 },
    { x = 730, y = 1258, z = 23.5 },
}, 2.5)
```

Chase behavior:
- `target = 0` (or omitted) → NPC auto-picks the nearest alive
  player every tick. Use this for "hostile guard reacts to whoever
  is closest."
- `target = playerID` → NPC tracks one specific player. If that
  player goes offline or dies, the NPC silently drops to `idle`.
- The NPC stops moving but keeps facing the target within 1.5m
  (default `chaseStopRadius`).

Patrol behavior:
- Cycles the waypoint list at `speed` m/s; arrival threshold is
  0.75m (default `arriveRadius`), then advances to the next waypoint.
- Switching out of patrol and back resets the cursor to waypoint[0].
- Empty waypoint list = silent no-op (no broadcasts, NPC stays put).

Server holds the canonical state; clients get NPC_SPAWN / NPC_UPDATE /
NPC_DESPAWN packets through their plugin. A newly-connecting player
automatically receives every currently-live NPC at join.

> **Plugin gate (v0.11)**: the wire packets are defined and broadcast,
> but the plugin handlers that actually render the NPC in-game are
> Selekt-blocked (next rebuild bundle). Resources authored against
> this API today will load cleanly and exercise server-side logic;
> in-world rendering lights up the moment the plugin rebuild lands.

### Client-local (legacy escape hatch)

For one-off cosmetic spawns visible only to specific players — e.g.
AMM-style decorative crowds, debug markers. No server state, no
replication.

```lua
-- spawn(playerID | {ids}, archetype, x, y, z, yaw?, hostile?) → spawnID
local spawnID = cybernet.npc.spawn(
    {pid1, pid2},
    "Character.Maelstrom_grunt",
    720.0, 1248.0, 23.5,
    180.0,
    true)

cybernet.npc.despawn({pid1, pid2}, spawnID)
```

Each receiving player spawns the NPC in their own simulation, so
positions and animations may drift. Don't use this for anything
gameplay-relevant.

---

## Quest Facts — `cybernet.fact`

Quest facts are the connective tissue between Lua resources and
CP2077's `QuestsSystem`. Setting a fact value can unlock districts,
toggle gameplay flags, drive scripted quest progression, or just
record cross-resource state.

```lua
-- Read (returns int, or nil if key has never been set + no default).
local locked = cybernet.fact.get("watson_prolog_lock")   -- 0 or 1
local step   = cybernet.fact.get("my_mission.step", 0)   -- with default

-- Write (returns true iff the value CHANGED).
cybernet.fact.set("my_mission.step", 3)
if cybernet.fact.set("intro_seen", 1) then
    -- first set succeeded; second set with same value would no-op
end

-- Existence test
if cybernet.fact.has("watson_prolog_lock") then ... end

-- Remove a key entirely
cybernet.fact.clear("my_mission.step")

-- Snapshot everything
for name, value in pairs(cybernet.fact.all()) do
    print(name .. " = " .. value)
end
```

**Naming**: keys must match `^[a-z0-9_]{1,63}$` (CP2077 fact name
convention — lowercase, digits, underscores, ≤63 chars). Invalid
keys silently fail (`set` returns false, `has` returns false). This
matches the engine's own fact key shape so `cybernet.fact.set(name, v)`
can mirror into `QuestsSystem.SetFact(n"<name>", v)` once the plugin
half lands.

**Persistence**: facts live in `state/facts.json` and survive
restarts. The 14 V0 blank-slate defaults (`watson_prolog_lock`,
`q101_done`, etc.) seed on first boot only — your overrides persist.

**Broadcast**: every `cybernet.fact.set` whose value changed fans a
`FACT_SET` packet to every connected player. The plugin-side handler
that applies the change in-game (live, mid-session) is queued for
the next plugin rebuild; today the change persists + applies on
every player's next connect via WORLD_INIT.

```lua
-- Example: a "wakako intro" quest that gates the rest of the world
cybernet.chat.on_command("startwakako", function(playerID)
    cybernet.fact.set("wakako_intro_seen", 1)
    cybernet.fact.set("watson_unlocked", 1)
    cybernet.notify(playerID, "Wakako will see you now.", 0, 4000)
    cybernet.waypoint.set(playerID, -1305.5, 1267.8, 11.2,
                          "Wakako's bar")
end)
```

### Change-listeners — `watch` / `listen` / `unwatch`

React to fact changes as they happen, instead of polling. Two
modes: exact-key (`watch`) and prefix (`listen`). Hooks fire after
every `cybernet.fact.set` whose value actually changed, plus after
`cybernet.fact.clear` (which is reported as a transition to 0),
plus after `/admin setfact`.

```lua
-- Exact-key watcher
local tok = cybernet.fact.watch("watson_prolog_lock",
    function(key, newVal, oldVal)
        -- Watson unlocks when the lock flips 1 → 0
        if oldVal == 1 and newVal == 0 then
            cybernet.broadcast("Watson district has opened.")
        end
    end)

-- Prefix listener (empty prefix = catch every fact change)
local logger = cybernet.fact.listen("gangwar_",
    function(key, newVal, oldVal)
        cybernet.log.info(key .. " → " .. newVal)
    end)

-- Remove a hook
cybernet.fact.unwatch(tok)
cybernet.fact.unwatch(logger)
```

Hook signature: `fn(key, newVal, oldVal)`. Unset keys read as
`0`, matching CDPR's fact semantics — so the very first set of a
new key reports `oldVal=0`. Same applies to `clear` — the watcher
sees `newVal=0`.

Each registration returns an opaque integer **token**. Use it
with `unwatch(token)` to remove the hook later. Tokens are
globally unique within the server's lifetime.

Hooks owned by a resource auto-cleanup on `/restart` or `/stop`.
Multiple hooks on the same key all fire (one bad hook doesn't
stop the others — errors log and continue).

See [`resources/fact_listener/`](https://gitlab.com/c3jones3/cybernet/-/tree/feat/blank-slate-world/server/cyberfloom/resources/fact_listener)
for a working example with all three modes.

---

## Interaction Zones — Press F to ...

Register an in-world prompt that fires Lua when a player triggers
it. All zones are server-authoritative; the plugin's reds widget
draws the "Press F to <label>" overlay and reports back to the
server when the player actually hits the key.

```lua
-- Free-floating zone at a world position
local zid = cybernet.interact.register({
    pos     = { x = -1305.5, y = 1267.8, z = 11.2 },
    radius  = 2.5,
    label   = "Talk to Viktor",
    key     = "F",          -- default; one ASCII char
    visible = true,
    on_use  = function(playerID)
        cybernet.chat.say(playerID, "Welcome, choom.")
    end,
})

-- Pin to a server-spawned NPC — zone tracks the NPC's pose
cybernet.interact.npc(npcID, {
    label  = "Trade",
    radius = 2.5,
    on_use = function(playerID)
        -- open vendor / hand out a job …
    end,
})

-- Pin to a TweakDB prop record (terminals, doors, vending machines)
cybernet.interact.prop("Items.Terminal_42", "Access terminal", function(playerID)
    -- …
end)

cybernet.interact.set_visible(zid, false)   -- hide without removing
cybernet.interact.unregister(zid)
```

When a player triggers a zone, the registering resource's `on_use`
callback fires with the `playerID`. Zones attached to a server-
spawned NPC also fan to every resource's `cybernet.npc.on_interact`
hooks, so two resources can react to "the player talked to NPC X"
independently.

> **Plugin gate (v0.11)**: the reds InteractPrompt widget that
> displays the prompt + handles the F-press → INTERACT_FIRED packet
> is Selekt-blocked. Server-side zone registration + on_use
> dispatch is fully working; resources can author the full
> interaction flow today.

---

## Admin Primitives

```lua
-- Disconnect a player
cybernet.kick(playerID, "rude language")

-- (Future) Set/get/broadcast a quest fact — row 13, gated on
-- plugin work. Currently absent: a fact set from Lua won't
-- propagate to connected players.
```

---

## Logging

```lua
cybernet.log.info("ready")
cybernet.log.warn("ammo low for player " .. playerID)
cybernet.log.error("database connection refused: " .. err)
```

These go to the server's stdout, which systemd captures into
the journal. View with `journalctl -fu cybernet-server` or in
the LIVE LOG card on `admin-server.html`. Output is prefixed
with the resource name.

---

## Time

```lua
-- Server wallclock unix epoch seconds (system time).
local ts = cybernet.time.now()

-- In-game time-of-day in seconds (0–86399). Server is canonical;
-- this is what every connected player sees.
local sod = cybernet.time.game_seconds_of_day()
local hour = math.floor(sod / 3600)
if hour >= 22 or hour < 6 then
    cybernet.broadcast("Curfew is in effect.")
end

-- Time-of-day rate multiplier. 1.0 = realtime. >1 speeds up the
-- in-game day; 0 pauses; <0 runs backwards. Cfg key
-- `time_rate_x1000` on the server.
local rate = cybernet.time.day_rate()
```

---

## Timers

Sugar over `on_tick` for code that needs to wake up later or
periodically. Resolution is the server tick rate (~10 Hz, so
~100 ms granularity at best).

```lua
-- One-shot: fires once after delayMs. Returns an opaque timerID.
local id = cybernet.timer.set_timeout(5000, function()
    cybernet.broadcast("Server restart in 5 seconds.")
end)

-- Repeating: fires every intervalMs.
local hb = cybernet.timer.set_interval(60000, function()
    local n = cybernet.player.count()
    cybernet.log.info("heartbeat — " .. n .. " players online")
end)

-- Cancel either kind by ID.
cybernet.timer.clear(id)
cybernet.timer.clear(hb)
```

Interval semantics: when a tick is delayed (server under load),
the next fire re-anchors to "now + interval" rather than
catching up — there's no compounding drift, no burst of N
back-to-back fires.

Timers are scoped to the resource that created them. A timer
keeps a strong reference to its callback, so closures stay
alive as long as the timer is pending — be mindful of memory
leaks in long-lived intervals.

---

## Modules — Splitting Code Across Files

For anything larger than a single screen of code, split your
resource into multiple files using `cybernet.include`:

```
resources/my_economy/
├── init.lua         (entry script)
├── items.lua        (data table)
├── vendor_handlers.lua
└── util.lua
```

```lua
-- resources/my_economy/init.lua
local util  = cybernet.include("util.lua")
local ITEMS = cybernet.include("items.lua")
cybernet.include("vendor_handlers.lua")   -- ignore return value
```

```lua
-- resources/my_economy/items.lua
return {
    pistol_cheap  = { price = 200,  rarity = "common" },
    pistol_legend = { price = 9500, rarity = "legendary" },
    -- ...
}
```

Paths are resolved **relative to the resource's own
directory**. Absolute paths and `..` are rejected — you can't
read files outside your folder, no matter what. Each `include`
runs the file in the SAME Lua state, so anything it leaves
in `_G` is visible to the rest of the resource.

The chunk's return value is what `cybernet.include` returns,
so the standard module pattern (`return M` at the bottom)
works as expected.

---

## Events — Inter-Resource Communication

`cybernet.event` is a synchronous pub/sub bus. Resources fire
events; other resources subscribe by name. Decouples mission
logic from achievements from leaderboards from social feeds —
nothing has to know about anyone else's internals.

```lua
-- Subscribe — returns a listener id you can pass to .off() later
local id = cybernet.event.on("delivery.completed", function(payload)
    local pid    = payload.player_id
    local reward = payload.reward
    cybernet.log.info("delivery: player " .. pid ..
                      " earned " .. reward)
end)

-- Unsubscribe (rarely needed — listeners are auto-cleaned when
-- the owning resource is unloaded)
cybernet.event.off(id)

-- Fire — second+ args are forwarded to every listener
cybernet.event.emit("delivery.completed", {
    player_id = 7,
    reward    = 1200,
    dropoff   = "Kabuki Market",
})
```

Convention: name events `<domain>.<verb>`
(`delivery.completed`, `player.killed`, `gang.territory_lost`).

**Cross-state marshaling.** Resources each run in their own
Lua state. The bus serializes payloads across states for you,
but it's deliberately strict to keep the wire dumb:

- Scalars (string, number, boolean, nil) — copied
- Flat tables (one level deep) of those scalars — copied
- Anything else (functions, userdata, nested tables, threads) — silently nil'd

If you need to pass deep structure, serialize it on the
sender side (e.g. JSON-ish string) and parse on the receiver.
Same pattern as CMP / FiveM event buses.

**Error isolation.** A listener that throws gets its error
logged but does not stop dispatch — subsequent listeners
still fire.

**Order.** Listeners fire in registration order. There's no
priority system yet.

---

## Cleanup — `cybernet.on_unload`

Fires once, right before your resource is `/stop`-ed or
`/restart`-ed. Use it to broadcast goodbyes, flush in-memory
state, or release external handles.

```lua
cybernet.on_unload(function()
    cybernet.broadcast("Delivery service offline for maintenance.", 1)
    -- persist final tally to disk
    cybernet.store.set("_console_", "deliveries.last_uptime", cybernet.time.now())
end)
```

The framework's bookkeeping (chat handlers, timers, event
listeners) is **automatically released** on unload — you don't
need to manually unregister them in your unload handler. Use
on_unload for things the framework can't see.

---

## Player Iteration Helpers

```lua
-- Run fn(playerID, playerTable) for every connected player
cybernet.player.each(function(id, p)
    cybernet.log.info(p.name .. " is at (" .. p.x .. ", " .. p.y .. ")")
end)

-- Find players near a given player (XY-plane distance only —
-- Z is ignored on purpose; vertical Night City makes Z
-- mismatches usually irrelevant gameplay-wise).
local crew = cybernet.player.nearby(leaderID, 50.0)
for _, id in ipairs(crew) do
    cybernet.notify(id, "Leader signaled — converge.")
end
```

---

## Sandbox & Stripped APIs

Each resource runs in its own Lua state with a deliberately
narrow standard-library surface. The following are **removed**
to prevent the obvious sandbox escapes:

```
io.open, io.popen, io.lines, io.input, io.output, io.tmpfile, io.close
os.execute, os.exit, os.remove, os.rename, os.tmpname, os.setlocale
dofile, loadfile, load, loadstring
require, package.loadlib, package.cpath, package.path, package.searchers
debug.*  (entire table)
```

Still available: `math.*`, `string.*`, `table.*`, `print`,
`type`, `pairs`, `ipairs`, `select`, `tostring`, `tonumber`,
`error`, `os.time`, `os.date`, `os.clock`, `os.difftime`,
`io.read` (rare).

If you need filesystem state for your mod, use `cybernet.store`
instead of `io.open`. If you need to share code between
resources… you can't yet. Submit a request via the docs
issue tracker.

---

## Cookbook

### A mission with a waypoint + reward

```lua
-- resources/quick_courier/init.lua
local REWARD = 800
local DROP = { x = -780.0, y = 1090.0, z = 24.0, name = "Kabuki Market" }
local ACTIVE_KEY = "courier.active"

cybernet.chat.on_command("courier", function(playerID)
    if cybernet.store.get(playerID, ACTIVE_KEY) then
        cybernet.chat.say(playerID, "you already have an active courier run")
        return
    end
    cybernet.store.set(playerID, ACTIVE_KEY, true)
    cybernet.waypoint.set(playerID, DROP.x, DROP.y, DROP.z, DROP.name)
    cybernet.notify(playerID, "Courier: " .. DROP.name ..
                              " (" .. REWARD .. " eddies)", 0, 6000)
end)

cybernet.chat.on_command("delivered", function(playerID)
    if not cybernet.store.get(playerID, ACTIVE_KEY) then
        cybernet.chat.say(playerID, "no active run — /courier first")
        return
    end
    local p = cybernet.player.get(playerID)
    if p then
        local dx, dy = p.x - DROP.x, p.y - DROP.y
        if dx*dx + dy*dy > 30*30 then
            cybernet.chat.say(playerID,
                string.format("too far — %.0fm to %s",
                              math.sqrt(dx*dx + dy*dy), DROP.name))
            return
        end
    end
    cybernet.player.award_eddies(playerID, REWARD, "courier")
    cybernet.store.delete(playerID, ACTIVE_KEY)
    cybernet.waypoint.clear(playerID)
    cybernet.notify(playerID, "+" .. REWARD .. " eddies", 0, 4000)
end)
```

### Player join greeter that remembers returning players

```lua
-- resources/greeter/init.lua
cybernet.on_player_connect(function(playerID)
    local p = cybernet.player.get(playerID)
    local visits = cybernet.store.get(playerID, "greeter.visits") or 0
    visits = visits + 1
    cybernet.store.set(playerID, "greeter.visits", visits)

    if visits == 1 then
        cybernet.notify(playerID,
            "Welcome to Night City, " .. p.name .. ".", 0, 6000)
    else
        cybernet.notify(playerID,
            "Welcome back, " .. p.name .. ". Visit #" .. visits, 0, 4000)
    end
end)
```

### Periodic world event (every 5 minutes)

```lua
-- resources/random_event/init.lua
local elapsed = 0
local INTERVAL = 300  -- seconds

cybernet.on_tick(function(dt)
    elapsed = elapsed + dt
    if elapsed < INTERVAL then return end
    elapsed = 0
    local n = cybernet.player.count()
    if n == 0 then return end
    cybernet.broadcast("NCPD response in progress — " .. n ..
                       " runners online.", 1)
end)
```

### Timed broadcast (announce something on a fixed schedule)

```lua
-- resources/announcer/init.lua
local MESSAGES = {
    "Visit Misty's Esoterica in Watson — incense 50% off.",
    "Tyger Claws turf war in Japantown — solos wanted.",
    "Free ripper consult at Vik's, 18:00 to 22:00.",
}

-- Cycle through the bank every 5 minutes.
local i = 0
cybernet.timer.set_interval(5 * 60 * 1000, function()
    i = (i % #MESSAGES) + 1
    cybernet.broadcast(MESSAGES[i], 0)
end)
```

### Self-cancelling timer (one-shot reward)

```lua
cybernet.chat.on_command("countdown", function(playerID, args)
    local secs = tonumber(args[1]) or 10
    cybernet.notify(playerID, "Countdown started: " .. secs .. "s", 0, 2000)
    cybernet.timer.set_timeout(secs * 1000, function()
        cybernet.notify(playerID, "Done!", 0, 4000)
        cybernet.player.award_eddies(playerID, 50, "patience")
    end)
end)
```

### Cross-resource composition via the event bus

The `delivery` resource emits `delivery.completed` on every
turn-in. The `leaderboard` resource listens and tracks
run counts:

```lua
-- resources/delivery/init.lua  (excerpt)
cybernet.chat.on_command("turnin", function(playerID)
    -- ...award eddies, clear waypoint...
    cybernet.event.emit("delivery.completed", {
        player_id = playerID,
        dropoff   = target.name,
        reward    = REWARD_EDDIES,
    })
end)
```

```lua
-- resources/leaderboard/init.lua
cybernet.event.on("delivery.completed", function(payload)
    local pid = payload.player_id
    local n   = (cybernet.store.get(pid, "lb.runs") or 0) + 1
    cybernet.store.set(pid, "lb.runs", n)
end)

cybernet.chat.on_command("mystats", function(playerID)
    local n = cybernet.store.get(playerID, "lb.runs") or 0
    cybernet.chat.say(playerID, "your deliveries: " .. n)
end)
```

`/restart delivery` blows away the delivery code without
touching leaderboard. `/stop leaderboard` silences stats
without affecting delivery turn-ins.

### Multi-file resource (mission catalog)

```
resources/missions/
├── init.lua
├── catalog.lua
└── dispatcher.lua
```

```lua
-- resources/missions/catalog.lua
return {
    courier = {
        name   = "Courier Run",
        reward = 800,
        target = { x = -780, y = 1090, z = 24 },
    },
    bounty  = {
        name   = "Tyger Bounty",
        reward = 2500,
        target = { x = -1840, y =  780, z = 20 },
    },
}
```

```lua
-- resources/missions/dispatcher.lua
local catalog = cybernet.include("catalog.lua")

cybernet.chat.on_command("jobs", function(playerID)
    for id, m in pairs(catalog) do
        cybernet.chat.say(playerID,
            "  /accept " .. id .. " — " .. m.name ..
            " (" .. m.reward .. " eddies)")
    end
end)
```

```lua
-- resources/missions/init.lua
cybernet.include("dispatcher.lua")
cybernet.log.info("missions loaded")
```

### Per-player counter, leaderboard-style

```lua
cybernet.chat.on_command("kills", function(playerID)
    local k = cybernet.store.get(playerID, "stats.kills") or 0
    cybernet.chat.say(playerID, "your kills: " .. k)
end)

-- Elsewhere, e.g. in a kill event handler:
local k = cybernet.store.get(playerID, "stats.kills") or 0
cybernet.store.set(playerID, "stats.kills", k + 1)
```

---

## Restart Workflow

Hot-restart is live — edit your `init.lua`, then from the admin
console (web UI at `cybernet.gg/admin-server.html` or the
`cybernet-admin` CLI) run:

```
/restart my_first_mod
```

The server closes the resource's old Lua state, releases its
timers / chat handlers / hooks, then loads the file fresh from
disk. Connected players don't drop. Total round-trip: under a
second.

Full lifecycle command set (all reachable from the admin
console, both web UI and `cybernet-admin` CLI):

```
/resources              # list every loaded resource
/refresh                # discover + load any new resource folders on disk
/start <name>           # start a resource that's on disk but not loaded
/stop <name>            # stop (unload) a running resource
/restart <name>         # restart a resource — pick up file changes
/reload <name>          # alias for /restart
```

`/refresh` lets you drop a new resource folder onto the server
while it's running and pick it up without `/start`-ing each
one manually. Already-loaded resources are left alone — use
`/restart` for them.

### Native dispatch — no `/lua` prefix

The admin console treats unrecognized `/verb` strings as Lua
chat dispatch, so you can type the verb directly:

```
/hello                  # native dispatch into Lua
/lua hello              # back-compat explicit form, still works
/takejob                # delivery resource's chat handler
```

Built-in verbs (`/status`, `/reload`, `/help`, etc.) **always
win** over Lua-registered verbs of the same name — a Lua
resource can't shadow `/reload` or `/stop` by accident.

**Failure mode.** If your script has a syntax error or throws
during top-level execution, the restart **leaves the resource
unloaded**. You'll see the error in the journal:

```
[ResourceManager] Failed to load my_first_mod: ...:12: 'end' expected
[ResourceManager] reload: 'my_first_mod' failed — resource is now UNLOADED.
                  Fix the script and reload again.
```

Same posture as FiveM — restart either succeeds or leaves you
unloaded. No "keep the previous version running" fallback,
because the framework's bookkeeping is name-keyed; preserving
the old state across a failed restart would orphan its
registrations. Fix-then-restart is the pattern.

**State on restart.** A restart drops everything in the resource's
Lua state — local variables, registered chat handlers, hooks,
timers. Persistent data (`cybernet.store`) is on-disk and
unaffected. If your resource accumulates in-memory state you
care about across restarts, persist it via `cybernet.store` or
re-derive it at startup.

A full server restart (`sudo systemctl restart cybernet-server.service`)
is still the right answer when you want a clean slate or when
you've made C++/Redscript changes that the reload-loop can't
help with.

---

## Testing Without a Game

You don't need to launch Cyberpunk to test your resource — the
admin console at `cybernet.gg/admin-server.html` (or the
`cybernet-admin` CLI on the box) can dispatch any chat command:

```
/lua greet
/lua roll 20
/lua takejob
/lua jobstatus
```

These run with `playerID=0` (the console sentinel). Replies
appear in the LIVE LOG card. `cybernet.store` writes route to a
`_console_` bucket so persistence works end-to-end without an
in-game player.

---

## API Quick Reference

| Namespace | Signature | Notes |
|---|---|---|
| `cybernet.chat.on_command(name, fn)` | `fn(playerID, args, raw)` | Register a `/cmd` handler |
| `cybernet.chat.say(playerID|{ids}, msg)` | | Reply or fan-out |
| `cybernet.player.list()` | `→ {ids}` | Connected player IDs |
| `cybernet.player.count()` | `→ n` | |
| `cybernet.player.get(id)` | `→ {…}` or nil | Full player snapshot |
| `cybernet.player.find_by_name(name)` | `→ id` or nil | |
| `cybernet.player.award_eddies(id, amt, reason?)` | | |
| `cybernet.player.charge_eddies(id, amt, reason?)` | `→ bool` | false if insufficient |
| `cybernet.player.award_xp(id, amt)` | | |
| `cybernet.player.teleport(id, x, y, z, yaw?)` | | Needs plugin (row 11) |
| `cybernet.player.get_health(id)` | `→ current, max` or nil | virtual HP |
| `cybernet.player.set_health(id, value)` | `→ bool` | clamped to [0, max] |
| `cybernet.player.set_max_health(id, value)` | `→ bool` | clamps current down if exceeded |
| `cybernet.player.damage(id, amt, attackerID?)` | `→ newHp` | fires `on_damage` + `on_death` if fatal |
| `cybernet.player.heal(id, amt)` | `→ newHp` | clamps to max; no-op on dead |
| `cybernet.player.revive(id)` | `→ bool` | full HP restore |
| `cybernet.player.is_alive(id)` | `→ bool` | |
| `cybernet.player.on_damage(fn)` | `fn(victim, attacker, amt, newHp)` | many subscribers OK |
| `cybernet.player.on_death(fn)` | `fn(victim, killer)` | fires once per death |
| `cybernet.player.count_item(player, item)` | `→ int` | 0 if absent |
| `cybernet.player.has_item(player, item, min?=1)` | `→ bool` | |
| `cybernet.player.give_item(player, item, count?=1)` | `→ newTotal` | broadcasts INVENTORY_UPDATE |
| `cybernet.player.take_item(player, item, count?=1)` | `→ bool` | false if insufficient |
| `cybernet.player.inventory(player)` | `→ {[item]=count, ...}` | empty table if absent |
| `cybernet.item.on_use(fn)` | `fn(playerID, itemRecord)` | Selekt-blocked emit |
| `cybernet.hud.register(opts)` | `→ widgetID` | bar/text/icon |
| `cybernet.hud.update(id, opts)` | `→ bool` | patch named fields only |
| `cybernet.hud.remove(id)` | `→ bool` | |
| `cybernet.hud.list()` | `→ {{id=, type=, …}, ...}` | |
| `cybernet.dialog.show(playerID, opts)` | `→ dialogID` | choices ≤4; inline on_response OK |
| `cybernet.dialog.cancel(id)` | `→ bool` | silent dismiss (no callback) |
| `cybernet.dialog.on_response(fn)` | `fn(playerID, dialogID, choiceIndex)` | global hook |
| `cybernet.player.is_admin(id)` | `→ bool` | true for console (id=0) |
| `cybernet.admin.is_admin(id\|name)` | `→ bool` | polymorphic |
| `cybernet.admin.list()` | `→ {names}` | |
| `cybernet.admin.promote(name)` | `→ bool` | true if newly added |
| `cybernet.admin.demote(name)` | `→ bool` | |
| `cybernet.admin.audit(verb, args?)` | | manual audit-log line |
| `cybernet.chat.on_admin_command(name, fn)` | `fn(playerID, args, raw)` | gated; "admin" reserved |
| `cybernet.fact.get(key, default?)` | `→ int` or nil | unset reads as default or nil |
| `cybernet.fact.set(key, value)` | `→ bool` | true iff value changed; broadcasts FACT_SET |
| `cybernet.fact.clear(key)` | `→ bool` | fires watchers with newVal=0 |
| `cybernet.fact.has(key)` | `→ bool` | |
| `cybernet.fact.all()` | `→ {[name]=value}` | |
| `cybernet.fact.watch(key, fn)` | `fn(key, newVal, oldVal) → token` | exact key |
| `cybernet.fact.listen(prefix, fn)` | `fn(key, newVal, oldVal) → token` | "" = all keys |
| `cybernet.fact.unwatch(token)` | `→ bool` | |
| `cybernet.notify(id|{ids}, msg, sev?, durMs?)` | | sev 0/1/2 = info/warn/error |
| `cybernet.broadcast(msg, sev?)` | | Every connected player |
| `cybernet.waypoint.set(id|{ids}, x, y, z, label?)` | | |
| `cybernet.waypoint.clear(id|{ids})` | | |
| `cybernet.store.get(player, key)` | `→ value` or nil | player = ID or display-name string |
| `cybernet.store.set(player, key, value)` | `→ bool` | scalars only (string/number/boolean) |
| `cybernet.store.delete(player, key)` | `→ bool` | |
| `cybernet.store.keys(player)` | `→ {keys}` | |
| `cybernet.store.all_players(key)` | `→ {[name]=value}` | sees offline players |
| `cybernet.store.scan(prefix?)` | `→ {{player,key,value}, ...}` | every triple, prefix-filtered |
| `cybernet.npc.spawn({record, pos, hostile?, health?, name?})` | `→ npcID` | Server-authoritative spawn |
| `cybernet.npc.spawn(ids, arch, x, y, z, yaw?, hostile?)` | `→ spawnID` | Client-local spawn |
| `cybernet.npc.despawn(npcID)` | `→ bool` | Authoritative despawn |
| `cybernet.npc.despawn(ids, spawnID)` | | Client-local despawn |
| `cybernet.npc.set_hostile(id, bool)` | `→ bool` | |
| `cybernet.npc.move_to(id, x, y, z)` | `→ bool` | |
| `cybernet.npc.teleport(id, x, y, z, yaw?)` | `→ bool` | |
| `cybernet.npc.set_state(id, "idle"\|"patrol"\|"chase", {target=?})` | `→ bool` | |
| `cybernet.npc.set_speed(id, mPerSec)` | `→ bool` | Default 2.0; negatives clamp to 0 |
| `cybernet.npc.set_waypoints(id, {{x,y,z}, ...})` | `→ bool` | Empty array clears |
| `cybernet.npc.patrol(id, waypoints[, speed])` | `→ bool` | set_waypoints + set_speed? + state=patrol |
| `cybernet.npc.kill(id, killerPlayerID?)` | `→ bool` | Fires on_killed |
| `cybernet.npc.get(id)` | `→ {…}` or nil | |
| `cybernet.npc.on_killed(fn)` | `fn(npcID, killerPlayerID)` | |
| `cybernet.npc.on_interact(fn)` | `fn(npcID, playerID)` | |
| `cybernet.kick(playerID, reason?)` | | |
| `cybernet.log.info(msg)` | | Server stdout / journal |
| `cybernet.log.warn(msg)` | | |
| `cybernet.log.error(msg)` | | |
| `cybernet.on_player_connect(fn)` | `fn(playerID)` | |
| `cybernet.on_player_disconnect(fn)` | `fn(playerID)` | |
| `cybernet.on_tick(fn)` | `fn(dtSeconds)` | ~10 Hz |
| `cybernet.player.each(fn)` | `fn(id, snapshot)` | |
| `cybernet.player.nearby(id, radius)` | `→ {ids}` | XY plane, Z ignored |
| `cybernet.time.now()` | `→ unix seconds` | |
| `cybernet.time.game_seconds_of_day()` | `→ 0..86399` | |
| `cybernet.time.day_rate()` | `→ rate` | 1.0 = realtime |
| `cybernet.timer.set_timeout(ms, fn)` | `→ timerID` | |
| `cybernet.timer.set_interval(ms, fn)` | `→ timerID` | re-anchors on tick lag, no compounding |
| `cybernet.timer.clear(timerID)` | `→ bool` | |
| `cybernet.include(path)` | `→ chunk-return-value` | sibling file, sandbox-checked |
| `cybernet.event.on(name, fn)` | `→ listenerID` | `fn(...)` receives emit args |
| `cybernet.event.off(listenerID)` | `→ bool` | |
| `cybernet.event.emit(name, ...)` | | scalars + flat tables only |
| `cybernet.on_unload(fn)` | `fn()` | fires before /stop or /restart |

---

## What's Coming

Roadmap items that will land in future slices (see [ROADMAP.md §1H](ROADMAP.md)):

- **Animated toast widget** (slice 4, Selekt rebuild) — proper
  stacked / colored / animated pop-ups for `cybernet.notify`.
- **`cybernet.fact.set / get`** (row 13) — quest-fact mutation
  with auto-broadcast to all clients.
- **`cybernet.timer.set_timeout(ms, fn) / set_interval(ms, fn)`** —
  one-shot and repeating timers without manual `on_tick`
  bookkeeping.
- **`cybernet.vendor.register(id, stock)`** + **`cybernet.vendor.open`** —
  shop-counter abstraction; depends on inventory replication.
- **Bidirectional RPC handshake** — auto-bound stubs both
  directions so Redscript can call into Lua and vice versa
  without packet wiring. The current chat-command route is a
  hand-wired tracer of this.
- **Hot-reload** (v1.1) — replace a resource's code mid-session
  without restarting the server.
- **`manifest.lua`** — declare dependencies, version, client-side
  assets to bundle with the launcher manifest.

---

## Real-World Examples

The CyberNet repo ships four demo resources you can read as
references:

- [`resources/hello/init.lua`](../server/cyberfloom/resources/hello/init.lua) —
  Minimal tracer: `/hello [name]`, `/ping`, `/echo`, `/who`,
  `/whereis <name>`. Exercises chat + player namespaces.
- [`resources/delivery/`](../server/cyberfloom/resources/delivery) —
  Full mission loop: `/takejob`, `/turnin`, `/jobstatus`,
  `/cancel`. Multi-file via `manifest.lua` (data in `config.lua`,
  logic in `handlers.lua`). Exercises chat + player + notify +
  waypoint + store, and emits `delivery.completed` on the event bus.
- [`resources/leaderboard/init.lua`](../server/cyberfloom/resources/leaderboard/init.lua) —
  Pure event-bus consumer: `/lb`, `/mystats`. Tracks delivery
  run counts + lifetime eddies. Demonstrates `cybernet.event.on`
  + `cybernet.on_unload`.
- [`resources/timer_test/init.lua`](../server/cyberfloom/resources/timer_test/init.lua) —
  Health-signal smoke test: 10s heartbeat in the journal, plus
  `/uptime`. Demonstrates `cybernet.timer.set_interval` +
  `cybernet.time.now`.

Read those before writing your first non-trivial resource.
