From 6d07e7e7f6beded1c91052835cbdb20b8db19eae Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:35:39 +0000 Subject: [PATCH 1/2] Skin custom 3D masks and register them with RPCharacters. Approved mask submissions become wearable helmets and are recorded so wearing one hides identity. Co-authored-by: Cursor --- .../net/tfminecraft/armourshop/Cache.java | 6 + .../armourshop/loaders/ConfigLoader.java | 6 + .../pack/apply/PackApplyService.java | 20 ++++ .../pack/delete/PackSubmissionRemover.java | 18 +++ .../armourshop/pack/model/PackKind.java | 1 + .../pack/shop/ShopSubmissionWriter.java | 1 + .../armourshop/pack/writer/mask/MasksYml.java | 111 ++++++++++++++++++ .../pack/writer/model3d/Item3dWriter.java | 24 ++++ src/main/resources/base-sets.yml | 13 ++ src/main/resources/categories.yml | 6 + src/main/resources/config.yml | 2 + src/main/resources/permission-groups.yml | 2 +- .../pack/writer/mask/MasksYmlTest.java | 51 ++++++++ .../writer/model3d/Item3dWriterMaskTest.java | 28 +++++ 14 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java create mode 100644 src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java create mode 100644 src/test/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriterMaskTest.java diff --git a/src/main/java/net/tfminecraft/armourshop/Cache.java b/src/main/java/net/tfminecraft/armourshop/Cache.java index 28d894a..c08f8c2 100644 --- a/src/main/java/net/tfminecraft/armourshop/Cache.java +++ b/src/main/java/net/tfminecraft/armourshop/Cache.java @@ -20,6 +20,12 @@ public class Cache { /** GunsAndGadgets skins.yml (Step 15). */ public static String gunsSkinsYmlPath = ""; + /** + * RPCharacters custom mask registry. Blank config falls back to this path + * so an existing live config.yml still registers masks. + */ + public static String masksYmlPath = "plugins/RPCharacters/custom-masks.yml"; + /** Daily force pull+reload at HH:mm server local; blank disables. */ public static String forceReloadTime = "06:00"; /** Seconds between iareload and iazip. */ diff --git a/src/main/java/net/tfminecraft/armourshop/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/armourshop/loaders/ConfigLoader.java index b3413c9..39475ad 100644 --- a/src/main/java/net/tfminecraft/armourshop/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/armourshop/loaders/ConfigLoader.java @@ -34,6 +34,12 @@ public void load(File configFile) { String gunsSkins = config.getString("pack-apply.guns-skins-yml", ""); Cache.gunsSkinsYmlPath = gunsSkins == null ? "" : gunsSkins.trim(); + String masksYml = config.getString("pack-apply.masks-yml", ""); + if (masksYml == null || masksYml.isBlank()) { + masksYml = "plugins/RPCharacters/custom-masks.yml"; + } + Cache.masksYmlPath = masksYml.trim(); + String forceTime = config.getString("pack-apply.force-reload-time", "06:00"); Cache.forceReloadTime = forceTime == null ? "" : forceTime.trim(); diff --git a/src/main/java/net/tfminecraft/armourshop/pack/apply/PackApplyService.java b/src/main/java/net/tfminecraft/armourshop/pack/apply/PackApplyService.java index 9958e7a..f7175cf 100644 --- a/src/main/java/net/tfminecraft/armourshop/pack/apply/PackApplyService.java +++ b/src/main/java/net/tfminecraft/armourshop/pack/apply/PackApplyService.java @@ -13,6 +13,7 @@ import net.tfminecraft.armourshop.pack.writer.flat.FlatItemWriter; import net.tfminecraft.armourshop.pack.writer.gun.GunWriter; import net.tfminecraft.armourshop.pack.writer.large.LargeHandheldWriter; +import net.tfminecraft.armourshop.pack.writer.mask.MasksYml; import net.tfminecraft.armourshop.pack.writer.model3d.Item3dWriter; import net.tfminecraft.armourshop.pack.writer.model3d.ShieldWriter; import java.nio.file.Path; @@ -479,6 +480,15 @@ private static void writeKind( ns ); return; + case "mask": + requireModel3d(packFiles); + Item3dWriter.writeMask( + contentsRoot, + new PackSubmission(slug, display, PackKind.MASK, packFiles), + ns + ); + MasksYml.upsert(requireMasksYml(), slug, ns); + return; case "gun": requireGun(packFiles); GunWriter.write( @@ -562,6 +572,16 @@ private static void requireGun(Map files) { } } + static Path requireMasksYml() { + String masks = Cache.masksYmlPath; + if (masks == null || masks.isBlank()) { + throw new IllegalStateException( + "pack-apply.masks-yml is not set in config.yml" + ); + } + return Path.of(masks.trim()); + } + static Path requireGunsSkinsYml() { String skins = Cache.gunsSkinsYmlPath; if (skins == null || skins.isBlank()) { diff --git a/src/main/java/net/tfminecraft/armourshop/pack/delete/PackSubmissionRemover.java b/src/main/java/net/tfminecraft/armourshop/pack/delete/PackSubmissionRemover.java index 2847428..75507c9 100644 --- a/src/main/java/net/tfminecraft/armourshop/pack/delete/PackSubmissionRemover.java +++ b/src/main/java/net/tfminecraft/armourshop/pack/delete/PackSubmissionRemover.java @@ -5,6 +5,7 @@ import net.tfminecraft.armourshop.pack.model.PackPaths; import net.tfminecraft.armourshop.pack.writer.flat.BookWriter; import net.tfminecraft.armourshop.pack.writer.gun.GunWriter; +import net.tfminecraft.armourshop.pack.writer.mask.MasksYml; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -80,6 +81,15 @@ public static List remove( removeArmorPack(contentsRoot, ns, s, removed); } else if ("gun".equals(k)) { removed.addAll(GunWriter.remove(contentsRoot, ns, s, gunsSkinsYmlOrNull())); + } else if ("mask".equals(k)) { + removeNonArmorPack(contentsRoot, ns, s, removed); + try { + MasksYml.remove(masksYmlOrNull(), s); + } catch (RuntimeException e) { + if (log != null) { + log.warning("[pack-delete] mask registry remove failed: " + e.getMessage()); + } + } } else if ("book".equals(k)) { removed.addAll(BookWriter.remove(contentsRoot, ns, s)); } else { @@ -156,6 +166,14 @@ private static void deleteQuiet(Path path, List removed) throws IOExceptio } } + private static Path masksYmlOrNull() { + String masks = Cache.masksYmlPath; + if (masks == null || masks.isBlank()) { + return null; + } + return Path.of(masks.trim()); + } + private static Path gunsSkinsYmlOrNull() { String skins = Cache.gunsSkinsYmlPath; if (skins == null || skins.isBlank()) { diff --git a/src/main/java/net/tfminecraft/armourshop/pack/model/PackKind.java b/src/main/java/net/tfminecraft/armourshop/pack/model/PackKind.java index a1e4795..29e14ab 100644 --- a/src/main/java/net/tfminecraft/armourshop/pack/model/PackKind.java +++ b/src/main/java/net/tfminecraft/armourshop/pack/model/PackKind.java @@ -14,6 +14,7 @@ public enum PackKind { ITEM_3D, SHIELD, HELMET_3D, + MASK, GUN, BOOK } diff --git a/src/main/java/net/tfminecraft/armourshop/pack/shop/ShopSubmissionWriter.java b/src/main/java/net/tfminecraft/armourshop/pack/shop/ShopSubmissionWriter.java index 269dc21..771570f 100644 --- a/src/main/java/net/tfminecraft/armourshop/pack/shop/ShopSubmissionWriter.java +++ b/src/main/java/net/tfminecraft/armourshop/pack/shop/ShopSubmissionWriter.java @@ -221,6 +221,7 @@ private static boolean isItemKind(String kind) { || "item_3d".equals(kind) || "shield".equals(kind) || "helmet_3d".equals(kind) + || "mask".equals(kind) || "gun".equals(kind) || "book".equals(kind); } diff --git a/src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java b/src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java new file mode 100644 index 0000000..04925cc --- /dev/null +++ b/src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java @@ -0,0 +1,111 @@ +package net.tfminecraft.armourshop.pack.writer.mask; + +import net.tfminecraft.armourshop.pack.util.YamlUtil; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Upsert / remove RPCharacters {@code custom-masks.yml} entries (Bukkit-free). + * Each entry is the ItemsAdder path of an approved mask skin. + */ +public final class MasksYml { + + private MasksYml() {} + + public static void upsert(Path masksYml, String slug, String namespace) + throws IOException + { + if (masksYml == null) { + throw new IllegalArgumentException("masksYml is null"); + } + String key = requireSlug(slug); + String item = "ia." + requireNamespace(namespace) + ":" + key; + Path parent = masksYml.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Map entries = read(masksYml); + entries.put(key, item); + write(masksYml, entries); + } + + public static boolean remove(Path masksYml, String slug) throws IOException { + if (masksYml == null || !Files.isRegularFile(masksYml)) { + return false; + } + String key = requireSlug(slug); + Map entries = read(masksYml); + if (entries.remove(key) == null) { + return false; + } + write(masksYml, entries); + return true; + } + + static Map read(Path masksYml) throws IOException { + Map entries = new LinkedHashMap<>(); + if (masksYml == null || !Files.isRegularFile(masksYml)) { + return entries; + } + String current = null; + for (String raw : Files.readString(masksYml, StandardCharsets.UTF_8).split("\\R")) { + String line = raw.stripTrailing(); + if (line.startsWith(" ") && !line.startsWith(" ") && line.endsWith(":")) { + String key = line.substring(2, line.length() - 1).trim(); + current = key.isEmpty() ? null : key; + continue; + } + if (current != null && line.startsWith(" item:")) { + String item = line.substring(" item:".length()).trim(); + if (!item.isEmpty()) { + entries.put(current, item); + } + current = null; + } + } + return entries; + } + + private static void write(Path masksYml, Map entries) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("# Custom RP masks written by ArmourShop. Loaded by RPCharacters with masks.yml.\n"); + sb.append("masks:\n"); + for (Map.Entry entry : entries.entrySet()) { + sb.append(" ").append(entry.getKey()).append(":\n"); + sb.append(" item: ").append(entry.getValue()).append('\n'); + } + Files.writeString(masksYml, sb.toString(), StandardCharsets.UTF_8); + } + + private static String requireSlug(String slug) { + if (slug == null || slug.isBlank()) { + throw new IllegalArgumentException("missing slug"); + } + String key = slug.trim(); + YamlUtil.validateSlug(key); + return key; + } + + private static String requireNamespace(String namespace) { + if (namespace == null || namespace.isBlank()) { + throw new IllegalArgumentException("missing namespace"); + } + String ns = namespace.trim(); + for (int i = 0; i < ns.length(); i++) { + char c = ns.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '_' + || c == '-'; + if (!ok) { + throw new IllegalArgumentException("invalid namespace: " + namespace); + } + } + return ns; + } +} diff --git a/src/main/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriter.java b/src/main/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriter.java index 9e7606c..e228ef0 100644 --- a/src/main/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriter.java +++ b/src/main/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriter.java @@ -75,6 +75,30 @@ public static List writeHelmet3d( ); } + /** + * Writes mask skins as an unplaceable carved pumpkin hat, same pack shape as helmet_3d. + * RPCharacters identity comes from the mask registry, not from this YAML. + */ + public static List writeMask( + Path contentsRoot, + PackSubmission submission, + String namespace + ) throws IOException { + if (submission.kind() != PackKind.MASK) { + throw new IllegalArgumentException( + "writeMask requires MASK, got " + submission.kind() + ); + } + return writeModelItem( + contentsRoot, + submission, + "CARVED_PUMPKIN", + null, + true, + namespace + ); + } + /** * Shared pack write for standalone 3D items (item_3d / helmet_3d). * diff --git a/src/main/resources/base-sets.yml b/src/main/resources/base-sets.yml index 8948861..6d9e404 100644 --- a/src/main/resources/base-sets.yml +++ b/src/main/resources/base-sets.yml @@ -540,6 +540,19 @@ helmets: - helmets.infantry_mythril_helmet - helmets.infantry_custom_helmet +masks: + item: + - masks.ghost_mask + - masks.monster_mask + - masks.oni_mask + - masks.urukhai_mask + - masks.samurai_mask + - masks.steampunk_mask + - masks.steampunk_robot_mask + - masks.skeleton_mask + - masks.skeleton_mask2 + - masks.zerratoris_mask + rifles: item: - guns.rifle_template diff --git a/src/main/resources/categories.yml b/src/main/resources/categories.yml index 017ac18..dc5882d 100644 --- a/src/main/resources/categories.yml +++ b/src/main/resources/categories.yml @@ -280,6 +280,12 @@ i_helmets: item: m.helmets.shako is-item: true +i_masks: + name: "Masks" + colour: "#7f7d80" + item: m.masks.ghost_mask + is-item: true + i_guns: name: "Guns" colour: "#7f7d80" diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7e1f104..cd9f3ae 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -7,6 +7,8 @@ pack-apply: categories-path: "plugins/ArmourShop/Categories" # GunsAndGadgets skins.yml (Step 15 — IA ids; no CMD dual-write) guns-skins-yml: "plugins/GunsAndGadgets/skins.yml" + # RPCharacters custom mask registry (mask skin kind) + masks-yml: "plugins/RPCharacters/custom-masks.yml" # Daily force pull + IA refresh at this server-local time (HH:mm). Blank disables. force-reload-time: "06:00" # Seconds to wait after /iareload before /iazip diff --git a/src/main/resources/permission-groups.yml b/src/main/resources/permission-groups.yml index 4e9e2b8..2c8bfa0 100644 --- a/src/main/resources/permission-groups.yml +++ b/src/main/resources/permission-groups.yml @@ -37,7 +37,7 @@ groups: display-name: "⋫EE&lA⍉DD&lsόDCC&lc E27BB&le&#A620AB&ln&#AD1A9A&ld&#B41489&le&#BB0E78&ld&#C20867" name-colour-stops: 20 skin-token-cooldown-days: 14 - skin-kinds: [item_3d, shield, helmet_3d, gun] + skin-kinds: [item_3d, shield, helmet_3d, mask, gun] allow-armor-3d-helmet: true # Uncomment to raise the combined texture+model budget for this rank: # max-3d-pair-bytes: 40960 diff --git a/src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java b/src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java new file mode 100644 index 0000000..1bea76b --- /dev/null +++ b/src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java @@ -0,0 +1,51 @@ +package net.tfminecraft.armourshop.pack.writer.mask; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MasksYmlTest { + + @Test + void upsertAndRemoveKeepOtherMasks(@TempDir Path dir) throws Exception { + Path file = dir.resolve("custom-masks.yml"); + MasksYml.upsert(file, "oni_mask", "tfmc_submissions"); + MasksYml.upsert(file, "ghost_mask", "tfmc_armorshop"); + + assertEquals( + "ia.tfmc_submissions:oni_mask", + MasksYml.read(file).get("oni_mask") + ); + assertEquals( + "ia.tfmc_armorshop:ghost_mask", + MasksYml.read(file).get("ghost_mask") + ); + + assertTrue(MasksYml.remove(file, "oni_mask")); + assertFalse(MasksYml.read(file).containsKey("oni_mask")); + assertEquals( + "ia.tfmc_armorshop:ghost_mask", + MasksYml.read(file).get("ghost_mask") + ); + assertFalse(MasksYml.remove(file, "oni_mask")); + } + + @Test + void rewriteReplacesTheSameSlug(@TempDir Path dir) throws Exception { + Path file = dir.resolve("custom-masks.yml"); + MasksYml.upsert(file, "oni_mask", "tfmc_submissions"); + MasksYml.upsert(file, "oni_mask", "tfmc_armorshop"); + assertEquals(1, MasksYml.read(file).size()); + assertEquals( + "ia.tfmc_armorshop:oni_mask", + MasksYml.read(file).get("oni_mask") + ); + assertTrue(Files.readString(file).contains("masks:")); + } +} diff --git a/src/test/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriterMaskTest.java b/src/test/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriterMaskTest.java new file mode 100644 index 0000000..4e80d23 --- /dev/null +++ b/src/test/java/net/tfminecraft/armourshop/pack/writer/model3d/Item3dWriterMaskTest.java @@ -0,0 +1,28 @@ +package net.tfminecraft.armourshop.pack.writer.model3d; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.armourshop.pack.model.PackKind; +import net.tfminecraft.armourshop.pack.model.PackSubmission; + +class Item3dWriterMaskTest { + + @Test + void maskYamlIsAnUnplaceableHat() { + String yaml = Item3dWriter.buildYaml( + new PackSubmission("oni_mask", "Oni", PackKind.MASK, Map.of()), + "CARVED_PUMPKIN", + null, + true, + "tfmc_submissions" + ); + assertTrue(yaml.contains("material: CARVED_PUMPKIN")); + assertTrue(yaml.contains("generate: false")); + assertTrue(yaml.contains("hat: true")); + assertTrue(yaml.contains("model_path: item/oni_mask")); + } +} From 8ddee6bd931edaedabc5b83dd167a7ac9c16c25f Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:46:17 +0000 Subject: [PATCH 2/2] Keep existing mask registry entries when updating the file. Indentation differences are preserved, unrecognized lines abort the rewrite, and the file is replaced atomically. Co-authored-by: Cursor --- .../armourshop/pack/writer/mask/MasksYml.java | 42 +++++++++++++++---- .../pack/writer/mask/MasksYmlTest.java | 20 +++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java b/src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java index 04925cc..b9da549 100644 --- a/src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java +++ b/src/main/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYml.java @@ -4,8 +4,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.LinkedHashMap; import java.util.Map; @@ -55,18 +57,28 @@ static Map read(Path masksYml) throws IOException { String current = null; for (String raw : Files.readString(masksYml, StandardCharsets.UTF_8).split("\\R")) { String line = raw.stripTrailing(); - if (line.startsWith(" ") && !line.startsWith(" ") && line.endsWith(":")) { - String key = line.substring(2, line.length() - 1).trim(); - current = key.isEmpty() ? null : key; + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { continue; } - if (current != null && line.startsWith(" item:")) { - String item = line.substring(" item:".length()).trim(); - if (!item.isEmpty()) { - entries.put(current, item); + if ("masks:".equals(trimmed)) { + current = null; + continue; + } + if (trimmed.endsWith(":") && !trimmed.contains(" ") && !trimmed.contains("\t")) { + current = trimmed.substring(0, trimmed.length() - 1); + continue; + } + if (current != null && trimmed.startsWith("item:")) { + String item = trimmed.substring("item:".length()).trim(); + if (item.isEmpty()) { + throw new IOException("mask '" + current + "' is missing an item path"); } + entries.put(current, item); current = null; + continue; } + throw new IOException("unrecognized custom-masks.yml line: " + trimmed); } return entries; } @@ -79,7 +91,21 @@ private static void write(Path masksYml, Map entries) throws IOE sb.append(" ").append(entry.getKey()).append(":\n"); sb.append(" item: ").append(entry.getValue()).append('\n'); } - Files.writeString(masksYml, sb.toString(), StandardCharsets.UTF_8); + Path parent = masksYml.getParent(); + Path tmp = parent == null + ? Path.of(masksYml.getFileName().toString() + ".tmp") + : parent.resolve(masksYml.getFileName().toString() + ".tmp"); + Files.writeString(tmp, sb.toString(), StandardCharsets.UTF_8); + try { + Files.move( + tmp, + masksYml, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, masksYml, StandardCopyOption.REPLACE_EXISTING); + } } private static String requireSlug(String slug) { diff --git a/src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java b/src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java index 1bea76b..60552f0 100644 --- a/src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java +++ b/src/test/java/net/tfminecraft/armourshop/pack/writer/mask/MasksYmlTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -48,4 +49,23 @@ void rewriteReplacesTheSameSlug(@TempDir Path dir) throws Exception { ); assertTrue(Files.readString(file).contains("masks:")); } + + @Test + void deeperIndentIsKept(@TempDir Path dir) throws Exception { + Path file = dir.resolve("custom-masks.yml"); + Files.writeString( + file, + "masks:\n ghost_mask:\n item: ia.tfmc_submissions:ghost_mask\n", + StandardCharsets.UTF_8 + ); + MasksYml.upsert(file, "oni_mask", "tfmc_armorshop"); + assertEquals( + "ia.tfmc_submissions:ghost_mask", + MasksYml.read(file).get("ghost_mask") + ); + assertEquals( + "ia.tfmc_armorshop:oni_mask", + MasksYml.read(file).get("oni_mask") + ); + } }