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