Exploration
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Last updated: October 30, 2026
Dark Tower -- Exploration & Activities
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Subsystem: Free Explore zones, mines, training facilities, fishing, teleport, dungeons, crafting Primary sources: , ,
DarkTowerFreeExplorePoint,DarkTowerTeleportType,
Free Explore World
The Free Explore system provides an open-world layer on top of the card-based narrative. Cards with freeExplorePoint set belong to the world map. Each zone type provides different activities, resources, or strategic advantages.
Entering Free Explore
The Free Explore world is reached from the main menu (card 1) "free explore" option, which routes
to the world-map hub (card 50: world, space, ocean, guild hall, keep). Players can also open it
directly by voice from anywhere in the story with "free explore", "explore", "explore the world",
"world map", or "go exploring" — these route to DARK_TOWER_FREE_EXPLORE
(), which jumps to the hub card. The same entry is
surfaced as a "free explore" option under the EXPLORE section of the Dark-Tower-specific command
list (). The direct command is refused during combat.
Guarded by scenario_test 1858.
Zone Types (31 Defined)
Source: llitd.model.darktower.enums.character.DarkTowerFreeExplorePoint
God Prayer Points (8)
| Zone | Purpose |
|---|---|
| ELYSIUM_PRAYER_POINT | Pray to Elysium |
| MERRIS_PRAYER_POINT | Pray to Merris |
| DESTIN_PRAYER_POINT | Pray to Destin |
| TORRENT_PRAYER_POINT | Pray to Torrent |
| PERRIK_PRAYER_POINT | Pray to Perrik |
| APOLLO_PRAYER_POINT | Pray to Apollo |
| VENUS_PRAYER_POINT | Pray to Venus |
| POSEIDON_PRAYER_POINT | Pray to Poseidon |
Minimum level 25 required to pray. Each god has exactly one prayer point on the world map.
Invasion Assault Points (5)
| Zone | Reward Type |
|---|---|
| INVASION_ASSAULT_GOLD_POINT | 2x gold mine rewards |
| INVASION_ASSAULT_WOOD_POINT | 2x wood mine rewards |
| INVASION_ASSAULT_IRON_POINT | 2x iron mine rewards |
| INVASION_ASSAULT_TOKEN_POINT | 2x token mine rewards |
| INVASION_ASSAULT_ITEM_POINT | 2x item mine rewards |
Keep Lookup Points (8)
| Zone | Structure |
|---|---|
| KEEP_LOOKUP_POINT_STABLES | Stables building |
| KEEP_LOOKUP_POINT_ARMORY | Armory building |
| KEEP_LOOKUP_POINT_BAKEHOUSE | Bakehouse building |
| KEEP_LOOKUP_POINT_WALLS | Walls fortification |
| KEEP_LOOKUP_POINT_CHAPEL | Chapel building |
| KEEP_LOOKUP_POINT_MOAT | Moat fortification |
| KEEP_LOOKUP_POINT_BARRACKS | Barracks building |
| KEEP_LOOKUP_POINT_SILO | Silo storage |
NPC Purchase Points (1)
| Zone | Card | Keeper |
|---|---|---|
| NPC_ARISTOTLE_PURCHASE_POINT | 2763, the guild stock room (C-4) | Aristotle, the stock room's manager |
The point marks the card where a named keeper keeps their wares; the card's own options carry the
exits and the keeper's line. No handler reads it, and it gates nothing: the shop opens with buy
from any card. It is appended after NONE because the card cache writes this enum by ordinal.
A point a card names that the enum does not define loads as a null freeExplorePoint
(READ_UNKNOWN_ENUM_VALUES_AS_NULL), so the card plays as a plain card; guarded by
.
Mines and training facilities remain intended content. The Java handlers
(, ) and
DarkTowerFreeExplorePoint enum values are live. Do not delete the handlers
while this section still lists the points.
Training facilities are routed: the action DARK_TOWER_RECRUIT_UNITS dispatches
to , and "recruit" or "recruit
DARK_TOWER context. The handler reads the card's
freeExplorePoint, so a player recruits only on a card that carries one of the
five facility points; anywhere else it says they must be at a training facility.
What is still missing is the cards: a FREE_EXPLORE card per facility, carrying
its freeExplorePoint and a "Recruit" option whose freeTextDeclarationAction
is DARK_TOWER_RECRUIT_UNITS, with a neighbouring card's option leading to it.
That is a game-content import into dark_citadel_card.
Mines are not routed yet: no action, dispatch row or one-shot reaches , and no card carries a mine point.
Mines (4)
| Zone | Resource |
|---|---|
| GOLD_MINE | Gold currency |
| SPECIAL_TOKEN_MINE | Special tokens (rare) |
| RESOURCE_ONE_MINE | Wood (wooden logs) |
| RESOURCE_TWO_MINE | Iron (iron sheets) |
Training Facilities (5)
| Zone | Unit Type |
|---|---|
| SOLDIER_TRAINING_FACILITY | Soldiers |
| ARCHER_TRAINING_FACILITY | Archers |
| KNIGHT_TRAINING_FACILITY | Knights |
| ARTILLERY_TRAINING_FACILITY | Artillery |
| CANNON_TRAINING_FACILITY | Cannons |
Free-Explore Combat Rewards
Source: DarkTowerBasicFeatures.grantFreeExploreKillGold, ,
Roaming enemies on the open-world map are fought via free-explore cards (typeOfCard = FREE_EXPLORE). Roughly half of all enemies (146 of 297), including all six elementals, carry the ENEMY_REWARD death reward, which grants only experience and no gold — so defeating them left the player with nothing tangible (feedback 8742).
When an enemy dies on a FREE_EXPLORE card and its death reward grants no gold, combat now queues a supplemental gold drop:
base gold = 15 + max(1, enemyLevel) * 4
queued through the standard pending-reward pipeline, so it is announced and difficulty-boosted (EASY x2 .. HELLSCAPE x4), gold-find-buffed, and new-player-capped exactly like every other reward (see economy.md Free-Explore Combat Gold Supplement). Scoped to FREE_EXPLORE cards (story/dungeon balance untouched) and to no-gold death rewards (so ENEMY_GOLD_REWARD enemies are never double-paid). Scales by the enemy's level, not player level, to avoid a high-level trash-farming faucet, and stays below the gold-mine base (100). Guarded by scenario_test 2072 (3 grounded Slime Monsters on check-free free-explore card 3902; combat starts with no skill check, so the win is deterministic).
Mine Resource Collection
Source:
Mine Reward Formulas
| Mine | Formula |
|---|---|
| Gold | 100 + (level * 10) + random(0-49) |
| Special Token | 5 + (level / 5) + random(0-2) |
| Wood | 20 + (level * 2) + random(0-9) |
| Iron | 20 + (level * 2) + random(0-9) |
Reward Scaling Examples
| Level | Gold Range | Token Range | Wood/Iron Range |
|---|---|---|---|
| 1 | 110-159 | 5-7 | 22-31 |
| 10 | 200-249 | 7-9 | 40-49 |
| 50 | 600-649 | 15-17 | 120-129 |
| 100 | 1100-1149 | 25-27 | 220-229 |
| 500 | 5100-5149 | 105-107 | 1020-1029 |
Guard Condition
The handler checks isMinePointType() before allowing collection. If the current card is not a mine zone: "You must be at a mine to collect resources."
Training Facility Recruitment
Source: From index documentation (handler not directly read)
Base recruitment: 10 + (level * 2)
Multiplier by facility:
Soldier / Archer: 1.0x
Knight: 0.5x
Artillery / Cannon: 0.3x
| Level | Soldiers | Knights | Artillery |
|---|---|---|---|
| 1 | 12 | 6 | 3-4 |
| 50 | 110 | 55 | 33 |
| 100 | 210 | 105 | 63 |
Fishing System
Source:
Overview
Fishing is a multi-phase activity available at cards with collectibleToFishOrHunt set to a fish collectible type.
Go Fishing is always offered on such a card. offers it (or Go Hunting) on every turn at the card, beside paragon or anything else, and on the turn after a missed command as well. Its synonyms ("fish", "go fish", "fishing") exist only on that option, so a turn that left it out sent "go fishing" to another option, such as the card's own "done fishing", and the story moved on without a cast.
A net or reel option's key leads with its action. Picking a net carries WAIT-<net>-<bonus>-<fish>
and reeling carries REEL-.... handleFishing reads that first word before any other, because a fish
name can hold one of the words it looks for (CATFISH and SWORD_FISH hold FISH, which reads as start).
Resolution fix: fishings catch path had been broken because the collectible wrapper-service registry never resolved any collectible. The rule rows store
primaryEnumKeywith aCOLLECTIBLE_prefix (COLLECTIBLE_COD) while the enum constants are unprefixed (COD), so everyprimaryEnumKeydeserialized to null andgetCollectible(type)missed for every collectible — the fish/hunt sub-type checks then saw null and replied "cannot fish or hunt." A normalizing@JsonCreatoronDarkTowerCollectibleTypesnow strips the prefix so both forms resolve. This un-broke the net-selection flow (un-quarantined scenario_tests 963/964) and is guarded by 2431.
Phase Flow
1. startFishing() -- Player selects "fish" at a fishing card
- Blocked if in combat or other action
- Checks for fishing net in inventory
- If no net: offers to buy FLIMSY_NET from shop
- Player picks net: FLIMSY, FABRIC, WIRE, or WELDED_MESH
- Initial bonus starts at 5
2. keepWaiting() -- Tug phase (repeatable)
Roll 1d3:
1: Big tug -> bonus += rollDice(200) + 100 (range +101 to +300)
2: Bad tug -> bonus -= (rollDice(100) + 50) (range -51 to -150)
3: Neutral -> no change
Player chooses:
"Reel In" -> proceed to reelItIn()
"Keep Waiting" -> another tug phase
"Give Up" -> CONTINUE (stop fishing)
3. reelItIn() -- Catch resolution
If bonus > 900: Fish escapes ("waited too long"), net consumed
Otherwise:
catchRoll = rollDice(1000)
threshold = min(900, bonus + netBonus - rarityPenalty + fishingBonus)
If catchRoll <= threshold: CATCH TARGET FISH
Else: catch random GRAY rarity fish instead
Net Capture Bonus
netBonus in the threshold formula is DarkTowerItem.chanceCaptureBonus, an OPTIONAL rule field.
No ITEM rule row sets it -- all 44 items, including all four nets, omit the key -- so it resolves
to null and contributes 0. Consequence: the four nets are currently mechanically identical for
catch chance and differ only in shop price. Both consumers must therefore read an unset bonus as
zero: does so via .orElse(0L), and
now does the same -- unboxing it directly threw a on every reel-in and killed
the turn with the INTERNAL_ERROR response. Giving each net a distinct capture bonus is an open
content/balance decision, not a code gap.
Rarity Penalties
Source: switch statement
| Fish Rarity | Penalty |
|---|---|
| WHITE | -200 |
| RED | -200 |
| DANTRINE | -200 |
| INDIGO | -200 |
| BLACK | -300 |
| ORANGE | -100 |
| BLUE | -50 |
| All others | 0 |
Catch Amount
baseCatch = rollDice(3) // 1-3 fish
fishingYieldStacks = effectStacks(EFFECT_FISHING_YIELD_BOOST)
totalCatch = baseCatch + fishingYieldStacks
Fishing Bonus Calculation
Source:
fishingBonus = effectStacks(EFFECT_FISHING_YIELD_BOOST) * 50
Each stack of the fishing yield buff adds +50 to the catch threshold AND +1 to fish per catch.
Daily Fishing Bonus
First fish of the day: +100 special tokens (18-hour cooldown between bonuses).
Bonus Random Item
On catching a gray-rarity fish: 1 in 20 chance (5%) of finding a random item reward, throttled to once per 10 minutes.
Net Consumption
Nets are consumed on each catch attempt, regardless of success or failure. If the fish escapes (bonus > 900), the net is still consumed.
Hunting System
Source:
Hunting is the land-animal counterpart to fishing, available at a card whose collectibleToFishOrHunt
is a land-animal collectible (a quarry whose specificSubType is ANIMAL_MAMMAL_TYPES,
ANIMAL_BIRD_TYPES, ANIMAL_INSECT_LIZARD, or ANIMAL_MONSTER_TYPES). The same DARK_TOWER_FISHING
START entry (and the new hunt / go hunting / track NLU) drives it; startFishing inspects the
quarry sub-type and dispatches a fish target to the net loop and a land-animal target to startHunting.
Flow
Unlike fishings multi-tug net loop, hunting is a single stalk-and-take roll:
threshold = HUNT_BASE_CHANCE (450) + level * HUNT_LEVEL_SCALE (9) - rarityPenalty(quarry)
success if rollDice(1000) <= threshold
- Success: add 1-3 of the quarry collectible to inventory; the first hunt of the day grants +100 special tokens (18-hour cooldown), mirroring fishing.
- Miss: the animal catches the hunters scent and escapes (nothing gained, nothing consumed).
- Rarity penalty bands mirror fishing (WHITE/RED/DANTRINE/INDIGO -200, BLACK -300, ORANGE -100, BLUE -50).
A higher-level hunter reliably takes level-appropriate quarry while a novice has a real chance to be eluded. The wrapper surfaces the affordance as Go Hunting (vs Go Fishing) on a hunting card. Guarded by scenario_test 2429 (deterministic catch at seeded level 100).
Content note: the authored card corpus currently sets
collectibleToFishOrHuntonly to fish collectibles, so no card routes a real player to hunting yet. The hunting SYSTEM is complete and tested (via thesetFishHuntTargettest seeder); surfacing it to players is a content step — author hunting grounds (free-explore cards with a land-animalcollectibleToFishOrHunt).
Combat Capture System
Source:
Overview
During combat (level 3 or higher) the player can capture a living enemy that carries a captureTarget collectible, turning a fight into a creature for their collection. Capturable enemies are mostly the ANIMAL subtype. Capture follows a pick, then hold, then throw flow that mirrors the fishing tug loop: the longer you hold the net the better your odds, but the enemy strikes you each turn you wait.
Phase Flow
1. attemptCapture() -- Player says "capture" in combat
- Blocked if not in combat, below level 3, or no capturable enemy is present
- Lists each distinct capturable enemy as a target option
(key START-<ENEMY>-NONE-10, starting bonus 10)
2. openHoldStep() -- Player picks a target
- Surfaces the live capture odds for that target BEFORE any throw, including the
enemy-health band narration, so the chance is seen to respond to how wounded the
enemy is (feedback 8658)
- Offers "Hold the net" (HOLD-<ENEMY>-<NET>-<BONUS>) and "Throw now"
(THROW-<ENEMY>-<NET>-<BONUS>), plus a none/continue option
- No enemy strike on this first entry -- the player only just chose a target
3. keepHoldingNet() -- Player chooses "Hold the net" (repeatable)
- Charge rises by HOLD_CHARGE_PER_TURN (+100, about ten percentage points)
- The enemy takes one free strike (enemyAttacksAndStillAlive); if it drops the
player below zero hit points the last-stand flow takes over
- Otherwise re-presents the hold-or-throw menu with the higher carried bonus
4. processThrowNetKey() / executeNetThrow() -- Player chooses "Throw now"
- Resolves the capture at the carried bonus: rollDice(1000) <= captureChance
- Success: capture the collectible, grant the companion-unlock clue, consume the
net, remove the enemy; first capture of the day grants +100 special tokens
- Failure: the enemy shakes the net off
- Either way the enemy then attacks (processEnemyAttacksAfterThrow)
Capture Chance
Source: (measured in thousandths, shown to the player as a whole percent)
chance = enemy.captureChance
+ heldBonus // +100 per turn held; +200 more with the CAPTURE_MASTER power
+ net.chanceCaptureBonus // 0 for the bare-hands NONE net
+ timeoutBonuses // +50 if no net thrown in 15 min, +100 if none in 2 min
+ enemySubTypeModifier // see table
+ healthPercentModifier // see table -- the dominant term
chance = clamp(chance, 30, 970) // floor 30 = 3 percent, ceiling 970 = 97 percent
Enemy Sub-Type Modifier
| Sub-Type | Modifier |
|---|---|
| DRAGON | -500 |
| HUMANOID | -100 |
| UNDEAD | -70 |
| ANIMAL | +50 |
| All others | 0 |
Enemy-Health Modifier
The single biggest lever -- capture a wounded enemy, not a fresh one. Each band narrates a line so the player hears the odds respond to damage.
| Enemy Health % | Modifier | Narration |
|---|---|---|
| > 80% | -1400 | "drastically reduces your chances" |
| > 60% | -1000 | "greatly reduces your chances" |
| > 40% | -700 | "reduces your chances" |
| > 20% | -350 | "reduces your chances by a small amount" |
| <= 20% | +50 | "much easier to capture" |
At full health the -1400 penalty pins the chance to the 30 (3 percent) floor for most enemies, so holding alone cannot make a fresh enemy a likely capture -- the player is meant to weaken it first. Because the held bonus and the health band are both shown in the hold step preview, the announced chance visibly differs between a high-health and a low-health enemy (proven by the hermetic DARK_TOWER scenario test for the hold option plus JUnit for the high-vs-low difference).
Hold-step copy must match the floor behavior (UX). When the enemy-health band pins the chance to the 3 percent floor, the hold-step preview must NOT promise the percentage will rise from holding -- doing so contradicts the player's experience (holding while a fresh enemy attacks leaves the displayed percent at 3). presentHoldMenu() computes whether one more hold (adding HOLD_CHARGE_PER_TURN then re-clamping) would actually raise the displayed whole percent: if yes it offers "Hold the net to charge it higher"; if no (the floored full-health case) it tells the player the enemy is too healthy and to wound it in battle first -- and, for ANIMALS, that they can weaken it. This keeps the spoken guidance truthful against the intentional weaken-first formula without changing any math. Source: feedback 9061 / 9062 / 8961 / 8962 / 8963 ("the percentage never changes no matter what you do"); guarded by DARK_TOWER scenario test 2193 (sibling of 2070, which guards the unchanged menu shape and health-responsiveness narration).
Weaken Option
Source: -- "weaken" in combat. On a successful ANIMAL_HANDLING HARD check, halves the weakest capturable animal's current hit points. Only animals can be weakened, not monsters. Either way the enemy then attacks.
Net
The combat-capture flow uses a bare-hands net (NONE): no net item is consumed and no net bonus applies, so the "net" is narrative. Net-item selection was retired and endWithPickingNet is inert.
Teleport System
Source: DarkTowerTeleportType
Teleport locations are gated by clues. The baseTeleportMenu() method filters available locations by discovered clues. Players unlock fast travel by finding location clues during exploration.
Available teleport types are defined in the DarkTowerTeleportType enum and mapped to specific card IDs.
- Opening the menu. Saying "fast travel" or "teleport" (
DARK_TOWER_FAST_TRAVEL,DARK_TOWER_FAST_TRAVEL_BASIC) opens the menu, and so does the "fast travel" option on the main hub (DARK_TOWER_TELEPORT_MENU). Both reach . - Rows. Each location is a
dark_citadel.rule_dark_citadel_commonrow withrule_type = 'TELEPORT_LOCATION', a textrule_id(TELEPORT_1toTELEPORT_11) and the location JSON inrule_data. A location'sdarkTowerClueTypesmust name aDarkTowerClueTypesmember; a name that is not one is refused, never read as no requirement. Regression: , .
Dungeons
Three dungeon types are available from the world map:
| Dungeon | Card Count | Entry |
|---|---|---|
| Lich Lair | ~500 cards | From world map |
| Goblin Dungeon | ~400 cards | From world map |
| Graveyard | ~328 cards | From world map |
Entering a dungeon saves the world map position. Exiting returns to the saved position.
Dungeon rewards use forced NIGHTMARE difficulty for loot rolls (see ).
Activities Summary
| Activity | Key Mechanic | Handler |
|---|---|---|
| Fishing | Multi-phase timing game with nets | |
| Mining | Level-scaled resource collection | |
| Arena | Wave-based combat challenges, leaderboard | -- |
| Tower Defense | Defense-style, score-based ranking | Uses |
| Crafting | Recipe-based item creation from gold, wood, and iron | |
| Hunting | Single-roll stalk-and-take for land-animal quarry; level-scaled catch chance |
World Map Coordinates
Source: DarkTowerCard coordinate fields
Cards have 3D coordinates for world map positioning:
coordinatesX: Integer
coordinatesY: Integer
coordinatesZ: Integer
These are used for the visual world map (currently missing from frontend -- see index.md frontend gaps).
Bounty System
Source:
Map<DarkTowerEnemyTypes, KeyValueLongLong> activeBounties
Bounties track kill targets for specific enemy types. The KeyValueLongLong likely stores target count and current progress.
Bounty completion triggers COLLECT_BOUNTY_COMPLETION_REWARD.
Upgrade System
Source: DarkTowerUpgradeTypes
Upgrades are persistent improvements purchased with resources:
DarkTowerUpgradeTypes includes:
GUILD_TAVERN // +2% HP per level (used in calculateHitPoints)
// Other upgrade types defined in the enum
Upgrade levels are queried via .
Guild System
Source: DarkTowerUpgradeTypes (guild upgrades), DarkTowerGuildRank, ChatGuild* classes,
Overview
Guilds in Dark Tower are a cross-system feature spanning the chat system (social) and the upgrade system (gameplay bonuses). Guild management (creation, joining, inviting, leaving) is handled by the chat system, while guild buildings provide gameplay bonuses via the upgrade system.
Guild Ranks
Source: llitd.model.darktower.enums.character.DarkTowerGuildRank
| Rank | Role |
|---|---|
| LEADER | Guild founder/owner |
| MARSHAL | Senior officer |
| LIEUTENANT | Junior officer |
| MEMBER | Standard member |
Guild Operations (Chat System)
Source: llitd.engines.handlers.common.chat.guild.*
| Operation | Handler | Description |
|---|---|---|
| Create guild | Create a new guild | |
| Join guild | Request to join | |
| Leave guild | Leave current guild | |
| Invite to guild | Invite player | |
| Accept invite | Accept pending invite | |
| Approve join request | Approve/deny join requests | |
| Guild settings | Modify guild settings | |
| Member management | Promote/demote/remove members | |
| Guild wars | Guild vs guild competition |
Guild Buildings (Upgrade System)
Source: llitd.model.darktower.enums.character.DarkTowerUpgradeTypes (all DarkTowerResearchType.GUILD)
| Building | Upgrade Key | Effect |
|---|---|---|
| Guild Hall | GUILD_HALL |
Core guild building |
| Guild Inn | GUILD_INN |
Guild social space |
| Guild Headquarters | GUILD_HEADQUARTERS |
Guild command center |
| Guild Prison | GUILD_PRISON |
Guild justice system |
| Guild Warehouse | GUILD_WAREHOUSE |
Increases guild shared inventory capacity |
| Guild Tavern | GUILD_TAVERN |
+2% HP per level (confirmed in calculateHitPoints()) |
Guild Tavern HP Bonus (Verified)
Source: .java:106-110
final long guildTavernLevel = getUpgradeLevel(gameBasicProperties, DarkTowerUpgradeTypes.GUILD_TAVERN);
if (guildTavernLevel > 0) {
basePercent[0] += guildTavernLevel * 0.02; // +2% HP per level
}
| Tavern Level | HP Bonus |
|---|---|
| 1 | +2% |
| 5 | +10% |
| 10 | +20% |
| 20 | +40% |
Guild Reward Loot Tiers
Source:
| Reward Type | Forced Difficulty |
|---|---|
GUILD_REWARD_MINOR |
HARD |
GUILD_REWARD_MAJOR |
HARD |
GUILD_REWARD_EPIC |
NIGHTMARE |
GUILD_REWARD_PURCHASE |
HELLSCAPE |
Guild Wars System
Source: llitd.model.guildwars.*
The Guild Wars subsystem has dedicated models for seasons, contributions, and stats:
| Model | Purpose |
|---|---|
GuildWarsSeason |
Season tracking with status enum |
GuildWarsGuildStats |
Per-guild season performance |
GuildWarsPlayerContribution |
Individual player contributions |
GuildWarsContributionLog |
Contribution history |
GuildWarsActionType |
Types of guild war actions |
| Contribution calculation logic |
Wave Combat System
Source: DarkTowerStatus.WAVES, DarkTowerRewardTypes.WAVES, DarkTowerItemRarity.WHITE
Overview
Tower Waves is a standalone endurance mode reached from the waves menu (card 150) by saying "waves" or "start waves". It is a gauntlet of successive, escalating enemy waves: each wave is a small group that grows by one body per wave, the player does not heal between waves within a run, and the run ends when the player dies. The best wave reached is tracked as the rank and is kept as the player's BEST (never overwritten by a weaker later run). This is the "successive groups of enemies" + "score and ranking tracking" the design intends.
History (player_feedback 9065, owner decision 2026-06-20): an earlier rewrite collapsed the mode into a SINGLE dynamically-spawned mega-pack (8-to-10 zombies + 7-to-8 skeletons scaled by level), with no sequential waves, no rank readout, and a defeat that wrongly narrated "Victory!" plus a duplicated diamond upsell. Even a level-500 character could not clear the one giant fight, and the run had no payoff — the "I dont like the new version of waves" report. The owner directed: do NOT revert to the legacy authored wave-card chain (an old-game artifact); make the NEW mode compelling. It was rebuilt into the real escalating gauntlet described below.
How It Works (live mode)
| Concern | Behavior |
|---|---|
| Entry | heals the player to full (a fresh run starts fresh; the no-heal rule is WITHIN a run), surfaces the best-wave goal, then spawns wave one. Below the run opens with a spoken warning (lowLevelWarning): a level 1 character clears wave one and falls in one round of wave two. It warns rather than refuses, because how far a run gets is the score and the player may still choose it. |
| Wave composition + scaling | (pure, unit-tested): waveEnemyCount = WAVE_BASE_ENEMIES (3) + one per wave, capped at WAVE_MAX_ENEMIES (12); each normal wave draws a fresh 2-to-3 type mix from a depth-tiered pool (EARLY waves 1-5, MID 6-10, LATE 11-20, ELITE 21+, every entry verified-present in rule_dark_citadel_common so no enemy-not-found risk), rotated by wave so consecutive waves are not the same fight; every BOSS_EVERY (10th) wave is a single named BOSS on an escalating roster (Goblin King, Minotaur, ... Dragon, Tarrasque) escorted by up to BOSS_MAX_MINIONS (4) tier minions; waveStatDifficulty ramps with character level AND wave depth, accelerates past POST_RAMP_WAVE (10), and adds a boss premium so deep runs and bosses bite. |
| Wave advance on victory | detects an active run (guarded to non-card combat) and calls spawnTowerWave(waveNumber+1) in place — the next, larger wave begins immediately with NO heal. Narrated "Wave N cleared. The next wave presses in. Wave N+1." Each wave also grants its own victory XP/reward. |
| Rank tracking | Each cleared wave is recorded via into towerStats["TOWER_WAVES"] (highestWaveReached = best, totalWavesCompleted incremented). The rank is the BEST run. |
| Death / run end | continueFailureActions reads the run state BEFORE clearing it and speaks the rank readout ("You held the line through N waves. Your best run is wave M. Say start waves to try again."), then ends the run. The duplicate post-death diamond upsell is suppressed in waves (the drop prompt already shows one nudge). |
| Run-state safety | inTowerWaves / towerWaveNumber live on ; populateEnemies clears inTowerWaves at EVERY combat start (re-set by the two waves entry points), so a stale flag can never make a story or free-explore victory spawn a wave. |
| No Victory-on-loss | combatOutcomeResolved (set when an outcome resolves, reset in populateEnemies) guards so the drop-to-zero path's 30 percent heal can no longer make handleCombatEnd re-check HP and wrongly fire the Victory branch on a loss. This also hardens the general fb-927 fix. |
Regression shield: (pure JUnit) pins the wave-size, composition, stat
ramp, and best-wave rank math — the live fight is RNG-driven so a value assertion on a real fight would
be brittle (.claude/rules/hermetic-scenario-tests.md). game_test.scenario_tests 2294 (DARK_TOWER)
asserts the deterministic entry: saying "waves" announces "Wave 1" and renders combat options (a small
first wave, not the old mega-pack). Source feedback 9065.
Variety, bosses, and post-wave-ten escalation (2026-06-28, player Lauri): the original rebuilt mode fielded only zombies and skeletons (plus orcs from wave four) with no bosses and a single linear stat ramp — player Lauri (lauri2023@gmx.de, 2026-06-27) reported it as "the same fight ... four orcs and four skeletons ... boring after wave 10". waveEnemies now draws a fresh two-to-three type mix from a depth-tiered pool of verified-present enemies, every tenth wave is a named boss with an escalating roster, and waveStatDifficulty accelerates past wave ten and adds a boss premium. was extended to pin the variety, the boss cadence + escalation, and the steeper post-ten ramp. A boss/variety check cannot be a single-command scenario_test (reaching wave ten requires winning nine RNG fights), so JUnit stays the regression surface per the hermetic-test rule; scenario_test 2294 still guards the deterministic wave-one entry. Still deferred to a fast-follow: a per-milestone reward ladder (an item every tenth wave).
Legacy wave-cards (NOT the live mode): the authored WAVES_CARD chain (cards 600-899, ORC →
ACOLYTES → BEAR → ...) is the predecessor's hand-authored gauntlet and is the old-game artifact the
owner declined to revert to. The live mode is the dynamic -driven loop above,
not these cards.
Pending content step (apply at deploy): the waves menu card 150 still reads "only your most recent
rank will count as your total value" — that contradicts the best-wave tracking this mode now does.
Update card 150's rules text (prod dark_citadel_card, then evict STORY_CARD_LIST_CACHE_V2) to describe
the BEST-run rank when the code deploys, so the menu copy matches behavior.
| Reward detail | Status |
|---|---|
| Reward type | DarkTowerRewardTypes.WAVES — forced NIGHTMARE difficulty loot |
| Exclusive rarity | DarkTowerItemRarity.WHITE — items only obtainable from waves |
| Wave stat tracking | towerStats in — best wave + completion count (key TOWER_WAVES) |
Invasion System
Source: DarkTowerStatus.INVASION_WON, DarkTowerStatus.INVASION_LOST, , DarkTowerFreeExplorePoint.INVASION_ASSAULT_*
Overview
Invasions are event-based content where players compete at invasion assault points on the world map. The system provides enhanced resource rewards (2x base mine rewards) at contested points.
PvP Keep Assault -- the migration target (player Lauri 2026-06-27; design locked 2026-06-28)
The implementation documented below is the ORPHANED VESTIGE. The old Dark Citadel had a real player-versus-player invasion: pick a live rival, travel to an assault point, and steal their gold through an attack-versus-defense contest. Player Lauri (lauri2023@gmx.de) asked for it back -- "steal gold from other players' ships or keeps ... improve the rates." This is the spec for the PvP-only migration into Dark Tower.
Locked design decisions (owner, 2026-06-28):
- Scope: PvP-only. Build the steal-from-other-players half. The old PvE half (AI armies raid YOUR keep for event points) is DEFERRED, not built now.
- "Ships" = a naval-skinned assault point. Ships never existed as a mechanic (Warship is a separate Battleship minigame). A ship target is a sea-themed flavor of the SAME keep-assault mechanic -- one system, two skins (keep / ship). No Warship tie-in.
- Steal rates: moderate retune. The old GOLD
protected%was 1.0 (100 percent), so the unprotected pool was always zero and every target -- rich or poor -- yielded only the flat floor, which felt pointless. Retune to the moderate band below.
Steal math (ZERO-SUM, pure + unit-tested in ): the steal is a TRANSFER, not a faucet -- the attacker gains EXACTLY what the victim loses, so an invasion can never mint new gold into the economy (owner-approved 2026-06-29, refining the earlier asymmetric-minting design).
EXPOSED_PCT = 40-- only 40 percent of the victim's gold is exposed to a steal; 60 percent is safe.ATTACKER_TAKE_PCT = 80-- a winning attacker takes 80 percent of the exposed pool, so the net steal isstolen = round(victimGold * 0.40 * 0.80) = round(victimGold * 0.32).FLAT_FLOOR = 15000gold -- a minimum steal, but ONLY from a victim who actually holds at least 15,000 gold (so a broke target yields almost nothing instead of paying a floor out of thin air); the steal is never more than the victim has.- The victim loses exactly
stolenand the attacker gains exactlystolen, sogold_mintedis always 0. A failed assault steals nothing. - Worked example: a 1,000,000-gold target -> attacker gains 320,000 AND victim loses 320,000; a 10,000-gold target -> 3,200 each way; a 0-gold target -> 0.
The assault contest: an attack-versus-defense roll (port the exploding-d20 from the old ). Keep the resolver PURE (takes the two scores / seeded rolls, returns the winner) so it is unit-testable like the wave math; the handler supplies the RNG.
Build stages — stages 1-6 SHIPPED 2026-06-29 (PvP gold-steal MVP):
- ✅ (pure: the ZERO-SUM steal math + the attack-versus-defense contest resolver + the 6-hour anti-spam cooldown) + JUnit .
- ✅ Target selection — the attacker's guild roster IS the rival pool (a social gate: no guild means
no targets). walks the guild members, resolves each
chat_refto the player's Alexa customer id (linked players viachat_skill_xref, unlinked viachat_account.email_uniq), keeps only loadable Dark Tower players, and the chosen target's customer id is stored on . (Chosen over a global "find any rival" query -- a guild gives a social reason to raid and a bounded, consenting target set.) - ✅ The assault flow — runs the contest (attacker level + hero-team
power vs the victim's, exploding-d20), CREDITS the attacker the exact stolen amount and DEBITS the
victim through (the live cache write-back path under the
per-customer currency lock, never raw SQL). The attacker credit is applied directly to the seasonal
stash, bypassing the double-money-weekend multiplier in
addMoneyso the transfer stays zero-sum. - ✅ Cooldown — stamps a 6-hour per-attacker cooldown (stamped
on BOTH a win and a loss);
isCooldownReady/minutesUntilReadygate and narrate it. - ✅ Log table
dark_citadel.invasion_attack_log(mirror ofstory_log.story_invasions_attack_log) + — one row per attempt (scores, won, gold_stolen, gold_minted=0). - ✅ NLU — the
DARK_TOWER_INVASIONaction routes to (key-dispatch: no key lists targets + cooldown status,TARGET_nselects a rival,ASSAULTresolves). Cold-wordsinvade/invasion/invasions/steal gold/raid a keep/invade a keep/raid a player(bareraidis left toDARK_TOWER_RAID_STATUS). Action + one_shots seeded prodenabled=0/ localenabled=1; the prod handler was flipped toenabled=1on 2026-07-04 (config-drift remediation, report_findings 1085 / ) once the invasion code was confirmed live on prod — prod invasion is now live. - ☐ Naval skin (DEFERRED) — a sea-themed "ship" flavor of the same keep-assault mechanic (one system, two skins). The mechanic is live; the skin is content/copy, a fast-follow.
Code: service/darktower/invasion/, engines/handlers/darktower/invasion/ , mappers/darktower/. The card-spawned
INVASION_ASSAULT_* world-map points are NOT used by the PvP flow (they stay the orphan reward vestige);
the invasion is reached by the invade voice command + the guild roster instead.
Why this is a careful, dedicated build (not a quick slice): stage 3 mutates a SECOND real player's persisted gold balance. Done wrong it corrupts a live player's economy, and it cannot be tested hermetically with a single player. Build it deliberately with the cross-player writeback path verified, not rushed.
What Exists
| Component | Status |
|---|---|
| Card status types | INVASION_WON / INVASION_LOST — post-event narrative cards |
| Assault points (5) | Gold, Wood, Iron, Token, Item points on world map |
| PvP invasion (live) | + — the SHIPPED PvP keep-assault gold-steal, reached by the invade command, targeting guild rivals (see the Build stages above). |
| Invasion point handler (orphan) | — captures a flat reward from a world-map assault point. STILL ORPHANED: zero call sites; PvE self-reward only, no PvP theft. The live PvP flow above replaced its intent; this vestige is unused. |
| Exclusive rarity | DarkTowerItemRarity.INDIGO — items only obtainable from invasions |
| Punishment type | INVADED_LOST — penalty for losing an invasion |
Invasion Assault Point Rewards
Source: reward formulas
| Point Type | Formula |
|---|---|
| Gold | 200 + (level * 20) + random(0-99) |
| Wood | 40 + (level * 4) + random(0-19) |
| Iron | 40 + (level * 4) + random(0-19) |
| Token | 10 + (level / 3) + random(0-4) |
| Item | Same as Token (awards special tokens) |
These are approximately 2x the standard mine rewards.
Keep Building System
Source: , DarkTowerFreeExplorePoint.KEEP_LOOKUP_POINT_*, DarkTowerUpgradeTypes
Overview
The keep is the player's stronghold containing 8 functional buildings. Each building is visited at a world map location (KEEP_LOOKUP_POINT_*) and provides scaled resource/unit rewards based on character level and building upgrade level.
Keep Buildings (8 Types)
Source:
| Building | Upgrade Key | Primary Reward | Building Bonus |
|---|---|---|---|
| Stables | STABLES |
Knights (cavalry) | +3 knights/level |
| Armory | ARMORY |
Iron + special tokens | +5 iron, +2 tokens/level |
| Bakehouse | BAKEHOUSE |
Gold + wood | +25 gold, +5 wood/level |
| Walls | WALLS |
Soldiers + archers | +4 defenders/level |
| Chapel | CHAPEL |
Special tokens + gold | +2 tokens, +25 gold/level |
| Moat | MOAT |
Artillery + cannons | +2 siege/level |
| Barracks | BARRACKS |
Mixed troops (soldiers/archers/knights) | +6 troops/level (multiplied by type) |
| Silo | SILO |
Wood + iron + gold | +8 resources/level |
Reward Formulas
All rewards follow this pattern:
reward = baseReward + (characterLevel * charScaler) + (buildingLevel * buildingBonus) + random(0 to variance)
Example — Barracks at level 50, building level 5:
Soldiers: (15 + 50*2 + 5*6 + random(0-7)) * 1.0 = ~145
Archers: (15 + 50*2 + 5*6 + random(0-7)) * 0.8 = ~116
Knights: (15 + 50*2 + 5*6 + random(0-7)) * 0.4 = ~58
Capture Points
Status: REMOVED 2026-05-12. Capture-point zone types and the isCaptureWorldPointType() utility were deleted from DarkTowerFreeExplorePoint and after the round-3 design decision (fb 718-721) to lock in the buy-only research-station model. Research stations are progression-gated purchases, not contested world-map zones — see progression.md § Research System.
Rest cooldown and city markets
A rest starts its cooldown only when it really rests the player; a refused rest costs nothing (). City card 8120 carries a market and a farm walk as free-explore cards 617200 to 617204; the other cities have none yet.