Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen, drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall, ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73, M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
20 KiB
GDScript
344 lines
20 KiB
GDScript
extends Node
|
|
## M2 rules: heroes (XP, talents, three item slots), attack champions (book n -> fight n+1)
|
|
## and the boss grotto (contest, failure, success, one deployment, lock, bounty).
|
|
## Gold granted below is a test fixture on drafts only.
|
|
## Run: godot --headless --path . res://tests/luneraria/test_lune_m2.tscn
|
|
var checks: int = 0
|
|
var failures: Array[String] = []
|
|
|
|
func check(ok: bool, label: String) -> void:
|
|
checks += 1
|
|
if not ok:
|
|
failures.append(label)
|
|
printerr("M2_FAIL: ", label)
|
|
|
|
func _ready() -> void:
|
|
call_deferred("run")
|
|
|
|
func new_match(seed: int = 21) -> LuneMatch:
|
|
var state := LuneMatch.new()
|
|
state.setup({"seed": seed, "human_slot": -1, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]})
|
|
return state
|
|
|
|
func grant(state: LuneMatch, slot: int, gold: int) -> void:
|
|
state.players[slot].draft.gold = int(state.players[slot].draft.gold) + gold
|
|
|
|
func run_round(state: LuneMatch, planner: Callable = Callable()) -> void:
|
|
if planner.is_valid():
|
|
planner.call(state)
|
|
state.begin_combat()
|
|
state.finish_combat()
|
|
|
|
func run() -> void:
|
|
_test_heroes_and_items()
|
|
_test_xp_and_persistence()
|
|
_test_champions()
|
|
_test_grotto_withdrawal_and_contest()
|
|
_test_boss_success_lock_and_deployment()
|
|
_test_boss_failure_restores()
|
|
_test_bots_m2()
|
|
_test_no_legacy_economy()
|
|
_test_lane_loss()
|
|
var report := {"checks": checks, "failures": failures}
|
|
var file := FileAccess.open("res://docs/luneraria/logs/m2-tests.json", FileAccess.WRITE)
|
|
file.store_string(JSON.stringify(report, "\t"))
|
|
file.close()
|
|
print("LUNE_M2_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()])
|
|
get_tree().quit(0 if failures.is_empty() else 1)
|
|
|
|
func _test_heroes_and_items() -> void:
|
|
var state := new_match()
|
|
grant(state, 0, 5000)
|
|
var gold: int = state.gold_of(0)
|
|
check(state.submit(0, {"type": "hero_buy", "hero_id": "held_ringborke"}).ok and state.gold_of(0) == gold - 260, "hero costs exactly 260")
|
|
check(not state.submit(0, {"type": "hero_buy", "hero_id": "held_funkenlanze"}).ok, "only one active hero per player")
|
|
state.submit(0, {"type": "buy", "type_id": "g01"})
|
|
var troop_uid: String = state.players[0].draft.roster[0].uid
|
|
check(not state.submit(0, {"type": "item_buy", "item_id": "glutklinge", "uid": troop_uid}).ok, "normal troops carry no items")
|
|
check(not state.players[0].draft.roster[0].has("items"), "troop entries have no inventory")
|
|
var hero_uid: String = state.players[0].draft.hero.uid
|
|
check(state.submit(0, {"type": "item_buy", "item_id": "glutklinge", "uid": hero_uid}).ok, "hero buys an item")
|
|
check(not state.submit(0, {"type": "item_buy", "item_id": "glutklinge"}).ok, "items are unique per hero")
|
|
check(not state.submit(0, {"type": "item_buy", "item_id": "kristallsplitter"}).ok, "item requirement (level 3) enforced")
|
|
check(state.submit(0, {"type": "item_buy", "item_id": "rankenmantel"}).ok and state.submit(0, {"type": "item_buy", "item_id": "basaltschild"}).ok, "second and third slot")
|
|
check(not state.submit(0, {"type": "item_buy", "item_id": "funkenfokus"}).ok, "three item slots at most")
|
|
check(not state.submit(0, {"type": "talent", "tier": 0, "choice": 0}).ok, "talent needs hero level 3")
|
|
var definition: Dictionary = LuneContent.hero_definition(state.players[0].draft.hero)
|
|
check(is_equal_approx(float(definition.max_hp), 2200.0 + 350.0) and is_equal_approx(float(definition.damage_bonus), .18) and is_equal_approx(float(definition.reduction_bonus), .10), "item bonuses stack into the hero definition")
|
|
var gold_before_sell: int = state.gold_of(0)
|
|
state.submit(0, {"type": "item_sell", "item_id": "basaltschild"})
|
|
check(state.gold_of(0) == gold_before_sell + 180, "item bought this planning refunds fully")
|
|
state.submit(0, {"type": "item_buy", "item_id": "basaltschild"})
|
|
state.begin_combat()
|
|
state.finish_combat()
|
|
var hero: Dictionary = state.players[0].hero
|
|
check(hero.items == ["glutklinge", "rankenmantel", "basaltschild"], "items persist after the round")
|
|
gold_before_sell = state.gold_of(0)
|
|
state.submit(0, {"type": "item_sell", "item_id": "basaltschild"})
|
|
check(state.gold_of(0) == gold_before_sell + 90, "committed item sells for 50 %")
|
|
check(not state.submit(0, {"type": "hero_cancel"}).ok, "committed hero cannot be returned for gold")
|
|
# Caps: two reduction sources plus a talent stay capped at 35 %.
|
|
var probe := LuneUnit.new()
|
|
probe.def = {"reduction_bonus": .40, "ability": ""}
|
|
check(is_equal_approx(probe.damage_reduction(), .35), "damage reduction capped at 35 %")
|
|
probe.def = {"attack_speed_bonus": .9}
|
|
check(probe.attack_speed_factor() <= 1.5 + .0001, "attack tempo capped at +50 %")
|
|
|
|
func _test_xp_and_persistence() -> void:
|
|
var state := new_match(22)
|
|
grant(state, 1, 3000)
|
|
state.submit(1, {"type": "hero_buy", "hero_id": "held_quellgeflecht"})
|
|
for i: int in range(3):
|
|
state.submit(1, {"type": "buy", "type_id": "w03"})
|
|
var hero_uid: String = state.players[1].draft.hero.uid
|
|
state.begin_combat()
|
|
var spawned: LuneUnit = null
|
|
for unit: LuneUnit in state.combat.units:
|
|
if unit.persistent_uid == hero_uid:
|
|
spawned = unit
|
|
check(spawned != null and spawned.kind == "hero" and spawned.lane == "mid", "hero fights on its lane as its own kind")
|
|
state.finish_combat()
|
|
var participation: int = LuneContent.XP_PARTICIPATION + LuneContent.XP_PER_ROUND * 1
|
|
var xp: int = int(state.players[1].hero.xp)
|
|
check(xp > participation, "supporting hero near fights earns more than participation XP (%d > %d)" % [xp, participation])
|
|
# Kills are not needed: a hero far away from any death still earns the participation share.
|
|
var lonely := new_match(23)
|
|
grant(lonely, 0, 3000)
|
|
lonely.submit(0, {"type": "hero_buy", "hero_id": "held_funkenlanze"})
|
|
lonely.begin_combat()
|
|
for unit: LuneUnit in lonely.combat.units:
|
|
if unit.kind == "hero":
|
|
unit.position = Vector2(-34, -34)
|
|
lonely.finish_combat()
|
|
check(int(lonely.players[0].hero.xp) >= participation, "participation XP without any kill")
|
|
# Level, talent and persistence across rounds.
|
|
state.players[1].hero.xp = 290
|
|
state.players[1].draft = state._snapshot(state.players[1])
|
|
check(LuneContent.level_for(290) == 3 and state.submit(1, {"type": "talent", "tier": 0, "choice": 1}).ok, "talent tier 1 at level 3")
|
|
check(not state.submit(1, {"type": "talent", "tier": 0, "choice": 0}).ok, "talent choice is final")
|
|
check(not state.submit(1, {"type": "talent", "tier": 1, "choice": 0}).ok, "tier 2 needs level 6")
|
|
state.begin_combat()
|
|
var healthy: bool = false
|
|
var talent_live: bool = false
|
|
for unit: LuneUnit in state.combat.units:
|
|
if unit.persistent_uid == hero_uid:
|
|
healthy = unit.hp == unit.max_hp and int(unit.def.level) == 3
|
|
talent_live = unit.has_talent("weite_quelle")
|
|
unit.hp = 0.0
|
|
state.finish_combat()
|
|
check(healthy and talent_live, "hero starts healed with level and talent")
|
|
check(state.players[1].hero.talents == ["weite_quelle"] and int(state.players[1].hero.xp) >= 290, "hero keeps XP and talents after dying")
|
|
var fresh := new_match(22)
|
|
check(fresh.players[1].hero.is_empty() and fresh.players[1].roster.is_empty(), "new match starts without heroes or XP")
|
|
|
|
func _test_champions() -> void:
|
|
var state := new_match(24)
|
|
grant(state, 0, 1000)
|
|
var gold: int = state.gold_of(0)
|
|
check(state.submit(0, {"type": "champion_book", "champion_id": "ch_belagerung", "lane": "bot"}).ok and state.gold_of(0) == gold - 180, "booking costs the champion price")
|
|
var booking_uid: String = state.players[0].draft.new_bookings[0].uid
|
|
state.submit(0, {"type": "champion_book", "champion_id": "ch_druck", "lane": "top"})
|
|
check(not state.submit(0, {"type": "champion_book", "champion_id": "ch_schutz", "lane": "top"}).ok, "at most two bookings per round")
|
|
state.submit(0, {"type": "champion_cancel", "uid": state.players[0].draft.new_bookings[1].uid})
|
|
check(state.players[0].draft.new_bookings.size() == 1, "fresh booking can be cancelled")
|
|
state.begin_combat()
|
|
var in_round_one: int = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion").size()
|
|
check(in_round_one == 0, "a booking never fights in the round it was bought")
|
|
state.finish_combat()
|
|
check(state.round_number == 2 and state.announcements().any(func(a: Dictionary) -> bool: return a.kind == "champion" and a.lane == "bot" and a.team == 0), "booking is announced publicly for round 2")
|
|
check(not state.submit(0, {"type": "champion_cancel", "uid": booking_uid}).ok, "committed booking cannot be moved or refunded")
|
|
state.begin_combat()
|
|
var champions: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion")
|
|
check(champions.size() == 1 and champions[0].team == 0 and champions[0].lane == "bot" and str(champions[0].def.champion_id) == "ch_belagerung", "round 2 fields the champion for Blau on Bottom")
|
|
# Kill it for a clean bounty check.
|
|
var champion: LuneUnit = champions[0]
|
|
champion.hp = 0.0
|
|
state.combat.step()
|
|
var bounty: int = LuneContent.CHAMPION_BOUNTY + LuneContent.CHAMPION_BOUNTY_PER_ROUND * 2
|
|
var events: Array = state.combat.events.filter(func(e: Dictionary) -> bool: return e.kind == "champion")
|
|
check(events.size() == 1 and int(events[0].bounty) == bounty, "champion death recorded once with its bounty")
|
|
var before: Array = state.players.map(func(p: Dictionary) -> int: return int(p.gold))
|
|
state.finish_combat()
|
|
var rot_gain: int = 0
|
|
for slot: int in range(3, 6):
|
|
rot_gain += int(state.last_settlement.gains[slot])
|
|
check(rot_gain >= bounty, "defenders receive the champion bounty")
|
|
check(state.players[0].bookings.is_empty(), "champion is consumed after its round")
|
|
state.begin_combat()
|
|
check(state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion").is_empty(), "no champion in round 3")
|
|
state.finish_combat()
|
|
# Siege focus: a siege champion next to a tower and a monster attacks the tower.
|
|
var map := LuneMap.new()
|
|
var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1)
|
|
var tower: LuneUnit = combat.add_building(map.building_def("rot_mid_outer"), 6000.0)
|
|
var siege: LuneUnit = combat.spawn("champion", 0, "mid", LuneContent.champion_definition("ch_belagerung", 1, LuneBalance.default_balance()), 1.0, 0.0)
|
|
siege.position = tower.position + Vector2(-3.0, 0)
|
|
var monster: LuneUnit = combat.spawn("monster", 1, "mid", DataCatalog.ENEMIES["e01"].duplicate(true), 1.0, 0.0)
|
|
monster.position = siege.position + Vector2(-1.5, 0)
|
|
# Targetless units search on their staggered step; four steps cover every offset.
|
|
for i: int in range(LuneCombat.RETARGET):
|
|
combat.step()
|
|
check(siege.target == tower, "siege champion prefers the attackable tower")
|
|
|
|
func _expedition(state: LuneMatch, slot: int, types: Array, hero: String = "") -> void:
|
|
grant(state, slot, 6000)
|
|
var ids: Array[String] = state.base_ids(state.players[slot].bund)
|
|
for type_index: int in types:
|
|
state.submit(slot, {"type": "buy", "type_id": ids[type_index]})
|
|
var entry: Dictionary = state.players[slot].draft.roster[-1]
|
|
state.submit(slot, {"type": "upgrade", "uid": entry.uid})
|
|
state.submit(slot, {"type": "assign", "uid": entry.uid, "lane": "grotto"})
|
|
if not hero.is_empty():
|
|
state.submit(slot, {"type": "hero_buy", "hero_id": hero})
|
|
state.submit(slot, {"type": "assign", "uid": state.players[slot].draft.hero.uid, "lane": "grotto"})
|
|
|
|
func _test_grotto_withdrawal_and_contest() -> void:
|
|
var state := new_match(25)
|
|
_expedition(state, 1, [1, 1, 0, 2])
|
|
state.submit(1, {"type": "buy", "type_id": "w01"})
|
|
_expedition(state, 4, [1])
|
|
state.begin_combat()
|
|
var grotto_units: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.zone == "grotto" and u.team != 2)
|
|
var mid_troops: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "troop" and u.team == 0 and u.lane == "mid")
|
|
check(grotto_units.size() == 5 and mid_troops.size() == 1, "grotto troops leave the Mid lane (4 in grotto for Blau, 1 on Mid)")
|
|
check(state.combat.grotto_boss != null and state.combat.grotto_boss.dormant, "boss waits while two expeditions are present")
|
|
var boss_hp: float = state.combat.grotto_boss.hp
|
|
var woke_step: int = -1
|
|
var zones_stable: bool = true
|
|
while state.phase == LuneMatch.Phase.COMBAT:
|
|
state.combat.step()
|
|
for unit: LuneUnit in grotto_units:
|
|
zones_stable = zones_stable and unit.zone == "grotto"
|
|
if woke_step < 0 and not state.combat.grotto_boss.dormant:
|
|
woke_step = state.combat.steps
|
|
var rot_alive: bool = state.combat.units.any(func(u: LuneUnit) -> bool: return u.alive and u.zone == "grotto" and u.team == 1)
|
|
check(not rot_alive and is_equal_approx(state.combat.grotto_boss.hp, boss_hp), "boss wakes untouched once only one expedition survives")
|
|
if state.combat.finished:
|
|
state._settle()
|
|
check(woke_step > 0 and zones_stable, "expedition stays in the grotto for the whole combat phase")
|
|
|
|
func _test_boss_success_lock_and_deployment() -> void:
|
|
var state := new_match(26)
|
|
for i: int in range(5):
|
|
run_round(state)
|
|
check(state.round_number == 6, "reached round 6")
|
|
_expedition(state, 1, [1, 1, 1, 0, 2, 2, 2], "held_ringborke")
|
|
state.begin_combat()
|
|
state.finish_combat()
|
|
check(state.last_settlement.boss == "won", "strong expedition conquers the boss in round 6")
|
|
var claim: Dictionary = state.boss.claim
|
|
check(claim.team == 0 and int(claim.won_round) == 6 and int(claim.deploy_round) == 7 and int(claim.chooser_slot) == 1 and claim.lane == "mid", "claim for Blau: deploy in 7, chooser is the contributing Mid player")
|
|
check(int(state.boss.next_available_round) == 10, "grotto available again from round 10")
|
|
var repeated: String = state._settle_boss()
|
|
check(repeated == "repeat" and int(state.boss.next_available_round) == 10 and int(state.boss.claim.get("deploy_round", -1)) == 7, "repeating the settlement signal creates no second claim and keeps the first")
|
|
check(state.announcements().any(func(a: Dictionary) -> bool: return a.kind == "boss" and a.team == 0 and a.lane == "mid"), "deployment announced with lane in the next planning")
|
|
check(not state.submit(4, {"type": "boss_lane", "lane": "top"}).ok and not state.submit(0, {"type": "boss_lane", "lane": "top"}).ok, "only the chooser may redirect the boss")
|
|
check(state.submit(1, {"type": "boss_lane", "lane": "top"}).ok, "chooser redirects the boss to Top")
|
|
var entry_uid: String = state.players[1].roster[0].uid
|
|
check(not state.submit(1, {"type": "assign", "uid": entry_uid, "lane": "grotto"}).ok, "grotto locked in round 7")
|
|
state.submit(1, {"type": "assign", "uid": entry_uid, "lane": "mid"})
|
|
state.begin_combat()
|
|
var bosses: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss")
|
|
check(bosses.size() == 1 and bosses[0].team == 0 and bosses[0].lane == "top" and bosses[0].zone == "lane", "round 7: exactly one boss marches for Blau on Top")
|
|
var start_progress: float = bosses[0].progress
|
|
for i: int in range(200):
|
|
state.combat.step()
|
|
check(bosses[0].progress > start_progress + 5.0 or not bosses[0].alive, "the boss advances towards Rot's Ancient")
|
|
bosses[0].hp = 0.0
|
|
state.combat.step()
|
|
var bounty: int = LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * 7
|
|
state.finish_combat()
|
|
var gains: Array = state.last_settlement.gains
|
|
var rot_boss_part: bool = gains[3] + gains[4] + gains[5] >= bounty and abs(gains[3] - gains[4]) <= 1 + 60 and bounty > 10 * 30
|
|
check(rot_boss_part, "Rot receives the high boss bounty (%d), split in the team" % bounty)
|
|
check(state.boss.claim.is_empty(), "claim consumed after the deployment round")
|
|
for locked_round: int in [8, 9]:
|
|
check(state.round_number == locked_round and not state.grotto_available(), "grotto locked in round %d" % locked_round)
|
|
# Fixture: keep all buildings standing so the strong test army cannot end the match early.
|
|
for id: String in state.building_hp:
|
|
state.building_hp[id] = float(state.map.building_def(id).max_hp)
|
|
run_round(state)
|
|
check(state.round_number == 10 and state.grotto_available(), "grotto open again in round 10")
|
|
state.begin_combat()
|
|
check(state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss").is_empty(), "no second deployment")
|
|
state.finish_combat()
|
|
|
|
func _test_boss_failure_restores() -> void:
|
|
var state := new_match(27)
|
|
grant(state, 2, 200)
|
|
state.submit(2, {"type": "buy", "type_id": "g01"})
|
|
state.submit(2, {"type": "assign", "uid": state.players[2].draft.roster[0].uid, "lane": "grotto"})
|
|
state.begin_combat()
|
|
var first_hp: float = state.combat.grotto_boss.max_hp
|
|
state.finish_combat()
|
|
check(state.last_settlement.boss == "failed" and state.boss.claim.is_empty() and state.grotto_available(), "weak expedition fails without reward or lock")
|
|
state.submit(2, {"type": "assign", "uid": state.players[2].roster[0].uid, "lane": "grotto"})
|
|
state.begin_combat()
|
|
check(state.combat.grotto_boss.hp == state.combat.grotto_boss.max_hp and state.combat.grotto_boss.max_hp >= first_hp, "next attempt faces a fully restored boss")
|
|
state.finish_combat()
|
|
var no_gold: bool = true
|
|
for event: Dictionary in state.combat.events:
|
|
if event.zone == "grotto":
|
|
no_gold = no_gold and int(event.bounty) == 0
|
|
check(no_gold, "grotto deaths pay no gold")
|
|
|
|
func _test_bots_m2() -> void:
|
|
var state := new_match(28)
|
|
var heroes: int = 0
|
|
var bookings: int = 0
|
|
var grotto: int = 0
|
|
for round_index: int in range(12):
|
|
if state.phase == LuneMatch.Phase.OVER:
|
|
break
|
|
for slot: int in range(6):
|
|
LuneBot.plan(state, slot)
|
|
for player: Dictionary in state.players:
|
|
bookings += (player.draft.new_bookings as Array).size()
|
|
for entry: Dictionary in player.draft.roster:
|
|
grotto += int(entry.lane == "grotto")
|
|
state.begin_combat()
|
|
state.finish_combat()
|
|
for player: Dictionary in state.players:
|
|
heroes += int(not (player.hero as Dictionary).is_empty())
|
|
check(heroes >= 3, "bots recruit heroes (%d)" % heroes)
|
|
check(bookings >= 1, "bots book attack champions (%d)" % bookings)
|
|
check(grotto >= 1, "a bot sends an expedition to the grotto (%d troop assignments)" % grotto)
|
|
|
|
func _test_no_legacy_economy() -> void:
|
|
# Working assumption E: the Saumwacht worker/spark economy must not run in this mode.
|
|
var phase_before: int = GameState.phase
|
|
var legacy_before: Dictionary = EconomyManager.get_team(0)
|
|
var state := new_match(29)
|
|
for i: int in range(3):
|
|
for slot: int in range(6):
|
|
LuneBot.plan(state, slot)
|
|
state.begin_combat()
|
|
state.finish_combat()
|
|
WaveManager.advance(1.0)
|
|
check(GameState.phase == phase_before and EconomyManager.get_team(0) == legacy_before, "legacy phase, workers and sparks stay untouched")
|
|
var has_spark_fields: bool = false
|
|
for player: Dictionary in state.players:
|
|
has_spark_fields = has_spark_fields or player.has("sparks") or player.has("workers")
|
|
check(not has_spark_fields, "Luneraria players have gold only, no sparks or workers")
|
|
|
|
func _test_lane_loss() -> void:
|
|
# A player whose lane is broken is not eliminated: they keep planning, defending and helping.
|
|
var state := new_match(30)
|
|
state.building_hp["blau_top_outer"] = 0.0
|
|
state.building_hp["blau_top_inner"] = 0.0
|
|
state.players[0].draft = state._snapshot(state.players[0])
|
|
check(state.submit(0, {"type": "buy", "type_id": "g03"}).ok, "lane-less player can still buy")
|
|
check(state.submit(0, {"type": "assign", "uid": state.players[0].draft.roster[0].uid, "lane": "mid"}).ok, "and support another lane")
|
|
state.submit(0, {"type": "buy", "type_id": "g01"})
|
|
state.begin_combat()
|
|
var top: int = 0
|
|
var mid: int = 0
|
|
for unit: LuneUnit in state.combat.units:
|
|
if unit.owner_slot == 0:
|
|
top += int(unit.lane == "top")
|
|
mid += int(unit.lane == "mid")
|
|
check(top == 1 and mid == 1, "troops fight on the broken lane and on the supported lane")
|
|
check(LuneMap.is_attackable(state.map.building_def("blau_base_1"), state.combat.alive_map()), "the broken lane opens Blau's base towers")
|
|
state.finish_combat()
|
|
check(state.phase != LuneMatch.Phase.OVER or state.winner != LuneMatch.ONGOING, "the match continues or ends only by an Ancient")
|
|
check(state.players[0].roster.size() == 2 and int(state.last_settlement.income) > 0, "lane-less player still owns troops and earns income")
|