diff --git a/tests/RpgMapGridTests.cs b/tests/RpgMapGridTests.cs
new file mode 100644
index 0000000..7efe92e
--- /dev/null
+++ b/tests/RpgMapGridTests.cs
@@ -0,0 +1,122 @@
+namespace Cthangover.Mods.Rpg.Tests
+{
+ using System.Collections.Generic;
+ using Cthangover.Core.Mods.Testing;
+
+ ///
+ /// Map layers are hand-edited text: parsing must be forgiving and give a
+ /// grid of exactly the declared size, and writing must read back the same.
+ ///
+ public class RpgMapGridTests
+ {
+ [ModTest("grid/parse_reads_rows_and_collision")]
+ static void ParseReadsRowsAndCollision(ModTestContext ctx)
+ {
+ var map = new RpgMap
+ {
+ Width = 3,
+ Height = 2,
+ Layers = new RpgMapLayers
+ {
+ Back = new List { "0 1 2", "3 . 5" },
+ Main = new List { ". 8" },
+ Front = new List(),
+ },
+ Collision = new List { "#o.", ".x" },
+ };
+
+ var grid = RpgMapGrid.Parse(map);
+
+ ModAssert.Equal(1, grid.GetTile(RpgLayer.Back, 1, 0), "back (1,0)");
+ ModAssert.Equal(-1, grid.GetTile(RpgLayer.Back, 1, 1), "a dot is empty");
+ ModAssert.Equal(8, grid.GetTile(RpgLayer.Main, 1, 0), "main (1,0)");
+ ModAssert.Equal(-1, grid.GetTile(RpgLayer.Main, 2, 1), "a missing row is empty");
+ ModAssert.Equal("#", grid.GetCollision(0, 0).ToString(), "block code");
+ ModAssert.Equal("o", grid.GetCollision(1, 0).ToString(), "open code");
+ ModAssert.Equal(".", grid.GetCollision(1, 1).ToString(), "an unknown code is auto");
+ ModAssert.Equal("#", grid.GetCollision(5, 5).ToString(), "outside the map is blocked");
+ }
+
+ [ModTest("grid/parse_is_forgiving")]
+ static void ParseIsForgiving(ModTestContext ctx)
+ {
+ var map = new RpgMap
+ {
+ Width = 2,
+ Height = 1,
+ Layers = new RpgMapLayers { Back = new List { "7 abc 9 9" } },
+ };
+
+ var grid = RpgMapGrid.Parse(map);
+
+ ModAssert.Equal(2, grid.Width, "width stays declared");
+ ModAssert.Equal(7, grid.GetTile(RpgLayer.Back, 0, 0), "first cell");
+ ModAssert.Equal(-1, grid.GetTile(RpgLayer.Back, 1, 0), "an unreadable token is empty");
+ ModAssert.Equal(-1, grid.GetTile(RpgLayer.Back, 2, 0), "extra cells are cut off");
+ }
+
+ [ModTest("grid/write_reads_back")]
+ static void WriteReadsBack(ModTestContext ctx)
+ {
+ var grid = new RpgMapGrid(3, 2);
+ grid.SetTile(RpgLayer.Back, 0, 0, 12);
+ grid.SetTile(RpgLayer.Main, 2, 1, 3);
+ grid.SetTile(RpgLayer.Front, 1, 0, 0);
+ grid.SetCollision(1, 1, RpgCollision.Block);
+
+ var map = new RpgMap();
+ grid.WriteTo(map);
+ var back = RpgMapGrid.Parse(map);
+
+ ModAssert.Equal(3, map.Width, "width written");
+ ModAssert.Equal(2, map.Height, "height written");
+ ModAssert.Equal("12 . .", map.Layers.Back[0], "indices are aligned to one width");
+ ModAssert.Equal(12, back.GetTile(RpgLayer.Back, 0, 0), "back");
+ ModAssert.Equal(3, back.GetTile(RpgLayer.Main, 2, 1), "main");
+ ModAssert.Equal(0, back.GetTile(RpgLayer.Front, 1, 0), "front tile 0 is not empty");
+ ModAssert.Equal("#", back.GetCollision(1, 1).ToString(), "collision");
+ }
+
+ [ModTest("grid/all_auto_collision_writes_no_rows")]
+ static void AllAutoCollisionWritesNoRows(ModTestContext ctx)
+ {
+ var map = new RpgMap();
+ new RpgMapGrid(4, 4).WriteTo(map);
+
+ ModAssert.Equal(0, map.Collision.Count, "no collision rows");
+ ModAssert.Equal(4, map.Layers.Main.Count, "layer rows are always written");
+ }
+
+ [ModTest("grid/resize_keeps_top_left")]
+ static void ResizeKeepsTopLeft(ModTestContext ctx)
+ {
+ var grid = new RpgMapGrid(2, 2);
+ grid.SetTile(RpgLayer.Back, 1, 1, 5);
+ grid.SetCollision(0, 1, RpgCollision.Open);
+
+ grid.Resize(4, 1);
+
+ ModAssert.Equal(4, grid.Width, "new width");
+ ModAssert.Equal(1, grid.Height, "new height");
+ ModAssert.Equal(-1, grid.GetTile(RpgLayer.Back, 1, 1), "a cut row is gone");
+ ModAssert.Equal(-1, grid.GetTile(RpgLayer.Back, 3, 0), "new cells are empty");
+
+ grid.Resize(2, 2);
+ ModAssert.Equal(".", grid.GetCollision(0, 1).ToString(), "a row cut once does not come back");
+ }
+
+ [ModTest("grid/clone_is_independent")]
+ static void CloneIsIndependent(ModTestContext ctx)
+ {
+ var grid = new RpgMapGrid(2, 2);
+ grid.SetTile(RpgLayer.Main, 0, 0, 4);
+
+ var copy = grid.Clone();
+ copy.SetTile(RpgLayer.Main, 0, 0, 9);
+ copy.SetCollision(1, 1, RpgCollision.Block);
+
+ ModAssert.Equal(4, grid.GetTile(RpgLayer.Main, 0, 0), "original tile");
+ ModAssert.Equal(".", grid.GetCollision(1, 1).ToString(), "original collision");
+ }
+ }
+}
diff --git a/tests/RpgRulesTests.cs b/tests/RpgRulesTests.cs
new file mode 100644
index 0000000..03dc132
--- /dev/null
+++ b/tests/RpgRulesTests.cs
@@ -0,0 +1,101 @@
+namespace Cthangover.Mods.Rpg.Tests
+{
+ using System;
+ using System.Collections.Generic;
+ using Cthangover.Core.Mods.Testing;
+
+ /// Walking rules and random battles: the terrain decisions the world makes on every step.
+ public class RpgRulesTests
+ {
+ private static RpgTileset Tileset() => new()
+ {
+ ID = "test",
+ Tiles = new List
+ {
+ new() { Index = 5, Solid = true },
+ new() { Index = 9, Solid = false },
+ },
+ };
+
+ [ModTest("rules/solid_tile_blocks_on_back_and_main_only")]
+ static void SolidTileBlocksOnBackAndMainOnly(ModTestContext ctx)
+ {
+ var grid = new RpgMapGrid(3, 1);
+ grid.SetTile(RpgLayer.Back, 0, 0, 5);
+ grid.SetTile(RpgLayer.Main, 1, 0, 5);
+ grid.SetTile(RpgLayer.Front, 2, 0, 5);
+
+ ModAssert.False(RpgPassability.IsWalkable(grid, Tileset(), 0, 0), "solid on back");
+ ModAssert.False(RpgPassability.IsWalkable(grid, Tileset(), 1, 0), "solid on main");
+ ModAssert.True(RpgPassability.IsWalkable(grid, Tileset(), 2, 0), "front tiles are walked under");
+ ModAssert.False(RpgPassability.IsWalkable(grid, Tileset(), 3, 0), "outside the map");
+ }
+
+ [ModTest("rules/collision_layer_overrides_tiles")]
+ static void CollisionLayerOverridesTiles(ModTestContext ctx)
+ {
+ var grid = new RpgMapGrid(2, 1);
+ grid.SetTile(RpgLayer.Main, 0, 0, 5);
+ grid.SetCollision(0, 0, RpgCollision.Open);
+ grid.SetCollision(1, 0, RpgCollision.Block);
+
+ ModAssert.True(RpgPassability.IsWalkable(grid, Tileset(), 0, 0), "open beats a solid tile");
+ ModAssert.False(RpgPassability.IsWalkable(grid, Tileset(), 1, 0), "block on empty ground");
+ }
+
+ [ModTest("rules/tileset_solid_lookup")]
+ static void TilesetSolidLookup(ModTestContext ctx)
+ {
+ var tileset = Tileset();
+ ModAssert.True(tileset.IsSolid(5), "listed solid");
+ ModAssert.False(tileset.IsSolid(9), "listed walkable");
+ ModAssert.False(tileset.IsSolid(1), "unlisted");
+ ModAssert.False(tileset.IsSolid(-1), "empty cell");
+ }
+
+ [ModTest("rules/encounter_off_without_table")]
+ static void EncounterOffWithoutTable(ModTestContext ctx)
+ {
+ var random = new Random(1);
+ ModAssert.Null(RpgEncounterRoller.Roll(null, random), "no table");
+ ModAssert.Null(RpgEncounterRoller.Roll(new RpgEncounterTable { Steps = 0, Groups = new() { "a" } }, random), "steps 0");
+ ModAssert.Null(RpgEncounterRoller.Roll(new RpgEncounterTable { Steps = 1 }, random), "no groups");
+ }
+
+ [ModTest("rules/encounter_every_step_picks_a_group")]
+ static void EncounterEveryStepPicksAGroup(ModTestContext ctx)
+ {
+ var table = new RpgEncounterTable { Steps = 1, Groups = new() { "slime", "wolf,slime" } };
+ var random = new Random(7);
+ var seen = new HashSet();
+ for (var i = 0; i < 50; i++)
+ seen.Add(RpgEncounterRoller.Roll(table, random));
+
+ ModAssert.Equal(2, seen.Count, "both groups are picked, never null");
+ ModAssert.True(seen.Contains("wolf,slime"), "groups come back as written");
+ }
+
+ [ModTest("rules/encounter_rate_follows_steps")]
+ static void EncounterRateFollowsSteps(ModTestContext ctx)
+ {
+ var table = new RpgEncounterTable { Steps = 10, Groups = new() { "slime" } };
+ var random = new Random(3);
+ var battles = 0;
+ for (var i = 0; i < 10000; i++)
+ {
+ if (RpgEncounterRoller.Roll(table, random) != null)
+ battles++;
+ }
+ ModAssert.True(battles > 800 && battles < 1200, $"about one battle in ten steps, got {battles}");
+ }
+
+ [ModTest("rules/directions")]
+ static void Directions(ModTestContext ctx)
+ {
+ ModAssert.Equal(-1, RpgDirections.Delta(RpgDirection.Up).Y, "up is y-1");
+ ModAssert.Equal(1, RpgDirections.Delta(RpgDirection.Right).X, "right is x+1");
+ ModAssert.Equal("Up", RpgDirections.Opposite(RpgDirection.Down).ToString(), "opposite of down");
+ ModAssert.Equal("Left", RpgDirections.Opposite(RpgDirection.Right).ToString(), "opposite of right");
+ }
+ }
+}
diff --git a/tests/RpgStateTests.cs b/tests/RpgStateTests.cs
new file mode 100644
index 0000000..4858591
--- /dev/null
+++ b/tests/RpgStateTests.cs
@@ -0,0 +1,76 @@
+namespace Cthangover.Mods.Rpg.Tests
+{
+ using System.Text.Json;
+ using Cthangover.Core.Mods.Testing;
+ using Cthangover.Core.Settings;
+
+ /// The saved position and the map file format survive a JSON round trip.
+ public class RpgStateTests
+ {
+ [ModTest("state/save_and_load_round_trip")]
+ static void SaveAndLoadRoundTrip(ModTestContext ctx)
+ {
+ var state = RpgState.Instance;
+ var before = state.Data;
+ try
+ {
+ state.Place("rpg_forest", 4, 7, RpgDirection.Left);
+ var slot = new ModSlot();
+ var saveable = new RpgSaveable();
+ saveable.OnSave(slot);
+
+ var json = JsonSerializer.Serialize(slot);
+ var restored = JsonSerializer.Deserialize(json);
+ state.Reset();
+ ModAssert.True(saveable.OnLoad(restored, out var error), error);
+
+ ModAssert.Equal("rpg_forest", state.Data.Map, "map");
+ ModAssert.Equal(4, state.Data.X, "x");
+ ModAssert.Equal(7, state.Data.Y, "y");
+ ModAssert.Equal("Left", state.Data.Dir.ToString(), "facing");
+ }
+ finally
+ {
+ state.Data = before;
+ }
+ }
+
+ [ModTest("state/load_without_data_goes_to_spawn")]
+ static void LoadWithoutDataGoesToSpawn(ModTestContext ctx)
+ {
+ var state = RpgState.Instance;
+ var before = state.Data;
+ try
+ {
+ state.Place("rpg_forest", 1, 1, RpgDirection.Up);
+ ModAssert.True(new RpgSaveable().OnLoad(new ModSlot(), out _), "an empty slot is accepted");
+ ModAssert.Null(state.Data.Map, "no map: the next entry uses the spawn");
+ }
+ finally
+ {
+ state.Data = before;
+ }
+ }
+
+ [ModTest("state/map_file_round_trip")]
+ static void MapFileRoundTrip(ModTestContext ctx)
+ {
+ var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
+ ""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""},
+ ""Events"":[{""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}}],
+ ""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}";
+
+ var map = JsonSerializer.Deserialize(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
+ var again = JsonSerializer.Deserialize(JsonSerializer.Serialize(map), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
+
+ ModAssert.Equal("m", again.ID, "id");
+ ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case");
+ ModAssert.Equal("Touch", again.Events[0].Trigger.ToString(), "trigger");
+ ModAssert.Equal("x", again.Events[0].Teleport.Map, "teleport map");
+ ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y");
+ ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block");
+ ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
+ ModAssert.Equal(2, again.Zoom, "zoom default");
+ }
+ }
+}