Clean repo for open source release
Remove build artifacts, dev tool configs, unused dependencies, and third-party source dumps. Add proper README, update .gitignore, clean up Makefile.
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>Expected JSON files in {@code data/<namespace>/tiedup_furniture/}.</p>
|
||||
*/
|
||||
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<String, Integer> 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<SeatDefinition> 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;
|
||||
}
|
||||
if (seatId.contains(":")) {
|
||||
LOGGER.error("{} Skipping {}: seats[{}] id '{}' must not contain ':'",
|
||||
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<BodyRegionV2> 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<BodyRegionV2> 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<BodyRegionV2> 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<String, Integer> 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<String, Integer> result = new LinkedHashMap<>();
|
||||
|
||||
for (Map.Entry<String, JsonElement> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user