extends Node # Reproducible Monte Carlo audit. Combat is an analytical estimate, not a movement bot. const GEN = preload("res://scripts/items/item_generator.gd") const BAL = preload("res://scripts/levels/level_balance.gd") const SKILLS = preload("res://scripts/captain/captain_skills.gd") const RECIPES = preload("res://scripts/items/crafting_recipes.gd") const RUNS := 60 var ship: CharacterBody2D var inv: ShipInventory var enemy: CharacterBody2D var rows: Array = [] var finishes: Array = [] var weapon_id := "" var collect := 0.75 var accuracy := 0.75 var shot_demand := 0.0 var mined_this_level := 0 var cash_spent := 0 var ammo_shortages := 0 func item_score(item: ItemInstance) -> float: if item == null: return -1.0 var v := 0.0 for key in ["max_hull", "max_shields", "max_energy", "energy_regeneration", "shield_regeneration", "damage_percent", "critical_chance_percent", "critical_damage_percent", "kinetic_absorption", "energy_absorption", "radiation_absorption", "hull_percent", "shield_percent", "experience_percent", "credits_percent"]: var weight: float = {"energy_regeneration": 10.0, "shield_regeneration": 10.0, "damage_percent": 2.0, "experience_percent": 2.0}.get(key, 1.0) v += item.get_stat_total(key) * weight return v func train() -> void: var priority := ["miner", "explorer", "business_shark", "efficient_magazine", "lucky", "electrician", "reactor_overload", "tactical_maneuver", "power_redirect"] while GameState.captain.learning_points > 0: GameState.captain.prepare_skill_offers() var selected := "" for id in priority: if id in GameState.captain.skill_offers: selected = id break if selected.is_empty() or not GameState.captain.train_skill(selected): break func loot(level: int, tier: int, module_only: bool = false) -> void: var item: ItemInstance if module_only: var pool: Array = GEN.DEFINITIONS.filter(func(d): return d.category != ItemDefinition.Category.WEAPON) item = GEN.generate_loot(level, tier, pool.pick_random(), ship.luck_percent) else: item = GEN.generate_loot(level, tier, null, ship.luck_percent) var equipped := false if item.definition.category == ItemDefinition.Category.WEAPON and item.definition.item_id == weapon_id: if inv.weapon_slots[0].definition.item_id != weapon_id or inv.get_weapon_comparison(item, 0).dps_delta > 0.0: inv.weapon_slots[0] = item equipped = true elif item.definition.category in ShipInventory.MODULE_CATEGORIES: var index: int = ShipInventory.MODULE_CATEGORIES.find(item.definition.category) if item_score(item) > item_score(inv.module_slots[index]): equipped = true if inv.module_slots[index] != null: GameState.captain.parts += 10 inv.module_slots[index] = item elif item.definition.category == ItemDefinition.Category.ACTIVE_EXTENSION: var same := -1 for index in inv.extension_slots.size(): if inv.extension_slots[index] != null and inv.extension_slots[index].definition.item_id == item.definition.item_id: same = index if same >= 0: var old: ItemInstance = inv.extension_slots[same] if (item.is_passive_extension() and item.get_passive_value() > old.get_passive_value()) or (not item.is_passive_extension() and item.extension_cooldown < old.extension_cooldown): inv.extension_slots[same] = item equipped = true GameState.captain.parts += 10 elif inv.extension_slots.has(null): inv.extension_slots[inv.extension_slots.find(null)] = item equipped = true if not equipped: GameState.captain.parts += 10 inv._recalculate_ship_stats() func supplies(level: int) -> void: mined_this_level = 0 var scale := BAL.power_scale(level) for index in randi_range(6, 10): if randf() > collect: continue mined_this_level += 1 var mineral: String = RECIPES.MINERALS.pick_random() GameState.captain.minerals[mineral] += randi_range(5, 7) + roundi(GameState.captain.get_skill_bonus("miner")) var wrecks: int = randi_range(2, 4) + roundi(GameState.captain.get_skill_bonus("explorer")) for index in wrecks: if randf() > collect: continue match preload("res://scripts/levels/exploration_poi.gd").wreck_reward_from_roll(randi_range(0, 99)): "minerals": var mineral: String = RECIPES.MINERALS.pick_random() GameState.captain.minerals[mineral] += randi_range(3, 8) "module": loot(level, 0, true) "missile": inv.missile_ammunition += maxi(1, roundi(scale)) "ammunition": inv.ammunition += randi_range(40, 60) # Rare ruins: mean XP, module generation uses the real loot rules. if randf() < 0.08 * collect: GameState.captain.add_rewards(GameState.captain.encounter_xp(15 * scale, level), 0) loot(level + 1, 2, true) train() func top_up(level: int) -> void: var demand := ceili(shot_demand) var gamma := weapon_id == "gamma_cannon" var stock: int = inv.irradiated_ammunition if gamma else inv.ammunition if weapon_id == "plasma_cannon": return var recipe := "irradiated_ammunition" if gamma else "ammunition" while stock < demand and inv.can_craft(recipe): inv.craft(recipe) stock += 50 var cost: int = inv.get_irradiated_ammunition_buy_cost() if gamma else inv.get_ammunition_buy_cost() var batch := 10 if gamma else 100 while stock < demand and GameState.captain.credits >= cost: GameState.captain.credits -= cost cash_spent += cost stock += batch if stock < demand: ammo_shortages += 1 if gamma: inv.irradiated_ammunition = maxi(0, stock - demand) else: inv.ammunition = maxi(0, stock - demand) func combat(level: int, tier: int) -> Dictionary: enemy.apply_level_settings(level, tier) var item: ItemInstance = inv.weapon_slots[0] var dps: float = inv.get_total_dps() * accuracy var interval: float = item.definition.fire_interval var energy_rate: float = item.get_energy_cost() / interval var sustainable := minf(1.0, ship.energy_regeneration / maxf(0.001, energy_rate)) var profile := DamageTypes.enemy_profile(level, GameState.system_seed) dps *= 1.0 - profile.absorption[item.definition.get_damage_type()] / 100.0 if item.definition.weapon_kind == ItemDefinition.WeaponKind.GAMMA: # Existing comparison includes 1 + .25 * interval; no stacking DoT assumed. pass var regen: float = (enemy.max_hull + enemy.max_shields) * 0.01 var burst_time: float = ship.max_energy / maxf(0.001, energy_rate - ship.energy_regeneration) if energy_rate > ship.energy_regeneration else 100000.0 var ehp: float = enemy.max_hull + enemy.max_shields var seconds := ehp / maxf(0.01, dps - regen) if seconds > burst_time: var remaining := maxf(0.0, ehp - maxf(0.0, dps - regen) * burst_time) seconds = burst_time + remaining / maxf(0.01, dps * sustainable - regen) seconds = minf(3600.0, seconds) var enemy_dps: float = enemy.get_weapon_dps() var resistance: float = ship.absorption[DamageTypes.for_weapon(enemy.weapon_kind)] / 100.0 # Stationary exposure baseline. Movement success must be supplied, not inferred. var incoming := enemy_dps * (1.0 - resistance) var survival: float = (ship.max_hull + ship.max_shields) / maxf(0.01, incoming - ship.shield_regeneration) var used := seconds / interval * (1.0 - GameState.captain.get_skill_bonus("efficient_magazine")) if item.definition.weapon_kind in [ItemDefinition.WeaponKind.MACHINE_GUN, ItemDefinition.WeaponKind.GAMMA] else 0.0 if item.definition.item_id != weapon_id: used = 0.0 return {"seconds": seconds, "survival": survival, "used": used, "sustainable": sustainable, "regen_lock": dps * sustainable <= regen, "enemy_dps": incoming, "required_avoid": 1.0 - clampf((ship.shield_regeneration + (ship.max_hull + ship.max_shields) / maxf(0.01, seconds)) / maxf(0.01, incoming), 0.0, 1.0)} func group_exposure(level: int) -> Dictionary: # Upper-pressure sample: both elites plus up to three normal ships firing together. var tiers: Array[int] = [1, 1] for index in mini(3, BAL.enemy_count(level) - 3): tiers.append(0) var incoming := 0.0 for tier in tiers: enemy.apply_level_settings(level, tier) incoming += enemy.get_weapon_dps() * (1.0 - ship.absorption[DamageTypes.for_weapon(enemy.weapon_kind)] / 100.0) var survival: float = (ship.max_hull + ship.max_shields) / maxf(0.01, incoming - ship.shield_regeneration) return {"survival": survival, "size": tiers.size(), "dps": incoming} func run_trial(profile: String, trial: int) -> void: seed(920001 + trial * 7919) GameState.start_new_run() GameState.captain._skill_rng.seed = 440001 + trial * 3571 weapon_id = "machine_gun" if profile == "machine" else ("gamma_cannon" if profile == "gamma" else "plasma_cannon") collect = 0.3 if profile == "rookie_energy" else 0.75 accuracy = 0.45 if profile == "rookie_energy" else 0.75 inv.weapon_slots.fill(null) inv.module_slots.fill(null) inv.extension_slots.fill(null) inv._add_starter_items() inv.ammunition = ShipInventory.STARTING_AMMUNITION inv.irradiated_ammunition = 0 inv.missile_ammunition = 2 inv.energy_banks = 0 inv._recalculate_ship_stats() cash_spent = 0 ammo_shortages = 0 var all_skills := 0 var all_types := 0 var kills := 0 var earned := 0 for level in range(1, 51): GameState.system_level = level GameState.system_seed = 100000 + trial * 53 + level * 997 # Explore before combat. No spending resources on optional missiles/banks. supplies(level) shot_demand = 0.0 var fight_seconds := 0.0 var boss_seconds := 0.0 var boss_survival := 0.0 var regen_locks := 0 var boss_avoid := 0.0 var normal_immune := 0 var tiers: Array[int] = BAL.enemy_tiers(level) tiers.pop_front() tiers.append(2) for tier in tiers: var c := combat(level, tier) fight_seconds += c.seconds shot_demand += c.used if tier == 2: boss_seconds = c.seconds boss_survival = c.survival boss_avoid = c.required_avoid if tier == 0 and ship.shield_regeneration >= c.enemy_dps: normal_immune += 1 regen_locks += int(c.regen_lock) var reward := GameState.captain.reward_enemy(level, tier, ship.experience_multiplier, ship.credits_multiplier) earned += reward.y kills += 1 train() if all_skills == 0 and GameState.captain.get_spent_learning_points() == 27: all_skills = level if all_types == 0 and GameState.captain.skills.size() == SKILLS.IDS.size(): all_types = level if randf() < 0.1 * collect: inv.energy_banks += 1 if randf() < (0.65 if tier == 0 else (0.9 if tier == 1 else 1.0)) * collect: loot(level, tier) # Add mining shots only for kinetic/radiation builds, same hit accuracy. if weapon_id != "plasma_cannon" and inv.weapon_slots[0].definition.item_id == weapon_id: shot_demand += float(mined_this_level) * 20.0 * BAL.power_scale(level) / maxf(1, inv.weapon_slots[0].get_stat_total("damage")) / accuracy * (1.0 - GameState.captain.get_skill_bonus("efficient_magazine")) var group := group_exposure(level) var before_shortages := ammo_shortages top_up(level) var minerals := 0 for count in GameState.captain.minerals.values(): minerals += int(count) rows.append({"profile": profile, "trial": trial, "level": level, "captain_level": GameState.captain.level, "ranks": GameState.captain.get_spent_learning_points(), "credits": GameState.captain.credits, "earned": earned, "spent": cash_spent, "parts": GameState.captain.parts, "minerals": minerals, "ammunition": inv.ammunition, "gamma": inv.irradiated_ammunition, "missiles": inv.missile_ammunition, "banks": inv.energy_banks, "ammo_demand": shot_demand, "shortage": ammo_shortages > before_shortages, "boss_seconds": boss_seconds, "boss_survival": boss_survival, "boss_required_avoid": boss_avoid, "normal_immune": normal_immune, "fight_seconds": fight_seconds, "regen_locks": regen_locks, "dps": inv.get_total_dps(), "energy_regen": ship.energy_regeneration, "shield_regen": ship.shield_regeneration, "ehp": ship.max_hull + ship.max_shields, "mineral_stocks": GameState.captain.minerals.duplicate(), "group_survival": group.survival, "group_size": group.size, "group_dps": group.dps}) finishes.append({"profile": profile, "trial": trial, "all_skills": all_skills, "all_types": all_types, "shortage_boards": ammo_shortages, "kills": kills}) func _ready() -> void: GameState.start_new_run() ship = preload("res://scenes/game/player_ship.tscn").instantiate() add_child(ship) inv = ship.get_node("ShipInventory") ship.set_physics_process(false) ship.get_node("WeaponController").set_physics_process(false) ship.get_node("ExtensionSystems").set_physics_process(false) enemy = preload("res://scenes/game/enemy_ship.tscn").instantiate() add_child(enemy) enemy.set_physics_process(false) for profile in ["rookie_energy", "energy", "machine", "gamma"]: for trial in RUNS: run_trial(profile, trial) print("SIMULATION_PROFILE_DONE: ", profile) await get_tree().process_frame var output := {"runs_per_profile": RUNS, "profiles": 4, "levels": 50, "rows": rows, "finishes": finishes} var output_path := "res://artifacts/balance_simulation_50_after.json" if "--after-balance" in OS.get_cmdline_user_args() else "res://artifacts/balance_simulation_50.json" var file := FileAccess.open(output_path, FileAccess.WRITE) file.store_string(JSON.stringify(output)) file.close() print("BALANCE_SIMULATION_50: DONE ", rows.size(), " rows") get_tree().quit()