265 lines
13 KiB
GDScript
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()
|