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