using System; using System.IO; using UnityEngine; // General Robbins-Monro difficulty balancer (abstract base). // // A 1-D stochastic root-finder that tunes a single scalar control value so the // player wins a target fraction of games. Each game yields a binary outcome // (won / lost). With a constant step alpha the update is // // value -= alpha * DirectionSign * (observed - targetWinRate) // // where observed = won ? 1 : 0. DirectionSign is sign( d P(win) / d value ): // -1 raising the value makes the game HARDER (player wins less) e.g. fish speed // +1 raising the value makes the game EASIER (player wins more) e.g. AI blunder rate // // The expected update is zero exactly when P(win) == targetWinRate. Because the // step is a *constant* (no 1/n decay) it tracks a moving target: the value hovers // around the difficulty that hits targetWinRate and re-adapts as the player // improves, rather than converging to a fixed point. // // Everything game-specific reduces to three things, supplied by subclasses: // 1. DirectionSign - which way difficulty moves as the value grows, // 2. StateKey - a stable id used to build the on-disk filenames, // 3. glue - a strongly-typed method that injects CurrentValue into the // game, plus a call to RecordResult(won) when it finishes. // The update, clamping, JSON state persistence and JSONL logging live here and // are warm-started from disk across sessions. public abstract class RobbinsMonroBalancer : MonoBehaviour { [Header("Robbins-Monro parameters")] public float targetWinRate = 0.5f; public float alpha = 0.025f; [Header("Control value seed and bounds")] public float initialValue = 0.5f; public float minValue = 0.0f; public float maxValue = 1.0f; // sign( d P(win) / d controlValue ). See the class comment. // -1 => higher value is harder, +1 => higher value is easier. protected abstract float DirectionSign { get; } // Stable identifier used to build the state/log filenames. Unique per game. protected abstract string StateKey { get; } // The canonical, learned control value. Inject this into your game before a round. public float CurrentValue { get; private set; } // Read-only diagnostics. public int GamesPlayed => n; public float ObservedWinRate => n > 0 ? (float)wins / n : 0f; int n; // games played int wins; // games won bool loaded = false; string StatePath => Path.Combine(Application.persistentDataPath, StateKey + "_balancer_state.json"); string LogPath => Path.Combine(Application.persistentDataPath, StateKey + "_balancer_log.jsonl"); protected virtual void Awake() { Load(); } // Guarantees state is loaded before first use (e.g. if a caller runs before Awake). public void EnsureLoaded() { if (!loaded) Load(); } // Wire to your game's finished event. Performs the Robbins-Monro update, // clamps, then persists. Aborts should arrive here as won == false and are // treated as ordinary losses. public void RecordResult(bool won) { EnsureLoaded(); float usedValue = CurrentValue; // value this game was actually played at float observed = won ? 1f : 0f; CurrentValue -= alpha * DirectionSign * (observed - targetWinRate); CurrentValue = Mathf.Clamp(CurrentValue, minValue, maxValue); n++; if (won) wins++; Save(); AppendLog(usedValue, won); } [Serializable] class StateData { public float value; public int n; public int wins; } void Load() { CurrentValue = initialValue; n = 0; wins = 0; try { if (File.Exists(StatePath)) { StateData data = JsonUtility.FromJson(File.ReadAllText(StatePath)); if (data != null) { CurrentValue = data.value; n = data.n; wins = data.wins; } } } catch (Exception e) { Debug.LogWarning(GetType().Name + ": failed to load state, using defaults. " + e.Message); } CurrentValue = Mathf.Clamp(CurrentValue, minValue, maxValue); loaded = true; } void Save() { try { StateData data = new StateData { value = CurrentValue, n = n, wins = wins }; File.WriteAllText(StatePath, JsonUtility.ToJson(data)); } catch (Exception e) { Debug.LogWarning(GetType().Name + ": failed to save state. " + e.Message); } } [Serializable] class LogRecord { public string ts; // ISO 8601 UTC timestamp public string variable; // StateKey - which control value this row is for public int n; // game index (1-based, after this game) public float value; // value used for THIS game public bool won; public float nextValue; // value after the Robbins-Monro update (used next game) public float winRate; // cumulative wins / n public float alpha; public float target; } void AppendLog(float usedValue, bool won) { try { LogRecord rec = new LogRecord { ts = DateTime.UtcNow.ToString("o"), variable = StateKey, n = n, value = usedValue, won = won, nextValue = CurrentValue, winRate = n > 0 ? (float)wins / n : 0f, alpha = alpha, target = targetWinRate }; File.AppendAllText(LogPath, JsonUtility.ToJson(rec) + "\n"); } catch (Exception e) { Debug.LogWarning(GetType().Name + ": failed to append log. " + e.Message); } } }