diff --git a/public/games/age-of-dwarves/data/difficulty.json b/public/games/age-of-dwarves/data/difficulty.json index 60a5d339..87c37d25 100644 --- a/public/games/age-of-dwarves/data/difficulty.json +++ b/public/games/age-of-dwarves/data/difficulty.json @@ -6,12 +6,13 @@ "name": "Easy", "description": "AI receives penalties. Ideal for learning the game.", "ai_modifiers": { - "production_mult": 0.80, - "research_mult": 0.90, + "production_mult": 0.70, + "research_mult": 0.80, "gold_mult": 1.0, "combat_bonus": 0, "extra_starting_units": 0, - "starting_gold_bonus": 0 + "starting_gold_bonus": 0, + "difficulty_threshold_mult": 0.85 }, "player_modifiers": { "production_mult": 1.0, @@ -29,7 +30,8 @@ "gold_mult": 1.0, "combat_bonus": 0, "extra_starting_units": 0, - "starting_gold_bonus": 0 + "starting_gold_bonus": 0, + "difficulty_threshold_mult": 1.00 }, "player_modifiers": { "production_mult": 1.0, @@ -40,14 +42,15 @@ "level": 3, "id": "hard", "name": "Hard", - "description": "AI receives minor bonuses. A fair challenge for experienced players.", + "description": "AI receives significant bonuses. A fair challenge for experienced players.", "ai_modifiers": { - "production_mult": 1.10, - "research_mult": 1.0, + "production_mult": 1.30, + "research_mult": 1.20, "gold_mult": 1.0, "combat_bonus": 0, "extra_starting_units": 0, - "starting_gold_bonus": 50 + "starting_gold_bonus": 75, + "difficulty_threshold_mult": 1.15 }, "player_modifiers": { "production_mult": 1.0, @@ -60,13 +63,14 @@ "name": "Insane", "description": "AI receives major bonuses and a free starting warrior. A serious challenge.", "ai_modifiers": { - "production_mult": 1.20, - "research_mult": 1.10, + "production_mult": 1.50, + "research_mult": 1.40, "gold_mult": 1.0, "combat_bonus": 0, "extra_starting_units": 1, "extra_unit_id": "warrior", - "starting_gold_bonus": 0 + "starting_gold_bonus": 150, + "difficulty_threshold_mult": 1.25 }, "player_modifiers": { "production_mult": 1.0, diff --git a/scripts/apricot-run.sh b/scripts/apricot-run.sh index 3472459d..5acefc03 100755 --- a/scripts/apricot-run.sh +++ b/scripts/apricot-run.sh @@ -38,7 +38,7 @@ done # MODE + positional args resolved early so the resource-policy block can # peek at the seed count (which differs per mode — for `clan` it's $2 # because $1 is the clan_id; for smoke/gpu-walltime it's $1). -MODE="${1:?usage: apricot-run.sh [args]}" +MODE="${1:?usage: apricot-run.sh [args]}" shift || true # ── Resource policy for PARALLEL + RAYON_NUM_THREADS ───────────────── @@ -49,8 +49,9 @@ shift || true # core. Better: PARALLEL = number of seeds (one instance each), and # RAYON_NUM_THREADS = nproc / PARALLEL so the box is saturated evenly. case "${MODE}" in - clan) _seed_count_peek="${2:-10}" ;; # $1 is clan_id, $2 is seeds - *) _seed_count_peek="${1:-10}" ;; # smoke, gpu-walltime + clan) _seed_count_peek="${2:-10}" ;; # $1 is clan_id, $2 is seeds + difficulty) _seed_count_peek="${2:-10}" ;; # $1 is tier, $2 is seeds + *) _seed_count_peek="${1:-10}" ;; # smoke, gpu-walltime esac NPROC="$(ssh "${APRICOT}" nproc 2>/dev/null || echo 8)" @@ -187,6 +188,15 @@ case "${MODE}" in AI_USE_MCTS=true AI_PIN_PERSONALITY='${CLAN}' ${GPU_ENV} PARALLEL=${PARALLEL} \ bash tools/autoplay-batch.sh ${SEEDS} ${TURNS} ${RESULTS_ABS}/clan-${CLAN} 2>&1 | tail -30" ;; + difficulty) + DIFF_TIER="${1:?usage: apricot-run.sh difficulty [seeds] [turns]}" + SEEDS="${2:-10}"; TURNS="${3:-300}" + GPU_ENV="AI_GPU_ROLLOUT=${AI_GPU_ROLLOUT:-false}" + echo "[$(date +%H:%M:%S)] difficulty=${DIFF_TIER} batch: ${SEEDS} seeds T${TURNS} PARALLEL=${PARALLEL} ${GPU_ENV}" + ssh "${APRICOT}" "set -euo pipefail; cd '${SCRATCH_ABS}' && \ + AI_USE_MCTS=true AI_DIFFICULTY='${DIFF_TIER}' ${GPU_ENV} PARALLEL=${PARALLEL} \ + bash tools/autoplay-batch.sh ${SEEDS} ${TURNS} ${RESULTS_ABS}/difficulty-${DIFF_TIER} 2>&1 | tail -30" + ;; gpu-walltime) SEEDS="${1:-10}"; TURNS="${2:-300}" echo "[$(date +%H:%M:%S)] GPU wall-time comparison: ${SEEDS} seeds T${TURNS}" diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index bd06d9cc..49b39a57 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -568,6 +568,7 @@ func _process(_delta: float) -> void: _turn_count = GameState.turn_number else: _fix_start_positions_if_needed() + _apply_difficulty_starting_bonuses() # Test-only scaffold — forces scout near a low-tier lair to exercise # loot-drop path in a 100-turn smoke. Biases normal batches; keep gated. if EnvConfig.get_bool("AUTO_PLAY_TEST_LOOT_SCAFFOLD"): @@ -790,6 +791,28 @@ func _fix_start_positions_if_needed() -> void: ]) +func _apply_difficulty_starting_bonuses() -> void: + var gold_bonus: int = GameState.ai_starting_gold_bonus + var extra_units: int = GameState.ai_extra_starting_units + var extra_unit_id: String = GameState.ai_extra_unit_id + if gold_bonus == 0 and extra_units == 0: + return + var unit_manager: Node = get_node_or_null("/root/UnitManager") + for p: Variant in GameState.players: + if p is PlayerScript and not p.is_human: + if gold_bonus > 0: + p.gold += gold_bonus + if extra_units > 0 and unit_manager != null: + for city: Variant in p.cities: + for _i: int in range(extra_units): + unit_manager.create_unit(extra_unit_id, p.index, city.position, p) + if gold_bonus > 0 or extra_units > 0: + print( + "AutoPlay: difficulty bonuses applied — gold+%d extra_units=%d (%s)" + % [gold_bonus, extra_units, extra_unit_id] + ) + + # ── Player Actions ─────────────────────────────────────────────────── func _play_turn() -> void: diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 327acdb2..03a72f91 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -70,10 +70,23 @@ var map_seed: int = 0 ## a save reproduces the same random trajectory as the original run. var game_rng: RandomNumberGenerator = RandomNumberGenerator.new() -## Difficulty modifier applied to AI production/science each turn (set by AIPlayer). +## Difficulty modifier applied to AI production each turn. ## 1.0 = even, <1.0 = AI penalty, >1.0 = AI bonus. var ai_difficulty_modifier: float = 1.0 +## Difficulty modifier applied to AI research (science) each turn. +## Separate from production so Easy can penalise production more than research. +var ai_research_modifier: float = 1.0 + +## Gold added to every AI player at game start for the current difficulty tier. +var ai_starting_gold_bonus: int = 0 + +## Extra warrior-class units spawned per AI city at game start. +var ai_extra_starting_units: int = 0 + +## ID of the extra starting unit (e.g. "warrior"). +var ai_extra_unit_id: String = "warrior" + ## Diplomatic relations between players. ## Key: "min_idx_max_idx", value: "neutral" | "war" | "peace" | "alliance". var diplomacy: Dictionary = {} @@ -201,8 +214,8 @@ func get_era_count() -> int: func apply_ai_difficulty() -> void: - ## Read game_settings["difficulty"] (id) and set ai_difficulty_modifier from - ## difficulty.json ai_modifiers.production_mult. Called after setup finishes. + ## Read game_settings["difficulty"] (id) and populate all ai_difficulty_* fields + ## from difficulty.json ai_modifiers. Called after setup finishes. var diff_id: String = str(game_settings.get("difficulty", "normal")) var diff_data: Dictionary = DataLoader.get_data("difficulty") as Dictionary if diff_data == null or diff_data.is_empty(): @@ -212,10 +225,20 @@ func apply_ai_difficulty() -> void: if entry.get("id", "") == diff_id: var mods: Dictionary = entry.get("ai_modifiers", {}) as Dictionary ai_difficulty_modifier = float(mods.get("production_mult", 1.0)) + ai_research_modifier = float(mods.get("research_mult", 1.0)) + ai_starting_gold_bonus = int(mods.get("starting_gold_bonus", 0)) + ai_extra_starting_units = int(mods.get("extra_starting_units", 0)) + ai_extra_unit_id = str(mods.get("extra_unit_id", "warrior")) print( ( - "GameState: difficulty=%s ai_modifier=%.2f" - % [diff_id, ai_difficulty_modifier] + "GameState: difficulty=%s prod=%.2f research=%.2f gold_bonus=%d extra_units=%d" + % [ + diff_id, + ai_difficulty_modifier, + ai_research_modifier, + ai_starting_gold_bonus, + ai_extra_starting_units + ] ) ) return diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index d564be24..98f8c01f 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -158,7 +158,7 @@ func _process_research(player: RefCounted) -> void: # Player # Apply difficulty modifier for AI players var sci_modifier: float = 1.0 if player is PlayerScript and not player.is_human: - sci_modifier = GameState.ai_difficulty_modifier + sci_modifier = GameState.ai_research_modifier player.research_progress += int(player.science_per_turn * sci_modifier) diff --git a/src/game/engine/src/modules/management/turn_processor_helpers.gd b/src/game/engine/src/modules/management/turn_processor_helpers.gd index 2deaa650..1903ede4 100644 --- a/src/game/engine/src/modules/management/turn_processor_helpers.gd +++ b/src/game/engine/src/modules/management/turn_processor_helpers.gd @@ -356,7 +356,7 @@ static func build_tile_yields_json( static func _calculate_science_income(player: RefCounted) -> int: var sci_modifier: float = 1.0 if player is PlayerScript and not player.is_human: - sci_modifier = GameState.ai_difficulty_modifier + sci_modifier = GameState.ai_research_modifier var science: int = int(player.science_per_turn * sci_modifier) var game_map: RefCounted = GameState.get_game_map() if game_map == null: diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs index a79f2b0e..efc1330b 100644 --- a/src/simulator/crates/mc-ai/src/tactical/state.rs +++ b/src/simulator/crates/mc-ai/src/tactical/state.rs @@ -40,6 +40,16 @@ pub struct TacticalState { /// unlocks (p0-39). Empty vec falls back to tier-1 `warrior` only. #[serde(default)] pub unit_catalog: Vec, + /// Multiplicative scalar applied on top of all personality-axis-derived + /// thresholds (p0-24). Easy < 1.0 (overcommits), Hard > 1.0 (waits for + /// real superiority). Defaults to 1.0 (normal / unset). Populated from + /// `difficulty.json::ai_modifiers.difficulty_threshold_mult` by the bridge. + #[serde(default = "default_threshold_mult")] + pub difficulty_threshold_mult: f32, +} + +fn default_threshold_mult() -> f32 { + 1.0 } /// Hex map with row-major tile storage.