feat: add OECS.PlayTest project
Introduce a new PlayTest project containing tools for simulating gameplay, including: - Agent abstractions (IAgent, GreedyAgent, WeightedAgentPool) - ObservableCapture for logging entity changes - PlayLog for generating and saving formatted play logs
This commit is contained in:
parent
6e8ac0adc0
commit
4871f68fb8
|
|
@ -0,0 +1,54 @@
|
||||||
|
using OECS;
|
||||||
|
|
||||||
|
namespace OECS.PlayTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstract agent that picks the action with the highest score.
|
||||||
|
/// When <see cref="ScoreAction"/> returns 0 for all actions (the default),
|
||||||
|
/// the agent behaves as a uniform random agent over legal actions.
|
||||||
|
/// Ties are broken randomly.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class GreedyAgent<TDecision> : IAgent<TDecision>
|
||||||
|
{
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the list of currently legal actions.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract List<TDecision> GetLegalActions(World world);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scores an action. Defaults to 0 (uniform random).
|
||||||
|
/// </summary>
|
||||||
|
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<TDecision>();
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using OECS;
|
||||||
|
|
||||||
|
namespace OECS.PlayTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An AI player that inspects the world and returns a decision.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAgent<TDecision>
|
||||||
|
{
|
||||||
|
TDecision Decide(World world);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace OECS.PlayTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pool of agents selected by weight. Higher weight = more likely to be picked.
|
||||||
|
/// </summary>
|
||||||
|
public class WeightedAgentPool<TDecision>
|
||||||
|
{
|
||||||
|
private readonly List<(IAgent<TDecision> Agent, int Weight)> _agents = new();
|
||||||
|
private int _totalWeight;
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
|
||||||
|
public void Add(IAgent<TDecision> agent, int weight)
|
||||||
|
{
|
||||||
|
_agents.Add((agent, weight));
|
||||||
|
_totalWeight += weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAgent<TDecision> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>OECS.PlayTest</RootNamespace>
|
||||||
|
<AssemblyName>OECS.PlayTest</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
using OECS;
|
||||||
|
using R3;
|
||||||
|
|
||||||
|
namespace OECS.PlayTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures all entity changes from the World's observable API and produces
|
||||||
|
/// a compact text log on demand. Call <see cref="FormatWith{T}"/> to enrich
|
||||||
|
/// component entries with human-readable values.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ObservableCapture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly World _world;
|
||||||
|
private readonly IDisposable _subscription;
|
||||||
|
private readonly List<CapturedEvent> _events = new();
|
||||||
|
private readonly Dictionary<Type, Func<World, Entity, string>> _formatters = new();
|
||||||
|
|
||||||
|
public ObservableCapture(World world)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_subscription = world.ObserveEntityChanges().Subscribe(OnChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a formatter for component type <typeparamref name="T"/>.
|
||||||
|
/// When a change for this type is captured, the formatted value is
|
||||||
|
/// included in the log output.
|
||||||
|
/// </summary>
|
||||||
|
public void FormatWith<T>(Func<T, string> formatter) where T : struct
|
||||||
|
{
|
||||||
|
_formatters[typeof(T)] = (w, e) =>
|
||||||
|
{
|
||||||
|
var value = w.ReadComponent<T>(e);
|
||||||
|
return formatter(value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the captured log as a single string with one line per change.
|
||||||
|
/// </summary>
|
||||||
|
public string GetLog() => string.Join(Environment.NewLine, GetLogLines());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the captured log as a list of lines, one per change.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> GetLogLines()
|
||||||
|
{
|
||||||
|
var lines = new List<string>(_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace OECS.PlayTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a sectioned play log with header, sections, and final snapshot,
|
||||||
|
/// then saves it to a .playlog file.
|
||||||
|
/// </summary>
|
||||||
|
public class PlayLog
|
||||||
|
{
|
||||||
|
public string Header { get; set; } = "";
|
||||||
|
public string FinalSnapshot { get; set; } = "";
|
||||||
|
|
||||||
|
private readonly List<(string Title, List<string> Lines)> _sections = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a section with the given title and lines.
|
||||||
|
/// </summary>
|
||||||
|
public void AddSection(string title, IEnumerable<string> lines)
|
||||||
|
{
|
||||||
|
_sections.Add((title, lines.ToList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the complete play log text.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the play log to AppContext.BaseDirectory/playlogs/<paramref name="filename"/>
|
||||||
|
/// and returns the full path.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
OECS.sln
23
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
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
|
@ -8,18 +9,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "OECS.Sour
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||||
EndProject
|
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
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Game.Blackjack.Tests\Game.Blackjack.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Game.Blackjack.Tests\Game.Blackjack.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}"
|
||||||
EndProject
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars.Tests", "Game.CardWars.Tests\Game.CardWars.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678902}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars.Tests", "Game.CardWars.Tests\Game.CardWars.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678902}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.PlayTest", "OECS.PlayTest\OECS.PlayTest.csproj", "{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
@ -138,6 +141,18 @@ Global
|
||||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.Build.0 = Release|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue