SRD Level-Up Contracts

Canonical Decision Shape

  • LevelUpDecision now supports structured replacements with replacements: [{from_id, to_id}].
  • selected_values remains for non-replacement choices.
  • LevelUpChoice.depends_on declares branch gates such as asi/feat depending on asi-or-feat.
  • Responses return canonical decisions in decision_log, including replacement objects.

The decision model distinguishes between two fundamentally different player actions: selections (adding something new, like choosing a spell to learn) and replacements (swapping an existing choice for a different one, like replacing a known spell). Earlier iterations tried to encode both in selected_values using arrow-token syntax (old->new), but this conflated two distinct operations into one field and made validation ambiguous. The structured ReplacementSelection type (@file:engine/srd_level_up/schemas.py) makes the intent explicit at the schema level so the engine can validate the from_id against the character's current state independently from the to_id against legal options.

The depends_on mechanism enables conditional choice trees. When a character reaches a level granting an ASI-or-feat choice, the engine emits a parent choice (asi-or-feat) whose selected value gates which child choice (asi or feat) is active. This avoids requiring the UI to encode class-specific branching logic—the engine declares the dependency graph and the UI simply hides inactive branches. ChoiceDependency.allowed_values specifies which parent selection activates the child, so the relationship is fully declarative.

Each LevelUpDecision carries a source tag (manual, default_policy, or imported_plan) that records how the decision was produced. This is informational rather than load-bearing—the engine validates all decisions identically regardless of source—but it enables audit trails and helps the UI distinguish user-entered choices from auto-populated defaults.

Typed Validation and Warnings

  • ValidationIssue.code is constrained to known backend codes.
  • Legacy replacement tokens (old->new) are still accepted and canonicalized, but now emit deprecated_replacement_token warnings.
  • /preview, /apply, and /validate responses include warning payloads alongside error issues.

The ValidationCode literal type (@file:engine/srd_level_up/schemas.py) enumerates every possible validation outcome as a machine-readable code. This was chosen over free-text error messages to enable the frontend to programmatically react to specific issues—for example, highlighting a particular choice widget when invalid_selection_count is returned for its choice_id. The path and choice_id fields on ValidationIssue provide enough context for the UI to map an issue back to a specific input element.

Warnings and errors are separated into distinct response fields (validation_issues for errors, validation_warnings for warnings) rather than being mixed with a severity flag as the only discriminator. This means clients can check len(validation_issues) == 0 to determine overall validity without filtering. The severity field still exists on each issue for clients that want to render a unified list, but the structural separation is the primary API contract.

The backward-compatible handling of legacy arrow tokens is a migration affordance. Old decision plans stored in the database may contain selected_values entries like "fireball->lightning-bolt". Rather than requiring a data migration, the engine parses these tokens, converts them to ReplacementSelection objects, and emits a deprecated_replacement_token warning so clients know to update their payloads.

State Hardening

  • Magical Secrets state is first-class via magical_secrets_spell_ids.
  • Off-list legality allowances are explicit via off_list_spell_allowances with source_tag.
  • Backward compatibility for old snapshots is preserved by reading notes.magical_secrets_spells and promoting it to canonical fields.

The SrdCharacterState model (@file:engine/srd_level_up/schemas.py) uses a model_validator to normalize legacy state on load. When a snapshot was created before magical_secrets_spell_ids existed, the validator reads notes.magical_secrets_spells and promotes those values to the canonical field. It also ensures that every Magical Secrets spell has a corresponding OffListSpellAllowance entry with source_tag: "magical_secrets", deduplicating as it goes. This normalization runs at deserialization time, meaning the engine always operates on clean canonical state regardless of when the snapshot was created.

The off_list_spell_allowances pattern is a general-purpose mechanism for tracking spells that a character can legally know despite them not appearing on their class spell list. Each allowance pairs a spell_id with a source_tag that explains why the spell is legal (e.g., "magical_secrets", or potentially future sources like domain spells or racial features). This tagged approach avoids a proliferation of separate list fields for each source of off-list spells.

Apply Safety and Validation

  • Unknown and unoffered choice_id decisions are rejected (unknown_choice_id).
  • Duplicate/off-branch decisions are rejected (choice_conflict).
  • Selection uniqueness is enforced before min/max counting.
  • Warlock Mystic Arcanum option filtering reads typed choice metadata fields and no longer uses dict-style access that can crash preview/apply.
  • Decision application is transactional: invalid or incomplete decision sets do not mutate the proposed state.
  • Subclass decisions trigger same-level progression recomputation so subclass grants and validations are applied against the correct progression.
  • Feat and invocation prerequisites and feat repeatability are enforced from source data metadata.
  • Replacement operations validate legal target sets, not only token format.

The transactional semantics are the most important safety property in the apply flow. When the engine processes a decision set, it builds a proposed_state by applying decisions sequentially. If any decision fails validation, the entire proposed state is discarded and the response returns the original state unchanged. This prevents a scenario where the first three of five decisions succeed, leaving the character in a half-leveled state that is neither the old level nor a valid new level.

Subclass decision handling has a subtle but critical ordering concern. When a character reaches a subclass-selection level (e.g., level 3 for most classes), the subclass decision must be processed before the engine evaluates subclass-granted features, spells, and additional choices. The engine handles this by detecting a subclass decision in the set, applying it first, then recomputing the progression for the current level with the subclass factored in. This recomputation may introduce new choices (subclass-specific features) that other decisions in the set can then resolve.

Prerequisite enforcement uses the OptionPrerequisite type hierarchy, which supports four prerequisite kinds: ability_score (requires a minimum ability score), level (requires a minimum character level), feature (requires possession of a specific feature), and spell (requires knowing a specific spell). Each prerequisite kind has its own required fields validated by a model_validator, making it impossible to create a malformed prerequisite at the schema level. Feat repeatability is tracked via feat_history on the character state—if a feat appears in history and its metadata marks it as non-repeatable, the option is filtered from the available set.

Persistence Contract

  • /api/characters/{id}/level-up/apply persists snapshots only when there are no validation issues and no unresolved required choices.

The persistence boundary is deliberately sharp. The character store (@file:engine/characters/store.py) calls the stateless apply_level_up function, inspects the result, and only creates a new CharacterSnapshotRecord if validation_issues is empty and remaining_unresolved_choices contains no required choices. This means the persistence layer has no concept of "partial success"—either the level-up fully succeeds and a new snapshot is appended, or nothing changes. The store returns a CharacterLevelUpApplyStoreResult that includes any issues and unresolved choice IDs so the caller can present actionable feedback.

This all-or-nothing persistence model was chosen over an alternative where partial decisions could be saved and resumed later. The decision-plan feature (PUT /api/characters/{id}/decision-plan) fills the "save progress" use case instead—players can persist their in-progress choices as a plan without committing them as a level-up. This keeps the snapshot history clean (every snapshot represents a fully valid character state) while still supporting iterative decision-making workflows.

Design Rationale

The level-up engine is designed as a pure function: given a character state, a target level, and a set of decisions, it produces a deterministic result. No database access, no side effects, no randomness. This purity was chosen to make the engine exhaustively testable (see @file:tests/test_srd_level_up_api.py) and to allow the same engine to be called from both the stateless API endpoints and the character persistence layer without code duplication.

The three-endpoint pattern (previewapplyvalidate) mirrors a transactional workflow. Preview shows what will happen and what choices are available. Apply attempts to execute the decisions and reports success or failure. Validate is a lightweight check that returns a boolean validity signal without computing the full apply result. In practice, the UI calls preview on page load, re-calls it as the user makes choices (to get updated validation), and calls apply only when the user explicitly confirms.

Assumptions & Constraints

  • Single-class only: The v1 engine supports exactly one class per character. Multi-classing would require fundamental changes to how progressions are loaded, how spell lists are merged, and how choice IDs are namespaced.
  • SRD 2014 only: The SupportedSrdEdition type is Literal["2014"]. The broader SrdEdition type includes "2024" for progression generation, but the level-up engine rejects 2024 edition requests at the schema level.
  • Stateless computation assumes valid prior state: The engine trusts that the incoming SrdCharacterState is internally consistent (e.g., spell counts match, ability scores are within range). The model_validator on SrdCharacterState enforces structural invariants like level range and ability score bounds, but does not re-derive whether the current spell list is legal for the current level—it accepts it as given.
  • Progression data must be pre-generated: The engine reads progression JSON from disk rather than computing progressions on the fly. A missing or outdated progression file will result in a missing_progression validation issue rather than a runtime crash.
  • Choice IDs are stable across calls: The engine generates deterministic choice_id values based on the choice family, level, and position. Clients can rely on these IDs being consistent between a preview call and a subsequent apply call for the same state and target level.

Conceptual Model

The level-up flow can be modeled as a state machine with three stages:

  1. Projection: The engine loads the progression for the target level and computes what changes deterministically (hit points, proficiency bonus, new features) versus what requires player input (spell selections, ASI/feat choices, subclass pick). This produces a SrdLevelUpPreviewRecord with the full choice graph.
  2. Resolution: The player (or an automated policy) provides decisions for the available choices. Each decision is validated against the choice's option set, prerequisites, and dependency constraints. Decisions can be submitted incrementally—the preview endpoint accepts partial decision sets and reflects which choices remain unresolved.
  3. Commitment: Once all required choices are resolved and validation passes, the apply endpoint produces a new SrdCharacterState representing the leveled-up character. The persistence layer can then atomically append this as a snapshot.

The boundary between projection and resolution is where most of the complexity lives. The engine must handle dependency chains (ASI-or-feat gates), dynamic option sets (subclass-granted spells that only appear after the subclass decision), and replacement interactions (swapping a known spell, which changes what spells are legal to learn). The depends_on graph and the subclass-recomputation logic exist to keep this complexity declarative rather than procedural—the engine describes the choice structure and the client navigates it.