diff --git a/src/main/java/com/tiedup/remake/core/TiedUpMod.java b/src/main/java/com/tiedup/remake/core/TiedUpMod.java index 9fcd3ab..6b13fd9 100644 --- a/src/main/java/com/tiedup/remake/core/TiedUpMod.java +++ b/src/main/java/com/tiedup/remake/core/TiedUpMod.java @@ -581,6 +581,14 @@ public class TiedUpMod { LOGGER.info( "Registered FurnitureServerReloadListener for data-driven furniture definitions" ); + + // Data-driven room theme definitions (server-side, from data//tiedup_room_themes/) + event.addListener( + new com.tiedup.remake.worldgen.RoomThemeReloadListener() + ); + LOGGER.info( + "Registered RoomThemeReloadListener for data-driven room themes" + ); } } } diff --git a/src/main/java/com/tiedup/remake/worldgen/BlockPalette.java b/src/main/java/com/tiedup/remake/worldgen/BlockPalette.java new file mode 100644 index 0000000..81ff97f --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/BlockPalette.java @@ -0,0 +1,74 @@ +package com.tiedup.remake.worldgen; + +import java.util.List; +import java.util.Map; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Weighted random block selection with named condition variants. + * + *

Each palette maps condition names (e.g. "default", "bottom_row", "edge", + * "corner", "cap") to a list of weighted block state entries. When a block + * is requested for a condition that has no entries, falls back to "default".

+ */ +public final class BlockPalette { + + /** + * A single block state with its selection weight. + * + * @param state the block state to place + * @param weight relative weight (higher = more likely) + */ + public record WeightedEntry(BlockState state, float weight) {} + + private final Map> variants; + + /** + * @param variants condition name to weighted entries map (defensively copied) + */ + public BlockPalette(Map> variants) { + this.variants = Map.copyOf(variants); + } + + /** + * Pick a random block state for the given condition. + * Falls back to "default" if the condition has no entries. + * + * @param random the random source + * @param condition the condition name (e.g. "default", "bottom_row") + * @return a randomly selected block state + */ + public BlockState pick(RandomSource random, String condition) { + List entries = variants.getOrDefault( + condition, variants.get("default") + ); + if (entries == null || entries.isEmpty()) { + // Should never happen if parsed correctly -- safety fallback + return Blocks.STONE.defaultBlockState(); + } + + float totalWeight = 0; + for (WeightedEntry e : entries) { + totalWeight += e.weight(); + } + + float roll = random.nextFloat() * totalWeight; + float cumulative = 0; + for (WeightedEntry e : entries) { + cumulative += e.weight(); + if (roll < cumulative) return e.state(); + } + + // Floating-point edge case fallback + return entries.get(entries.size() - 1).state(); + } + + /** + * @return unmodifiable map of all condition variants + */ + public Map> variants() { + return variants; + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/DecorationConfig.java b/src/main/java/com/tiedup/remake/worldgen/DecorationConfig.java new file mode 100644 index 0000000..3fc0928 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/DecorationConfig.java @@ -0,0 +1,36 @@ +package com.tiedup.remake.worldgen; + +import java.util.List; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +/** + * Configuration for decorative blocks placed in a room theme. + * + *

Captures corner decorations, wall midpoint blocks, optional special + * first-corner placement, furniture cluster, and lighting/chain flags.

+ * + * @param cornerDecorations blocks placed at room corners (each with a Y offset) + * @param wallMidpointBlocks blocks placed at wall midpoints + * @param firstCornerSpecial optional special block for the first corner only + * @param furnitureCluster blocks placed as a furniture group + * @param useTorchLighting whether to place torches for lighting + * @param hasCeilingChain whether to place ceiling chains + */ +public record DecorationConfig( + List cornerDecorations, + List wallMidpointBlocks, + @Nullable PositionedBlock firstCornerSpecial, + List furnitureCluster, + boolean useTorchLighting, + boolean hasCeilingChain +) { + + /** + * A block state with an associated Y offset for positioned placement. + * + * @param state the block state to place + * @param yOffset vertical offset from the base position + */ + public record PositionedBlock(BlockState state, int yOffset) {} +} diff --git a/src/main/java/com/tiedup/remake/worldgen/RoomThemeDefinition.java b/src/main/java/com/tiedup/remake/worldgen/RoomThemeDefinition.java new file mode 100644 index 0000000..1b6260e --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/RoomThemeDefinition.java @@ -0,0 +1,78 @@ +package com.tiedup.remake.worldgen; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +/** + * Immutable data-driven room theme definition, replacing the old {@link RoomTheme} enum. + * + *

Loaded from {@code data//tiedup_room_themes/*.json} and stored + * in {@link RoomThemeRegistry}. Convenience methods mirror the old + * abstract API so that {@code HangingCagePiece} can swap with minimal changes.

+ * + * @param id unique identifier (e.g. "tiedup:oubliette") + * @param weight random selection weight (higher = more likely) + * @param wallPalette palette for wall blocks (supports "default" and "bottom_row") + * @param floorPalette palette for floor blocks (supports "default", "edge", "corner") + * @param ceilingPalette palette for ceiling blocks + * @param wallShellBlock single block used for the outer wall shell + * @param wallAccentBlock single accent block (e.g. for frames, trims) + * @param pillarPalette palette for pillars (supports "default" and "cap") + * @param scatterPalette optional palette for scatter decorations (cobwebs, etc.) + * @param decorations decoration configuration (corners, midpoints, furniture, etc.) + */ +public record RoomThemeDefinition( + ResourceLocation id, + int weight, + BlockPalette wallPalette, + BlockPalette floorPalette, + BlockPalette ceilingPalette, + BlockState wallShellBlock, + BlockState wallAccentBlock, + BlockPalette pillarPalette, + @Nullable BlockPalette scatterPalette, + DecorationConfig decorations +) { + + /** + * Pick a wall block. Uses "bottom_row" condition for ry==1, "default" otherwise. + */ + public BlockState wallBlock(RandomSource random, int ry) { + return wallPalette.pick(random, ry == 1 ? "bottom_row" : "default"); + } + + /** + * Pick a floor block. Uses "corner" for corner positions, "edge" for edges, + * "default" for interior positions. + */ + public BlockState floorBlock(RandomSource random, int rx, int rz, boolean isEdge) { + boolean isCorner = (rx == 1 || rx == 11) && (rz == 1 || rz == 11); + return floorPalette.pick( + random, isCorner ? "corner" : isEdge ? "edge" : "default" + ); + } + + /** + * Pick a ceiling block (always "default" condition). + */ + public BlockState ceilingBlock(RandomSource random) { + return ceilingPalette.pick(random, "default"); + } + + /** + * Pick a pillar block. Uses "cap" for top/bottom rows, "default" for the shaft. + */ + public BlockState pillarBlock(RandomSource random, int ry) { + return pillarPalette.pick(random, (ry == 1 || ry == 10) ? "cap" : "default"); + } + + /** + * Pick a scatter block, or null if no scatter palette is defined. + */ + @Nullable + public BlockState scatterBlock(RandomSource random) { + return scatterPalette != null ? scatterPalette.pick(random, "default") : null; + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/RoomThemeParser.java b/src/main/java/com/tiedup/remake/worldgen/RoomThemeParser.java new file mode 100644 index 0000000..de30203 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/RoomThemeParser.java @@ -0,0 +1,494 @@ +package com.tiedup.remake.worldgen; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.commands.arguments.blocks.BlockStateParser; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.level.block.state.properties.Property; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; + +/** + * Parses JSON files into {@link RoomThemeDefinition} 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_room_themes/}.

+ */ +public final class RoomThemeParser { + + private static final Logger LOGGER = LogManager.getLogger("TiedUpWorldgen"); + private static final String TAG = "[RoomThemeParser]"; + + private RoomThemeParser() {} + + /** + * Parse a JSON input stream into a RoomThemeDefinition. + * + * @param input the JSON input stream + * @param fileId the resource location derived from the file path + * @return the parsed definition, or null if the file is invalid + */ + @Nullable + public static RoomThemeDefinition parse(InputStream input, ResourceLocation fileId) { + try { + JsonObject root = JsonParser.parseReader( + new InputStreamReader(input, StandardCharsets.UTF_8) + ).getAsJsonObject(); + + return parseRoot(root, fileId); + } catch (Exception e) { + LOGGER.error("{} Failed to parse JSON {}: {}", TAG, fileId, e.getMessage()); + return null; + } + } + + /** + * Parse the root JSON object into a RoomThemeDefinition. + */ + @Nullable + private static RoomThemeDefinition parseRoot(JsonObject root, ResourceLocation fileId) { + // --- Required: weight (default 10 if missing, clamped [1, 1000]) --- + int weight = clampInt(getIntOrDefault(root, "weight", 10), 1, 1000); + + // --- Required: wall_palette --- + BlockPalette wallPalette = parsePaletteField(root, "wall_palette", fileId); + if (wallPalette == null) { + LOGGER.error("{} Skipping {}: missing or invalid 'wall_palette'", TAG, fileId); + return null; + } + + // --- Required: floor_palette --- + BlockPalette floorPalette = parsePaletteField(root, "floor_palette", fileId); + if (floorPalette == null) { + LOGGER.error("{} Skipping {}: missing or invalid 'floor_palette'", TAG, fileId); + return null; + } + + // --- Required: ceiling_palette --- + BlockPalette ceilingPalette = parsePaletteField(root, "ceiling_palette", fileId); + if (ceilingPalette == null) { + LOGGER.error("{} Skipping {}: missing or invalid 'ceiling_palette'", TAG, fileId); + return null; + } + + // --- Required: wall_shell (single block string) --- + BlockState wallShell = parseBlockStateField(root, "wall_shell", fileId); + if (wallShell == null) { + LOGGER.error("{} Skipping {}: missing or invalid 'wall_shell'", TAG, fileId); + return null; + } + + // --- Required: wall_accent (single block string) --- + BlockState wallAccent = parseBlockStateField(root, "wall_accent", fileId); + if (wallAccent == null) { + LOGGER.error("{} Skipping {}: missing or invalid 'wall_accent'", TAG, fileId); + return null; + } + + // --- Required: pillar_palette --- + BlockPalette pillarPalette = parsePaletteField(root, "pillar_palette", fileId); + if (pillarPalette == null) { + LOGGER.error("{} Skipping {}: missing or invalid 'pillar_palette'", TAG, fileId); + return null; + } + + // --- Optional: scatter_palette --- + BlockPalette scatterPalette = null; + if (root.has("scatter_palette") && root.get("scatter_palette").isJsonObject()) { + scatterPalette = parsePaletteField(root, "scatter_palette", fileId); + // If scatter_palette is present but invalid, warn but don't reject + if (scatterPalette == null) { + LOGGER.warn( + "{} In {}: 'scatter_palette' present but invalid, ignoring", TAG, fileId + ); + } + } + + // --- Optional: decorations --- + DecorationConfig decorations = new DecorationConfig( + List.of(), List.of(), null, List.of(), false, false + ); + if (root.has("decorations") && root.get("decorations").isJsonObject()) { + DecorationConfig parsed = parseDecorations( + root.getAsJsonObject("decorations"), fileId + ); + if (parsed != null) { + decorations = parsed; + } + } + + return new RoomThemeDefinition( + fileId, weight, + wallPalette, floorPalette, ceilingPalette, + wallShell, wallAccent, + pillarPalette, scatterPalette, + decorations + ); + } + + // ===== Palette Parsing ===== + + /** + * Parse a palette field from the root object. The field value must be a JSON object + * mapping condition names to arrays of weighted entries. + * + *

Example: + *

{
+     *   "default": [{"block": "minecraft:stone_bricks", "weight": 0.8}],
+     *   "bottom_row": [{"block": "minecraft:mossy_stone_bricks", "weight": 1.0}]
+     * }
+ */ + @Nullable + private static BlockPalette parsePaletteField( + JsonObject root, String fieldName, ResourceLocation fileId + ) { + if (!root.has(fieldName) || !root.get(fieldName).isJsonObject()) { + return null; + } + + JsonObject paletteObj = root.getAsJsonObject(fieldName); + Map> variants = new HashMap<>(); + + for (Map.Entry condEntry : paletteObj.entrySet()) { + String condition = condEntry.getKey(); + if (!condEntry.getValue().isJsonArray()) { + LOGGER.warn( + "{} In {} palette '{}': condition '{}' is not an array, skipping", + TAG, fileId, fieldName, condition + ); + continue; + } + + List entries = parseWeightedEntries( + condEntry.getValue().getAsJsonArray(), fileId, fieldName, condition + ); + if (!entries.isEmpty()) { + variants.put(condition, List.copyOf(entries)); + } + } + + if (variants.isEmpty() || !variants.containsKey("default")) { + LOGGER.warn( + "{} In {} palette '{}': no valid 'default' condition found", + TAG, fileId, fieldName + ); + return null; + } + + return new BlockPalette(variants); + } + + /** + * Parse an array of weighted block entries for a single palette condition. + * + *

Each entry is a JSON object with: + *

    + *
  • {@code "block"} — block state string (e.g. "minecraft:candle[lit=true]")
  • + *
  • {@code "weight"} — float weight (default 1.0)
  • + *
  • {@code "random_property"} — optional object with {@code "name"}, {@code "min"}, {@code "max"} + * to expand a single entry into multiple entries with varying integer property values
  • + *
+ */ + private static List parseWeightedEntries( + JsonArray array, ResourceLocation fileId, String paletteName, String condition + ) { + List entries = new ArrayList<>(); + + for (int i = 0; i < array.size(); i++) { + if (!array.get(i).isJsonObject()) { + LOGGER.warn( + "{} In {} palette '{}' condition '{}': entry [{}] is not an object, skipping", + TAG, fileId, paletteName, condition, i + ); + continue; + } + + JsonObject entryObj = array.get(i).getAsJsonObject(); + + String blockStr = getStringOrNull(entryObj, "block"); + if (blockStr == null || blockStr.isEmpty()) { + LOGGER.warn( + "{} In {} palette '{}' condition '{}': entry [{}] missing 'block', skipping", + TAG, fileId, paletteName, condition, i + ); + continue; + } + + BlockState baseState = parseBlockState(blockStr, fileId); + if (baseState == null) { + continue; // parseBlockState already logged warning + } + + float weight = getFloatOrDefault(entryObj, "weight", 1.0f); + if (weight <= 0) { + LOGGER.warn( + "{} In {} palette '{}' condition '{}': entry [{}] has non-positive weight, skipping", + TAG, fileId, paletteName, condition, i + ); + continue; + } + + // Handle random_property expansion + if (entryObj.has("random_property") && entryObj.get("random_property").isJsonObject()) { + List expanded = expandRandomProperty( + baseState, weight, entryObj.getAsJsonObject("random_property"), + fileId, paletteName, condition, i + ); + entries.addAll(expanded); + } else { + entries.add(new BlockPalette.WeightedEntry(baseState, weight)); + } + } + + return entries; + } + + /** + * Expand a single weighted entry into multiple entries by varying an integer + * block state property across a range. + * + *

For example, {@code candle[candles=1]} with {@code random_property: {name: "candles", min: 1, max: 3}} + * and weight 0.30 produces three entries: candles=1 (0.10), candles=2 (0.10), candles=3 (0.10).

+ */ + @SuppressWarnings("unchecked") + private static List expandRandomProperty( + BlockState baseState, float totalWeight, JsonObject propObj, + ResourceLocation fileId, String paletteName, String condition, int entryIndex + ) { + String propName = getStringOrNull(propObj, "name"); + if (propName == null) { + LOGGER.warn( + "{} In {} palette '{}' condition '{}': entry [{}] random_property missing 'name', using base state", + TAG, fileId, paletteName, condition, entryIndex + ); + return List.of(new BlockPalette.WeightedEntry(baseState, totalWeight)); + } + + int min = getIntOrDefault(propObj, "min", 1); + int max = getIntOrDefault(propObj, "max", min); + if (max < min) { + LOGGER.warn( + "{} In {} palette '{}' condition '{}': entry [{}] random_property max < min, using base state", + TAG, fileId, paletteName, condition, entryIndex + ); + return List.of(new BlockPalette.WeightedEntry(baseState, totalWeight)); + } + + Property property = baseState.getBlock().getStateDefinition().getProperty(propName); + if (!(property instanceof IntegerProperty intProp)) { + LOGGER.warn( + "{} In {} palette '{}' condition '{}': entry [{}] property '{}' is not an IntegerProperty, using base state", + TAG, fileId, paletteName, condition, entryIndex, propName + ); + return List.of(new BlockPalette.WeightedEntry(baseState, totalWeight)); + } + + int count = max - min + 1; + float perWeight = totalWeight / count; + List expanded = new ArrayList<>(count); + + for (int val = min; val <= max; val++) { + if (intProp.getPossibleValues().contains(val)) { + expanded.add(new BlockPalette.WeightedEntry( + baseState.setValue(intProp, val), perWeight + )); + } else { + LOGGER.warn( + "{} In {} palette '{}' condition '{}': entry [{}] property '{}' does not accept value {}, skipping", + TAG, fileId, paletteName, condition, entryIndex, propName, val + ); + } + } + + if (expanded.isEmpty()) { + return List.of(new BlockPalette.WeightedEntry(baseState, totalWeight)); + } + + return expanded; + } + + // ===== Block State Parsing ===== + + /** + * Parse a block state string like "minecraft:stone_bricks" or "minecraft:candle[lit=true]" + * using Minecraft's built-in BlockStateParser. + * + * @return the parsed BlockState, or null on failure (logged as warning) + */ + @Nullable + private static BlockState parseBlockState(String blockString, ResourceLocation fileId) { + try { + return BlockStateParser.parseForBlock( + BuiltInRegistries.BLOCK.asLookup(), + blockString, + false // don't allow NBT + ).blockState(); + } catch (Exception e) { + LOGGER.warn( + "{} In {}: invalid block state '{}': {}", + TAG, fileId, blockString, e.getMessage() + ); + return null; + } + } + + /** + * Parse a single block state from a string field in a JSON object. + * + * @return the parsed BlockState, or null if field is missing or invalid + */ + @Nullable + private static BlockState parseBlockStateField( + JsonObject obj, String fieldName, ResourceLocation fileId + ) { + String blockStr = getStringOrNull(obj, fieldName); + if (blockStr == null || blockStr.isEmpty()) { + return null; + } + return parseBlockState(blockStr, fileId); + } + + // ===== Decoration Parsing ===== + + /** + * Parse the "decorations" JSON object into a DecorationConfig. + */ + @Nullable + private static DecorationConfig parseDecorations(JsonObject obj, ResourceLocation fileId) { + try { + List cornerDecos = parsePositionedBlockList( + obj, "corner_decorations", fileId + ); + List wallMidpoints = parsePositionedBlockList( + obj, "wall_midpoint_blocks", fileId + ); + + DecorationConfig.PositionedBlock firstCornerSpecial = null; + if (obj.has("first_corner_special") && obj.get("first_corner_special").isJsonObject()) { + firstCornerSpecial = parsePositionedBlock( + obj.getAsJsonObject("first_corner_special"), fileId + ); + } + + List furnitureCluster = parsePositionedBlockList( + obj, "furniture_cluster", fileId + ); + + boolean useTorchLighting = getBooleanOrDefault(obj, "use_torch_lighting", false); + boolean hasCeilingChain = getBooleanOrDefault(obj, "has_ceiling_chain", false); + + return new DecorationConfig( + cornerDecos, wallMidpoints, firstCornerSpecial, + furnitureCluster, useTorchLighting, hasCeilingChain + ); + } catch (Exception e) { + LOGGER.warn( + "{} In {}: failed to parse decorations: {}", TAG, fileId, e.getMessage() + ); + return null; + } + } + + /** + * Parse an array of positioned blocks from a field. + */ + private static List parsePositionedBlockList( + JsonObject parent, String fieldName, ResourceLocation fileId + ) { + if (!parent.has(fieldName) || !parent.get(fieldName).isJsonArray()) { + return List.of(); + } + + JsonArray array = parent.getAsJsonArray(fieldName); + List result = new ArrayList<>(); + + for (int i = 0; i < array.size(); i++) { + if (!array.get(i).isJsonObject()) continue; + + DecorationConfig.PositionedBlock pb = parsePositionedBlock( + array.get(i).getAsJsonObject(), fileId + ); + if (pb != null) { + result.add(pb); + } + } + + return List.copyOf(result); + } + + /** + * Parse a single positioned block: { "block": "...", "y_offset": 0 } + */ + @Nullable + private static DecorationConfig.PositionedBlock parsePositionedBlock( + JsonObject obj, ResourceLocation fileId + ) { + String blockStr = getStringOrNull(obj, "block"); + if (blockStr == null || blockStr.isEmpty()) return null; + + BlockState state = parseBlockState(blockStr, fileId); + if (state == null) return null; + + int yOffset = getIntOrDefault(obj, "y_offset", 0); + return new DecorationConfig.PositionedBlock(state, yOffset); + } + + // ===== 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 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; + } + } + + private static int clampInt(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/RoomThemeRegistry.java b/src/main/java/com/tiedup/remake/worldgen/RoomThemeRegistry.java new file mode 100644 index 0000000..d7f0224 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/RoomThemeRegistry.java @@ -0,0 +1,142 @@ +package com.tiedup.remake.worldgen; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +/** + * Thread-safe registry for data-driven room theme definitions. + * + *

Server-authoritative: definitions are loaded from {@code data//tiedup_room_themes/} + * JSON files by the server reload listener. Unlike furniture, there is no client sync + * because room themes are only used during server-side worldgen.

+ * + *

Uses volatile atomic swap to ensure threads always see a consistent snapshot.

+ * + * @see RoomThemeDefinition + */ +public final class RoomThemeRegistry { + + /** + * Volatile reference to an unmodifiable map. {@link #reload} builds a new map + * and swaps atomically; consumer threads always see a consistent snapshot. + */ + private static volatile Map< + ResourceLocation, + RoomThemeDefinition + > DEFINITIONS = Map.of(); + + /** Hardcoded fallback for when the registry is empty (worldgen safety). */ + private static final RoomThemeDefinition FALLBACK = createFallback(); + + private RoomThemeRegistry() {} + + /** + * Atomically replace all definitions with a new set. + * Called by the reload listener after parsing all JSON files. + * + * @param newDefs the new definitions map (will be defensively copied) + */ + public static void reload(Map newDefs) { + DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs)); + } + + /** + * Get a definition by its unique ID. + * + * @param id the definition ID (e.g. "tiedup:oubliette") + * @return the definition, or null if not found + */ + @Nullable + public static RoomThemeDefinition get(ResourceLocation id) { + return DEFINITIONS.get(id); + } + + /** + * Get all registered definitions. + * + * @return unmodifiable collection of all definitions + */ + public static Collection getAll() { + return DEFINITIONS.values(); + } + + /** + * Pick a random theme proportionally by weight. + * + * @param random the random source + * @return a randomly selected theme, or null if registry is empty + */ + @Nullable + public static RoomThemeDefinition pickRandom(RandomSource random) { + Collection all = DEFINITIONS.values(); + if (all.isEmpty()) return null; + + int totalWeight = 0; + for (RoomThemeDefinition def : all) { + totalWeight += def.weight(); + } + + if (totalWeight <= 0) return null; + + int roll = random.nextInt(totalWeight); + int cumulative = 0; + for (RoomThemeDefinition def : all) { + cumulative += def.weight(); + if (roll < cumulative) return def; + } + + // Fallback (should not happen) + return all.iterator().next(); + } + + /** + * Pick a random theme, or return a hardcoded stone bricks fallback if the + * registry is empty. Guarantees non-null for worldgen safety. + * + * @param random the random source + * @return a theme definition (never null) + */ + public static RoomThemeDefinition pickRandomOrFallback(RandomSource random) { + RoomThemeDefinition picked = pickRandom(random); + return picked != null ? picked : FALLBACK; + } + + /** + * Clear all definitions. Called on world unload or for testing. + */ + public static void clear() { + DEFINITIONS = Map.of(); + } + + /** + * Create a minimal stone bricks fallback theme for when no JSON themes are loaded. + */ + private static RoomThemeDefinition createFallback() { + BlockState stone = Blocks.STONE_BRICKS.defaultBlockState(); + BlockPalette single = new BlockPalette( + Map.of("default", List.of(new BlockPalette.WeightedEntry(stone, 1.0f))) + ); + return new RoomThemeDefinition( + new ResourceLocation("tiedup", "fallback"), + 1, + single, // wall + single, // floor + single, // ceiling + stone, // wallShell + stone, // wallAccent + single, // pillar + null, // no scatter + new DecorationConfig( + List.of(), List.of(), null, List.of(), false, false + ) + ); + } +} diff --git a/src/main/java/com/tiedup/remake/worldgen/RoomThemeReloadListener.java b/src/main/java/com/tiedup/remake/worldgen/RoomThemeReloadListener.java new file mode 100644 index 0000000..d135e63 --- /dev/null +++ b/src/main/java/com/tiedup/remake/worldgen/RoomThemeReloadListener.java @@ -0,0 +1,103 @@ +package com.tiedup.remake.worldgen; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Server-side resource reload listener that scans {@code data//tiedup_room_themes/} + * for JSON files and populates the {@link RoomThemeRegistry}. + * + *

Room themes are server-authoritative only (used during worldgen). Unlike furniture, + * there is no client sync packet -- the registry is atomically replaced via + * {@link RoomThemeRegistry#reload(Map)} on each reload.

+ * + *

Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in + * {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.

+ */ +public class RoomThemeReloadListener + extends SimplePreparableReloadListener +{ + + private static final Logger LOGGER = LogManager.getLogger("TiedUpWorldgen"); + + /** Resource directory containing room theme definition JSON files (under data/). */ + private static final String DIRECTORY = "tiedup_room_themes"; + + @Override + protected Void prepare( + ResourceManager resourceManager, + ProfilerFiller profiler + ) { + // No preparation needed -- parsing happens in apply phase + return null; + } + + @Override + protected void apply( + Void nothing, + ResourceManager resourceManager, + ProfilerFiller profiler + ) { + Map newDefs = new HashMap<>(); + + Map resources = + resourceManager.listResources(DIRECTORY, loc -> + loc.getPath().endsWith(".json") + ); + + int skipped = 0; + + for (Map.Entry entry : resources.entrySet()) { + ResourceLocation fileId = entry.getKey(); + Resource resource = entry.getValue(); + + try (InputStream input = resource.open()) { + RoomThemeDefinition def = RoomThemeParser.parse(input, fileId); + + if (def != null) { + // Check for duplicate IDs (fileId IS the ID for room themes) + if (newDefs.containsKey(def.id())) { + LOGGER.warn( + "[TiedUpWorldgen] Server: Duplicate room theme ID '{}' from file '{}' -- overwriting previous definition", + def.id(), + fileId + ); + } + + newDefs.put(def.id(), def); + LOGGER.debug( + "[TiedUpWorldgen] Server loaded room theme: {}", + def.id() + ); + } else { + skipped++; + } + } catch (Exception e) { + LOGGER.error( + "[TiedUpWorldgen] Server: Failed to read resource {}: {}", + fileId, + e.getMessage() + ); + skipped++; + } + } + + // Atomically replace all definitions in the registry + RoomThemeRegistry.reload(newDefs); + + LOGGER.info( + "[TiedUpWorldgen] Server loaded {} room theme definitions ({} skipped) from {} JSON files", + newDefs.size(), + skipped, + resources.size() + ); + } +}