feat(@projects/@magic-civilization): ✨ add mcts strategic override logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
91b6386f23
commit
c02c0767ab
7 changed files with 133 additions and 10 deletions
|
|
@ -13,6 +13,8 @@ evidence:
|
|||
- src/game/engine/src/autoloads/event_bus.gd
|
||||
- src/game/engine/tests/unit/test_chronicle_coverage.gd
|
||||
- src/game/engine/tests/unit/test_chronicle_filter_and_click.gd
|
||||
- src/game/engine/scenes/tests/chronicle_filter_proof.tscn
|
||||
- ~/Desktop/magic_civ_chronicle_filter.png
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ func _ready() -> void:
|
|||
)
|
||||
panel._is_processing = false
|
||||
panel._show_log()
|
||||
# Prevent the 3-second auto-dismiss timer from hiding the log during capture.
|
||||
panel._dismiss_timer.stop()
|
||||
|
||||
|
||||
func _seed_players() -> void:
|
||||
|
|
|
|||
|
|
@ -65,7 +65,10 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void:
|
|||
ctrl.set_rollout_budget(MCTS_ROLLOUT_COUNT)
|
||||
ctrl.set_rollout_depth(MCTS_ROLLOUT_DEPTH)
|
||||
|
||||
var json: String = _build_game_state_json(player)
|
||||
var data_dir: String = ProjectSettings.globalize_path(
|
||||
"res://public/games/age-of-dwarves/data"
|
||||
)
|
||||
var json: String = _build_game_state_json(player, ctrl, data_dir)
|
||||
# Seed from turn + player index for determinism: same (turn, player) pair
|
||||
# always produces the same MCTS path given the same game state.
|
||||
var seed: int = GameState.turn_number * 1000 + player.index
|
||||
|
|
@ -81,7 +84,11 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void:
|
|||
|
||||
## Build a minimal Rust-compatible GameState JSON from the live GDScript state.
|
||||
## Uses explicit string concatenation to avoid GDScript % operator edge cases.
|
||||
static func _build_game_state_json(_focal: RefCounted) -> String:
|
||||
## `ctrl` is a live GdMcTreeController used to resolve per-clan ScoringWeights.
|
||||
## `data_dir` is the OS filesystem path to the game data directory.
|
||||
static func _build_game_state_json(
|
||||
_focal: RefCounted, ctrl: RefCounted, data_dir: String
|
||||
) -> String:
|
||||
var player_jsons: PackedStringArray = PackedStringArray()
|
||||
for p: Variant in GameState.players:
|
||||
if p == null:
|
||||
|
|
@ -119,12 +126,20 @@ static func _build_game_state_json(_focal: RefCounted) -> String:
|
|||
var cap_pos: String = (city_pos_strs[0]
|
||||
if not city_pos_strs.is_empty()
|
||||
else "null")
|
||||
# Resolve per-clan ScoringWeights so MCTS rollouts use clan-specific values.
|
||||
# Falls back to "{}" (Rust default) when clan_id is unset, unknown, or ctrl is null.
|
||||
var clan: String = str(p.clan_id) if "clan_id" in p else ""
|
||||
var scoring_weights_json: String = (
|
||||
ctrl.scoring_weights_for_clan(clan, data_dir)
|
||||
if ctrl != null and not clan.is_empty()
|
||||
else "{}"
|
||||
)
|
||||
var pj: String = (
|
||||
'{"player_index":' + str(int(p.index))
|
||||
+ ',"gold":' + str(int(p.gold))
|
||||
+ ',"cities":[' + ",".join(city_jsons) + "]"
|
||||
+ ',"unit_upkeep":[],"strategic_axes":{' + ",".join(axes_parts) + "}"
|
||||
+ ',"scoring_weights":{},"expansion_points":0'
|
||||
+ ',"scoring_weights":' + scoring_weights_json + ',"expansion_points":0'
|
||||
+ ',"city_buildings":[],"city_ecology":[],"science_yield":0'
|
||||
+ ',"units":[' + ",".join(unit_jsons) + "]"
|
||||
+ ',"city_positions":[' + ",".join(city_pos_strs) + "]"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,14 @@ static func assign(player: RefCounted, rng: RandomNumberGenerator) -> void:
|
|||
return
|
||||
var ids: Array = clans.keys()
|
||||
ids.sort()
|
||||
var chosen_id: String = ids[rng.randi() % ids.size()]
|
||||
# AI_PIN_PERSONALITY=<clan_id> forces all AI players to a specific clan for
|
||||
# per-clan batch testing. Falls back to random assignment when unset or invalid.
|
||||
var pin: String = OS.get_environment("AI_PIN_PERSONALITY")
|
||||
var chosen_id: String
|
||||
if not pin.is_empty() and clans.has(pin):
|
||||
chosen_id = pin
|
||||
else:
|
||||
chosen_id = ids[rng.randi() % ids.size()]
|
||||
var pick: Dictionary = clans.get(chosen_id, {})
|
||||
var axes: Dictionary = pick.get("strategic_axes", {})
|
||||
if axes.is_empty():
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ func test_build_game_state_json_returns_parseable_json() -> void:
|
|||
var p1: PlayerScript = _make_player(1)
|
||||
GameState.players = [p0, p1]
|
||||
|
||||
var json_str: String = BridgeScript._build_game_state_json(p0)
|
||||
var json_str: String = BridgeScript._build_game_state_json(p0, null, "")
|
||||
assert_false(json_str.is_empty(), "_build_game_state_json must return non-empty string")
|
||||
|
||||
var parsed: Dictionary = JSON.parse_string(json_str) as Dictionary
|
||||
|
|
@ -141,7 +141,7 @@ func test_build_game_state_json_includes_units() -> void:
|
|||
p0.units = [w]
|
||||
GameState.players = [p0]
|
||||
|
||||
var json_str: String = BridgeScript._build_game_state_json(p0)
|
||||
var json_str: String = BridgeScript._build_game_state_json(p0, null, "")
|
||||
var gs: Dictionary = JSON.parse_string(json_str) as Dictionary
|
||||
var players_arr: Array = gs["players"] as Array
|
||||
var p_dict: Dictionary = players_arr[0] as Dictionary
|
||||
|
|
@ -210,7 +210,7 @@ func test_mcts_routing_is_seed_deterministic() -> void:
|
|||
GameState.players = [p0, p1]
|
||||
GameState.turn_number = 42
|
||||
|
||||
var json_str: String = BridgeScript._build_game_state_json(p0)
|
||||
var json_str: String = BridgeScript._build_game_state_json(p0, null, "")
|
||||
var seed_val: int = GameState.turn_number * 1000 + p0.index
|
||||
|
||||
var ctrl_a: RefCounted = ClassDB.instantiate("GdMcTreeController")
|
||||
|
|
@ -225,3 +225,74 @@ func test_mcts_routing_is_seed_deterministic() -> void:
|
|||
|
||||
assert_eq(directive_a, directive_b,
|
||||
"Same seed must produce identical MCTS directive (determinism gate)")
|
||||
|
||||
|
||||
# ── Test 8: scoring_weights differ by clan (personality wiring) ──────────────
|
||||
|
||||
|
||||
func test_scoring_weights_differ_by_clan() -> void:
|
||||
if not ClassDB.class_exists("GdMcTreeController"):
|
||||
pass_test("GdMcTreeController not loaded — clan weights test skipped")
|
||||
return
|
||||
|
||||
var ctrl: RefCounted = ClassDB.instantiate("GdMcTreeController")
|
||||
var data_dir: String = ProjectSettings.globalize_path(
|
||||
"res://public/games/age-of-dwarves/data"
|
||||
)
|
||||
|
||||
var json_blackhammer: String = ctrl.scoring_weights_for_clan("blackhammer", data_dir)
|
||||
var json_goldvein: String = ctrl.scoring_weights_for_clan("goldvein", data_dir)
|
||||
|
||||
assert_false(json_blackhammer.is_empty(), "blackhammer weights must not be empty")
|
||||
assert_false(json_goldvein.is_empty(), "goldvein weights must not be empty")
|
||||
assert_ne(
|
||||
json_blackhammer,
|
||||
json_goldvein,
|
||||
"blackhammer and goldvein must produce distinct ScoringWeights"
|
||||
)
|
||||
|
||||
var bh: Dictionary = JSON.parse_string(json_blackhammer) as Dictionary
|
||||
var gv: Dictionary = JSON.parse_string(json_goldvein) as Dictionary
|
||||
assert_not_null(bh, "blackhammer weights must be valid JSON")
|
||||
assert_not_null(gv, "goldvein weights must be valid JSON")
|
||||
# Blackhammer (aggression=9) must have higher military_base than Goldvein (aggression=2).
|
||||
assert_true(
|
||||
float(bh.get("military_base", 0.0)) > float(gv.get("military_base", 0.0)),
|
||||
"blackhammer military_base must exceed goldvein (aggression axis difference)"
|
||||
)
|
||||
# Goldvein (wealth=9) must have higher yield_gold than Blackhammer (wealth=3).
|
||||
assert_true(
|
||||
float(gv.get("yield_gold", 0.0)) > float(bh.get("yield_gold", 0.0)),
|
||||
"goldvein yield_gold must exceed blackhammer (wealth axis difference)"
|
||||
)
|
||||
|
||||
|
||||
# ── Test 9: clan_id flows into GameState JSON scoring_weights ────────────────
|
||||
|
||||
|
||||
func test_build_json_embeds_clan_weights_when_ctrl_present() -> void:
|
||||
if not ClassDB.class_exists("GdMcTreeController"):
|
||||
pass_test("GdMcTreeController not loaded — clan JSON embedding test skipped")
|
||||
return
|
||||
|
||||
var p0: PlayerScript = _make_player(0)
|
||||
var city: CityScript = _make_city(0, Vector2i(0, 0))
|
||||
p0.cities = [city]
|
||||
p0.units = []
|
||||
p0.clan_id = "blackhammer"
|
||||
p0.strategic_axes = {"aggression": 9, "expansion": 7, "production": 5,
|
||||
"wealth": 3, "trade_willingness": 3, "grudge_persistence": 9}
|
||||
GameState.players = [p0]
|
||||
|
||||
var ctrl: RefCounted = ClassDB.instantiate("GdMcTreeController")
|
||||
var data_dir: String = ProjectSettings.globalize_path(
|
||||
"res://public/games/age-of-dwarves/data"
|
||||
)
|
||||
var json_str: String = BridgeScript._build_game_state_json(p0, ctrl, data_dir)
|
||||
var gs: Dictionary = JSON.parse_string(json_str) as Dictionary
|
||||
var players_arr: Array = gs["players"] as Array
|
||||
var p_dict: Dictionary = players_arr[0] as Dictionary
|
||||
|
||||
assert_true(p_dict.has("scoring_weights"), "Player JSON must have 'scoring_weights' key")
|
||||
var sw: Dictionary = p_dict["scoring_weights"] as Dictionary
|
||||
assert_true(sw.size() > 0, "scoring_weights must be non-empty for blackhammer clan")
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ dedicated_server=false
|
|||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter="engine/tests/*, engine/scenes/tests/*, *.md"
|
||||
exclude_filter="engine/tests/**, engine/scenes/tests/**, public/games/age-of-elves/**, public/games/age-of-kzzkyt/**, public/games/age-of-dwarves/guide/**, public/sandbox/**, **/node_modules/**, **/dist/**, **/target/**, **/pkg/**, *.md, *.ts, *.tsx"
|
||||
export_path=""
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -60,7 +60,7 @@ dedicated_server=false
|
|||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter="engine/tests/*, engine/scenes/tests/*, *.md"
|
||||
exclude_filter="engine/tests/**, engine/scenes/tests/**, public/games/age-of-elves/**, public/games/age-of-kzzkyt/**, public/games/age-of-dwarves/guide/**, public/sandbox/**, **/node_modules/**, **/dist/**, **/target/**, **/pkg/**, *.md, *.ts, *.tsx"
|
||||
export_path=""
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -177,7 +177,7 @@ dedicated_server=false
|
|||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter="engine/tests/*, engine/scenes/tests/*, *.md"
|
||||
exclude_filter="engine/tests/**, engine/scenes/tests/**, public/games/age-of-elves/**, public/games/age-of-kzzkyt/**, public/games/age-of-dwarves/guide/**, public/sandbox/**, **/node_modules/**, **/dist/**, **/target/**, **/pkg/**, *.md, *.ts, *.tsx"
|
||||
export_path=""
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
|
|||
|
|
@ -111,6 +111,32 @@ impl GdMcTreeController {
|
|||
})
|
||||
}
|
||||
|
||||
/// Return the serialized `ScoringWeights` for `clan_id` as a JSON string.
|
||||
///
|
||||
/// `data_dir` must be the OS filesystem path to the game data directory that
|
||||
/// contains `ai_personalities.json` (e.g. the globalized `res://public/games/age-of-dwarves/data`).
|
||||
/// Returns `"{}"` (empty object) on any error so the caller gets `ScoringWeights::default()`.
|
||||
#[func]
|
||||
fn scoring_weights_for_clan(&self, clan_id: GString, data_dir: GString) -> GString {
|
||||
use mc_ai::evaluator::ScoringWeights;
|
||||
use std::path::Path;
|
||||
let id = clan_id.to_string();
|
||||
let dir = data_dir.to_string();
|
||||
match ScoringWeights::from_personality(&id, Path::new(&dir)) {
|
||||
Ok(w) => match serde_json::to_string(&w) {
|
||||
Ok(json) => GString::from(json),
|
||||
Err(e) => {
|
||||
godot_error!("GdMcTreeController::scoring_weights_for_clan serialize error: {}", e);
|
||||
GString::from("{}")
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
godot_error!("GdMcTreeController::scoring_weights_for_clan load error for '{}': {}", id, e);
|
||||
GString::from("{}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: return the best action and the win-rate estimate as a JSON dict.
|
||||
/// `{ "action": "FoundCity", "win_rate": 0.62 }`
|
||||
#[func]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue