191 lines
7.6 KiB
GDScript
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)),
|
|
}
|