Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

35 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ursamu-map-plugin

A procedural, coordinate-based sector map for UrsaMU — Simplex topology, sparse DBO overlays, and a Latin-1 split-pane renderer.


What it is

  • A topology engine that turns any (x, y, z) into a deterministic biome via two Simplex noise fields and a Whittaker matrix.
  • A sparse persistence model: no rooms are pre-materialized. Procedural terrain is the default; the DBO collection map.overlays stores only authored overrides.
  • A native-UI renderer that emits a 78-column briefing using UrsaMU's header / divider / footer helpers, plus a +map command and a DESCFORMAT handler.

Showcase

Default config, centre (144, 219, 0), two authored overlays, three contacts. Generated by deno task showcase.

==============================================================================
                          %chSECTOR 4A: JABIIM TRENCHES%cn                          
==============================================================================
                                                          LOC: (144, 219) Z: 0
 t ~ ~ ~ t t T ~ ~ ~ T T T T t | %chTOPOGRAPHY & CLIMATE%cn                         
 t t t t t t t ~ ~ T T T T T t | dense brush crowds the line of sight To the  
 t t t t t t t ~ + T T T T T ~ | north, open water stretches outward To the   
 t t t t t t t T # T T T T T t | west, the plain runs out toward the horizon  
 t ~ = t t t t ~ ~ ~ ~ ~ T T t |                                              
 = = = t t t t t ~ ~ ~ T T T T |                                              
 . = = t t t t t t ~ ~ ~ T T t |                                              

%chNOTABLE INFRASTRUCTURE%cn
------------------------------------------------------------------------------
  # (145, 219) Forward Command Bunker (Republic)
  + (145, 220) Unsecured Munitions Cache

%chSECTOR CONTACTS%cn
------------------------------------------------------------------------------
  Lemuel is operating in an AT-RT Walker.
  RC-1138 "Boss" (Republic) holds a defensive perimeter.
  2x B2-Super Battle Droid (Hostile) is advancing through the brush.

%chADJACENT SECTORS%cn
------------------------------------------------------------------------------
  N (Deep Water), S (Deep Water), E (Brush), W (Plains)
==============================================================================

Install

deno add jsr:@ursamu/map-plugin

Then drop into your game's plugin loader:

import mapPlugin from "@ursamu/map-plugin";

Quickstart

import { loadPlugin } from "ursamu";
import mapPlugin from "@ursamu/map-plugin";

await loadPlugin(mapPlugin);   // registers +map, wires DESCFORMAT
// In-game: +map  -> renders the sector at your state.coord (or 0,0,0)

Commands

Command Lock What it does
+map / +map/here connected Renders the procedural sector centred on the caller's active MapEntity.
+map/embark <vehicle> connected Board a map-capable object in the same room.
+map/disembark connected Step out of the current vehicle into its current location.
+map/launch connected From inside a map-capable vehicle, create a MapEntity and enter the map.
+map/land connected Destroy the MapEntity, return the vehicle to lastDock.
+move <dir> connected n/s/e/w/ne/nw/se/sw/u/d — walk the caller's active entity.
+map/link <entityId> connected Take remote control of a scout / structure that names you as controllerId.
+map/unlink connected Release any remote link.
+map/spectate <entityId> admin See the map through an entity's eyes; read-only.
+map/unspectate admin Exit spectate mode.
+map/stats admin Dump entity / fog DBO summary.
+map/jump <x> <y> [z] admin Teleport the caller's active entity to a coordinate.

See docs/entities.md, docs/embarkation.md, docs/fog-of-war.md.

Help text registered with the engine (verbatim from commands.ts):

+map[/<switch>] [<args>]  — View the procedural sector map.

Switches:
  /here          Centre the map on your current coordinate (default).
  /jump <x> <y> [z]  Teleport your map cursor to a coordinate (builder+).

Examples:
  +map                    Render the sector around you.
  +map/here               Same as bare +map.
  +map/jump 120 -40       Jump to (120, -40, 0).

Architecture

+map
  -> getPlayerCoord(state)        [state.ts]
  -> DESCFORMAT handler           [format.ts]
       -> topology.sample(coord)  [topology.ts]
       -> getOverlaysInRegion()   [state.ts]
       -> renderMap(input)        [renderer.ts]
  -> u.send(output)

The plugin uses UrsaMU's three-phase lifecycle. Phase 1 (module load): importing ./commands.ts registers +map via addCmd. Phase 2 (init): registerFormatHandler("DESCFORMAT", descFormatHandler) wires the renderer into the format-attribute pipeline. Phase 3 (remove): unregisterFormatHandler cleanly detaches. See docs/architecture.md.

Configuration

The MapConfig shape (schemas.ts):

Field Type Purpose
noise.elevation MapNoiseConfig Seed, scale, and octaves for the elevation field (0..1).
noise.moisture MapNoiseConfig Seed, scale, and octaves for the moisture field (0..1).
biomes BiomeDefinition[] id, name, glyph, color, traversal, phrase fragments.
legend MapLegend Glyph categories — terrain / infrastructure / entities.
matrix WhittakerCell[] Elevation + moisture ranges -> biome id. First match wins.
viewportWidth number? Minimap columns; must be odd (default 15).
viewportHeight number? Minimap rows; must be odd (default 7).
sectors Record<string, { name, aabb }>? Named regions used for header titles.

See docs/configuration.md for the Whittaker matrix deep-dive.

Sparse persistence model

Procedural is the default. Every call to +map (or any DESCFORMAT resolution against a coord-bearing target) runs topology.sample() over the viewport — no rooms are created, no DBO records are written. The same coordinate is the same biome for the lifetime of the seed.

setOverlay(overlay) is the only way to make a coordinate persistent. Overlays live in the DBO collection map.overlays (constant OVERLAY_COLLECTION in schemas.ts), keyed by `${x},${y},${z}`. Overlay records carry optional glyph, biome, name, kind, faction, desc. Reads through getOverlaysInRegion(min, max) reject any AABB whose span exceeds REGION_MAX_TILES = 4096.

MapEntity + map-capable gating. Mobile pieces of the world (vehicles, squads, scouts) are persisted as MapEntity rows in the map.entities DBO collection — players never carry coordinates themselves. A MapEntity is only reachable from in-game when its containerId references a MAP_CAPABLE object the player has embarked, or when the player's state.mapControlling names the entity as a remote link. Without one of those, the player has no map presence and every map command refuses. See docs/entities.md for the full model.

Security invariants

  • safeText in renderer.ts strips %c? color codes and rewrites [/] to (/) so authored content cannot inject native-UI control sequences.
  • parseCoord (commands_internals.ts) accepts only finite integers and is range-checked downstream.
  • validateOverlay rejects non-integer coords, |x| > 1_000_000, multi-character glyphs, strings over 80 chars (2048 for desc), and any string containing [ or ].
  • getOverlaysInRegion throws when the requested AABB spans more than REGION_MAX_TILES = 4096 cells.

See docs/security.md.

DESCFORMAT integration

The handler is registered on the DESCFORMAT format-attribute hook. Resolution order:

  1. Softcode @desc — if the target has a DESC attribute, the engine uses it. As a defensive belt-and-braces, the handler also calls u.attr.get(target.id, "DESC") and returns null if it finds one.
  2. Our handler — fires when the target has a state.coord or the map flag. Returns the rendered sector.
  3. Engine default — used when our handler returns null.

Because the handler returns null for any target without state.coord and no map flag, it is safe to register globally.

Extension API for sibling plugins

The plugin exposes a set of registries and helpers so sibling plugins (combat systems, encounter tables, AI GM bridges, mobile companion apps) can extend the map without forking.

Realms (multi-map)

A realm is a logical map id. Coords carry an optional realm field; when absent, they belong to "default". The plugin uses realms throughout — overlay lookups, entity scans, fog memory, and topology sampling all scope by realm.

import { realmOf, DEFAULT_REALM } from "@ursamu/map-plugin";
realmOf({ x: 0, y: 0, z: 0, realm: "tatooine" }); // "tatooine"
realmOf({ x: 0, y: 0, z: 0 });                    // "default"

Per-realm MapConfig

import { registerMapConfig, getMapConfig } from "@ursamu/map-plugin";

registerMapConfig("tatooine", tatooineCfg);
// later: `getMapConfig("tatooine")` (and the cached TopologyEngine) drive
// rendering whenever a coord with realm "tatooine" is sampled.

Unregistered realms fall back to defaultMapConfig. Re-registering a realm invalidates its cached TopologyEngine.

Movement guards

moveCoord(u, playerId, from, delta, opts?) performs a single-step move with traversal cost + guard veto. Siblings register guards to veto with a reason:

import { registerMoveGuard, moveCoord, N } from "@ursamu/map-plugin";

registerMoveGuard(({ playerId, to }) =>
  isEncumbered(playerId)
    ? { allow: false, reason: "encumbered" }
    : { allow: true }
);

const result = await moveCoord(u, playerId, currentCoord, N);
// { ok: false, blocked: "encumbered", cost: 1, biome, from, to }  on veto
// { ok: true, cost, biome, from, to }                              on success

+move runs the same guard chain via runMoveGuards(ctx), so guards fire on both player- and entity-driven moves.

Building your own movement commands

If +move doesn't fit your game ("go", "drive", "pilot", "rush", "jump"), register your own command and use entityStep as the engine. It runs the full validation pipeline (bounds, overlay block, occupant stacking, impassable, guards) and emits the same map:player:moved / map:player:blocked events as the built-in.

import { addCmd } from "ursamu";
import {
  entityStep,
  getActiveEntity,
  STEP_DIRECTIONS,
} from "@ursamu/map-plugin";

addCmd({
  name: "drive",
  pattern: /^drive\s+(\S+)/i,
  lock: "connected",
  exec: async (u) => {
    const dir = STEP_DIRECTIONS[u.cmd.args[0].toLowerCase()];
    const active = await getActiveEntity(u);
    if (!dir || !active) return u.send("usage: drive <dir>");
    const r = await entityStep(u, active.entity, dir);
    u.send(r.ok
      ? `Your ${active.entity.name} rolls to (${r.to.x}, ${r.to.y}).`
      : `Stalled: ${r.reason ?? r.blocked}.`);
  },
});

To suppress the bundled +map / +move, configure the plugin in either the main engine config or a plugin-local override file.

Engine config — config/config.json (preferred, deployer-facing):

{
  "plugins": {
    "map": {
      "defaultCommands": {
        "map":  true,
        "move": false
      }
    }
  }
}

Plugin-local — config/map.json (defaults the plugin ships with):

{
  "defaultCommands": {
    "map":  true,
    "move": false
  }
}

true / missing = register; false = skip. Engine config wins per-key over the local file, so operators always have the final say without touching the plugin's repo. The rest of the extension API stays live regardless of these toggles.

Precedence (high → low):

  1. explicit opts: registerDefaultCommands({ move: false })
  2. env var URSAMU_MAP_DISABLE_DEFAULT_COMMANDS=1 (kills both — useful for tests / CI)
  3. engine config plugins.map.defaultCommands in config/config.json
  4. plugin-local config/map.json
  5. hardcoded defaults (both register)

Emitted events (via gameHooks):

Event Payload Fires when
map:player:moved { playerId, from, to, biome, cost } moveCoord succeeds
map:player:blocked { playerId, from, to, reason, biome } terrain / overlay / guard veto

Render extension points

import { registerRenderLayer, registerInfoLine } from "@ursamu/map-plugin";

registerRenderLayer("encounters", ({ viewport, realm }) =>
  encountersIn(viewport, realm).map((e) => ({
    coord: e.coord,
    glyph: "!",
    authored: true,
  }))
);

registerInfoLine(({ realm }) =>
  `Faction: ${activeFactionFor(realm)}`
);

Layers paint in registration order; later wins at the same coord. Re-registering the same name replaces. Info lines append below "ADJACENT SECTORS" in a new "INTEL" section. Each provider is sandboxed — a thrown provider is logged and skipped, render still completes.

Regions

Nested regions with metadata. getRegion returns the deepest match; getRegionPath returns the deepest-to-outermost chain.

import { getRegion, getRegionPath } from "@ursamu/map-plugin";

const cfg = getMapConfig("tatooine");
getRegion(cfg, { x: 0, y: 0, z: 0, realm: "tatooine" });
// → { slug: "moseisley", name: "Mos Eisley", parent: "huttspace", tags: ["spaceport"], ... }

getRegionPath(cfg, coord).map((r) => r.name).join(" — ");
// → "Mos Eisley — Hutt Space — Outer Rim"

Legacy MapConfig.sectors still works; it auto-converts into single-level regions when regions isn't set.

Pathfinding

import { findPath, getTraversalCost } from "@ursamu/map-plugin";

const path = findPath(from, to, {
  overlays: await getOverlaysInRegion(min, max),
  maxCost: 64,
  avoid: (c) => isHostileTile(c),
});

A* on the grid. Honors BiomeDefinition.traversal + overlay blocksMovement / kind === "blocked". Diagonals on by default (cost ×√2). Returns null when unreachable within maxCost or maxIterations. Cross-realm and cross-z queries return null.

REST surface

Bearer-authenticated routes under /api/v1/map/:

  • GET /api/v1/map/realm/:id/render?center=x,y&radius=N — JSON tile grid (parity with the in-game renderer).
  • GET /api/v1/map/player/:id{ realm, coord, biome }.
  • POST /api/v1/map/overlay — admin-locked, author tile.
  • DELETE /api/v1/map/overlay?x=&y=&z= — admin-locked, clear tile.

All routes return 401 before any DB / topology work when the bearer resolves to a null user.

v3 migration

After upgrading from v2.x, run once:

import { migrateToV3 } from "@ursamu/map-plugin";

await migrateToV3();
// { overlays: { inspected, rewritten, skipped }, fog: { ... } }

Rewrites pre-v3 "x,y,z" DBO ids/keys into the v3 "realm:x,y,z" form. Idempotent.

Tasks

Task Description
deno task test Run the Deno test suite (-A --unstable-kv).
deno task check Type-check index.ts.
deno task lint Lint sources (excludes tools/).
deno task showcase Render the canonical Jabiim Trenches snapshot to stdout.

Project layout

ursamu-map-plugin/
  index.ts                 plugin entry; init/remove lifecycle
  commands.ts              +map command + switches
  commands_internals.ts    parseCoord and shared helpers
  schemas.ts               types + constants (single source of truth)
  topology.ts              Simplex + Whittaker engine
  state.ts                 DBO overlays, player coord, validators
  format.ts                DESCFORMAT handler (renderer input assembly)
  renderer.ts              Latin-1 split-pane renderer
  entities.ts              MapEntity DBO + access predicates
  fog.ts                   fog-of-war, visibility, memory pruning
  mapconfig.ts             per-realm MapConfig + TopologyEngine registry
  regions.ts               nested region resolution
  move.ts                  moveCoord, move-guard registry, runMoveGuards
  extensions.ts            render-layer + info-line provider registries
  pathfinding.ts           getTraversalCost + findPath (A*)
  routes.ts                /api/v1/map REST surface
  migrate.ts               v3 DBO key migration helpers
  config.default.ts        bundled default biomes / matrix / noise
  config/config.json       runtime overrides
  help/map.md              in-game help
  ursamu.plugin.json       plugin manifest
  deno.json                JSR + tasks
  tools/showcase.ts        snapshot generator
  tests/
    map_render.snapshot.txt
    security.test.ts
    showcase.test.ts
  docs/                    architecture, configuration, security

Compatibility

UrsaMU >= 2.5.2. The plugin imports createNoise (per-instance Noise class) added in 2.5.2, plus header / divider / footer and registerFormatHandler / unregisterFormatHandler from 2.3.0. Zero npm dependencies — all noise + PRNG comes from the engine.

Roadmap

  • Chunk-key index for map.fog and map.entities so per-render region queries stop full-scanning the collections.
  • Vehicle stacking / collision rules — currently two entities can share a tile silently.
  • Line-of-sight for stealth detection: tie MapEntity.hidden into per-viewer probabilistic spotting rather than a binary toggle.
  • REST routes (read-only sector lookup, builder overlay CRUD) are not implemented.
  • getOverlaysInRegion still uses an in-memory full scan; a shared chunk index would cover overlays too.

License

MIT.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages