initialize repo

This commit is contained in:
2025-08-08 15:36:09 +02:00
parent d6a2c37a5f
commit cabf13d164
92 changed files with 2160 additions and 2 deletions

1
Lib

Submodule Lib deleted from 67de04e3da

2
Lib/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.idea/
civ_codebase.txt

133
Lib/Civilization.Core/.gitignore vendored Normal file
View File

@@ -0,0 +1,133 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
[Bb]in/
[Oo]bj/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.log
*.svclog
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# Click-Once directory
publish/
# Publish Web Output
*.Publish.xml
*.pubxml
*.azurePubxml
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
packages/
## TODO: If the tool you use requires repositories.config, also uncomment the next line
!packages/repositories.config
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Store app package directory
AppPackages/
# Others
sql/
*.Cache
ClientBin/
[Ss]tyle[Cc]op.*
![Ss]tyle[Cc]op.targets
~$*
*~
*.dbmdl
*.[Pp]ublish.xml
*.publishsettings
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
App_Data/*.mdf
App_Data/*.ldf
# =========================
# Windows detritus
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Mac desktop service store files
.DS_Store
_NCrunch*

View File

@@ -0,0 +1,26 @@
using Civilization.Core.Interfaces;
namespace Civilization.Core.Actions;
public class ActionQueue
{
private readonly Queue<IGameAction> _pending = new();
private readonly Stack<ExecutedAction> _history = new();
public void Enqueue(IGameAction action)
{
_pending.Enqueue(action);
}
public void ExecuteAll(GameActionContext context)
{
while (_pending.Count > 0)
{
var action = _pending.Dequeue();
if (!action.CanExecute(context)) continue;
action.Execute(context);
_history.Push(new ExecutedAction(action, context));
}
}
}

View File

@@ -0,0 +1,15 @@
using Civilization.Core.Interfaces;
namespace Civilization.Core.Actions;
public class ExecutedAction
{
public IGameAction Action { get; }
public GameActionContext ContextSnapshot { get; }
public ExecutedAction(IGameAction action, GameActionContext snapshot)
{
Action = action;
ContextSnapshot = snapshot;
}
}

View File

@@ -0,0 +1,39 @@
using Civilization.Core.Interfaces;
namespace Civilization.Core.Actions;
public class ExpandTerritoryAction(Guid cityId, Vec2I targetTile) : IGameAction
{
public Guid CityId { get; } = cityId;
public Vec2I TargetTile { get; } = targetTile;
public bool CanExecute(GameActionContext context)
{
var city = context.State.FindCity(CityId);
if (city == null || city.ActionPoints < 1) return false;
if (!context.Map.Grid.IsValidPosition(TargetTile)) return false;
var tile = context.Map.GetTile(TargetTile);
if (tile is not { OwnerId: null }) return false;
return city.Territory.Any(t =>
{
var neighbors = context.Map.GetNeighbors(t);
return neighbors.Contains(tile);
});
}
public void Execute(GameActionContext context)
{
var city = context.State.FindCity(CityId)!;
city.ClaimTile(context.Map, TargetTile);
city.SpendActionPoint();
}
public void Undo(GameActionContext context)
{
}
}

View File

@@ -0,0 +1,19 @@
using Civilization.Core.Game;
namespace Civilization.Core.Actions;
public class GameActionContext
{
public GameMap Map { get; }
public List<Player> Players { get; }
public GameState State { get; }
public GameActionContext(GameState gameState)
{
State = gameState;
Map = gameState.Map;
Players = gameState.Players;
}
public Player CurrentPlayer => State.CurrentPlayer;
}

View File

@@ -0,0 +1,31 @@
using Civilization.Core.Interfaces;
namespace Civilization.Core.Actions;
public class MoveUnitAction(Guid unitId, Vec2I targetPosition) : IGameAction
{
public Guid UnitId { get; } = unitId;
public Vec2I TargetPosition { get; } = targetPosition;
public bool CanExecute(GameActionContext context)
{
var unit = context.State.FindUnit(UnitId);
if (unit == null || unit.OwnerId != context.CurrentPlayer.Id) return false;
return context.Map.Grid.IsValidPosition(TargetPosition) && unit.CanMoveTo(TargetPosition, context.Map);
}
public void Execute(GameActionContext context)
{
var unit = context.State.FindUnit(UnitId);
if (unit == null) return;
unit.Position = TargetPosition;
unit.ActionPoints -= 1;
}
public void Undo(GameActionContext context)
{
}
}

View File

@@ -0,0 +1,45 @@
using Civilization.Core.Game;
using Civilization.Core.Interfaces;
using Civilization.Core.Units;
namespace Civilization.Core.Actions;
public class SettleCityAction(Guid unitId) : IGameAction
{
private const int CityNearbyRange = 4;
public Guid UnitId { get; } = unitId;
public bool CanExecute(GameActionContext context)
{
var unit = context.State.FindUnit(UnitId);
if (unit == null || !unit.HasTag(UnitTag.Settle)) return false;
if (unit.OwnerId != context.CurrentPlayer.Id) return false;
if (unit.ActionPoints < 1) return false;
var tile = context.Map.GetTile(unit.Position);
if (tile is not { OwnerId: null }) return false;
// Later we could also check if there is city nearby
return true;
}
public void Execute(GameActionContext context)
{
var unit = context.State.FindUnit(UnitId)!;
var position = unit.Position;
context.State.RemoveUnit(UnitId);
var city = new City(unit.OwnerId, position);
city.ClaimTile(context.Map, position);
context.State.AddCity(city);
}
public void Undo(GameActionContext context)
{
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,14 @@
namespace Civilization.Core;
public readonly struct ColorRGBA(byte r, byte g, byte b, byte a = 255)
{
public byte R { get; } = r;
public byte G { get; } = g;
public byte B { get; } = b;
public byte A { get; } = a;
public string ToHex()
{
return $"#{R:X2}{G:X2}{B:X2}{A:X2}";
}
}

View File

@@ -0,0 +1,71 @@
using Civilization.Core.Actions;
using Civilization.Core.Interfaces;
namespace Civilization.Core.Game;
public class City : IOnTurnListener, IEntity
{
public Guid Id { get; } = Guid.NewGuid();
public int OwnerId { get; }
public string Name { get; set; }
public Vec2I Position { get; }
public HashSet<Vec2I> Territory { get; } = [];
public int MaxActionPoints { get; private set; } = 1;
public int ActionPoints { get; set; }
public City(int ownerId, Vec2I position, string name = "New City")
{
OwnerId = ownerId;
Position = position;
Name = name;
Territory.Add(position);
ActionPoints = MaxActionPoints;
}
public void ExpandTo(Vec2I newTile) => Territory.Add(newTile);
public void ResetActionPoints() => ActionPoints = MaxActionPoints;
public bool CanProduce() => ActionPoints > 0;
public void SpendActionPoint()
{
if (ActionPoints > 0) ActionPoints--;
}
public void ClaimTile(GameMap map, Vec2I tilePos)
{
var tile = map.GetTile(tilePos);
if (tile == null) return;
tile.OwnerId = OwnerId;
Territory.Add(tilePos);
}
public void OnTurnStart(GameState state)
{
ResetActionPoints();
var map = state.Map;
var neighbors = Territory.SelectMany(pos => map.Grid.GetNeighbors(pos))
.Distinct()
.Where(p => map.GetTile(p)?.OwnerId == null)
.ToList();
if (neighbors.Count > 0)
{
var rng = new Random();
var choice = neighbors[rng.Next(neighbors.Count)];
state.ActionQueue.Enqueue(new ExpandTerritoryAction(Id, choice));
}
}
public void OnTurnEnd(GameState state)
{
}
}

View File

@@ -0,0 +1,56 @@
using Civilization.Core.Actions;
using Civilization.Core.Interfaces;
using Civilization.Core.Units;
namespace Civilization.Core.Game;
public class GameState(GameMap map, List<Player> players)
{
private readonly Dictionary<Guid, Unit> _units = new();
private readonly Dictionary<Guid, City> _cities = new();
public GameMap Map { get; } = map;
public List<Player> Players { get; } = players;
public TurnManager TurnManager { get; } = new(players);
public ActionQueue ActionQueue { get; } = new();
public IEnumerable<Unit> Units => _units.Values;
public Player CurrentPlayer => TurnManager.CurrentPlayer;
public IEnumerable<City> Cities => _cities.Values;
public void NextTurn()
{
foreach (var listener in GetAllTurnListeners(CurrentPlayer.Id)) listener.OnTurnEnd(this);
TurnManager.AdvanceTurn();
foreach (var listener in GetAllTurnListeners(CurrentPlayer.Id)) listener.OnTurnStart(this);
ActionQueue.ExecuteAll(new GameActionContext(this));
}
public void AddUnit(Unit unit) => _units.Add(unit.Id, unit);
public Unit? FindUnit(Guid id) => _units.GetValueOrDefault(id);
public IEnumerable<Unit> GetUnitsForPlayer(int playerId) => _units.Values.Where(u => u.OwnerId == playerId);
public void RemoveUnit(Guid id) {
// Later, let's check if player can remove this unit (e.g. his turn, unit is not in combat, etc.)
_units.Remove(id);
}
public void AddCity(City city) => _cities.Add(city.Id, city);
public City? FindCity(Guid id) => _cities.GetValueOrDefault(id);
public IEnumerable<City> GetCitiesForPlayer(int playerId)
=> _cities.Values.Where(c => c.OwnerId == playerId);
private IEnumerable<IOnTurnListener> GetAllTurnListeners(int playerId)
{
foreach (var unit in GetUnitsForPlayer(playerId)) yield return unit;
foreach (var city in GetCitiesForPlayer(playerId)) yield return city;
}
}

View File

@@ -0,0 +1,11 @@
namespace Civilization.Core.Game;
public class Player(int id, string name, ColorRGBA color, bool isHuman = true)
{
public int Id { get; } = id;
public string Name { get; } = name;
public ColorRGBA Color { get; } = color;
public bool IsHuman { get; } = isHuman;
}

View File

@@ -0,0 +1,24 @@
namespace Civilization.Core.Game;
public class TurnManager
{
private readonly List<Player> _players = [];
private int _currentIndex = 0;
public int TurnNumber { get; private set; } = 1;
public Player CurrentPlayer => _players[_currentIndex];
public TurnManager(IEnumerable<Player> players)
{
_players = players.ToList();
}
public void AdvanceTurn()
{
_currentIndex++;
if (_currentIndex < _players.Count) return;
_currentIndex = 0;
TurnNumber++;
}
}

View File

@@ -0,0 +1,33 @@
using Civilization.Core.Interfaces;
namespace Civilization.Core;
public class GameMap
{
private readonly Dictionary<Vec2I, Tile.Tile> _tiles = new();
public ITileGrid Grid { get; private set; }
public GameMap(ITileGrid grid)
{
Grid = grid;
for (var x = 0; x < ((IGridSize)grid).Width; x++)
for (var y = 0; y < ((IGridSize)grid).Height; y++)
{
var pos = new Vec2I(x, y);
_tiles[pos] = new Tile.Tile(pos);
}
}
public IEnumerable<Tile.Tile> GetTiles() => _tiles.Values;
public IEnumerable<Tile.Tile> GetNeighbors(Vec2I position)
{
return Grid.GetNeighbors(position).Select(GetTile).Where(t => t != null)!;
}
public Tile.Tile? GetTile(Vec2I position) => _tiles.GetValueOrDefault(position);
}

View File

@@ -0,0 +1,46 @@
using Civilization.Core.Interfaces;
namespace Civilization.Core.Grid;
public class SquareGrid : ITileGrid, IGridSize
{
public int Width { get; }
public int Height { get; }
private static readonly Vec2I[] NeighborOffsets =
{
new Vec2I(-1, 0), // Left
new Vec2I(1, 0), // Right
new Vec2I(0, -1), // Up
new Vec2I(0, 1) // Down
};
public SquareGrid(int width, int height)
{
Width = width;
Height = height;
}
public IEnumerable<Vec2I> GetNeighbors(Vec2I tilePosition)
{
foreach (var offset in NeighborOffsets)
{
var neighbor = tilePosition + offset;
if (IsValidPosition(neighbor))
yield return neighbor;
}
}
public Vec2I ClampPosition(Vec2I position)
{
var x = Math.Clamp(position.X, 0, Width - 1);
var y = Math.Clamp(position.Y, 0, Height - 1);
return new Vec2I(x, y);
}
public bool IsValidPosition(Vec2I position)
{
return position.X >= 0 && position.X < Width && position.Y >= 0 && position.Y < Height;
}
}

View File

@@ -0,0 +1,7 @@
namespace Civilization.Core.Interfaces;
public interface IEntity
{
Guid Id { get; }
int OwnerId { get; }
}

View File

@@ -0,0 +1,10 @@
using Civilization.Core.Actions;
namespace Civilization.Core.Interfaces;
public interface IGameAction
{
bool CanExecute(GameActionContext context);
void Execute(GameActionContext context);
void Undo(GameActionContext context);
}

View File

@@ -0,0 +1,7 @@
namespace Civilization.Core.Interfaces;
public interface IGridSize
{
public int Width { get; }
public int Height { get; }
}

View File

@@ -0,0 +1,6 @@
namespace Civilization.Core.Interfaces;
public interface ILogger
{
void Log(string message);
}

View File

@@ -0,0 +1,9 @@
using Civilization.Core.Game;
namespace Civilization.Core.Interfaces;
public interface IOnTurnListener
{
void OnTurnStart(GameState state);
void OnTurnEnd(GameState state);
}

View File

@@ -0,0 +1,10 @@
namespace Civilization.Core.Interfaces;
public interface ITileGrid
{
IEnumerable<Vec2I> GetNeighbors(Vec2I tilePosition);
Vec2I ClampPosition(Vec2I position);
bool IsValidPosition(Vec2I position);
}

View File

@@ -0,0 +1,16 @@
namespace Civilization.Core.Tile;
public class Tile
{
public Vec2I Position { get; }
public TileType Type { get; set; } = TileType.Plain;
public int? OwnerId { get; set; } = null;
public Tile(Vec2I position)
{
Position = position;
}
}

View File

@@ -0,0 +1,16 @@
namespace Civilization.Core.Tile;
public enum TileType
{
Plain = 0,
Forest = 1,
Mountain = 2,
Water = 3,
Desert = 4,
Grassland = 5,
Hills = 6,
Ocean = 7,
Tundra = 8,
Snow = 9,
Swamp = 10,
}

View File

@@ -0,0 +1,56 @@
using Civilization.Core.Game;
using Civilization.Core.Interfaces;
namespace Civilization.Core.Units;
public class Unit : IOnTurnListener, IEntity
{
public Guid Id { get; } = Guid.NewGuid();
public int OwnerId { get; }
public UnitType Type { get; }
public UnitData Data { get; }
public Vec2I Position { get; set; }
public int MaxActionPoints { get; }
public int ActionPoints { get; set; }
public Unit(int ownerId, UnitType type, Vec2I position)
{
Id = Guid.NewGuid();
OwnerId = ownerId;
Type = type;
Position = position;
Data = UnitDataRegistry.Get(Type);
MaxActionPoints = Data.MaxActionPoints;
ActionPoints = MaxActionPoints;
}
public void ResetActionPoints()
{
ActionPoints = MaxActionPoints;
}
public bool CanMoveTo(Vec2I destination, GameMap map)
{
if (ActionPoints <= 0) return false;
if (!map.Grid.IsValidPosition(destination)) return false;
var distance = Math.Abs(Position.X - destination.X) + Math.Abs(Position.Y - destination.Y);
return distance <= Data.MoveRange;
}
public bool HasTag(UnitTag tag) => Data.HasTag(tag);
public void OnTurnStart(GameState state)
{
ResetActionPoints();
}
public void OnTurnEnd(GameState state)
{
}
}

View File

@@ -0,0 +1,11 @@
namespace Civilization.Core.Units;
public class UnitData
{
public string Name { get; init; }
public int MaxActionPoints { get; init; }
public int MoveRange { get; init; } = 1;
public HashSet<UnitTag> Tags { get; init; } = [];
public bool HasTag(UnitTag tag) => Tags.Contains(tag);
}

View File

@@ -0,0 +1,17 @@
namespace Civilization.Core.Units;
public class UnitDataRegistry
{
private static readonly Dictionary<UnitType, UnitData> _registry = new()
{
[UnitType.Settler] = new UnitData
{
Name = "Settler",
MaxActionPoints = 1,
MoveRange = 1,
Tags = new() { UnitTag.Settle, }
}
};
public static UnitData Get(UnitType unitType) => _registry[unitType];
}

View File

@@ -0,0 +1,6 @@
namespace Civilization.Core.Units;
public enum UnitTag
{
Settle,
}

View File

@@ -0,0 +1,6 @@
namespace Civilization.Core.Units;
public enum UnitType
{
Settler,
}

View File

@@ -0,0 +1,15 @@
namespace Civilization.Core;
public readonly struct Vec2I(int x, int y) : IEquatable<Vec2I>
{
public int X { get; } = x;
public int Y { get; } = y;
public static Vec2I operator +(Vec2I a, Vec2I b) => new(a.X + b.X, a.Y + b.Y);
public static Vec2I operator -(Vec2I a, Vec2I b) => new(a.X - b.X, a.Y - b.Y);
public override string ToString() => $"({X}, {Y})";
public bool Equals(Vec2I other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) => obj is Vec2I other && Equals(other);
public override int GetHashCode() => HashCode.Combine(X, Y);
}

133
Lib/Civilization.Server/.gitignore vendored Normal file
View File

@@ -0,0 +1,133 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
[Bb]in/
[Oo]bj/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.log
*.svclog
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# Click-Once directory
publish/
# Publish Web Output
*.Publish.xml
*.pubxml
*.azurePubxml
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
packages/
## TODO: If the tool you use requires repositories.config, also uncomment the next line
!packages/repositories.config
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Store app package directory
AppPackages/
# Others
sql/
*.Cache
ClientBin/
[Ss]tyle[Cc]op.*
![Ss]tyle[Cc]op.targets
~$*
*~
*.dbmdl
*.[Pp]ublish.xml
*.publishsettings
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
App_Data/*.mdf
App_Data/*.ldf
# =========================
# Windows detritus
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Mac desktop service store files
.DS_Store
_NCrunch*

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Civilization.Core\Civilization.Core.csproj" />
<ProjectReference Include="..\Civilization.Shared\Civilization.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,68 @@
using Civilization.Core;
using Civilization.Core.Actions;
using Civilization.Core.Game;
using Civilization.Core.Grid;
using Civilization.Core.Interfaces;
using Civilization.Core.Units;
using Civilization.Shared.Commands;
using Civilization.Shared.Packets;
using Civilization.Shared.Packets.ServerMessages;
namespace Civilization.Server.Game;
public class GameSession
{
private const int DefaultSize = 10;
private readonly GameState _state;
private readonly GameSessionCoordinator _coordinator;
public GameState State => _state;
public GameSession(GameSessionCoordinator coordinator)
{
_coordinator = coordinator;
var grid = new SquareGrid(DefaultSize, DefaultSize);
var map = new GameMap(grid);
var players = new List<Player>
{
new(0, "Player 1", new ColorRGBA(255, 0, 0)),
new(1, "Player 2", new ColorRGBA(0, 0, 255)),
};
_state = new GameState(map, players);
_state.AddUnit(new Unit(0, UnitType.Settler, new Vec2I(2, 2)));
}
public Task ProcessCommand(ClientMessage msg)
{
var context = new GameActionContext(_state);
switch (msg.Command)
{
case MoveUnitCommand move:
EnqueueAndExecute(new MoveUnitAction(move.UnitId, move.TargetPosition), context);
break;
case SettleCityCommand settle:
EnqueueAndExecute(new SettleCityAction(settle.UnitId), context);
break;
default:
throw new NotSupportedException($"Command type {msg.Command.GetType()} is not supported.");
}
return Task.CompletedTask;
}
private void EnqueueAndExecute(IGameAction action, GameActionContext context)
{
if (!action.CanExecute(context))
{
Console.WriteLine($"Rejected invalid command from player {context.CurrentPlayer.Id}");
return;
}
_state.ActionQueue.Enqueue(action);
_state.ActionQueue.ExecuteAll(context);
}
}

View File

@@ -0,0 +1,115 @@
using Civilization.Core.Interfaces;
using Civilization.Server.Networking.Interfaces;
using Civilization.Shared.Commands;
using Civilization.Shared.Packets;
using Civilization.Shared.Packets.ServerMessages;
namespace Civilization.Server.Game;
public class GameSessionCoordinator
{
private readonly GameSession _session;
private readonly Dictionary<int, bool> _connectedPlayers = new();
private readonly Dictionary<int, IClientConnection> _connections = new();
public GameSessionCoordinator()
{
_session = new GameSession(this);
}
public Task RegisterPlayerAsync(int playerId, IClientConnection connection)
{
_connectedPlayers[playerId] = true;
_connections[playerId] = connection;
Console.WriteLine($"Player {playerId} registered.");
// Send initial log message
return connection.SendAsync(new LogMessage("Welcome to the game!"));
}
public async Task ReceiveCommandAsync(ClientMessage message)
{
if (!_connectedPlayers.ContainsKey(message.PlayerId))
{
Console.WriteLine($"Rejected command from unregistered player {message.PlayerId}.");
return;
}
switch (message.Command)
{
case EndTurnCommand:
await HandleEndTurnAsync(message.PlayerId);
break;
default:
try
{
await _session.ProcessCommand(message);
var state = _session.State;
var currentPlayerInfo = new PlayerInfo(state.CurrentPlayer.Id, state.CurrentPlayer.Name, state.CurrentPlayer.Color.ToHex());
await BroadcastAsync(new StateUpdateMessage(_session.State, currentPlayerInfo));
}
catch (Exception ex)
{
Console.WriteLine($"[Player {message.PlayerId}] Error: {ex.Message}");
await SendToPlayerAsync(message.PlayerId, new ErrorMessage(ex.Message));
}
break;
}
}
public async Task SendToPlayerAsync(int playerId, BaseServerMessage message)
{
if (_connections.TryGetValue(playerId, out var conn))
{
await conn.SendAsync(message);
}
}
public async Task BroadcastAsync(BaseServerMessage message)
{
foreach (var conn in _connections.Values)
{
await conn.SendAsync(message);
}
}
public async Task BroadcastStateUpdateAsync()
{
var state = _session.State;
var currentPlayerInfo = new PlayerInfo(state.CurrentPlayer.Id, state.CurrentPlayer.Name, state.CurrentPlayer.Color.ToHex());
var msg = new StateUpdateMessage(state, currentPlayerInfo);
await BroadcastAsync(msg);
}
public async Task OnTurnAdvancedAsync()
{
var state = _session.State;
var currentPlayerInfo = new PlayerInfo(state.CurrentPlayer.Id, state.CurrentPlayer.Name, state.CurrentPlayer.Color.ToHex());
var message = new StateUpdateMessage(_session.State, currentPlayerInfo);
await BroadcastAsync(message);
}
public async Task HandleEndTurnAsync(int playerId)
{
if (playerId != _session.State.CurrentPlayer.Id)
{
Console.WriteLine($"[Turn] Player {playerId} attempted to end turn out of order.");
await SendToPlayerAsync(playerId, new ErrorMessage("Not your turn."));
return;
}
_session.State.NextTurn();
Console.WriteLine($"[Turn] Player {playerId} ended their turn. Now it's Player {_session.State.CurrentPlayer.Id}'s turn.");
await BroadcastAsync(new LogMessage($"Player {playerId} ended their turn."));
await OnTurnAdvancedAsync();
}
// Later: Add methods like:
// - BroadcastGameState()
// - GetStateForPlayer(int playerId)
// - NotifyTurnAdvance()
}

View File

@@ -0,0 +1,9 @@
using Civilization.Shared.Packets.ServerMessages;
namespace Civilization.Server.Networking.Interfaces;
public interface IClientConnection
{
int PlayerId { get; }
Task SendAsync(BaseServerMessage message);
}

View File

@@ -0,0 +1,8 @@
using Civilization.Server.Game;
namespace Civilization.Server.Networking.Interfaces;
public interface ITransport
{
Task StartAsync(GameSessionCoordinator coordinator, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json;
using Civilization.Server.Networking.Interfaces;
using Civilization.Shared;
using Civilization.Shared.Packets.ServerMessages;
namespace Civilization.Server.Networking;
public class TcpClientConnection : IClientConnection
{
private readonly StreamWriter _writer;
public int PlayerId { get; }
public TcpClientConnection(int playerId, Stream stream)
{
PlayerId = playerId;
_writer = new StreamWriter(stream) { AutoFlush = true };
}
public Task SendAsync(BaseServerMessage message)
{
var json = JsonSerializer.Serialize(message, SharedJson.Options);
return _writer.WriteLineAsync(json);
}
}

View File

@@ -0,0 +1,74 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Civilization.Server.Game;
using Civilization.Server.Networking.Interfaces;
using Civilization.Shared;
using Civilization.Shared.Packets;
namespace Civilization.Server.Networking.Transports;
public class TcpTransport : ITransport
{
private readonly int _port;
public TcpTransport(int port = 9000)
{
_port = port;
}
public async Task StartAsync(GameSessionCoordinator coordinator, CancellationToken cancellationToken = default)
{
var listener = new TcpListener(IPAddress.Any, _port);
listener.Start();
Console.WriteLine($"TCP transport listening on port {_port}");
var nextPlayerId = 0;
while (!cancellationToken.IsCancellationRequested)
{
var client = await listener.AcceptTcpClientAsync(cancellationToken);
_ = HandleClientAsync(client, nextPlayerId++, coordinator, cancellationToken);
}
}
private async Task HandleClientAsync(
TcpClient client,
int playerId,
GameSessionCoordinator coordinator,
CancellationToken cancellationToken)
{
Console.WriteLine($"Client {playerId} connected.");
var stream = client.GetStream();
var reader = new StreamReader(stream);
var writer = new StreamWriter(stream) { AutoFlush = true };
await writer.WriteLineAsync($"{{\"info\": \"You are Player {playerId}\"}}");
var connection = new TcpClientConnection(playerId, stream);
await coordinator.RegisterPlayerAsync(playerId, connection);
while (!cancellationToken.IsCancellationRequested && client.Connected)
{
var line = await reader.ReadLineAsync(cancellationToken);
if (line is null)
break;
try
{
var message = JsonSerializer.Deserialize<ClientMessage>(line, SharedJson.Options);
if (message is not null)
{
await coordinator.ReceiveCommandAsync(message);
}
}
catch (Exception ex)
{
Console.WriteLine($"[Player {playerId}] Failed to process message: {ex.Message}");
}
}
Console.WriteLine($"Client {playerId} disconnected.");
}
}

View File

@@ -0,0 +1,7 @@
using Civilization.Server.Game;
using Civilization.Server.Networking.Transports;
var transport = new TcpTransport();
var coordinator = new GameSessionCoordinator();
await transport.StartAsync(coordinator);

133
Lib/Civilization.Shared/.gitignore vendored Normal file
View File

@@ -0,0 +1,133 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
[Bb]in/
[Oo]bj/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.log
*.svclog
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# Click-Once directory
publish/
# Publish Web Output
*.Publish.xml
*.pubxml
*.azurePubxml
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
packages/
## TODO: If the tool you use requires repositories.config, also uncomment the next line
!packages/repositories.config
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Store app package directory
AppPackages/
# Others
sql/
*.Cache
ClientBin/
[Ss]tyle[Cc]op.*
![Ss]tyle[Cc]op.targets
~$*
*~
*.dbmdl
*.[Pp]ublish.xml
*.publishsettings
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
App_Data/*.mdf
App_Data/*.ldf
# =========================
# Windows detritus
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Mac desktop service store files
.DS_Store
_NCrunch*

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Civilization.Core\Civilization.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,3 @@
namespace Civilization.Shared.Commands;
public abstract record BaseCommand;

View File

@@ -0,0 +1,3 @@
namespace Civilization.Shared.Commands;
public record EndTurnCommand() : BaseCommand;

View File

@@ -0,0 +1,5 @@
using Civilization.Core;
namespace Civilization.Shared.Commands;
public record MoveUnitCommand(Guid UnitId, Vec2I TargetPosition) : BaseCommand;

View File

@@ -0,0 +1,3 @@
namespace Civilization.Shared.Commands;
public record SettleCityCommand(Guid UnitId) : BaseCommand;

View File

@@ -0,0 +1,46 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Civilization.Shared;
public class JsonPolymorphicConverter<TBase> : JsonConverter<TBase>
{
private readonly Dictionary<string, Type> _typeMap;
public JsonPolymorphicConverter()
{
_typeMap = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => typeof(TBase).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface)
.ToDictionary(t => t.Name, t => t);
}
public override TBase? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
if (!doc.RootElement.TryGetProperty("type", out var typeProp))
throw new JsonException("Missing 'type' field for polymorphic deserialization");
var typeName = typeProp.GetString();
if (typeName is null || !_typeMap.TryGetValue(typeName, out var derivedType))
throw new JsonException($"Unknown type discriminator: '{typeName}'");
var json = doc.RootElement.GetRawText();
return (TBase?)JsonSerializer.Deserialize(json, derivedType, options);
}
public override void Write(Utf8JsonWriter writer, TBase value, JsonSerializerOptions options)
{
using var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(value, value.GetType(), options));
writer.WriteStartObject();
writer.WriteString("type", value.GetType().Name);
foreach (var prop in jsonDoc.RootElement.EnumerateObject())
{
prop.WriteTo(writer);
}
writer.WriteEndObject();
}
}

View File

@@ -0,0 +1,5 @@
using Civilization.Shared.Commands;
namespace Civilization.Shared.Packets;
public record ClientMessage(int PlayerId, BaseCommand Command);

View File

@@ -0,0 +1,3 @@
namespace Civilization.Shared.Packets;
public record PlayerInfo(int Id, string Name, string ColorHex);

View File

@@ -0,0 +1,3 @@
namespace Civilization.Shared.Packets.ServerMessages;
public abstract record BaseServerMessage;

View File

@@ -0,0 +1,3 @@
namespace Civilization.Shared.Packets.ServerMessages;
public record ErrorMessage(string Reason) : BaseServerMessage;

View File

@@ -0,0 +1,3 @@
namespace Civilization.Shared.Packets.ServerMessages;
public record LogMessage(string Message) : BaseServerMessage;

View File

@@ -0,0 +1,5 @@
using Civilization.Core.Game;
namespace Civilization.Shared.Packets.ServerMessages;
public record StateUpdateMessage(GameState GameState, PlayerInfo CurrentPlayer) : BaseServerMessage;

View File

@@ -0,0 +1,21 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Civilization.Shared.Commands;
using Civilization.Shared.Packets.ServerMessages;
namespace Civilization.Shared;
public static class SharedJson
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
new JsonPolymorphicConverter<BaseCommand>(),
new JsonPolymorphicConverter<BaseServerMessage>(),
},
WriteIndented = false
};
}

1
Lib/codebase_cmd.txt Normal file
View File

@@ -0,0 +1 @@
uvx files-to-prompt Civilization.Core GodotIntegration -o civ_codebase.txt --ignore bin* --ignore *.dll --ignore *.cache --ignore *.pdb --ignore *.uid --ignore obj*