diff --git a/OECS.PlayTest/Agents/GreedyAgent.cs b/OECS.PlayTest/Agents/GreedyAgent.cs new file mode 100644 index 0000000..13f7bd2 --- /dev/null +++ b/OECS.PlayTest/Agents/GreedyAgent.cs @@ -0,0 +1,54 @@ +using OECS; + +namespace OECS.PlayTest; + +/// +/// Abstract agent that picks the action with the highest score. +/// When returns 0 for all actions (the default), +/// the agent behaves as a uniform random agent over legal actions. +/// Ties are broken randomly. +/// +public abstract class GreedyAgent : IAgent +{ + private static readonly Random _rng = new(); + + /// + /// Returns the list of currently legal actions. + /// + protected abstract List GetLegalActions(World world); + + /// + /// Scores an action. Defaults to 0 (uniform random). + /// + protected virtual float ScoreAction(World world, TDecision action) => 0f; + + public TDecision Decide(World world) + { + var actions = GetLegalActions(world); + if (actions.Count == 0) + throw new InvalidOperationException( + $"{GetType().Name}: no legal actions available"); + + if (actions.Count == 1) + return actions[0]; + + float bestScore = float.MinValue; + var bestActions = new List(); + foreach (var action in actions) + { + float score = ScoreAction(world, action); + if (score > bestScore) + { + bestScore = score; + bestActions.Clear(); + bestActions.Add(action); + } + else if (score == bestScore) + { + bestActions.Add(action); + } + } + + return bestActions[_rng.Next(bestActions.Count)]; + } +} \ No newline at end of file diff --git a/OECS.PlayTest/Agents/IAgent.cs b/OECS.PlayTest/Agents/IAgent.cs new file mode 100644 index 0000000..f56479e --- /dev/null +++ b/OECS.PlayTest/Agents/IAgent.cs @@ -0,0 +1,11 @@ +using OECS; + +namespace OECS.PlayTest; + +/// +/// An AI player that inspects the world and returns a decision. +/// +public interface IAgent +{ + TDecision Decide(World world); +} \ No newline at end of file diff --git a/OECS.PlayTest/Agents/WeightedAgentPool.cs b/OECS.PlayTest/Agents/WeightedAgentPool.cs new file mode 100644 index 0000000..b3be9c3 --- /dev/null +++ b/OECS.PlayTest/Agents/WeightedAgentPool.cs @@ -0,0 +1,33 @@ +namespace OECS.PlayTest; + +/// +/// A pool of agents selected by weight. Higher weight = more likely to be picked. +/// +public class WeightedAgentPool +{ + private readonly List<(IAgent Agent, int Weight)> _agents = new(); + private int _totalWeight; + private static readonly Random _rng = new(); + + public void Add(IAgent agent, int weight) + { + _agents.Add((agent, weight)); + _totalWeight += weight; + } + + public IAgent Pick() + { + if (_agents.Count == 0) + throw new InvalidOperationException("WeightedAgentPool is empty"); + + int roll = _rng.Next(_totalWeight); + int cumulative = 0; + foreach (var (agent, weight) in _agents) + { + cumulative += weight; + if (roll < cumulative) + return agent; + } + return _agents[^1].Agent; + } +} \ No newline at end of file diff --git a/OECS.PlayTest/OECS.PlayTest.csproj b/OECS.PlayTest/OECS.PlayTest.csproj new file mode 100644 index 0000000..d89e842 --- /dev/null +++ b/OECS.PlayTest/OECS.PlayTest.csproj @@ -0,0 +1,12 @@ + + + + OECS.PlayTest + OECS.PlayTest + + + + + + + \ No newline at end of file diff --git a/OECS.PlayTest/ObservableCapture.cs b/OECS.PlayTest/ObservableCapture.cs new file mode 100644 index 0000000..61e341c --- /dev/null +++ b/OECS.PlayTest/ObservableCapture.cs @@ -0,0 +1,94 @@ +using OECS; +using R3; + +namespace OECS.PlayTest; + +/// +/// Captures all entity changes from the World's observable API and produces +/// a compact text log on demand. Call to enrich +/// component entries with human-readable values. +/// +public sealed class ObservableCapture : IDisposable +{ + private readonly World _world; + private readonly IDisposable _subscription; + private readonly List _events = new(); + private readonly Dictionary> _formatters = new(); + + public ObservableCapture(World world) + { + _world = world; + _subscription = world.ObserveEntityChanges().Subscribe(OnChange); + } + + /// + /// Registers a formatter for component type . + /// When a change for this type is captured, the formatted value is + /// included in the log output. + /// + public void FormatWith(Func formatter) where T : struct + { + _formatters[typeof(T)] = (w, e) => + { + var value = w.ReadComponent(e); + return formatter(value); + }; + } + + /// + /// Returns the captured log as a single string with one line per change. + /// + public string GetLog() => string.Join(Environment.NewLine, GetLogLines()); + + /// + /// Returns the captured log as a list of lines, one per change. + /// + public List GetLogLines() + { + var lines = new List(_events.Count); + foreach (var evt in _events) + { + var change = evt.Change; + var kind = change.Kind.ToString(); + if (change.ComponentType != null) + { + var typeName = change.ComponentType.Name; + if (evt.FormattedValue != null) + lines.Add($"{kind} {typeName} = {evt.FormattedValue} on {change.Entity}"); + else + lines.Add($"{kind} {typeName} on {change.Entity}"); + } + else + { + lines.Add($"{kind} on {change.Entity}"); + } + } + return lines; + } + + public void Dispose() => _subscription.Dispose(); + + private void OnChange(EntityChange change) + { + string? formattedValue = null; + if (change.ComponentType != null + && change.Kind != ChangeKind.ComponentRemoved + && change.Kind != ChangeKind.EntityRemoved + && _formatters.TryGetValue(change.ComponentType, out var formatter)) + { + formattedValue = formatter(_world, change.Entity); + } + _events.Add(new CapturedEvent(change, formattedValue)); + } + + private readonly struct CapturedEvent + { + public readonly EntityChange Change; + public readonly string? FormattedValue; + public CapturedEvent(EntityChange change, string? formattedValue) + { + Change = change; + FormattedValue = formattedValue; + } + } +} \ No newline at end of file diff --git a/OECS.PlayTest/PlayLog.cs b/OECS.PlayTest/PlayLog.cs new file mode 100644 index 0000000..e74c536 --- /dev/null +++ b/OECS.PlayTest/PlayLog.cs @@ -0,0 +1,57 @@ +using System.Text; + +namespace OECS.PlayTest; + +/// +/// Builds a sectioned play log with header, sections, and final snapshot, +/// then saves it to a .playlog file. +/// +public class PlayLog +{ + public string Header { get; set; } = ""; + public string FinalSnapshot { get; set; } = ""; + + private readonly List<(string Title, List Lines)> _sections = new(); + + /// + /// Adds a section with the given title and lines. + /// + public void AddSection(string title, IEnumerable lines) + { + _sections.Add((title, lines.ToList())); + } + + /// + /// Builds the complete play log text. + /// + public string Build() + { + var sb = new StringBuilder(); + sb.AppendLine(Header); + sb.AppendLine(new string('=', Header.Length)); + sb.AppendLine(); + foreach (var (title, lines) in _sections) + { + sb.AppendLine($"--- {title} ---"); + foreach (var line in lines) + sb.AppendLine($" {line}"); + sb.AppendLine(); + } + sb.AppendLine("--- Final State ---"); + sb.AppendLine(FinalSnapshot); + return sb.ToString(); + } + + /// + /// Saves the play log to AppContext.BaseDirectory/playlogs/ + /// and returns the full path. + /// + public string SaveTo(string filename) + { + var dir = Path.Combine(AppContext.BaseDirectory, "playlogs"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, filename); + File.WriteAllText(path, Build()); + return path; + } +} \ No newline at end of file diff --git a/OECS.sln b/OECS.sln index 3f88df9..5258ed6 100644 --- a/OECS.sln +++ b/OECS.sln @@ -1,4 +1,5 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -8,18 +9,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "OECS.Sour EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Game.Blackjack.Tests\Game.Blackjack.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars", "Game.CardWars\CardWars.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CardWars", "Game.CardWars\CardWars.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars.Tests", "Game.CardWars.Tests\Game.CardWars.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678902}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.PlayTest", "OECS.PlayTest\OECS.PlayTest.csproj", "{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -138,8 +141,20 @@ Global {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.Build.0 = Release|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x64.ActiveCfg = Debug|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x64.Build.0 = Debug|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x86.ActiveCfg = Debug|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x86.Build.0 = Debug|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|Any CPU.Build.0 = Release|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x64.ActiveCfg = Release|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x64.Build.0 = Release|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x86.ActiveCfg = Release|Any CPU + {ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection - GlobalSection(SolutionProperties) = preSolution + GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection EndGlobal