Military
Status: COMPLETE — All systems documented; see Known Issues section for any deferred follow-ups
Last updated: September 15, 2026
Lineage -- Military & Combat System Design
Status: COMPLETE — All systems documented; see Known Issues section for any deferred follow-ups
Military in Lineage encompasses army creation, unit recruitment, movement, and combat resolution across both standard (Local/Multiplayer) and Global World modes. Combat is deterministic-with-variance: strength is calculated from unit composition, morale, terrain, and fortifications, then a random 10% variance is applied.
Army Model
Source: LineageArmy
| Field | Type | Purpose |
|---|---|---|
armyId |
String | Unique identifier (army_ + 12 hex chars) |
kingdomId |
String | Owning kingdom |
armyName |
String | Display name (auto: "Army N") |
currentTerritoryId |
String | Current location |
xCoord / yCoord |
Integer | Grid position |
units |
Map<String, Integer> | Unit type to quantity |
stance |
String | DEFENSIVE, OFFENSIVE, RAIDING, FORTIFYING |
morale |
Integer | 0-100, affects combat strength |
movementPointsRemaining |
Integer | Points left this turn |
maxMovementPoints |
Integer | Max per turn (default: 3) |
movementPath |
List |
Ordered territory IDs for pathing |
combatBonuses |
Map<String, Integer> | Active modifiers |
Armies are created at a kingdom's capital territory. New armies start with 100 morale, DEFENSIVE stance, and 3 movement points.
Unit Types (15)
Source:
All unit stats are defined in a single constants class. Three maps define the full unit economy:
| Unit | Strength | Recruit Cost (Gold) | Upkeep/Turn (Gold) |
|---|---|---|---|
| MILITIA | 5 | 50 | 1 |
| WARRIOR | 8 | 75 | 2 |
| INFANTRY | 10 | 100 | 2 |
| ARCHER | 12 | 150 | 3 |
| SCOUT | 6 | 80 | 2 |
| CAVALRY | 18 | 200 | 4 |
| PIKEMAN | 12 | 120 | 2 |
| CROSSBOWMAN | 15 | 175 | 3 |
| KNIGHT | 25 | 450 | 9 |
| BATTLE_MAGE | 30 | 400 | 8 |
| SIEGE | 20 | 500 | 10 |
| ELITE | 40 | 800 | 16 |
Legendary tier — gated by LEGENDARY_WARFARE tech. See endgame.md Pillar B.
| Unit | Strength | Recruit Cost (Gold) | Upkeep/Turn (Gold) |
|---|---|---|---|
| DRAGON_KNIGHT | 60 | 1200 | 24 |
| ARCHMAGE | 70 | 1500 | 30 |
| WAR_TITAN | 100 | 3000 | 60 |
Default fallback values (for unknown unit types):
- Strength: 10
- Cost: 100
- Upkeep: 1
Spoken copy is DERIVED from this table, not re-typed (L8). The recruit menu reads each unit as "Militia, 50 gold, strength 5", pulling both figures from via , so the price a player hears is always the price the game charges. Recruit success reads "That army now has N units, combat strength M" — it previously read "Army strength: N units" where N was the unit COUNT, which reported a mob of militia as stronger than a handful of knights. Combat strength is , the single summation that army strength, conquest strength and this readout all now share.
Design note: Upkeep is approximately 2% of recruitment cost. Military must be viable as a parallel strategy to economic play — players need large armies for DOMINATION victory without bankrupting their treasury. SIEGE units have high cost but only 20 strength -- they are specialized for fortification damage, not field combat.
Unit Categories and Counter Relationships (Battle Type Values)
Source: and
Every unit belongs to a battle category. When two armies meet, each attacking-unit-versus-defending-composition pairing applies a counter multiplier on top of base strength. This gives players a meaningful rock-paper-scissors layer without turning combat into deck-building.
Categories
| Category | Units | Battlefield Role |
|---|---|---|
| INFANTRY | MILITIA, WARRIOR, INFANTRY | Cheap line troops, baseline |
| RANGED | ARCHER, CROSSBOWMAN | Soften enemy before melee, weak in close quarters |
| CAVALRY | CAVALRY, SCOUT | High mobility, devastating versus ranged and light infantry |
| ANTI_CAVALRY | PIKEMAN | Spears and pikes, designed to break charges |
| SIEGE | SIEGE | Slow, devastating against fortifications |
| MAGIC | BATTLE_MAGE | Magical artillery, ignores armor |
| ELITE | KNIGHT, ELITE | Heavy professional troops, balanced |
| LEGENDARY | DRAGON_KNIGHT, ARCHMAGE, WAR_TITAN | LEGENDARY_WARFARE-gated apex units |
Counter Multipliers
Multipliers applied to attacker strength based on the dominant category of the defending force. A defender army's dominant category is the category with the most units; ties resolve to ANTI_CAVALRY > MAGIC > CAVALRY > RANGED > INFANTRY > SIEGE > ELITE > LEGENDARY.
| Attacker Category | vs INFANTRY | vs RANGED | vs CAVALRY | vs ANTI_CAVALRY | vs SIEGE | vs MAGIC | vs ELITE | vs LEGENDARY |
|---|---|---|---|---|---|---|---|---|
| INFANTRY | 1.00 | 0.85 | 0.90 | 1.00 | 1.10 | 0.95 | 0.85 | 0.75 |
| RANGED | 1.25 | 1.00 | 0.75 | 0.95 | 1.20 | 1.00 | 0.90 | 0.80 |
| CAVALRY | 1.15 | 1.30 | 1.00 | 0.65 | 1.20 | 1.10 | 0.95 | 0.85 |
| ANTI_CAVALRY | 0.95 | 0.90 | 1.50 | 1.00 | 0.85 | 0.95 | 1.00 | 0.95 |
| SIEGE | 0.80 | 0.85 | 0.70 | 0.90 | 1.00 | 0.80 | 0.85 | 0.95 |
| MAGIC | 1.25 | 1.10 | 1.20 | 1.10 | 1.15 | 1.00 | 1.10 | 0.95 |
| ELITE | 1.15 | 1.10 | 1.05 | 1.00 | 1.10 | 0.90 | 1.00 | 0.90 |
| LEGENDARY | 1.25 | 1.20 | 1.15 | 1.05 | 1.10 | 1.05 | 1.10 | 1.00 |
Siege Bonus Against Fortifications
SIEGE units gain an additional +50% strength when attacking a territory with fortificationLevel > 0. This is on top of the counter multiplier and applies per SIEGE unit, not to the whole army.
Combat Strength Formula (Standard Mode, Updated)
1. ATTACKER STRENGTH
For each unit type in attacker.units:
unitContribution = unitStrength[type] * count * counterMultiplier(type, defenderDominantCategory)
if type == SIEGE AND target.fortificationLevel > 0:
unitContribution *= 1.5
baseStrength = SUM(unitContribution)
moraleModifier = .getMoraleModifier(army.morale)
variance = random(STANDARD_COMBAT_VARIANCE_MIN, STANDARD_COMBAT_VARIANCE_MAX)
attackerStrength = baseStrength * moraleModifier * variance
2. DEFENDER STRENGTH (per defending army at territory)
Same formula with defenderDominantCategory swapped for attackerDominantCategory.
Apply terrainBonus + winterDefenseBonus + fortificationBonus on the territory side.
Design intent: a player who recruits only one unit type can be hard-countered by a clever opponent. Mixed armies tend toward neutral multipliers and reward composition planning.
Recruitment
Source: ,
Flow
Player says "recruit 5 militia" (or "recruit 20 archers to army 2", bare "infantry",
"1 unit militia", or Alexa-misheard "intern" -> infantry)
-> (Tier 4b) parses via RECRUIT_PATTERN or BARE_UNIT_PATTERN
-> Parse quantity (default: 5) and unit type from matchedKey
-> Normalize: strip UNIT_ prefix, handle plurals, handle synonyms
-> Validate: unit type exists in UNIT_COSTS, quantity in [1, 10000]
-> Check gold: costPerUnit * quantity (overflow-safe via Math.multiplyExact)
-> Find target army: parse "to/into army N" from the utterance OR the matchedKey, else default to first army
-> .recruitUnits: verify army ownership, deduct gold, add units
Army Selection (Menu Path)
Free-text always works: "recruit 5 warriors into army 2" targets a specific army directly (.handleRecruit parses "into army N" from the utterance or the matchedKey).
When the kingdom fields 2 or more armies and the player opens the recruit MENU (bare "recruit" / "recruit units"), a two-step army picker is shown, mirroring the move/attack pickers:
recruitMenu->showRecruitArmyPicker: one option per army ("army 1", "army 2", ...) carrying the 1-based index via theRECRUIT_PICK_ARMY_<index>matchedKey, plus a reminder of the free-text form.- Picking an army routes back through
handleRecruit->showScopedRecruitMenu: a unit menu scoped to that army. Each unit quick option encodes the army in its matchedKey ("5 MILITIA INTO ARMY 2"), so selecting it recruits into the chosen army; the player can also speak a custom amount ("recruit 10 archers into army 2").
A single-army kingdom keeps the direct recruit menu (no picker). When the player taps a scoped
quick option the chosen army travels in the option matchedKey (5 MILITIA INTO ARMY 2). When
they instead speak a custom quantity or a unit with no scoped quick option (e.g. recruit 8 knights), that utterance routes through Tier 4 free-text NLU which carries no army — so
showScopedRecruitMenu also records the chosen army on the kingdom (pendingRecruitArmyIndex,
non-transient so it survives the cache round-trip) and handleRecruit consumes it one-shot when
no army is named, then clears it. Without this memory the free-text recruit silently fell back to
Army 1 (fb 9070 'barely works'). scenario_test 2201.
Constants
| Constant | Value |
|---|---|
MIN_RECRUIT_QUANTITY |
1 |
MAX_RECRUIT_QUANTITY |
10,000 |
| Default quantity | 5 (if no number specified) |
Voice-Recognition Robustness (Tier 4b NLU)
exposes two recruit patterns so Alexa mishearings do not break recruitment:
| Pattern | Accepts | Example |
|---|---|---|
RECRUIT_PATTERN |
Verb + optional qty + unit (+ optional "to army N") | recruit 5 infantry, hire fifty warriors, recluse units battle mage |
BARE_UNIT_PATTERN |
Optional qty + optional "unit(s)" filler + unit | infantry, militia, 5 militia, 1 unit militia, intern |
Alexa-mishearing and natural-language synonyms live in game_process.process_alternate_synonyms (game_context='BLOOD_LINEAGE', type='LINEAGE_UNIT_SYNONYM' / LINEAGE_BUILDING_SYNONYM') and are resolved at NLU time by LineageSynonymResolver. Adding a new mishearing requires an INSERT and a caffeine_clear_nlu call — no code deploy. Current unit-type synonyms:
| Heard (Alexa) | Routes To | Reason |
|---|---|---|
intern, interns, infant, infants |
INFANTRY | Alexa misrecognizes "infantry" |
recluse (as verb) |
treated as recruit |
Alexa misrecognizes "recruit" |
catapult, trebuchet, siege weapon |
SIEGE | Natural-language aliases |
soldiers, horsemen, riders, troops |
INFANTRY / CAVALRY / MILITIA | Natural-language aliases |
pipemen, pipe men, bike men, night, nights, malaria, malaysia, crossbow men, crossbow, crossbow man |
PIKEMAN / KNIGHT / MILITIA / CROSSBOWMAN | Alexa mishearings, carried in the hardcoded normalizeUnitPluralForm map so they resolve through both the recruit one-shot path and the Tier-4 bare-unit path (findings 911, 846) |
BARE_UNIT_PATTERN only routes when the captured token resolves to a known
unit type, and explicitly rejects menu navigation words (status, help,
back, cancel, menu, end, yes, no) so it cannot hijack
non-recruit commands.
Recruitment Cost Resolution
Resolved — delegates to for all 15 unit types. There is no longer a separate handler-level cost map; the prior WARRIOR/CROSSBOWMAN discrepancy is closed.
Disbanding Armies (finding 914)
Source: +
Over-recruiting had no relief valve — an army's gold upkeep drained the treasury with no way to
dissolve it by voice. disband army (synonyms: dissolve / disperse / dismiss my army) now removes a
force from the field via LineageGameMapData.removeArmy, lifting its upkeep, and returns to the
military menu with options. With two or more armies the handler shows the standard army picker
(PICK_DISBAND_<armyId>); disband army N disbands the Nth army directly (DISBAND_ARMY_<N>, a
1-based index resolved by lookupArmyIdByIndex). The disband army option appears in the military
menu only when the player has at least one army.
Guard: scenario_test 2562 (create army; disband army -> "disbanded" + options).
Movement
Source:
Movement Rules
Default max movement points: 3 per turn
Orthogonal move (N/S/E/W): costs 1 movement point
Diagonal move: costs 2 movement points
Adjacency check: |dx| <= 1 AND |dy| <= 1
Movement is reset at the start of each turn via resetMovementForTurn().
Movement Validation
- Army must exist and be owned by the kingdom
- Army must have movement points remaining (
canMove()) - Target territory must exist
- Target must be adjacent (within 1 tile in both x and y)
- Army must have enough movement points for the move type
On successful move, army position is updated to match the target territory coordinates.
Combat Resolution (Standard Mode)
Source:
Every resolved standard-mode attack logs one row to game_log.combat_metrics (game_type lineage) via GameMetricsLogger.logCombat, mirroring the Darkness Falls and Dark Tower combat instrumentation, so Blood Lineage combat balance (win rate, casualties, unit-category matchups) is queryable the same way. Logging is skipped when no player context is available (e.g. the Global World path in , or a direct unit-test call).
Full Combat Formula
1. ATTACKER STRENGTH
baseStrength = SUM(unitStrength[type] * quantity) for each unit type
moraleModifier = 0.5 + (morale/100) * 0.7
(linear: 50% at morale 0, 120% at morale 100)
attackerStrength = baseStrength * moraleModifier
2. DEFENDER STRENGTH
armyStrength = SUM for each defending army at territory:
SUM(unitStrength[type] * quantity) * moraleModifier
terrainBonus = TERRAIN_DEFENSE_BONUS[terrainType]
defenderStrength = armyStrength + terrainBonus
3. RESOLUTION
attackerWins = attackerStrength > defenderStrength
4. CASUALTIES
If attacker wins:
attacker loses 10% of units
each defender loses 40% of units
If attacker loses:
attacker loses 25% of units
each defender loses 15% of units
5. TERRITORY CAPTURE
If attackerWins AND attacker army is not empty:
territory.ownerKingdomId = attacker.kingdomId
army moves to captured territory
If attackerWins AND attacker army is empty:
"Pyrrhic victory" - territory captured but army destroyed
Morale Modifier Formula
Source:
normalizedMorale = clamp(morale, 0, 100) / 100.0
modifier = 0.5 + (1.2 - 0.5) * normalizedMorale
= 0.5 + 0.7 * normalizedMorale
| Morale | Modifier |
|---|---|
| 0 | 0.50x (50%) |
| 25 | 0.675x |
| 50 | 0.85x |
| 75 | 1.025x |
| 100 | 1.20x (120%) |
Terrain Defense Bonuses (Defender Only)
Source:
| Terrain | Bonus |
|---|---|
| MOUNTAINS | +25 |
| HILLS | +15 |
| FOREST | +10 |
| PLAINS | +0 |
| SWAMP | -5 |
| DESERT | -3 |
Other Combat Constants
| Constant | Value |
|---|---|
FORTIFICATION_BONUS_PER_LEVEL |
+20 per level |
BASE_TERRITORY_DEFENSE |
50 (even without garrison) |
COMBAT_VARIANCE_MIN |
0.9 (-10%) |
COMBAT_VARIANCE_MAX |
1.1 (+10%) |
WINNER_CASUALTY_PERCENT |
10% |
LOSER_CASUALTY_PERCENT |
25% |
PYRRHIC_VICTORY_CASUALTY_PERCENT |
40% |
Casualty Application
Source:
For each unit type in army:
lost = max(0, count * percentLoss / 100)
(integer division -- small armies may lose 0 units)
After casualties, empty armies (0 total units) are removed from the game map.
Combat Variance (RESOLVED 2026-05-22)
Standard / Multiplayer combat applies a +/-5% random variance on each side; Global World combat applies +/-10%. Constants live in :
| Mode | Variance Range | Constants |
|---|---|---|
| Standard / Multiplayer | +/-5% | STANDARD_COMBAT_VARIANCE_MIN, STANDARD_COMBAT_VARIANCE_MAX |
| Global World | +/-10% | COMBAT_VARIANCE_MIN, COMBAT_VARIANCE_MAX |
Applied as: finalStrength = baseStrength * moraleModifier * randomVariance. Strategy still dominates — variance only matters when forces are near-equal. Implemented in and .
How many combat resolutions Lineage has
Three. A fourth unused stub () is deleted.
| Mode | Owner | Casualties | Why it is separate |
|---|---|---|---|
| Local | Attacker 10% win / 25% lose; defenders 40% if the attacker wins, 15% if they hold | Turn-map fight against stationed defenders, with counter categories and terrain | |
| Global | Loser 20–40% of min strength, winner 10–20% of min strength; CONTESTED territory | Persistent-world conquest against base defense, fortification, and garrison | |
| Multiplayer | Loser 50%, winner 25% via | Simultaneous pair resolution, not a map-square attack |
Do not fold these into one loop. The numbers are different because the contexts are different.
Combat Resolution (Global World Mode)
Source:
Global World combat differs from standard mode in several ways:
Differences from Standard Combat
| Aspect | Standard | Global World |
|---|---|---|
| Variance | None | +/-10% random on both sides |
| Casualties | Fixed % (10/25/40) | Variable (10-20% winner, 20-40% loser) |
| Territory transfer | Immediate | Enters CONTESTED state (4 days) |
| Base defense | Not applied | 50 base territory defense |
| Fortification | Not factored in standard attack | Factored via constants |
Global Combat Formula
1. attackerStrength = SUM(unitStrength * count) * moraleModifier
2. defenderStrength = BASE_TERRITORY_DEFENSE (50)
+ fortificationLevel * FORTIFICATION_BONUS_PER_LEVEL (20)
+ terrainBonus
+ SUM(garrison army strengths at territory coordinates)
3. Apply variance:
attackerStrength *= random(0.9, 1.1)
defenderStrength *= random(0.9, 1.1)
4. attackerWins = attackerStrength > defenderStrength
5. Casualties:
loserCasualties = min(attackerStr, defenderStr) * random(0.2, 0.4)
winnerCasualties = min(attackerStr, defenderStr) * random(0.1, 0.2)
Conquest Flow
Player initiates attack on adjacent territory
-> Validate: not own territory, not PLAYER_BASE, not already contested,
no newbie protection (first 5 turns), no peace treaty,
army has strength > 0
-> Resolve combat (with variance)
-> If attacker wins:
-> Race condition check (re-fetch territory state)
-> Territory enters CONTESTED state
-> Contest progresses 25% per day
-> At 100%: ownership transfers
-> If attacker loses:
-> Attack failed, army takes loser casualties
Protection Rules
| Protection | Condition |
|---|---|
| Player base | isPlayerBase() = true -- cannot be attacked |
| Newbie protection | Kingdom currentTurn <= 5 |
| Peace treaty | Active NON_AGGRESSION or ALLIANCE treaty |
| Self-attack | Cannot attack own territory |
| Already contested | Territory already being contested |
Army Upkeep
Source:
totalUpkeep = SUM for each army:
SUM for each unit type:
unitCount * UNIT_UPKEEP[unitType]
Upkeep is calculated per turn using the unified map.
Upkeep Deduction (Implemented)
Source: , (lines 282-298)
Army upkeep is deducted during turn processing after resource production, before food consumption:
1. Calculate total upkeep via lineageArmyService.calculateTotalUpkeep()
2. Compare against gold produced this turn:
netGold = goldProduced - armyUpkeep
3. If netGold >= 0:
production["gold"] = netGold (upkeep absorbed by income)
4. If netGold < 0:
deficit = -netGold
production["gold"] = 0 (no gold income this turn)
Deduct min(deficit, currentGold) from reserves
If reserves < deficit: -10 morale penalty + warning text
Army Food Consumption
Armies also consume food via (Global/MP mode):
armyFoodConsumption = SUM(army.totalUnitCount / 4) per army
This is added to the population food consumption. The SP endTurn() implementation does NOT include army food consumption -- only population-based.
Military Handler Routing
Source:
The handler routes on matchedKey:
| Action | Handler Method |
|---|---|
CREATE_ARMY |
Creates army at capital territory |
MOVE_ARMY |
Moves first army to first adjacent territory |
ATTACK_TERRITORY |
Routes to standard or global attack |
GARRISON |
Stations an army in FORTIFYING stance to hold its province and suppress unrest (army picker for 2+ armies; NLU garrison / fortify / entrench, GARRISON_ARMY_<N>) |
BATTLE_REPORT |
Shows army summary |
| (default) | Shows military menu |
Guild Wars Integration
When a battle results in victory (text contains "victory"), the system records a Guild Wars contribution via .linBattleWon(props, unitCount).
Finding the Enemy — Map Reveal & Attack Guidance (feedback 8041, 8042, 2026-05-31)
A Single-Player player who declared war on Goldcrest reported two blockers: they could not find Goldcrest on the world map, and they could not attack it after declaring war. Root cause was NOT that rival kingdoms lacked territory — LineageWorldInitializer.populateAIKingdoms places each rival capital at a far map corner. The real problems were discoverability and reachability:
- Map reveal — described only the player's own land. It now appends a "Rival realms" line naming each AI kingdom, the compass bearing from the player's capital, and a war flag, so a declared-war rival is locatable. scenario_test 1759.
- Attack guidance — listed only the adjacent (usually unclaimed) tiles, so a far-off rival never appeared. It now also names the distant rival capitals and their bearing ("Distant rivals: Goldcrest lies to the southwest, at war. March your army toward a rival to assault its capital."). scenario_test 1760.
- Reachability — army movement now materializes frontier tiles (see Known Issues row above), so once the player knows which way to march, the army can actually cross the sparse map and reach the rival capital. still requires the attacker to be adjacent to the target, so the engagement resolves through the existing combat path once the army arrives.
These are runtime handler-layer fixes, so they apply to existing saves as well as freshly initialized worlds.
Capital Capture & Surrender Resolution (Phase 0 war loop, 2026-07-17)
Source: -> resolveCapitalFall
Before Phase 0 a won attack only transferred the single captured tile — even when that tile was the rival's capital — so players reported "I captured the capital but they did not surrender." The war loop never resolved. Now, when a standard-mode attack captures a territory that is the defending kingdom's capital (LineageTerritory.isCapital OR the tile equals the defender's capitalTerritoryId), the fall of the capital resolves the war decisively:
- Surrender — the defeated realm bends the knee; the battle report narrates the surrender by name.
- Annexation — every remaining territory the defeated kingdom held is transferred to the conqueror (the capital tile itself was already transferred by the normal capture step).
- Population transfer — the defeated realm's
population(or an estimate ofCONQUERED_SUBJECTS_PER_TERRITORY= 1000 per held territory when no population is tracked) is added to the victor'spopulationand reported as "N subjects swell your realm." - Elimination — the defeated kingdom is marked
isEliminated = trueand itscapitalTerritoryIdcleared, so (checked each turn) folds it into DOMINATION progress and skips it thereafter.
The resolution is symmetric (an AI that captures the player's capital eliminates the player the same way) but is exercised chiefly by the player conquering an AI capital. It only fires on a real capital capture with a non-null, non-self previous owner, so capturing unclaimed frontier tiles is unaffected. Guard: JUnit (capital capture annexes + eliminates + reports population).
Garrison (Phase 0 army command, 2026-07-17)
Source: ; NLU
The menus and loyalty copy told players to "garrison armies on restless territories," but there was no speakable garrison command — garrison fell to NLU FALLBACK. garrison / fortify / entrench now put an army into FORTIFYING stance where it stands. Because counts an army present at a territory as that territory's garrison for unrest/rebellion suppression, a garrisoned army on an owned restless province steadies it and stands ready to defend. Bare garrison uses the shared army picker for 2+ armies (PICK_GARRISON_<id>); garrison army 2 targets a 1-based index (GARRISON_ARMY_<N>). The garrison army option appears in the military menu whenever the player has at least one army.
Locating a Rival by Voice (Phase 0 war loop, 2026-07-17)
Source: ; NLU
where is <faction> capital and where is the enemy capital previously NLU-fell-back — a player could declare a war they could not aim OR find. A LOCATE command now answers a location question about a rival: a named rival ("where is Goldcrest capital") returns its capital's compass bearing, grid coordinates, distance in leagues from the player's seat, and a war flag; a generic target ("the enemy") lists every rival's bearing. This is the spoken twin of the existing view map rival line and the attack-picker's distant-rival guidance. The bearing is computed from the player's capital to each rival's capitalTerritoryId.