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:
- Honest one-shot semantics (post-migrate Dark Citadel play does not transfer again).
- Progress scaling so uncapped legacy levels do not break Dark Tower's level-500 world.
- Permanent-season soft-currency placement so competitive / seasonal buckets stay fair when DT seasons exist (mirror Darkness Falls intent).
- Discord as the support surface for migration bugs and edge cases.
- 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:
- Set character XP to the total required to reach
dtLevel(mid-band of that level: 50% of the XP bar towarddtLevel+1), so the player is not stuck at "just leveled" or "one kill from next." - Recompute max HP from DT formulas after class/gear hydrate.
- 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
e9224c7ais 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 skille9224c7a(P2 there) and the owner's store action to switch the endpoint. - Cross-skill. A veteran who opens the Dark Tower skill
1a44179farrives 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 legacystory_character.customer_id, and 1 auser_keys.skill_customer_id(onDARK_CITADEL), while legacy holds 69,021DARK_CITADELplayers 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
- A request on the Dark Citadel skill
e9224c7awith an Amazon user ID (the DTcustomer_id). Any other entry is told to open the Dark Citadel skill and is not looked up. - Not already
completedon the migration ledger for that user ID. - Legacy lookup finds ≥1 non-deleted character for that user ID: the
user.user_keysrow whereskill_id = 'DARK_CITADEL'andskill_customer_id = <user ID>, thenstory_characterrows with thatcustomer_idanddeleted = 0. - 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
maxCharacterSlotsby 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)
- Load all non-deleted legacy characters for the source identity (§2.2).
- Primary = max(
current_level), ties → max(experience), then latestlast_upd_tms. - 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). - Merge alts into primary per §1.3 asset table (union gear, sum stacks with caps, max buildings).
- Apply level/XP/paragon scaling from primary's pre-merge level only (alts do not raise level).
- Persist DT character + permanent-season currency + migration ledger completed row.
- Optionally set as
currentCharacterKeyonly 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_choicesstory_character.story_character_gems_itemsstory_character.story_character_buildingsstory_character.chapters_complete(optional)story_character.hero_quick_points(optional / best-effort)user.user_keysfor email identity resolution (customer_id= email,skill_customer_id= Alexa/browser skill account id forskill_id='DARK_CITADEL'— never a non-existentuser_idcolumn)
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 |
| 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
DarkTowerMigrationFeatureFlag.isEnabled()defaults false — player enqueue speaks "not available yet".- JOB_CONFIGS
darkTowerCharacterMigration.enabled = false— scheduler does not run the worker. - Env
DARK_TOWER_CHARACTER_MIGRATION_ENABLEDmust betruefor the job body to process rows even if someone flips config. - NLU: actions are code-resident in ; without NLU rows + flag, players never hit the path.
Enable checklist (when ready to pilot)
- Table
dark_citadel.character_migration_loglive on prod (created 2026-08-10). - Deploy Java with migration packages.
- Set env
DARK_TOWER_CHARACTER_MIGRATION_ENABLED=trueon Java backend and dashboard. - Flip
JOB_CONFIGSdarkTowerCharacterMigration.enabledto true (or one-shot admin apply). - Add NLU one-shot synonyms for migrate / confirm migrate if not already present.
- Pilot known linked emails only; watch ledger for failed/empty/unreachable.
- 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:
- Remove DCMIG key from account characterKeys
- deleteCharacterRow (Redis + DB)
- Reverse gold/wood/iron + dark_tower_tokens from summary_json amounts when present
- 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)
- Java code with migration packages deployed to the target backend.
- Table
dark_citadel.character_migration_logpresent (prod + local). - 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 thellitd.credsrowsMYSQL_LEGACY_HOSTandMYSQL_LEGACY_USER(MYSQL_LEGACY_PORToptional, default 3306). It never falls back to the prod login. At application readyLegacyDarkCitadelPreflightreads 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 aslegacyPreflightonGET /api/dark-citadel/migration/status. - Dashboard can reach Java admin apply with Ghost admin Authorization (not bare Bearer).
- Env
DARK_TOWER_MIGRATION_JAVA_BASE_URLorJAVA_BACKEND_URLpoints at the live backend, not localhost, on the job host. - Dual-off defaults remain until you flip them deliberately:
- Java
DARK_TOWER_CHARACTER_MIGRATION_ENABLEDdefault false - JOB_CONFIGS
darkTowerCharacterMigration.enabled: false
- Java
- Competitive season authority: only
DARK_TOWER_COMPETITIVE_SEASON_LIVE=trueswitches 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
- Deploy Java.
- Set Ghost token for job:
DARK_TOWER_MIGRATION_ADMIN_TOKENorADMIN_GHOST_TOKEN(raw token; job prefixes Ghost). - Set Java URL env on dashboard.
- Set
DARK_TOWER_CHARACTER_MIGRATION_ENABLED=trueon Java backend and dashboard. - Give Java the legacy login of prerequisite 3, then confirm
legacyPreflightreads "reached legacy host" before relying on either flag. - Flip
darkTowerCharacterMigration.enabledto true, or use adminPOST .../apply?force=truefor one-shot. - Pilot: linked player says migrate from dark citadel, confirm migrate; watch ledger → completed; player hears pendingMessages or says migration status.
- If bad:
POST .../rollback?reason=pilot_rollbackfrees 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:
- Special Tokens (
dark_tower_tokens) — progressive bands, hard cap 50_000 - Bonus gold — permanent season, floor 50 special, 25 gold/special, cap 2.5M
- 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)
- Deploy Java + dashboard with interest path + compensation formula.
- Prod table:
active_email_keyincludesinterest_requested(done 2026-08-10). - Set only
DARK_TOWER_CHARACTER_MIGRATION_INTEREST_ENABLED=true(full flag stays false; job staysenabled:false). - Announce interest in Discord / skill; players say migrate from dark citadel → confirm.
- Ops lists interest rows; review compensation previews offline if needed.
- 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.
- 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 |