From 3fc15091faddff42e922a55db73e5d790be6a00c Mon Sep 17 00:00:00 2001
From: KAMKEEL <28842281+KAMKEEL@users.noreply.github.com>
Date: Fri, 14 Aug 2026 00:20:02 -0400
Subject: [PATCH] Cover quests, effects, player records and the world documents
Extends the generator from dialogs and factions to most of what a CustomNPC+
world holds: 146 files where there were 70.
Quests walk every axis -- all six types, all eight repeat modes, both completion
modes, the nine reward slots, the party and profile blocks in both their written
and their omitted form, and each objective kind in depth. Worth recording that a
quest has no availability at all: Quest.readNBTPartial calls
VersionCompatibility.CheckAvailabilityCompatibility, whose name says otherwise,
but that is a generic fill-in-missing-keys pass applied to every ICompatibilty.
Quest declares no Availability field, writeToNBTPartial emits no Availability*
key, and neither does the 1.20 line. Gating belongs to the dialog that offers the
quest.
Custom effects turned out to persist in two places: customeffects.dat is only an
index of {Name, ID}, and each effect is a file under customeffects/ whose
FILENAME is its name -- loadEffectsFromDir overwrites the name key after reading,
so the two can disagree and the file wins. There is a fixture for that, one for
an effect with no name at all (absent from the index, and its file is ".json"),
and one whose script crosses 65535 chars so ScriptContainer splits it across
Script plus ExpandedScript0..N. It splits into two parts; the fixture records
that rather than predicting it.
Player records exercise all fifteen sub-writers, including a quest whose
ExtraData holds the per-objective counters -- the quest itself stores only its
targets, so progress lives nowhere else. Written in both containers, since
ConfigMain.DatFormat picks between JSON and gzipped NBT and a server will have
either. PlayerData.getNBT ends at DBCAddon.instance.writeToNBT, so boot now
constructs the shell addon; without the companion mod every method is a no-op,
which is the state a real server is in.
Also global, tags, transport, banks and spawns. Two shapes there are easy to
miss: a spawn entry numbers its variants as SpawnCompound0..N keys rather than a
list, so they must be probed for; and transport stores a bare int dimension
beside a position, including a modded 7265, which is the signature that stopped
being expressible after 1.16.
Tag.uuid defaults to randomUUID(), so every tag fixture pins one. Two full runs
still diff clean across all 146 files.
---
.../java/kamkeel/npcs/fixtures/Fixtures.java | 7 +
.../kamkeel/npcs/fixtures/WorldWriter.java | 29 ++
.../npcs/fixtures/content/EffectFixtures.java | 187 ++++++++
.../npcs/fixtures/content/PlayerFixtures.java | 133 ++++++
.../npcs/fixtures/content/QuestFixtures.java | 445 ++++++++++++++++++
.../fixtures/content/WorldDataFixtures.java | 256 ++++++++++
.../npcs/fixtures/worlds/MaximalWorld.java | 65 +++
7 files changed, 1122 insertions(+)
create mode 100644 src/fixtures/java/kamkeel/npcs/fixtures/content/EffectFixtures.java
create mode 100644 src/fixtures/java/kamkeel/npcs/fixtures/content/PlayerFixtures.java
create mode 100644 src/fixtures/java/kamkeel/npcs/fixtures/content/QuestFixtures.java
create mode 100644 src/fixtures/java/kamkeel/npcs/fixtures/content/WorldDataFixtures.java
diff --git a/src/fixtures/java/kamkeel/npcs/fixtures/Fixtures.java b/src/fixtures/java/kamkeel/npcs/fixtures/Fixtures.java
index 92d4c8f15..b008e0e41 100644
--- a/src/fixtures/java/kamkeel/npcs/fixtures/Fixtures.java
+++ b/src/fixtures/java/kamkeel/npcs/fixtures/Fixtures.java
@@ -1,6 +1,7 @@
package kamkeel.npcs.fixtures;
import cpw.mods.fml.common.Loader;
+import kamkeel.npcs.addon.DBCAddon;
import net.minecraft.init.Bootstrap;
import net.minecraft.init.Items;
import net.minecraft.item.Item;
@@ -48,6 +49,12 @@ public final class Fixtures {
}
installBareLoader();
Bootstrap.func_151354_b();
+ // PlayerData.getNBT ends with DBCAddon.instance.writeToNBT(...), and the field is only set
+ // by the constructor. DBCAddon is a shell whose methods a companion mod replaces by mixin,
+ // so every method here is a no-op -- constructing one costs nothing and is what stops a
+ // player record from throwing on its last line. Without the addon installed this is
+ // exactly the state a real server is in.
+ new DBCAddon();
booted = true;
verifyRegistry();
}
diff --git a/src/fixtures/java/kamkeel/npcs/fixtures/WorldWriter.java b/src/fixtures/java/kamkeel/npcs/fixtures/WorldWriter.java
index 225ebd01a..99f948561 100644
--- a/src/fixtures/java/kamkeel/npcs/fixtures/WorldWriter.java
+++ b/src/fixtures/java/kamkeel/npcs/fixtures/WorldWriter.java
@@ -3,6 +3,7 @@ package kamkeel.npcs.fixtures;
import net.minecraft.nbt.CompressedStreamTools;
import net.minecraft.nbt.NBTTagCompound;
import noppes.npcs.NoppesStringUtils;
+import noppes.npcs.controllers.data.CustomEffect;
import noppes.npcs.controllers.data.Dialog;
import noppes.npcs.controllers.data.Quest;
import noppes.npcs.util.NBTJsonUtil;
@@ -82,6 +83,34 @@ public final class WorldWriter {
manifest.record(world, "quest", String.valueOf(quest.id), rel(file), note);
}
+ // ------------------------------------------------------------------
+ // Custom effects -- CustomEffectController.saveCustomEffect / loadEffectsFromDir
+ //
+ // The filename is the name: loadEffectsFromDir overwrites whatever `name` the file contained
+ // with the filename minus .json. An empty category means the effect sits at the root of
+ // customeffects/, which is what CategoryManager calls Uncategorized -- there is no category
+ // file anywhere, only the directory.
+ // ------------------------------------------------------------------
+ public void effect(String category, CustomEffect effect, String note) {
+ effect(category, effect.getName(), effect, note);
+ }
+
+ /**
+ * As {@link #effect}, with the filename stated separately.
+ *
+ *
Needed because the filename and the {@code name} key are genuinely independent on disk,
+ * and a fixture proving the loader prefers the filename has to be able to disagree with it.
+ */
+ public void effect(String category, String fileName, CustomEffect effect, String note) {
+ File dir = new File(customnpcs, "customeffects");
+ if (category != null && !category.isEmpty()) {
+ dir = new File(dir, NoppesStringUtils.cleanFileName(category));
+ }
+ File file = new File(dir, NoppesStringUtils.cleanFileName(fileName) + ".json");
+ writeJson(file, effect.writeToNBT(true));
+ manifest.record(world, "effect", String.valueOf(effect.id), rel(file), note);
+ }
+
/**
* A dialog written from a compound rather than from a {@link Dialog}.
*
diff --git a/src/fixtures/java/kamkeel/npcs/fixtures/content/EffectFixtures.java b/src/fixtures/java/kamkeel/npcs/fixtures/content/EffectFixtures.java
new file mode 100644
index 000000000..c3cfcca9d
--- /dev/null
+++ b/src/fixtures/java/kamkeel/npcs/fixtures/content/EffectFixtures.java
@@ -0,0 +1,187 @@
+package kamkeel.npcs.fixtures.content;
+
+import kamkeel.npcs.fixtures.Case;
+import net.minecraft.nbt.NBTTagCompound;
+import net.minecraft.nbt.NBTTagList;
+import noppes.npcs.controllers.ScriptContainer;
+import noppes.npcs.controllers.data.CustomEffect;
+import noppes.npcs.controllers.data.EffectScript;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Custom effects, which persist in two places at once.
+ *
+ *
Each effect is a file at {@code customeffects/.json} -- or
+ * {@code customeffects//.json}, where the subdirectory is the category -- written
+ * with {@code writeToNBT(true)}. The filename is the name: {@code loadEffectsFromDir}
+ * overwrites whatever {@code name} the file contained with the filename minus {@code .json}, so
+ * the {@code name} key inside is dead weight and the two can legitimately disagree. There is a
+ * fixture for that below.
+ *
+ * Alongside them, {@code customeffects.dat} is an index of {@code {Name, ID}} pairs plus
+ * {@code lastID} -- not the effects themselves. An effect whose name is empty is left out of the
+ * index entirely, which is its own case.
+ */
+public final class EffectFixtures {
+
+ private EffectFixtures() {
+ }
+
+ public static List> maximal() {
+ List> out = new ArrayList>();
+
+ CustomEffect bare = effect(1, "Bare");
+ out.add(new Case("", bare,
+ "every field at its constructor default. Note menuName defaults to a coloured "
+ + "'NEW EFFECT' containing a section sign, so even the bare case is not ASCII"));
+
+ CustomEffect full = effect(2, "Fully Configured");
+ full.menuName = Text.SECTION + "bFully " + Text.SECTION + "6Configured";
+ full.length = 1200;
+ full.everyXTick = 10;
+ full.lossOnDeath = false;
+ full.icon = "customnpcs:textures/gui/effects.png";
+ full.iconX = 32;
+ full.iconY = 48;
+ full.width = 24;
+ full.height = 24;
+ out.add(new Case("", full,
+ "every scalar off its default: a 60-second duration, a 10-tick period, an icon with "
+ + "its atlas offsets and size, and lossOnDeath cleared"));
+
+ CustomEffect animated = effect(3, "Animated Icon");
+ animated.animated = true;
+ animated.frameCount = 8;
+ animated.frametime = 3;
+ animated.icon = "customnpcs:textures/gui/anim.png";
+ out.add(new Case("", animated,
+ "an animated icon -- iconAnimated, iconFrameCount and iconFrameTime. Both counts are "
+ + "clamped to a minimum of 1 on read, so zero cannot round-trip"));
+
+ CustomEffect tagged = effect(4, "Tagged");
+ tagged.tagUUIDs.addAll(Arrays.asList(
+ UUID.fromString("0e5f2b6a-1c3d-4e5f-8a9b-0c1d2e3f4a5b"),
+ UUID.fromString("1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9"),
+ UUID.fromString("ffffffff-ffff-4fff-bfff-ffffffffffff")));
+ out.add(new Case("", tagged,
+ "three tag UUIDs. TagController.writeTagUUIDs omits the key entirely when the set is "
+ + "empty, so every other effect here has no TagUUIDs key at all"));
+
+ CustomEffect scripted = effect(5, "Scripted");
+ scripted.setScriptHandler(script(
+ "function onEffectAdd(event) { event.player.message('effect added'); }\n"
+ + "function onEffectTick(event) {}\n"
+ + "function onEffectRemove(event) {}\n", true, "ECMAScript"));
+ out.add(new Case("", scripted,
+ "a ScriptData compound carrying ScriptLanguage, ScriptEnabled and a ScriptContent "
+ + "unit with three hook functions"));
+
+ CustomEffect scriptDisabled = effect(6, "Script Disabled");
+ scriptDisabled.setScriptHandler(script("function onEffectTick(event) {}", false, "javascript"));
+ out.add(new Case("", scriptDisabled,
+ "ScriptEnabled false, and a language string that is not the canonical 'ECMAScript' -- "
+ + "normalizeLanguage runs on read, not on write"));
+
+ CustomEffect hugeScript = effect(7, "Huge Script");
+ StringBuilder sb = new StringBuilder();
+ while (sb.length() < 70000) {
+ sb.append("// a line of script that exists only to push past the 65535 char limit\n");
+ }
+ hugeScript.setScriptHandler(script(sb.toString(), true, "ECMAScript"));
+ out.add(new Case("", hugeScript,
+ "a script over 65535 characters. NBT strings cannot hold that, so ScriptContainer "
+ + "splits it across Script plus ExpandedScript0..N, bounded by "
+ + "ConfigScript.ExpandedScriptLimit which defaults to 2. Check the written file "
+ + "to see how many parts survived -- this fixture exists to record that"));
+
+ CustomEffect noName = effect(8, "");
+ out.add(new Case("", noName,
+ "an EMPTY name. writeMapNBT skips these, so this effect is absent from "
+ + "customeffects.dat while its file exists on disk -- and since the filename is "
+ + "the name, its file is '.json'. Recorded rather than avoided"));
+
+ CustomEffect categorised = effect(10, "In A Category");
+ out.add(new Case("Buffs", categorised,
+ "an effect inside a category subdirectory. Categories are directories and nothing "
+ + "else -- there is no category file and no id stored anywhere"));
+
+ CustomEffect unicode = effect(11, "Unicode");
+ unicode.menuName = Text.KATAKANA_FACTION + " " + Text.EM_DASH + " " + Text.ELEVES;
+ out.add(new Case("Buffs", unicode,
+ "a non-Latin menu name inside a category"));
+
+ CustomEffect extremes = effect(12, "Extremes");
+ extremes.length = Integer.MAX_VALUE;
+ extremes.everyXTick = Integer.MAX_VALUE;
+ extremes.iconX = Integer.MIN_VALUE;
+ extremes.iconY = Integer.MIN_VALUE;
+ extremes.width = 0;
+ extremes.height = 0;
+ extremes.frameCount = 0;
+ extremes.frametime = 0;
+ out.add(new Case("", extremes,
+ "integer boundaries, plus zero width, height, frame count and frame time. Width and "
+ + "height fall back to 16 on read only when their key is ABSENT, so an explicit "
+ + "zero survives where a missing key does not"));
+
+ return out;
+ }
+
+ /**
+ * The filename for {@link #nameMismatch()}, deliberately unequal to the effect's name field.
+ */
+ public static final String MISMATCH_FILENAME = "Name On Disk";
+
+ /**
+ * An effect whose {@code name} key disagrees with its filename.
+ *
+ * Kept out of {@link #maximal()} because it cannot be expressed there: everything in that
+ * list is written to a file named after the effect, which is what makes a mismatch impossible
+ * to state. loadEffectsFromDir assigns {@code effect.name} from the filename after reading, so
+ * the key inside is discarded and a reader that trusts it gets a different answer.
+ */
+ public static CustomEffect nameMismatch() {
+ CustomEffect mismatched = effect(9, "Name Inside The File");
+ return mismatched;
+ }
+
+ /** The index document. Copied from CustomEffectController.writeMapNBT. */
+ public static NBTTagCompound index(List> effects, int lastUsedId) {
+ NBTTagList list = new NBTTagList();
+ for (Case c : effects) {
+ CustomEffect effect = c.value;
+ // writeMapNBT skips unnamed effects. Reproduced, because the omission is the contract.
+ if (!effect.getName().isEmpty()) {
+ NBTTagCompound entry = new NBTTagCompound();
+ entry.setString("Name", effect.getName());
+ entry.setInteger("ID", effect.id);
+ list.appendTag(entry);
+ }
+ }
+ NBTTagCompound compound = new NBTTagCompound();
+ compound.setTag("CustomEffects", list);
+ compound.setInteger("lastID", lastUsedId);
+ return compound;
+ }
+
+ private static CustomEffect effect(int id, String name) {
+ CustomEffect effect = new CustomEffect();
+ effect.id = id;
+ effect.name = name;
+ return effect;
+ }
+
+ private static EffectScript script(String source, boolean enabled, String language) {
+ EffectScript handler = new EffectScript();
+ handler.setLanguage(language);
+ handler.setEnabled(enabled);
+ ScriptContainer container = new ScriptContainer(handler);
+ container.script = source;
+ handler.addScriptUnit(container);
+ return handler;
+ }
+}
diff --git a/src/fixtures/java/kamkeel/npcs/fixtures/content/PlayerFixtures.java b/src/fixtures/java/kamkeel/npcs/fixtures/content/PlayerFixtures.java
new file mode 100644
index 000000000..a836b28fa
--- /dev/null
+++ b/src/fixtures/java/kamkeel/npcs/fixtures/content/PlayerFixtures.java
@@ -0,0 +1,133 @@
+package kamkeel.npcs.fixtures.content;
+
+import kamkeel.npcs.fixtures.Case;
+import kamkeel.npcs.fixtures.Fixtures;
+import net.minecraft.nbt.NBTTagCompound;
+import net.minecraft.nbt.NBTTagList;
+import noppes.npcs.controllers.data.PlayerData;
+import noppes.npcs.controllers.data.Quest;
+import noppes.npcs.controllers.data.QuestData;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Per-player records, and the name/uuid index beside them.
+ *
+ * A record is {@code playerdata/.json}, written by {@code PlayerData.getNBT}, which
+ * composes fifteen sub-writers -- dialog, bank, quest, transport, faction, item giver, mail,
+ * timers, skin overlays, animation, effect, magic, trade, ability and hotbar. Only some are
+ * populated here; the rest write their empty form, which is itself worth having, since "what an
+ * untouched sub-record looks like" is a contract too.
+ *
+ * {@code playerdatamap.dat} is a flat {@code {Name, UUID}} list. It is a lookup index rebuilt
+ * by scanning the directory when absent, so it is derived rather than authoritative -- but it is
+ * what CustomNPC+ reads first.
+ *
+ *
The record can also be gzipped NBT rather than JSON, chosen by {@code ConfigMain.DatFormat}.
+ * Both forms are generated, because a reader that only handles the default will meet the other on
+ * somebody's server.
+ */
+public final class PlayerFixtures {
+
+ private PlayerFixtures() {
+ }
+
+ /** uuid -> display name, in a fixed order so the index document is reproducible. */
+ public static Map roster() {
+ Map roster = new LinkedHashMap();
+ roster.put("29cc52dd-2c50-4e8f-a388-be6c497cf0b4", "Fresh");
+ roster.put("8667ba71-b85a-4004-af54-457a9734eed7", "Progressed");
+ roster.put("069a79f4-44e9-4726-a5be-fca90e38aaf5", "Notch");
+ roster.put("00000000-0000-0000-0000-000000000000", "NilUuid");
+ return roster;
+ }
+
+ public static List> records() {
+ List> out = new ArrayList>();
+
+ PlayerData fresh = player("29cc52dd-2c50-4e8f-a388-be6c497cf0b4", "Fresh");
+ out.add(new Case("", fresh,
+ "a player who has done nothing. Every one of the fifteen sub-records writes its empty "
+ + "form -- the baseline for what an untouched record contains"));
+
+ PlayerData progressed = player("8667ba71-b85a-4004-af54-457a9734eed7", "Progressed");
+ progressed.dialogData.dialogsRead.addAll(Arrays.asList(1, 2, 3, 41, 90));
+ progressed.factionData.factionData.put(0, 1800);
+ progressed.factionData.factionData.put(1, 1000);
+ progressed.factionData.factionData.put(2, 0);
+ progressed.factionData.factionData.put(14, -250);
+ progressed.questData.finishedQuests.put(1, Fixtures.EPOCH);
+ progressed.questData.finishedQuests.put(30, Fixtures.EPOCH + 86400000L);
+ progressed.questData.activeQuests.put(2, questData(2, false));
+ progressed.questData.activeQuests.put(71, questData(71, true));
+ progressed.companionID = 4;
+ progressed.profileSlot = 2;
+ out.add(new Case("", progressed,
+ "read dialogs, standing in four factions including a negative one, two finished "
+ + "quests with completion timestamps, and two active quests -- one of them marked "
+ + "complete but not turned in. Also a companion id and a non-zero profile slot"));
+
+ PlayerData extras = player("069a79f4-44e9-4726-a5be-fca90e38aaf5", "Notch");
+ QuestData withExtras = questData(75, false);
+ withExtras.extraData.setInteger("Zombie", 14);
+ withExtras.extraData.setInteger("Skeleton", 3);
+ withExtras.extraData.setString("LastLocation", "Northern Watchtower");
+ extras.questData.activeQuests.put(75, withExtras);
+ extras.dialogData.dialogsRead.add(58);
+ out.add(new Case("", extras,
+ "an active quest whose ExtraData holds per-objective counters. This is where kill "
+ + "counts and reached locations actually live -- the quest itself stores only the "
+ + "targets, so a reader that ignores ExtraData can never show progress"));
+
+ PlayerData nil = player("00000000-0000-0000-0000-000000000000", "NilUuid");
+ nil.dialogData.dialogsRead.add(1);
+ out.add(new Case("", nil,
+ "the nil UUID, which is legal in the file and names no player. A reader that treats "
+ + "it as absent rather than as a value diverges here"));
+
+ return out;
+ }
+
+ /** PlayerDataController.writeNBT -- a flat Name/UUID list, with no lastID. */
+ public static NBTTagCompound index(Map roster) {
+ NBTTagList list = new NBTTagList();
+ for (Map.Entry entry : roster.entrySet()) {
+ NBTTagCompound player = new NBTTagCompound();
+ // The controller keys the map by NAME and stores the uuid as the value, which is the
+ // opposite of what the file's key order suggests at a glance.
+ player.setString("Name", entry.getValue());
+ player.setString("UUID", entry.getKey());
+ list.appendTag(player);
+ }
+ NBTTagCompound compound = new NBTTagCompound();
+ compound.setTag("PlayerDataMap", list);
+ return compound;
+ }
+
+ private static PlayerData player(String uuid, String name) {
+ PlayerData data = new PlayerData();
+ data.uuid = uuid;
+ data.playername = name;
+ return data;
+ }
+
+ /**
+ * An entry in a player's active quest list.
+ *
+ * {@code QuestData} needs a {@link Quest} to construct, but only writes three keys and
+ * never touches it -- the quest id is the map key in the enclosing list, not part of the
+ * entry. So a bare Quest carrying the right id is enough and nothing is invented.
+ */
+ private static QuestData questData(int questId, boolean completed) {
+ Quest quest = new Quest();
+ quest.id = questId;
+ QuestData data = new QuestData(quest);
+ data.isCompleted = completed;
+ data.sendAlerts = true;
+ return data;
+ }
+}
diff --git a/src/fixtures/java/kamkeel/npcs/fixtures/content/QuestFixtures.java b/src/fixtures/java/kamkeel/npcs/fixtures/content/QuestFixtures.java
new file mode 100644
index 000000000..3acbf4284
--- /dev/null
+++ b/src/fixtures/java/kamkeel/npcs/fixtures/content/QuestFixtures.java
@@ -0,0 +1,445 @@
+package kamkeel.npcs.fixtures.content;
+
+import kamkeel.npcs.fixtures.Case;
+import kamkeel.npcs.fixtures.Fixtures;
+import net.minecraft.nbt.NBTTagCompound;
+import noppes.npcs.constants.EnumPartyExchange;
+import noppes.npcs.constants.EnumPartyObjectives;
+import noppes.npcs.constants.EnumPartyRequirements;
+import noppes.npcs.constants.EnumProfileSync;
+import noppes.npcs.constants.EnumQuestCompletion;
+import noppes.npcs.constants.EnumQuestRepeat;
+import noppes.npcs.constants.EnumQuestType;
+import noppes.npcs.controllers.data.Quest;
+import noppes.npcs.quests.QuestDialog;
+import noppes.npcs.quests.QuestItem;
+import noppes.npcs.quests.QuestKill;
+import noppes.npcs.quests.QuestLocation;
+import noppes.npcs.quests.QuestManual;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Quests, one axis at a time.
+ *
+ *
A quest has no availability. Worth stating because it is easy to conclude otherwise:
+ * {@code Quest.readNBTPartial} calls {@code VersionCompatibility.CheckAvailabilityCompatibility},
+ * whose name says availability, and which is in fact a generic "fill in keys this file predates"
+ * pass applied to every {@code ICompatibilty}. {@code Quest} declares no {@code Availability}
+ * field, {@code writeToNBTPartial} writes no {@code Availability*} key, and neither does the 1.20
+ * line. Gating is a property of the dialog that offers the quest.
+ *
+ *
Ranges: 1-9 the six types, 10-29 repeat and completion, 30-49 rewards, 50-69 party and
+ * profile, 70-89 objectives in depth, 90-99 combinations.
+ */
+public final class QuestFixtures {
+
+ private QuestFixtures() {
+ }
+
+ public static List> maximal() {
+ List> out = new ArrayList>();
+ types(out);
+ repeatAndCompletion(out);
+ rewards(out);
+ partyAndProfile(out);
+ objectives(out);
+ combinations(out);
+ return out;
+ }
+
+ // ------------------------------------------------------------------ types
+
+ private static void types(List> out) {
+ int id = 1;
+ for (EnumQuestType type : EnumQuestType.values()) {
+ Quest quest = quest(id, "Type " + type.name(), type);
+ populateObjective(quest, type);
+ out.add(new Case("Types", quest,
+ "quest type " + type.name() + " (ordinal " + type.ordinal() + ") with its "
+ + "objective populated. NOTE: Kill and AreaKill both build a QuestKill and "
+ + "write the same keys -- the only thing separating them on disk is Type"));
+ id++;
+ }
+ }
+
+ /** Kill and AreaKill differ by Type alone, so they get compared directly. */
+ private static void populateObjective(Quest quest, EnumQuestType type) {
+ if (type == EnumQuestType.Item) {
+ QuestItem items = (QuestItem) quest.questInterface;
+ items.items.items.put(0, Fixtures.item("minecraft:iron_ingot", 12));
+ items.items.items.put(1, Fixtures.namedItem("minecraft:written_book", 1, 0, "Proof"));
+ items.leaveItems = true;
+ items.ignoreDamage = true;
+ items.ignoreNBT = false;
+ } else if (type == EnumQuestType.Dialog) {
+ QuestDialog dialogs = (QuestDialog) quest.questInterface;
+ dialogs.dialogs.put(0, 1);
+ dialogs.dialogs.put(1, 2);
+ dialogs.dialogs.put(2, 3);
+ } else if (type == EnumQuestType.Kill || type == EnumQuestType.AreaKill) {
+ QuestKill kill = (QuestKill) quest.questInterface;
+ kill.targets.put("Zombie", 20);
+ kill.targets.put("Skeleton", 15);
+ kill.targetType = type == EnumQuestType.AreaKill ? 1 : 0;
+ kill.customTargetType = "noppes.npcs.entity.EntityCustomNpc";
+ } else if (type == EnumQuestType.Location) {
+ QuestLocation location = (QuestLocation) quest.questInterface;
+ location.location = "Northern Watchtower";
+ location.location2 = "Sunken Crypt";
+ location.location3 = "The Long Bridge";
+ } else if (type == EnumQuestType.Manual) {
+ QuestManual manual = (QuestManual) quest.questInterface;
+ manual.manuals.put("Speak to the smith", 1);
+ manual.manuals.put("Gather reeds", 30);
+ }
+ }
+
+ // ------------------------------------------------------------------ repeat and completion
+
+ private static void repeatAndCompletion(List> out) {
+ int id = 10;
+ for (EnumQuestRepeat repeat : EnumQuestRepeat.values()) {
+ Quest quest = quest(id, "Repeat " + repeat.name(), EnumQuestType.Manual);
+ quest.repeat = repeat;
+ // The two CUSTOM modes are the only ones that read customCooldown, but it is written
+ // unconditionally, so setting it everywhere shows that.
+ quest.customCooldown = 3600000L;
+ ((QuestManual) quest.questInterface).manuals.put("Do the thing", 1);
+ out.add(new Case("Repeat", quest,
+ "QuestRepeat = " + repeat.name() + " (ordinal " + repeat.ordinal() + ") with "
+ + "CustomCooldown 3600000. MCDAILY/MCWEEKLY count world ticks, RLDAILY/RLWEEKLY "
+ + "count wall time, and only MCCUSTOM/RLCUSTOM read the cooldown"));
+ id++;
+ }
+
+ int completionId = 18;
+ for (EnumQuestCompletion completion : EnumQuestCompletion.values()) {
+ Quest quest = quest(completionId, "Completion " + completion.name(), EnumQuestType.Manual);
+ quest.completion = completion;
+ quest.completerNpc = completion == EnumQuestCompletion.Npc ? "Quest Giver" : "";
+ ((QuestManual) quest.questInterface).manuals.put("Do the thing", 1);
+ out.add(new Case("Repeat", quest,
+ "QuestCompletion = " + completion.name() + ". Npc requires turning in to the named "
+ + "NPC; Instant completes the moment the objective is met"));
+ completionId++;
+ }
+
+ Quest cooldownExtremes = quest(20, "Cooldown Extremes", EnumQuestType.Manual);
+ cooldownExtremes.repeat = EnumQuestRepeat.RLCUSTOM;
+ cooldownExtremes.customCooldown = Long.MAX_VALUE;
+ ((QuestManual) cooldownExtremes.questInterface).manuals.put("Wait", 1);
+ out.add(new Case("Repeat", cooldownExtremes,
+ "CustomCooldown at Long.MAX_VALUE -- the one long on a quest, and the field most "
+ + "likely to be narrowed to an int by a reader"));
+ }
+
+ // ------------------------------------------------------------------ rewards
+
+ private static void rewards(List> out) {
+ Quest exp = quest(30, "Experience Reward", EnumQuestType.Manual);
+ exp.rewardExp = 2500;
+ ((QuestManual) exp.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", exp, "RewardExp only"));
+
+ Quest items = quest(31, "Item Rewards", EnumQuestType.Manual);
+ items.rewardItems.items.put(0, Fixtures.item("minecraft:diamond", 5));
+ items.rewardItems.items.put(1, Fixtures.item("minecraft:gold_ingot", 32));
+ items.rewardItems.items.put(8, Fixtures.namedItem("minecraft:diamond_pickaxe", 1, 3, "Miner's Due"));
+ ((QuestManual) items.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", items,
+ "three of the nine reward slots filled, including slot 8 and one stack with custom "
+ + "NBT. Stored under the Rewards tag as an NpcMiscInventory"));
+
+ Quest full = quest(32, "Nine Item Rewards", EnumQuestType.Manual);
+ String[] nine = {
+ "minecraft:stone", "minecraft:dirt", "minecraft:oak_stairs", "minecraft:torch",
+ "minecraft:bread", "minecraft:coal", "minecraft:string", "minecraft:bone",
+ "minecraft:paper"
+ };
+ for (int i = 0; i < nine.length; i++) {
+ full.rewardItems.items.put(i, Fixtures.item(nine[i], i + 1));
+ }
+ ((QuestManual) full.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", full,
+ "all nine reward slots filled, each a different count -- the inventory at capacity"));
+
+ Quest random = quest(33, "Random Reward", EnumQuestType.Manual);
+ random.randomReward = true;
+ random.rewardItems.items.put(0, Fixtures.item("minecraft:emerald", 1));
+ random.rewardItems.items.put(1, Fixtures.item("minecraft:redstone", 1));
+ ((QuestManual) random.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", random,
+ "RandomReward true -- one of the reward slots is granted rather than all of them"));
+
+ Quest faction = quest(34, "Faction Reward", EnumQuestType.Manual);
+ faction.factionOptions.factionId = 14;
+ faction.factionOptions.factionPoints = 500;
+ faction.factionOptions.decreaseFactionPoints = false;
+ faction.factionOptions.faction2Id = 15;
+ faction.factionOptions.faction2Points = 250;
+ faction.factionOptions.decreaseFaction2Points = true;
+ ((QuestManual) faction.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", faction,
+ "both faction reward slots, nested under QuestFactionPoints -- note a dialog writes "
+ + "the same FactionOptions keys flat, not nested"));
+
+ Quest mail = quest(35, "Mail Reward", EnumQuestType.Manual);
+ mail.mail.subject = "Your reward";
+ mail.mail.sender = "The Guild";
+ mail.mail.time = Fixtures.EPOCH;
+ NBTTagCompound message = new NBTTagCompound();
+ message.setString("Text", "Enclosed, with thanks.");
+ mail.mail.message = message;
+ mail.mail.items[0] = Fixtures.item("minecraft:gold_nugget", 64);
+ mail.mail.items[3] = Fixtures.item("minecraft:cake", 1);
+ ((QuestManual) mail.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", mail,
+ "QuestMail with two of four item slots, including the last slot"));
+
+ Quest command = quest(36, "Command On Completion", EnumQuestType.Manual);
+ command.command = "/give {player} minecraft:diamond 1";
+ ((QuestManual) command.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", command, "QuestCommand with a {player} placeholder"));
+
+ Quest chain = quest(37, "Chains To Another", EnumQuestType.Manual);
+ chain.nextQuestid = 30;
+ chain.nextQuestTitle = "Experience Reward";
+ ((QuestManual) chain.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", chain,
+ "NextQuestId 30 with a matching NextQuestTitle. On read CustomNPC+ overwrites the "
+ + "title from the live controller and blanks it when the target is missing, so "
+ + "the id is the real link and the title is a cache"));
+
+ Quest danglingChain = quest(38, "Chains Nowhere", EnumQuestType.Manual);
+ danglingChain.nextQuestid = 999999;
+ danglingChain.nextQuestTitle = "A quest that does not exist";
+ ((QuestManual) danglingChain.questInterface).manuals.put("Earn it", 1);
+ out.add(new Case("Rewards", danglingChain,
+ "NextQuestId pointing at nothing, with a stale title still on disk -- what a world "
+ + "looks like after the target quest was deleted"));
+ }
+
+ // ------------------------------------------------------------------ party and profile
+
+ private static void partyAndProfile(List> out) {
+ // The party block is written only when AllowParty is true. Both halves matter.
+ Quest disabled = quest(50, "Party Disabled", EnumQuestType.Manual);
+ disabled.partyOptions.allowParty = false;
+ disabled.partyOptions.minPartySize = 4;
+ disabled.partyOptions.maxPartySize = 8;
+ ((QuestManual) disabled.questInterface).manuals.put("Solo", 1);
+ out.add(new Case("Party", disabled,
+ "AllowParty false with sizes assigned. Proves the eight party keys are ABSENT from "
+ + "the file when the flag is off -- the values are lost upstream, so there is "
+ + "nothing for a converter to preserve"));
+
+ Quest enabled = quest(51, "Party Enabled", EnumQuestType.Manual);
+ enabled.partyOptions.allowParty = true;
+ enabled.partyOptions.onlyParty = true;
+ enabled.partyOptions.partyRequirements = EnumPartyRequirements.All;
+ enabled.partyOptions.rewardControl = EnumPartyExchange.Enrolled;
+ enabled.partyOptions.completeFor = EnumPartyExchange.Valid;
+ enabled.partyOptions.executeCommand = EnumPartyExchange.All;
+ enabled.partyOptions.minPartySize = 2;
+ enabled.partyOptions.maxPartySize = 6;
+ enabled.partyOptions.objectiveRequirement = EnumPartyObjectives.Leader;
+ ((QuestManual) enabled.questInterface).manuals.put("Together", 1);
+ out.add(new Case("Party", enabled,
+ "AllowParty true and all eight party keys set to non-default enum constants"));
+
+ int id = 52;
+ for (EnumPartyRequirements requirement : EnumPartyRequirements.values()) {
+ Quest quest = quest(id, "Party Requirement " + requirement.name(), EnumQuestType.Manual);
+ quest.partyOptions.allowParty = true;
+ quest.partyOptions.partyRequirements = requirement;
+ ((QuestManual) quest.questInterface).manuals.put("Together", 1);
+ out.add(new Case("Party", quest,
+ "PartyRequirements = " + requirement.name() + " (ordinal " + requirement.ordinal() + ")"));
+ id++;
+ }
+ for (EnumPartyExchange exchange : EnumPartyExchange.values()) {
+ Quest quest = quest(id, "Party Reward " + exchange.name(), EnumQuestType.Manual);
+ quest.partyOptions.allowParty = true;
+ quest.partyOptions.rewardControl = exchange;
+ ((QuestManual) quest.questInterface).manuals.put("Together", 1);
+ out.add(new Case("Party", quest,
+ "RewardControl = " + exchange.name() + " (ordinal " + exchange.ordinal() + ")"));
+ id++;
+ }
+ for (EnumPartyObjectives objective : EnumPartyObjectives.values()) {
+ Quest quest = quest(id, "Party Objective " + objective.name(), EnumQuestType.Manual);
+ quest.partyOptions.allowParty = true;
+ quest.partyOptions.objectiveRequirement = objective;
+ ((QuestManual) quest.questInterface).manuals.put("Together", 1);
+ out.add(new Case("Party", quest,
+ "ObjectiveRequirement = " + objective.name() + " (ordinal " + objective.ordinal() + ")"));
+ id++;
+ }
+
+ Quest profileOff = quest(62, "Profiles Disabled", EnumQuestType.Manual);
+ profileOff.profileOptions.enableOptions = false;
+ profileOff.profileOptions.cooldownControl = EnumProfileSync.Shared;
+ ((QuestManual) profileOff.questInterface).manuals.put("Alone", 1);
+ out.add(new Case("Party", profileOff,
+ "EnableProfiles false with a Shared cooldown assigned -- the two profile keys are "
+ + "absent from the file, same shape as the party block"));
+
+ int profileId = 63;
+ for (EnumProfileSync sync : EnumProfileSync.values()) {
+ Quest quest = quest(profileId, "Profile " + sync.name(), EnumQuestType.Manual);
+ quest.profileOptions.enableOptions = true;
+ quest.profileOptions.cooldownControl = sync;
+ quest.profileOptions.completeControl = sync;
+ ((QuestManual) quest.questInterface).manuals.put("Alone", 1);
+ out.add(new Case("Party", quest,
+ "EnableProfiles true, CooldownControl and CompleteControl = " + sync.name()));
+ profileId++;
+ }
+ }
+
+ // ------------------------------------------------------------------ objectives in depth
+
+ private static void objectives(List> out) {
+ Quest emptyKill = quest(70, "Kill Nothing", EnumQuestType.Kill);
+ out.add(new Case("Objectives", emptyKill,
+ "a Kill quest with an EMPTY target list. isCompleted counts completed targets against "
+ + "the target count, so zero of zero passes -- this quest is complete on accept"));
+
+ Quest manyKill = quest(71, "Kill Many Kinds", EnumQuestType.Kill);
+ QuestKill kill = (QuestKill) manyKill.questInterface;
+ String[] mobs = {"Zombie", "Skeleton", "Creeper", "Spider", "Enderman", "Blaze"};
+ for (int i = 0; i < mobs.length; i++) {
+ kill.targets.put(mobs[i], (i + 1) * 10);
+ }
+ out.add(new Case("Objectives", manyKill,
+ "six kill targets. Stored as a TreeMap, so the file is alphabetical rather than "
+ + "insertion-ordered -- Blaze first, Zombie last"));
+
+ Quest customTarget = quest(72, "Kill A Custom NPC", EnumQuestType.Kill);
+ QuestKill custom = (QuestKill) customTarget.questInterface;
+ custom.targets.put("Village Elder", 1);
+ custom.targetType = 1;
+ custom.customTargetType = "noppes.npcs.entity.EntityCustomNpc";
+ out.add(new Case("Objectives", customTarget,
+ "TargetType 1 with a CustomTargetType class name -- targets an NPC by display name "
+ + "rather than an entity by type"));
+
+ Quest emptyItems = quest(73, "Bring Nothing", EnumQuestType.Item);
+ QuestItem noItems = (QuestItem) emptyItems.questInterface;
+ noItems.ignoreDamage = true;
+ noItems.ignoreNBT = true;
+ noItems.leaveItems = true;
+ out.add(new Case("Objectives", emptyItems,
+ "an Item quest with no required items and all three matching flags on"));
+
+ Quest variantItems = quest(74, "Bring Variants", EnumQuestType.Item);
+ QuestItem variants = (QuestItem) variantItems.questInterface;
+ variants.items.items.put(0, Fixtures.variant("minecraft:wool", 8, 14));
+ variants.items.items.put(1, Fixtures.variant("minecraft:planks", 4, 3));
+ variants.items.items.put(2, Fixtures.variant("minecraft:dye", 16, 4));
+ out.add(new Case("Objectives", variantItems,
+ "three pre-Flattening variants -- red wool (14), jungle planks (3) and lapis dye (4). "
+ + "Damage is a variant selector here, not durability, and each becomes a distinct "
+ + "item after 1.13, so this is the case a variant table has to cover"));
+
+ Quest manyDialogs = quest(75, "Read Many Dialogs", EnumQuestType.Dialog);
+ QuestDialog dialogs = (QuestDialog) manyDialogs.questInterface;
+ for (int i = 0; i < 5; i++) {
+ dialogs.dialogs.put(i, 30 + i);
+ }
+ out.add(new Case("Objectives", manyDialogs,
+ "five dialog objectives. NOTE the key collision worth knowing about: a Dialog quest "
+ + "stores these under QuestDialogs, which is exactly where a pre-split Kill quest "
+ + "stored its targets -- see the legacy world"));
+
+ Quest oneLocation = quest(76, "One Location", EnumQuestType.Location);
+ ((QuestLocation) oneLocation.questInterface).location = "The Old Well";
+ out.add(new Case("Objectives", oneLocation,
+ "only the first of three location slots set; the other two are written as empty"));
+
+ Quest manuals = quest(77, "Manual Counters", EnumQuestType.Manual);
+ QuestManual manual = (QuestManual) manuals.questInterface;
+ manual.manuals.put("Deliver the letter", 1);
+ manual.manuals.put("Light the beacons", 3);
+ manual.manuals.put("Survive the night", 1);
+ out.add(new Case("Objectives", manuals,
+ "three manual counters -- progress a script or an operator advances by hand"));
+ }
+
+ // ------------------------------------------------------------------ combinations
+
+ private static void combinations(List> out) {
+ Quest everything = quest(90, "Everything At Once", EnumQuestType.Item);
+ everything.logText = "Bring the ingots, and be quick about it.";
+ everything.completeText = "You have my thanks.";
+ everything.completerNpc = "Quartermaster";
+ everything.command = "/say {player} finished";
+ everything.repeat = EnumQuestRepeat.MCCUSTOM;
+ everything.customCooldown = 172800000L;
+ everything.completion = EnumQuestCompletion.Instant;
+ everything.nextQuestid = 1;
+ everything.nextQuestTitle = "Type Item";
+ everything.rewardExp = 1000;
+ everything.randomReward = true;
+ everything.rewardItems.items.put(0, Fixtures.item("minecraft:diamond", 3));
+ everything.rewardItems.items.put(4, Fixtures.namedItem("minecraft:iron_sword", 1, 5, "Reward"));
+ everything.factionOptions.factionId = 0;
+ everything.factionOptions.factionPoints = 75;
+ everything.partyOptions.allowParty = true;
+ everything.partyOptions.onlyParty = false;
+ everything.partyOptions.partyRequirements = EnumPartyRequirements.Valid;
+ everything.partyOptions.rewardControl = EnumPartyExchange.All;
+ everything.partyOptions.minPartySize = 2;
+ everything.partyOptions.maxPartySize = 4;
+ everything.profileOptions.enableOptions = true;
+ everything.profileOptions.cooldownControl = EnumProfileSync.Shared;
+ everything.profileOptions.completeControl = EnumProfileSync.Individual;
+ everything.mail.subject = "Everything";
+ everything.mail.sender = "Quartermaster";
+ everything.mail.time = Fixtures.EPOCH;
+ everything.mail.items[0] = Fixtures.item("minecraft:emerald", 12);
+ QuestItem items = (QuestItem) everything.questInterface;
+ items.items.items.put(0, Fixtures.item("minecraft:iron_ingot", 20));
+ items.items.items.put(2, Fixtures.variant("minecraft:wool", 1, 11));
+ items.leaveItems = false;
+ items.ignoreDamage = false;
+ items.ignoreNBT = true;
+ out.add(new Case("Combinations", everything,
+ "every quest sub-object populated at once: objective, rewards, faction, party, "
+ + "profile, mail, chain, cooldown and command"));
+
+ Quest bare = quest(91, "Bare", EnumQuestType.Item);
+ out.add(new Case("Combinations", bare,
+ "every field at its constructor default, for comparison against id 90"));
+
+ Quest unicode = quest(92, Text.CYRILLIC_QUEST + " " + Text.EM_DASH + " " + Text.ELEVES,
+ EnumQuestType.Manual);
+ unicode.logText = Text.RTL;
+ unicode.completeText = Text.ASTRAL;
+ unicode.completerNpc = Text.KATAKANA_FACTION;
+ ((QuestManual) unicode.questInterface).manuals.put(Text.CYRILLIC_QUEST, 1);
+ out.add(new Case("Combinations", unicode,
+ "non-Latin title, log text, complete text, completer name and objective key. The "
+ + "objective key is the interesting one -- it is an NBT map key, not a value"));
+ }
+
+ // ------------------------------------------------------------------ helpers
+
+ /**
+ * Builds a quest with its type applied.
+ *
+ * Order matters: {@code setType} replaces {@code questInterface} and stamps it with the
+ * quest id, so both must be set before the objective is populated.
+ */
+ private static Quest quest(int id, String title, EnumQuestType type) {
+ Quest quest = new Quest();
+ quest.id = id;
+ quest.title = title;
+ quest.setType(type);
+ quest.logText = "Objective for " + title + ".";
+ quest.completeText = "Done: " + title + ".";
+ return quest;
+ }
+}
diff --git a/src/fixtures/java/kamkeel/npcs/fixtures/content/WorldDataFixtures.java b/src/fixtures/java/kamkeel/npcs/fixtures/content/WorldDataFixtures.java
new file mode 100644
index 000000000..91ba6f81f
--- /dev/null
+++ b/src/fixtures/java/kamkeel/npcs/fixtures/content/WorldDataFixtures.java
@@ -0,0 +1,256 @@
+package kamkeel.npcs.fixtures.content;
+
+import kamkeel.npcs.fixtures.Fixtures;
+import net.minecraft.nbt.NBTTagCompound;
+import net.minecraft.nbt.NBTTagList;
+import noppes.npcs.controllers.data.Bank;
+import noppes.npcs.controllers.data.SpawnData;
+import noppes.npcs.controllers.data.Tag;
+import noppes.npcs.controllers.data.TransportCategory;
+import noppes.npcs.controllers.data.TransportLocation;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * The world-level {@code .dat} documents: global, tags, transport, banks and spawns.
+ *
+ *
All five share a shape -- one compound holding a list of entries plus a {@code lastID} -- and
+ * each list is built here in a fixed order rather than by iterating the controller's HashMap, so
+ * regeneration is a no-op rather than a reshuffle.
+ *
+ *
Every document format below is copied from its controller's {@code getNBT}, named per method.
+ */
+public final class WorldDataFixtures {
+
+ private WorldDataFixtures() {
+ }
+
+ // ------------------------------------------------------------------ global.dat
+
+ /** GlobalDataController.saveData -- a single integer, and the smallest document CNPC+ writes. */
+ public static NBTTagCompound global(int itemGiverId) {
+ NBTTagCompound compound = new NBTTagCompound();
+ compound.setInteger("itemGiverId", itemGiverId);
+ return compound;
+ }
+
+ // ------------------------------------------------------------------ tags.dat
+
+ /**
+ * Tags. Note {@link Tag#uuid} defaults to {@code randomUUID()}, so every fixture here sets one
+ * explicitly -- otherwise the corpus would differ on every run and the reproducibility check
+ * would fail for a reason that has nothing to do with the format.
+ */
+ public static NBTTagCompound tags() {
+ List tags = new ArrayList();
+
+ tags.add(tag(1, "Boss", 0xFF0000, "3f2504e0-4f89-41d3-9a0c-0305e82c3301", false));
+ tags.add(tag(2, "Hidden", 0x333333, "3f2504e0-4f89-41d3-9a0c-0305e82c3302", true));
+ tags.add(tag(3, "", 0x00FF00, "3f2504e0-4f89-41d3-9a0c-0305e82c3303", false));
+ tags.add(tag(4, Text.KATAKANA_FACTION, 0xFFFFFF,
+ "3f2504e0-4f89-41d3-9a0c-0305e82c3304", false));
+ tags.add(tag(5, "Extremes", -1, "ffffffff-ffff-ffff-ffff-ffffffffffff", true));
+
+ NBTTagList list = new NBTTagList();
+ for (Tag tag : tags) {
+ NBTTagCompound entry = new NBTTagCompound();
+ tag.writeNBT(entry);
+ list.appendTag(entry);
+ }
+ NBTTagCompound compound = new NBTTagCompound();
+ compound.setInteger("lastID", 5);
+ compound.setTag("NPCTags", list);
+ return compound;
+ }
+
+ private static Tag tag(int id, String name, int color, String uuid, boolean hidden) {
+ Tag tag = new Tag();
+ tag.id = id;
+ tag.name = name;
+ tag.color = color;
+ tag.uuid = UUID.fromString(uuid);
+ tag.hideTag = hidden;
+ return tag;
+ }
+
+ // ------------------------------------------------------------------ transport.dat
+
+ /**
+ * Transport locations, grouped into categories.
+ *
+ * The one place CustomNPC+ stores a dimension as a bare {@code int} alongside a position --
+ * which is the signature that stopped being expressible after 1.16, so it is worth having on
+ * disk in both a vanilla dimension and a modded id.
+ */
+ public static NBTTagCompound transport() {
+ List categories = new ArrayList();
+
+ TransportCategory overworld = category(1, "Overworld");
+ overworld.locations.put(1, location(1, "Spawn Plaza", 0.5D, 64.0D, 0.5D, 0, 0));
+ overworld.locations.put(2, location(2, "Northern Watchtower", -1204.5D, 96.0D, 3311.5D, 1, 0));
+ overworld.locations.put(3, location(3, "Negative Coordinates",
+ -30000000.0D, -64.0D, -30000000.0D, 2, 0));
+ categories.add(overworld);
+
+ TransportCategory other = category(2, "Other Dimensions");
+ other.locations.put(4, location(4, "Nether Hub", 12.0D, 70.0D, -8.0D, 0, -1));
+ other.locations.put(5, location(5, "The End", 100.0D, 50.0D, 0.0D, 0, 1));
+ // A modded dimension id, which is the case a numeric-to-key translation table must either
+ // cover or refuse rather than silently map to the overworld.
+ other.locations.put(6, location(6, "Modded Dimension", 0.0D, 0.0D, 0.0D, 0, 7265));
+ categories.add(other);
+
+ TransportCategory empty = category(3, "Empty Category");
+ categories.add(empty);
+
+ NBTTagList list = new NBTTagList();
+ for (TransportCategory category : categories) {
+ NBTTagCompound entry = new NBTTagCompound();
+ category.writeNBT(entry);
+ list.appendTag(entry);
+ }
+ NBTTagCompound compound = new NBTTagCompound();
+ compound.setInteger("lastID", 6);
+ compound.setTag("NPCTransportCategories", list);
+ return compound;
+ }
+
+ private static TransportCategory category(int id, String title) {
+ TransportCategory category = new TransportCategory();
+ category.id = id;
+ category.title = title;
+ return category;
+ }
+
+ private static TransportLocation location(int id, String name, double x, double y, double z,
+ int type, int dimension) {
+ TransportLocation location = new TransportLocation();
+ location.id = id;
+ location.name = name;
+ location.posX = x;
+ location.posY = y;
+ location.posZ = z;
+ location.type = type;
+ location.dimension = dimension;
+ return location;
+ }
+
+ // ------------------------------------------------------------------ bank.dat
+
+ /** BankController.getNBT -- note the key is "Data" and there is no lastID. */
+ public static NBTTagCompound banks() {
+ List banks = new ArrayList();
+
+ Bank basic = bank(1, "Village Bank", 2, 9);
+ basic.currencyInventory.items.put(0, Fixtures.item("minecraft:emerald", 1));
+ basic.upgradeInventory.items.put(0, Fixtures.item("minecraft:gold_ingot", 8));
+ for (int slot = 0; slot < 9; slot++) {
+ basic.slotTypes.put(slot, slot % 3);
+ }
+ banks.add(basic);
+
+ Bank bare = bank(2, "", 1, 1);
+ banks.add(bare);
+
+ Bank big = bank(3, "Vault", 6, 54);
+ big.currencyInventory.items.put(0, Fixtures.namedItem("minecraft:diamond", 64, 0, "Deposit"));
+ for (int slot = 0; slot < 54; slot++) {
+ big.slotTypes.put(slot, slot % 4);
+ }
+ banks.add(big);
+
+ NBTTagList list = new NBTTagList();
+ for (Bank bank : banks) {
+ NBTTagCompound entry = new NBTTagCompound();
+ bank.writeEntityToNBT(entry);
+ list.appendTag(entry);
+ }
+ NBTTagCompound compound = new NBTTagCompound();
+ compound.setTag("Data", list);
+ return compound;
+ }
+
+ private static Bank bank(int id, String name, int startSlots, int maxSlots) {
+ Bank bank = new Bank();
+ bank.id = id;
+ bank.name = name;
+ bank.startSlots = startSlots;
+ bank.maxSlots = maxSlots;
+ return bank;
+ }
+
+ // ------------------------------------------------------------------ spawns.dat
+
+ /**
+ * Natural spawn entries.
+ *
+ * The interesting shape here is {@code SpawnCompound}: the per-variant NPC data is
+ * written as a numbered key rather than a list, so a reader has to probe for
+ * {@code SpawnCompound0}, {@code SpawnCompound1}, ... rather than iterate. The dimension set
+ * is written only when non-empty, so the key is absent on most entries.
+ */
+ public static NBTTagCompound spawns() {
+ List spawns = new ArrayList();
+
+ SpawnData plains = spawn(1, "Plains Wanderer", 10);
+ plains.biomes.addAll(Arrays.asList("Plains", "Sunflower Plains", "Forest"));
+ plains.spawnCompounds.put(0, npcStub("Wanderer"));
+ spawns.add(plains);
+
+ SpawnData variants = spawn(2, "Cave Dweller", 3);
+ variants.biomes.add("Extreme Hills");
+ variants.dimensions.addAll(Arrays.asList(0, -1, 7265));
+ variants.monsterSpawning = true;
+ variants.animalSpawning = false;
+ variants.airSpawning = true;
+ variants.liquidSpawning = true;
+ variants.spawnHeightMin = 5;
+ variants.spawnHeightMax = 40;
+ variants.maxAlive = 12;
+ variants.cooldownTicks = 600;
+ variants.attemptsPerCycle = 4;
+ // Three numbered compounds, which is the shape worth pinning.
+ variants.spawnCompounds.put(0, npcStub("Dweller A"));
+ variants.spawnCompounds.put(1, npcStub("Dweller B"));
+ variants.spawnCompounds.put(2, npcStub("Dweller C"));
+ spawns.add(variants);
+
+ SpawnData bare = spawn(3, "", 0);
+ spawns.add(bare);
+
+ NBTTagList list = new NBTTagList();
+ for (SpawnData spawn : spawns) {
+ list.appendTag(spawn.writeNBT(new NBTTagCompound()));
+ }
+ NBTTagCompound compound = new NBTTagCompound();
+ compound.setInteger("lastID", 3);
+ compound.setTag("NPCSpawnData", list);
+ return compound;
+ }
+
+ private static SpawnData spawn(int id, String name, int weight) {
+ SpawnData spawn = new SpawnData();
+ spawn.id = id;
+ spawn.name = name;
+ spawn.itemWeight = weight;
+ return spawn;
+ }
+
+ /**
+ * A stand-in for a stored NPC.
+ *
+ * Deliberately not a real {@code EntityCustomNpc}: constructing one needs a World, and a
+ * spawn entry stores whatever compound it was given without inspecting it. A fixture with a
+ * full NPC in it belongs with the NPC system, which is not covered here yet -- so this says
+ * what it is rather than pretending.
+ */
+ private static NBTTagCompound npcStub(String name) {
+ NBTTagCompound stub = new NBTTagCompound();
+ stub.setString("Name", name);
+ stub.setString("FixtureNote", "placeholder -- not a serialized EntityCustomNpc");
+ return stub;
+ }
+}
diff --git a/src/fixtures/java/kamkeel/npcs/fixtures/worlds/MaximalWorld.java b/src/fixtures/java/kamkeel/npcs/fixtures/worlds/MaximalWorld.java
index ab9abb551..eb2b21a44 100644
--- a/src/fixtures/java/kamkeel/npcs/fixtures/worlds/MaximalWorld.java
+++ b/src/fixtures/java/kamkeel/npcs/fixtures/worlds/MaximalWorld.java
@@ -4,9 +4,16 @@ import kamkeel.npcs.fixtures.Case;
import kamkeel.npcs.fixtures.FixtureWorld;
import kamkeel.npcs.fixtures.WorldWriter;
import kamkeel.npcs.fixtures.content.DialogFixtures;
+import kamkeel.npcs.fixtures.content.EffectFixtures;
import kamkeel.npcs.fixtures.content.FactionFixtures;
+import kamkeel.npcs.fixtures.content.PlayerFixtures;
+import kamkeel.npcs.fixtures.content.QuestFixtures;
+import kamkeel.npcs.fixtures.content.WorldDataFixtures;
+import noppes.npcs.controllers.data.PlayerData;
+import noppes.npcs.controllers.data.CustomEffect;
import noppes.npcs.controllers.data.Dialog;
import noppes.npcs.controllers.data.Faction;
+import noppes.npcs.controllers.data.Quest;
import java.util.List;
@@ -36,5 +43,63 @@ public final class MaximalWorld implements FixtureWorld {
for (Case