package com.tiedup.remake.v2.furniture; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.tiedup.remake.v2.BodyRegionV2; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import net.minecraft.resources.ResourceLocation; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; /** * Parses JSON files into {@link FurnitureDefinition} instances. * *

Uses manual field extraction (not Gson deserialization) for strict * validation control. Invalid required fields cause the entire definition * to be rejected; optional fields use safe defaults.

* *

Expected JSON files in {@code data//tiedup_furniture/}.

*/ public final class FurnitureParser { private static final Logger LOGGER = LogManager.getLogger( "TiedUpFurniture" ); private static final String TAG = "[FurnitureParser]"; /** Strict hex color pattern: # followed by exactly 6 hex digits. */ private static final Pattern HEX_COLOR = Pattern.compile( "^#[0-9A-Fa-f]{6}$" ); /** Maximum number of seats per furniture (bitmask limit: 8 bits). */ private static final int MAX_SEATS = 8; private FurnitureParser() {} /** * Parse a JSON input stream into a FurnitureDefinition. * * @param input the JSON input stream * @param fileId the resource location of the source file (for error messages) * @return the parsed definition, or null if the file is invalid */ @Nullable public static FurnitureDefinition parse( InputStream input, ResourceLocation fileId ) { try { JsonObject root = JsonParser.parseReader( new InputStreamReader(input, StandardCharsets.UTF_8) ).getAsJsonObject(); return parseObject(root, fileId); } catch (Exception e) { LOGGER.error( "{} Failed to parse JSON {}: {}", TAG, fileId, e.getMessage() ); return null; } } /** * Parse a JsonObject into a FurnitureDefinition. * * @param root the parsed JSON object * @param fileId the resource location of the source file (for error messages) * @return the parsed definition, or null if validation fails */ @Nullable public static FurnitureDefinition parseObject( JsonObject root, ResourceLocation fileId ) { // --- Required: id --- String idStr = getStringOrNull(root, "id"); if (idStr == null || idStr.isEmpty()) { LOGGER.error("{} Skipping {}: missing 'id'", TAG, fileId); return null; } ResourceLocation id = ResourceLocation.tryParse(idStr); if (id == null) { LOGGER.error( "{} Skipping {}: invalid id ResourceLocation '{}'", TAG, fileId, idStr ); return null; } // --- Required: display_name --- String displayName = getStringOrNull(root, "display_name"); if (displayName == null || displayName.isEmpty()) { LOGGER.error("{} Skipping {}: missing 'display_name'", TAG, fileId); return null; } // --- Optional: translation_key --- String translationKey = getStringOrNull(root, "translation_key"); // --- Required: model --- String modelStr = getStringOrNull(root, "model"); if (modelStr == null || modelStr.isEmpty()) { LOGGER.error("{} Skipping {}: missing 'model'", TAG, fileId); return null; } ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr); if (modelLocation == null) { LOGGER.error( "{} Skipping {}: invalid model ResourceLocation '{}'", TAG, fileId, modelStr ); return null; } // --- Optional: tint_channels (strict hex validation) --- Map tintChannels = parseTintChannels(root, fileId); if (tintChannels == null) { // parseTintChannels returns null on invalid hex -> reject entire furniture return null; } // --- Optional: supports_color (default false) --- boolean supportsColor = getBooleanOrDefault( root, "supports_color", false ); // --- Optional: hitbox (defaults: 1.0 x 1.0, clamped [0.1, 5.0]) --- float hitboxWidth = 1.0f; float hitboxHeight = 1.0f; if (root.has("hitbox") && root.get("hitbox").isJsonObject()) { JsonObject hitbox = root.getAsJsonObject("hitbox"); hitboxWidth = clamp( getFloatOrDefault(hitbox, "width", 1.0f), 0.1f, 5.0f ); hitboxHeight = clamp( getFloatOrDefault(hitbox, "height", 1.0f), 0.1f, 5.0f ); } // --- Optional: placement --- boolean snapToWall = false; boolean floorOnly = true; if (root.has("placement") && root.get("placement").isJsonObject()) { JsonObject placement = root.getAsJsonObject("placement"); snapToWall = getBooleanOrDefault(placement, "snap_to_wall", false); floorOnly = getBooleanOrDefault(placement, "floor_only", true); } // --- Optional: lockable (default false) --- boolean lockable = getBooleanOrDefault(root, "lockable", false); // --- Optional: break_resistance (default 100, clamped [1, 10000]) --- float breakResistance = clamp( getFloatOrDefault(root, "break_resistance", 100.0f), 1.0f, 10000.0f ); // --- Optional: drop_on_break (default true) --- boolean dropOnBreak = getBooleanOrDefault(root, "drop_on_break", true); // --- Required: seats (non-empty array, size [1, 8]) --- if (!root.has("seats") || !root.get("seats").isJsonArray()) { LOGGER.error( "{} Skipping {}: missing or invalid 'seats' array", TAG, fileId ); return null; } JsonArray seatsArray = root.getAsJsonArray("seats"); if (seatsArray.isEmpty()) { LOGGER.error("{} Skipping {}: 'seats' array is empty", TAG, fileId); return null; } if (seatsArray.size() > MAX_SEATS) { LOGGER.error( "{} Skipping {}: 'seats' array has {} entries (max {})", TAG, fileId, seatsArray.size(), MAX_SEATS ); return null; } List seats = new ArrayList<>(seatsArray.size()); for (int i = 0; i < seatsArray.size(); i++) { if (!seatsArray.get(i).isJsonObject()) { LOGGER.error( "{} Skipping {}: seats[{}] is not a JSON object", TAG, fileId, i ); return null; } SeatDefinition seat = parseSeat( seatsArray.get(i).getAsJsonObject(), i, lockable, fileId ); if (seat == null) { // parseSeat already logged the error return null; } seats.add(seat); } // --- Optional: feedback --- FurnitureFeedback feedback = FurnitureFeedback.EMPTY; if (root.has("feedback") && root.get("feedback").isJsonObject()) { feedback = parseFeedback(root.getAsJsonObject("feedback"), fileId); } // --- Optional: category (default "furniture") --- String category = getStringOrDefault(root, "category", "furniture"); // --- Optional: icon (item model ResourceLocation for inventory sprite) --- ResourceLocation icon = parseOptionalResourceLocation( root, "icon", fileId ); return new FurnitureDefinition( id, displayName, translationKey, modelLocation, tintChannels, supportsColor, hitboxWidth, hitboxHeight, snapToWall, floorOnly, lockable, breakResistance, dropOnBreak, seats, feedback, category, icon ); } // ===== Seat Parsing ===== /** * Parse a single seat JSON object. * * @param obj the seat JSON object * @param index the seat index (for error messages) * @param parentLockable the top-level lockable value (used as default) * @param fileId the source file (for error messages) * @return the parsed seat, or null on validation failure */ @Nullable private static SeatDefinition parseSeat( JsonObject obj, int index, boolean parentLockable, ResourceLocation fileId ) { // Required: id (must not contain ':') String seatId = getStringOrNull(obj, "id"); if (seatId == null || seatId.isEmpty()) { LOGGER.error( "{} Skipping {}: seats[{}] missing 'id'", TAG, fileId, index ); return null; } // Reject separators used by SEAT_ASSIGNMENTS_SYNC encoding // ("uuid;seat|uuid;seat|…") — a seat id containing | or ; would // corrupt the client map on parse. `:` is reserved for the // ResourceLocation separator if this id is ever promoted to one. if ( seatId.contains(":") || seatId.contains("|") || seatId.contains(";") ) { LOGGER.error( "{} Skipping {}: seats[{}] id '{}' must not contain ':', '|', or ';'", TAG, fileId, index, seatId ); return null; } // Required: armature String armature = getStringOrNull(obj, "armature"); if (armature == null || armature.isEmpty()) { LOGGER.error( "{} Skipping {}: seats[{}] missing 'armature'", TAG, fileId, index ); return null; } // Optional: blocked_regions (unknown region = fatal for entire furniture) Set blockedRegions = parseBlockedRegions( obj, index, fileId ); if (blockedRegions == null) { // parseBlockedRegions returns null ONLY on unknown region name (fatal) return null; } // Optional: lockable (inherits from top-level) boolean seatLockable = getBooleanOrDefault( obj, "lockable", parentLockable ); // Optional: locked_difficulty (clamped [1, 10000], default 1) int lockedDifficulty = clampInt( getIntOrDefault(obj, "locked_difficulty", 1), 1, 10000 ); // Optional: item_difficulty_bonus (default false) boolean itemDifficultyBonus = getBooleanOrDefault( obj, "item_difficulty_bonus", false ); return new SeatDefinition( seatId, armature, blockedRegions, seatLockable, lockedDifficulty, itemDifficultyBonus ); } /** * Parse blocked_regions for a seat. Returns empty set if field is absent. * Returns null (fatal) if any region name is unknown. */ @Nullable private static Set parseBlockedRegions( JsonObject obj, int seatIndex, ResourceLocation fileId ) { if ( !obj.has("blocked_regions") || !obj.get("blocked_regions").isJsonArray() ) { return Collections.unmodifiableSet( EnumSet.noneOf(BodyRegionV2.class) ); } JsonArray arr = obj.getAsJsonArray("blocked_regions"); if (arr.isEmpty()) { return Collections.unmodifiableSet( EnumSet.noneOf(BodyRegionV2.class) ); } EnumSet regions = EnumSet.noneOf(BodyRegionV2.class); for (JsonElement elem : arr) { String name; try { name = elem.getAsString().toUpperCase(); } catch (Exception e) { LOGGER.error( "{} Skipping {}: seats[{}] invalid element in 'blocked_regions': {}", TAG, fileId, seatIndex, e.getMessage() ); return null; } BodyRegionV2 region = BodyRegionV2.fromName(name); if (region == null) { LOGGER.error( "{} Skipping {}: seats[{}] unknown body region '{}'", TAG, fileId, seatIndex, name ); return null; } regions.add(region); } return Collections.unmodifiableSet(regions); } // ===== Feedback Parsing ===== private static FurnitureFeedback parseFeedback( JsonObject obj, ResourceLocation fileId ) { return new FurnitureFeedback( parseOptionalResourceLocation(obj, "mount_sound", fileId), parseOptionalResourceLocation(obj, "lock_sound", fileId), parseOptionalResourceLocation(obj, "unlock_sound", fileId), parseOptionalResourceLocation(obj, "struggle_loop_sound", fileId), parseOptionalResourceLocation(obj, "escape_sound", fileId), parseOptionalResourceLocation(obj, "denied_sound", fileId) ); } // ===== Tint Channel Parsing ===== /** * Parse tint_channels with strict hex validation. * Returns empty map if field is absent. Returns null if any value is invalid hex. */ @Nullable private static Map parseTintChannels( JsonObject root, ResourceLocation fileId ) { if ( !root.has("tint_channels") || !root.get("tint_channels").isJsonObject() ) { return Map.of(); } JsonObject channels = root.getAsJsonObject("tint_channels"); Map result = new LinkedHashMap<>(); for (Map.Entry entry : channels.entrySet()) { String hex; try { hex = entry.getValue().getAsString(); } catch (Exception e) { LOGGER.error( "{} Skipping {}: tint_channels '{}' value is not a string", TAG, fileId, entry.getKey() ); return null; } if (!HEX_COLOR.matcher(hex).matches()) { LOGGER.error( "{} Skipping {}: tint_channels '{}' has invalid hex color '{}' " + "(expected '#' followed by 6 hex digits)", TAG, fileId, entry.getKey(), hex ); return null; } int color = Integer.parseInt(hex.substring(1), 16); result.put(entry.getKey(), color); } return Collections.unmodifiableMap(result); } // ===== Primitive Helpers ===== @Nullable private static String getStringOrNull(JsonObject obj, String key) { if (!obj.has(key) || obj.get(key).isJsonNull()) return null; try { return obj.get(key).getAsString(); } catch (Exception e) { return null; } } private static String getStringOrDefault( JsonObject obj, String key, String defaultValue ) { String value = getStringOrNull(obj, key); return (value != null && !value.isEmpty()) ? value : defaultValue; } private static int getIntOrDefault( JsonObject obj, String key, int defaultValue ) { if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; try { return obj.get(key).getAsInt(); } catch (Exception e) { return defaultValue; } } private static float getFloatOrDefault( JsonObject obj, String key, float defaultValue ) { if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; try { return obj.get(key).getAsFloat(); } catch (Exception e) { return defaultValue; } } private static boolean getBooleanOrDefault( JsonObject obj, String key, boolean defaultValue ) { if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; try { return obj.get(key).getAsBoolean(); } catch (Exception e) { return defaultValue; } } @Nullable private static ResourceLocation parseOptionalResourceLocation( JsonObject obj, String key, ResourceLocation fileId ) { String value = getStringOrNull(obj, key); if (value == null || value.isEmpty()) return null; ResourceLocation loc = ResourceLocation.tryParse(value); if (loc == null) { LOGGER.warn( "{} In {}: invalid ResourceLocation for '{}': '{}'", TAG, fileId, key, value ); } return loc; } // ===== Clamping Helpers ===== private static float clamp(float value, float min, float max) { return Math.max(min, Math.min(max, value)); } private static int clampInt(int value, int min, int max) { return Math.max(min, Math.min(max, value)); } }