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.