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