@game-infra/event-schemas
v1.1.4
Published
Narrative event-editor API contract: event definitions, board collaboration, AI generation/review/polish schemas + toad-contracts endpoint definitions
Readme
@game-infra/event-schemas
Narrative event-editor API contract: valibot schemas plus toad-contracts defineApiContract definitions for event definitions, the Miro-style event board (HTTP + live-collab WebSocket protocol), and the AI generation/review/polish/tier-review endpoints. This is the largest schema slice extracted from golem-forge; it builds on @game-infra/api-schemas-core (response wrappers, AI provider enum) and @game-infra/common-data-model (tier-review entity types).
Published to npm. Also consumable as sibling source (exports points at src/).
Install
// pnpm sibling checkout
"@game-infra/event-schemas": "link:../../game-infra/packages/schemas/event-schemas"
// or from npm
"@game-infra/event-schemas": "^0.1.0"valibot is a peer dependency (^1.4.0); the consumer provides it.
Usage
Contracts carry method, path, params, and request/response schemas in one object. Build a URL with pathResolver, derive the route pattern with mapApiContractToPath:
import {
eventDefinitionLoadContract,
aiReviewEventContract,
TierReviewRequestSchema,
} from "@game-infra/event-schemas";
import { mapApiContractToPath } from "@toad-contracts/valibot";
eventDefinitionLoadContract.pathResolver({ gameId: "g1", eventId: "e1" });
// '/games/g1/event-definitions/e1'
aiReviewEventContract.pathResolver(); // '/ai/review-event'
mapApiContractToPath(eventDefinitionLoadContract); // '/games/:gameId/event-definitions/:eventId'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 event board's live traffic is not HTTP: eventBoardLayoutWsContract is only the upgrade endpoint, and the actual edits flow through the ClientCollabMessageSchema / ServerCollabMessageSchema op protocol validated on the WebSocket.
Text that reads differently per player
An event's prose is normally one fixed string, which forces an author who wants "you have been here before" to write a second event. ConditionalTextDTO assembles it at read time instead, out of fragments carrying the same preconditions a choice is gated on:
import {
type ConditionalTextDTO,
flattenConditionalText,
resolveConditionalText,
} from "@game-infra/event-schemas";
const greeting: ConditionalTextDTO = {
fragments: [
{
id: "greeting",
text: "The smith looks up at a stranger.",
variants: [
{
conditions: [{ type: "HasMet", params: { characterId: "tallow" } }],
text: "The smith looks up and knows your face.",
},
],
},
{
id: "the-debt",
text: "She has not forgotten what you owe her.",
conditions: [{ type: "FlagSet", params: { flag: "owes-the-smith" } }],
},
],
};
resolveConditionalText(greeting, (condition) => myGame.holds(condition));
flattenConditionalText(greeting); // the whole text as written, for anything that cannot evaluateA fragment whose conditions fail is left out; otherwise the first variant whose conditions hold replaces its text, which is therefore the "else" wording. Nothing here evaluates a precondition — which terms exist is a game's own vocabulary, so the predicate is an argument.
It hangs off EventDefinitionDTO and EncounterStageDTO as conditionalDescription, and off ChoiceDefinitionDTO as conditionalName / conditionalDescription. Every one of those fields is optional and sits beside the plain string it refines; by convention the plain field holds flattenConditionalText of the conditional one, so a consumer that does not understand fragments still shows the whole authored text. The event editor ships a ConditionalTextEditor for authoring them.
Strict validation is built per game, not shipped
Preconditions, activations and consumable conditions travel as { type, params }. The DTOs here are lenient — any type, any params — because a shared service stores events, it does not adjudicate them.
A game wants more, so it builds the strict schemas from its own vocabulary:
import { createStrictEventSchemas } from "@game-infra/event-schemas";
import { createCodecRegistry, commonActivationCodecs } from "@game-infra/common-data-model";
import { parse } from "valibot";
const strict = createStrictEventSchemas({
preconditions: [...commonPreconditionCodecs, ...myGameCodecs.preconditions],
activations: [...commonActivationCodecs, ...myGameCodecs.activations],
consumableConditions: myGameCodecs.consumables,
});
parse(strict.eventDefinition, incomingEvent);A TypeCodec from common-data-model already carries type and paramsSchema, so it satisfies EventTermSpec structurally — pass the codec arrays you already have. An omitted or empty kind rejects every term of that kind.
Returned schemas are GenericSchema, since their shape depends on the terms passed in. Name a type from your own call: InferOutput<typeof strict.eventDefinition>.
Migrating from the shipped strict schemas
ModifiableStatSchema, StrictActivationDTOSchema, StrictPreconditionDTOSchema, StrictConsumableConditionDTOSchema, StrictOutcomeConfigDTOSchema, StrictChoiceDefinitionDTOSchema, StrictEncounterStageDTOSchema, StrictEventDefinitionDTOSchema and the per-term schemas behind them (ModifyStatActivationSchema, LearnSpellActivationSchema, SpecificLecturePreconditionSchema, CastSpellConsumableSchema, …) are removed.
They enumerated one game's vocabulary — strength, mana, maxSneakiness, LearnSpell, SpecificLecture — in a package every game shares, so a second game could only adopt those terms or ignore strict validation entirely. Replace them with createStrictEventSchemas and the terms your game registers; the shapes it returns are the same, with the vocabulary yours.
EventRaritySchema was union([string("common"), string("uncommon"), …]), which reads like an enum but is not one: valibot's string(message) takes an error message, not a literal, so it accepted any string. It is now spelled string() — identical at runtime. Re-tighten it in your own layer if you want your tiers enforced.
Extension points
- Tier-review entity vocabulary:
TierReviewEntityTypeSchemais a plainstring(). golem-forge hardcodedpicklist(['persona','item','secret','spell','organization']); that vocabulary is game-specific, so games re-tighten it via the editor'stierReviewEntityTypesconfig. TheentityTypefield intierReviewAIOutputSchemalikewise dropped itsenumso each game defines its own types. - AI provider: the
providerfield reusesAIProviderSchemafrom@game-infra/api-schemas-core.
Migrating from golem-forge
The old EventApiPaths constants and PathResolvers / resolve*Path helpers (including the internal-tools EventGenerationPathResolvers and NextScreenPathResolvers) are gone. Get a URL from the contract itself with contract.pathResolver(params) and the :param route pattern with mapApiContractToPath(contract). Prefer driving requests through @toad-contracts/frontend-http-client (sendByApiContract) and routes through @toad-contracts/hono (buildHonoRoute), which consume the contract directly so you never touch a raw path.
Dependencies
@game-infra/api-schemas-core (createSuccessResponseSchema, TimestampFieldsSchema, ApiResponseSchema, AIProviderSchema), @game-infra/common-data-model (re-exported EntityInfo / AvailableEntities types), @toad-contracts/core, @toad-contracts/valibot; peer valibot.
Consumers
event-editor (frontend), core-service (event-board Durable Object plus the event-definition, board-layout, and AI endpoints).
