feat(@projects/@magic-civilization): update difficulty modifiers and thresholds

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 20:29:35 -07:00
parent c81744b01e
commit 4c8dbbe29c
7 changed files with 91 additions and 21 deletions

View file

@ -6,12 +6,13 @@
"name": "Easy", "name": "Easy",
"description": "AI receives penalties. Ideal for learning the game.", "description": "AI receives penalties. Ideal for learning the game.",
"ai_modifiers": { "ai_modifiers": {
"production_mult": 0.80, "production_mult": 0.70,
"research_mult": 0.90, "research_mult": 0.80,
"gold_mult": 1.0, "gold_mult": 1.0,
"combat_bonus": 0, "combat_bonus": 0,
"extra_starting_units": 0, "extra_starting_units": 0,
"starting_gold_bonus": 0 "starting_gold_bonus": 0,
"difficulty_threshold_mult": 0.85
}, },
"player_modifiers": { "player_modifiers": {
"production_mult": 1.0, "production_mult": 1.0,
@ -29,7 +30,8 @@
"gold_mult": 1.0, "gold_mult": 1.0,
"combat_bonus": 0, "combat_bonus": 0,
"extra_starting_units": 0, "extra_starting_units": 0,
"starting_gold_bonus": 0 "starting_gold_bonus": 0,
"difficulty_threshold_mult": 1.00
}, },
"player_modifiers": { "player_modifiers": {
"production_mult": 1.0, "production_mult": 1.0,
@ -40,14 +42,15 @@
"level": 3, "level": 3,
"id": "hard", "id": "hard",
"name": "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": { "ai_modifiers": {
"production_mult": 1.10, "production_mult": 1.30,
"research_mult": 1.0, "research_mult": 1.20,
"gold_mult": 1.0, "gold_mult": 1.0,
"combat_bonus": 0, "combat_bonus": 0,
"extra_starting_units": 0, "extra_starting_units": 0,
"starting_gold_bonus": 50 "starting_gold_bonus": 75,
"difficulty_threshold_mult": 1.15
}, },
"player_modifiers": { "player_modifiers": {
"production_mult": 1.0, "production_mult": 1.0,
@ -60,13 +63,14 @@
"name": "Insane", "name": "Insane",
"description": "AI receives major bonuses and a free starting warrior. A serious challenge.", "description": "AI receives major bonuses and a free starting warrior. A serious challenge.",
"ai_modifiers": { "ai_modifiers": {
"production_mult": 1.20, "production_mult": 1.50,
"research_mult": 1.10, "research_mult": 1.40,
"gold_mult": 1.0, "gold_mult": 1.0,
"combat_bonus": 0, "combat_bonus": 0,
"extra_starting_units": 1, "extra_starting_units": 1,
"extra_unit_id": "warrior", "extra_unit_id": "warrior",
"starting_gold_bonus": 0 "starting_gold_bonus": 150,
"difficulty_threshold_mult": 1.25
}, },
"player_modifiers": { "player_modifiers": {
"production_mult": 1.0, "production_mult": 1.0,

View file

@ -38,7 +38,7 @@ done
# MODE + positional args resolved early so the resource-policy block can # 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 # 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). # because $1 is the clan_id; for smoke/gpu-walltime it's $1).
MODE="${1:?usage: apricot-run.sh <smoke|clan|gpu-walltime> [args]}" MODE="${1:?usage: apricot-run.sh <smoke|clan|difficulty|gpu-walltime> [args]}"
shift || true shift || true
# ── Resource policy for PARALLEL + RAYON_NUM_THREADS ───────────────── # ── Resource policy for PARALLEL + RAYON_NUM_THREADS ─────────────────
@ -49,8 +49,9 @@ shift || true
# core. Better: PARALLEL = number of seeds (one instance each), and # core. Better: PARALLEL = number of seeds (one instance each), and
# RAYON_NUM_THREADS = nproc / PARALLEL so the box is saturated evenly. # RAYON_NUM_THREADS = nproc / PARALLEL so the box is saturated evenly.
case "${MODE}" in case "${MODE}" in
clan) _seed_count_peek="${2:-10}" ;; # $1 is clan_id, $2 is seeds clan) _seed_count_peek="${2:-10}" ;; # $1 is clan_id, $2 is seeds
*) _seed_count_peek="${1:-10}" ;; # smoke, gpu-walltime difficulty) _seed_count_peek="${2:-10}" ;; # $1 is tier, $2 is seeds
*) _seed_count_peek="${1:-10}" ;; # smoke, gpu-walltime
esac esac
NPROC="$(ssh "${APRICOT}" nproc 2>/dev/null || echo 8)" 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} \ 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" bash tools/autoplay-batch.sh ${SEEDS} ${TURNS} ${RESULTS_ABS}/clan-${CLAN} 2>&1 | tail -30"
;; ;;
difficulty)
DIFF_TIER="${1:?usage: apricot-run.sh difficulty <easy|normal|hard|insane> [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) gpu-walltime)
SEEDS="${1:-10}"; TURNS="${2:-300}" SEEDS="${1:-10}"; TURNS="${2:-300}"
echo "[$(date +%H:%M:%S)] GPU wall-time comparison: ${SEEDS} seeds T${TURNS}" echo "[$(date +%H:%M:%S)] GPU wall-time comparison: ${SEEDS} seeds T${TURNS}"

View file

@ -568,6 +568,7 @@ func _process(_delta: float) -> void:
_turn_count = GameState.turn_number _turn_count = GameState.turn_number
else: else:
_fix_start_positions_if_needed() _fix_start_positions_if_needed()
_apply_difficulty_starting_bonuses()
# Test-only scaffold — forces scout near a low-tier lair to exercise # 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. # loot-drop path in a 100-turn smoke. Biases normal batches; keep gated.
if EnvConfig.get_bool("AUTO_PLAY_TEST_LOOT_SCAFFOLD"): 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 ─────────────────────────────────────────────────── # ── Player Actions ───────────────────────────────────────────────────
func _play_turn() -> void: func _play_turn() -> void:

View file

@ -70,10 +70,23 @@ var map_seed: int = 0
## a save reproduces the same random trajectory as the original run. ## a save reproduces the same random trajectory as the original run.
var game_rng: RandomNumberGenerator = RandomNumberGenerator.new() 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. ## 1.0 = even, <1.0 = AI penalty, >1.0 = AI bonus.
var ai_difficulty_modifier: float = 1.0 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. ## Diplomatic relations between players.
## Key: "min_idx_max_idx", value: "neutral" | "war" | "peace" | "alliance". ## Key: "min_idx_max_idx", value: "neutral" | "war" | "peace" | "alliance".
var diplomacy: Dictionary = {} var diplomacy: Dictionary = {}
@ -201,8 +214,8 @@ func get_era_count() -> int:
func apply_ai_difficulty() -> void: func apply_ai_difficulty() -> void:
## Read game_settings["difficulty"] (id) and set ai_difficulty_modifier from ## Read game_settings["difficulty"] (id) and populate all ai_difficulty_* fields
## difficulty.json ai_modifiers.production_mult. Called after setup finishes. ## from difficulty.json ai_modifiers. Called after setup finishes.
var diff_id: String = str(game_settings.get("difficulty", "normal")) var diff_id: String = str(game_settings.get("difficulty", "normal"))
var diff_data: Dictionary = DataLoader.get_data("difficulty") as Dictionary var diff_data: Dictionary = DataLoader.get_data("difficulty") as Dictionary
if diff_data == null or diff_data.is_empty(): if diff_data == null or diff_data.is_empty():
@ -212,10 +225,20 @@ func apply_ai_difficulty() -> void:
if entry.get("id", "") == diff_id: if entry.get("id", "") == diff_id:
var mods: Dictionary = entry.get("ai_modifiers", {}) as Dictionary var mods: Dictionary = entry.get("ai_modifiers", {}) as Dictionary
ai_difficulty_modifier = float(mods.get("production_mult", 1.0)) 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( print(
( (
"GameState: difficulty=%s ai_modifier=%.2f" "GameState: difficulty=%s prod=%.2f research=%.2f gold_bonus=%d extra_units=%d"
% [diff_id, ai_difficulty_modifier] % [
diff_id,
ai_difficulty_modifier,
ai_research_modifier,
ai_starting_gold_bonus,
ai_extra_starting_units
]
) )
) )
return return

View file

@ -158,7 +158,7 @@ func _process_research(player: RefCounted) -> void: # Player
# Apply difficulty modifier for AI players # Apply difficulty modifier for AI players
var sci_modifier: float = 1.0 var sci_modifier: float = 1.0
if player is PlayerScript and not player.is_human: 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) player.research_progress += int(player.science_per_turn * sci_modifier)

View file

@ -356,7 +356,7 @@ static func build_tile_yields_json(
static func _calculate_science_income(player: RefCounted) -> int: static func _calculate_science_income(player: RefCounted) -> int:
var sci_modifier: float = 1.0 var sci_modifier: float = 1.0
if player is PlayerScript and not player.is_human: 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 science: int = int(player.science_per_turn * sci_modifier)
var game_map: RefCounted = GameState.get_game_map() var game_map: RefCounted = GameState.get_game_map()
if game_map == null: if game_map == null:

View file

@ -40,6 +40,16 @@ pub struct TacticalState {
/// unlocks (p0-39). Empty vec falls back to tier-1 `warrior` only. /// unlocks (p0-39). Empty vec falls back to tier-1 `warrior` only.
#[serde(default)] #[serde(default)]
pub unit_catalog: Vec<TacticalUnitSpec>, pub unit_catalog: Vec<TacticalUnitSpec>,
/// 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. /// Hex map with row-major tile storage.