Randoloot/tests/levels/balance_simulation_50.gd
2026-10-17 18:58:40 +02:00

265 lines
13 KiB
GDScript

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()