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

c : DialogFixtures.maximal()) { out.dialog(c.category, c.value, c.note); } + + for (Case c : QuestFixtures.maximal()) { + out.quest(c.category, c.value, c.note); + } + + List> effects = EffectFixtures.maximal(); + for (Case c : effects) { + out.effect(c.category, c.value, c.note); + } + out.effect("", EffectFixtures.MISMATCH_FILENAME, EffectFixtures.nameMismatch(), + "the name key inside the file says 'Name Inside The File' while the file is called '" + + EffectFixtures.MISMATCH_FILENAME + ".json'. loadEffectsFromDir overwrites the " + + "field from the filename after reading, so the filename wins and a reader " + + "trusting the key gets a different answer"); + + out.dat("effect", "customeffects.dat", EffectFixtures.index(effects, 12), + "the custom effect INDEX -- {Name, ID} pairs and lastID, not the effects themselves, " + + "which are the .json files under customeffects/. The unnamed effect is absent " + + "from this index by design"); + + out.dat("global", "global.dat", WorldDataFixtures.global(42), + "one integer. The smallest document CustomNPC+ writes, and a useful floor for a " + + "reader's envelope handling"); + + out.dat("tag", "tags.dat", WorldDataFixtures.tags(), + "five tags with explicit UUIDs, covering hidden, empty-named, non-Latin and a colour " + + "of -1. Tag.uuid defaults to randomUUID(), so these are pinned deliberately"); + + out.dat("transport", "transport.dat", WorldDataFixtures.transport(), + "three categories and six locations, including negative coordinates, the Nether and " + + "End by numeric id, and a modded dimension 7265 -- the int dimension that " + + "stopped being expressible after 1.16"); + + out.dat("bank", "bank.dat", WorldDataFixtures.banks(), + "three banks with currency and upgrade inventories and per-slot types. Keyed under " + + "'Data' with no lastID, unlike every other document here"); + + out.dat("spawn", "spawns.dat", WorldDataFixtures.spawns(), + "three natural spawn entries. One carries three numbered SpawnCompound keys rather " + + "than a list, which a reader has to probe for rather than iterate"); + + List> players = PlayerFixtures.records(); + for (Case c : players) { + out.json("player", c.value.uuid, "playerdata/" + c.value.uuid + ".json", + c.value.getNBT(), c.note); + } + // The same record as gzipped NBT, which is what ConfigMain.DatFormat selects instead. Same + // compound, different container -- a reader has to handle both because a server operator + // chooses between them. + PlayerData asDat = players.get(1).value; + out.dat("player", "playerdata/" + asDat.uuid + ".dat", asDat.getNBT(), + "the Progressed player again, written as gzipped NBT rather than JSON. " + + "ConfigMain.DatFormat picks the container; the compound is identical. Both " + + "extensions are loaded by PlayerDataController, so both are real"); + + out.dat("player", "playerdatamap.dat", PlayerFixtures.index(PlayerFixtures.roster()), + "the name/uuid index. Derived rather than authoritative -- CustomNPC+ rebuilds it by " + + "scanning playerdata/ when the file is missing -- but it is what gets read first"); } }