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
RequiredLevelis aboveL(this is what_LoadInventory+CanEquipItemwould 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 atL(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 whileSpentfitsGetCoATalentBudget(class, L), whole entries only (no partial rank); an automatic entry whose required entry is held is held with it. - Buttons:
character_actionspell 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 isblocked: not given to the realm and never merged back. - Level
L, xp0. 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 andcharacter_server_mapping.progression_pinthe 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, elseProjectionNeedsRunningCore(nothing is written). - Session / reconcile
merge3(C0, B0, B1, Lenient)withfreeze_progression(the realm's level/xp never reachC), blocked records ignored, build records merged by key. A held item/ability/button is absent fromB0andB1, 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 -> B1would erase every held entry ofC. 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_protocol2 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.pinis not its progression; read at login by query, becausecharacter_settingssources outsidecore.are not loaded into the player).HostService::observe_profilesees the new signature;tickreads 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 flaggedreprojectandrearm_pendingdoes not arm it.reproject_sessionasks 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
updateafter 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.