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:
2026-07-21 14:31:57 +08:00
parent 6e8ac0adc0
commit 4871f68fb8
7 changed files with 281 additions and 5 deletions
+54
View File
@@ -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)];
}
}
+11
View File
@@ -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);
}
+33
View File
@@ -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;
}
}