Skip to main content

Dark Citadel → Dark Tower — Character State Migration

Dark Citadel → Dark Tower — Character State Migration

> Status: LOCKED 2026-08-10, join key amended 2026-10-08. This is the canonical design for player > character state migration (legacy Dark Citadel skill → Dark Tower skill). The constants, scaling,

Last updated: October 10, 2026

Dark Citadel → Dark Tower — Character State Migration

Status: LOCKED 2026-08-10, join key amended 2026-10-08. This is the canonical design for player character state migration (legacy Dark Citadel skill → Dark Tower skill). The constants, scaling, compensation and season rules stay LOCKED. The join key does not: by the owner ruling of 2026-09-29 the migration keys on the Amazon user ID, and the email join is an optional admin path, no longer LOCKED (§2). Feature-system bring-back (heroes, invasions, research UI, etc.) lives in legacy-feature-migration-audit.md — a different project. When code or product copy conflicts with this doc, this doc wins until an explicit owner decision amends it.

0. Purpose

Give veterans of the legacy Dark Citadel Alexa skill a one-time, opt-in path, keyed on their Amazon user ID, to bring one merged character into Dark Tower, with:

  1. Honest one-shot semantics (post-migrate Dark Citadel play does not transfer again).
  2. Progress scaling so uncapped legacy levels do not break Dark Tower's level-500 world.
  3. Permanent-season soft-currency placement so competitive / seasonal buckets stay fair when DT seasons exist (mirror Darkness Falls intent).
  4. Discord as the support surface for migration bugs and edge cases.
  5. Idempotent tracking so the same identity cannot double-grant.

This document locks the WHAT / WHY. Implementation follows later under /developer + build gates.


1. Non-negotiable product rules (LOCKED)

1.1 One-shot migration (confirm required)

Rule Detail
Confirm gate The player must hear the full warning and say an explicit confirm (e.g. "confirm migrate") before any write. A bare "migrate from dark citadel" only starts the explain + confirm flow.
One grant per source identity After status=completed on the migration ledger for a source Amazon user ID (or primary legacy character id), further migrate attempts refuse and name the existing DT character.
No re-export Anything earned on Dark Citadel after a successful migration is not transferred. Voice copy MUST say this before confirm.
Legacy game stays playable Migration does not delete or lock the old Dark Citadel character. It snapshots state at confirm time.

Confirm copy must include (voice-first, no parens/slashes):

  • Migration is one time.
  • Further Dark Citadel progress will not come over later.
  • All Dark Citadel characters for this account fold into one Dark Tower character (highest level primary, items merged from alts).
  • Level and power are scaled into Dark Tower's progression, not copied 1:1 when the old level is extreme.
  • Join Discord for migration problems or bug reports (see §1.2).

1.2 Discord for migration support (LOCKED)

Every migration surface (pre-confirm, success, and failure paths that are not pure "not linked") MUST encourage Discord for migration-related problems and bug reports.

Surface Requirement
Pre-confirm "If anything looks wrong after you migrate, join our Discord and report it. The invite is in the skill description, or say report bug in game."
Success Same Discord nudge once.
Partial / transform skip Name what was skipped + Discord nudge.
Support ops Migration ledger rows are the source of truth for agents investigating tickets.

Canonical community bot / Discord design: design/platforms/discord-bot.md and . Exact invite URL is not hard-baked into this design (it can change); runtime copy reads a single config/constant (e.g. skill description + optional spoken short form "say report bug, or join Discord from the skill card").

1.3 Progress scaling / balancing (LOCKED)

Facts

Game Level model Cap
Legacy Dark Citadel Triangular XP: cumulative XP grows roughly as level² × 1000 (, no hard level cap in code). Live max observed 3402. None
Dark Tower Exponential bands; loops while (level < 500). 500 hard cap

A literal transfer of level 2400 into DT is impossible and would be broken even if allowed. Scaling is mandatory, not optional polish.

Scaling constants (single source of truth)

Constant Value Meaning
DT_LEVEL_CAP 500 Dark Tower hard cap (code).
MIGRATION_PROGRESS_RATIO 0.80 Max progress a migrate may land at relative to the DT cap.
MIGRATION_LEVEL_CEILING 400 floor(DT_LEVEL_CAP × MIGRATION_PROGRESS_RATIO). No migrated character exceeds this.
LEGACY_REFERENCE_LEVEL 2500 Design reference for "full" legacy progression. A primary character at this level maps to the ceiling (400). Tunable later only with a design amendment + rebalance note.
LEGACY_LEVEL_FLOOR 1 Minimum primary level to migrate (level 0 / null treated as 1).

Level formula (LOCKED)

sourceLevel = max(LEGACY_LEVEL_FLOOR, primary.current_level)
progress = min(1.0, sourceLevel / LEGACY_REFERENCE_LEVEL)   // 0..1
dtLevel   = max(1, floor(progress * MIGRATION_LEVEL_CEILING))
// clamp hard:
dtLevel   = min(dtLevel, MIGRATION_LEVEL_CEILING)

Examples (illustrative):

Legacy primary level progress DT level after migrate
1 0.0004 1
50 0.02 8
100 0.04 16
250 0.10 40
500 0.20 80
1000 0.40 160
1250 0.50 200
2000 0.80 320
2400 0.96 384
2500+ 1.00 400 (ceiling)

XP after level (LOCKED)

Do not import raw legacy XP totals (different curve). After choosing dtLevel:

  1. Set character XP to the total required to reach dtLevel (mid-band of that level: 50% of the XP bar toward dtLevel+1), so the player is not stuck at "just leveled" or "one kill from next."
  2. Recompute max HP from DT formulas after class/gear hydrate.
  3. Paragon: do not copy legacy paragon fields from a 2000+ level character. Re-derive paragon points available from the scaled DT level using DT rules (ceil((level-100)/10) when level > 100, else 0). Leave allocation unspent (player assigns) unless we later add a conservative auto-split — default LOCKED = unspent.

Inventory / currency scaling (LOCKED v1 policy)

Asset Policy
Owned gear unlocks (union) Full union of alts; equip from primary only. Unknown enum ids skipped (logged).
Stackable consumables Sum counts across alts, then soft-cap each item type at min(sum, 99) for common consumables (potions, nets, speed-ups) unless a DT item already has a lower hard cap (use the lower).
Gold / wood / iron Sum across alts, write to permanent season currency (§1.4). Soft-cap gold at DT moneyCap if cap is live and > 0; else uncapped write (same as normal earn path).
Special / diamond-like Legacy special_currency maps to Special Tokens ledger (dark_tower_tokens), not purchase-only Diamonds. Cap: min(sum, 50_000) v1. Diamonds stay ISP-only (do not invent diamond balance from legacy).
Buildings max(level) per building id; store on buildingLevels. No power if system incomplete — still migrate data.
Heroes / missions / invasion ranks OUT OF SCOPE permanently (owner 2026-08-11) — not worth migrating; skip + disclose only.
Story card / chapter Best-effort map primary card; if unmapped, start DARK_CITADEL storyline at safe hub with gear intact.

Balancing principle: the veteran should feel recognized and ahead of a brand-new player, never instantly endgame-complete relative to DT's 500-cap and content pacing. The 80% ceiling is the guardrail; the reference level (2500) is the stretch of "who we treat as full progress."

1.4 Seasons — permanent bucket only (LOCKED)

Shared season enum

SeasonTypes (cross-game): EARLY_ACCESS, STANDARD_SEASON, CURRENT_SEASON.

Darkness Falls design authority for the model: design/games/darkness-falls/seasons.md —

  • CURRENT_SEASON = competitive race (fresh).
  • STANDARD_SEASON = permanent home after a season ends; characters keep everything.
  • EARLY_ACCESS = pre-launch / testing bucket.

Dark Tower today stores soft currency under SeasonTypes.EARLY_ACCESS ( / ). Competitive DT seasons are planned / not fully launched the way DF designs them — but migration MUST NOT be written in a way that dumps legacy wealth into a future competitive season.

Migration season placement (LOCKED)

When Soft currency (gold, wood, iron) target
Always The permanent season bucket — never CURRENT_SEASON.
While DT has no live competitive season Write to EARLY_ACCESS (current live bucket) and stamp migration metadata seasonIntent=STANDARD_SEASON so a future EARLY_ACCESS→STANDARD launch treats migrated players as permanent-tier.
Once DT opens CURRENT_SEASON Write migrated soft currency only to STANDARD_SEASON. Never credit CURRENT_SEASON balances from migration.
Asset Season rule
Soft currency on Permanent bucket per table above
Premium ledgers (tokens) Account-level ledgers (not season-scoped today) — still migration-tagged
Character blob No season field required on DT character today; if DT later adds characterSeason, migrated characters default to STANDARD_SEASON / permanent
Competitive leaderboards / seasonal titles Migrated power must not seed CURRENT_SEASON rankings

Rationale (same as DF): migration is not a competitive advantage for a new ranked season; it is permanent-mode recognition of prior play.


2. Identity and eligibility

2.1 The join key: the Amazon user ID

Owner ruling 2026-09-29: the migration is a cutover by Amazon user ID, not an email join. The user ID a returning player arrives with on the Dark Citadel skill (amzn1.ask.account.*, their Dark Tower customer_id) is the key that finds their Dark Citadel characters: the user.user_keys row with skill_id = 'DARK_CITADEL' and skill_customer_id equal to it, then that row's story_character rows (customer_id equal to it, deleted = 0). Legacy skill_id holds skill names, not Alexa skill ids, so the legacy side carries no e9224c7a filter. Nothing else matches: not the characters of the email that row names, and not a story_character with no Dark Citadel key.

The ruling rests on one premise, that a returning player arrives with the same user ID the legacy engine recorded. Alexa issues a user ID per skill (Dark Tower amzn1.ask.skill.1a44179f-..., The Dark Citadel amzn1.ask.skill.e9224c7a-...), so the premise holds for one route and not the other:

  • In-place (the endpoint switch). The Dark Citadel skill e9224c7a is repointed at this backend, and a veteran who says "open the dark citadel" arrives with the user ID for that skill, which is the ID the legacy engine recorded. The match is exact by construction, subject to the unknowns U1 to U3 of the transition plan. The owner's word on the entrypoint feature, "same Amazon IDs as Dark Citadel, entrypoint cutover assumes identity continuity", names this route. It needs the backend to accept skill e9224c7a (P2 there) and the owner's store action to switch the endpoint.
  • Cross-skill. A veteran who opens the Dark Tower skill 1a44179f arrives with a different user ID, which the legacy host never saw. The measurement bears this out: 1 of 1,090 prod Dark Tower user IDs is a legacy story_character.customer_id, and 1 a user_keys.skill_customer_id (on DARK_CITADEL), while legacy holds 69,021 DARK_CITADEL players with a live character. A Dark Tower skill arrival is therefore never matched by its user ID; the player is told to open the Dark Citadel skill.

The two cohorts, the switch and its prerequisites are owned by dark-citadel-to-dark-tower-transition.md (§ 2.1, § 3.1); this section only fixes the key. The code serves the in-place route only: the lookup runs for a request that arrived on skill e9224c7a, and for no other entry.

Email is an optional admin path, never the gate. An admin may still migrate by a known email (the browser cohort's legacy characters are keyed on email), and a linked email may help support find a player, but no player is refused for having no linked email.

2.2 Eligibility ladder

  1. A request on the Dark Citadel skill e9224c7a with an Amazon user ID (the DT customer_id). Any other entry is told to open the Dark Citadel skill and is not looked up.
  2. Not already completed on the migration ledger for that user ID.
  3. Legacy lookup finds ≥1 non-deleted character for that user ID: the user.user_keys row where skill_id = 'DARK_CITADEL' and skill_customer_id = <user ID>, then story_character rows with that customer_id and deleted = 0.
  4. Player confirms.

If step 3 fails → "no Dark Citadel characters for this Amazon account, join Discord for help."

The attach is idempotent: a second confirm, a retried batch claim or a re-run of the apply for the same user ID finds the existing ledger row and grants nothing again (§5, §13).

Admin goodwill migrate by a known email stays available as the optional admin path of §2.1.

2.3 Account / slot

  • Mint a new characterKey; append to .
  • Migration slot grant (LOCKED): a successful migrate always increments maxCharacterSlots by 1 if needed so the new character fits (do not charge diamonds for the migration character). Do not delete the player's existing DT characters.

2.4 How the built code keys on the Amazon user ID

The Dark Citadel skill e9224c7a is a second entry onto the Dark Tower game: application.yaml llitd.alexa.skills.dark-citadel serves it at /api/skills/dark-citadel on the Dark Tower delegate, and maps it to DARK_TOWER. Its traffic arrives there only after the owner repoints the skill's endpoint in the store.

The player path keys on the account id the request carries (MigrationSourceIdentity.amazonId): "migrate from dark citadel", "confirm migrate" and "migration status" ask for no linked email. "Migrate" and "confirm" run only when holds; otherwise the player hears DarkTowerMigrationCopy.OPEN_DARK_CITADEL and nothing is looked up. The legacy presence check () is §2.2 step 3 by that id, and it never looks up an email. The ledger keeps its one-grant rule: source_email_norm holds the source key, a normalized email for the admin path or amzn: plus the SHA-256 of the account id for a player, so the UNIQUE active_email_key is still the single rule for both kinds and a second confirm finds the open or completed row. The clear id is the row's dest_customer_id, and summary_json.sourceKind names the kind. Enqueue by email stays as the admin path; it brings a forced enqueue or its own legacy count, because the legacy host is never searched by email. Where §5 and §12 to §15 say email, they describe that admin path.

The batch job is the orchestrator's dashboard job darkTowerCharacterMigration (dashboard/backend-node/src/scheduler/jobs/darkTowerCharacterMigration.logic.ts), and its LEGACY_CHARACTER_BY_DARK_CITADEL_ID_SQL is the definition of the legacy match it runs. It reads the legacy story_character rows of the user.user_keys row with skill_id = 'DARK_CITADEL' and skill_customer_id = dest_customer_id: the §2.2 step 3 match, by the Amazon id, with no email. It claims no email row: an email-keyed row answers admin_only and stays for an admin, so email remains the admin path alone. Where this file and that query differ, the query is right.

One legacy character is granted at most once, whichever key reached it. The apply stamps the granted legacy primary in source_primary_character_id; the stored generated column active_source_character_id holds it while the row is live (the same live condition as active_email_key, with interest_requested counted as live), and the UNIQUE key uq_cml_active_source_character refuses a second live row for that character, an email row and an id row included. The columns are catalogued in dark-citadel.md § character_migration_log.

The first-launch veteran welcome is still to build (transition F6).


3. Merge algorithm (LOCKED)

  1. Load all non-deleted legacy characters for the source identity (§2.2).
  2. Primary = max(current_level), ties → max(experience), then latest last_upd_tms.
  3. Name: {primary.user_name or "Hero"} DC{primary.character_id} (sanitize for voice; ensure unique among account keys by appending a short suffix if collision).
  4. Merge alts into primary per §1.3 asset table (union gear, sum stacks with caps, max buildings).
  5. Apply level/XP/paragon scaling from primary's pre-merge level only (alts do not raise level).
  6. Persist DT character + permanent-season currency + migration ledger completed row.
  7. Optionally set as currentCharacterKey only if the player had no prior DT character; if they already have a main, leave current as-is and tell them to "switch character" to find the migrated one.

4. Command UX (LOCKED sketch)

Step Player Response intent
A "migrate from dark citadel" Explain one-shot, merge, scaling, Discord; ask confirm if linked; else ask link.
B "confirm migrate" (only after A pending) Run migrate or refuse with reason.
C Success Name, scaled level, alt count merged, Discord nudge, switch character hint.
D Already migrated Name existing character; no second grant.

NLU: dedicated action (e.g. DARK_TOWER_MIGRATE_FROM_DARK_CITADEL) + confirm path; tier placement per command-processing rules (not Tier 1 content synonyms).

Voice: no parens, brackets, slashes, or abbreviations.


5. Persistence and idempotency (LOCKED)

5.1 Migration ledger table (new)

Suggested: dark_citadel.character_migration_log (schema name stays dark_citadel for DT player state).

Column Purpose
migration_id PK
source_email_norm Source key: a normalized email (admin path), or amzn: plus the SHA-256 of the Amazon account id (player path, §2.4). Unique while active
source_customer_ids_json All legacy customer ids folded
source_primary_character_id Primary legacy id
source_merged_character_ids_json All source char ids
source_primary_level Pre-scale level (audit)
dest_customer_id DT customer id
dest_character_key New key
dest_level Post-scale level
status pending / claimed / completed / failed / rolled_back
season_bucket Actual season enum written for soft currency
summary_json Counts skipped items, caps hit, etc.
created_at / completed_at Timestamps

Unique: generated active_email_key holds email when dest_character_key is set or status is pending/claimed/completed (UNIQUE index). Pure pre-grant failed (no dest key) frees re-queue.

5.2 Character blob marker

On the migrated :

legacyMigration: {
  primaryCharacterId,
  primaryLegacyLevel,
  scaledLevel,
  mergedCharacterIds,
  sourceEmailHash,   // not raw email in logs if avoidable
  migratedAt,
  seasonIntent: "STANDARD_SEASON"
}

6. Export surface (for implementers)

Read-only from legacy host at confirm time (preferred over stale snapshot):

  • story_character.story_character (+ alts for customer)
  • story_character.story_character_choices
  • story_character.story_character_gems_items
  • story_character.story_character_buildings
  • story_character.chapters_complete (optional)
  • story_character.hero_quick_points (optional / best-effort)
  • user.user_keys for email identity resolution (customer_id = email, skill_customer_id = Alexa/browser skill account id for skill_id='DARK_CITADEL' — never a non-existent user_id column)

Do not migrate guilds, friends, warbands, or chat history — permanently out of scope (owner 2026-08-11).

Backend needs a legacy DB read path (or pre-stage export job) — MCP target:'legacy' is for agents, not the live game process, unless we deliberately add a bridge.


7. Notification (when scheduled)

Channel Use
In-skill Command always available; optional launch tip for linked players once
Discord Support + optional #announcements when live
Email Only to linked real emails; one announcement; no spam
pendingMessages Optional one-shot for known linked customers

Never promise bit-perfect parity with every legacy meta-system.


8. Implementation phases (build order, not yet scheduled)

Phase Deliverable
P0 This design locked; owner sign-off on constants if any change
P1 Ledger table + pure scaling util + JUnit (table of legacy→DT levels)
P2 Legacy export reader + transform → (no player command)
P3 Command + confirm + Discord copy + scenario/JUnit
P4 Notify + pilot known email veterans + open

9. Open expansion (explicitly NOT blocking design lock)

These may be added later without breaking §1:

  • Migration codes for Alexa players without legacy email (moot under the Amazon user ID key, if the §2.1 premise holds).
  • Admin MCP tool for goodwill migrate.
  • Richer inventory balance (rarity gates, story-progress soft-start).
  • Full hero/army parity — rejected (owner 2026-08-11; not worth migrating).
  • Spoken Discord invite short-code if product wants it.

10. Code and data references

Side Location
Legacy character old-alexa-games , (uncapped level)
Legacy DB story_character.*, user.user_keys (email-as-customer for browser cohort)
DT character , , dark_citadel.character_dark_citadel
DT account , dark_citadel.account_dark_citadel
DT level cap — level 500
Currency (gold → seasonal; tokens/diamonds → ledgers)
Seasons enum SeasonTypes; DF seasons design for permanent vs current
Account link
Feature migration (systems) legacy-feature-migration-audit.md

11. Decision log

Date Decision
2026-08-10 Character migration design LOCKED: one-shot + confirm; Discord support nudge; scale to 80% of DT cap (400) with LEGACY_REFERENCE_LEVEL 2500; permanent-season currency only; free migration slot; email join; merge alts into highest-level primary.
2026-08-10 Supersedes informal "deferred indefinitely" product stance for design — build remains phased; design is ready to implement.
2026-08-10 Post-grant failure isolation: never markFailed after dest_character_key reserved; use recordPostGrantError and leave status=claimed so active_email_key cannot free a double grant.
2026-10-08 Join key amended (owner ruling 2026-09-29, via zeus): the migration keys on the Amazon user ID; the email join is an optional admin path and no longer LOCKED. Constants, scaling, compensation and season rules unchanged.
2026-10-08 Built to reading A (zeus): the Dark Citadel skill is a Dark Tower entry, and the player path keys on the arriving account id, held by the existing one-grant key as amzn: plus its SHA-256 (§2.4).
2026-08-11 Value-only migration LOCKED: heroes, friends, guilds, warbands, chat out of scope permanently. ISP stays separate (no Diamond minting). Migrate level/soft currency + special as tokens/gold/consumables.

12. Implementation architecture (async pipeline — LOCKED 2026-08-10)

Player path only enqueues. A dashboard batch job applies.

Player: "migrate from dark citadel" → explain + Discord
Player: "confirm migrate" → insert character_migration_log status=pending (no DT character yet)
Batch job darkTowerCharacterMigration (enabled:false by default):
  claim pending (conditional UPDATE) → fetch legacy story_character for the row's source (§2.4)
  → POST /api/dark-citadel/migration/{id}/apply with export JSON
  → Java transform + write DT character/account + mark completed
Component Location
Pure scale/merge/season llitd.utils.darktower.migration.*
Ledger mapper → dark_citadel.character_migration_log
Enqueue +
Apply +
Feature flag DARK_TOWER_CHARACTER_MIGRATION_ENABLED (default false)
Batch job darkTowerCharacterMigration in JOB_CONFIGS with enabled: false
JUnit , ,

Feature-off posture

  1. DarkTowerMigrationFeatureFlag.isEnabled() defaults false — player enqueue speaks "not available yet".
  2. JOB_CONFIGS darkTowerCharacterMigration.enabled = false — scheduler does not run the worker.
  3. Env DARK_TOWER_CHARACTER_MIGRATION_ENABLED must be true for the job body to process rows even if someone flips config.
  4. NLU: actions are code-resident in ; without NLU rows + flag, players never hit the path.

Enable checklist (when ready to pilot)

  1. Table dark_citadel.character_migration_log live on prod (created 2026-08-10).
  2. Deploy Java with migration packages.
  3. Set env DARK_TOWER_CHARACTER_MIGRATION_ENABLED=true on Java backend and dashboard.
  4. Flip JOB_CONFIGS darkTowerCharacterMigration.enabled to true (or one-shot admin apply).
  5. Add NLU one-shot synonyms for migrate / confirm migrate if not already present.
  6. Pilot known linked emails only; watch ledger for failed/empty/unreachable.
  7. Discord announcement + skill description invite.

13. Hardening / exploit audit (LOCKED)

Threat Mitigation Test / gate
Double-grant (same email twice) Enqueue refuses completed OR any row with dest_character_key set; apply refuses if email already completed; claim conditional UPDATE; UNIQUE active_email_key (holds when dest key set OR status pending/claimed/completed); reserveDestCharacterKey so claimed retries reuse one DCMIG_ key; tryMarkEconomyGranted one-shot economy Pipeline JUnit
Post-grant failure frees email lock After durable character/economy write starts, failures use recordPostGrantError and leave status=claimed; markFailed SQL refuses when dest_character_key is set (pre-grant only) PipelineTest
Race on claim UPDATE ... WHERE status='pending' returns 0 for loser claimPending unit path
Post-migrate legacy re-play One-shot ledger; no re-export; copy says so before confirm Design + handler copy
Season dump into CURRENT_SEASON DarkTowerMigrationSeasonPolicy never returns CURRENT; apply refuses forbidden season SeasonPolicyTest + TransformTest
Diamond minting from legacy special_currency → special tokens only; summary stamps diamondsMinted:0; no pack credit path TransformTest token cap
No source Player enqueue keys on the request's Amazon account id and needs no email; the admin email enqueue refuses NOT_LINKED without an @ Enqueue service,
Empty legacy match Apply marks empty_legacy_export failed; no character save PipelineTest
Partial transform / unknown gear Skip + list in summary/disclosures; Discord nudge on player surfaces TransformTest skipped gear
Level ceiling exploit floor(progress * 400) clamped to 400; 2500+ maps to 400 ScalingTest table
Feature on by accident Dual off: Java flag + job enabled:false FeatureFlagTest + config grep
Legacy unreachable Job marks failed with reason; does not invent success Node classifyLegacyFetch
Admin force path Apply force param admin-only; player path never force Admin controller PreAuthorize

Residual holes (accepted for v1)

  • Legacy columns for wood/iron/gear may be thin on first fetch — job ships level/gold/special; fuller inventory can expand export SQL later without changing scale/season rules.
  • Soft currency + special tokens are credited durably on apply via (permanent season bucket + dark_tower_tokens). Do not invent diamonds.
  • Alexa-only veterans without email still need migration codes (design §9 expansion).
  • NLU rows for "migrate from dark citadel" may be absent until enable step 5 — code path exists.

Agent review deltas (2026-08-10)

  • Batch job must use Ghost admin Authorization (not bare Bearer) and JAVA_BACKEND_URL / DARK_TOWER_MIGRATION_JAVA_BASE_URL at the live backend.
  • Job re-selects stale claimed rows older than 10 minutes; markFailed refuses when dest_character_key is set; legacy fetch joins user.user_keys.skill_customer_id.
  • Confirm-before-explain is not a hard session gate; async success/failure player notify is still enable-checklist work.

Rollback (ops/test — 2026-08-10)

status=rolled_back is first-class. Admin POST /api/dark-citadel/migration/{id}/rollback:

  1. Remove DCMIG key from account characterKeys
  2. deleteCharacterRow (Redis + DB)
  3. Reverse gold/wood/iron + dark_tower_tokens from summary_json amounts when present
  4. markRolledBack clears dest_character_key + status=rolled_back so active_email_key frees for a controlled re-migrate

Not a player self-serve path. Prefer test accounts.

14. Operator enable runbook (LOCKED ops package)

Goal: pilot one known email safely. Do not announce broadly until pilot is green.

Prerequisites (hard)

  1. Java code with migration packages deployed to the target backend.
  2. Table dark_citadel.character_migration_log present (prod + local).
  3. The legacy login reachable from the dashboard job host and from Java. Java reads it by exact name only: the password from the bootstrap env MYSQL_LEGACY_PASSWORD, the host and user from the llitd.creds rows MYSQL_LEGACY_HOST and MYSQL_LEGACY_USER (MYSQL_LEGACY_PORT optional, default 3306). It never falls back to the prod login. At application ready LegacyDarkCitadelPreflight reads the legacy database once; a set flag takes effect only after that read reached the host, and a refusal is logged with its reason and shown as legacyPreflight on GET /api/dark-citadel/migration/status.
  4. Dashboard can reach Java admin apply with Ghost admin Authorization (not bare Bearer).
  5. Env DARK_TOWER_MIGRATION_JAVA_BASE_URL or JAVA_BACKEND_URL points at the live backend, not localhost, on the job host.
  6. Dual-off defaults remain until you flip them deliberately:
    • Java DARK_TOWER_CHARACTER_MIGRATION_ENABLED default false
    • JOB_CONFIGS darkTowerCharacterMigration.enabled: false
  7. Competitive season authority: only DARK_TOWER_COMPETITIVE_SEASON_LIVE=true switches permanent soft currency to STANDARD_SEASON. Never CURRENT_SEASON.

NLU verification

sql SELECT story_action FROM game_process.process_action_registry WHERE story_action IN ( 'DARK_TOWER_MIGRATE_FROM_DARK_CITADEL', 'DARK_TOWER_CONFIRM_MIGRATE_FROM_DARK_CITADEL', 'DARK_TOWER_CANCEL_MIGRATE_FROM_DARK_CITADEL', 'DARK_TOWER_MIGRATION_STATUS' ); SELECT direct_match, action_key FROM game_process.process_one_shot_values WHERE action_key LIKE 'DARK_TOWER_%MIGRATE%' OR action_key = 'DARK_TOWER_MIGRATION_STATUS';

Expect registry rows + one-shots for migrate / confirm migrate / migration status.

Preflight SQL for a pilot email

sql SELECT sc.character_id, sc.user_name, sc.current_level, sc.money, sc.special_currency, sc.deleted FROM story_character.story_character sc WHERE sc.deleted = 0 AND ( sc.customer_id = 'email@example.com' OR sc.customer_id IN ( SELECT uk.skill_customer_id FROM user.user_keys uk WHERE uk.customer_id = 'email@example.com' AND uk.skill_id = 'DARK_CITADEL' ) ); SELECT * FROM dark_citadel.character_migration_log WHERE source_email_norm = 'email@example.com' ORDER BY migration_id DESC LIMIT 5;

Refuse pilot if zero non-deleted characters. Expected DT level = floor(min(1, level/2500) * 400).

Enable flip order

  1. Deploy Java.
  2. Set Ghost token for job: DARK_TOWER_MIGRATION_ADMIN_TOKEN or ADMIN_GHOST_TOKEN (raw token; job prefixes Ghost).
  3. Set Java URL env on dashboard.
  4. Set DARK_TOWER_CHARACTER_MIGRATION_ENABLED=true on Java backend and dashboard.
  5. Give Java the legacy login of prerequisite 3, then confirm legacyPreflight reads "reached legacy host" before relying on either flag.
  6. Flip darkTowerCharacterMigration.enabled to true, or use admin POST .../apply?force=true for one-shot.
  7. Pilot: linked player says migrate from dark citadel, confirm migrate; watch ledger → completed; player hears pendingMessages or says migration status.
  8. If bad: POST .../rollback?reason=pilot_rollback frees email lock.

Rollback

Admin POST /api/dark-citadel/migration/{id}/rollback deletes DCMIG character, reverses summary currency when present, sets rolled_back and clears dest_character_key so active_email_key frees.

15. Interest waitlist + purchase compensation (LOCKED 2026-08-10)

15.1 Interest / waitlist (HOLD auto-apply)

Players can register interest before the batch job is enabled. Confirm does not create a claimable pending row until full migrate is on.

Flag Env / property Effect
Interest DARK_TOWER_CHARACTER_MIGRATION_INTEREST_ENABLED=true Player can say migrate / confirm → ledger status=interest_requested. Batch job never claims these.
Full migrate DARK_TOWER_CHARACTER_MIGRATION_ENABLED=true Confirm creates pending (or promotes existing interest → pending). Job may apply when also enabled:true in JOB_CONFIGS.

Surface requestors (ops SQL):

SELECT migration_id, source_email_norm, dest_customer_id, created_at, summary_json
FROM dark_citadel.character_migration_log
WHERE status = 'interest_requested'
ORDER BY created_at DESC;

Admin: GET /api/dark-citadel/migration/interest?limit=50 and POST .../{id}/promote-interest (interest → pending for a held manual apply).

active_email_key includes interest_requested so one open interest per email.

15.2 Purchase compensation — tokens + gold + other (NEVER diamonds)

Legacy special_currency is mixed purchase/earn wealth. DT Diamonds (special_tokens ledger) stay ISP-only. Migration credits:

  1. Special Tokens (dark_tower_tokens) — progressive bands, hard cap 50_000
  2. Bonus gold — permanent season, floor 50 special, 25 gold/special, cap 2.5M
  3. Veteran consumable pack — at 1k / 5k / 20k special thresholds (healing, recharge, war-chest keys, boosts, welded net)

Formula version stamp: COMPENSATION_FORMULA_VERSION in plan + summary_json. Code: DarkTowerMigrationPurchaseCompensation.

Legacy special (sum) Approx tokens Gold bonus Pack
0–49 1:1 or 0 0 no
500 500 12_500 no
5_000 2_750 125_000 yes (base + mid)
50_000 ~12k progressive 1_250_000 full
huge cap 50_000 cap 2_500_000 full

diamondsMinted always 0.

15.3 Enable plan (interest first, apply held)

  1. Deploy Java + dashboard with interest path + compensation formula.
  2. Prod table: active_email_key includes interest_requested (done 2026-08-10).
  3. Set only DARK_TOWER_CHARACTER_MIGRATION_INTEREST_ENABLED=true (full flag stays false; job stays enabled:false).
  4. Announce interest in Discord / skill; players say migrate from dark citadel → confirm.
  5. Ops lists interest rows; review compensation previews offline if needed.
  6. When ready to grant: promote selected interest → pending, admin apply with force (or flip full flag + job for a pilot). Prefer direct/admin apply for first wave.
  7. After pilot green: full flag + job enable per §14.

15.4 Remaining migration gaps (not blocking interest)

Gap Status
Full gear library / enum map Thin export — still skip unknown
Wood/iron on legacy export SQL Job SELECT still thin (money + special mainly)
Heroes / guilds / friends / chat OUT OF SCOPE permanently (owner 2026-08-11) — not worth migrating
Legacy ISP / Diamonds Separate — never mint Diamonds; value only via tokens+gold+consumables
Async pendingMessages polish Partial
Migration codes for email-less Alexa vets §9 expansion
Competitive season live switch Env DARK_TOWER_COMPETITIVE_SEASON_LIVE only