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"); + } + } +}