feat(m105): pass rank context through battle bridge

This commit is contained in:
2026-09-20 22:29:17 +08:00
parent 87fa498378
commit eda2f51419
7 changed files with 89 additions and 21 deletions
+5 -1
View File
@@ -1153,7 +1153,11 @@ func _run_simulation(company: Dictionary, contract: Dictionary, battle: Dictiona
enemy_meta[key] = row
var region_index := int(contract.get("region_index", 1))
var seed := "%s|%s" % [String(battle.get("operation_id", "")), String(battle.get("snapshot_hash", ""))]
return BattleSimBridge.run(seed, Array(battle.get("front_to_rear_ids", [])), recruits, Array(battle.get("enemy_ids", [])), region_index, enemy_meta)
var rank_context := {
"market_index": int(contract.get("market_index", 1)),
"risk": int(battle.get("risk", 0)),
}
return BattleSimBridge.run(seed, Array(battle.get("front_to_rear_ids", [])), recruits, Array(battle.get("enemy_ids", [])), region_index, enemy_meta, rank_context)
func _presentation_cache_key(battle: Dictionary) -> String:
+7 -7
View File
@@ -62,7 +62,7 @@ const BOOKKEEPING_KINDS := [
const COVERAGE_ORDER := ["knockout", "ranged", "magic", "healing", "guard", "telegraph", "damage"]
static func run(seed: String, formation: Array, recruits: Array, enemy_ids: Array, region_index: int, enemy_meta: Dictionary) -> Dictionary:
static func run(seed: String, formation: Array, recruits: Array, enemy_ids: Array, region_index: int, enemy_meta: Dictionary, rank_context: Dictionary = {}) -> Dictionary:
var ally_rows: Array = []
for index in range(formation.size()):
var recruit_id := String(formation[index])
@@ -70,11 +70,11 @@ static func run(seed: String, formation: Array, recruits: Array, enemy_ids: Arra
var enemy_rows: Array = []
for index in range(enemy_ids.size()):
var enemy_id := String(enemy_ids[index])
enemy_rows.append(_enemy_row(enemy_id, index, region_index, Dictionary(enemy_meta.get(enemy_id, {}))))
enemy_rows.append(_enemy_row(enemy_id, index, region_index, Dictionary(enemy_meta.get(enemy_id, {})), rank_context))
return _run_rows(seed, ally_rows, enemy_rows)
static func run_encounter(seed: String, formation: Array, recruits: Array, encounter: Array, region_index: int, enemy_meta: Dictionary) -> Dictionary:
static func run_encounter(seed: String, formation: Array, recruits: Array, encounter: Array, region_index: int, enemy_meta: Dictionary, rank_context: Dictionary = {}) -> Dictionary:
## M104: an authored encounter row owns the rank for this occurrence. The
## legacy ID-only path above remains unchanged for P10.0 and M103; M80
## contract probes use this path so a row marked elite cannot be silently
@@ -92,7 +92,7 @@ static func run_encounter(seed: String, formation: Array, recruits: Array, encou
meta["class"] = String(entry.get("class", "ordinary"))
if entry.has("level"):
meta["level"] = int(entry.get("level", 1))
enemy_rows.append(_enemy_row(enemy_id, index, region_index, meta))
enemy_rows.append(_enemy_row(enemy_id, index, region_index, meta, rank_context))
return _run_rows(seed, ally_rows, enemy_rows)
@@ -305,11 +305,11 @@ static func _ally_row(recruit_id: String, position: int, recruit: Dictionary) ->
return row
static func _enemy_row(enemy_id: String, position: int, region_index: int, meta: Dictionary) -> Dictionary:
static func _enemy_row(enemy_id: String, position: int, region_index: int, meta: Dictionary, rank_context: Dictionary = {}) -> Dictionary:
var class_id := String(meta.get("class", "ordinary"))
var row := Authoring.enemy_row(enemy_id, class_id, region_index)
var row := Authoring.enemy_row(enemy_id, class_id, region_index, rank_context)
row["id"] = "enemy-%s" % enemy_id
row["position"] = position
row["level"] = maxi(1, int(meta.get("level", region_index + 1)))
row["level"] = maxi(1, int(meta.get("level", row.get("level", 1))))
row["profession_id"] = ""
return row
@@ -0,0 +1,53 @@
extends SceneTree
## M105 P2: the entry bridge accepts the committed market/risk context and
## passes it to enemy authoring without reintroducing region pressure.
const Bridge = preload("res://entry/battle_sim_bridge.gd")
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var recruits := [{
"recruit_id": "R-1", "profession_id": "PF-B01", "trait_id": "", "level": 3,
"might": 11, "finesse": 10, "insight": 9, "vigor": 12, "resolve": 10,
}]
var formation: Array = ["R-1"]
var meta := {"EN-TEST": {"class": "ordinary"}}
var ordinary_r1 := Bridge.run_encounter("m105-bridge-r1", formation, recruits,
[{"enemy_id": "EN-TEST", "class": "ordinary"}], 1, meta,
{"market_index": 12, "risk": 0})
var ordinary_r8 := Bridge.run_encounter("m105-bridge-r8", formation, recruits,
[{"enemy_id": "EN-TEST", "class": "ordinary"}], 8, meta,
{"market_index": 12, "risk": 0})
var ordinary_risk2 := Bridge.run_encounter("m105-bridge-risk2", formation, recruits,
[{"enemy_id": "EN-TEST", "class": "ordinary"}], 8, meta,
{"market_index": 12, "risk": 2})
var hp_r1 := _enemy_max_hp(ordinary_r1)
var hp_r8 := _enemy_max_hp(ordinary_r8)
var hp_risk2 := _enemy_max_hp(ordinary_risk2)
_expect(hp_r1 == 322 and hp_r8 == 322, "same market/risk context crosses regions unchanged")
_expect(hp_risk2 == 364, "risk context reaches the formal rank multiplier")
_expect(str(ordinary_r1.get("outcome", "")) in ["victory", "defeat", "timeout"], "bridge simulation resolves")
if failures.is_empty():
print("ABG_M105_BRIDGE_RANK_CONTEXT_OK ordinary_r1_hp=%d ordinary_r8_hp=%d risk2_hp=%d region_invariant=true risk_forwarded=true" % [hp_r1, hp_r8, hp_risk2])
quit(0)
return
for failure in failures:
push_error("M105 bridge rank context: %s" % failure)
quit(1)
func _enemy_max_hp(result: Dictionary) -> int:
var enemies: Array = Array(result.get("enemies", []))
return int(Dictionary(enemies[0]).get("max_hp", 0)) if not enemies.is_empty() else 0
func _expect(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
+12 -5
View File
@@ -28,6 +28,7 @@ var remove_round10_extra := false
var remove_late_extras := false
var enemy_level_offset := 2
var enemy_region_cap := 0
var legacy_level_model := false
var late_only := false
var region_from := 1
var region_to := 8
@@ -43,6 +44,7 @@ func _run() -> void:
var stride := int(args[1]) if args.size() > 1 else 3
remove_round10_extra = OS.get_environment("ABG_M61_REMOVE_ROUND10_EXTRA") == "1"
remove_late_extras = OS.get_environment("ABG_M61_REMOVE_LATE_EXTRAS") == "1"
legacy_level_model = OS.get_environment("ABG_M61_LEGACY_LEVEL_MODEL") == "1"
late_only = OS.get_environment("ABG_M61_LATE_ONLY") == "1"
var region_from_text := OS.get_environment("ABG_M61_REGION_FROM")
if not region_from_text.is_empty():
@@ -92,9 +94,14 @@ func _run() -> void:
elif remove_round10_extra and sampled >= 10:
encounter = _remove_one_ordinary(encounter, 1)
var before := carry.duplicate(true)
var stat_region := region_index if enemy_region_cap <= 0 else mini(region_index, enemy_region_cap)
var stat_region := region_index if not legacy_level_model or enemy_region_cap <= 0 else mini(region_index, enemy_region_cap)
var meta := enemy_meta
var rank_context := {"market_index": sampled, "risk": 0}
if legacy_level_model:
meta = Ladder.meta_at(enemy_meta, level + enemy_level_offset)
rank_context = {}
var result := Bridge.run_encounter("density|r%d|rnd%d|a%d" % [region_index, sampled, attempt],
formation, recruits, encounter, stat_region, Ladder.meta_at(enemy_meta, level + enemy_level_offset))
formation, recruits, encounter, stat_region, meta, rank_context)
var seconds := float(result.get("ticks", 0)) / 10.0
seconds_sum += seconds
seconds_total += seconds
@@ -122,12 +129,12 @@ func _run() -> void:
wall_late += 1
var mean_all := seconds_total / float(maxi(1, battles))
var mean_decision := (mean_all + POST_BATTLE_SECONDS) / float(DECISIONS_PER_BATTLE)
var variant := "remove_late_extras" if remove_late_extras else "remove_round10_extra" if remove_round10_extra else "baseline"
var variant := "remove_late_extras" if remove_late_extras else "remove_round10_extra" if remove_round10_extra else "authority_rank"
if late_only:
variant = "%s_late_only" % variant
if enemy_level_offset != 2:
if legacy_level_model and enemy_level_offset != 2:
variant = "%s_level_offset_%d" % [variant, enemy_level_offset]
if enemy_region_cap > 0:
if legacy_level_model and enemy_region_cap > 0:
variant = "%s_region_cap_%d" % [variant, enemy_region_cap]
print("ABG_M61_CAMPAIGN_DENSITY_OK variant=%s regions=%d-%d rounds=%s battles=%d timeouts=%d defeats=%d battle_mean=%.1fs battle_max=%.1fs per_decision_mean=%.1fs per_decision_max=%.1fs trivial_late=%d wall_late=%d band=%.0f-%.0f human_share=on_top"
% [variant, region_from, region_to, "7,10,12" if late_only else "sampled", battles, timeouts, defeats, mean_all, seconds_max, mean_decision,
+1 -1
View File
@@ -29,7 +29,7 @@ func _run() -> void:
var enemy_ids := Ladder.enemy_ids(catalog, region_index)
var encounter := ContractRun.ordinary_encounter(region_index, enemy_ids, round_index, 0, party_size)
var result := Bridge.run_encounter("report|r%d|rnd%d" % [region_index, round_index], formation, recruits,
encounter, region_index, Ladder.meta_at(Ladder.enemy_meta(catalog), level + 2))
encounter, region_index, Ladder.enemy_meta(catalog), {"market_index": round_index, "risk": 0})
print("REPORT region=%d round=%d party=%d level=%d outcome=%s seconds=%.1f enemies=%d"
% [region_index, round_index, party_size, level, str(result.get("outcome", "")),
float(result.get("ticks", 0)) / 10.0, encounter.size()])
+7 -5
View File
@@ -6,9 +6,9 @@ extends SceneTree
## instead of a single static party.
##
## Declared ladder: a party enters region `R` at level `3 + 2*(R-1)` and gains
## one level per two cleared rounds. Enemy rank pressure already scales with the
## region inside `battle_stat_authoring.gd`, so the ladder only models what a
## run can field, not the enemy side.
## one level per two cleared rounds. Enemy rank pressure follows the formal
## offer-level/risk contract inside `battle_stat_authoring.gd`, so the ladder
## only models what a run can field.
const ContentRegistry = preload("res://p10_0/content_registry.gd")
const Ladder = preload("res://domain/m80_ladder.gd")
@@ -64,7 +64,8 @@ func _run() -> void:
# A failed attempt is retired at the Market and retried at the
# state it entered with, which keeps the retry rule simple here.
var before := carry.duplicate(true)
var result := Bridge.run_encounter("sweep|r%d|rnd%d|a%d" % [region_index, round_index, attempt], formation, active, encounter, region_index, _meta_at(enemy_meta, level + 2))
var result := Bridge.run_encounter("sweep|r%d|rnd%d|a%d" % [region_index, round_index, attempt], formation, active, encounter, region_index, enemy_meta,
{"market_index": round_index, "risk": 0})
ticks_total += int(result.get("ticks", 0))
survivors_total += _survivors(result)
if str(result.get("outcome", "")) == "victory":
@@ -107,7 +108,8 @@ func _sweep_boss(catalog: Dictionary, region_index: int, enemy_meta: Dictionary,
var muster: Array = [{"enemy_id": str(contract.get("boss_id", "")), "class": "boss"}]
for entry in ContractRun.ordinary_encounter(region_index, enemy_ids, 12, attempt, 6).slice(0, MUSTER_ESCORTS):
muster.append(Dictionary(entry).duplicate(true))
var result := Bridge.run_encounter("sweep|r%d|boss|a%d" % [region_index, attempt], formation, recruits, muster, region_index, _meta_at(enemy_meta, level))
var result := Bridge.run_encounter("sweep|r%d|boss|a%d" % [region_index, attempt], formation, recruits, muster, region_index, enemy_meta,
{"market_index": 12, "risk": 0})
ticks_total += int(result.get("ticks", 0))
survivors_total += _survivors(result)
if str(result.get("outcome", "")) == "victory":
+4 -2
View File
@@ -45,7 +45,8 @@ func _run() -> void:
formation.append(str(Dictionary(recruit).get("recruit_id", "")))
var attempt := int(state.get("attempt", 0))
var encounter := ContractRun.ordinary_encounter(REGION_INDEX, enemy_ids, round_index, attempt, size)
var result := Bridge.run_encounter("m80-contract|round-%d|attempt-%d" % [round_index, attempt], formation, recruits, encounter, REGION_INDEX, enemy_meta)
var result := Bridge.run_encounter("m80-contract|round-%d|attempt-%d" % [round_index, attempt], formation, recruits, encounter, REGION_INDEX, enemy_meta,
{"market_index": round_index, "risk": 0})
var victory := str(result.get("outcome", "")) == "victory"
state = ContractRun.advance(state, victory)
record.append("%02d:%s(%d)" % [round_index, "W" if victory else "L", int(result.get("ticks", 0))])
@@ -61,7 +62,8 @@ func _run() -> void:
var muster: Array = [{"enemy_id": boss_id, "class": "boss"}]
for entry in ContractRun.ordinary_encounter(REGION_INDEX, enemy_ids, 12, 0).slice(0, ContractRun.MUSTER_ESCORTS):
muster.append(Dictionary(entry).duplicate(true))
var boss_result := Bridge.run_encounter("m80-contract|boss", boss_formation, boss_recruits, muster, REGION_INDEX, enemy_meta)
var boss_result := Bridge.run_encounter("m80-contract|boss", boss_formation, boss_recruits, muster, REGION_INDEX, enemy_meta,
{"market_index": 12, "risk": 0})
var boss_outcome := str(boss_result.get("outcome", ""))
# The probe owns the structure: every ordinary round resolves, clears and
# retries land where section 7.2 says, and the boss Muster resolves. How