Files
coa-server-manager/docs/PORTABLE_LEVEL_PROJECTION.md
T
2026-10-07 11:13:21 +05:00

11 KiB

Level-cap projection (Phase 8): design, implementation, verification

Companion of PORTABLE_LEVEL_PROJECTION_AUDIT.md (what the core and mod-coa-content-scaling actually do; read it first). CoA only.

Invariant. The Owner's canonical character is never down-levelled. A realm whose MaxPlayerLevel is below the character's level is given a working copy at the cap (P = apply(hold, C)); everything the copy cannot hold stays in C; a realm that can hold it all gets all of it back.

1. Who decides, and about what

Only the running core knows the effective (post-scaling) item levels and holds the CoA progression tables, so the core decides and the Manager applies. The Manager never reads a level requirement.

Core (src/server/coa/CoAPortableProjection.{h,cpp}) Manager (crates/coa-core/src/portable/projection/)
portable project <job_id>: read-only query over a job file (header query:"project", snapshot) -> manifest in <job_id>.result ProjectionAnswer::parse (strict), Decision::{Native, Projected(ProjectionHold)}
Project(input, level): held items, held abilities, held buttons, held entries of the stored build records, blocked records apply(canonical, hold); settings::Record (structure-only codecs); merge3_with(.., MergeProjection)
Signature(), capabilities report v2 progression {max_player_level, projection_protocol, projection_policy_version, progression_signature, scaling_enabled} profile v2 (capabilities::Progression), ProgressionPin, ProjectionContext
import job header projection {active, level, policy_version, progression_signature}: verified, idempotence check (the core projects the working copy again and it must hold nothing), coa.portable.pin written realm::project::plan (the one policy for import, update, reconcile), CoreOracle, SuppliedDecision
login of a session character whose pin is not the core's progression: refused HostService ends the session under the old pin, reproject_session

What the core holds at level L (policy version 1)

  • Items: a top-level item in an equipment slot (0-18) or a bag slot (19-22) whose runtime RequiredLevel is above L (this is what _LoadInventory + CanEquipItem would otherwise delete from the slot and mail), and the contents of a held bag. Everything else (backpack, bank, bag contents) is written.
  • Abilities: a spell of the character that appears in a CoA level table (ClassSpells, LegacyGeneratedClassSpells, Ranks, Felsworn rifts, TaughtAbilities, TalentReplacements, talent entries) and no source of which allows it at L (a source allows it if its level is <= L, its talent entry is not held, its parent ability is not held). Spells of the live baseline, proficiencies and unresolved trainer spells are level independent and never held. Computed to a fixpoint.
  • Talent entries: held when RequiredLevel > L; paid entries (AE/TE cost) in (RequiredLevel, EntryId) order are kept while Spent fits GetCoATalentBudget(class, L), whole entries only (no partial rank); an automatic entry whose required entry is held is held with it.
  • Buttons: character_action spell buttons showing a held ability.
  • Stored builds (core.ascension_slot.<n>, core.ascension_build.*, core.ascension_bar.*, also .slot.<n>.build/.bar): the held entries/buttons are listed per record; a record the core cannot take apart is blocked: not given to the realm and never merged back.
  • Level L, xp 0. Money, honor, reputation, quests, skills, pets, glyphs, wardrobe, collections, extensions: applied (unchanged by the cap).

If the cap has no talent budget row for a class, the query fails (projection): nothing is guessed.

The progression signature

SHA-256 over: policy version, MaxPlayerLevel, the content-scaling switches that decide the layout (Enable, Progression.Mode/ClassicEnd/TbcEnd, ScaleItems), the compile-time CoA progression tables, the talent data and budgets as loaded, and the sorted (entry, runtime RequiredLevel) of every item template (hashed once, lazily, not the item database). Eras/packs are covered through the item levels they change. It is constant while a worldserver runs; any change means a restart.

2. Manager flow

plan(C, revision, options, oracle)
  no capabilities          -> given as is (lower layers, bare tools)
  capabilities, no progression -> refused (unknown is never "no cap")
  level <= cap             -> native; pinned to the realm's progression
  level >  cap             -> oracle.decide(C) (running core via RA, or SuppliedDecision for a stopped realm) -> hold
                              checked: same signature/cap/policy as the realm's profile, made for exactly this snapshot
                              view = apply(C, hold); context = ProjectionContext{canonical_level, projected_level, canonical_revision,
                              content_profile_hash, progression_signature, projection_policy_version, hold}
  • Import (offline and online) and update use plan; the job/script carries the pin; after the realm committed, realm_projection (schema 8) holds the context and character_server_mapping.progression_pin the pin (cap, policy, signature, content profile hash, projected or native).
  • Update merge3(realm now, P_old(synced), P_new(canonical), Strict): both ends are views (the stored hold is re-applied to the synced canonical, a fresh decision to the new one); build records merge key by key; when the old state was a working copy, the new view's level/xp win over the player's own (adopt_progression). A changed pin forces the update even at the same canonical revision. A stopped realm needs a supplied decision, else ProjectionNeedsRunningCore (nothing is written).
  • Session / reconcile merge3(C0, B0, B1, Lenient) with freeze_progression (the realm's level/xp never reach C), blocked records ignored, build records merged by key. A held item/ability/button is absent from B0 and B1, which the existing three-way merge already reads as "filtered by the realm: kept". Nothing was added to the merge for them.
  • Why key-wise merge of builds. The core rewrites core.ascension_slot.<n> from the live spellbook at every save (audit section 6). Replaced as one value, B0 -> B1 would erase every held entry of C. Merged by key (entries, buttons, (class, spec) head) the realm's rewrite changes nothing and a pick made at the cap arrives.
  • Session protocol 2. PortableSessionStarted.progression {pin, projection?}, PortableCheckpoint.pin, OwnerAck.pin. The Owner stores the pin/context at the start (and refuses an inconsistent one: another canonical level/revision, a projected pin without its context), then refuses any checkpoint under another pin. session_protocol 2 in the content profile and the version check on every message make a v1 and a v2 peer refuse each other before a character is armed.
  • The cap moves under a session (restart with another MaxPlayerLevel/rules). The core refuses to let the character in (coa.portable.pin is not its progression; read at login by query, because character_settings sources outside core. are not loaded into the player). HostService::observe_profile sees the new signature; tick reads the realm as it is (the old working copy: nobody played it) and sends the final checkpoint under the old pin; the Owner merges it as a projection of the old cap. The next session is flagged reproject and rearm_pending does not arm it. reproject_session asks for the decision at the new cap, updates the working copy in place (arming the next session in the same transaction) and clears the flag. No message ever merges across two progressions.

3. Classification of the other state

Apply: money, honor, arena points, currencies, titles, explored zones, reputation, rewarded and active quests, skills, glyph rows (none exist), pets (the core levels a pet to its owner at load: normalisation, not progress), wardrobe, collections, extensions, macros. HoldByProjection: items, abilities, buttons, build entries as above. Projection-owned: level, xp. Unsupported: stock talent rows (never written, as before), a build record that does not parse (blocked, kept canonical). Evidence per row: audit sections 4-7.

4. Capability profile v2

profile_version 2 adds progression (outside the content hash) and Feature::LevelProjection; core report v2 carries progression (a v1 core reports none). A stored v1 profile is migrated (from_json): content and hash are unchanged, progression = None, which compat reads as Blocking "probe the realm through its core" for every operation. ProfileChange::progression_changed reports a moved cap/signature even when the content hash did not move.

5. Verification

Unit (Manager, cargo test -p coa-core --lib): codecs, apply, activation matrix (80->80 none, 80->70, 80->60, 70->60, 60->60 none), strict answer parsing, key-wise merge incl. idle session equals canonical, repeated and later checkpoints, blocked records, level freeze, adoption, pins/contexts in the store, fake-realm sessions (whole projected session, forged pin, wrong projection, cap change under a session, online at the change), profile v1 migration, compat outcomes.

Live (#[ignore], real MySQL A/B): realm::live_projection (offline import at cap 60 with a supplied decision, offline play + reconcile with the realm's level moved and a new item, update to a cap-80 profile restoring helmet/abilities with the canonical byte-identical, down to 70 and 60 again). Live with a real worldserver (session::live_projection, cap-60 build of the fork, optional cap-70 configuration, fixture ranger or a real level-80 character): the core refuses an unprojected level-80 job (above_level_cap), an inconsistent working copy (projection_inconsistent) and another progression (progression_changed); the real load mails nothing the projection held; the Host takes B0 under the pin; the realm-side level is moved (59) and the canonical level/xp stay; checkpoints and the final checkpoint keep held items, abilities and build entries; after a restart at cap 70 the core refuses the character, the Host ends the session under the old pin, reproject_session brings the working copy to 70 and restores exactly what 70 allows.

6. Limits

  • A stopped realm cannot be projected without a decision supplied from its core (portable_smoke project ... --out, then --projection).
  • Whole-entry hold of talents (a partly affordable entry is held entirely); the budget order is deterministic, not optimal.
  • Offline (non-runtime-session) play is not guarded against a cap change in between; run update after a cap change (the pin makes it write) before playing. Runtime sessions are guarded by the core.
  • Item holding is by level only (equipment and bag slots), as in the audit; skill/proficiency requirements stay the realm's own business.
  • No conflict-resolution UI, Registry, Relay, Wildcard, personal bank or equipment sets in this phase.