@game-infra/story-schemas
v0.5.4
Published
Story-mode API contract: branching arcs and beats built on the event-editor's encounter-stage and choice DTOs, behind one design-content resource
Readme
@game-infra/story-schemas
API contract for authored design content and story mode: valibot schemas plus toad-contracts defineApiContract definitions. Covers worlds, the content written against them (locations, characters, skill definitions, lore entries), the story arcs chosen for a world, and the branching beats those arcs are told in. Builds on @game-infra/api-schemas-core for the shared success-response wrapper.
Published to npm. Also consumable as sibling source (exports points at src/).
Install
// pnpm sibling checkout
"@game-infra/story-schemas": "link:../../game-infra/packages/schemas/story-schemas"
// or from npm
"@game-infra/story-schemas": "^0.1.0"valibot is a peer dependency (^1.4.0); the consumer provides it. @game-infra/api-schemas-core is a runtime dependency (re-uses createSuccessResponseSchema).
One resource, not a family
All of it is the same shape — a keyed JSON payload, scoped to a parent, tagged with a type — so it is one resource with five endpoints rather than a CRUD family per kind. contentType and scopeId do the work a separate resource per kind would otherwise do:
| contentType | scopeId | contentId |
| ------------------------------------------------------------------------------------ | ---------- | ----------------------- |
| world | - | the world id |
| locations, characters, events, stats, skills, perks, resources, lore | a world id | the entity id |
| story-arc | a world id | the story id |
| story-node | a story id | the trail of choice ids |
data is an opaque JSON string. The service stores and indexes it, never parses it — a game's design vocabulary changes far more often than this contract should, which is the same rule GameEntitySaveRequestSchema already follows in api-schemas-core. contentType is likewise plain string(); DESIGN_CONTENT_TYPES is the suggested vocabulary, not a closed one.
Usage
import { designContentListContract, designContentLoadContract } from "@game-infra/story-schemas";
import { mapApiContractToPath } from "@toad-contracts/valibot";
designContentListContract.pathResolver({ gameId: "g1" }); // '/games/g1/design-content'
mapApiContractToPath(designContentLoadContract);
// '/games/:gameId/design-content/:contentType/:scopeId/:contentId'Register on a Hono service with @toad-contracts/hono (buildHonoRoute) and call from a frontend with @toad-contracts/frontend-http-client (sendByApiContract) — both drive off the same contract objects.
The five endpoints
| Contract | Method + path | What it is for |
| ------------------------------- | ------------------------------------------- | -------------------------------------------------- |
| designContentListContract | GET /games/:gameId/design-content | every listing, narrowed by contentType/scopeId |
| designContentLoadContract | GET …/:contentType/:scopeId/:contentId | one row by key |
| designContentSaveContract | POST /games/:gameId/design-content | upsert one row |
| designContentBulkSaveContract | POST /games/:gameId/design-content/bulk | upsert many, optionally as a snapshot |
| designContentDeleteContract | DELETE …/:contentType/:scopeId/:contentId | delete one row, with an optional cascade |
The listing answers every question story mode asks:
// the worlds to choose between
{ contentType: "world" }
// a world's whole design document, payloads included
{ scopeId: "the-ash-reckoning", includeData: "true" }
// the arcs offered for that world
{ contentType: "story-arc", scopeId: "the-ash-reckoning" }
// every beat already written for an arc — the map of what is pregenerated
{ contentType: "story-node", scopeId: "the-quiet-forge-answers", includeData: "true" }includeData is off by default: a picker needs names and counts, not every character's biography.
Snapshots and cascades
designContentBulkSaveContract takes a replace list. Each entry clears a scope (or one type within it) before the write, which is what makes a bulk save a snapshot rather than a merge — without it, content the author deleted locally would survive on the server forever.
designContentDeleteContract takes ?cascade=:
none(default) — the row alone.prefix— the row and every row whosecontentIdsits under it. Discarding a story branch takes its descendants with it this way.scope— the row and everything scoped to it, transitively. Deleting a world also removes its content, its arcs, and their beats.
Payload shapes — the event editor's, not new ones
A branching story and a multi-stage event are the same object seen from two angles: both are prose with gated, consequential branches out of it. So a story beat is an EncounterStageDTO and a branch is a ChoiceDefinitionDTO, straight from @game-infra/event-schemas:
| Story mode | event-editor DTO |
| ---------- | --------------------------------------------------------------------------------------------------------------------- |
| arc | EventDefinitionDTO header (multi-stage) + hook/tags + story-specific player power, stat values, and skill ranks |
| beat | EncounterStageDTO + model/origin/revision/notes |
| branch | ChoiceDefinitionDTO, unchanged |
A branch therefore carries conditionsToShow, conditionsToEnable, consumableConditions, effects and an outcome config exactly as an event's choice does, and a finished arc assembles into an event_definitions row by collecting its beats into stages — a copy, not a translation.
data is opaque to the service, but two clients writing and reading the same rows still have to agree, so the shapes ship alongside the contract and are parsed client-side:
import { StoryBeatPayloadSchema } from "@game-infra/story-schemas";
import { safeParse } from "valibot";
const parsed = safeParse(StoryBeatPayloadSchema, JSON.parse(row.data ?? "{}"));
if (parsed.success) console.log(parsed.output.description, parsed.output.choices);WorldPayloadSchema, StoryArcPayloadSchema and StoryBeatPayloadSchema are exported for this, along with the underlying ChoiceDefinitionDTOSchema, PreconditionDTOSchema, ActivationDTOSchema and ConsumableConditionDTOSchema so a story client needs one import rather than two. They are deliberately not attached to any endpoint.
Use the lenient DTOs, not the strict ones. StrictPreconditionDTOSchema and StrictActivationDTOSchema enumerate one game's vocabulary (strength, mana, LearnSpell, SpecificLecture). A design world declares its own stats, skills, perks and resources, so a story client uses the lenient { type, params } forms and validates type and the ids in params against the world it belongs to.
What this package is not for
Everything here is authored — written once, read by every player, and the
same tomorrow as today. Anything that changes because someone is playing —
a session in progress, its journal of turns, a save slot — belongs to
@game-infra/game-state-schemas and game-state-service instead. Freeform
mode's turn journal and synapse memory live there, in
FreeformTurnPayloadSchema and friends.
Beat addressing
A beat's identity is the trail of choices taken to reach it — that is what makes "has this branch been travelled before?" a lookup rather than a search. The client minting ids and the service sweeping subtrees import the same helpers:
import { storyNodeId, childStoryNodeId, isStoryNodeAtOrBelow } from "@game-infra/story-schemas";
storyNodeId([]); // 'root'
childStoryNodeId("root", "take-the-tunnel"); // 'root.take-the-tunnel'
isStoryNodeAtOrBelow("root.take.left", "root.take"); // true
isStoryNodeAtOrBelow("root.taken", "root.take"); // falseSegments join with . rather than / because a beat id travels as a single URL path segment, which a slash would split; the dot is also what cascade=prefix sweeps on.
Extension points
contentType: typedstring();DESIGN_CONTENT_TYPESis the suggested vocabulary and a game re-tightens it in its own validation layer.data: opaque to the contract, so a game evolves its design document without a contract bump. Add a payload schema next to the existing three when a new type needs one.origin: a beat isgeneratedorauthored— the latter is a branch a player wrote themselves.- Condition and effect vocabulary:
typeis free text in the lenient DTOs, so each game declares the set it evaluates and drops the rest.
Dependencies
@game-infra/api-schemas-core (the success-response wrapper), @game-infra/event-schemas (the stage and choice DTOs), @toad-contracts/core, @toad-contracts/valibot; peer valibot.
Consumers
services/game-content-service (the Hono service registering these routes) and the design/story clients that call them — currently the Fluid Emerald game editor.
