diff --git a/src/Data/RpgMap.cs b/src/Data/RpgMap.cs index 143fac7..dcf7075 100644 --- a/src/Data/RpgMap.cs +++ b/src/Data/RpgMap.cs @@ -16,7 +16,10 @@ namespace Cthangover.Mods.Rpg /// /// Files declare "Kind": "rpg_map". Layers are parsed by /// ; a dependent mod replaces a whole map by id, the - /// usual entity override. + /// usual entity override. The map file holds only the ground: what stands on it + /// (NPCs, doors, chests, pickups) are events in map_events/<id>/ of any + /// mod, merged by , so a mod adds to another + /// mod's map without replacing it. /// [EntityKind("rpg_map")] public class RpgMap : IIdentifiable @@ -49,8 +52,6 @@ namespace Cthangover.Mods.Rpg /// Where the player appears when entering the map without a teleport target. public RpgTeleport Spawn { get; set; } = new(); - /// Events placed on the map. - public List Events { get; set; } = new(); /// Random battles; null for a safe map. public RpgEncounterTable Encounters { get; set; } diff --git a/src/Data/RpgMapEvent.cs b/src/Data/RpgMapEvent.cs index 07e1a7d..07c1210 100644 --- a/src/Data/RpgMapEvent.cs +++ b/src/Data/RpgMapEvent.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Cthangover.Mods.Rpg { @@ -7,7 +8,7 @@ namespace Cthangover.Mods.Rpg /// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and /// what it does are independent: /// - /// look — a charset , or one atlas , or nothing (an invisible trigger); + /// look — a charset , or one atlas , or an item , or nothing (an invisible trigger); /// effect — a , handled natively, or a dialog built /// from a file followed by inline /// (the full scenario DSL, so action rpg.battle …, flag …, quests). @@ -15,6 +16,9 @@ namespace Cthangover.Mods.Rpg /// A in the scenario condition language hides the /// event while false; it is re-checked whenever game state changes, so a /// defeated boss (flag boss_dead 1) disappears at once. + /// + /// Events are stored apart from their map, in map_events/<map>/*.json + /// of any mod (). /// public class RpgMapEvent { @@ -33,13 +37,19 @@ namespace Cthangover.Mods.Rpg /// Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none. public int Tile { get; set; } = -1; + /// + /// Picture in the items group (what an item's Sprite names), drawn fitted into the + /// cell when the event has neither a sprite nor a tile: an item lying on the ground. + /// + public string Icon { get; set; } + /// Initial facing of a sprite. public RpgDirection Dir { get; set; } = RpgDirection.Down; /// When the event fires. public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action; - /// The event occupies its cell. Null means "solid when it is visible" (a sprite or a tile). + /// The event occupies its cell. Null means "solid when it is visible" (a sprite, a tile or an icon). public bool? Solid { get; set; } /// Scenario path inside the scenarios group (extension optional), played as a dialog. @@ -54,7 +64,15 @@ namespace Cthangover.Mods.Rpg /// Scenario condition; the event exists only while it is true. public string Condition { get; set; } + /// Mod the event was read from (set by the event resolver, not stored). + [JsonIgnore] + public string ModId { get; set; } + + /// Mod-relative path of the file the event was read from (set by the event resolver, not stored). + [JsonIgnore] + public string File { get; set; } + /// Effective solidity: explicit value, else whether the event is drawn. - public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0); + public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0 || !string.IsNullOrEmpty(Icon)); } } diff --git a/src/Data/RpgMapEventFile.cs b/src/Data/RpgMapEventFile.cs new file mode 100644 index 0000000..505c4ba --- /dev/null +++ b/src/Data/RpgMapEventFile.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// One file of map events, map_events/<map id>/<any name>.json: + /// {"Events": [ … ]}. The folder names the map, so a file holds the events + /// of one map, and a mod may split them over as many files as it likes. + /// + public class RpgMapEventFile + { + /// The events of the file, in drawing order. + public List Events { get; set; } = new(); + } +} diff --git a/src/Data/RpgMapEventMerge.cs b/src/Data/RpgMapEventMerge.cs new file mode 100644 index 0000000..db29d5b --- /dev/null +++ b/src/Data/RpgMapEventMerge.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Builds the event list of every map from event files read in mod load order. + /// A new id is appended; an id already present (ignoring case) is replaced in + /// place, so the later file wins and keeps the original's drawing order. Mods + /// load after their dependencies, so the engine's replacement rule holds: + /// ship the same event id and declare depends on the owner to replace + /// it, ship a new id to add to any mod's map. + /// + /// Pure data, so the merge rules are tested without a mod set. + /// + public class RpgMapEventMerge + { + /// Events per map id (case-insensitive). + public Dictionary> Maps { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds the events of one file of for map ; + /// each event gets and . + /// Returns the ids that replaced an earlier event. + /// + public List Add(string map, string modId, string file, IEnumerable events) + { + var replaced = new List(); + if (!Maps.TryGetValue(map, out var list)) + { + list = new List(); + Maps[map] = list; + } + + foreach (var mapEvent in events ?? Array.Empty()) + { + if (mapEvent == null || string.IsNullOrWhiteSpace(mapEvent.Id)) + continue; + mapEvent.ModId = modId; + mapEvent.File = file; + var index = list.FindIndex(e => string.Equals(e.Id, mapEvent.Id, StringComparison.OrdinalIgnoreCase)); + if (index < 0) + { + list.Add(mapEvent); + continue; + } + list[index] = mapEvent; + replaced.Add(mapEvent.Id); + } + return replaced; + } + } +} diff --git a/src/Data/RpgMapEventResolver.cs b/src/Data/RpgMapEventResolver.cs new file mode 100644 index 0000000..3ad12e4 --- /dev/null +++ b/src/Data/RpgMapEventResolver.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Collects map events from map_events/<map id>/**/*.json of every + /// loaded mod in load order (files of one mod sorted by path), then from the + /// studio overlays, and merges them per map with . + /// + /// Events live apart from the map file (maps/<id>.json holds only + /// tiles, collision, spawn and encounters) so that any mod can put its own NPC, + /// chest or quest trigger on another mod's map without replacing the map, and + /// so that drawing and placing do not fight over one big file. Events of a map + /// no mod defines are kept and simply never shown. + /// + public class RpgMapEventResolver : ModAssetResolver + { + /// Group folder of event files. + public const string Directory = "map_events"; + + private Dictionary> cache; + + /// + public override string Id => "rpg_map_events"; + + /// Events per map id, merged; cached until the next mod reload or . + public Dictionary> Collect() + { + Context.Initialize(); + if (cache != null) + return cache; + + var merge = new RpgMapEventMerge(); + foreach (var modId in Context.GetOrderedModIds()) + CollectMod(Context.Mods[modId].FileProvider, modId, merge); + foreach (var overlay in ModOverlays.All) + CollectMod(overlay.Value, overlay.Key, merge); + + cache = merge.Maps; + GameLogger.Log("RPG", $"map events: {cache.Values.Sum(l => l.Count)} event(s) on {cache.Count} map(s)"); + return cache; + } + + /// + public override void ClearCache() => cache = null; + + private void CollectMod(IModFileProvider provider, string modId, RpgMapEventMerge merge) + { + foreach (var mapDir in provider.ListFiles(Directory).Where(e => e.EndsWith("/")).OrderBy(e => e, System.StringComparer.Ordinal)) + { + var map = mapDir.TrimEnd('/'); + map = map.Substring(map.LastIndexOf('/') + 1); + var files = new List(); + ListJson(provider, mapDir.TrimEnd('/'), files); + foreach (var file in files.OrderBy(f => f, System.StringComparer.Ordinal)) + ReadFile(provider, modId, map, file, merge); + } + } + + private static void ListJson(IModFileProvider provider, string dir, List files) + { + foreach (var entry in provider.ListFiles(dir)) + { + if (entry.EndsWith("/")) + ListJson(provider, entry.TrimEnd('/'), files); + else if (entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase)) + files.Add(entry); + } + } + + private void ReadFile(IModFileProvider provider, string modId, string map, string file, RpgMapEventMerge merge) + { + var text = Context.ReadResolvedText(modId, file, provider); + if (string.IsNullOrWhiteSpace(text)) + return; + try + { + var data = JsonSerializer.Deserialize(text, JsonOptionsProvider.ReadOptions); + foreach (var id in merge.Add(map, modId, file, data?.Events)) + GameLogger.Log("RPG", $"map '{map}': event '{id}' replaced by mod '{modId}' ({file})"); + } + catch (JsonException ex) + { + GameLogger.Log("RPG", $"map events '{modId}/{file}': broken JSON: {ex.Message}", LogLevel.Error); + } + } + } +} diff --git a/src/Data/RpgMapEvents.cs b/src/Data/RpgMapEvents.cs new file mode 100644 index 0000000..1276121 --- /dev/null +++ b/src/Data/RpgMapEvents.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Cthangover.Core.Mods; + +namespace Cthangover.Mods.Rpg +{ + /// Access to the merged map events of . + public static class RpgMapEvents + { + /// The events of a map (an empty list when it has none); the list is shared, do not change it. + public static IReadOnlyList Of(string map) + { + var maps = ModManager.Instance.GetResolver()?.Collect(); + return map != null && maps != null && maps.TryGetValue(map, out var list) ? list : new List(); + } + + /// Forgets the collected events, so the next reads the files again (after an editor save). + public static void Invalidate() => ModManager.Instance.GetResolver()?.ClearCache(); + } +} diff --git a/src/World/RpgActor.cs b/src/World/RpgActor.cs index 26ae03f..794ea52 100644 --- a/src/World/RpgActor.cs +++ b/src/World/RpgActor.cs @@ -79,6 +79,25 @@ namespace Cthangover.Mods.Rpg AddChild(sprite); } + /// + /// Shows an item picture fitted into three quarters of the cell and centred on it, + /// so an icon of any size lies on the ground like a tile-sized object. + /// + public void UseIcon(Texture2D texture) + { + if (texture == null) + return; + var fit = tileSize * 0.75f / Mathf.Max(1, Mathf.Max(texture.GetWidth(), texture.GetHeight())); + sprite = new Sprite2D + { + Texture = texture, + Position = new Vector2(tileSize, tileSize) / 2f, + Scale = new Vector2(fit, fit), + TextureFilter = TextureFilterEnum.Linear, + }; + AddChild(sprite); + } + /// Puts the actor on a cell at once, stopping any step. public void Place(Vector2I cell, RpgDirection facing) { diff --git a/src/World/RpgTextures.cs b/src/World/RpgTextures.cs index 3f9e3f1..43a1164 100644 --- a/src/World/RpgTextures.cs +++ b/src/World/RpgTextures.cs @@ -8,7 +8,8 @@ namespace Cthangover.Mods.Rpg /// Image lookup of the RPG world: tileset atlases in the tilesets file /// group, charsets in charsets. Both are file groups (last mod wins), /// not texture groups, so their names never collide with backgrounds or - /// icons. Decoded textures are kept for the life of one world, which builds + /// icons; item pictures for come from the + /// items group the inventory uses. Decoded textures are kept for the life of one world, which builds /// and owns an instance; the editor keeps its own. /// public class RpgTextures @@ -19,6 +20,9 @@ namespace Cthangover.Mods.Rpg /// File group of charset images. public const string CharsetGroup = "charsets"; + /// File group of item pictures (an item's Sprite). + public const string IconGroup = "items"; + private readonly Dictionary cache = new(); /// Tileset atlas image, or null (logged by the loader). @@ -27,6 +31,9 @@ namespace Cthangover.Mods.Rpg /// Charset image, or null. public Texture2D Charset(string path) => Load(CharsetGroup, path); + /// Item picture, or null. + public Texture2D Icon(string path) => Load(IconGroup, path); + private Texture2D Load(string group, string path) { if (string.IsNullOrWhiteSpace(path)) diff --git a/src/World/RpgWorldView.cs b/src/World/RpgWorldView.cs index f09e518..3f9f160 100644 --- a/src/World/RpgWorldView.cs +++ b/src/World/RpgWorldView.cs @@ -66,7 +66,7 @@ namespace Cthangover.Mods.Rpg AddChild(actorLayer); AddChild(BuildLayer(RpgLayer.Front, tileSet)); - foreach (var mapEvent in map.Events ?? new List()) + foreach (var mapEvent in RpgMapEvents.Of(map.ID)) AddEventActor(mapEvent); RefreshEvents(); @@ -227,6 +227,8 @@ namespace Cthangover.Mods.Rpg actor.UseCharset(textures.Charset(mapEvent.Sprite)); else if (mapEvent.Tile >= 0 && Tileset != null) actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, mapEvent.Tile)); + else if (!string.IsNullOrEmpty(mapEvent.Icon)) + actor.UseIcon(textures.Icon(mapEvent.Icon)); actor.Place(new Vector2I(mapEvent.X, mapEvent.Y), mapEvent.Dir); actorLayer.AddChild(actor); eventActors[mapEvent.Id] = actor; diff --git a/test_suites/walk/manifest.json b/test_suites/walk/manifest.json index c9a5925..6185262 100644 --- a/test_suites/walk/manifest.json +++ b/test_suites/walk/manifest.json @@ -1,6 +1,6 @@ { "name": "RPG Walk Test", - "description": "Integration test of the rpg mod on the rpg_demo game: real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. Activated by --itest=rpg_walk with --game=rpg_demo --new; --shots= saves screenshots (windowed runs only).", + "description": "Integration test of the rpg mod on the rpg_demo game: real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. A pickup the suite adds to the village through its own map_events file is picked up and must stay gone after leaving the map and coming back, and after a save is loaded. Activated by --itest=rpg_walk with --game=rpg_demo --new; --shots= saves screenshots (windowed runs only).", "sources": [ "src/*.cs" ], diff --git a/test_suites/walk/map_events/rpg_village/rpg_walk_test.json b/test_suites/walk/map_events/rpg_village/rpg_walk_test.json new file mode 100644 index 0000000..d6f9114 --- /dev/null +++ b/test_suites/walk/map_events/rpg_village/rpg_walk_test.json @@ -0,0 +1,19 @@ +{ + "Events": [ + { + "Id": "rpg_walk_mushroom", + "X": 18, + "Y": 14, + "Icon": "rpg_demo/forest_mushroom", + "Trigger": "Touch", + "Solid": false, + "Commands": [ + "ptext \"Found: a mushroom\"", + "action item.add id=rpg_demo/mushroom count=1", + "flag rpg_village_rpg_walk_mushroom_taken 1", + "end" + ], + "Condition": "rpg_village_rpg_walk_mushroom_taken != 1" + } + ] +} diff --git a/test_suites/walk/src/RpgWalkTestDriver.cs b/test_suites/walk/src/RpgWalkTestDriver.cs index 9f56902..d1f0c67 100644 --- a/test_suites/walk/src/RpgWalkTestDriver.cs +++ b/test_suites/walk/src/RpgWalkTestDriver.cs @@ -6,6 +6,7 @@ using Cthangover.Core.Factories.Impls; using Cthangover.Core.Games; using Cthangover.Core.Scenes; using Cthangover.Core.Settings; +using Cthangover.Core.Items; using Cthangover.Core.Utils; using Cthangover.Mods.Interface.View; using Godot; @@ -17,6 +18,13 @@ namespace Cthangover.Mods.Rpg.Tests /// , so the world reads them exactly as /// it reads a keyboard (physical keys, held state, unhandled action key). /// + /// A pickup is checked across map rebuilds: the suite's own + /// map_events/rpg_village/rpg_walk_test.json lays a mushroom on the demo's + /// village (an event added by another mod, shaped like the map editor's + /// pickups); it is picked up, the player leaves the map and comes back, then + /// saves, loads and comes back again — it must never reappear nor give a + /// second item. + /// /// One step per timer tick; a step either waits (returns without /// advancing) or checks and moves on. A failed check is recorded and the /// run goes on; an exception ends it. Result line: @@ -31,12 +39,18 @@ namespace Cthangover.Mods.Rpg.Tests private const string House = "rpg_elder_house"; private const string WinFlag = "rpg_test_win"; private const string SaveSlot = "slot_9901"; + private const string PickupId = "rpg_walk_mushroom"; + private const string PickupFlag = "rpg_village_rpg_walk_mushroom_taken"; + private const string Mushroom = "rpg_demo/mushroom"; + + private static readonly Vector2I PickupCell = new(18, 14); private readonly string shotsDir; private readonly List failures = new(); private int passed; private int step; private int waitTicks; + private int mushroomsBefore; private double elapsed; /// Creates the driver; null disables screenshots. @@ -51,6 +65,10 @@ namespace Cthangover.Mods.Rpg.Tests private static RpgWorldView View => RpgWorldHost.Current?.View; + private static IInventory Inventory => GameData.Instance.Runtime.Inventory; + + private static string Flag(string name) => GameData.Instance.Runtime.Flags.TryGetValue(name, out var value) ? value : null; + private void Tick() { if (step < 0) @@ -142,17 +160,63 @@ namespace Cthangover.Mods.Rpg.Tests case 9: if (AdvanceDialog()) return; + // A pickup this suite adds to the demo's village (map_events of another mod). + mushroomsBefore = Inventory.CheckCount(Mushroom); + Check(View.FindEvent(PickupId)?.Visible == true, "a test mod's map_events file adds a pickup to the demo's village"); + View.PlacePlayer(PickupCell.X - 1, PickupCell.Y, RpgDirection.Right); + Press(Key.D, true); + Next(); + return; + case 10: + if (View.Player.Cell.X < PickupCell.X) + return; + Press(Key.D, false); + Next(); + return; + case 11: + // Stepping on the pickup starts its dialog; the flag is set when it ends. + if (AdvanceDialog() || Flag(PickupFlag) != "1") + return; + Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"stepping on the pickup adds the item, {Inventory.CheckCount(Mushroom)} now"); + Check(View.FindEvent(PickupId)?.Visible == false, "the picked item leaves the map"); + RpgWorldHost.Teleport(new RpgTeleport { Map = House, X = 6, Y = 8, Dir = RpgDirection.Up }); + Next(); + return; + case 12: + if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || AdvanceDialog()) + return; + RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right }); + Next(); + return; + case 13: + if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog()) + return; + CheckPickupGone("after leaving the map and coming back"); + Press(Key.D, true); + Next(); + return; + case 14: + if (View.Player.Cell.X < PickupCell.X) + return; + Press(Key.D, false); + Next(); + return; + case 15: + if (View.Player.IsMoving || ++waitTicks < 5) + return; + Check(RpgInput.FindDialogBox()?.IsRunning != true && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, + "stepping on the empty cell again gives nothing"); View.PlacePlayer(6, 7, RpgDirection.Up); Press(Key.W, true); Next(); return; - case 10: + case 16: if (!View.Player.IsMoving && View.Map.ID == Village) return; Press(Key.W, false); Next(); return; - case 11: + case 17: if (View?.Map?.ID != House) return; if (AdvanceDialog()) @@ -163,14 +227,14 @@ namespace Cthangover.Mods.Rpg.Tests ScenarioCondition.SetFlag("rpg_wolf_dead", "1"); Next(); return; - case 12: + case 18: Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once"); ScenarioCondition.SetFlag("rpg_wolf_dead", "0"); Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map"); Godot.Engine.TimeScale = 4; Next(); return; - case 13: + case 19: if (GetTree().CurrentScene is not BattleSceneContext battle) return; // The core's auto turn only passes, so the hero would lose: once the @@ -184,14 +248,14 @@ namespace Cthangover.Mods.Rpg.Tests battle.ShowWinground(); Next(); return; - case 14: + case 20: if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree()) return; Shot("victory"); button.EmitSignal(BaseButton.SignalName.Pressed); Next(); return; - case 15: + case 21: if (View?.Map?.ID != House || GodotSceneService.IsTransitioning) return; Godot.Engine.TimeScale = 1; @@ -202,19 +266,27 @@ namespace Cthangover.Mods.Rpg.Tests GameSession.OpenMainMenu(GetTree()); Next(); return; - case 16: + case 22: if (RpgWorldHost.Current != null || ++waitTicks < 5) return; Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads"); Next(); return; - case 17: + case 23: if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5) return; Shot("loaded"); var dialog = RpgInput.FindDialogBox(); Check(dialog != null && !dialog.IsRunning && !dialog.IsVisibleInTree(), $"no dialog box after loading (running={dialog?.IsRunning}, visible={dialog?.IsVisibleInTree()})"); Check(View.Player.Cell == new Vector2I(6, 8), $"the loaded game puts the player on the saved cell, got {View.Player.Cell}"); + Check(Flag(PickupFlag) == "1" && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, "the save keeps the pickup's flag and the picked item"); + RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right }); + Next(); + return; + case 24: + if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog()) + return; + CheckPickupGone("after loading the save"); SaveService.DeleteSave(SaveSlot); Finish(); return; @@ -237,6 +309,15 @@ namespace Cthangover.Mods.Rpg.Tests } } + /// The map was built again (a teleport or a load): the picked item must stay gone and give nothing. + private void CheckPickupGone(string when) + { + var pickup = View.FindEvent(PickupId); + Check(pickup != null && !pickup.Visible, $"the picked item does not come back {when}"); + Check(View.EventAt(PickupCell, RpgEventTrigger.Touch) == null, $"its cell has no pickup to step on {when}"); + Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"the inventory still holds exactly the one picked item {when}"); + } + /// Advances a running dialog with the action key; true while it is still running. private bool AdvanceDialog() { diff --git a/tests/RpgStateTests.cs b/tests/RpgStateTests.cs index 4858591..b17a5f5 100644 --- a/tests/RpgStateTests.cs +++ b/tests/RpgStateTests.cs @@ -57,7 +57,6 @@ namespace Cthangover.Mods.Rpg.Tests { 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); @@ -65,12 +64,62 @@ namespace Cthangover.Mods.Rpg.Tests ModAssert.Equal("m", again.ID, "id"); ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case"); + ModAssert.Equal(20, again.Encounters.Steps, "encounter steps"); + ModAssert.Equal("slime", again.Encounters.Groups[0], "encounter group"); + ModAssert.Equal(2, again.Zoom, "zoom default"); + } + + [ModTest("state/event_file_round_trip")] + static void EventFileRoundTrip(ModTestContext ctx) + { + var json = @"{""Events"":[ + {""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}}, + {""Id"":""herb"",""X"":2,""Y"":1,""Icon"":""rpg_demo/forest_mushroom"",""Commands"":[""end""],""Condition"":""herb != 1""}]}"; + + var file = JsonSerializer.Deserialize(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); + file.Events[0].ModId = "some_mod"; + var text = JsonSerializer.Serialize(file); + var again = JsonSerializer.Deserialize(text, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); + + ModAssert.Equal(2, again.Events.Count, "both events"); 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"); + ModAssert.Equal("rpg_demo/forest_mushroom", again.Events[1].Icon, "icon"); + ModAssert.True(again.Events[1].IsSolid, "an icon is drawn, so it blocks unless told otherwise"); + ModAssert.Equal("herb != 1", again.Events[1].Condition, "condition"); + ModAssert.Equal("end", again.Events[1].Commands[0], "commands"); + ModAssert.False(text.Contains("some_mod"), "where an event came from is not written"); + } + + [ModTest("state/event_merge_adds_and_replaces")] + static void EventMergeAddsAndReplaces(ModTestContext ctx) + { + var merge = new RpgMapEventMerge(); + merge.Add("village", "base_game", "map_events/village/events.json", new[] + { + new RpgMapEvent { Id = "elder", X = 1, Y = 1 }, + new RpgMapEvent { Id = "sign", X = 2, Y = 1 }, + new RpgMapEvent { Id = " " } + }); + var replaced = merge.Add("VILLAGE", "quest_mod", "map_events/village/quest.json", new[] + { + new RpgMapEvent { Id = "Elder", X = 5, Y = 5 }, + new RpgMapEvent { Id = "quest_giver", X = 3, Y = 3 } + }); + merge.Add("forest", "quest_mod", "map_events/forest/a.json", null); + + var village = merge.Maps["village"]; + ModAssert.Equal(3, village.Count, "an event without an id is skipped, a new id is added"); + ModAssert.Equal(1, replaced.Count, "one replacement reported"); + ModAssert.Equal("Elder", replaced[0], "the replacing id"); + ModAssert.Equal(5, village[0].X, "the later mod's event replaces in place (any case)"); + ModAssert.Equal("quest_mod", village[0].ModId, "the replacement remembers its mod"); + ModAssert.Equal("sign", village[1].Id, "the others keep their order"); + ModAssert.Equal("base_game", village[1].ModId, "and their mod"); + ModAssert.Equal("map_events/village/quest.json", village[2].File, "an added event remembers its file"); + ModAssert.Equal(0, merge.Maps["forest"].Count, "a file without events adds nothing"); } } }