Expand material support and checkpoint the completed building toolkit
Expose the runtime block/item registry through compact material search and single-material descriptions. Preserve private block-entity data through checked edits and durable undo without sending payloads to model context. Include the completed terrain tools, isolated world/map plugin, station lift, ACP streaming and guidance fixes, local camera auto-connect, construction scripts, and their public documentation, references and verification records. Active station decoration and private runtime data remain outside this commit. Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build, and isolated Paper verification of all 1,196 block defaults plus 5,392 independent property cases for placement, same-material edits and restoration.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# Initial terrain worlds
|
||||
|
||||
## Natural Shacraft profile
|
||||
|
||||
Configure `terrain-profile: shacraft-natural-v1`, copy `examples/terrain/shacraft-natural-world.json` as the recipe, and select a new world name such as `shacraft_lobby_v2`. The recipe supplies the seed, footprint and palette; its feature list must be empty because the versioned profile defines its own landforms. This is not a new MCP terrain recipe type.
|
||||
|
||||
Independent OpenSimplex2S fBm, weighted ridged noise, moderate coordinate warping and authored mountain corridors shape the skyline around soft central hills and connected water courses. There are no rectangular platform features. Before integer flooring, all heights exactly match the approved study's full-field SHA-256. No hydraulic erosion, biome pass or automatic route grading is implied.
|
||||
|
||||
Steep columns expose stone/andesite without a dirt layer. Gentle dry columns retain grass and soil; submerged columns have rock beds and continuous source water. Geometry, profile and material version are bound into the immutable generation identity. Live pre-generation checks every column's height, top material and water fill.
|
||||
|
||||
FastNoiseLite is vendored under `noise/` from upstream commit `785f37a9ad76e283586a379675085f2063ae03f7`, with a package declaration added. Its MIT notice is present in the source and under `META-INF/LICENSE-FastNoiseLite.txt` in the jar.
|
||||
|
||||
## Recipe-based initial worlds
|
||||
|
||||
This optional Paper 26.2 plugin creates and reloads a separate world using the existing `TerrainRecipe` height field. It loads before MinecraftBuilderMCP. Installation is inert until `enabled: true` is explicitly set in `plugins/ShacraftTerrain/config.yml`.
|
||||
|
||||
Build with `./mvnw package`, install `target/terrain-world-plugin-0.1.0-SNAPSHOT.jar`, and copy a sculpt recipe without preserve masks into the plugin data directory as `terrain.json`:
|
||||
|
||||
```yaml
|
||||
enabled: true
|
||||
world: shacraft_lobby
|
||||
recipe: terrain.json
|
||||
water-level: 48
|
||||
border-size: 2048
|
||||
```
|
||||
|
||||
The Shacraft example is `../examples/terrain/shacraft-lobby-world.json`: a 768×768 layout translated 64 blocks upward from the original design recipe. Spawn plaza Y=106; station Y=118; airship harbor Y=124. The west lake has a rock bed at Y=38 and water through Y=48. The ravines share that water level. Omit `water-level` for dry terrain.
|
||||
|
||||
Generation builds solid foundations down to world bedrock, follows the recipe's soil layers, replaces submerged surface blocks with rock and fills submerged columns with source water. There are no vanilla caves, structures, decorations or mobs. The recipe continues outside the footprint so neighboring chunks do not end at artificial vertical cuts. The optional exploration border can sit beyond the design footprint to keep its visible wall out of construction views; it does not expand the editor's authorized bounds. Day and weather are fixed for construction.
|
||||
|
||||
This is **initial world generation**, not an editor apply operation. It has no block-by-block undo journal. Existing chunks are never regenerated. The persisted generation manifest binds the generator version, recipe and water level; changing them under the same world name fails closed. Existing world folders without that manifest are refused. Keep the generator plugin, its configuration and manifest with world backups so future chunks use the same terrain.
|
||||
|
||||
Commands:
|
||||
|
||||
- `/lobby`: teleport yourself to the central spawn. Requires operator permission.
|
||||
- `/lobby status`: inspect the current pre-generation pass.
|
||||
- Server console `lobby <player>`: teleport an online player to the lobby.
|
||||
- Server console `lobby generate`: asynchronously load/generate the recipe footprint one chunk at a time. Verify every column's visible height, water fill and non-air bed; save `generation-report.json` on completion. Do this before building: later edits legitimately produce mismatches. Repeating the command loads existing chunks, without overwriting them.
|
||||
- Server console `lobby map <name>`: export an actual top-down surface map of the configured footprint, with no player or camera client. For example, `lobby map layout-before` writes `plugins/ShacraftTerrain/maps/layout-before.json` and `layout-before.png`. Use `lobby status` to see progress. Names use 1–48 lowercase letters, digits, underscores or hyphens; an existing map is never overwritten.
|
||||
|
||||
The map exporter reads one existing chunk per tick on the server thread and refuses to generate missing chunks. It changes no blocks. A detached worker renders the PNG and writes the JSON after all columns have been captured. Maps are bounded to 4,194,304 columns. The height and material describe the highest non-air block, including water and survey markers, so bridges and roofs hide the surfaces below. PNGs show actual surface materials with slope shading; colored concrete keeps its unshaded survey color. This is a server-derived orthographic map, not a screenshot or a perspective camera.
|
||||
|
||||
The compact JSON format `minecraft-builder-surface-map-v1` includes the world UUID/key, inclusive X/Z bounds, capture timestamps, `palette`, `palette_rgb`, and flat `surface_y` / `material_index` arrays. Index a column with `(z - min_z) * width + (x - min_x)`. North is at the top (negative Z), east at the right (positive X), and each PNG pixel represents one block. These are live, sequential chunk observations rather than an atomic snapshot: pause edits while capturing a before/after verification map. The exporter does not certify a particular build revision.
|
||||
|
||||
MinecraftBuilderMCP still supports one active project. Before selecting this world in its config, stop the server and archive the previous project's configuration, metadata and journal together. Use a new project ID and world epoch, a fresh journal directory, and explicit project bounds within the new world. Keep normal per-plan limits and owner authorization. The player-facing `/ai area` command retains its 2-million-block selection limit; an operator can configure the full lobby envelope directly for staged work.
|
||||
|
||||
For the local Shacraft session, connect to `127.0.0.1:25575`, then `/lobby`. The old `world` remains loaded; console `execute in minecraft:overworld run tp <player> 0 5 0` can return a player there. The active AI editor remains bound to Shacraft until the old project is restored offline.
|
||||
|
||||
Unit checks cover solid foundations, lake source-water continuity, negative chunk coordinates, the complete design's height bounds and invalid initial-world modes. Live generation reports cover real Paper chunks; they are not screenshots or a visual approval of the composition.
|
||||
|
||||
The first live Shacraft pass generated/loaded 2304 chunks in 144 seconds and verified all 589,824 columns, including 59,609 water columns, with zero mismatches. See [the saved report](../docs/references/shacraft-lobby-generation-report.json). Terrain reaches Y=203. The local server uses `--heap 4G` and `view-distance=32`; the client also needs an adequate render distance.
|
||||
|
||||
Paper 26.2 stores this dimension under `world/dimensions/minecraft/shacraft_lobby`. Back up the complete primary `world` save (including `level.dat`) together with both plugins' data and the generator jar. Copying only a legacy top-level `shacraft_lobby` folder is not a valid backup for this version.
|
||||
|
||||
The live natural v2 pass verified all 589,824 columns across 2304 chunks in 275 seconds: zero height/material/water mismatches, 42,399 water columns, highest surface Y=204. See [the report](../docs/references/shacraft-natural-v2-generation-report.json). The separately retained v1 report describes the earlier rectangular layout.
|
||||
@@ -0,0 +1,15 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>io.github.minecraftbuilder</groupId><artifactId>minecraft-builder-mcp</artifactId><version>0.1.0-SNAPSHOT</version></parent>
|
||||
<artifactId>terrain-world-plugin</artifactId>
|
||||
<repositories><repository><id>papermc</id><url>https://repo.papermc.io/repository/maven-public/</url></repository></repositories>
|
||||
<dependencies>
|
||||
<dependency><groupId>io.github.minecraftbuilder</groupId><artifactId>world-core</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>io.papermc.paper</groupId><artifactId>paper-api</artifactId><version>${paper.version}</version><scope>provided</scope></dependency>
|
||||
<dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.14.0</version></dependency>
|
||||
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.13.4</version><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-shade-plugin</artifactId><version>3.6.1</version><executions><execution><phase>package</phase><goals><goal>shade</goal></goals><configuration><createDependencyReducedPom>false</createDependencyReducedPom><filters><filter><artifact>*:*</artifact><excludes><exclude>META-INF/*.SF</exclude><exclude>META-INF/*.RSA</exclude><exclude>META-INF/*.DSA</exclude></excludes></filter></filters></configuration></execution></executions></plugin>
|
||||
</plugins></build>
|
||||
</project>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
|
||||
/** A configured indoor spawn must survive vanilla's highest-surface respawn search. */
|
||||
final class LobbyRespawn implements Listener {
|
||||
private final World world;
|
||||
private final Location configuredSpawn;
|
||||
|
||||
LobbyRespawn(World world, Location configuredSpawn) {
|
||||
this.world = world;
|
||||
this.configuredSpawn = configuredSpawn == null ? null : configuredSpawn.clone();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onRespawn(PlayerRespawnEvent event) {
|
||||
if (configuredSpawn != null && event.getRespawnReason() == PlayerRespawnEvent.RespawnReason.DEATH
|
||||
&& event.getPlayer().getWorld() == world)
|
||||
event.setRespawnLocation(configuredSpawn);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
/** Explicit player feet position; never derived from a roof or containment heightmap. */
|
||||
record LobbySpawn(double x, double y, double z, float yaw, float pitch) {
|
||||
LobbySpawn {
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||
|| !Float.isFinite(yaw) || !Float.isFinite(pitch))
|
||||
throw new IllegalArgumentException("Spawn coordinates and angles must be finite");
|
||||
if (Math.abs(x) >= 30_000_000 || Math.abs(z) >= 30_000_000)
|
||||
throw new IllegalArgumentException("Spawn coordinates must be inside Minecraft's world limits");
|
||||
if (pitch < -90 || pitch > 90) throw new IllegalArgumentException("Spawn pitch must be between -90 and 90");
|
||||
}
|
||||
|
||||
static LobbySpawn fromConfig(ConfigurationSection config, IntSupplier fallbackY) {
|
||||
if (!config.contains("spawn")) return new LobbySpawn(.5, fallbackY.getAsInt(), .5, 180, 0);
|
||||
ConfigurationSection spawn = config.getConfigurationSection("spawn");
|
||||
if (spawn == null) throw new IllegalArgumentException("Spawn must be a section with numeric x, y and z");
|
||||
return new LobbySpawn(number(spawn, "x"), number(spawn, "y"), number(spawn, "z"),
|
||||
(float) (spawn.contains("yaw") ? number(spawn, "yaw") : 180),
|
||||
(float) (spawn.contains("pitch") ? number(spawn, "pitch") : 0));
|
||||
}
|
||||
|
||||
private static double number(ConfigurationSection section, String key) {
|
||||
if (!(section.get(key) instanceof Number value))
|
||||
throw new IllegalArgumentException("Spawn " + key + " must be numeric");
|
||||
return value.doubleValue();
|
||||
}
|
||||
|
||||
void requireHeight(int minY, int maxY) {
|
||||
if (y < minY || y >= maxY) throw new IllegalArgumentException("Spawn Y must be inside world height bounds");
|
||||
}
|
||||
|
||||
Location location(World world) {
|
||||
requireHeight(world.getMinHeight(), world.getMaxHeight());
|
||||
return new Location(world, x, y, z, yaw, pitch);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import io.github.minecraftbuilder.terrainworld.noise.FastNoiseLite;
|
||||
|
||||
/** Frozen Shacraft natural-v1 profile, promoted from the approved offline study.
|
||||
* Noise objects are private and never mutated after construction; sampling is thread safe.
|
||||
*/
|
||||
public final class NaturalTerrain {
|
||||
private static FastNoiseLite noise(int seed, float scale, int octaves, boolean ridged) {
|
||||
var n = new FastNoiseLite(seed);
|
||||
n.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2S);
|
||||
n.SetFrequency(1f / scale);
|
||||
n.SetFractalType(ridged ? FastNoiseLite.FractalType.Ridged : FastNoiseLite.FractalType.FBm);
|
||||
n.SetFractalOctaves(octaves); n.SetFractalGain(.48f); n.SetFractalLacunarity(2.07f);
|
||||
n.SetFractalWeightedStrength(ridged ? .7f : .25f);
|
||||
return n;
|
||||
}
|
||||
private final FastNoiseLite broad, ridges, warpX, warpZ, detail, bank;
|
||||
public NaturalTerrain(int seed) {
|
||||
broad=noise(seed,220,4,false); ridges=noise(seed+12,85,5,true);
|
||||
warpX=noise(seed+8995,165,3,false); warpZ=noise(seed+108995,165,3,false);
|
||||
detail=noise(seed+208995,17,3,false); bank=noise(seed+308995,52,3,false);
|
||||
}
|
||||
public double slope(int x,int z) {
|
||||
return Math.hypot((height(x+2,z)-height(x-2,z))/4.0,(height(x,z+2)-height(x,z-2))/4.0);
|
||||
}
|
||||
public float rockVariation(int x,int z) { return bank.GetNoise(x*2f,z*2f); }
|
||||
private static final double[][] north={{-370,-225},{-280,-295},{-180,-320},{-70,-270},{55,-295},{170,-330},{275,-270},{370,-235}};
|
||||
private static final double[][] west={{-355,-235},{-315,-135},{-360,-30},{-315,95},{-325,245},{-285,370}};
|
||||
private static final double[][] east={{345,-235},{320,-100},{355,45},{315,160},{340,300},{285,390}};
|
||||
private static final double[][] riverEast={{160,-384},{145,-280},{112,-190},{127,-100},{107,-25},{100,70},{85,145},{110,215},{55,280},{30,384}};
|
||||
private static final double[][] riverWest={{-220,65},{-190,125},{-140,175},{-110,235},{-50,285},{-20,345},{30,384}};
|
||||
private static double distance(double x,double z,double[][] path) {
|
||||
double best=Double.POSITIVE_INFINITY;
|
||||
for(int i=1;i<path.length;i++) {
|
||||
double ax=path[i-1][0],az=path[i-1][1],dx=path[i][0]-ax,dz=path[i][1]-az;
|
||||
double t=Math.max(0,Math.min(1,((x-ax)*dx+(z-az)*dz)/(dx*dx+dz*dz)));
|
||||
best=Math.min(best,Math.hypot(x-ax-t*dx,z-az-t*dz));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
private static double bell(double value) { return Math.exp(-value*value); }
|
||||
private static double mix(double a,double b,double t) { return a+(b-a)*t; }
|
||||
private static double smooth(double x) { x=Math.max(0,Math.min(1,x));return x*x*(3-2*x); }
|
||||
public float height(int x,int z) {
|
||||
double wx=x+27*warpX.GetNoise(x,z),wz=z+27*warpZ.GetNoise(x,z);
|
||||
double h=62+11*broad.GetNoise((float)wx,(float)wz);
|
||||
double mountain=Math.max(145*bell(distance(wx,wz,north)/88),
|
||||
Math.max(82*bell(distance(wx,wz,west)/65),94*bell(distance(wx,wz,east)/70)));
|
||||
double ridge=Math.pow((ridges.GetNoise((float)wx,(float)wz)+1)*.5,1.55);
|
||||
h+=mountain*(.30+.70*ridge);
|
||||
h+=30*Math.exp(-Math.pow(wx/140,2)-Math.pow(wz/155,2));
|
||||
h+=11*Math.exp(-Math.pow(wx/100,2)-Math.pow((wz+115)/100,2));
|
||||
h+=14*Math.exp(-Math.pow((wx-205)/100,2)-Math.pow((wz-80)/150,2));
|
||||
h+=detail.GetNoise((float)wx,(float)wz)*(1.2+3.2*Math.min(1,mountain/90));
|
||||
double lake=Math.hypot((wx+220)/70,(wz+15)/98);
|
||||
h=mix(h,34+3*bank.GetNoise(x,z),1-smooth((lake-.55)/.85));
|
||||
double width=15+3*bank.GetNoise(x+421,z-312);
|
||||
double d=Math.min(distance(wx,wz,riverEast),distance(wx,wz,riverWest));
|
||||
h=mix(h,33+3*bank.GetNoise(x,z),bell(d/width));
|
||||
return (float)h;
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import io.github.minecraftbuilder.core.TerrainRecipe;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.generator.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Immutable initial terrain. Never reads or edits an existing chunk. */
|
||||
public final class RecipeGenerator extends ChunkGenerator {
|
||||
final TerrainRecipe recipe;
|
||||
private final Material rock, soil, surface;
|
||||
private final int depth;
|
||||
private final Integer waterLevel;
|
||||
private final NaturalTerrain natural;
|
||||
private final String profile;
|
||||
|
||||
public RecipeGenerator(JsonObject source) {
|
||||
this(source, null);
|
||||
}
|
||||
public RecipeGenerator(JsonObject source, Integer waterLevel) {
|
||||
this(source, waterLevel, "recipe-v1");
|
||||
}
|
||||
public RecipeGenerator(JsonObject source, Integer waterLevel, String profile) {
|
||||
recipe = new TerrainRecipe(source);
|
||||
if (!Set.of("recipe-v1", "shacraft-natural-v1").contains(profile))
|
||||
throw new IllegalArgumentException("Unknown terrain profile");
|
||||
this.profile = profile;
|
||||
natural = profile.equals("shacraft-natural-v1") ? new NaturalTerrain(source.get("seed").getAsInt()) : null;
|
||||
if (natural != null && !source.getAsJsonArray("features").isEmpty())
|
||||
throw new IllegalArgumentException("Natural profile uses its own landforms; recipe features must be empty");
|
||||
this.waterLevel = waterLevel;
|
||||
if (!recipe.mode().equals("sculpt") || !source.getAsJsonArray("preserve").isEmpty())
|
||||
throw new IllegalArgumentException("New worlds require sculpt mode without preserve masks");
|
||||
var palette = source.getAsJsonObject("palette");
|
||||
rock = Objects.requireNonNull(Material.matchMaterial(palette.get("rock").getAsString()));
|
||||
soil = Objects.requireNonNull(Material.matchMaterial(palette.get("soil").getAsString()));
|
||||
surface = Objects.requireNonNull(Material.matchMaterial(palette.get("surface").getAsString()));
|
||||
depth = palette.get("soil_depth").getAsInt();
|
||||
}
|
||||
|
||||
public String identity() {
|
||||
String legacy = "recipe-generator-v1:" + recipe.id() + ":water=" + waterLevel;
|
||||
return natural == null ? legacy : legacy + ":profile=" + profile + ":materials=slope-v1";
|
||||
}
|
||||
public int height(int x, int z) { return natural == null ? recipe.surfaceHeight(x, z) : (int)Math.floor(natural.height(x, z)); }
|
||||
public int visibleHeight(int x, int z) { return waterLevel == null ? height(x, z) : Math.max(height(x, z), waterLevel); }
|
||||
public Material surfaceMaterial(int x, int z) {
|
||||
boolean wet = waterLevel != null && height(x,z) < waterLevel;
|
||||
if (natural == null) return wet ? rock : surface;
|
||||
double variation = natural.rockVariation(x,z);
|
||||
boolean cliff = natural.slope(x,z) > 1.05 + .22 * variation;
|
||||
if (wet || cliff) return variation > .1 ? Material.ANDESITE : Material.STONE;
|
||||
return surface;
|
||||
}
|
||||
|
||||
@Override public void generateNoise(WorldInfo info, Random random, int cx, int cz, ChunkData data) {
|
||||
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) {
|
||||
int top = height(cx * 16 + x, cz * 16 + z);
|
||||
if (waterLevel != null && (waterLevel <= data.getMinHeight() || waterLevel >= data.getMaxHeight() - 1))
|
||||
throw new IllegalArgumentException("Water level outside world height");
|
||||
if (top <= data.getMinHeight() || top >= data.getMaxHeight() - 1)
|
||||
throw new IllegalArgumentException("Recipe surface outside world height");
|
||||
data.setBlock(x, data.getMinHeight(), z, Material.BEDROCK);
|
||||
Material topMaterial = surfaceMaterial(cx*16+x,cz*16+z);
|
||||
int soilDepth = natural != null && topMaterial != surface ? 0 : depth;
|
||||
int dirtStart = Math.max(data.getMinHeight() + 1, top - soilDepth);
|
||||
data.setRegion(x, data.getMinHeight() + 1, z, x + 1, dirtStart, z + 1, rock);
|
||||
data.setRegion(x, dirtStart, z, x + 1, top, z + 1, soil);
|
||||
data.setBlock(x, top, z, topMaterial);
|
||||
if (waterLevel != null && top < waterLevel)
|
||||
data.setRegion(x, top + 1, z, x + 1, waterLevel + 1, z + 1, Material.WATER);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public int getBaseHeight(WorldInfo world, Random random, int x, int z, HeightMap map) {
|
||||
return (map == HeightMap.OCEAN_FLOOR || map == HeightMap.OCEAN_FLOOR_WG ? height(x, z) : visibleHeight(x, z)) + 1;
|
||||
}
|
||||
@Override public Location getFixedSpawnLocation(World world, Random random) {
|
||||
return new Location(world, .5, height(0, 0) + 1, .5, 180, 0);
|
||||
}
|
||||
@Override public BiomeProvider getDefaultBiomeProvider(WorldInfo world) {
|
||||
return new BiomeProvider() {
|
||||
@Override public Biome getBiome(WorldInfo info, int x, int y, int z) { return Biome.PLAINS; }
|
||||
@Override public List<Biome> getBiomes(WorldInfo info) { return List.of(Biome.PLAINS); }
|
||||
};
|
||||
}
|
||||
@Override public boolean shouldGenerateNoise() { return false; }
|
||||
@Override public boolean shouldGenerateSurface() { return false; }
|
||||
@Override public boolean shouldGenerateCaves() { return false; }
|
||||
@Override public boolean shouldGenerateDecorations() { return false; }
|
||||
@Override public boolean shouldGenerateMobs() { return false; }
|
||||
@Override public boolean shouldGenerateStructures() { return false; }
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Display;
|
||||
import org.bukkit.entity.TextDisplay;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.util.Transformation;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/** Optional, persistent signs for the completed station; no arena destinations are implied. */
|
||||
public final class StationLabels {
|
||||
public static final String TAG = "shacraft_station_v1";
|
||||
private static final TextColor INK = TextColor.color(0xf3dfab);
|
||||
private static final Map<UUID, Installation> ACTIVE = new HashMap<>();
|
||||
|
||||
/** Position is the visual centre; yaw 0 faces south and yaw -90 faces east. */
|
||||
public record LabelSpec(String id, String text, double x, double y, double z,
|
||||
float yaw, float scale, int lineWidth, boolean bold) {}
|
||||
|
||||
private static final List<LabelSpec> LABELS = labels();
|
||||
|
||||
private StationLabels() {}
|
||||
|
||||
/** Immutable positions/text for a build manifest or a review before installation. */
|
||||
public static List<LabelSpec> manifest() { return LABELS; }
|
||||
|
||||
/**
|
||||
* Schedule installation on the server thread after existing label chunks have
|
||||
* loaded their saved entities. This prevents duplicate labels after a restart.
|
||||
* No terrain is generated. Only this plugin's marked TextDisplays are replaced.
|
||||
*/
|
||||
public static void install(JavaPlugin plugin, World world) {
|
||||
Objects.requireNonNull(plugin); Objects.requireNonNull(world);
|
||||
if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("Station labels must be scheduled on the server thread");
|
||||
Installation installation = new Installation(plugin, world);
|
||||
Installation previous = ACTIVE.put(world.getUID(), installation);
|
||||
if (previous != null) previous.close();
|
||||
installation.load();
|
||||
}
|
||||
|
||||
private static List<LabelSpec> labels() {
|
||||
List<LabelSpec> labels = new ArrayList<>();
|
||||
labels.add(new LabelSpec("exterior-shacraft", "SHACRAFT", -5.5, 111.2, -81.94, 0, 6.4f, 160, true));
|
||||
labels.add(new LabelSpec("smash-header", "S M A S H", -16.5, 122.2, -136.94, 0, 7f, 160, true));
|
||||
int[] bayCenters = {-42, -32, -22, -12, -2, 8};
|
||||
for (int i = 0; i < bayCenters.length; i++) {
|
||||
String number = String.format(java.util.Locale.ROOT, "%02d", i + 1);
|
||||
labels.add(new LabelSpec("arena-" + number, number,
|
||||
bayCenters[i] + .5, 119.6, -134.95, 0, 1.2f, 120, false));
|
||||
labels.add(new LabelSpec("arena-" + number + "-sign", "АРЕНА " + number + "\nСКОРО",
|
||||
bayCenters[i] + .5, 114.5, -134.95, 0, 1f, 120, false));
|
||||
}
|
||||
for (int floor = 1; floor <= 2; floor++) {
|
||||
int feet = floor == 1 ? 99 : 113;
|
||||
labels.add(new LabelSpec("lift-" + floor + "-front", floor == 1 ? "1 · ВЕСТИБЮЛЬ" : "2 · SMASH",
|
||||
-5.5, feet + 6.2, -114.93, 0, 2.4f, 160, true));
|
||||
labels.add(new LabelSpec("lift-" + floor + "-selector", floor == 1 ? "2 SMASH ↑" : "1 ВЕСТИБЮЛЬ ↓",
|
||||
-5.5, feet + 3.3, -121.92, 0, 1.65f, 160, true));
|
||||
labels.add(new LabelSpec("lift-" + floor + "-instruction", "ПКМ по золотой\nпанели",
|
||||
-5.5, feet + 1.3, -121.92, 0, 1.2f, 120, false));
|
||||
}
|
||||
labels.add(new LabelSpec("vestibule-lift-heading", "SHACRAFT", -5.5, 108, -114.93, 0, 3f, 160, true));
|
||||
labels.add(new LabelSpec("smash-damage", "УРОН\nЧем выше урон,\nтем сильнее\nотбрасывание",
|
||||
-70.96, 116.9, -121.5, -90, 1.1f, 120, false));
|
||||
labels.add(new LabelSpec("smash-knockback", "ОТБРАСЫВАНИЕ\nСтолкни соперников\nс острова",
|
||||
-70.96, 116.9, -114.5, -90, 1.1f, 120, false));
|
||||
labels.add(new LabelSpec("smash-double-jump", "ДВОЙНОЙ ПРЫЖОК\nПрыгни ещё раз,\nчтобы вернуться\nна остров",
|
||||
-70.96, 116.9, -107.5, -90, 1.1f, 120, false));
|
||||
return List.copyOf(labels);
|
||||
}
|
||||
|
||||
private static void configure(TextDisplay display, LabelSpec label, NamespacedKey owner, NamespacedKey id) {
|
||||
display.text(Component.text(label.text(), INK).decoration(TextDecoration.BOLD, label.bold()));
|
||||
display.setBillboard(Display.Billboard.FIXED);
|
||||
display.setRotation(label.yaw(), 0);
|
||||
display.setAlignment(TextDisplay.TextAlignment.CENTER);
|
||||
display.setLineWidth(label.lineWidth());
|
||||
display.setDefaultBackground(false);
|
||||
display.setBackgroundColor(Color.fromARGB(0, 0, 0, 0));
|
||||
display.setSeeThrough(false);
|
||||
display.setShadowed(true);
|
||||
display.setTextOpacity((byte) 255);
|
||||
display.setBrightness(new Display.Brightness(15, 15));
|
||||
display.setViewRange(label.id().equals("exterior-shacraft") ? 2f : 1f);
|
||||
display.setShadowRadius(0); display.setShadowStrength(0);
|
||||
display.setInterpolationDuration(0); display.setTeleportDuration(0);
|
||||
float lines = label.text().split("\n", -1).length;
|
||||
// Minecraft text is bottom-centred and uses 0.025 blocks per font pixel.
|
||||
display.setTransformation(new Transformation(new Vector3f(0, -.125f * label.scale() * lines, 0),
|
||||
new Quaternionf(), new Vector3f(label.scale()), new Quaternionf()));
|
||||
// Disable the optional display bounding-box cull; normal view distance still applies.
|
||||
display.setDisplayWidth(0); display.setDisplayHeight(0);
|
||||
display.setPersistent(true);
|
||||
display.addScoreboardTag(TAG);
|
||||
display.getPersistentDataContainer().set(owner, PersistentDataType.STRING, TAG);
|
||||
display.getPersistentDataContainer().set(id, PersistentDataType.STRING, label.id());
|
||||
}
|
||||
|
||||
private static final class Installation {
|
||||
private final JavaPlugin plugin;
|
||||
private final World world;
|
||||
private final List<Chunk> ownedTickets = new ArrayList<>();
|
||||
private List<Chunk> chunks = List.of();
|
||||
private boolean closed;
|
||||
private int attempts;
|
||||
|
||||
Installation(JavaPlugin plugin, World world) { this.plugin = plugin; this.world = world; }
|
||||
|
||||
void load() {
|
||||
record Column(int x, int z) {}
|
||||
var columns = new LinkedHashSet<Column>();
|
||||
for (LabelSpec label : LABELS)
|
||||
columns.add(new Column(Math.floorDiv((int) Math.floor(label.x()), 16),
|
||||
Math.floorDiv((int) Math.floor(label.z()), 16)));
|
||||
for (Column column : columns) {
|
||||
if (!world.isChunkGenerated(column.x(), column.z())) {
|
||||
fail("Station label chunk is not generated: " + column.x() + "," + column.z()); return;
|
||||
}
|
||||
}
|
||||
List<CompletableFuture<Chunk>> loads = columns.stream()
|
||||
.map(c -> world.getChunkAtAsync(c.x(), c.z(), false)).toList();
|
||||
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((unused, error) -> {
|
||||
if (!plugin.isEnabled() || closed) return;
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
if (closed) return;
|
||||
if (error != null) { fail("Cannot load station label chunks: " + error.getMessage()); return; }
|
||||
chunks = loads.stream().map(CompletableFuture::join).toList();
|
||||
if (chunks.stream().anyMatch(Objects::isNull)) { fail("An existing station chunk is unavailable"); return; }
|
||||
for (Chunk chunk : chunks) if (chunk.addPluginChunkTicket(plugin)) ownedTickets.add(chunk);
|
||||
waitForEntities();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void waitForEntities() {
|
||||
if (closed || !plugin.isEnabled()) { close(); return; }
|
||||
if (chunks.stream().anyMatch(c -> !c.isEntitiesLoaded())) {
|
||||
if (++attempts >= 100) { fail("Station entity data did not load; existing labels retained"); return; }
|
||||
Bukkit.getScheduler().runTaskLater(plugin, this::waitForEntities, 1L); return;
|
||||
}
|
||||
NamespacedKey owner = new NamespacedKey(plugin, "station_label_set");
|
||||
NamespacedKey id = new NamespacedKey(plugin, "station_label_id");
|
||||
List<TextDisplay> previous = world.getEntitiesByClass(TextDisplay.class).stream()
|
||||
.filter(e -> TAG.equals(e.getPersistentDataContainer().get(owner, PersistentDataType.STRING))).toList();
|
||||
List<TextDisplay> created = new ArrayList<>();
|
||||
try {
|
||||
for (LabelSpec label : LABELS) {
|
||||
Location location = new Location(world, label.x(), label.y(), label.z(), label.yaw(), 0);
|
||||
created.add(world.spawn(location, TextDisplay.class, display -> configure(display, label, owner, id)));
|
||||
}
|
||||
previous.forEach(TextDisplay::remove);
|
||||
plugin.getLogger().info("Station labels installed: " + created.size() + "; replaced=" + previous.size());
|
||||
} catch (Exception error) {
|
||||
created.forEach(TextDisplay::remove);
|
||||
plugin.getLogger().warning("Station labels not installed; previous labels retained: " + error.getMessage());
|
||||
} finally { close(); }
|
||||
}
|
||||
|
||||
void fail(String message) { plugin.getLogger().warning(message); close(); }
|
||||
|
||||
void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (Chunk chunk : ownedTickets) chunk.removePluginChunkTicket(plugin);
|
||||
ownedTickets.clear();
|
||||
ACTIVE.remove(world.getUID(), this);
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Explicitly enabled, two-stop personal lift for the completed station layout. */
|
||||
final class StationLift implements Listener, CommandExecutor {
|
||||
private final JavaPlugin plugin;
|
||||
private final World world;
|
||||
private final Set<UUID> pending = ConcurrentHashMap.newKeySet();
|
||||
private static final Set<Material> LANDING_FLOORS = EnumSet.of(Material.SMOOTH_SANDSTONE,
|
||||
Material.SMOOTH_QUARTZ, Material.QUARTZ_BLOCK, Material.CUT_SANDSTONE,
|
||||
Material.STONE_BRICKS, Material.SMOOTH_STONE, Material.POLISHED_ANDESITE,
|
||||
Material.GOLD_BLOCK, Material.WAXED_OXIDIZED_CUT_COPPER, Material.GREEN_CONCRETE,
|
||||
Material.SPRUCE_PLANKS, Material.POLISHED_DEEPSLATE, Material.WHITE_CONCRETE);
|
||||
|
||||
StationLift(JavaPlugin plugin, World world) { this.plugin = plugin; this.world = world; }
|
||||
|
||||
static int selectorFloor(int x, int y, int z) {
|
||||
if (x != -6 || z != -123) return 0;
|
||||
return y == 101 ? 1 : y == 115 ? 2 : 0;
|
||||
}
|
||||
|
||||
static int feetY(int floor) {
|
||||
if (floor != 1 && floor != 2) throw new IllegalArgumentException("Only completed floors 1 and 2 are available");
|
||||
return floor == 1 ? 99 : 113;
|
||||
}
|
||||
|
||||
static boolean supportsLanding(Material material) { return LANDING_FLOORS.contains(material); }
|
||||
|
||||
static boolean inCabin(double x, double y, double z, int floor) {
|
||||
return x >= -8 && x < -3 && z >= -122 && z < -117 && y >= feetY(floor) && y < feetY(floor) + 2;
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onInteract(PlayerInteractEvent event) {
|
||||
if (event.getHand() != EquipmentSlot.HAND || event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
|
||||
Player player = event.getPlayer();
|
||||
Block block = event.getClickedBlock();
|
||||
if (player.getWorld() != world || block == null || block.getType() != Material.GOLD_BLOCK) return;
|
||||
int floor = selectorFloor(block.getX(), block.getY(), block.getZ());
|
||||
if (floor == 0) return;
|
||||
event.setCancelled(true);
|
||||
Location at = player.getLocation();
|
||||
if (!inCabin(at.getX(), at.getY(), at.getZ(), floor)) {
|
||||
player.sendMessage(Component.text("Войди в кабину лифта.", NamedTextColor.GOLD)); return;
|
||||
}
|
||||
travel(player, floor == 1 ? 2 : 1, false);
|
||||
}
|
||||
|
||||
private void travel(Player player, int floor, boolean entrance) {
|
||||
if (!pending.add(player.getUniqueId())) return;
|
||||
Location target = new Location(world, -5.5, feetY(floor), entrance ? -92.5 : -119.5, entrance ? 180 : 0, 0);
|
||||
world.getChunkAtAsync(target).whenComplete((chunk, error) -> {
|
||||
if (!plugin.isEnabled()) { pending.remove(player.getUniqueId()); return; }
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
if (error != null || !player.isOnline() || player.getWorld() != world || !safeLanding(target)) {
|
||||
pending.remove(player.getUniqueId());
|
||||
if (player.isOnline()) player.sendMessage(Component.text("Лифт временно недоступен: проверь площадку назначения.", NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
// Loaded, checked destination. No shared cabin moves other passengers.
|
||||
boolean moved = player.teleport(target);
|
||||
pending.remove(player.getUniqueId());
|
||||
if (moved) {
|
||||
player.playSound(target, Sound.BLOCK_NOTE_BLOCK_CHIME, .6f, floor == 2 ? 1.2f : .8f);
|
||||
player.sendActionBar(Component.text(floor == 1 ? "1 · ВЕСТИБЮЛЬ" : "2 · SMASH", NamedTextColor.GOLD));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private boolean safeLanding(Location target) {
|
||||
int x=target.getBlockX(),y=target.getBlockY(),z=target.getBlockZ();
|
||||
return supportsLanding(world.getBlockAt(x,y-1,z).getType())
|
||||
&& world.getBlockAt(x,y,z).getType().isAir() && world.getBlockAt(x,y+1,z).getType().isAir();
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
Player player;
|
||||
String selected;
|
||||
boolean entrance;
|
||||
if (sender instanceof Player p) {
|
||||
if (args.length > 1) return usage(sender);
|
||||
player=p; selected=args.length==0 ? "1" : args[0]; entrance=args.length==0;
|
||||
} else if (sender instanceof ConsoleCommandSender && args.length>=1 && args.length<=2) {
|
||||
player=Bukkit.getPlayerExact(args[0]); selected=args.length==2 ? args[1] : "1"; entrance=args.length==1;
|
||||
} else return usage(sender);
|
||||
if (!selected.equals("1") && !selected.equals("2")) return usage(sender);
|
||||
if (player==null || player.getWorld()!=world) {
|
||||
sender.sendMessage("Station is available in the configured lobby world."); return true;
|
||||
}
|
||||
travel(player,Integer.parseInt(selected),entrance); return true;
|
||||
}
|
||||
|
||||
private boolean usage(CommandSender sender) { sender.sendMessage("/station [1|2] — vestibule or SMASH"); return true; }
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/** Detached world surface data. Rendering and file I/O never call the Bukkit API. */
|
||||
final class SurfaceMap {
|
||||
static final int MAX_COLUMNS = 4_194_304;
|
||||
final int minX, minZ, maxX, maxZ, width, length;
|
||||
private final int[] heights, materials;
|
||||
private final BitSet captured;
|
||||
private final Map<String, Integer> materialIds = new LinkedHashMap<>();
|
||||
private int columns, minY = Integer.MAX_VALUE, maxY = Integer.MIN_VALUE;
|
||||
|
||||
SurfaceMap(int minX, int minZ, int maxX, int maxZ) {
|
||||
long w = (long) maxX - minX + 1, l = (long) maxZ - minZ + 1;
|
||||
if (w <= 0 || l <= 0 || w > MAX_COLUMNS || l > MAX_COLUMNS || w * l > MAX_COLUMNS)
|
||||
throw new IllegalArgumentException("Map bounds must contain 1 to " + MAX_COLUMNS + " columns");
|
||||
this.minX = minX; this.minZ = minZ; this.maxX = maxX; this.maxZ = maxZ;
|
||||
width = (int) w; length = (int) l;
|
||||
heights = new int[width * length]; materials = new int[heights.length];
|
||||
captured = new BitSet(heights.length);
|
||||
}
|
||||
|
||||
void setColumn(int x, int z, int y, String material) {
|
||||
if (x < minX || x > maxX || z < minZ || z > maxZ) throw new IllegalArgumentException("Column outside map");
|
||||
if (material == null || !material.matches("minecraft:[a-z0-9_]+")) throw new IllegalArgumentException("Invalid material key");
|
||||
int index = (z - minZ) * width + x - minX;
|
||||
if (captured.get(index)) throw new IllegalStateException("Column captured twice");
|
||||
heights[index] = y;
|
||||
materials[index] = materialIds.computeIfAbsent(material, ignored -> materialIds.size());
|
||||
captured.set(index); columns++; minY = Math.min(minY, y); maxY = Math.max(maxY, y);
|
||||
}
|
||||
|
||||
int columns() { return columns; }
|
||||
|
||||
static void validateName(String name) {
|
||||
if (name == null || !name.matches("[a-z0-9][a-z0-9_-]{0,47}"))
|
||||
throw new IllegalArgumentException("Map name must use 1–48 lowercase letters, digits, underscores or hyphens");
|
||||
}
|
||||
|
||||
static void requireUnusedName(Path directory, String name) throws IOException {
|
||||
validateName(name);
|
||||
for (String extension : List.of(".json", ".png"))
|
||||
if (Files.exists(directory.resolve(name + extension), LinkOption.NOFOLLOW_LINKS))
|
||||
throw new FileAlreadyExistsException("Map already exists; choose a new name: " + name);
|
||||
}
|
||||
|
||||
void write(Path directory, String name, String world, String worldKey, String worldUuid,
|
||||
Instant startedAt, Instant finishedAt) throws IOException {
|
||||
if (columns != heights.length) throw new IllegalStateException("Cannot export an incomplete map");
|
||||
requireUnusedName(directory, name);
|
||||
Files.createDirectories(directory);
|
||||
Path jsonTemp = Files.createTempFile(directory, ".surface-", ".json.tmp");
|
||||
Path pngTemp = Files.createTempFile(directory, ".surface-", ".png.tmp");
|
||||
try {
|
||||
try (JsonWriter out = new JsonWriter(Files.newBufferedWriter(jsonTemp))) {
|
||||
out.beginObject();
|
||||
out.name("format").value("minecraft-builder-surface-map-v1");
|
||||
out.name("source").value("paper_world_surface");
|
||||
out.name("ignored_materials").beginArray().value("minecraft:barrier").endArray();
|
||||
out.name("surface_policy").value("Highest block excluding all air variants and ignored_materials; wholly transparent columns use minecraft:air at world minimum Y.");
|
||||
out.name("world").value(world); out.name("world_key").value(worldKey); out.name("world_uuid").value(worldUuid);
|
||||
out.name("capture_started_at").value(startedAt.toString());
|
||||
out.name("capture_finished_at").value(finishedAt.toString());
|
||||
out.name("atomic_snapshot").value(false);
|
||||
out.name("capture_note").value("Existing chunks read sequentially on the server thread; edits during capture can appear in different chunks at different times.");
|
||||
out.name("orientation").value("north_up; x increases right; z increases down");
|
||||
out.name("index").value("(z - min_z) * width + (x - min_x)");
|
||||
out.name("min_x").value(minX); out.name("max_x").value(maxX);
|
||||
out.name("min_z").value(minZ); out.name("max_z").value(maxZ);
|
||||
out.name("width").value(width); out.name("length").value(length);
|
||||
out.name("columns").value(columns); out.name("min_surface_y").value(minY); out.name("max_surface_y").value(maxY);
|
||||
out.name("palette").beginArray();
|
||||
for (String material : materialIds.keySet()) out.value(material);
|
||||
out.endArray(); out.name("palette_rgb").beginArray();
|
||||
for (String material : materialIds.keySet()) out.value(String.format(Locale.ROOT, "#%06x", color(material)));
|
||||
out.endArray(); out.name("surface_y").beginArray();
|
||||
for (int height : heights) out.value(height);
|
||||
out.endArray(); out.name("material_index").beginArray();
|
||||
for (int material : materials) out.value(material);
|
||||
out.endArray(); out.endObject();
|
||||
}
|
||||
if (!ImageIO.write(render(), "png", pngTemp.toFile())) throw new IOException("PNG writer unavailable");
|
||||
// Neither final filename is replaced, even if another process creates it during capture.
|
||||
Files.move(jsonTemp, directory.resolve(name + ".json"));
|
||||
Files.move(pngTemp, directory.resolve(name + ".png"));
|
||||
} finally {
|
||||
Files.deleteIfExists(jsonTemp); Files.deleteIfExists(pngTemp);
|
||||
}
|
||||
}
|
||||
|
||||
BufferedImage render() {
|
||||
if (columns != heights.length) throw new IllegalStateException("Cannot render an incomplete map");
|
||||
List<String> palette = new ArrayList<>(materialIds.keySet());
|
||||
BufferedImage image = new BufferedImage(width, length, BufferedImage.TYPE_INT_RGB);
|
||||
for (int z = 0; z < length; z++) for (int x = 0; x < width; x++) {
|
||||
int index = z * width + x;
|
||||
String material = palette.get(materials[index]);
|
||||
int base = color(material);
|
||||
double dx = (height(x + 1, z) - height(x - 1, z)) / 2.0;
|
||||
double dz = (height(x, z + 1) - height(x, z - 1)) / 2.0;
|
||||
double norm = Math.sqrt(dx * dx + dz * dz + 1);
|
||||
double light = (-dx * -.55 - dz * -.55 + .63) / norm;
|
||||
double shade = Math.clamp(.76 + .36 * light, .44, 1.13);
|
||||
// Survey blocks retain their categorical color; water has no false land relief.
|
||||
if (material.endsWith("_concrete") || material.endsWith("_wool") || material.endsWith("_terracotta")) shade = 1;
|
||||
if (material.equals("minecraft:water")) shade = .96;
|
||||
image.setRGB(x, z, shaded(base, shade));
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private double height(int x, int z) {
|
||||
return heights[Math.clamp(z, 0, length - 1) * width + Math.clamp(x, 0, width - 1)];
|
||||
}
|
||||
|
||||
private static int shaded(int color, double shade) {
|
||||
int r = (int) Math.clamp((color >> 16 & 255) * shade, 0, 255);
|
||||
int g = (int) Math.clamp((color >> 8 & 255) * shade, 0, 255);
|
||||
int b = (int) Math.clamp((color & 255) * shade, 0, 255);
|
||||
return r << 16 | g << 8 | b;
|
||||
}
|
||||
|
||||
static int color(String key) {
|
||||
String material = key.replaceFirst("^minecraft:", "");
|
||||
String pigment = material.replaceFirst("_(concrete|wool|terracotta)$", "");
|
||||
if (!pigment.equals(material)) {
|
||||
Integer color = switch (pigment) {
|
||||
case "white" -> 0xf0f0e6; case "orange" -> 0xf78d27; case "magenta" -> 0xdc52c7;
|
||||
case "light_blue" -> 0x68c8ec; case "yellow" -> 0xf6d34a; case "lime" -> 0x98d84d;
|
||||
case "pink" -> 0xf394b5; case "gray" -> 0x545c61; case "light_gray" -> 0xa4aaa6;
|
||||
case "cyan" -> 0x23b6b6; case "purple" -> 0x9460ce; case "blue" -> 0x4a69d8;
|
||||
case "brown" -> 0x916044; case "green" -> 0x527c31; case "red" -> 0xe3544b;
|
||||
case "black" -> 0x26282d; default -> null;
|
||||
};
|
||||
if (color != null) return color;
|
||||
}
|
||||
if (material.contains("leaves")) return 0x397548;
|
||||
if (material.contains("spruce") || material.contains("dark_oak")) return 0x75583c;
|
||||
if (material.endsWith("_planks") || material.endsWith("_log")) return 0xa48458;
|
||||
if (material.contains("copper")) return 0x639f8d;
|
||||
if (material.contains("quartz")) return 0xe9e4d5;
|
||||
if (material.contains("sandstone")) return 0xc8bb83;
|
||||
if (material.contains("deepslate") || material.equals("obsidian")) return 0x555a64;
|
||||
if (material.contains("stone_brick")) return 0x9ca49e;
|
||||
return switch (material) {
|
||||
case "water", "bubble_column" -> 0x367dba;
|
||||
case "grass_block", "short_grass", "tall_grass", "moss_block" -> 0x79a957;
|
||||
case "dirt", "coarse_dirt", "rooted_dirt", "dirt_path", "farmland" -> 0x937454;
|
||||
case "podzol", "mud" -> 0x675746;
|
||||
case "stone", "cobblestone", "smooth_stone" -> 0xa2a8a3;
|
||||
case "andesite", "polished_andesite" -> 0x929e98;
|
||||
case "diorite", "polished_diorite" -> 0xb8bebb;
|
||||
case "granite", "polished_granite" -> 0xae8980;
|
||||
case "sand" -> 0xd7cc9a;
|
||||
case "gravel" -> 0xa49f97;
|
||||
case "snow", "snow_block", "powder_snow" -> 0xe8eff1;
|
||||
case "ice", "packed_ice", "blue_ice" -> 0x93c7dc;
|
||||
case "gold_block", "glowstone", "lantern" -> 0xf4cf58;
|
||||
case "sea_lantern" -> 0xb4ece2;
|
||||
case "terracotta", "bricks" -> 0xb6775d;
|
||||
case "glass", "tinted_glass" -> 0xacced3;
|
||||
case "air", "cave_air", "void_air" -> 0x18232e;
|
||||
default -> 0x8a8988;
|
||||
};
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.function.IntFunction;
|
||||
|
||||
/** One existing chunk per server tick, followed by detached asynchronous rendering. */
|
||||
final class SurfaceMapCapture {
|
||||
private final JavaPlugin plugin;
|
||||
private final World world;
|
||||
private boolean active;
|
||||
private int completed, total, chunksWide;
|
||||
private String name = "none", state = "idle";
|
||||
private SurfaceMap map;
|
||||
private Instant startedAt;
|
||||
|
||||
SurfaceMapCapture(JavaPlugin plugin, World world) { this.plugin = plugin; this.world = world; }
|
||||
boolean active() { return active; }
|
||||
String status() { return "map=" + name + "; map_state=" + state + "; map_chunks=" + completed + "/" + total; }
|
||||
private Path directory() { return plugin.getDataFolder().toPath().resolve("maps"); }
|
||||
|
||||
record Column(int y, Material material) {}
|
||||
|
||||
/** Barriers contain players, but must not hide the visible world in a surface map. */
|
||||
static Column visibleColumn(int minY, int highestY, IntFunction<Material> materialAt) {
|
||||
for (int y = Math.max(minY, highestY); y >= minY; y--) {
|
||||
Material material = materialAt.apply(y);
|
||||
if (material != Material.AIR && material != Material.CAVE_AIR
|
||||
&& material != Material.VOID_AIR && material != Material.BARRIER) return new Column(y, material);
|
||||
if (y == minY) break;
|
||||
}
|
||||
// A void column has no visible surface; retain a bounded, explicit air sentinel.
|
||||
return new Column(minY, Material.AIR);
|
||||
}
|
||||
|
||||
private Column visibleColumn(int x, int z) {
|
||||
return visibleColumn(world.getMinHeight(), world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE),
|
||||
y -> world.getBlockAt(x, y, z).getType());
|
||||
}
|
||||
|
||||
void start(String name, int minX, int minZ, int maxX, int maxZ) throws IOException {
|
||||
if (active) throw new IllegalStateException("Map capture already active");
|
||||
SurfaceMap.requireUnusedName(directory(), name);
|
||||
map = new SurfaceMap(minX, minZ, maxX, maxZ);
|
||||
this.name = name; completed = 0;
|
||||
chunksWide = Math.floorDiv(maxX, 16) - Math.floorDiv(minX, 16) + 1;
|
||||
total = chunksWide * (Math.floorDiv(maxZ, 16) - Math.floorDiv(minZ, 16) + 1);
|
||||
startedAt = Instant.now(); active = true; state = "reading";
|
||||
plugin.getLogger().info("Surface map " + name + " started: " + total + " existing chunks; no terrain generation or block changes");
|
||||
next();
|
||||
}
|
||||
|
||||
private void next() {
|
||||
if (!active || !plugin.isEnabled()) return;
|
||||
if (completed == total) { write(); return; }
|
||||
int cx = Math.floorDiv(map.minX, 16) + completed % chunksWide;
|
||||
int cz = Math.floorDiv(map.minZ, 16) + completed / chunksWide;
|
||||
if (!world.isChunkGenerated(cx, cz)) { fail("Chunk " + cx + "," + cz + " has not been generated"); return; }
|
||||
// generate=false: exporting a map must not extend the world.
|
||||
world.getChunkAtAsync(cx, cz, false).whenComplete((chunk, error) -> {
|
||||
if (!plugin.isEnabled()) return;
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
if (error != null) { fail(error.getMessage()); return; }
|
||||
if (chunk == null) { fail("Existing chunk unavailable: " + cx + "," + cz); return; }
|
||||
boolean ticket = false;
|
||||
try {
|
||||
ticket = chunk.addPluginChunkTicket(plugin);
|
||||
for (int z = Math.max(map.minZ, cz * 16); z <= Math.min(map.maxZ, cz * 16 + 15); z++)
|
||||
for (int x = Math.max(map.minX, cx * 16); x <= Math.min(map.maxX, cx * 16 + 15); x++) {
|
||||
Column column = visibleColumn(x, z);
|
||||
map.setColumn(x, z, column.y(), column.material().getKey().toString());
|
||||
}
|
||||
completed++;
|
||||
if (completed % 512 == 0) plugin.getLogger().info("Surface map " + name + ": " + completed + "/" + total + " chunks");
|
||||
} catch (Exception e) { fail(e.getMessage()); return; }
|
||||
finally { if (ticket) chunk.removePluginChunkTicket(plugin); }
|
||||
// Zero-delay tasks can run again in the same scheduler heartbeat
|
||||
// when the chunk future is already complete. Require a later tick.
|
||||
Bukkit.getScheduler().runTaskLater(plugin, this::next, 1L);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void write() {
|
||||
state = "writing";
|
||||
Instant finishedAt = Instant.now();
|
||||
String worldName = world.getName(), worldKey = world.getKey().toString(), worldUuid = world.getUID().toString();
|
||||
// This worker only sees the completed primitive grid, strings and output directory.
|
||||
SurfaceMap capturedMap = map;
|
||||
Path destination = directory();
|
||||
String capturedName = name;
|
||||
Instant capturedStart = startedAt;
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
capturedMap.write(destination, capturedName, worldName, worldKey, worldUuid, capturedStart, finishedAt);
|
||||
if (!plugin.isEnabled()) return;
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
active = false; state = "complete"; map = null;
|
||||
plugin.getLogger().info("Surface map complete: maps/" + capturedName + ".json and .png; "
|
||||
+ capturedMap.columns() + " observed columns; north up; chunk-sequential capture");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
if (plugin.isEnabled()) Bukkit.getScheduler().runTask(plugin, () -> fail(e.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fail(String reason) {
|
||||
active = false; state = "failed"; map = null;
|
||||
plugin.getLogger().severe("Surface map " + name + " stopped: " + reason);
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import com.google.gson.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Optional bootstrap for a separate, operator-selected world. */
|
||||
public final class TerrainWorldPlugin extends JavaPlugin {
|
||||
private RecipeGenerator generator;
|
||||
private World world;
|
||||
private Location lobbySpawn;
|
||||
private SurfaceMapCapture surfaceMaps;
|
||||
private boolean generating;
|
||||
private int completed, total, mismatches, minHeight, maxHeight;
|
||||
private long columns, wetColumns, started;
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
saveDefaultConfig();
|
||||
if (!getConfig().getBoolean("enabled", false)) return;
|
||||
String name = getConfig().getString("world", "shacraft_lobby");
|
||||
if (!name.matches("[a-z0-9_]{1,48}")) throw new IllegalArgumentException("Invalid world name");
|
||||
// Paper 26.2 stores dimensions beneath the primary save, not server/name.
|
||||
World overworld = Objects.requireNonNull(Bukkit.getWorld(NamespacedKey.minecraft("overworld")));
|
||||
Path folder = overworld.getWorldPath().resolveSibling(name);
|
||||
Path recipeFile = getDataFolder().toPath().resolve(getConfig().getString("recipe", "terrain.json")).normalize();
|
||||
if (!recipeFile.startsWith(getDataFolder().toPath())) throw new IllegalArgumentException("Recipe must be inside plugin data folder");
|
||||
JsonObject source = JsonParser.parseString(Files.readString(recipeFile)).getAsJsonObject();
|
||||
Integer waterLevel = getConfig().isInt("water-level") ? getConfig().getInt("water-level") : null;
|
||||
String profile = getConfig().getString("terrain-profile", "recipe-v1");
|
||||
generator = new RecipeGenerator(source, waterLevel, profile);
|
||||
LobbySpawn spawn = LobbySpawn.fromConfig(getConfig(), () -> generator.height(0, 0) + 1);
|
||||
Path manifest = getDataFolder().toPath().resolve(name + ".generation.json");
|
||||
String identity = generator.identity();
|
||||
if (Files.exists(manifest)) {
|
||||
var saved = JsonParser.parseString(Files.readString(manifest)).getAsJsonObject();
|
||||
if (!saved.get("identity").getAsString().equals(identity))
|
||||
throw new IllegalStateException("Recipe changed: use a new world name to avoid seams");
|
||||
} else {
|
||||
if (Files.exists(folder)) throw new IllegalStateException("Refusing to adopt an existing world without its generation manifest");
|
||||
JsonObject saved = new JsonObject(); saved.addProperty("identity", identity); saved.add("recipe", source);
|
||||
if (waterLevel != null) saved.addProperty("water_level", waterLevel);
|
||||
saved.addProperty("terrain_profile", profile);
|
||||
Files.writeString(manifest, new GsonBuilder().setPrettyPrinting().create().toJson(saved), StandardOpenOption.CREATE_NEW);
|
||||
}
|
||||
if (Bukkit.getWorld(name) != null) throw new IllegalStateException("World already loaded by another provider");
|
||||
world = new WorldCreator(NamespacedKey.minecraft(name)).seed(source.get("seed").getAsLong())
|
||||
.generator(generator).generateStructures(false).createWorld();
|
||||
if (world == null) throw new IllegalStateException("World creation failed");
|
||||
surfaceMaps = new SurfaceMapCapture(this, world);
|
||||
var bounds = generator.recipe.bounds();
|
||||
world.getWorldBorder().setCenter((bounds.min().x() + bounds.max().x() + 1) / 2.0,
|
||||
(bounds.min().z() + bounds.max().z() + 1) / 2.0);
|
||||
int footprint = Math.max(generator.recipe.width(), generator.recipe.length());
|
||||
world.getWorldBorder().setSize(Math.max(footprint, getConfig().getInt("border-size", footprint)));
|
||||
lobbySpawn = spawn.location(world);
|
||||
if (getConfig().contains("spawn")) world.setSpawnLocation(lobbySpawn);
|
||||
else world.setSpawnLocation(0, lobbySpawn.getBlockY(), 0);
|
||||
getServer().getPluginManager().registerEvents(new LobbyRespawn(world,
|
||||
getConfig().contains("spawn") ? lobbySpawn : null), this);
|
||||
world.setTime(6000); world.setStorm(false); world.setThundering(false);
|
||||
world.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
|
||||
world.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
|
||||
world.setGameRule(GameRule.DO_MOB_SPAWNING, false);
|
||||
Objects.requireNonNull(getCommand("lobby")).setExecutor(this::command);
|
||||
if (getConfig().getBoolean("station.enabled", false)) {
|
||||
StationLift lift = new StationLift(this, world);
|
||||
getServer().getPluginManager().registerEvents(lift, this);
|
||||
Objects.requireNonNull(getCommand("station")).setExecutor(lift);
|
||||
StationLabels.install(this, world);
|
||||
}
|
||||
getLogger().info("Terrain world ready: " + name + "; recipe=" + generator.recipe.id());
|
||||
} catch (Exception e) {
|
||||
getLogger().severe("Terrain world disabled: " + e.getMessage());
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean command(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (world == null) { sender.sendMessage("Terrain world unavailable"); return true; }
|
||||
if (args.length == 1 && args[0].equals("status")) {
|
||||
sender.sendMessage("Shacraft: " + completed + "/" + total + " chunks; active=" + generating + "; mismatched columns=" + mismatches + "; " + surfaceMaps.status());
|
||||
} else if (args.length == 2 && args[0].equals("map")) {
|
||||
if (!(sender instanceof ConsoleCommandSender)) { sender.sendMessage("Surface map export is a server-console command"); return true; }
|
||||
if (generating) { sender.sendMessage("Wait for terrain generation before capturing a map"); return true; }
|
||||
try {
|
||||
var b = generator.recipe.bounds();
|
||||
surfaceMaps.start(args[1], b.min().x(), b.min().z(), b.max().x(), b.max().z());
|
||||
sender.sendMessage("Surface map started; inspect lobby status. Output: plugins/ShacraftTerrain/maps/" + args[1] + ".{json,png}");
|
||||
} catch (Exception e) { sender.sendMessage("Cannot capture map: " + e.getMessage()); }
|
||||
} else if (args.length == 1 && args[0].equals("generate") && sender instanceof ConsoleCommandSender) {
|
||||
if (generating) { sender.sendMessage("Generation already active"); return true; }
|
||||
if (surfaceMaps.active()) { sender.sendMessage("Wait for the active surface map capture"); return true; }
|
||||
completed = 0; columns = 0; wetColumns = 0; mismatches = 0;
|
||||
minHeight = Integer.MAX_VALUE; maxHeight = Integer.MIN_VALUE;
|
||||
var b = generator.recipe.bounds();
|
||||
total = (Math.floorDiv(b.max().x(), 16) - Math.floorDiv(b.min().x(), 16) + 1)
|
||||
* (Math.floorDiv(b.max().z(), 16) - Math.floorDiv(b.min().z(), 16) + 1);
|
||||
started = System.currentTimeMillis(); generating = true; next();
|
||||
} else {
|
||||
Player player = sender instanceof Player p ? p : args.length == 1 ? Bukkit.getPlayerExact(args[0]) : null;
|
||||
if (player == null) { sender.sendMessage("/lobby [status] or console: lobby <player> | generate | map <name>"); return true; }
|
||||
player.teleportAsync(lobbySpawn.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void next() {
|
||||
if (!isEnabled() || !generating) return;
|
||||
if (completed == total) {
|
||||
generating = false; world.save();
|
||||
var report = Map.of("world", world.getName(), "recipe_id", generator.recipe.id(), "chunks", total,
|
||||
"verified_columns", columns, "water_columns", wetColumns, "mismatches", mismatches, "min_surface_y", minHeight,
|
||||
"max_surface_y", maxHeight, "elapsed_ms", System.currentTimeMillis() - started);
|
||||
try { Files.writeString(getDataFolder().toPath().resolve("generation-report.json"), new GsonBuilder().setPrettyPrinting().create().toJson(report)); }
|
||||
catch (Exception e) { getLogger().severe("Cannot save generation report: " + e.getMessage()); }
|
||||
getLogger().info("Terrain generation complete: " + report); return;
|
||||
}
|
||||
var b = generator.recipe.bounds();
|
||||
int nx = Math.floorDiv(b.max().x(), 16) - Math.floorDiv(b.min().x(), 16) + 1;
|
||||
int cx = Math.floorDiv(b.min().x(), 16) + completed % nx;
|
||||
int cz = Math.floorDiv(b.min().z(), 16) + completed / nx;
|
||||
world.getChunkAtAsync(cx, cz, true).whenComplete((chunk, error) -> {
|
||||
if (!isEnabled()) return;
|
||||
Bukkit.getScheduler().runTask(this, () -> {
|
||||
if (error != null) { generating = false; getLogger().severe("Generation stopped: " + error.getMessage()); return; }
|
||||
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) {
|
||||
int wx = cx * 16 + x, wz = cz * 16 + z;
|
||||
if (wx < b.min().x() || wx > b.max().x() || wz < b.min().z() || wz > b.max().z()) continue;
|
||||
int expected = generator.visibleHeight(wx, wz);
|
||||
int actual = world.getHighestBlockYAt(wx, wz, HeightMap.WORLD_SURFACE);
|
||||
int ground = generator.height(wx, wz);
|
||||
boolean wet = expected > ground;
|
||||
boolean mismatch = actual != expected || world.getBlockAt(wx, ground, wz).getType() != generator.surfaceMaterial(wx,wz);
|
||||
if (wet) {
|
||||
wetColumns++;
|
||||
for (int y = ground + 1; y <= expected; y++)
|
||||
if (world.getBlockAt(wx, y, wz).getType() != Material.WATER) { mismatch = true; break; }
|
||||
if (world.getBlockAt(wx, ground, wz).getType().isAir()) mismatch = true;
|
||||
}
|
||||
if (mismatch) mismatches++;
|
||||
minHeight = Math.min(minHeight, actual); maxHeight = Math.max(maxHeight, actual); columns++;
|
||||
}
|
||||
completed++;
|
||||
if (completed % 128 == 0) getLogger().info("Terrain progress " + completed + "/" + total);
|
||||
Bukkit.getScheduler().runTaskLater(this, this::next, 1L);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+2611
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
MIT License
|
||||
|
||||
Copyright(c) 2023 Jordan Peck (jordan.me2@gmail.com)
|
||||
Copyright(c) 2023 Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files(the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions :
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Upstream: https://github.com/Auburn/FastNoiseLite
|
||||
Commit: 785f37a9ad76e283586a379675085f2063ae03f7
|
||||
Local change: Java package declaration only.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Explicit opt-in; never creates or adopts a world by default.
|
||||
enabled: false
|
||||
world: shacraft_lobby
|
||||
recipe: terrain.json
|
||||
# recipe-v1, or shacraft-natural-v1 with an empty-feature envelope recipe.
|
||||
terrain-profile: recipe-v1
|
||||
# Optional absolute surface Y for lakes/rivers. Omit for dry terrain.
|
||||
# water-level: 48
|
||||
# Optional exploration border, independent of the editor's authorized bounds.
|
||||
# border-size: 2048
|
||||
# Optional fixed player feet position for /lobby, world spawn and deaths in this lobby.
|
||||
# Omit this section to keep the recipe's original spawn at x=0, z=0.
|
||||
# Set this after building a raised plaza; invisible roofs are not spawn surfaces.
|
||||
# spawn:
|
||||
# x: 0.5
|
||||
# y: 96
|
||||
# z: 43.5
|
||||
# yaw: 180
|
||||
# pitch: 0
|
||||
# Enable only after the two-floor clock station at the documented coordinates is built.
|
||||
# station:
|
||||
# enabled: true
|
||||
@@ -0,0 +1,17 @@
|
||||
name: ShacraftTerrain
|
||||
version: '0.1.0'
|
||||
main: io.github.minecraftbuilder.terrainworld.TerrainWorldPlugin
|
||||
api-version: '26.2'
|
||||
loadbefore: [MinecraftBuilderMCP]
|
||||
commands:
|
||||
lobby:
|
||||
description: Visit the configured terrain world, inspect generation or export a surface map
|
||||
permission: minecraftbuilder.lobby
|
||||
station:
|
||||
description: Visit the completed clock station vestibule or SMASH floor
|
||||
permission: minecraftbuilder.station
|
||||
permissions:
|
||||
minecraftbuilder.lobby:
|
||||
default: op
|
||||
minecraftbuilder.station:
|
||||
default: true
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.lang.reflect.Proxy;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LobbyRespawnTest {
|
||||
@Test void configuredLobbyDeathUsesPlazaFeetEvenWhenVanillaSelectedBarrierRoof() {
|
||||
World lobby = world();
|
||||
Location spawn = new Location(lobby, .5, 96, 43.5, 180, 0);
|
||||
LobbyRespawn listener = new LobbyRespawn(lobby, spawn);
|
||||
spawn.setY(300); // The listener retains its own configuration snapshot.
|
||||
var event = event(lobby, new Location(lobby, .5, 131, 43.5), PlayerRespawnEvent.RespawnReason.DEATH);
|
||||
listener.onRespawn(event);
|
||||
assertDestination(event.getRespawnLocation(), lobby, .5, 96, 43.5, 180, 0);
|
||||
event.getRespawnLocation().setY(200);
|
||||
var next = event(lobby, new Location(world(), 8, 70, 9), PlayerRespawnEvent.RespawnReason.DEATH);
|
||||
listener.onRespawn(next);
|
||||
assertDestination(next.getRespawnLocation(), lobby, .5, 96, 43.5, 180, 0);
|
||||
}
|
||||
|
||||
@Test void otherWorldDeathsAreUnchangedEvenWhenTheirDestinationIsLobby() {
|
||||
World lobby = world(), other = world();
|
||||
var listener = new LobbyRespawn(lobby, new Location(lobby, .5, 96, 43.5, 180, 0));
|
||||
var event = event(other, new Location(lobby, 11, 115, 23, 90, 10), PlayerRespawnEvent.RespawnReason.DEATH);
|
||||
listener.onRespawn(event);
|
||||
assertDestination(event.getRespawnLocation(), lobby, 11, 115, 23, 90, 10);
|
||||
}
|
||||
|
||||
@Test void absentConfigurationAndNonDeathRespawnsKeepOriginalBehavior() {
|
||||
World lobby = world();
|
||||
var disabled = new LobbyRespawn(lobby, null);
|
||||
var death = event(lobby, new Location(lobby, 11, 115, 23), PlayerRespawnEvent.RespawnReason.DEATH);
|
||||
disabled.onRespawn(death);
|
||||
assertDestination(death.getRespawnLocation(), lobby, 11, 115, 23, 0, 0);
|
||||
var enabled = new LobbyRespawn(lobby, new Location(lobby, .5, 96, 43.5, 180, 0));
|
||||
for (var reason : PlayerRespawnEvent.RespawnReason.values()) {
|
||||
if (reason == PlayerRespawnEvent.RespawnReason.DEATH) continue;
|
||||
var event = event(lobby, new Location(lobby, 11, 115, 23), reason);
|
||||
enabled.onRespawn(event);
|
||||
assertDestination(event.getRespawnLocation(), lobby, 11, 115, 23, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerRespawnEvent event(World origin, Location destination, PlayerRespawnEvent.RespawnReason reason) {
|
||||
Player player = (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class},
|
||||
(proxy, method, args) -> {
|
||||
if (method.getName().equals("getWorld")) return origin;
|
||||
throw new AssertionError("Unexpected player call: " + method.getName());
|
||||
});
|
||||
return new PlayerRespawnEvent(player, destination, false, false, false, reason);
|
||||
}
|
||||
|
||||
private static World world() {
|
||||
return (World) Proxy.newProxyInstance(World.class.getClassLoader(), new Class<?>[]{World.class},
|
||||
(proxy, method, args) -> { throw new AssertionError("Must not query terrain or world state: " + method.getName()); });
|
||||
}
|
||||
|
||||
private static void assertDestination(Location location, World world, double x, double y, double z, float yaw, float pitch) {
|
||||
assertSame(world, location.getWorld());
|
||||
assertEquals(x, location.getX()); assertEquals(y, location.getY()); assertEquals(z, location.getZ());
|
||||
assertEquals(yaw, location.getYaw()); assertEquals(pitch, location.getPitch());
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import org.bukkit.configuration.MemoryConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LobbySpawnTest {
|
||||
@Test void absentSectionKeepsOriginalRecipeFallback() {
|
||||
assertEquals(new LobbySpawn(.5, 89, .5, 180, 0),
|
||||
LobbySpawn.fromConfig(new MemoryConfiguration(), () -> 89));
|
||||
}
|
||||
|
||||
@Test void explicitPlazaCoordinatesDoNotConsultGeneratedTerrainOrRoof() {
|
||||
MemoryConfiguration config = configured();
|
||||
config.set("spawn.yaw", 137.5);
|
||||
config.set("spawn.pitch", -12.5);
|
||||
assertEquals(new LobbySpawn(.5, 96, 43.5, 137.5f, -12.5f),
|
||||
LobbySpawn.fromConfig(config, () -> { throw new AssertionError("Fallback must not be read"); }));
|
||||
config.set("spawn.yaw", null);
|
||||
config.set("spawn.pitch", null);
|
||||
assertEquals(new LobbySpawn(.5, 96, 43.5, 180, 0), LobbySpawn.fromConfig(config, () -> 200));
|
||||
}
|
||||
|
||||
@Test void incompleteNonNumericAndNonFiniteSettingsFailInsteadOfFallingBack() {
|
||||
MemoryConfiguration config = configured();
|
||||
config.set("spawn.z", null);
|
||||
assertThrows(IllegalArgumentException.class, () -> LobbySpawn.fromConfig(config, () -> 89));
|
||||
for (Object value : new Object[]{"96", Double.NaN, Double.POSITIVE_INFINITY}) {
|
||||
MemoryConfiguration invalid = configured(); invalid.set("spawn.y", value);
|
||||
assertThrows(IllegalArgumentException.class, () -> LobbySpawn.fromConfig(invalid, () -> 89));
|
||||
}
|
||||
config.set("spawn", "invalid");
|
||||
assertThrows(IllegalArgumentException.class, () -> LobbySpawn.fromConfig(config, () -> 89));
|
||||
}
|
||||
|
||||
@Test void worldAndViewLimitsAreChecked() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(30_000_000, 96, 0, 180, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, 96, 0, Float.POSITIVE_INFINITY, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, 96, 0, 180, 91));
|
||||
assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, -65, 0, 180, 0).requireHeight(-64, 320));
|
||||
assertThrows(IllegalArgumentException.class, () -> new LobbySpawn(0, 320, 0, 180, 0).requireHeight(-64, 320));
|
||||
assertDoesNotThrow(() -> new LobbySpawn(0, 96, 0, 180, 0).requireHeight(-64, 320));
|
||||
}
|
||||
|
||||
private static MemoryConfiguration configured() {
|
||||
MemoryConfiguration config = new MemoryConfiguration();
|
||||
config.createSection("spawn", Map.of("x", .5, "y", 96, "z", 43.5, "yaw", 180, "pitch", 0));
|
||||
return config;
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import com.google.gson.*;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.generator.ChunkGenerator.ChunkData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RecipeGeneratorTest {
|
||||
private JsonObject naturalSource() throws Exception {
|
||||
return JsonParser.parseString(Files.readString(Path.of("../examples/terrain/shacraft-natural-world.json"))).getAsJsonObject();
|
||||
}
|
||||
@Test void productionFieldExactlyMatchesTheApprovedStudy() throws Exception {
|
||||
var terrain = new NaturalTerrain(28092005);
|
||||
var digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
var buffer = java.nio.ByteBuffer.allocate(4);
|
||||
for(int z=-384;z<384;z++)for(int x=-384;x<384;x++) {
|
||||
float h=terrain.height(x,z);
|
||||
assertTrue(h>=16&&h<=239);
|
||||
buffer.clear();buffer.putFloat(h);digest.update(buffer.array());
|
||||
}
|
||||
assertEquals("3ac6a3d8d884adf8ef074ec0dd4c0e066b18832f45a50e771b6d9433e4c79096",HexFormat.of().formatHex(digest.digest()));
|
||||
}
|
||||
@Test void naturalCliffsHaveRockInsteadOfSoilStripes() throws Exception {
|
||||
var generator=new RecipeGenerator(naturalSource(),48,"shacraft-natural-v1");
|
||||
var terrain=new NaturalTerrain(28092005);
|
||||
var cells=chunk(generator,-18,-19);
|
||||
int cliffs=0;
|
||||
for(int x=0;x<16;x++)for(int z=0;z<16;z++) {
|
||||
int wx=-288+x,wz=-304+z,top=generator.height(wx,wz);
|
||||
if(terrain.slope(wx,wz)>1.5) {
|
||||
cliffs++;
|
||||
assertTrue(Set.of(Material.STONE,Material.ANDESITE).contains(cells[x][top+64][z]));
|
||||
for(int y=top-3;y<top;y++)assertEquals(Material.STONE,cells[x][y+64][z]);
|
||||
}
|
||||
}
|
||||
assertTrue(cliffs>20);
|
||||
assertEquals(Material.GRASS_BLOCK,generator.surfaceMaterial(0,0));
|
||||
}
|
||||
@Test void naturalLakeHasContinuousWaterAndNoGrassUnderwater() throws Exception {
|
||||
var generator=new RecipeGenerator(naturalSource(),48,"shacraft-natural-v1");
|
||||
var cells=chunk(generator,-14,-1);
|
||||
for(int x=0;x<16;x++)for(int z=0;z<16;z++) {
|
||||
int top=generator.height(-224+x,-16+z);
|
||||
assertTrue(top<48);
|
||||
assertTrue(Set.of(Material.STONE,Material.ANDESITE).contains(cells[x][top+64][z]));
|
||||
for(int y=top+1;y<=48;y++)assertEquals(Material.WATER,cells[x][y+64][z]);
|
||||
assertNull(cells[x][49+64][z]);
|
||||
}
|
||||
}
|
||||
@Test void profileAndWaterArePartOfImmutableGenerationIdentity() throws Exception {
|
||||
var source=naturalSource();
|
||||
var dry=new RecipeGenerator(source,null,"shacraft-natural-v1");
|
||||
var wet=new RecipeGenerator(source,48,"shacraft-natural-v1");
|
||||
assertNotEquals(dry.identity(),wet.identity());
|
||||
assertNotEquals(wet.identity(),new RecipeGenerator(source,48).identity());
|
||||
assertThrows(IllegalArgumentException.class,()->new RecipeGenerator(source,48,"unknown"));
|
||||
assertThrows(IllegalArgumentException.class,()->new RecipeGenerator(source(),48,"shacraft-natural-v1"));
|
||||
}
|
||||
private JsonObject source() throws Exception {
|
||||
return JsonParser.parseString(Files.readString(Path.of("../examples/terrain/shacraft-lobby-world.json"))).getAsJsonObject();
|
||||
}
|
||||
private Material[][][] chunk(RecipeGenerator generator, int cx, int cz) {
|
||||
Material[][][] cells = new Material[16][384][16];
|
||||
ChunkData data = (ChunkData) Proxy.newProxyInstance(ChunkData.class.getClassLoader(), new Class[]{ChunkData.class}, (p, m, args) -> {
|
||||
switch (m.getName()) {
|
||||
case "getMinHeight": return -64;
|
||||
case "getMaxHeight": return 320;
|
||||
case "setBlock": cells[(int)args[0]][(int)args[1]+64][(int)args[2]] = (Material)args[3]; return null;
|
||||
case "setRegion":
|
||||
for(int x=(int)args[0];x<(int)args[3];x++) for(int y=(int)args[1];y<(int)args[4];y++) for(int z=(int)args[2];z<(int)args[5];z++)
|
||||
cells[x][y+64][z]=(Material)args[6];
|
||||
return null;
|
||||
default: throw new UnsupportedOperationException(m.getName());
|
||||
}
|
||||
});
|
||||
generator.generateNoise(null, new Random(1), cx, cz, data);
|
||||
return cells;
|
||||
}
|
||||
@Test void spawnPlateauHasSolidFoundationAndDrySurface() throws Exception {
|
||||
var generator = new RecipeGenerator(source(), 48);
|
||||
var cells = chunk(generator, 0, 0);
|
||||
assertEquals(106, generator.height(0, 0));
|
||||
assertEquals(Material.BEDROCK, cells[0][0][0]);
|
||||
assertEquals(Material.STONE, cells[0][64][0]);
|
||||
assertEquals(Material.DIRT, cells[0][169][0]);
|
||||
assertEquals(Material.GRASS_BLOCK, cells[0][170][0]);
|
||||
assertNull(cells[0][171][0]);
|
||||
}
|
||||
@Test void westernLakeHasRockBedAndSourceWaterWithoutGaps() throws Exception {
|
||||
var generator = new RecipeGenerator(source(), 48);
|
||||
var cells = chunk(generator, -14, -1);
|
||||
int x = Math.floorMod(-220,16), z = Math.floorMod(-5,16);
|
||||
assertEquals(38, generator.height(-220,-5));
|
||||
assertEquals(Material.STONE,cells[x][38+64][z]);
|
||||
for(int y=39;y<=48;y++) assertEquals(Material.WATER,cells[x][y+64][z]);
|
||||
assertNull(cells[x][49+64][z]);
|
||||
assertEquals(48,generator.visibleHeight(-220,-5));
|
||||
assertEquals(38,new RecipeGenerator(source()).visibleHeight(-220,-5));
|
||||
}
|
||||
@Test void neighboringNegativeChunksMatchTheGlobalField() throws Exception {
|
||||
var generator = new RecipeGenerator(source(),48);
|
||||
for(int cx=-2;cx<=-1;cx++) {
|
||||
var cells=chunk(generator,cx,-18);
|
||||
for(int x=0;x<16;x++)for(int z=0;z<16;z++) {
|
||||
int top=generator.visibleHeight(cx*16+x,-18*16+z);
|
||||
assertNotNull(cells[x][top+64][z]);
|
||||
assertNull(cells[x][top+65][z]);
|
||||
for(int y=-64;y<top;y++)assertNotNull(cells[x][y+64][z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void entireLobbyFitsWorldAndRetainsBuildingPads() throws Exception {
|
||||
var generator = new RecipeGenerator(source(),48);
|
||||
int low=320,high=-64;
|
||||
for(int x=-384;x<384;x++)for(int z=-384;z<384;z++) {
|
||||
int h=generator.height(x,z);low=Math.min(low,h);high=Math.max(high,h);
|
||||
assertTrue(h>=16&&h<=207,"Recipe clips at "+x+","+z);
|
||||
}
|
||||
assertEquals(22,low);
|
||||
assertTrue(high>150);
|
||||
assertEquals(118,generator.height(0,-120));
|
||||
assertEquals(124,generator.height(180,-140));
|
||||
}
|
||||
@Test void cutOrPreservedRecipesCannotGenerateNewWorlds() throws Exception {
|
||||
var source=source();source.addProperty("mode","cut");
|
||||
assertThrows(IllegalArgumentException.class,()->new RecipeGenerator(source));
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.bukkit.Material;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StationLiftTest {
|
||||
@Test void partialAndProtrudingSupportsCannotBeLandingFloors() {
|
||||
assertTrue(StationLift.supportsLanding(Material.WAXED_OXIDIZED_CUT_COPPER));
|
||||
assertTrue(StationLift.supportsLanding(Material.SMOOTH_SANDSTONE));
|
||||
for (Material block : new Material[]{Material.AIR,Material.SPRUCE_FENCE,Material.STONE_BRICK_WALL,
|
||||
Material.SMOOTH_STONE_SLAB,Material.STONE_BRICK_STAIRS,Material.WATER})
|
||||
assertFalse(StationLift.supportsLanding(block));
|
||||
}
|
||||
@Test void onlyCompletedStopsCanBeSelected() {
|
||||
assertEquals(99, StationLift.feetY(1));
|
||||
assertEquals(113, StationLift.feetY(2));
|
||||
for (int floor : new int[]{-1,0,3,4,100})
|
||||
assertThrows(IllegalArgumentException.class, () -> StationLift.feetY(floor));
|
||||
}
|
||||
|
||||
@Test void decorativeGoldAndOtherHeightsDoNotActAsControls() {
|
||||
assertEquals(1, StationLift.selectorFloor(-6,101,-123));
|
||||
assertEquals(2, StationLift.selectorFloor(-6,115,-123));
|
||||
assertEquals(0, StationLift.selectorFloor(-5,101,-123));
|
||||
assertEquals(0, StationLift.selectorFloor(-6,101,-122));
|
||||
assertEquals(0, StationLift.selectorFloor(-6,129,-123));
|
||||
}
|
||||
|
||||
@Test void controlRequiresPlayerInsideTheMatchingCabin() {
|
||||
assertTrue(StationLift.inCabin(-5.5,99,-119.5,1));
|
||||
assertTrue(StationLift.inCabin(-5.5,114,-119.5,2));
|
||||
assertFalse(StationLift.inCabin(-5.5,99,-119.5,2));
|
||||
assertFalse(StationLift.inCabin(-5.5,113,-119.5,1));
|
||||
assertFalse(StationLift.inCabin(-5.5,98.9,-119.5,1));
|
||||
assertFalse(StationLift.inCabin(-5.5,99,-117,1));
|
||||
assertFalse(StationLift.inCabin(-8.1,99,-119.5,1));
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SurfaceMapCaptureTest {
|
||||
@Test void roofAndAirGapsDoNotHideGardenOrWater() {
|
||||
for (Material visible : List.of(Material.SPRUCE_LEAVES, Material.WATER, Material.STONE_SLAB)) {
|
||||
List<Integer> reads = new ArrayList<>();
|
||||
var column = SurfaceMapCapture.visibleColumn(-64, 122, y -> {
|
||||
reads.add(y);
|
||||
return switch (y) {
|
||||
case 122, 121 -> Material.BARRIER;
|
||||
case 120 -> Material.AIR;
|
||||
case 119 -> Material.CAVE_AIR;
|
||||
case 118 -> Material.VOID_AIR;
|
||||
case 117 -> visible;
|
||||
default -> throw new AssertionError("Read beneath first visible block");
|
||||
};
|
||||
});
|
||||
assertEquals(117, column.y());
|
||||
assertEquals(visible, column.material());
|
||||
assertEquals(List.of(122, 121, 120, 119, 118, 117), reads);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void emptyColumnHasAirSentinelAndNeverReadsBelowWorld() {
|
||||
List<Integer> reads = new ArrayList<>();
|
||||
var column = SurfaceMapCapture.visibleColumn(-64, -62, y -> {
|
||||
assertTrue(y >= -64);
|
||||
reads.add(y);
|
||||
return y == -63 ? Material.BARRIER : Material.AIR;
|
||||
});
|
||||
assertEquals(new SurfaceMapCapture.Column(-64, Material.AIR), column);
|
||||
assertEquals(List.of(-62, -63, -64), reads);
|
||||
assertEquals(new SurfaceMapCapture.Column(-64, Material.BEDROCK),
|
||||
SurfaceMapCapture.visibleColumn(-64, -65, y -> {
|
||||
assertEquals(-64, y);
|
||||
return Material.BEDROCK;
|
||||
}));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package io.github.minecraftbuilder.terrainworld;
|
||||
|
||||
import com.google.gson.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import javax.imageio.ImageIO;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SurfaceMapTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void exportedGridPreservesCoordinatesWaterAndMarkersIndependentOfCaptureOrder() throws Exception {
|
||||
SurfaceMap map = new SurfaceMap(-17, -1, -16, 0);
|
||||
map.setColumn(-16, 0, 81, "minecraft:magenta_concrete");
|
||||
map.setColumn(-17, -1, 48, "minecraft:water");
|
||||
map.setColumn(-16, -1, 65, "minecraft:stone");
|
||||
map.setColumn(-17, 0, 80, "minecraft:grass_block");
|
||||
Instant start = Instant.parse("2026-09-13T00:00:00Z"), end = start.plusSeconds(2);
|
||||
map.write(directory, "live-01", "lobby", "minecraft:lobby", "world-uuid", start, end);
|
||||
JsonObject json = JsonParser.parseString(Files.readString(directory.resolve("live-01.json"))).getAsJsonObject();
|
||||
assertEquals(-17, json.get("min_x").getAsInt());
|
||||
assertEquals(2, json.get("width").getAsInt());
|
||||
assertEquals(4, json.get("columns").getAsInt());
|
||||
assertEquals(48, json.get("min_surface_y").getAsInt());
|
||||
assertEquals(81, json.get("max_surface_y").getAsInt());
|
||||
assertEquals(start.toString(), json.get("capture_started_at").getAsString());
|
||||
assertEquals(end.toString(), json.get("capture_finished_at").getAsString());
|
||||
assertFalse(json.get("atomic_snapshot").getAsBoolean());
|
||||
assertEquals(List.of("minecraft:barrier"), json.getAsJsonArray("ignored_materials").asList()
|
||||
.stream().map(JsonElement::getAsString).toList());
|
||||
assertTrue(json.get("surface_policy").getAsString().contains("all air variants"));
|
||||
assertEquals(List.of(48, 65, 80, 81), json.getAsJsonArray("surface_y").asList().stream().map(JsonElement::getAsInt).toList());
|
||||
JsonArray palette = json.getAsJsonArray("palette"), indices = json.getAsJsonArray("material_index");
|
||||
assertEquals("minecraft:water", palette.get(indices.get(0).getAsInt()).getAsString());
|
||||
assertEquals("minecraft:magenta_concrete", palette.get(indices.get(3).getAsInt()).getAsString());
|
||||
var image = ImageIO.read(directory.resolve("live-01.png").toFile());
|
||||
assertEquals(2, image.getWidth()); assertEquals(2, image.getHeight());
|
||||
assertEquals(SurfaceMap.color("minecraft:magenta_concrete"), image.getRGB(1, 1) & 0xffffff);
|
||||
}
|
||||
|
||||
@Test void incompleteOrDuplicateColumnsCannotProduceMisleadingMaps() {
|
||||
SurfaceMap map = new SurfaceMap(0, 0, 1, 0);
|
||||
map.setColumn(0, 0, 3, "minecraft:stone");
|
||||
assertThrows(IllegalStateException.class, () -> map.setColumn(0, 0, 4, "minecraft:stone"));
|
||||
assertThrows(IllegalArgumentException.class, () -> map.setColumn(-1, 0, 4, "minecraft:stone"));
|
||||
assertThrows(IllegalStateException.class, map::render);
|
||||
assertThrows(IllegalStateException.class, () -> map.write(directory, "incomplete", "w", "w", "w", Instant.now(), Instant.now()));
|
||||
assertFalse(Files.exists(directory.resolve("incomplete.json")));
|
||||
}
|
||||
|
||||
@Test void filenamesCannotTraverseOrOverwriteExistingArtifacts() throws Exception {
|
||||
for (String name : List.of("../bad", "/tmp/map", "UPPER", "", "a.b", "a".repeat(49)))
|
||||
assertThrows(IllegalArgumentException.class, () -> SurfaceMap.validateName(name));
|
||||
SurfaceMap map = new SurfaceMap(0, 0, 0, 0);
|
||||
map.setColumn(0, 0, 1, "minecraft:yellow_concrete");
|
||||
Files.writeString(directory.resolve("existing.png"), "keep");
|
||||
assertThrows(FileAlreadyExistsException.class, () -> map.write(directory, "existing", "w", "w", "w", Instant.now(), Instant.now()));
|
||||
assertEquals("keep", Files.readString(directory.resolve("existing.png")));
|
||||
assertFalse(Files.exists(directory.resolve("existing.json")));
|
||||
}
|
||||
|
||||
@Test void limitsRejectOverflowAndEveryConcreteColorHasItsOwnPigment() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new SurfaceMap(Integer.MIN_VALUE, 0, Integer.MAX_VALUE, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SurfaceMap(0, 0, 4096, 4096));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SurfaceMap(1, 0, 0, 0));
|
||||
Set<Integer> colors = new HashSet<>();
|
||||
for (String color : List.of("white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", "gray",
|
||||
"light_gray", "cyan", "purple", "blue", "brown", "green", "red", "black"))
|
||||
colors.add(SurfaceMap.color("minecraft:" + color + "_concrete"));
|
||||
assertEquals(16, colors.size());
|
||||
assertNotEquals(SurfaceMap.color("minecraft:water"), SurfaceMap.color("minecraft:grass_block"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user