feat(UC-02): data-driven room theme infrastructure (Phase 1+2)

- BlockPalette: weighted random block selection with condition variants
- RoomThemeDefinition: immutable record with wallBlock/floorBlock/etc convenience API
- DecorationConfig: positioned block records for theme-specific decorations
- RoomThemeRegistry: volatile atomic snapshot + pickRandom(weight-based)
- RoomThemeParser: JSON parsing with BlockStateParser + random_property expansion
- RoomThemeReloadListener: scans data/<ns>/tiedup_room_themes/*.json
- Register listener in TiedUpMod.onAddReloadListeners()
This commit is contained in:
NotEvil
2026-04-16 01:30:51 +02:00
parent a4fc05b503
commit 69f52eacf3
7 changed files with 935 additions and 0 deletions

View File

@@ -581,6 +581,14 @@ public class TiedUpMod {
LOGGER.info( LOGGER.info(
"Registered FurnitureServerReloadListener for data-driven furniture definitions" "Registered FurnitureServerReloadListener for data-driven furniture definitions"
); );
// Data-driven room theme definitions (server-side, from data/<namespace>/tiedup_room_themes/)
event.addListener(
new com.tiedup.remake.worldgen.RoomThemeReloadListener()
);
LOGGER.info(
"Registered RoomThemeReloadListener for data-driven room themes"
);
} }
} }
} }

View File

@@ -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.
*
* <p>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".</p>
*/
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<String, List<WeightedEntry>> variants;
/**
* @param variants condition name to weighted entries map (defensively copied)
*/
public BlockPalette(Map<String, List<WeightedEntry>> 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<WeightedEntry> 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<String, List<WeightedEntry>> variants() {
return variants;
}
}

View File

@@ -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.
*
* <p>Captures corner decorations, wall midpoint blocks, optional special
* first-corner placement, furniture cluster, and lighting/chain flags.</p>
*
* @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<PositionedBlock> cornerDecorations,
List<PositionedBlock> wallMidpointBlocks,
@Nullable PositionedBlock firstCornerSpecial,
List<PositionedBlock> 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) {}
}

View File

@@ -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.
*
* <p>Loaded from {@code data/<namespace>/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.</p>
*
* @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;
}
}

View File

@@ -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.
*
* <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_room_themes/}.</p>
*/
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.
*
* <p>Example:
* <pre>{
* "default": [{"block": "minecraft:stone_bricks", "weight": 0.8}],
* "bottom_row": [{"block": "minecraft:mossy_stone_bricks", "weight": 1.0}]
* }</pre>
*/
@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<String, List<BlockPalette.WeightedEntry>> variants = new HashMap<>();
for (Map.Entry<String, JsonElement> 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<BlockPalette.WeightedEntry> 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.
*
* <p>Each entry is a JSON object with:
* <ul>
* <li>{@code "block"} — block state string (e.g. "minecraft:candle[lit=true]")</li>
* <li>{@code "weight"} — float weight (default 1.0)</li>
* <li>{@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</li>
* </ul>
*/
private static List<BlockPalette.WeightedEntry> parseWeightedEntries(
JsonArray array, ResourceLocation fileId, String paletteName, String condition
) {
List<BlockPalette.WeightedEntry> 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<BlockPalette.WeightedEntry> 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.
*
* <p>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).</p>
*/
@SuppressWarnings("unchecked")
private static List<BlockPalette.WeightedEntry> 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<BlockPalette.WeightedEntry> 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<DecorationConfig.PositionedBlock> cornerDecos = parsePositionedBlockList(
obj, "corner_decorations", fileId
);
List<DecorationConfig.PositionedBlock> 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<DecorationConfig.PositionedBlock> 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<DecorationConfig.PositionedBlock> parsePositionedBlockList(
JsonObject parent, String fieldName, ResourceLocation fileId
) {
if (!parent.has(fieldName) || !parent.get(fieldName).isJsonArray()) {
return List.of();
}
JsonArray array = parent.getAsJsonArray(fieldName);
List<DecorationConfig.PositionedBlock> 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));
}
}

View File

@@ -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.
*
* <p>Server-authoritative: definitions are loaded from {@code data/<namespace>/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.</p>
*
* <p>Uses volatile atomic swap to ensure threads always see a consistent snapshot.</p>
*
* @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<ResourceLocation, RoomThemeDefinition> 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<RoomThemeDefinition> 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<RoomThemeDefinition> 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
)
);
}
}

View File

@@ -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/<namespace>/tiedup_room_themes/}
* for JSON files and populates the {@link RoomThemeRegistry}.
*
* <p>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.</p>
*
* <p>Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in
* {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.</p>
*/
public class RoomThemeReloadListener
extends SimplePreparableReloadListener<Void>
{
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<ResourceLocation, RoomThemeDefinition> newDefs = new HashMap<>();
Map<ResourceLocation, Resource> resources =
resourceManager.listResources(DIRECTORY, loc ->
loc.getPath().endsWith(".json")
);
int skipped = 0;
for (Map.Entry<ResourceLocation, Resource> 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()
);
}
}