Files
aetherbound-guild/runtime/domain/contract_run.gd
T

191 lines
7.6 KiB
GDScript

class_name ABGContractRun
extends RefCounted
## M80-B4 slice 1: the contract run structure.
##
## `docs/02_GAMEPLAY_AND_BALANCE.md` section 12: every Contract is twelve
## ordinary Rounds, a boss Muster and one boss. A victory clears the round;
## a defeat increments the market index without advancing `cleared_rounds`
## (section 7.2). Encounter composition is the region's authored grammar:
## region one pressure is reach, adjacency protection, push and the barrage
## screen, so rear reach and elite anchors arrive as the round index grows.
const Simulation = preload("res://domain/battle_simulation.gd")
const Authoring = preload("res://domain/battle_stat_authoring.gd")
const EnemyActions = preload("res://domain/enemy_actions.gd")
const ORDINARY_ROUNDS := 12
## Calibrated in the M80 sweep: four escorts make the Muster the contract peak
## (region 1 clears comfortably, the region 8 Muster is the hardest fight).
const MUSTER_ESCORTS := 4
static func ordinary_encounter(region_index: int, enemy_ids: Array, round_index: int, attempt: int, party_size := 4) -> Array[Dictionary]:
var pool := enemy_ids.duplicate()
if pool.is_empty():
return []
pool.sort()
var rows: Array[Dictionary] = []
var rng := RandomNumberGenerator.new()
rng.seed = hash("encounter|%d|%d|%d" % [region_index, round_index, attempt])
# Size grows with the round and tracks the Company capacity the section-17
# vector grants by that round, so the twelfth round stays a contest instead
# of a routine; elites anchor the late rounds and the rear reach identity is
# guaranteed from round five so reach decisions appear.
var size := 2 + int(floor(float(round_index - 1) / 2.0))
# M80-I10: the late contract leans on the opposition numbers, since the
# party kit (guard, healing, reorder) is worth more than one enemy each.
if round_index >= 7:
size += 1
if round_index >= 10:
size += 1
size = clampi(size, 2, maxi(2, party_size + 2))
for slot in range(size):
var pick := str(pool[rng.randi_range(0, pool.size() - 1)])
rows.append({"enemy_id": pick, "class": "ordinary"})
if round_index >= 6:
rows[rows.size() - 1]["class"] = "elite"
if round_index >= 5:
var rear := _rear_reach_id(pool)
rows[0]["enemy_id"] = rear
rows[0]["class"] = "ordinary"
return rows
static func _rear_reach_id(pool: Array) -> String:
for enemy_id in pool:
var action: Dictionary = Dictionary(EnemyActions.profile(str(enemy_id)).get("action", {}))
if str(action.get("reach", "")) == "line" and not bool(action.get("elite", false)):
return str(enemy_id)
for enemy_id in pool:
var action: Dictionary = Dictionary(EnemyActions.profile(str(enemy_id)).get("action", {}))
if str(action.get("reach", "")) == "line":
return str(enemy_id)
return str(pool[0])
static func enemy_rows(encounter: Array, region_index: int) -> Array[Dictionary]:
var rows: Array[Dictionary] = []
var index := 0
for entry in encounter:
var enemy_id := str(Dictionary(entry).get("enemy_id", ""))
var class_id := str(Dictionary(entry).get("class", "ordinary"))
var row := Authoring.enemy_row(enemy_id, class_id, region_index)
row["id"] = "enemy-%d-%s" % [index, enemy_id]
row["position"] = index
row["profession_id"] = ""
row["profession_table"] = {}
var profile := EnemyActions.profile(enemy_id)
if not profile.is_empty():
row["action"] = Dictionary(profile.get("action", {}))
row["equipment_hooks"] = [EnemyActions.hook_row(enemy_id)]
rows.append(row)
index += 1
return rows
static func boss_row(contract: Dictionary, region_index: int, position: int) -> Dictionary:
var boss_id := str(contract.get("boss_id", ""))
var row := Authoring.enemy_row(boss_id, "boss", region_index)
row["id"] = "boss-%s" % boss_id
row["position"] = position
row["profession_id"] = ""
row["profession_table"] = {}
row["equipment_hooks"] = []
return row
static func run_ordinary_round(contract: Dictionary, region_index: int, allies: Array, enemy_ids: Array, round_index: int, attempt: int, seed: String) -> Dictionary:
var encounter := ordinary_encounter(region_index, enemy_ids, round_index, attempt)
var simulation: ABGBattleSimulation = Simulation.new()
simulation.setup("%s|round-%d|attempt-%d" % [seed, round_index, attempt], allies, enemy_rows(encounter, region_index))
return simulation.run()
static func run_boss(contract: Dictionary, region_index: int, allies: Array, seed: String) -> Dictionary:
var simulation: ABGBattleSimulation = Simulation.new()
simulation.setup("%s|boss" % seed, allies, [boss_row(contract, region_index, 0)])
return simulation.run()
static func muster_rows(contract: Dictionary, region_index: int, enemy_ids: Array, attempt: int) -> Array[Dictionary]:
# The Muster is the boss with its escort, so the twelfth round reads as the
# contract's peak instead of a lone target that a grown Company deletes.
var rows: Array[Dictionary] = [boss_row(contract, region_index, 0)]
var escort: Array = ordinary_encounter(region_index, enemy_ids, 12, attempt).slice(0, MUSTER_ESCORTS)
var escorts := enemy_rows(escort, region_index)
for index in range(escorts.size()):
var row: Dictionary = escorts[index]
row["position"] = index + 1
rows.append(row)
return rows
static func run_boss_with_escort(contract: Dictionary, region_index: int, allies: Array, enemy_ids: Array, attempt: int, seed: String) -> Dictionary:
var simulation: ABGBattleSimulation = Simulation.new()
simulation.setup("%s|muster-%d" % [seed, attempt], allies, muster_rows(contract, region_index, enemy_ids, attempt))
return simulation.run()
static func apply_carry(recruits: Array, carry: Dictionary) -> Array:
# Returns the recruit rows with the carried current HP/guard folded in.
var rows: Array = []
for recruit in recruits:
var entry: Dictionary = Dictionary(recruit).duplicate(true)
var recruit_id := str(entry.get("recruit_id", entry.get("id", "")))
if carry.has(recruit_id):
var state: Dictionary = Dictionary(carry[recruit_id])
entry["hp"] = int(state.get("hp", 0))
entry["guard"] = int(state.get("guard", 0))
if bool(state.get("ready_with_priority", false)):
entry["ready_with_priority"] = true
rows.append(entry)
return rows
static func carry_from(result: Dictionary) -> Dictionary:
# Current HP/guard of the living after a battle, keyed by recruit id.
var carry := {}
for ally in Array(result.get("allies", [])):
var entry: Dictionary = Dictionary(ally)
if bool(entry.get("fallen", false)):
continue
var ally_id := str(entry.get("id", ""))
var recruit_id := ally_id.trim_prefix("ally-")
var state := {"hp": int(entry.get("hp", 0)), "guard": int(entry.get("guard", 0))}
if bool(entry.get("ready_with_priority", false)):
state["ready_with_priority"] = true
carry[recruit_id] = state
return carry
static func casualties(result: Dictionary) -> Array[String]:
var fallen: Array[String] = []
for ally in Array(result.get("allies", [])):
var entry: Dictionary = Dictionary(ally)
if bool(entry.get("fallen", false)):
fallen.append(str(entry.get("id", "")).trim_prefix("ally-"))
return fallen
static func advance(state: Dictionary, victory: bool) -> Dictionary:
var next: Dictionary = state.duplicate(true)
if victory:
next["cleared_rounds"] = int(next.get("cleared_rounds", 0)) + 1
next["attempt"] = 0
else:
next["market_index"] = int(next.get("market_index", 0)) + 1
next["attempt"] = int(next.get("attempt", 0)) + 1
return next
static func state_after_clear(contract: Dictionary, cleared_rounds: int) -> Dictionary:
return {
"contract_id": str(contract.get("id", "")),
"round_count": int(contract.get("round_count", ORDINARY_ROUNDS)),
"cleared_rounds": cleared_rounds,
"market_index": 0,
"attempt": 0,
"boss_muster_due": cleared_rounds >= int(contract.get("round_count", ORDINARY_ROUNDS)),
}