diff --git a/internal/world/feature_scheduler.go b/internal/world/feature_scheduler.go index e602a77..a1b1574 100644 --- a/internal/world/feature_scheduler.go +++ b/internal/world/feature_scheduler.go @@ -36,6 +36,12 @@ func (r *decorationRegion) replayScheduledOres(seed int64, targetX, targetZ int3 if err := r.placeScheduledGeodes(seed); err != nil { return fmt.Errorf("world: replay source geodes (%d,%d): %w", source.X, source.Z, err) } + // Stage 3 runs before the ores on purpose: the rooms' cave_air pockets + // are what vanilla's ore ellipsoids roll their air-exposure discards + // against. + if err := r.placeScheduledMonsterRooms(seed); err != nil { + return fmt.Errorf("world: replay source monster rooms (%d,%d): %w", source.X, source.Z, err) + } if err := r.placeScheduledUndergroundOresStage(seed); err != nil { return fmt.Errorf("world: replay source underground ores (%d,%d): %w", source.X, source.Z, err) } diff --git a/internal/world/monster_rooms.go b/internal/world/monster_rooms.go new file mode 100644 index 0000000..863582b --- /dev/null +++ b/internal/world/monster_rooms.go @@ -0,0 +1,297 @@ +package world + +import ( + "sync" + + "regionio/internal/worldgen" +) + +// monster_rooms.go ports net.minecraft.world.level.levelgen.feature. +// MonsterRoomFeature, the stage-3 underground-structures feature behind the +// monster_room and monster_room_deep placed features. Both share one configured +// feature with an empty config; only their placement modifiers differ, and the +// generic placement driver already handles those. +// +// The rooms matter far beyond their own blocks: they open cave_air pockets +// during the stage that runs before the ores. Ore ellipsoids crossing those +// pockets roll air-exposure discards, so replaying monster rooms in the right +// order is what lets the ore schedule see the world vanilla's ores saw. +// +// The port follows bytecode fidelity like the carvers do: every random draw, +// loop order, and early return is vanilla's. Block entities are not modelled +// yet — chests place as plain block states and the spawner's mob pick still +// consumes its draw so later placement positions stay on vanilla's stream. + +const undergroundStructuresStage = 3 + +var ( + monsterRoomOnce sync.Once + monsterRoomCannotTable []bool + monsterRoomStatesOnce sync.Once + + monsterCaveAirID uint16 + monsterChestIDs map[string]uint16 + monsterSpawnerID uint16 + monsterCobbleID uint16 + monsterMossyID uint16 + monsterAirIDs map[uint16]bool + monsterCannotTable []bool +) + +func initMonsterRoomTables() { + monsterRoomOnce.Do(func() { + names, err := worldgen.FeaturesCannotReplace() + if err != nil { + panic(err) + } + stateByIDOnce.Do(buildStateTable) + monsterCannotTable = make([]bool, totalBlockStates) + for _, name := range names { + for _, id := range idsByName[name] { + if int(id) < len(monsterCannotTable) { + monsterCannotTable[id] = true + } + } + } + }) + monsterRoomStatesOnce.Do(func() { + stateByIDOnce.Do(buildStateTable) + monsterCaveAirID = monsterMustState("minecraft:cave_air", nil) + monsterSpawnerID = monsterMustState("minecraft:spawner", nil) + monsterCobbleID = monsterMustState("minecraft:cobblestone", nil) + monsterMossyID = monsterMustState("minecraft:mossy_cobblestone", nil) + monsterChestIDs = map[string]uint16{} + for _, facing := range []string{"north", "south", "west", "east"} { + monsterChestIDs[facing] = monsterMustState("minecraft:chest", map[string]string{ + "facing": facing, "type": "single", "waterlogged": "false", + }) + } + monsterAirIDs = map[uint16]bool{ + mustMonsterAir("minecraft:air"): true, + mustMonsterAir("minecraft:cave_air"): true, + } + }) +} + +func monsterMustState(name string, props map[string]string) uint16 { + id, ok := nameToStateID(name, props) + if !ok { + panic("world: missing block state for monster rooms: " + name) + } + return id +} + +func mustMonsterAir(name string) uint16 { + return monsterMustState(name, nil) +} + +// monsterSafeSetBlock is Feature.safeSetBlock: replace unless the existing +// state is in #minecraft:features_cannot_replace. +func (r *decorationRegion) monsterSafeSetBlock(x, y, z int, state uint16) bool { + if monsterCannotTable[r.getBlock(x, y, z)] { + return false + } + return r.setBlock(x, y, z, state) +} + +// placeScheduledMonsterRooms replays the vanilla stage-3 schedule from one +// source center into the mutable decoration region. +func (r *decorationRegion) placeScheduledMonsterRooms(seed int64) error { + set, err := worldgen.LoadFeatureSet() + if err != nil { + return err + } + initMonsterRoomTables() + if err := r.ensureSourceNeighborhood(); err != nil { + return err + } + schedule, err := set.FeatureSchedule(possibleBiomeOrder(), r.sourceBiomes(), undergroundStructuresStage) + if err != nil { + return err + } + random, decorationSeed := worldgen.DecorationRandom(seed, int(r.sourceX), int(r.sourceZ)) + origin := worldgen.FeaturePosition{X: int(r.sourceX) << 4, Y: MinY, Z: int(r.sourceZ) << 4} + for _, scheduled := range schedule { + placed, ok := set.Placed[scheduled.Name] + if !ok { + continue + } + configured, ok := set.Configured[placed.Feature] + if !ok || configured.Type != "minecraft:monster_room" { + continue + } + random.SetFeatureSeed(decorationSeed, scheduled.Index, undergroundStructuresStage) + context := r.placementContext(func(position worldgen.FeaturePosition) bool { + return r.biomeAllowsFeature(set, scheduled.Name, undergroundStructuresStage, position) + }) + if err := set.ForEachPlacementPosition(scheduled.Name, random, origin, context, func(position worldgen.FeaturePosition) error { + placeMonsterRoom(r, random, position.X, position.Y, position.Z) + return nil + }); err != nil { + return err + } + } + return nil +} + +// placeMonsterRoom ports MonsterRoomFeature.place for one origin. +func placeMonsterRoom(r *decorationRegion, random worldgen.RandomSource, ox, oy, oz int) bool { + initMonsterRoomTables() + + j := int(random.NextIntN(2)) + 2 // x half-extent minus walls: 2..3 + o := int(random.NextIntN(2)) + 2 // z half-extent minus walls: 2..3 + k, l := -j-1, j+1 // full x span, walls included + p, q := -o-1, o+1 // full z span + + // Pass 1 validates the shell: solid floor at y=-1 and ceiling at y=4 for + // every column, and between one and five open side openings at y=0. + openings := 0 + for x := k; x <= l; x++ { + for y := -1; y <= 4; y++ { + for z := p; z <= q; z++ { + solid := monsterIsSolid(r.getBlock(ox+x, oy+y, oz+z)) + if y == -1 && !solid { + return false + } + if y == 4 && !solid { + return false + } + if (x == k || x == l || z == p || z == q) && y == 0 { + if monsterIsAir(r.getBlock(ox+x, oy, oz+z)) && + monsterIsAir(r.getBlock(ox+x, oy+1, oz+z)) { + openings++ + } + } + } + } + } + if openings < 1 || openings > 5 { + return false + } + + // Pass 2 carves: interior becomes cave air; walls become cobblestone, with + // a mossy three-in-four roll on the floor row; a wall block whose own floor + // was already carved becomes cave air outright, bypassing the replaceable + // guard just as vanilla's unconditional setBlock does. The mossy roll only + // draws for a solid non-chest cell that reaches the wall branch — the same + // conditions under which vanilla reaches its nextInt(4). + for x := k; x <= l; x++ { + for y := 3; y >= -1; y-- { + for z := p; z <= q; z++ { + bx, by, bz := ox+x, oy+y, oz+z + current := r.getBlock(bx, by, bz) + interior := x != k && y != -1 && z != p && x != l && y != 4 && z != q + if interior { + if current == monsterChestIDs["north"] || + current == monsterChestIDs["south"] || + current == monsterChestIDs["west"] || + current == monsterChestIDs["east"] || + current == monsterSpawnerID { + continue + } + r.monsterSafeSetBlock(bx, by, bz, monsterCaveAirID) + continue + } + // The mossy roll draws only here, exactly where vanilla's + // floor branch sits; every other wall row goes cobblestone + // without a draw. + placeWall := func() { + state := monsterCobbleID + if y == -1 && random.NextIntN(4) != 0 { + state = monsterMossyID + } + r.monsterSafeSetBlock(bx, by, bz, state) + } + if by < MinY { + if !monsterIsSolid(current) || isMonsterChest(current) { + continue + } + placeWall() + continue + } + if !monsterIsSolid(r.getBlock(bx, by-1, bz)) { + r.setBlock(bx, by, bz, monsterCaveAirID) + continue + } + if !monsterIsSolid(current) || isMonsterChest(current) { + continue + } + placeWall() + } + } + } + + // Pass 3 tries two chest spots, three times each: an air cell with exactly + // one opaque horizontal neighbour, faced away from it. An adjacent chest + // leaves the new one at its default facing instead. + for attempt := 0; attempt < 2; attempt++ { + for try := 0; try < 3; try++ { + cx := ox + int(random.NextIntN(int32(j*2+1))) - j + cz := oz + int(random.NextIntN(int32(o*2+1))) - o + cy := oy + if !monsterIsAir(r.getBlock(cx, cy, cz)) { + continue + } + solids := 0 + var solidDir [2]int + adjacentChest := false + for _, d := range [4][2]int{{0, -1}, {1, 0}, {0, 1}, {-1, 0}} { + neighbor := r.getBlock(cx+d[0], cy, cz+d[1]) + if isMonsterChest(neighbor) { + adjacentChest = true + continue + } + if monsterIsSolidRender(neighbor) { + solids++ + solidDir = d + } + } + if solids != 1 { + continue + } + facing := "north" + if !adjacentChest { + switch { + case solidDir[0] == 1: + facing = "west" + case solidDir[0] == -1: + facing = "east" + case solidDir[1] == 1: + facing = "north" + default: + facing = "south" + } + } + if r.monsterSafeSetBlock(cx, cy, cz, monsterChestIDs[facing]) { + // Vanilla seeds the chest's loot table from one long here. + random.NextLong() + } + } + } + + // Pass 4 places the spawner at the center; picking its mob is one draw. + if r.monsterSafeSetBlock(ox, oy, oz, monsterSpawnerID) { + random.NextIntN(4) // skeleton / zombie / zombie / spider + } + return true +} + +func isMonsterChest(state uint16) bool { + return state == monsterChestIDs["north"] || state == monsterChestIDs["south"] || + state == monsterChestIDs["west"] || state == monsterChestIDs["east"] +} + +// monsterIsSolid approximates BlockState.isSolid, which in this version reads +// the state's blocks-motion property. +func monsterIsSolid(state uint16) bool { + return stateFlags(state)&flagBlocksMotion != 0 +} + +// monsterIsSolidRender approximates BlockState.isSolidRender: an occluding, +// fully opaque cube. +func monsterIsSolidRender(state uint16) bool { + f := stateFlags(state) + return f&flagCanOcclude != 0 && lightOpacity(state) == 15 +} + +func monsterIsAir(state uint16) bool { return monsterAirIDs[state] } diff --git a/internal/world/monster_rooms_test.go b/internal/world/monster_rooms_test.go new file mode 100644 index 0000000..5ca5c0b --- /dev/null +++ b/internal/world/monster_rooms_test.go @@ -0,0 +1,128 @@ +package world + +import ( + "testing" + + "regionio/internal/worldgen" +) + +// buildSolidRegion returns a one-chunk region filled with stone below y=20 so +// room validation has a predictable shell to accept or reject. +func buildSolidRegion(t *testing.T) (*decorationRegion, []*Chunk) { + t.Helper() + chunk := NewChunk(0, 0, BiomePlains) + for y := MinY; y < 24; y++ { + for z := 0; z < 16; z++ { + for x := 0; x < 16; x++ { + chunk.SetBlock(x, y, z, StateStone) + } + } + } + region, err := newDecorationRegion([]*Chunk{chunk}) + if err != nil { + t.Fatal(err) + } + if err := region.setSource(0, 0); err != nil { + t.Fatal(err) + } + return region, []*Chunk{chunk} +} + +func TestMonsterRoomCarvesShell(t *testing.T) { + initMonsterRoomTables() + region, _ := buildSolidRegion(t) + + // A fully sealed shell has no side openings at y=0, so vanilla rejects the + // placement before any write. + random := worldgen.NewWorldgenRandom(1) + if placeMonsterRoom(region, random, 8, 10, 8) { + t.Fatal("room placed in a sealed shell") + } + + // Open one column on the +x wall: two adjacent air cells at y=0 and y=1. + region.setBlock(8 + 4, 10, 8, StateAir) + region.setBlock(8 + 4, 11, 8, StateAir) + if !placeMonsterRoom(region, random, 8, 10, 8) { + t.Fatal("room rejected despite an open side column") + } + + // The spawner lands at the origin; neighbouring interior cells are air. + if got := region.getBlock(8, 10, 8); got != monsterSpawnerID { + t.Fatalf("origin state = %s, want the spawner", stateLabel(got)) + } + if got := region.getBlock(6, 10, 6); !monsterIsAir(got) { + t.Fatalf("interior state = %s, want air", stateLabel(got)) + } + // The floor row is cobblestone or mossy cobblestone. + floor := region.getBlock(7, 9, 8) + if floor != monsterCobbleID && floor != monsterMossyID { + t.Fatalf("floor state = %s, want cobblestone family", stateLabel(floor)) + } + // The untouched shell above stays stone. + if got := region.getBlock(8, 15, 8); got != StateStone { + t.Fatalf("ceiling state = %s, want stone", stateLabel(got)) + } +} + +func TestMonsterRoomPlacementIsDeterministic(t *testing.T) { + initMonsterRoomTables() + build := func() *Chunk { + region, _ := buildSolidRegion(t) + region.setBlock(12, 10, 8, StateAir) + region.setBlock(12, 11, 8, StateAir) + random := worldgen.NewWorldgenRandom(42) + placeMonsterRoom(region, random, 8, 10, 8) + return region.chunks[[2]int32{0, 0}] + } + a, b := build(), build() + for y := MinY; y < MinY+WorldHeight; y++ { + for z := 0; z < 16; z++ { + for x := 0; x < 16; x++ { + if a.GetBlock(x, y, z) != b.GetBlock(x, y, z) { + t.Fatalf("nondeterministic block at (%d,%d,%d)", x, y, z) + } + } + } + } +} + +func TestMonsterRoomReplayMatchesDirectPass(t *testing.T) { + od, err := worldgen.LoadOverworldFinalDensity(12345) + if err != nil { + t.Fatal(err) + } + fluidPicker := worldgen.OverworldFluidPicker(od.SeaLevel) + veins := worldgen.NewOreVeinifier(od) + carver, err := worldgen.NewCarver(od, 12345) + if err != nil { + t.Fatal(err) + } + initCarverReplaceable(carver.ReplaceableBlocks()) + generate := func() *Chunk { + var chunks []*Chunk + for cx := int32(-2); cx <= 2; cx++ { + for cz := int32(-2); cz <= 2; cz++ { + chunks = append(chunks, generateVanillaWithoutDecoration(od, fluidPicker, veins, carver, 12345, cx, cz)) + } + } + region, err := newDecorationRegion(chunks) + if err != nil { + t.Fatal(err) + } + if err := region.replayScheduledOres(12345, 0, 0); err != nil { + t.Fatal(err) + } + return region.chunks[[2]int32{0, 0}] + } + a, b := generate(), generate() + for y := MinY; y < MinY+WorldHeight; y++ { + for z := 0; z < 16; z++ { + for x := 0; x < 16; x++ { + if a.GetBlock(x, y, z) != b.GetBlock(x, y, z) { + t.Fatalf("region replay nondeterministic at (%d,%d,%d)", x, y, z) + } + } + } + } +} + diff --git a/internal/world/store.go b/internal/world/store.go index a6b7382..e21c56c 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -33,7 +33,7 @@ const dataVersion26 = 4790 // first time it ran: chunkAt prefers the store over the generator, so the // already-explored area around spawn keeps its old terrain and every later fix // looks like it did nothing in exactly the place you are standing. -const generatorVersion = 21 +const generatorVersion = 22 // generatorVersionTag is the NBT key holding generatorVersion. It is namespaced // because it is ours, not part of the vanilla chunk format. diff --git a/internal/worldgen/data/tag/features_cannot_replace.json b/internal/worldgen/data/tag/features_cannot_replace.json new file mode 100644 index 0000000..f7cb8eb --- /dev/null +++ b/internal/worldgen/data/tag/features_cannot_replace.json @@ -0,0 +1,11 @@ +{ + "values": [ + "minecraft:bedrock", + "minecraft:spawner", + "minecraft:chest", + "minecraft:end_portal_frame", + "minecraft:reinforced_deepslate", + "minecraft:trial_spawner", + "minecraft:vault" + ] +} diff --git a/internal/worldgen/features_cannot_replace.go b/internal/worldgen/features_cannot_replace.go new file mode 100644 index 0000000..17eb2f0 --- /dev/null +++ b/internal/worldgen/features_cannot_replace.go @@ -0,0 +1,26 @@ +package worldgen + +import ( + _ "embed" + "encoding/json" +) + +// features_cannot_replace.json is #minecraft:features_cannot_replace, captured +// verbatim from the 26.1.2 server jar. Features guard their writes through +// Feature.safeSetBlock with this tag: an existing state in the tag stays, an +// existing state outside it may be replaced. +// +//go:embed data/tag/features_cannot_replace.json +var featuresCannotReplaceJSON []byte + +// FeaturesCannotReplace returns the block names in +// #minecraft:features_cannot_replace. +func FeaturesCannotReplace() ([]string, error) { + var doc struct { + Values []string `json:"values"` + } + if err := json.Unmarshal(featuresCannotReplaceJSON, &doc); err != nil { + return nil, err + } + return doc.Values, nil +}