Skip to main content

Combat

Combat

Status: IN PROGRESS — Tracked open issues; see Known Issues section

Last updated: October 31, 2026

Dark Tower -- Combat System

Status: IN PROGRESS — Tracked open issues; see Known Issues section

Subsystem: Damage calculation, hit chance, critical hits, flee mechanics, difficulty scaling, combat effects Primary sources: , , DarkTowerDifficultyUtils, ,


Combat Flow Overview

Player selects option with combat overlay
  -> DarkTowerCombat.enemies loaded into character cache
  -> Environmental effects announced (handleEnteringCombatEnvironment)
  -> Combat loop:
     Player turn:
       "attack"                  -> BASELINE_COMBAT_ATTACK (standard attack)
       "defensive stance"         -> DARK_TOWER_SET_STANCE (DEFENSIVE) -> +10 AC boost, 4 rounds
       "dodge stance"             -> DARK_TOWER_SET_STANCE (DODGE)     -> +12 hit boost, 4 rounds
       "drunken stance"           -> DARK_TOWER_SET_STANCE (DRUNKEN)   -> +6 AC +6 dmg, 4 rounds
       "magical stance"           -> DARK_TOWER_SET_STANCE (MAGICAL)   -> +10 dmg boost, 4 rounds
       "psychic stance"           -> DARK_TOWER_SET_STANCE (PSYCHIC)   -> +7 AC +8 skill, 4 rounds
       "move for better footing"  ->           -> Athletics HARD -> IMPROVED_FOOTING 6 rounds
       "use [power]"              ->             -> 25 power special types; needs charges
       "flee"                     -> .fleeAction()
       "use [item]"               -> Item usage (max 3 per combat)
       (all stance/move attempts: enemy attacks this round regardless of outcome)
     Enemy turn:
       -> .enemyAttacksAndStillAlive()
     Round summary:
       -> combatRoundSummaryText() shows round #, HP, warnings
  -> Victory: navigate to nextCardIdOnSuccess (resolved in startCombat as combat.nextCardIdOnSuccess -> option.nextCardId -> MAIN_MENU_CARD_ID when both that and the failure target are null), grant successReward
  -> Defeat: navigate to nextCardOnFailure (same fallback chain; MAIN_MENU_CARD_ID when the option authors no continuation at all), apply lossPunishment
  -> clearCombat(): clears enemies, items used, positives, negatives, allies, power usage

Hit Points Formula

Source:

Base HP = 45 + (level * 4)

Percentage multipliers (multiplicative on base):
  Class bonus:
    FIGHTER, LEADER:         +40%
    PSION, HEALER:           +20%
    SCOUNDREL, CASTER:       +10%
    Other:                   +3%
  (Both primary and secondary class contribute)

  TOUGHNESS stat:            +35%
  EXTRA_HIT_POINTS power:    +35%
  Set bonus:                 (set HP bonus)%
  Companion HP boost:        (sum of companion healthBoostPercent)%
  VIP level:                 +3% per VIP level
  Guild Tavern upgrade:      +2% per level

After percentage: baseHP = round(baseHP * totalPercent)

Flat additions (after percentage):
  Enchantment HP bonus:      +flat amount
  Trinket health boost:      +flat amount (sum of all trinkets)

Hit Chance Formula

Source:

hitChance = bonusToHit - enemyArmorClass

bonusToHit = 50
  + calculateBonusByLevel(level)  // See level bonus table below
  + initialBonus                  // Per-attack modifier
  + weapon.bonusToHit
  + bonusFromEnemyToKill          // Currently returns 0
  + setHitBoostPercent
  + weaponGemHitBoost
  + raceHitBoost                  // Applied twice if level >= 100
  + companionHitChanceBoost
  + combatHitDamageBoost          // Accumulating +4 per round
  + playerPositive(HIT_BOOST)     // Active buff stacks
  + enchantmentHitChanceBonus

Stat bonuses:
  Ranged weapon + DEXTERITY stat: +5
  Melee weapon + STRENGTH stat:   +5
  ELVEN_ARCHERY (ranged) or DWARVEN_PROWESS (melee): +2

Environmental modifiers:
  BRIGHT:            +5
  GOOD_FOOTING:      +5
  HOT or FREEZING:   -5
  BAD_FOOTING:       -8 (unless IMPROVED_FOOTING buff active)
  LOW_VISIBILITY:    -5 (ranged only)
  HIGH_VISIBILITY:   +5 (ranged only)

Enemy modifiers:
  Enemy has INVISIBILITY: -50

Difficulty floors (minimum hit chance):
  EASY:      50%   <!-- DECISION 2026-03-01: Hit chance floor IS difficulty-based (confirmed). Higher difficulty = lower floor, easier = hit more often. Values here are correct. The capping method is in a separate utility but these are the canonical floors. -->
  HARD:      30%
  NIGHTMARE: 20%
  HELLSCAPE: 10%
  Default:   35%

Maximum: 93% (hard cap)

Level Bonus Calculation

Source: DarkTowerDifficultyUtils.calculateBonusByLevel()

if (level > 1000) return 40 + 40 + percentOf(2, level);  // 80 + 2% of level
if (level > 100)  return 40 + percentOf(4, level);        // 40 + 4% of level
return percentOf(40, level);                               // 40% of level
Level Bonus
1 0
10 4
25 10
50 20
100 40
200 48
500 60
1000 80

Damage Pipeline

Source: , ,

Step 1: Roll Damage Dice

Source:

damageRoll = rollDice(weapon.damageDiceRoll
             + weaponGemBoostDamageType
             + enchantmentDamageDice)

If weapon is null, defaults to rollDice(6).

Step 2: Add Damage Bonus

Source:

damageBonus = 8                           // Base flat bonus (combat balancing update)
  + calculateBonusByLevel(level)
  + weapon.bonusToDamage                  // Weapon intrinsic bonus
  + initialBoost                          // Per-attack modifier

Minimum: 1 (guaranteed at least 1 bonus)

Step 3: Critical Hit Check

Source:

critChance = 5 (base)
  + criticalRange                         // From power/class
  + weaponGemCritChanceBoost
  + fullArmorGemCritChanceBoost
  + sum(trinketGemCritChanceBoost)

critChance -= critReducerBump(difficulty) // Difficulty penalty

roll = rollDice(100)

isCritical = (roll <= critChance)
isHit = isCritical || (roll <= chanceToHit)  // Crits auto-hit

Step 4: Apply Critical Multiplier

Source:

If critical hit:

critMultiplier = 2.0 (base)
  + armorGemCritDamageMultiplier * 0.01
  + sum(trinketGemCritDamageMultiplier) * 0.01
  + criticalMultiplier * 0.1              // From class/power
  + companionCriticalBoost * 0.01
  + weaponGemCritDamageMultiplier * 0.1
  + weapon.bonusToCritDamage * 0.01
  + armorCritDamageBoost * 0.04

damage = ceil(damage * critMultiplier)

Step 5: Apply Damage Percentage Multiplier

Source:

damagePercent = weaponDamageMultiplier  // See weakness/resistance below
  + armorDamageChanceBoost * 0.01
  + setDamageBoostPercent * 0.01
  + damageMultiplier * 0.2              // From class/power
  + companionDamageBoostMultiplier * 0.01
  + raceDamageBoost * 0.01
  + enchantmentDamageBoost * 0.01
  + playerPositive(DAMAGE_BOOST) * 0.01
  + VIP level * 0.01
  + warbandRaidDamageBonus

Weapon class modifier applied multiplicatively at end.

Environmental:
  GOOD_FOOTING:     +10%
  HOT or FREEZING:  -25%
  BAD_FOOTING:      -25%

Power bonuses:
  ORCISH_RAGE:      +45%
  Stat match (STRENGTH for melee / AGILITY for ranged): +30%
  WEAPON_MASTER (melee) or MASTER_ARCHER (ranged):      +30%
  DWARVEN_PROWESS (melee) or ELVEN_ARCHERY (ranged):    +40%
  DEMON_HUNTER vs DEMON enemy:    +25%
  UNDEAD_HUNTER vs UNDEAD enemy:   +25%
  HUMANOID_HUNTER vs HUMANOID:     +25%

Enemy knocked down:  +25%

Minimum: 10% of base damage (Math.max(damagePercent, 0.1))

Step 6: Final Damage

finalDamage = round((damageRoll + damageBonus) * damagePercent)
if (finalDamage <= 0) finalDamage = 1  // Guaranteed minimum 1 damage

Weapon Type Multiplier

Source:

If weapon.damageType matches enemy.damageWeakness:
  return 2.0  (double damage, "enemy is weak against X damage")

If weapon.damageType does NOT match enemy.damageWeakness:
  return 0.5  (half damage, "enemy is resistant to X damage")

If enemy has no weakness defined:
  return 1.0  (normal damage)

Weakness/resistance messages are throttled to once per 15 minutes per type.

Damage Types

Source: DarkTowerDamageTypes

SLASHING, PIERCING, BLUDGEONING, ACID, FIRE, ICE, POISON, NECROTIC, EXPLOSIVE, CHAOTIC, LIGHTNING, HOLY, ARCANE, NONE

Flee Mechanics

Source:

1. Check if in combat (if not, "nothing to flee from")
2. Check ALL enemies have escapeChance > 0
   - If ANY enemy has escapeChance <= 0: "trapped, cannot flee"
3. Roll 1d100
4. For each enemy: escapeThreshold = enemyEscapeChance + (has ESCAPE_COMBAT power ? 25 : 0)
5. ALL enemies must allow escape: roll <= each enemy's threshold (AND logic)
6. Success:
   - Clear enemy list
   - Clear pending rewards
   - Navigate to failure card (flee counts as loss, not victory)
7. Failure:
   - "You failed to escape"
   - Enemy gets a free attack round (enemyAttacksAndStillAlive)
   - If player survives, combat continues

Combat Item Usage

Source:

maxHealingItemsPerCombat() = 3  // Baseline limit

Previously returned 0 (from character property, always 0 for new chars). Fixed to return 3 so new characters can use starting health potions. countOfItemsUsedInCombat is reset to 0 when clearCombat() is called.

Stance System

Source:

Stances are set via DARK_TOWER_SET_STANCE with a matchedKey dispatch. All stances require a skill check; the enemy attacks this round regardless of outcome.

Stance Input Skill Check Buff (4 rounds)
DEFENSIVE "defensive stance" Athletics EASY +10 ARMOR_BOOST, +8 STANCE_ARMOR_BOOST
DODGE "dodge stance" Reaction EASY +12 HIT_BOOST, +5 STANCE_ARMOR_BOOST
DRUNKEN "drunken stance" Luck EASY +6 ARMOR_BOOST, +6 DAMAGE_BOOST, +1 STANCE_ARMOR_BOOST
MAGICAL "magical stance" Arcana EASY +10 DAMAGE_BOOST, +5 STANCE_ARMOR_BOOST
PSYCHIC "psychic stance" Psionics EASY +7 ARMOR_BOOST, +8 SKILL_BOOST, +5 STANCE_ARMOR_BOOST
BETTER_FOOTING "better footing" (flavour only) No mechanical buff

Design note: Stances are short-duration buffs with 4-round windows, designed for mid-fight pivoting rather than one-time opener bonuses. The enemy always attacks after a stance attempt so there's real cost to taking a non-attacking turn.

Implementation contract: addPlayerPositive(effect, rounds, amount) takes rounds FIRST. Call sites must pass (4, designedAmount) — never (designedAmount, 4). Swapping those args made roundsLeft(12) expire no earlier than ~7 rounds (fb 11220). Bare use power must open the power list and must not fuzzy-match a recharge elixir via the token power (fb 11201).

Move for Better Footing

Source:

moveForBetterFooting() requires an Athletics HARD check. Success grants IMPROVED_FOOTING for 6 rounds, which negates the BAD_FOOTING hit penalty. Failure returns flavour text; enemy attacks this round regardless.

Detail
Check Athletics HARD
Buff IMPROVED_FOOTING — 6 rounds
Effect Negates BAD_FOOTING hit penalty (-14 to hit)
Enemy Attacks this round (success or fail)

Design note: Useful specifically when the player is suffering BAD_FOOTING debuff; otherwise no benefit. The HARD difficulty means this is a meaningful trade-off rather than free healing.

Power Combat

Source:

attackWithPower() validates: HP > 0, not silenced, has power equipped, has charges remaining. An initiative roll determines whether the player or enemy goes first. Dispatches on DarkTowerPowerSpecial enum:

Category Power Specials
Timing modifiers UP_LAST, UP_FIRST, UP_ONLY
Direct damage DAMAGE_PERCENT
Crowd control DISARM, KNOCK_BACK, KNOCK_DOWN, WEAKEN, DAZE
AOE damage ACID_AOE, FROST_AOE, SILENCE_AOE, BURNING_AOE, POISON_AOE, SEED_AOE
Utility HEAL_ON_SWING, RAISE_DEAD, SUMMON_WOLF, SUMMON_DRAGON
Transformation SWAP_ALL, INVISIBLE_BOOST, FLYING_BOOST
Mind control CHARM_MONSTERS, DOMINATE_MONSTER (dominated converts become allies and attack with a hit-chance floor so low-hit enemies like ghosts still act; misses are spoken)

Design note: Powers with UP_FIRST/UP_LAST/UP_ONLY specials override the initiative system. AOE powers hit all enemies simultaneously. Powers have limited charges (reset via POWER_RECOVERY_REWARD or PERFECT_RECOVERY_REWARD).

What a power says about its targets is spoken once per kind of enemy and outcome: four Bandits taking the same damage is "You dealt 3 to each of the four Bandits.", not four sentences. Every per-target line goes through DarkTowerTargetLines.say, and attemptAction holds the lines while the power runs (DarkTowerTargetLines.spokenOncePerKind). A line that says why the player cannot strike (disarmed, dazed) is spoken once a turn however many strikes the player has.


Difficulty Scaling

Source: DarkTowerDifficultyUtils static initializer

Difficulty HP Bump Skill Check Bump Armor Bump Crit Reduce Damage Bump Money Boost Wave Count
EASY +0% +0 +0 +0 +0% 100% 1
HARD +25% +10 +15 +10 +20% 150% 2
NIGHTMARE +50% +25 +30 +25 +40% 200% 3
HELLSCAPE +100% +50 +50 +50 +75% 300% 4

Note: difficultyHitBump() applies hitPointBump * 1.5 (so HARD = 37.5, NIGHTMARE = 75, HELLSCAPE = 150).

Difficulty Reinforcements (extra enemy bodies)

Beyond the per-enemy stat bumps above, a higher difficulty may inject ADDITIONAL enemy bodies into an encounter via (called from when the base roster is under DEFAULT_ENEMY_LIST_SIZE). Each added body is a buffed clone of the weakest enemy, gated by the difficulty id (>3, >5, >7, >9, >11) and a 50 percent roll. This is distinct from the Wave Count column (which sequences multiple waves).

All combat counts read the same authoritative currentEnemies list: the combat-entry line (getEnemiesListString), the round-start "N enemies still standing" line, and the enemy-HP readout all report the true post-reinforcement total, so they are mutually consistent. The miscount players reported ("it says two but I fight four") was the gap between the card NARRATIVE, authored for the base count, and the difficulty-scaled live roster. When reinforcements are added, populateEnemies now speaks a short line, "The rising difficulty has bolstered the enemy ranks.", so the larger count is a deliberate, announced escalation rather than a surprise. No number is spoken in that line, so it can never disagree with the authoritative entering-combat total. Regression guard: (pure JUnit, because the trigger is RNG and difficulty gated, so a scenario test would be brittle).

Check Difficulty Stacking

Source: DarkTowerDifficultyUtils.getCheckDifficulty()

totalDifficulty = optionDifficultyBump + playerDifficultyBump + checkDifficultyBump

Both the card's check difficulty AND the player's chosen difficulty contribute. A HARD check for a player on NIGHTMARE difficulty gets both bumps stacked.

Skill Check System

Source:

39 Skill Types

Source: DarkTowerSkillTypes

ANIMAL_HANDLING, ARCANA, ATHLETICS, BLUFF, CLIMB, COMMON_LORE, COMPUTERS, DIPLOMACY, DISCERN_CIPHER, ENDURANCE, HISTORY, INITIATIVE, INSIGHT, INTERROGATE, INTIMIDATE, INVESTIGATE, LEADERSHIP, LINGUISTICS, LUCK, MEDICINE, OCCULT_LORE, PERCEPTION, PERFORM, PILOTING, PSIONICS, REACTION, RELIGION, SCHOLASTIC_LORE, SLEIGHT_OF_HAND, STEALTH, STREETWISE, SURVIVAL, SWIM, WARFARE, CHEMISTRY, DEMOLITION + meta: ANY, ALL, NONE

Skill Check Formula

Source:

Base bonus = (level > 50 ? 30 : 85)
  + 8
  + calculateBonusByLevel(level)
  + raceSkillBoostAll * levelBonus
  + (has skill ? +16 : 0) + (skill stat matches ? +16 : 0)  // Total +32 if both
  + enchantmentSkillBonus
  + skillStatPowerBonus * 12
  + playerPositive(SKILL_BOOST) * 3

Environmental:
  GOOD_FOOTING:     +7
  BAD_FOOTING:      -14
  HOT or FREEZING:  -14

BARDIC_KNOWLEDGE power: +13 base + LUCK_POWER weapon bonus * 4

Companion skill boost (from companionSkillBoostMultiplier)
Companion roll (from rollCompanionSkill)

Environmental positive/negative for specific skill: +10 / -20

Armor penalty (athletic skills only):
  HEAVY armor:  -32
  MEDIUM armor: -16
  LIGHT armor:  -8
  CLOTH armor:  0

Success Determination

Source:

checkSuccessChance = bonus - threshold

Floors by difficulty:
  EASY or HARD:   min 35%
  NIGHTMARE:      min 20%
  HELLSCAPE:      min 5%

BARDIC_KNOWLEDGE power: +10 to minimum floor

Maximum: 95% (hard cap)

Roll 1d100: success if roll <= checkSuccessChance

FORTUNATE_STREAK power: On failure (except INITIATIVE checks),
  re-roll once and spend power charge

Player-Facing Skill Check Message (Voice + Chat Format)

Source:

After every check resolves, a short sentence is appended to the card text that names the skill, the outcome, and the difficulty band. Players never hear raw roll/DC numbers — the design intent is "passive checks": the mechanic is real but the math stays under the hood.

Format on success:
  "Your <skill> check succeeds against the <easy|hard> challenge."
  "Your <skill> check succeeds despite the extreme difficulty."          (NIGHTMARE)
  "Your <skill> check succeeds against near-impossible odds."            (HELLSCAPE)

Format on failure:
  "Your <skill> check fails against the <easy|hard> challenge."
  "Your <skill> check fails against the extreme challenge."              (NIGHTMARE)
  "Your <skill> check fails against the near-impossible task."           (HELLSCAPE)

Near-miss tail (failure only, when roll - checkSuccessChance is in 1..15):
  " You came close, missing by only a hair."

Fortunate Streak tails:
  Success after re-roll: " You used your fortunate streak power for a second chance."
  Failure after re-roll: " Even with your fortunate streak power, you could not succeed."

Why difficulty is announced for every band (not only NIGHTMARE/HELLSCAPE): Feedback 682, 700, 701 (player AMAXNSD4, 2026-04-21) read as "checks arent working / restore the checks". Investigation under confirmed the announcement was reaching addText already, but EASY/HARD failures collapsed to "Your stealth check fails." with no difficulty anchor — players could not tell whether a missed check was a coin-flip on HARD or a near-impossible reach on HELLSCAPE, which read as arbitrary punishment. Naming the difficulty band on every outcome is the "hidden-bonus feedback line" — it surfaces what the engine knows about the check without exposing raw mechanics.

Why the near-miss tail: The combat-balance instrumentation in game_log.check_outcomes confirms that close failures (margin ≤ 15) are common at low levels with under-tier gear. The near-miss line tells the player the system saw their attempt and they were close, rather than implying the failure was foregone. Wide failures (margin > 15) get no extra tail so the line has weight when it appears.

Verdict on Option A vs Option B (resolved 2026-05-01):

  • Trace via ide_find_references from confirmed printOutCheckDetails(...) writes the message to the same the caller passes through addText(...). The DC→DT rename (commit f3cbc2e66f) was a pure rename — no logic changes to skill-check output.
  • Players were not silenced. They were under-informed. Option B applies: keep passive checks, add the hidden-bonus feedback line above. Locked in .

Combat Status Effects

Positive Effects (Buffs)

Source: DarkTowerPositiveCombatEffects

Effect Impact
DAMAGE_BOOST +1% damage per stack
ARMOR_BOOST +flat AC per stack
SKILL_BOOST +3 skill bonus per stack
HIT_BOOST +flat hit chance per stack
IMPROVED_FOOTING Negates BAD_FOOTING hit penalty
STANCE_ARMOR_BOOST Defensive stance AC bonus
INVISIBILITY -50 hit chance to attacker (enemy version). Player version also adds INVISIBILITY_DODGE_BONUS (+15) to the dodge-or-evade roll against on-death explosions ()
DAMAGE_REFLECT Reflects damage back
FLYING Airborne state

Negative Effects (Debuffs)

Source: DarkTowerNegativeCombatEffects

Effect Impact
DAMAGE_OVER_TIME_* Poison, Fire, Acid, Frost, Lightning DoT
CANNOT_HEAL Prevents healing
SILENCE Blocks spellcasting
LOST_WEAPON Disarmed state
DAZED Stunned state
WEAKENED Reduced effectiveness
DISTANCE Ranged-only attacks (melee blocked)
KNOCKED_DOWN Prone, enemies deal +25% damage
BLIND Reduced hit chance
CHARMED Cannot attack charmer
DOMINATED Controlled by enemy
SEED_TIME_BOMB Delayed explosion
FROZEN_SOLID Fully incapacitated

Enemy cooldown indicators (prevent spam): ALREADY_HEALED, USED_SPECIAL_POWER, USED_SPECIAL_ATTACK

Limited Enemy Moves and Disabling Effects

Each enemy uses life drain, stun and disarm at most once every three turns each, and no two disabling moves land on the player in a row, from one enemy or several. Players read a fight where a stun followed a disarm followed a drain, turn after turn, as one they could not act in.

  • The limited moves. names them and the cooldown marker each is held under: life drain (ENEMY_HEALTH_DRAIN) under ALREADY_HEALED, stun (ENEMY_DAZE, or any special whose effect is DAZED) under USED_SPECIAL_POWER, disarm (ENEMY_DISARM, or any special whose effect is LOST_WEAPON) under USED_SPECIAL_ATTACK. A content row that names its own cooldownId keeps it; a row that names none takes the move's marker. Either way the marker is held for at least rounds whatever the row's cooldownDuration rolls. The marker is counted down once a round after the enemies act (), so three rounds keeps the move out of the next two enemy turns. Every other move keeps exactly the cooldown its row gives it.
  • Markers are shared by name. A content row on the same enemy whose cooldownId is one of those three markers shares the limited move's cooldown: using either puts both out of reach. That is the meaning the markers already had.
  • The disabling effects are : LOST_WEAPON, DAZED and KNOCKED_DOWN, each of which takes the player's attack away for its rounds. One lands only while the player carries none of them and is not in the recovery rounds after one ended (mayAfflictPlayer). When any of them ends, decrementPlayerEffects gives the player free rounds, marked RECOVERED_FROM_DAZE (the marker keeps its name because the cached enum cannot take a new constant without a planned gap). So after a stun, a disarm or a knockdown the player always gets a turn to act before the next one.
  • Knockdown is not a limited move and keeps its row's cooldown, but it counts as disabling.

Proof: drives the round as combat does (specials tried in turn and skipped on cooldown, enemy markers counted down, then the player's effects) and asserts the spacing of each limited move and that disabling moves never land in consecutive rounds. holds the daze half. A scenario row cannot seed an enemy's specials or the roll, so the scenario guard is [HARNESS_LIMITATION] naming the JUnit.

Combat Hit/Damage Boost Accumulator

Source:

Each combat round, combatHitDamageBoost increases by +4. This is added to hit chance, ensuring that prolonged fights become progressively easier for the player (anti-stalemate mechanic). Cleared when combat ends.

Early-Fight Hit Floor — Design Confirmation (DT-2026-0603-004, 2026-06-04)

A chapter-1 playtest flagged the first wolf fight (DARK_CITADEL card 30102, three Small Wolves) stalling 3+ rounds in mutual-miss "dead air" at level 1-2. Reviewed against this doc as the arbiter — no combat-logic change; the behavior is the intended floor working as designed:

  • A freshly created character has difficultyLevel == null, which resolves to EASY everywhere via DarkTowerDifficultyUtils.difficulty(). isDifficulty(gbp, EASY) routes through that same difficulty() call, so the null-difficulty new character correctly receives the 50% EASY floor in — the highest floor, not the 35% default branch. A new player's combat hit chance is therefore never below 50%.
  • The Small Wolf's enemyHitChance is 2 against a new character's AC ~10, so the wolves connect on roughly 1 in 14 swings. The player is never in real danger — the stall is low-stakes, not a death spiral.
  • The anti-stalemate accumulator (combatHitDamageBoost, +4 per missed round, added pre-floor inside calculateHitBonus) escalates the player's hit chance every missed round and reliably breaks the stall; the "you find your rhythm and gain combat momentum" line surfaces it to the player.

The dead-air rounds are bounded variance at the intended 50% EASY floor combined with near-zero enemy hit chance, not a sub-floor bug. Raising the EASY floor above 50% would be a deliberate rebalance of the documented easiest difficulty and is out of scope; the floor table (EASY 50, HARD 30, NIGHTMARE 20, HELLSCAPE 10, default 35) stands. (Update 2026-06-20, human_question 22583, : the PLAYER floor table here still stands; separately, the owner approved raising the ENEMY-side hit floor for the opening fight to remove the dead-air mutual-miss rounds without touching the player's 50% EASY floor — see Opening-Fight Enemy Hit Floor below.) The companion fix for the same finding is content-only: it telegraphs the scripted orc-capture ambush (card 30023) so the narrative loss reads as story rather than a difficulty bug — see the DARK_CITADEL chapter-1 storyline doc.

Opening-Fight Enemy Hit Floor (human_question 22583, 2026-06-20)

Owner decision (human_question 22583, Option B "Raise the opening-fight floor") supersedes the "out of scope" stance of the two confirmation notes above for the enemy-side floor only. The player's 50% EASY hit floor is explicitly kept; what changed is the enemy-side minimum hit chance during the opening fight.

The problem (revisited): the standard enemy hit floor in is (ENEMY_CRIT_CHANCE_BASE + enemyCritChance) * BASE_HIT_CHANCE_MULTIPLIER, which is just 5 * 2 = 10% for a basic Small Wolf with no extra crit. A new character's AC (~10) already exceeds the wolf's raw hit chance (2), so the wolf sits at that ~10% floor. Combined with the player's 50% floor, roughly 0.5 * 0.9 = 45% of opening-fight rounds were mutual misses where neither side landed, so the readout repeated "all enemies remain at full health" and the first fight felt like dead air.

The rule: during an opening fight — player level at or below (currently 3) — the enemy hit floor is raised to (currently 35%). At a 35% enemy floor plus the player's 50% floor the all-miss rate drops to 0.5 * 0.65 = 32.5%, and something lands most rounds (1 - 0.5 * 0.65 = 67.5%). The anti-stalemate accumulator still climbs the player's hit chance every missed round on top of this, so long all-miss stretches collapse quickly. Past level 3 the standard crit-derived floor applies again, so leveled combat is unchanged.

Why it does not trivialize combat: the floor raises how OFTEN a low-tier enemy connects, not how hard it hits. Per-hit damage is already clamped to MAX_REGULAR_ATTACK_DAMAGE_PERCENT_PER_HIT (35% of max HP) and Small-Wolf weapon dice are tiny, so the opening fight becomes eventful (the wolf chips a few HP) without becoming dangerous to a ~49-HP new character. The player's 50% EASY floor is the owner constraint and is untouched, so the player's own hit rate is exactly as before.

Where enforced: .enemyHitChanceFloor(enemyCritChance, playerLevel) is a pure helper called by where it sets each attack's floor. It returns max(standardFloor, OPENING_FIGHT_ENEMY_HIT_FLOOR) inside the opening-fight window and the plain standardFloor otherwise, so an enemy whose crit-derived floor is already above 35% is never lowered (a high-crit enemy keeps its higher floor).

Tuning: the window and floor are single named constants (OPENING_FIGHT_MAX_LEVEL, OPENING_FIGHT_ENEMY_HIT_FLOOR). Raising or lowering either is a deliberate combat-feel change; change only those. The change does not touch enemy data or the Kryo-serialized card cache, so no enum-ordinal / cache-flush concern applies.

Regression guard: (pure JUnit) pins the floor math — the live hit/miss roll is RNG so a value assertion on a live fight would be brittle (see .claude/rules/hermetic-scenario-tests.md), which is why the floor is extracted to a pure helper and unit-tested, matching the capRegularAttackDamage / capCumulativeSpecialDamage precedent.

Armor Class Calculation

Source:

AC = 10 (base)
  + (DEFENSE stat ? +2 : 0)
  + ceil(levelBonus / 2.0)
  + sum(armor.armorBonus)                 // All equipped armor pieces
  + allGemsArmorClassBoost
  + setArmorBoostPercent
  + percentOf(25, VIP level)
  + raceArmorBoost                        // Applied twice if level >= 100
  + companionArmorBoostMultiplier
  + playerPositive(ARMOR_BOOST)
  + playerPositive(STANCE_ARMOR_BOOST)    // From defensive stance
  + enchantmentArmorBonus

Power defense bonus:
  If power has skill check requirement: must pass EASY check first
  Success: +defenseBonus + DEFENSIVE_POWER weapon bonus
  Failure: no defense bonus, penalty message

Environmental:
  GOOD_FOOTING:     +2
  HOT or FREEZING:  -3
  BAD_FOOTING:      -4

Sound Effect Token Convention (Dual Text Pipeline)

Combat text contains inline sound effect tokens that are processed differently by each rendering pipeline:

Token format: {KEY}replacemewithsound (e.g., DAGGER_HITreplacemewithsound, SWORD_MISSreplacemewithsound)

Pipeline Processing Source
Web sanitizeForBrowser() → filterSsmlTags() strips any word containing replacemewithsound
Alexa splits on replacemewithsound, extracts key, looks up audio URL

Where tokens are inserted:

Context Source Method
Weapon hit DarkTowerWeapon.soundEffect field printAndHitOutcome() in
Weapon miss Hardcoded per weapon type weaponMissSoundEffect() in
Enemy death DarkTowerEnemy.enemyDeathSound field enemyDeathSoundEffect() in
Starting weapon Hardcoded DAGGER_HITreplacemewithsound getStartingWeapon() in

Critical rule: ALL sound effect tokens inserted into text MUST include the replacemewithsound suffix. Without it, web users see raw token text (e.g., "DAGGER_HIT") and Alexa cannot extract the audio key. For DB-sourced values, the insertion point appends the suffix if missing.


Power Balance Rules (Exploit Guards)

Status: Enforced in code. Introduced 2026-04-19.

These rules prevent the legacy "sneak-attack + recharge potion" loop where a player chained invisibility with power-refill items to take zero damage for arbitrarily long fights. Every rule below has a matching enum / constant check in .

Rule 1 — INVISIBLE_BOOST is enemy-count gated

A power that grants INVISIBILITY positive effect (INVISIBLE_BOOST special, e.g. POWER_INVISIBILITY_TRIGGER) fails with a thematic "too many eyes" message when facing more than INVISIBLE_BOOST_MAX_ENEMIES = 2 enemies. The skill check is not rolled and the charge is still spent — slipping past a single cutpurse is believable, vanishing from a squad of orcs is not.

Why: Without this cap, the player could stealth past any encounter, including boss roomfuls, rendering difficulty meaningless.

Guarded at:

Rule 2 — INVISIBLE_BOOST has a per-combat cap

Even in fights with ≤2 enemies, invisibility can only be cast INVISIBLE_BOOST_MAX_USES_PER_COMBAT = 2 times per encounter. After that, the narrative acknowledges enemies are wise to the trick and the power is refused.

Why: Stops the "hide → attack → hide again → attack" rotation from extending indefinitely, especially when paired with recharge potions.

Guarded at: via powerUsedCountThisCombat

Rule 3 — Power-recharge rewards cap at one per combat

When a reward of type POWER_RECOVERY_REWARD or PERFECT_RECOVERY_REWARD is processed while the player is in combat, it only resets power charges the first time. Additional recharge items consumed mid-combat still grant their healing / cleanse side-effects (for PERFECT_RECOVERY_REWARD) but no longer refill charges. Out of combat (resting, world rewards, dungeon-exit rewards) the cap does not apply.

Why: This is the direct fix for the legacy loop. The player can no longer chain INVISIBILITY → attacks → recharge potion → INVISIBILITY → attacks → recharge potion → …. One recharge per combat means at most 2×(charge limit) invisibility uses total per fight, and Rule 2 caps that at 2 regardless.

Guarded at: via isPowerRechargeCapped / trackPowerRechargeUse. The in-combat recharge-elixir option additionally refuses a capped elixir BEFORE consuming it in UseCombatPowerRechargeItem.useCombatPowerRechargeItem, and the out-of-charges power denial is cap-aware so a player who has already spent their recharge is never told to drink another elixir (feedback 9168). Counter key: COMBAT_POWER_RECHARGE_USES in actionUsage; cleared by clearCombat → clearCombatPowerUsage at combat END and again by .populateEnemies → clearCombatPowerUsage at combat START (queue 20032 defense-in-depth), since the key starts with the COMBAT_POWER_ prefix. The counter therefore resets between combats — verified by .

Rule 4 — DOMINATE_MONSTER is once per combat

DOMINATE_MONSTER_MAX_USES_PER_COMBAT = 1. Converting an enemy into an ally is strong enough that doing it repeatedly would flip the roster. One swing of the pendulum per fight.

Guarded at:

Rule 5 — CHARM_MONSTERS is capped at two per combat

CHARM_MONSTERS_MAX_USES_PER_COMBAT = 2. Charm has a maxEnemies of 6 in data, so two casts can already neutralise a sizeable group — beyond that the enemies grow immune.

Guarded at:

Adding New Powers — Design Checklist

When introducing any new power, the designer must answer each of the following before shipping:

  • Does this power make the player un-targetable or immune? If yes, require an enemy-count gate (Rule 1 pattern) AND a per-combat cap (Rule 2 pattern).
  • Does this power restore a combat-limited resource (HP pool, power charges, cooldown timers)? If yes, subject it to a per-combat counter (Rule 3 pattern).
  • Does this power heal for damage dealt, reflect damage, or scale with enemy count? If yes, cap the total HP % restored or reflected per combat.
  • Does this power chain a crowd-control effect (stun, daze, charm, dominate)? If yes, require a per-combat cap (Rule 4/5 pattern).
  • Does this power stack its own effect (invincibility frames, damage multiplier, crit bonus)? If yes, forbid stacking the same effect twice — later casts refresh duration but do not add another layer.

Legacy Exploit Reference

The game this title replaces had POWER_SNEAK_ATTACK (hide from all enemies regardless of count) + ITEM_RECHARGE_POTION (refilled power charges mid-combat) with no caps. Players could loop: sneak attack → damage → potion → sneak attack → damage → potion → victory, taking zero damage regardless of opponent strength. Rules 1-3 make the same loop in Dark Tower structurally impossible.


Power Denial Messages (Voice-Friendly Guidance)

Status: Enforced in code. Introduced 2026-04-22 (feedback 567/570).

Every code path that refuses a power invocation MUST produce a voice-friendly message that names (a) which power was blocked, (b) WHY it was blocked, and (c) what the player can do next. Silent failures and one-line "you can't do that" messages are forbidden. Copy lives in and must stay in sync with the table below.

Denial-Reason Table

Every row below is a guarded code path. Each message reads aloud cleanly on Alexa (no abbreviations, no brackets, no code symbols) and gives the player at least one alternative action.

Reason Code Location Exact Voice Message
Zero hit points, has healing potion You are at zero hit points. You have {N} healing {potion|potions} left. Say drink healing potion to drink one and keep fighting, or say surrender to end the battle.
Zero hit points, no healing potion You are at zero hit points and have no healing potions left. You collapse and the battle is lost. Buy healing potions from the shop before your next fight.
Item cap reached (used max healing items this combat) You have already used {N} healing items this combat, which is the limit. Stock up before your next fight, or say buy healing potion during one. You collapse. The battle is lost.
No power selected (null power) No power selected. Say use power to see the list of your powers, or say the power name directly, like sneak attack or fireball.
Player is silenced {PowerName} cannot be used while you are silenced. Silence lasts a few rounds. Try attack or a stance while it wears off.
Player has not learned the power You have not learned {PowerName}. You can only use powers you have unlocked from your class or through leveling up. Say powers to hear the powers you have.
Out of charges, a recharge is still available via outOfChargesMessage You have used all of your charges of {PowerName} for now. Say rest at a safe location to refill your power charges, or drink a recharge elixir during combat.
Out of charges, the one per-combat recharge is already spent (cap-aware) via outOfChargesMessage You have used all of your charges of {PowerName} for now. You have already used your one power recharge this combat, so another recharge elixir will not help until this fight is over. Try attack or a stance, and rest at a safe location afterward to refill your charges.
Recharge elixir refused before it is consumed (per-combat cap reached, Rule 3) UseCombatPowerRechargeItem.useCombatPowerRechargeItem You have already used your one power recharge this combat, so drinking another elixir now would be wasted. Finish this fight, then rest at a safe location to refill your power charges.
INVISIBLE_BOOST — too many enemies (Rule 1) {PowerName} only works when you are facing {INVISIBLE_BOOST_MAX_ENEMIES} enemies or fewer, and there are {N} enemies right now. Too many eyes are on you. Try attack, a stance, or a different power.
INVISIBLE_BOOST — per-combat cap (Rule 2) You have already used {PowerName} {N} times this combat, which is the limit of {INVISIBLE_BOOST_MAX_USES_PER_COMBAT} per fight. The enemies are wise to the trick now. Wait until your next combat to try it again.
DOMINATE_MONSTER — per-combat cap (Rule 4) {PowerName} can only be used {DOMINATE_MONSTER_MAX_USES_PER_COMBAT} time per combat, and you have already used it. Your mind is spent for this fight. Try attack or a different power.
CHARM_MONSTERS — per-combat cap (Rule 5) You have already used {PowerName} {N} times this combat, which is the limit of {CHARM_MONSTERS_MAX_USES_PER_COMBAT} per fight. The enemies are wise to your charms now. Try attack or a different power.
UP_ONLY — per-combat cap You have already used {PowerName} {N} times this combat, which is the limit of 2 per fight. Try attack or a different power. The limit resets when combat ends.
Power-recharge item cap (Rule 3) — plain POWER_RECOVERY Only {POWER_RECHARGE_ITEM_MAX_PER_COMBAT} power recharge can be used per combat, and you have already used yours. Save the next recharge for after this fight.
Power-recharge item cap (Rule 3) — PERFECT_RECOVERY You were healed to full and cleared of negatives, but only {POWER_RECHARGE_ITEM_MAX_PER_COMBAT} power recharge is allowed per combat, so your powers did not recharge this fight.

Recharge-Aware Out-of-Charges Guidance (feedback 9168, report 93 finding 407, 2026-06-21)

A player reported being unable to use a power after drinking a recharge potion entering combat. The root cause was MESSAGING, not a reset bug. The per-combat recharge counter COMBAT_POWER_RECHARGE_USES resets correctly between combats: it is double-guarded, cleared at combat END ( to clearCombatPowerUsage) AND at combat START ( to clearCombatPowerUsage, the queue 20032 defense-in-depth). The confusion came from two circular dead ends once the player had already spent their one recharge this combat (Rule 3):

  1. The out-of-charges power denial unconditionally told the player to drink a recharge elixir during combat, but the reward path refuses a second recharge — so following the advice did nothing. is now cap-aware: it suggests the elixir only while a recharge is still available, and once the cap is reached it names the cap and points to attacking or stancing and resting after the fight.
  2. Drinking a second recharge elixir via the combat option consumed the item and only then reported that the recharge was already used, reading as a potion that silently did nothing. UseCombatPowerRechargeItem now checks isPowerRechargeCapped BEFORE consuming, so a capped elixir stays in the bag and the player is told why.

Regression shield: — testDetermineIfPowerToUse_outOfCharges_notRechargeCapped_suggestsRechargeElixir, testDetermineIfPowerToUse_outOfCharges_rechargeCapped_doesNotLoopBackToElixir, and testPowerRechargeCounter_resetsBetweenCombats_andRechargeRefillsCharges (pure JUnit — the in-combat class-power path cannot be granted by the scenario harness, matching the heal-power precedent above).

Combat Action Menu — Healing Potion Visibility

gates the "healing potion" option on TWO conditions that must BOTH be true:

  1. hasHealingItem(gameBasicProperties) — player has at least one healing potion in inventory.
  2. hitPoints < maxHitPoints — player is below full HP (no point offering at full HP).

Why both: Before 2026-04-22 the code only checked HP. Players at low HP with zero potions still saw the option and got "I couldn't find that item" when they picked it. The hasHealingItem guard fixes that. Players at full HP never see the option — by design, to discourage burning potions prophylactically.

Heal Powers vs Healing Potion — `heal` Routing and Zero-Heal Narration

Status: Documented 2026-06-08 (feedback 8516).

There are two distinct ways a player restores HP in combat, and they are reached by two distinct commands:

  • Healing potion (an item) — the visible healing potion combat option, reached by heal, drink, drink potion, use potion, potion. The word heal is wired to the healing-potion option by design ( and the zero-HP last-stand prompt). A potion is the universal heal and the only HP restore most players have, so heal defaulting to the on-screen potion option is the correct common-case behavior. The class heal POWER is NOT bound to heal.
  • Class heal power (a special ability, e.g. a HEAL_ON_SWING power) — reached by use power → the power list, never by the bare word heal. This keeps heal unambiguous for the majority of players who have no heal power, and avoids a cross-cutting NLU ambiguity. A player who wants the power says use power.

Zero-heal narration (the real defect in feedback 8516). A heal power that restores 0 HP — because the player is already at full health or carries the CANNOT_HEAL debuff — previously spoke nothing (the branch only narrated when the heal amount was positive), leaving an incomplete/silent response. Per the Power Denial Messages rule above, silent power outcomes are forbidden. All three heal-power sites now narrate the zero-heal outcome with a clear voice line instead of staying silent:

Power special Code location Zero-heal line
HEAL_ON_SWING Your healing power restored no health that swing.
HEAL_ON_HIT You hit, but your healing power restored no health this time.
HEAL_ON_KILL Your healing power restored no health from that kill. / ...those kills.

Regression shield: (JUnit — the heal-on-swing path needs a class power in combat, which the scenario harness cannot grant, so the output logic is pinned with a unit test per .claude/rules/hermetic-scenario-tests.md).

Zero-HP Last Stand — Text Source Fix

The zero-HP prompt in youAreAboutToDrop previously read "drink a " + item(...).getName() + " now or surrender". The item(...) utility returns a new empty DarkTowerItem whose getName() is null, so Alexa spoke "drink a null now or surrender" — which listeners heard as "drink a... now or surrender" or misheard as "A or surrender" (feedback 570). The text is now hardcoded to "healing potion" and includes the player's current potion count so they know they have a potion left.

Adding New Denial Paths — Design Checklist

When a new power-denial rule is added, the designer MUST:

  1. Add a row to the Denial-Reason Table above BEFORE writing code.
  2. Write the voice message to name the power, explain the rule, and offer an alternative action.
  3. Avoid abbreviations, brackets, slashes, percent signs in spoken text (see ).
  4. Never end a denial branch with a pure continueFailureActions — always addCombatOptions back so the player has a turn action available (unless the denial is terminal defeat).
  5. Cover the new path with a unit test in that asserts the message contains the power name, the reason, and the alternative.

Special-Attack Damage Cap (Per Round)

Status: Enforced in code. Introduced 2026-06-04 (player_feedback 8636, design decision 21446).

Enemy special-attack damage is calculated as a percent of the player's max hit points ( → getCharacterHealthPercent → percentOf(perc, maxHitPoints)) and accumulates across every special ability an enemy uses in a single round onto DarkTowerCombatOutcome.specialDamage. There was no aggregate ceiling, so a single multi-special enemy (or a boss with several percent-based specials) could deal more than the player's entire HP bar in one round purely from specials — a player with 268 max HP reported taking 421 special damage (~157% of max HP) while fighting one enemy. The number was honest (not a phantom-enemy summing bug), but reads as broken.

The rule

The cumulative special-attack damage the player takes in one enemy round is capped at percent of max hit points (currently 50%). Regular weapon-attack damage is dealt separately and is not affected by this cap — a round can still drop the player through normal hits plus capped specials, so multi-round fights stay lethal; what is removed is the one-round special-only near-kill.

Where enforced: .capCumulativeSpecialDamage(outcomes, maxHitPoints) is a pure helper that clamps each outcome's specialDamage in place so the round total never exceeds the cap. It is called by both special-damage render paths before the total is narrated or dealt: DarkTowerEnemyAttackOutcomeRenderer.enemyOutcomesStringFull (single-enemy) and outcomeStringFromHits (multi-enemy / minimal). Because the clamp mutates the same specialDamage values both the spoken "you also took N damage from special attacks" line and the HP actually removed reflect the cap — they cannot diverge.

Tuning: the cap is a single named constant. Raising or lowering it is a deliberate combat-difficulty change; change MAX_SPECIAL_DAMAGE_PERCENT_PER_ROUND only. The cap does not touch DarkTowerEnemyTypes or the Kryo-serialized card cache, so no enum-ordinal / cache-flush concern applies.

Regression guard: (pure JUnit) pins the clamp math — a scenario test is not used because special-attack triggering is RNG-driven and a value assertion on it would be brittle (see .claude/rules/hermetic-scenario-tests.md).


Regular-Attack Damage Cap (Per Hit)

Status: Enforced in code. Introduced 2026-06-14 ().

Enemy regular weapon-attack damage is rolled in as rollDice(weapon.damageDiceRoll) + bonusDamage + enemyBonusDamage, doubled on a crit (CRIT_DAMAGE_MULTIPLIER). The bonusDamage term comes from an enemy's ENEMY_POWER_ATTACK-style special, whose damageBonus is a flat, unscaled value tuned for the enemy's intended tier (the catalog ranges 10-114 across enemies). When a low-tier enemy is reused against a brand-new, low-HP player — the chapter-one Orc (damageBonus 34) vs a level-one, 69 max-HP Warrior — a single power-attack crit landed for 62 damage (89% of the HP bar), a near-one-shot of a fight that is a winnable story beat, not a scripted loss.

This is NOT an outlier enemy to nerf: the Orc's 34 is on the LOW end of the catalog, and the flat bonus is correct for the Orc's intended tier. The pathology is the flat bonus being unscaled relative to the player — lethal at level one, negligible at high level. Tuning the shared Orc data would weaken it everywhere.

The rule

Each single enemy regular weapon-attack hit is clamped to percent of the player's max hit points (currently 35%). Because the cap is a fraction of max HP it scales correctly at every level: a level-one player's cap is ~24, so the Orc fight is survivable; a level-100 player's cap is large, so high-tier enemies still hit hard. The cap only ever bites the pathological single-swing near-one-shot — an enemy whose intended per-hit damage is already under a third of the player's bar is unaffected, so documented difficulty is preserved.

Where enforced: .capRegularAttackDamage(damage, maxHitPoints) is a pure helper that returns min(damage, percentOf(MAX_REGULAR_ATTACK_DAMAGE_PERCENT_PER_HIT, maxHitPoints)). applies it where it sets each attack's damageTotal, so both the narrated hit and the HP actually removed reflect the cap.

Scope: the cap is per single regular hit. Multi-attack-per-round enemies still stack capped hits, and special (damagePercent) damage is capped separately and additively by MAX_SPECIAL_DAMAGE_PERCENT_PER_ROUND (50% per round), so multi-round and multi-attack fights stay lethal — only the one-swing near-kill is removed. Extending this to a per-round regular-attack ceiling (mirroring the special cap) is a deliberate future tuning choice, not done here.

Tuning: the cap is a single named constant. Raising or lowering it is a deliberate combat-difficulty change; change MAX_REGULAR_ATTACK_DAMAGE_PERCENT_PER_HIT only. The cap does not touch enemy data or the Kryo-serialized card cache, so no enum-ordinal / cache-flush concern applies.

Regression guard: (pure JUnit) pins the clamp math — a scenario test is not used because enemy attack damage is RNG-driven (crit chance + dice + special chance) and a value assertion on a live fight would be brittle (see .claude/rules/hermetic-scenario-tests.md).

Player hit-rate note (, low-hit-rate sub-item)

The same finding observed a 0-of-2 player hit rate against the Orc. This is the documented EASY floor working as designed, not a bug: a brand-new character has difficultyLevel == null, which resolves to EASY, so floors the player's combat hit chance at 50% (the highest floor). The 0-of-2 was bounded variance at that floor, and the anti-stalemate accumulator (combatHitDamageBoost, +4 per missed round) escalates it every round — the "you find your rhythm and gain combat momentum" line surfaces it. The acceptance "player hits at least 30%" is met by the existing 50% floor; raising the floor would be a deliberate rebalance of the documented easiest difficulty and is out of scope (consistent with the Early-Fight Hit Floor confirmation above). (Update 2026-06-20: still true for the PLAYER floor — it stays 50% — but the ENEMY-side opening-fight floor was subsequently raised under human_question 22583, ; see Opening-Fight Enemy Hit Floor below.)

Combat-entry environmental sentence capitalization ()

assembles the environmental line (e.g. the DARKNESS clause "it is pitch black, which means the power blindsight or a light source would help.") and speaks it as its own sentence immediately after the combat-entry sentence ("...entering combat with one Orc."). Each effect clause is authored lowercase for mid-list joining, so the assembled line read "...one Orc. it is pitch black..." — a lowercase sentence start. The assembled line is now passed through StringUtils.capitalize, which capitalizes only the first letter, fixing the DARKNESS clause and every other environmental clause without disturbing mid-list joining.


Combat Information & Targeting Commands

Status: COMPLETE — Implemented as one combat-information epic.

Four player-facing combat-information affordances let a voice player understand and direct a fight without a HUD. They consolidate three feedback cycles (choose a target, ask enemy HP mid-fight, hear grouped enemy attacks, see remaining power uses and silence in status).

Choose enemy target

A player can name which enemy to hit first instead of always striking whichever enemy is at the front of the list. Phrasings route to BASELINE_COMBAT_ATTACK with a PERSON slot — the existing attack the <X> / fight <X> / kill <X> family plus new target the <X>, focus on <X>, go after <X>, and attack the enemy with the <X> prefix rows (DARK_TOWER context, Tier 4).

resolves the spoken name against the living enemies in the current encounter via , which moves the first matching enemy to the front of currentEnemies so the single-target attack (firstEnemy) lands on it this round, and confirms with "You focus your attack on the ." Matching strips a leading "the" and accepts an exact name, an exact plural, or a contains-match either direction ("the small wolf", "small wolf", "wolf" all reach a Small Wolf). When the spoken name matches no living enemy the round proceeds as a normal attack against the front enemy — naming a target is an optional refinement, never a blocker.

Enemy hit-point readout (on demand)

DARK_TOWER_ENEMY_HIT_POINTS (handler → ) reads back the current enemies' HP on demand using the same enemyHitPointsText formatting as the round-start status line (full-health collapse, per-enemy numbers at or below the enumeration cap, banded aggregation above it). Out of combat it speaks a graceful "you are not in combat" message. NLU phrases (DARK_TOWER context, Tier 4 direct match): "enemy hit points", "enemy hp", "enemy health", "enemy life", "enemies health", "check enemy health", "how much health does the enemy have", and variants.

Status reports remaining power uses + silence rounds

The in-combat branch of additionally speaks:

  • Power uses remaining — lists each combat power's remaining charges (powerCountLeft), e.g. "Power uses remaining: Flurry of Blows has 6 uses left and Animal Strike has 3 uses left." Emits nothing when the player has no combat powers.
  • Silence rounds — reports how many rounds the player's SILENCE will last (longest remaining when stacked), e.g. "You are silenced and cannot use powers for 2 more rounds." Emits nothing when the player is not silenced.

These give the player the two pieces of state they need to decide whether a power is even available this turn before committing it.

Grouped enemy attacks (already grouped)

Enemy attacks are already narrated as a grouped rollup rather than one clause per swing: single-enemy rounds via createEnemyAttackSummary ("the Banshee hit you 2 times for 9 total damage"), multi-enemy rounds via DarkTowerEnemyAttackOutcomeRenderer.outcomeStringFromHits ("You were blocked and missed"). The living-enemy count is spoken once per turn by the round-start line ("There are N enemies still standing"); the rollup must not restate "Across N enemies". Guard: .

Hard-Boss Loss Routing — Checkpoint on Defeat

Status: Enforced via content. Introduced 2026-06-14 (decision human_question 21729).

Losing a brutal dungeon "final boss" used to drop the player either back toward the same fight or onto the bare dungeon menu, so repeated defeats read as the story "looping". The owner's CHECKPOINT-ON-LOSS decision (human_question 21729) routes a hard-boss defeat to a dedicated safe checkpoint card where the player can heal and re-enter deliberately, instead of being bounced straight back into the fight.

The rule

A hard-boss combat overlay sets combat.nextCardOnFailure to the shared dungeon checkpoint card 900000. On defeat, reads combatNextCardIdOnFailure (resolved in as combat.nextCardOnFailure ?? option.nextCardId) and navigates there. The victory path is untouched — with no nextCardIdOnSuccess, victory still falls back to the option's nextCardId, so only the loss path changes.

The checkpoint card (900000)

  • ID 900000 sits in the Free-Explore shared range (>= 613000, where getStorylineFromCardId returns null) — the same null-storyline class as the dungeon cards (< 30000). This is deliberate: the passive storyline save in DarkTowerBasicFeatures.incrementCardForSet only fires when leaving a non-null storyline, so a dungeon defeat landing on a null-storyline checkpoint can never overwrite the player's main-story saved position. The whole 0–29999 system range is fully populated, which is why a high free-explore ID is used rather than a low one.
  • restChancePercent: 100 so the player can fully heal via the rest command (the engine's idiomatic in-place heal; card-arrival rewards are intentionally not used — see cards.md). The card carries no freeExplorePoint, so it renders as a plain narrative card, not a free-explore zone.
  • Options: rest (freeTextDeclarationAction: DARK_TOWER_REST, heals to full subject to the 10-minute REST_COOLDOWN) and return to the entrance (nextCardId: 75, the shared dungeon menu), so re-entry is deliberate rather than an automatic bounce.

Hard-boss cards routed (enumerated)

The "hard boss" set is the gold-dragon dungeon final-boss encounters — the brutal boss the decision cited (a Gold Dragon backed by dozens of Chaos Beasts / Harpies / Goblin Warlocks):

Card Dungeon Roster Loss was → Loss now →
15118 Goblin Dungeon Gold Dragon + 50 Goblin Warlocks + 50 Harpies (no escape) 15048 (dungeon hub) 900000
17100 Castle Dungeon Gold Dragon + 25 Chaos Beasts + 5 Chaos Alligators 75 (option fallback) 900000
17250 Castle Dungeon Gold Dragon + 25 Chaos Beasts + 5 Chaos Alligators 75 (option fallback) 900000

Out of scope: card 402449 (CITY_*) is user-generated creator content, not an authored boss. The mechanism (set combat.nextCardOnFailure to 900000) extends trivially to any other authored hard boss the owner later flags — the checkpoint card and routing field are the reusable parts.

Combat dead-end content defect (creator territory/city/dungeon cards)

Status: CLOSED-as-accepted (2026-06-20). Engine safety net SHIPPED; the owner chose to accept the menu-bounce as the resolution (human_question 22647, Option B) — NO content backfill, no prod card_data mutation. Detection added to the card-graph validator; the standalone SQL sweep is the recurring guard for future bulk creator generation. ; doc follow-up .

What the defect is

A content sweep (2026-06-20) found 1,284 combat options across 1,133 cards that start combat (non-empty combat.enemies) but author NO continuation: no combat.nextCardIdOnSuccess, no combat.nextCardOnFailure, and no option-level nextCardId. The engine safety net routes both win and loss to MAIN_MENU_CARD_ID (storyline saved), so this is not player-blocking — it is a quality defect: the player bounces to the menu instead of continuing to explore.

The reproduction sweep (the SQL below, run against dark_citadel.dark_citadel_card, is the full per-card inventory):

SELECT c.card_id, c.status, jt.opt_order
FROM dark_citadel.dark_citadel_card c
JOIN JSON_TABLE(c.card_data, '$.options[*]' COLUMNS (
  opt_order FOR ORDINALITY,
  enemies JSON PATH '$.combat.enemies',
  succ INT PATH '$.combat.nextCardIdOnSuccess',
  fail INT PATH '$.combat.nextCardOnFailure',
  next_card INT PATH '$.nextCardId')) jt
WHERE c.card_data LIKE '%"enemies"%'
  AND jt.enemies IS NOT NULL AND JSON_LENGTH(jt.enemies) > 0
  AND jt.succ IS NULL AND jt.fail IS NULL AND jt.next_card IS NULL;

Root cause — an external bulk generation, not a live code path

All affected cards are procedurally-generated CUSTOM-creator activity cards (DarkTowerStatus CITY / TERRITORY / DUNGEON / COOPERATIVE / INSANITY) with a single bulk last_upd_tms of Jun 04 2026, tied to player-claimed spheres (WhatSphere → Dark Citadel territory). The creator feature exists in code (model/darktower/create/DarkTowerCardCreateTypes = LANDING_CARD / TERRITORY_CARD / EXPLORE_CARD; DarkTowerCardOwnedPerk perks such as SPECIAL_TOKEN_MINE, which match the in-card "create special token mine" options), but there is no live Java generator that emits these combat option trees — utils/darktower/creator/ holds only display/pricing helpers. The card JSON (LLM-authored narrative) was produced by a one-off external batch that truncated branches: it wrote combat successText like "you defeat the goblins and continue your journey through the forest" but never generated the continuation card. There is therefore no live generator to patch.

The continuations are NOT recoverable from the data

The task premise — "backfill the CORRECT continuation per card, verified from sibling options / the generation sequence" — does not hold:

  • The typeOfCard encodes the DIRECT parent for 981 of the 1,133 cards (<STATUS>_<parentCardId>_<regionId>; all 1,068 such parents exist). The remaining 152 cards (CLAIMED_TERRITORY, CASTLE_DUNGEON, LANDING_CARD_CLAIMED, INSANITY_DESERT_DUNGEON) encode no parent at all.
  • Tracing a representative region (root 300666, region 364942): card 400002 "enter the forest" is a dead-end, but the only children of 400002 (400003 "the hill", 401027 "the token mine") are already linked to its OTHER options. No orphan "forest" card exists — the forward card was never generated, so there is nothing correct to point the option at.
  • Within a single card the dead-end options point conceptually different directions (e.g. card 17113 "inspect the weapons" → deeper into the armory vs "ignore the sounds" → leave), none of which has a target card.

Pointing an option at any existing sibling card would be guessing a narrative branch — explicitly forbidden, and worse than the menu bounce the safety net already provides.

Detection (prevention) — card-graph validator

The repository root's mcp-servers/llitd-context/src/tools/game-content/card-graph.ts gained a combat_dead_end detector: validate_graph now flags any option with combat enemies but no success / failure / option next-card as an error. CAVEAT: validate_graph is scoped to one chapter_game, and these bulk territory cards carry chapter_game = NULL, so they are NOT swept by that path — the standalone SQL sweep above is the check for the NULL-chapter creator set. The detector guards future generated chapters that DO set chapter_game.

Resolution — CLOSED-as-accepted (Option B, owner decision 2026-06-20)

The owner chose Option B: accept the shipped engine menu-bounce safety net as the resolution and close the content backfill (human_question 22647; doc follow-up ). No prod card_data is mutated.

Why Option B (not A route-to-parent, not C regenerate):

  • The correct continuations do not exist and are not recoverable from the data (see the two sections above) — pointing a dead-end option at any existing card would be guessing a narrative branch, which is forbidden and worse than the menu bounce the safety net already provides.
  • Option A (route both outcomes to the direct parent / territory hub) does reach "sweep returns zero rows", but a return-to-parent loop on victory re-exposes the combat's successReward — a reward farm loop — which would itself need a per-option cooldown or no-reward-on-immediate-reloop guard to be safe.
  • Option C (regenerate the truncated branches as new content) is the highest-effort path and was not chosen.
  • The engine safety net () already routes both win and loss to MAIN_MENU_CARD_ID with storyline saved, so there is no soft-lock — only a quality bounce to the menu.

Accepted residual + the recurring guard. The SQL sweep against dark_citadel.dark_citadel_card is the full per-card inventory and stays non-zero by design — that is the accepted resolved state for the existing creator cards, not an open bug. Going forward the defect is guarded at the source, not back-filled in the data:

  • Re-run the standalone SQL sweep above after any future bulk creator generation. It is the recurring guard for the NULL-chapter_game bulk creator set (the cards the per-chapter validate_graph cannot reach); a non-zero delta on NEWLY-generated cards is a generation defect to fix where the cards are produced, never by guessing a continuation on the existing set.
  • The card-graph combat_dead_end detector (the repository root's mcp-servers/llitd-context/src/tools/game-content/card-graph.ts, shipped) flags the same defect in any future generated chapter that DOES set chapter_game, before it ships.

Duplicate dungeon-stage descriptions (post-combat repeat)

Status: FIXED in engine. Introduced 2026-06-21 (report 93 finding 401, group 70).

What the defect is

Procedurally-generated dungeon decks (GRAVEYARD_DUNGEON, CAVE_DUNGEON) bundle several sequential combat cards into one "stage" that all share a single continueText area description — e.g. cards 16076-16080 (GRAVEYARD_DUNGEON) carry byte-identical narrative ("The passage narrows as you approach the heart of darkness..."). The cards advance correctly (16076 -> 16077 -> 16078 -> ...), but the engine re-read the same area description after every fight, so a player fighting through a stage heard the identical passage repeatedly and perceived a loop. This is DISTINCT from the CONTINUE soft-lock: there the card pointer never advanced; here it advances fine — only the narrative repeats.

A scope sweep of dark_citadel.dark_citadel_card (status = DUNGEON) found the duplication concentrated in the two large procedural decks — GRAVEYARD_DUNGEON (112 cards, 23 duplicate descriptions) and CAVE_DUNGEON (99 cards, 42 duplicates); the smaller dungeon types carry no internal duplicates. The shared text is a generator artifact of the same external bulk authoring batch behind the dead-end defect above.

The fix (engine, universal safety net)

Rather than rewrite ~65 procedurally-generated descriptions (the correct distinct prose is not recoverable and blind-authoring is the same anti-pattern the owner rejected for the dead-end backfill, Option B above), the engine suppresses a card's area narrative when it byte-for-byte repeats the description just heard on the prior card — the design intent of "read the area description once on stage entry, then advance quietly until a genuinely new stage".

  • .isRepeatedContinueText(previousCard, nextCard) is a pure helper that flags a move to a DIFFERENT card carrying non-blank continueText identical to the prior card's, with no alternate narrative to vary it.
  • DarkTowerBasicFeatures.incrementCardForSet sets the request-scoped GameBasicProperties.suppressRepeatedContinueText flag during navigation (unconditionally, so a later navigation in the same turn overwrites a stale verdict).
  • skips the narrative line when the flag is set; environmental effects still narrate.

This fixes all current and future duplicate-description stages universally, in one small code change (the same "universal safety net" approach as the CONTINUE soft-lock fix). A genuinely new stage description, a blank narrative, an authored alternate telling, or a same-card re-render always reads normally.

Balance Analytics Instrumentation

Status: INSTRUMENTED (2026-04-24). Analytics-only. No balance tuning has been done from this data yet — a follow-up task evaluates and tunes after 7+ days of data.

Origin: Feedback 682, 700, 701 — DT players asking for "checks" to be "restored". The design intent (index.md) is that Dark Tower is deliberately more difficult than Dark Citadel (its predecessor branding). Before tuning, this instrumentation collects the data needed to evaluate whether difficulty is tuned correctly.

Tables

Table Populated by Purpose
game_log.check_outcomes One row per skill/ability check resolution
game_log.combat_metrics (extended) One row per combat resolution (victory, defeat, flee)

combat_metrics was extended in place (new columns chapter_id, card_id, player_hp_start, player_hp_end, enemy_starting_hp, primary_enemy_ref, weapon_tier, armor_tier, victory) instead of creating a separate DT-only table — this keeps one schema for both DT and Darkness Falls combat analytics.

Wiring

Event Code path Notes
Every check resolution Called inside isCheckSuccessUnderlying after success/roll is determined. Records DC, roll, bonus, gear tier, power count, source (e.g. OPTION, DODGE_STANCE).
Combat start snapshot Captures combatStartHitPoints + combatEnemyStartingHitPoints + combatPrimaryEnemyRef when populateEnemies finishes. Fields live on .
Victory → logCombatOutcome(true, false) Fires BEFORE clearCombat so enemies list is still populated.
Defeat → logCombatOutcome(false, true) Player died.
Flee → logCombatOutcome(false, false) Neither victory nor death.

Both loggers are @Async fire-and-forget and swallow exceptions at the call site so analytics failures never affect gameplay (lesson from pre-2026-04-23 milestone column-width bug that silently dropped Alexa milestones).

Admin Dashboard Endpoints

Base: /api/admin/analytics/dark-tower (admin JWT required). See .

Path Histogram
/summary All four below in one response + volume counts
/checks/by-dc Success rate bucketed by DC in 10-point bins
/checks/by-gear-tier Success rate by primary_gear_tier for each DC bucket
/combat/by-level Win rate by level band (1-10, 11-25, 26-50, 51-100, 100+)
/combat/ttk Combat round distribution (1-2, 3-5, 6-10, 11-20, 21+)
/combat/by-weapon-tier Win rate + avg rounds per weapon rarity

All endpoints take ?days=N (default 7, clamped 1-90).

Data-Driven Review Gate

Do not tune balance from partial data. Wait for 7+ days of combat_metrics rows with victory IS NOT NULL before opening a follow-up tuning task. Feedback 682, 700, 701 stay linked to (user_design) pending that review.

Tiered pacing + death-rate targets, and the metrics data-gap fixes, live in game-balance.md -- the DT sibling of darkness-falls/game-balance.md. That doc is the source of truth for the /game-audit Combat Balance lane target table (.claude/skills/game-audit/combat-balance.md) and tracks the zone_level reinterpretation (difficulty id, closed 2026-07-14 ) plus the open GA6 lethality-tuning decision.


Round-Start Status Line (Player + Enemy HP)

Status: COMPLETE — Implemented 2026-04-24 (feedback 571/572).

At the start of every combat round, the game narrates BOTH player and enemy HP with explicit numbers, not just "at full health" summaries. This gives voice/chat players the situational awareness they need to plan each turn.

Output Format

Round N: You have <playerHP> of <maxPlayerHP> hit points[. Status warning]. 
[There are M enemies still standing. ]
<enemyName1> has <hp1> out of <max1> hit points[, <enemyName2> has <hp2> out of <max2> hit points]. 
  • Player line via
  • Enemy line via
  • Both are called unconditionally at round start in and applyProlongedCombatFeedback().

Design Rationale

  • Voice-only players cannot scan a HUD; health numbers must be spoken to be useful.
  • Dark Tower's tactical combat rewards knowing when to flee, stance, or burn a power — those decisions need both-sides' HP.
  • Previous behavior suppressed enemy HP when enemies were at full health, so players starting a fight heard only their own HP and had no anchor.
  • Numeric format is consistent whether enemies are damaged or fresh, so the line reads the same every round.

Feedback Trail

  • Feedback 571: player praised hearing player HP at round start.
  • Feedback 572: requested enemy HP appended to the round-start status line in the same position.
  • Implementation: enemyHitPointsText() rewritten to list every living enemy with <cur> out of <max> rather than gating on damaged-only.
  • Large-pack bound (2026-05-28, feedback 25705): the per-enemy enumeration is capped at ENEMY_HP_ENUMERATION_CAP (3) living enemies. Above the cap the line aggregates by enemy name and health band (full health / wounded / nearly dead) so a 45-enemy arena round no longer reads a ~45-clause wall of text. The explicit <cur> out of <max> numbers are retained at or below the cap — small fights keep the planning anchor; large packs get a scannable summary. "nearly dead" = current hit points at or below a quarter of max.

Scenario Test Harness — Entering Combat Deterministically

Status: WORKING — harness fixed 2026-05-29 ().

This is the canonical recipe for authoring game_test.scenario_tests (and play_game / batch_playtest runs) that need to enter Dark Tower combat with a known living-enemy count and land an attack. Use it whenever a DT combat bug needs a regression test.

The recipe (verified end to end)

setup_json:

{
  "bypassCharacterCreation": true,
  "setPrimaryClass": "WARRIOR",
  "setLevel": 30,
  "setCardId": 628,
  "equipItems": ["MAIN_HAND", "OFF_HAND"],
  "setCharacterName": "YourTestName"
}
  • Card 628 is the canonical combat-entry card: it auto-spawns three Banshees behind a single no-check fight them option. Sending fight them enters combat ("You are entering combat with three Banshees..."), and a following attack resolves a round ("There are 3 enemies still standing.").
  • setPrimaryClass (and setSecondaryClass if level ≥ 10) fills the mandatory class slot so the player has class-granted combat stats/powers. WARRIOR trains MULTI_ATTACK, so flurry of blows is available for multi-attack tests (see scenario_test 1607).
  • equipItems: ["MAIN_HAND","OFF_HAND"] gives a sword + shield so attack/damage math runs against real gear.
  • The class seed is silent twice, so verify it landed. setClass is a DARKNESS FALLS key; Dark Tower declares only setPrimaryClass / setSecondaryClass, and Jackson drops the undeclared one without a word. Correcting the KEY is not sufficient: the VALUE must be a DarkTowerClassTypes subclass constant. FIGHTER and CASTER are class TYPES (the HP-grouping in progression.md § Class types), not classes, and MAGE / SORCERER are neither — applyClassByName catches the failed valueOf, logs at debug, and applies NO class. The Fighter-type subclass is WARRIOR; the caster analogue is WIZARD / WILD_MAGE. Verify by reading HP, which the seed must move: at level 5 a class-less character reports 65 HP and a seeded WARRIOR reports 91 (the x1.4 Fighter-type multiplier). A green suite does not prove the seed applied.
  • A single play_game call drives one command; for a multi-step manual sequence, send fight them then attack as separate play_game calls with the SAME userId and omit setCardId on the follow-up call (otherwise the teleport re-enters card 628 and resets combat). Combat enemy state persists on the character cache across calls for that userId.

scenario_test chain note

In the , a "; "-chained command (e.g. fight them; attack) carries setPrimaryClass / setSecondaryClass / setLevel forward to every step but applies setCardId, bypassCharacterCreation, and equipItems only on step 0 (see ). The runner also defaults every clearXxx flag to TRUE on step 0 (state isolation), but clearCombat is NOT carried to later steps, so combat entered on step 0 survives into step 1. A single-step fight them test (assert contains_text "entering combat") is the most robust combat-entry regression shield; scenario_test 1659 is the reference example.

Why setLevel ≥ 10 used to break (the harness bug this recipe documents)

Dark Tower levels are XP-derived. re-derives the level from experience on every hasUpgradeToSpend call and, on mismatch, resets the level DOWN to the XP value — and the default test experience is 0, which maps to level 1. populateLevelDetails.cleanupAbilities then strips the now-"excess" class slots (a level-1 character only has the primary class slot unlocked). Net effect before the fix: any test that set setLevel >= 10 with the default experience = 0 silently lost its primary AND secondary class the first time a handler checked for pending upgrades, and the mandatory PRIMARY_CLASS upgrade prompt ("You must fill a primary class slot") preempted the card under test — so combat at a realistic level could never be entered deterministically. setPrimaryClass set the class on the cache, but it was wiped before the gate ever read it.

The fix (applyCharacterModifications) reconciles experience UP to the minimum total XP for the test-set level () whenever setLevel or setCardId is used, so the test-set level survives reconciliation and the class stays filled. Experience is only ever raised, never lowered, so XP earned mid-combat on a multi-step chain is preserved. Below level 2 the reconciliation is a no-op (level 1 needs 0 XP), which is why low-level combat tests (e.g. card 628 at level 5) always worked.

Refused strikes, companions and enemy specials

A power whose strike would be refused says so before anything is spent (strikeWillBeRefused). Every companion can swing, at its roll chance (). A limited enemy move that a player mitigates still starts its cooldown: landSpecial and mitigateSpecial in both record it. A special's effect is narrated when it can land, and an on-hit negative is narrated at the hit.

Exploding shard. ITEM_EXPLODING_SHARD bursts for forty percent of the gear-scaled damage on every enemy and takes the round: the enemies still standing then get their turn (). Out of a fight it stays in the pack. Insects. ENEMY_BEE and ENEMY_WASP are critters, their rows in the rule table.