diff --git a/runtime/entry/battle_service.gd b/runtime/entry/battle_service.gd index 2e0697e0..775f8d39 100644 --- a/runtime/entry/battle_service.gd +++ b/runtime/entry/battle_service.gd @@ -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: diff --git a/runtime/entry/battle_sim_bridge.gd b/runtime/entry/battle_sim_bridge.gd index 95e3247e..71c92924 100644 --- a/runtime/entry/battle_sim_bridge.gd +++ b/runtime/entry/battle_sim_bridge.gd @@ -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 diff --git a/runtime/tests/battle/m105_bridge_rank_context_test.gd b/runtime/tests/battle/m105_bridge_rank_context_test.gd new file mode 100644 index 00000000..216bc1f1 --- /dev/null +++ b/runtime/tests/battle/m105_bridge_rank_context_test.gd @@ -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) diff --git a/runtime/tools/m61_campaign_density_probe.gd b/runtime/tools/m61_campaign_density_probe.gd index d472ec09..7a486b19 100644 --- a/runtime/tools/m61_campaign_density_probe.gd +++ b/runtime/tools/m61_campaign_density_probe.gd @@ -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, diff --git a/runtime/tools/m61_late_round_report.gd b/runtime/tools/m61_late_round_report.gd index 32261a58..255a6dcf 100644 --- a/runtime/tools/m61_late_round_report.gd +++ b/runtime/tools/m61_late_round_report.gd @@ -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()]) diff --git a/runtime/tools/m80_balance_sweep.gd b/runtime/tools/m80_balance_sweep.gd index c13efc6d..4bd79807 100644 --- a/runtime/tools/m80_balance_sweep.gd +++ b/runtime/tools/m80_balance_sweep.gd @@ -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": diff --git a/runtime/tools/m80_contract_run_probe.gd b/runtime/tools/m80_contract_run_probe.gd index 0fbad410..305e55dc 100644 --- a/runtime/tools/m80_contract_run_probe.gd +++ b/runtime/tools/m80_contract_run_probe.gd @@ -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