WorldForge Studio

03 — Offline world tools

Work with .gas-world documents without a server: validate, migrate, edit lore, and read the calendar — the world-engine bundled into the SDK.

The world document is the canonical interchange format (editor, 3D view, simulation, print and game-engine exports are projections of it — see docs/01-data-model.md). The /offline subpath exposes the same tools the editor uses, so an integration can process a world fetched once without keeping a connection open:

import {
  safeParseWorld, parseWorld, runMigrations, WORLD_SCHEMA_VERSION,
  loreOf, upsertLoreEntity, removeLoreEntity, validateWorldLore,
  formatDay, festivalsOn, calendarDrift, calendarDays, monthOfYear,
  type World,
} from '@blueforge-studio/worldforge-sdk/offline';

Validate and migrate

import { safeParseWorld, runMigrations, type World } from '@blueforge-studio/worldforge-sdk/offline';

// Migrate FIRST. `WorldSchema` pins `world.schemaVersion` to the current
// version (a literal), so a document on an older rung fails validation with
// `world.schemaVersion: Invalid literal value, expected "<current>"` until
// it has been migrated. (Checked: a 0.20.0 document fails `safeParseWorld`
// and passes after `runMigrations`.)
//
// Bring the document up the schema ladder (currently 0.53.0 — read
// `WORLD_SCHEMA_VERSION` rather than hard-coding it). Each rung is an
// explicit entry in the migration table; current → current is a no-op.
// `runMigrations` throws `Error('No migration from <v> to <current>')` for a
// version it has no rung for (a document newer than this SDK, or garbage).
const world = runMigrations(JSON.parse(raw) as World);
console.log(world.world.schemaVersion); // WORLD_SCHEMA_VERSION

const result = safeParseWorld(world);             // never throws
if (!result.ok) {
  // [{ path: 'world.name', message: '…', code: 'invalid_type' }, …]
  throw new Error(result.errors.map((e) => `${e.path}: ${e.message}`).join('; '));
}
const valid = result.data;

// `parseWorld(input)` returns the `World` or throws a ZodError — convenient
// when failure is fatal.

runMigrations reads only world.schemaVersion to pick the starting rung, so a bad document can still throw inside a migration step; wrap both calls if the input is untrusted. ValidationIssue is { path, message, code } (path is dot-joined, '(root)' for the top level).

WORLD_SCHEMA_VERSION (also re-exported as CURRENT_VERSION by the engine) tells you which ladder step a document is on. The server rejects documents it doesn't recognise, so migrate before uploading (client.worlds.create / .patch). createEmptyWorld({ id, name, author, projection, cols?, rows? }) builds a current-version document from scratch and serializeWorld(world) gives its deterministic, key-sorted UTF-8 bytes (a Uint8Array).

Lore without a server

let lore = loreOf(world);                       // no `lore` block → an empty one (every collection [])

lore = upsertLoreEntity(lore, 'festivals', { id: 'feast', name: 'Feast of Thaw', dayOfYear: 31, /* … */ });
lore = removeLoreEntity(lore, 'dynasties', 'valen'); // cascades: unlinks the house from
                                                     // festivals, strongholds, cultures, claims, …

const warnings = validateWorldLore({ ...world, lore }); // WorldLoreWarning[] — broken refs,
                                                        // dangling host refs; never a load failure

Collections (LORE_COLLECTIONS, the LoreCollection type): pantheons, deities, faiths, orders, traditions, spells, factions, entries, artifacts, rumours, dynasties, creatures, festivals, strongholds, cultures, eras, events. A WorldLoreWarning is { code, entity, id, message }.

upsertLoreEntity merges by id (insert-or-replace); removeLoreEntity scrubs every cross-reference to the removed entity across all collections — the same cascade the editor's Lore tab runs.

Calendar helpers (1-based!)

The world clock counts days 0-based (0 … daysPerYear - 1); calendars and festivals are 1-based. Convert with +1 at the boundary — none of the SDK's helpers do it for you (the SDK's realtime module does no calendar conversion):

formatDay(world.calendar, dayOfYear);   // "12 Frostmoor", or "day 12" with no calendar
festivalsOn(world, dayOfYear);          // festivals covering that 1-based day (counts lengthDays)
calendarDrift(world);                   // months total − clock.daysPerYear (0 = agrees)
calendarDays(world.calendar);           // sum of month lengths
monthOfYear(world.calendar, dayOfYear); // { month, dayOfMonth }, or undefined when
                                        // there are no months or dayOfYear < 1 / past the year

calendarDrift reports mismatched calendars as a number (positive = months overrun the year) — a warning, never a parse failure: a half-written calendar must still load. Movable feasts (whenText, no dayOfYear) never match a date — there is no date to match.

What else is in here

Everything @world-map-editor/world-engine exports (the subpath is export * of it), plus WORLD_SCHEMA_VERSION from shared: grid math (hexToPixel(hex, size, orientation), gridToHex(x, y, orientation), …), procgen presets (entitiesForPreset(preset, opts)), dialogue and quest helpers, the heraldry armsSvg(arms, size?) drawer, POI search (searchPois), Yjs snapshot/hydrate helpers (runMigrationsOnDoc(doc) for live documents), and the full Zod schema (WorldSchema and every sub-schema). That surface is large and changes with the engine; this guide documents only the pieces above — use your editor's autocomplete on the subpath for the rest.

There is no migrateWorldTo export, although the offline.ts header comment mentions one: only runMigrations (migrate to current) exists.