diff --git a/docs/roadmap/beta4.md b/docs/roadmap/beta4.md index 16bfe066..dd13f1a8 100644 --- a/docs/roadmap/beta4.md +++ b/docs/roadmap/beta4.md @@ -436,6 +436,23 @@ gate, not an unanswered design decision. The spike is not the Stage 6 implementa item; see the owner guide. - Stage 1 remaining: the B2 persistence spike PR and the formal B4 performance procedure (fixed controls, repetitions, JFR, versioned thresholds). Stage 2 follows. +- 2026-10-25: Opened the B2 persistence spike PR (`spike/b2-persistence`). The + codec lives in the gametest source set only (`gametest/persistence`), so the + shipped jar and Block Entities are unchanged apart from two read-only + `ModBlockMap` lookups. Findings to carry into Stage 6: + - `NbtHelper.toBlockState` silently returns air for unknown IDs and only logs bad + property values, so the codec validates the ID, `BlockTriple` family, and each + property before calling it; `Identifier.tryParse` avoids the exception on bad IDs. + - The schema is additive: legacy `positive_slab.id`/`negative_slab.id`/`axis` stay + and the current Block Entity reads the new format unchanged; a future + `version` falls back to those legacy fields. + - Besides `TYPE`/`FACING`/`SNOWY`/`DISTANCE`, the filter also drops + `WATERLOGGED` and `is_double` as container-owned. No current Slabee half owns a + property, so ownership was proven with vanilla leaves (`PERSISTENT`) through a + test-only catalog. + - Legacy reads now lose an invalid ID instead of substituting the default half; + the `dirt_path_slab` → `dirt_slab` rule stays outside the codec. + - Stage 1 then has only the B4 baseline PR left once the spike review passes. - Dev-environment note: Sodium cannot load in the yarn dev runtime (`getColorIndex` name clash in `BakedQuad`). Check it in a production-mapped client instead (Loom 1.10+ `ClientProductionRunTask`). diff --git a/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/PersistenceSpikeGameTests.java b/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/PersistenceSpikeGameTests.java new file mode 100644 index 00000000..20da92c0 --- /dev/null +++ b/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/PersistenceSpikeGameTests.java @@ -0,0 +1,456 @@ +package com.forestotzka.yurufu.slabee.gametest; + +import com.forestotzka.yurufu.slabee.block.AbstractDoubleSlabBlockEntity; +import com.forestotzka.yurufu.slabee.block.DoubleVerticalSlabBlock; +import com.forestotzka.yurufu.slabee.block.ModBlocks; +import com.forestotzka.yurufu.slabee.block.VerticalSlabBlock; +import com.forestotzka.yurufu.slabee.block.enums.VerticalSlabAxis; +import com.forestotzka.yurufu.slabee.gametest.persistence.ComponentStateCodec; +import com.forestotzka.yurufu.slabee.gametest.persistence.ComponentStateCodec.Catalog; +import com.forestotzka.yurufu.slabee.gametest.persistence.ComponentStateCodec.Decoded; +import com.forestotzka.yurufu.slabee.gametest.persistence.ComponentStateCodec.Family; +import com.forestotzka.yurufu.slabee.gametest.persistence.ComponentStateCodec.Issue; +import com.forestotzka.yurufu.slabee.gametest.persistence.ComponentStateCodec.Outcome; +import com.forestotzka.yurufu.slabee.gametest.persistence.ComponentStateCodec.Source; +import com.forestotzka.yurufu.slabee.gametest.persistence.Diagnostics; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import net.fabricmc.fabric.api.gametest.v1.FabricGameTest; +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.block.LeavesBlock; +import net.minecraft.block.SlabBlock; +import net.minecraft.block.enums.SlabType; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.StringNbtReader; +import net.minecraft.registry.RegistryEntryLookup; +import net.minecraft.registry.RegistryKeys; +import net.minecraft.state.property.Properties; +import net.minecraft.test.GameTest; +import net.minecraft.test.GameTestException; +import net.minecraft.test.TestContext; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.Vec3d; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +import static com.forestotzka.yurufu.slabee.gametest.SlabeeTestUtil.*; + +/** + * B2 永続化 spike のテスト。保存形式の変換だけを確かめ、ゲームの動きは変えない。 + * 実際の Block Entity とのやりとりは「今の Slabee が新形式を読める」「今の保存データを新しい読み手が読める」の2点だけ。 + */ +public class PersistenceSpikeGameTests implements FabricGameTest { + private static final BlockPos POS = new BlockPos(1, 1, 1); + + /** 葉を「横のハーフ」として扱う、テスト用の対応表。まだ Slabee に property を持つハーフがないので、所有権の確認に使う */ + private static final Catalog WITH_LEAVES = (family, block) -> + block instanceof LeavesBlock || Catalog.SLABEE.accepts(family, block); + + // ---- 新形式の往復 ---- + + @GameTest(templateName = EMPTY_STRUCTURE) + public void roundTripHorizontal(TestContext context) { + NbtCompound nbt = new NbtCompound(); + ComponentStateCodec.write(nbt, Family.HORIZONTAL, top(Blocks.OAK_SLAB), bottom(ModBlocks.GLASS_SLAB), null); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectSource(context, decoded, Source.V1); + expectOutcome(context, decoded, Outcome.DOUBLE); + expectNoProblems(context, decoded); + expectHalf(context, decoded.positive(), top(Blocks.OAK_SLAB)); + expectHalf(context, decoded.negative(), bottom(ModBlocks.GLASS_SLAB)); + context.complete(); + } + + @GameTest(templateName = EMPTY_STRUCTURE) + public void roundTripVerticalBothAxes(TestContext context) { + for (VerticalSlabAxis axis : VerticalSlabAxis.values()) { + BlockState positive = vertical(ModBlocks.STONE_VERTICAL_SLAB, axis == VerticalSlabAxis.X ? Direction.EAST : Direction.SOUTH); + BlockState negative = vertical(ModBlocks.OAK_VERTICAL_SLAB, axis == VerticalSlabAxis.X ? Direction.WEST : Direction.NORTH); + NbtCompound nbt = new NbtCompound(); + ComponentStateCodec.write(nbt, Family.VERTICAL, positive, negative, axis); + + Decoded decoded = read(context, nbt, Family.VERTICAL, axis); + expectSource(context, decoded, Source.V1); + expectNoProblems(context, decoded); + context.assertTrue(decoded.axis() == axis, "軸が違う: 期待=" + axis + " 実際=" + decoded.axis()); + expectHalf(context, decoded.positive(), positive); + expectHalf(context, decoded.negative(), negative); + } + context.complete(); + } + + /** 軸は容器に1回だけ保存し、ハーフの中には FACING を書かない */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void verticalAxisIsStoredOnceOnContainer(TestContext context) { + NbtCompound nbt = new NbtCompound(); + ComponentStateCodec.write(nbt, Family.VERTICAL, + vertical(ModBlocks.STONE_VERTICAL_SLAB, Direction.SOUTH), vertical(ModBlocks.OAK_VERTICAL_SLAB, Direction.NORTH), VerticalSlabAxis.Z); + + context.assertTrue("Z".equals(nbt.getString(ComponentStateCodec.AXIS_KEY)), "容器の axis が Z でない: " + nbt); + NbtCompound components = nbt.getCompound(ComponentStateCodec.COMPONENTS_KEY); + for (String side : List.of(ComponentStateCodec.POSITIVE_KEY, ComponentStateCodec.NEGATIVE_KEY)) { + NbtCompound half = components.getCompound(side); + context.assertFalse(half.getCompound("Properties").contains("facing"), "ハーフに facing が保存された: " + half); + context.assertFalse(half.getCompound("Properties").contains("is_double"), "ハーフに is_double が保存された: " + half); + context.assertFalse(half.getCompound("Properties").contains("waterlogged"), "ハーフに waterlogged が保存された: " + half); + } + context.complete(); + } + + // ---- property の所有権 ---- + + /** 雪が積もった草のハーフ:SNOWY は周りから決まるので保存せず、読んだ後は default(あとで再計算する) */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void environmentDerivedPropertyIsNotSaved(TestContext context) { + BlockState snowyGrass = top(ModBlocks.GRASS_SLAB).with(Properties.SNOWY, true); + NbtCompound nbt = new NbtCompound(); + ComponentStateCodec.write(nbt, Family.HORIZONTAL, snowyGrass, bottom(Blocks.STONE_SLAB), null); + + NbtCompound positive = nbt.getCompound(ComponentStateCodec.COMPONENTS_KEY).getCompound(ComponentStateCodec.POSITIVE_KEY); + context.assertFalse(positive.contains("Properties"), "保存しない property しかないのに Properties が書かれた: " + positive); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectHalf(context, decoded.positive(), top(ModBlocks.GRASS_SLAB)); + context.complete(); + } + + /** ハーフ自身の property(葉の PERSISTENT)は保存し、周りから決まる DISTANCE と容器の WATERLOGGED は保存しない */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void componentOwnedPropertyRoundTrips(TestContext context) { + BlockState leaves = Blocks.OAK_LEAVES.getDefaultState() + .with(LeavesBlock.PERSISTENT, true) + .with(LeavesBlock.DISTANCE, 3) + .with(LeavesBlock.WATERLOGGED, true); + NbtCompound nbt = new NbtCompound(); + ComponentStateCodec.write(nbt, Family.HORIZONTAL, leaves, bottom(Blocks.STONE_SLAB), null); + + NbtCompound properties = nbt.getCompound(ComponentStateCodec.COMPONENTS_KEY) + .getCompound(ComponentStateCodec.POSITIVE_KEY).getCompound("Properties"); + context.assertTrue(properties.getKeys().equals(java.util.Set.of("persistent")), "保存された property が違う: " + properties); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null, WITH_LEAVES, Diagnostics.silent()); + expectNoProblems(context, decoded); + expectHalf(context, decoded.positive(), Blocks.OAK_LEAVES.getDefaultState().with(LeavesBlock.PERSISTENT, true)); + context.complete(); + } + + /** 保存データに書かれていても、TYPE はスロットから決める(下向きと書かれた上側のハーフは上向きになる) */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void slotDerivedPropertyInDataIsIgnored(TestContext context) { + NbtCompound nbt = parse(""" + {slabee_components: {version: 1, + positive: {Name: "minecraft:oak_slab", Properties: {type: "bottom", waterlogged: "true"}}, + negative: {Name: "minecraft:stone_slab", Properties: {type: "top"}}}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectNoProblems(context, decoded); + expectHalf(context, decoded.positive(), top(Blocks.OAK_SLAB)); + expectHalf(context, decoded.negative(), bottom(Blocks.STONE_SLAB)); + context.complete(); + } + + /** property だけが壊れていれば、その property だけ default にしてハーフは残す */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void invalidPropertyUsesDefault(TestContext context) { + NbtCompound nbt = parse(""" + {slabee_components: {version: 1, + positive: {Name: "minecraft:oak_leaves", Properties: {persistent: "maybe", no_such_property: "1"}}, + negative: {Name: "minecraft:birch_leaves", Properties: {persistent: 1b}}}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null, WITH_LEAVES, Diagnostics.silent()); + expectOutcome(context, decoded, Outcome.DOUBLE); + expectHalf(context, decoded.positive(), Blocks.OAK_LEAVES.getDefaultState()); + expectHalf(context, decoded.negative(), Blocks.BIRCH_LEAVES.getDefaultState()); + expectProblem(context, decoded, Issue.INVALID_PROPERTY_VALUE); + expectProblem(context, decoded, Issue.UNKNOWN_PROPERTY); + context.complete(); + } + + // ---- 壊れた・消えた・対応外のブロック ---- + + /** 消えたブロック(削除された mod など)の側だけを失い、残った側は単体のハーフになる */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void unknownIdLosesOnlyThatHalf(TestContext context) { + NbtCompound nbt = parse(""" + {slabee_components: {version: 1, + positive: {Name: "removedmod:marble_slab"}, + negative: {Name: "minecraft:stone_slab"}}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectOutcome(context, decoded, Outcome.SINGLE_NEGATIVE); + expectProblem(context, decoded, Issue.UNKNOWN_ID); + expectHalf(context, decoded.singleizedState(), bottom(Blocks.STONE_SLAB)); + context.complete(); + } + + /** 縦で片側を失うと、残った側はそのスロットの向きの単体の縦ハーフになる */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void verticalSingleizationKeepsSlotFacing(TestContext context) { + NbtCompound nbt = parse(""" + {axis: "X", slabee_components: {version: 1, + positive: {Name: "slabee:stone_vertical_slab"}, + negative: {Name: "removedmod:marble_vertical_slab"}}}"""); + + Decoded decoded = read(context, nbt, Family.VERTICAL, VerticalSlabAxis.X); + expectOutcome(context, decoded, Outcome.SINGLE_POSITIVE); + expectHalf(context, decoded.singleizedState(), vertical(ModBlocks.STONE_VERTICAL_SLAB, Direction.EAST)); + context.complete(); + } + + /** id として読めない文字列でも例外にせず、その側を失う */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void malformedIdLosesHalf(TestContext context) { + NbtCompound nbt = parse(""" + {slabee_components: {version: 1, + positive: {Name: "Not A Valid Id!"}, + negative: {Name: "minecraft:stone_slab"}}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectOutcome(context, decoded, Outcome.SINGLE_NEGATIVE); + expectProblem(context, decoded, Issue.MALFORMED_ID); + context.complete(); + } + + /** ハーフでないブロックや、形の違うハーフ(横に縦のハーフ)は対応外として失う */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void unsupportedBlockLosesHalf(TestContext context) { + NbtCompound nbt = parse(""" + {slabee_components: {version: 1, + positive: {Name: "minecraft:stone"}, + negative: {Name: "slabee:stone_vertical_slab"}}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectOutcome(context, decoded, Outcome.AIR); + expectProblem(context, decoded, Issue.UNSUPPORTED_BLOCK); + context.complete(); + } + + /** 両側とも復元できなければ air */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void bothHalvesLostBecomesAir(TestContext context) { + NbtCompound nbt = parse(""" + {slabee_components: {version: 1, positive: "not a compound", negative: {Properties: {}}}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectOutcome(context, decoded, Outcome.AIR); + expectProblem(context, decoded, Issue.MISSING_COMPONENT); + expectHalf(context, decoded.singleizedState(), Blocks.AIR.getDefaultState()); + context.complete(); + } + + // ---- 旧形式 ---- + + /** 今の Slabee が書く形式(横:id と facing) */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void legacyHorizontalReads(TestContext context) { + NbtCompound nbt = parse(""" + {positive_slab: {id: "minecraft:oak_slab", facing: "south"}, + negative_slab: {id: "slabee:glass_slab", facing: "south"}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectSource(context, decoded, Source.LEGACY); + expectNoProblems(context, decoded); + expectHalf(context, decoded.positive(), top(Blocks.OAK_SLAB)); + expectHalf(context, decoded.negative(), bottom(ModBlocks.GLASS_SLAB)); + context.complete(); + } + + /** 旧形式の縦:NBT の axis を移行の入力にする(外側 BlockState と違っても NBT を使う) */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void legacyVerticalUsesNbtAxis(TestContext context) { + NbtCompound nbt = parse(""" + {axis: "Z", positive_slab: {id: "slabee:oak_vertical_slab"}, negative_slab: {id: "slabee:stone_vertical_slab"}}"""); + + Decoded decoded = read(context, nbt, Family.VERTICAL, VerticalSlabAxis.X); + expectSource(context, decoded, Source.LEGACY); + expectNoProblems(context, decoded); + context.assertTrue(decoded.axis() == VerticalSlabAxis.Z, "旧形式の axis が使われていない: " + decoded.axis()); + expectHalf(context, decoded.positive(), vertical(ModBlocks.OAK_VERTICAL_SLAB, Direction.SOUTH)); + expectHalf(context, decoded.negative(), vertical(ModBlocks.STONE_VERTICAL_SLAB, Direction.NORTH)); + context.complete(); + } + + /** 旧形式の axis が壊れていれば外側 BlockState、それもなければ X */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void legacyInvalidAxisFallsBack(TestContext context) { + NbtCompound nbt = parse(""" + {axis: "Y", positive_slab: {id: "slabee:oak_vertical_slab"}, negative_slab: {id: "slabee:stone_vertical_slab"}}"""); + + Decoded withState = read(context, nbt, Family.VERTICAL, VerticalSlabAxis.Z); + context.assertTrue(withState.axis() == VerticalSlabAxis.Z, "BlockState の軸が使われていない: " + withState.axis()); + expectProblem(context, withState, Issue.INVALID_AXIS); + + Decoded withoutState = read(context, nbt, Family.VERTICAL, null); + context.assertTrue(withoutState.axis() == VerticalSlabAxis.X, "既定の X になっていない: " + withoutState.axis()); + context.complete(); + } + + /** 旧形式でも、消えた id は今のように既定のハーフに置き換えず、その側を失う */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void legacyUnknownIdLosesHalf(TestContext context) { + NbtCompound nbt = parse(""" + {positive_slab: {id: "removedmod:marble_slab"}, negative_slab: {id: "minecraft:stone_slab"}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectOutcome(context, decoded, Outcome.SINGLE_NEGATIVE); + expectProblem(context, decoded, Issue.UNKNOWN_ID); + context.complete(); + } + + /** 新形式で軸が食い違えば、外側 BlockState を正として診断する */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void v1AxisConflictKeepsBlockState(TestContext context) { + NbtCompound nbt = new NbtCompound(); + ComponentStateCodec.write(nbt, Family.VERTICAL, + vertical(ModBlocks.STONE_VERTICAL_SLAB, Direction.EAST), vertical(ModBlocks.OAK_VERTICAL_SLAB, Direction.WEST), VerticalSlabAxis.X); + + Decoded decoded = read(context, nbt, Family.VERTICAL, VerticalSlabAxis.Z); + context.assertTrue(decoded.axis() == VerticalSlabAxis.Z, "BlockState の軸が使われていない: " + decoded.axis()); + expectProblem(context, decoded, Issue.AXIS_CONFLICT); + expectHalf(context, decoded.positive(), vertical(ModBlocks.STONE_VERTICAL_SLAB, Direction.SOUTH)); + context.complete(); + } + + /** 新しい版で保存されたデータは、一緒に書かれている旧形式で読む */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void futureSchemaFallsBackToLegacy(TestContext context) { + NbtCompound nbt = parse(""" + {positive_slab: {id: "minecraft:oak_slab"}, negative_slab: {id: "minecraft:stone_slab"}, + slabee_components: {version: 2, positive: {Name: "minecraft:oak_slab", NewField: 1}}}"""); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectSource(context, decoded, Source.LEGACY); + expectOutcome(context, decoded, Outcome.DOUBLE); + expectProblem(context, decoded, Issue.FUTURE_SCHEMA); + context.complete(); + } + + // ---- 今の Block Entity とのやりとり ---- + + /** 今の Slabee が保存したデータを、新しい読み手で読める */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void readsCurrentHorizontalBlockEntityNbt(TestContext context) { + placeDouble(context, POS, Blocks.OAK_SLAB, ModBlocks.GLASS_SLAB); + AbstractDoubleSlabBlockEntity entity = expectDouble(context, POS, ModBlocks.DOUBLE_SLAB_BLOCK); + NbtCompound nbt = entity.createNbt(context.getWorld().getRegistryManager()); + + Decoded decoded = read(context, nbt, Family.HORIZONTAL, null); + expectSource(context, decoded, Source.LEGACY); + expectNoProblems(context, decoded); + expectHalf(context, decoded.positive(), top(Blocks.OAK_SLAB)); + expectHalf(context, decoded.negative(), bottom(ModBlocks.GLASS_SLAB)); + context.complete(); + } + + @GameTest(templateName = EMPTY_STRUCTURE) + public void readsCurrentVerticalBlockEntityNbt(TestContext context) { + context.setBlockState(POS, ModBlocks.STONE_VERTICAL_SLAB.getDefaultState().with(VerticalSlabBlock.FACING, Direction.NORTH)); + placeItemOn(context, ModBlocks.OAK_VERTICAL_SLAB, POS, Direction.SOUTH, new Vec3d(0.5, 0.5, 0.5)); + AbstractDoubleSlabBlockEntity entity = expectDouble(context, POS, ModBlocks.DOUBLE_VERTICAL_SLAB_BLOCK); + NbtCompound nbt = entity.createNbt(context.getWorld().getRegistryManager()); + VerticalSlabAxis stateAxis = context.getBlockState(POS).get(DoubleVerticalSlabBlock.AXIS); + + Decoded decoded = read(context, nbt, Family.VERTICAL, stateAxis); + expectNoProblems(context, decoded); + context.assertTrue(decoded.axis() == VerticalSlabAxis.Z, "Z軸のはず: " + decoded.axis()); + expectHalf(context, decoded.positive(), vertical(ModBlocks.OAK_VERTICAL_SLAB, Direction.SOUTH)); + expectHalf(context, decoded.negative(), vertical(ModBlocks.STONE_VERTICAL_SLAB, Direction.NORTH)); + context.complete(); + } + + /** 新形式は追加型:今の Slabee の読み手(Block Entity)でも、旧形式の部分から正しく読める */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void currentBlockEntityReadsNewFormat(TestContext context) { + placeDouble(context, POS, Blocks.STONE_SLAB, Blocks.STONE_SLAB); + AbstractDoubleSlabBlockEntity entity = expectDouble(context, POS, ModBlocks.DOUBLE_SLAB_BLOCK); + + NbtCompound nbt = new NbtCompound(); + ComponentStateCodec.write(nbt, Family.HORIZONTAL, top(ModBlocks.GLASS_SLAB), bottom(Blocks.OAK_SLAB), null); + entity.read(nbt, context.getWorld().getRegistryManager()); + + expectHalves(context, entity, ModBlocks.GLASS_SLAB, Blocks.OAK_SLAB); + context.complete(); + } + + // ---- 診断ログ ---- + + /** 一定時間の件数に上限があり、超えた分は件数だけまとめて出る */ + @GameTest(templateName = EMPTY_STRUCTURE) + public void diagnosticsAreRateLimited(TestContext context) { + AtomicLong clock = new AtomicLong(0); + List lines = new ArrayList<>(); + Diagnostics diagnostics = new Diagnostics(clock::get, 3, 1000, lines::add); + NbtCompound broken = parse(""" + {positive_slab: {id: "removedmod:a_slab"}, negative_slab: {id: "removedmod:b_slab"}}"""); + + for (int i = 0; i < 10; i++) { + read(context, broken, Family.HORIZONTAL, null, Catalog.SLABEE, diagnostics); + } + context.assertTrue(lines.size() == 3, "上限の3件だけ出るはず: " + lines); + + clock.set(1000); + read(context, broken, Family.HORIZONTAL, null, Catalog.SLABEE, diagnostics); + context.assertTrue(lines.size() == 6, "区間が変わったら、まとめ1件と新しい2件が出るはず: " + lines); + context.assertTrue(lines.get(3).contains("17 more problems suppressed"), "まとめの件数が違う: " + lines.get(3)); + context.complete(); + } + + // ---- 補助 ---- + + private static Decoded read(TestContext context, NbtCompound nbt, Family family, VerticalSlabAxis blockStateAxis) { + return read(context, nbt, family, blockStateAxis, Catalog.SLABEE, Diagnostics.silent()); + } + + private static Decoded read(TestContext context, NbtCompound nbt, Family family, VerticalSlabAxis blockStateAxis, + Catalog catalog, Diagnostics diagnostics) { + RegistryEntryLookup blocks = context.getWorld().getRegistryManager().getWrapperOrThrow(RegistryKeys.BLOCK); + return ComponentStateCodec.read(nbt, family, blocks, catalog, blockStateAxis, diagnostics); + } + + private static NbtCompound parse(String snbt) { + try { + return StringNbtReader.parse(snbt); + } catch (CommandSyntaxException e) { + throw new GameTestException("fixture の SNBT が読めない: " + e.getMessage()); + } + } + + private static BlockState top(Block slab) { + return slab.getDefaultState().with(SlabBlock.TYPE, SlabType.TOP); + } + + private static BlockState bottom(Block slab) { + return slab.getDefaultState().with(SlabBlock.TYPE, SlabType.BOTTOM); + } + + private static BlockState vertical(Block slab, Direction facing) { + return slab.getDefaultState().with(VerticalSlabBlock.FACING, facing); + } + + private static void expectHalf(TestContext context, Optional actual, BlockState expected) { + context.assertTrue(actual.isPresent() && actual.get().equals(expected), + "ハーフの状態が違う: 期待=" + expected + " 実際=" + actual.map(Object::toString).orElse("(なし)")); + } + + private static void expectOutcome(TestContext context, Decoded decoded, Outcome expected) { + context.assertTrue(decoded.outcome() == expected, "結果が違う: 期待=" + expected + " 実際=" + decoded.outcome() + " " + decoded.problems()); + } + + private static void expectSource(TestContext context, Decoded decoded, Source expected) { + context.assertTrue(decoded.source() == expected, "読んだ形式が違う: 期待=" + expected + " 実際=" + decoded.source()); + } + + private static void expectNoProblems(TestContext context, Decoded decoded) { + context.assertTrue(decoded.problems().isEmpty(), "問題が出るはずがない: " + decoded.problems()); + } + + private static void expectProblem(TestContext context, Decoded decoded, Issue issue) { + context.assertTrue(decoded.has(issue), issue + " が出るはず: " + decoded.problems()); + } +} diff --git a/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/persistence/ComponentStateCodec.java b/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/persistence/ComponentStateCodec.java new file mode 100644 index 00000000..840304da --- /dev/null +++ b/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/persistence/ComponentStateCodec.java @@ -0,0 +1,364 @@ +package com.forestotzka.yurufu.slabee.gametest.persistence; + +import com.forestotzka.yurufu.slabee.block.ModBlockMap; +import com.forestotzka.yurufu.slabee.block.VerticalSlabBlock; +import com.forestotzka.yurufu.slabee.block.enums.VerticalSlabAxis; +import com.forestotzka.yurufu.slabee.state.property.ModProperties; +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.block.enums.SlabType; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtElement; +import net.minecraft.nbt.NbtHelper; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryEntryLookup; +import net.minecraft.registry.RegistryKey; +import net.minecraft.registry.RegistryKeys; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.state.StateManager; +import net.minecraft.state.property.Properties; +import net.minecraft.state.property.Property; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Direction; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * B2 永続化 spike:ダブルハーフの中のハーフ1枚ずつの状態を、バージョン付きの NBT と相互に変換する。 + *

+ * 実際の Block Entity にはまだつないでいない(Stage 6 で本実装する)。ここでは + * 「検査済みの {@link NbtHelper} 経路」「property の所有権」「旧形式の読み込み」「壊れたデータの扱い」が + * Minecraft 1.21.1 で成り立つかを確かめる。 + *

+ * 保存形式(追加型): + *

+ * positive_slab: {id: "minecraft:oak_slab"}      ← 旧形式。今の Slabee もこれを読める
+ * negative_slab: {id: "minecraft:stone_slab"}
+ * axis: "X"                                      ← 縦のダブルハーフだけ。軸はここに1回だけ保存する
+ * slabee_components: {version: 1,
+ *     positive: {Name: ..., Properties: {...}},  ← ハーフ自身が持つ property だけ
+ *     negative: {...}}
+ * 
+ */ +public final class ComponentStateCodec { + public static final int SCHEMA_VERSION = 1; + public static final String COMPONENTS_KEY = "slabee_components"; + public static final String VERSION_KEY = "version"; + public static final String POSITIVE_KEY = "positive"; + public static final String NEGATIVE_KEY = "negative"; + public static final String LEGACY_POSITIVE_KEY = "positive_slab"; + public static final String LEGACY_NEGATIVE_KEY = "negative_slab"; + public static final String LEGACY_ID_KEY = "id"; + public static final String AXIS_KEY = "axis"; + + /** + * ハーフ自身の状態として保存しない property。 + * TYPE/FACING はスロットから、SNOWY/葉の DISTANCE は周りから決まり、 + * WATERLOGGED と IS_DOUBLE はダブルハーフ全体(容器)の状態なので、片側の正データにしない。 + */ + static final Set> NON_COMPONENT_PROPERTIES = Set.of( + Properties.SLAB_TYPE, + VerticalSlabBlock.FACING, + Properties.WATERLOGGED, + Properties.SNOWY, + Properties.DISTANCE_1_7, + ModProperties.IS_DOUBLE + ); + + private ComponentStateCodec() {} + + public enum Family { HORIZONTAL, VERTICAL } + + public enum Side { POSITIVE, NEGATIVE } + + /** 読み込み時に見つかった問題。テストで確認し、診断ログにも出す */ + public enum Issue { + MISSING_COMPONENT, + MALFORMED_ID, + UNKNOWN_ID, + UNSUPPORTED_BLOCK, + UNKNOWN_PROPERTY, + INVALID_PROPERTY_VALUE, + MALFORMED_SCHEMA, + FUTURE_SCHEMA, + INVALID_AXIS, + AXIS_CONFLICT + } + + public record Problem(Issue issue, String detail) {} + + /** どの形式から読んだか */ + public enum Source { V1, LEGACY } + + /** 読み込み後にセルがどうなるか */ + public enum Outcome { DOUBLE, SINGLE_POSITIVE, SINGLE_NEGATIVE, AIR } + + /** どのブロックをハーフとして受け入れるか。通常は Slabee の対応表、テストでは差し替える */ + @FunctionalInterface + public interface Catalog { + boolean accepts(Family family, Block block); + + Catalog SLABEE = (family, block) -> family == Family.HORIZONTAL + ? ModBlockMap.isMappedSlab(block) + : ModBlockMap.isMappedVerticalSlab(block); + } + + public record Decoded(Family family, Source source, + Optional positive, Optional negative, + @Nullable VerticalSlabAxis axis, List problems) { + public Outcome outcome() { + if (positive.isPresent() && negative.isPresent()) return Outcome.DOUBLE; + if (positive.isPresent()) return Outcome.SINGLE_POSITIVE; + if (negative.isPresent()) return Outcome.SINGLE_NEGATIVE; + return Outcome.AIR; + } + + /** 片側だけ残ったときの単体ブロック、両方失ったときは air。両方残っていれば empty */ + public Optional singleizedState() { + return switch (outcome()) { + case DOUBLE -> Optional.empty(); + case SINGLE_POSITIVE -> positive; + case SINGLE_NEGATIVE -> negative; + case AIR -> Optional.of(Blocks.AIR.getDefaultState()); + }; + } + + public boolean has(Issue issue) { + return problems.stream().anyMatch(p -> p.issue() == issue); + } + } + + // ---- 書き込み ---- + + /** + * 両側のハーフを保存する。旧形式の id も一緒に書くので、今の Slabee でも読める。 + * {@code axis} は縦のダブルハーフだけで使い、横では null。 + */ + public static void write(NbtCompound nbt, Family family, BlockState positive, BlockState negative, @Nullable VerticalSlabAxis axis) { + nbt.put(LEGACY_POSITIVE_KEY, legacyComponent(positive)); + nbt.put(LEGACY_NEGATIVE_KEY, legacyComponent(negative)); + if (family == Family.VERTICAL) { + nbt.putString(AXIS_KEY, (axis == VerticalSlabAxis.Z) ? "Z" : "X"); + } + + NbtCompound components = new NbtCompound(); + components.putInt(VERSION_KEY, SCHEMA_VERSION); + components.put(POSITIVE_KEY, encodeComponent(positive)); + components.put(NEGATIVE_KEY, encodeComponent(negative)); + nbt.put(COMPONENTS_KEY, components); + } + + /** {@link NbtHelper#fromBlockState} の結果から、ハーフ自身が持たない property を取り除く */ + static NbtCompound encodeComponent(BlockState state) { + NbtCompound encoded = NbtHelper.fromBlockState(state); + if (encoded.contains("Properties", NbtElement.COMPOUND_TYPE)) { + NbtCompound properties = encoded.getCompound("Properties"); + for (Property property : state.getProperties()) { + if (NON_COMPONENT_PROPERTIES.contains(property)) { + properties.remove(property.getName()); + } + } + if (properties.isEmpty()) { + encoded.remove("Properties"); + } + } + return encoded; + } + + private static NbtCompound legacyComponent(BlockState state) { + NbtCompound legacy = new NbtCompound(); + legacy.putString(LEGACY_ID_KEY, Registries.BLOCK.getId(state.getBlock()).toString()); + return legacy; + } + + // ---- 読み込み ---- + + /** + * 保存データを読む。新形式があればそれを、なければ(または読めない版なら)旧形式を読む。 + * + * @param blockStateAxis 縦のダブルハーフの外側 BlockState の AXIS(分からなければ null) + */ + public static Decoded read(NbtCompound nbt, Family family, RegistryEntryLookup blocks, Catalog catalog, + @Nullable VerticalSlabAxis blockStateAxis, Diagnostics diagnostics) { + List problems = new ArrayList<>(); + + Source source = Source.LEGACY; + NbtCompound components = null; + if (nbt.contains(COMPONENTS_KEY)) { + if (nbt.contains(COMPONENTS_KEY, NbtElement.COMPOUND_TYPE) + && nbt.getCompound(COMPONENTS_KEY).contains(VERSION_KEY, NbtElement.NUMBER_TYPE)) { + int version = nbt.getCompound(COMPONENTS_KEY).getInt(VERSION_KEY); + if (version == SCHEMA_VERSION) { + source = Source.V1; + components = nbt.getCompound(COMPONENTS_KEY); + } else if (version > SCHEMA_VERSION) { + // 新しい Slabee で保存された:読めない部分は捨て、一緒に書かれている旧形式で読む + problems.add(new Problem(Issue.FUTURE_SCHEMA, "version=" + version)); + } else { + problems.add(new Problem(Issue.MALFORMED_SCHEMA, "version=" + version)); + } + } else { + problems.add(new Problem(Issue.MALFORMED_SCHEMA, String.valueOf(nbt.get(COMPONENTS_KEY)))); + } + } + + VerticalSlabAxis axis = (family == Family.VERTICAL) ? readAxis(nbt, source, blockStateAxis, problems) : null; + + Optional positive; + Optional negative; + if (components != null) { + positive = decodeComponent(components, POSITIVE_KEY, family, Side.POSITIVE, axis, blocks, catalog, problems); + negative = decodeComponent(components, NEGATIVE_KEY, family, Side.NEGATIVE, axis, blocks, catalog, problems); + } else { + positive = decodeLegacyComponent(nbt, LEGACY_POSITIVE_KEY, family, Side.POSITIVE, axis, blocks, catalog, problems); + negative = decodeLegacyComponent(nbt, LEGACY_NEGATIVE_KEY, family, Side.NEGATIVE, axis, blocks, catalog, problems); + } + + for (Problem problem : problems) { + diagnostics.report(problem.issue(), problem.detail()); + } + return new Decoded(family, source, positive, negative, axis, List.copyOf(problems)); + } + + /** + * 縦の軸を決める。新形式では外側 BlockState を正とし、NBT と食い違えば診断する。 + * 旧形式では NBT の axis を移行の入力にし、なければ BlockState、それもなければ X。 + */ + private static VerticalSlabAxis readAxis(NbtCompound nbt, Source source, @Nullable VerticalSlabAxis blockStateAxis, List problems) { + VerticalSlabAxis nbtAxis = null; + if (nbt.contains(AXIS_KEY, NbtElement.STRING_TYPE)) { + String raw = nbt.getString(AXIS_KEY); + if ("X".equals(raw)) { + nbtAxis = VerticalSlabAxis.X; + } else if ("Z".equals(raw)) { + nbtAxis = VerticalSlabAxis.Z; + } else { + problems.add(new Problem(Issue.INVALID_AXIS, raw)); + } + } else if (nbt.contains(AXIS_KEY)) { + problems.add(new Problem(Issue.INVALID_AXIS, String.valueOf(nbt.get(AXIS_KEY)))); + } + + if (source == Source.V1 && blockStateAxis != null) { + if (nbtAxis != null && nbtAxis != blockStateAxis) { + problems.add(new Problem(Issue.AXIS_CONFLICT, "blockState=" + blockStateAxis.asString() + " nbt=" + nbtAxis.asString())); + } + return blockStateAxis; + } + if (nbtAxis != null) return nbtAxis; + if (blockStateAxis != null) return blockStateAxis; + return VerticalSlabAxis.X; + } + + private static Optional decodeComponent(NbtCompound components, String key, Family family, Side side, + @Nullable VerticalSlabAxis axis, RegistryEntryLookup blocks, + Catalog catalog, List problems) { + if (!components.contains(key, NbtElement.COMPOUND_TYPE)) { + problems.add(new Problem(Issue.MISSING_COMPONENT, side.name())); + return Optional.empty(); + } + NbtCompound encoded = components.getCompound(key); + if (!encoded.contains("Name", NbtElement.STRING_TYPE)) { + problems.add(new Problem(Issue.MISSING_COMPONENT, side.name() + " Name")); + return Optional.empty(); + } + + Optional block = resolveBlock(encoded.getString("Name"), family, side, blocks, catalog, problems); + if (block.isEmpty()) return Optional.empty(); + + // 先に property を検査し、通ったものだけを NbtHelper に渡す(失敗が黙って捨てられないように) + NbtCompound sanitized = new NbtCompound(); + sanitized.putString("Name", encoded.getString("Name")); + if (encoded.contains("Properties", NbtElement.COMPOUND_TYPE)) { + NbtCompound validProperties = validProperties(block.get(), encoded.getCompound("Properties"), side, problems); + if (!validProperties.isEmpty()) { + sanitized.put("Properties", validProperties); + } + } + + BlockState state = NbtHelper.toBlockState(blocks, sanitized); + return Optional.of(applySlot(state, family, side, axis)); + } + + private static Optional decodeLegacyComponent(NbtCompound nbt, String key, Family family, Side side, + @Nullable VerticalSlabAxis axis, RegistryEntryLookup blocks, + Catalog catalog, List problems) { + if (!nbt.contains(key, NbtElement.COMPOUND_TYPE) + || !nbt.getCompound(key).contains(LEGACY_ID_KEY, NbtElement.STRING_TYPE)) { + problems.add(new Problem(Issue.MISSING_COMPONENT, side.name())); + return Optional.empty(); + } + // 旧形式の facing(横)は今の Slabee でも使っていないので読まない + return resolveBlock(nbt.getCompound(key).getString(LEGACY_ID_KEY), family, side, blocks, catalog, problems) + .map(block -> applySlot(block.getDefaultState(), family, side, axis)); + } + + /** id を検査してブロックを引く。壊れた・消えた・対応外の id ならそのハーフを失ったものとする */ + private static Optional resolveBlock(String rawId, Family family, Side side, RegistryEntryLookup blocks, + Catalog catalog, List problems) { + Identifier id = Identifier.tryParse(rawId); + if (id == null) { + problems.add(new Problem(Issue.MALFORMED_ID, side.name() + " " + rawId)); + return Optional.empty(); + } + Optional> entry = blocks.getOptional(RegistryKey.of(RegistryKeys.BLOCK, id)); + if (entry.isEmpty()) { + problems.add(new Problem(Issue.UNKNOWN_ID, side.name() + " " + id)); + return Optional.empty(); + } + Block block = entry.get().value(); + if (!catalog.accepts(family, block)) { + problems.add(new Problem(Issue.UNSUPPORTED_BLOCK, side.name() + " " + id + " (" + family.name() + ")")); + return Optional.empty(); + } + return Optional.of(block); + } + + /** 読める property だけを残す。所有していない property は黙って無視し、読めないものは診断する */ + private static NbtCompound validProperties(Block block, NbtCompound properties, Side side, List problems) { + StateManager stateManager = block.getStateManager(); + NbtCompound valid = new NbtCompound(); + for (String name : properties.getKeys()) { + Property property = stateManager.getProperty(name); + if (property == null) { + problems.add(new Problem(Issue.UNKNOWN_PROPERTY, side.name() + " " + name)); + continue; + } + if (NON_COMPONENT_PROPERTIES.contains(property)) { + continue; + } + if (!properties.contains(name, NbtElement.STRING_TYPE) || property.parse(properties.getString(name)).isEmpty()) { + problems.add(new Problem(Issue.INVALID_PROPERTY_VALUE, side.name() + " " + name + "=" + properties.get(name))); + continue; + } + valid.putString(name, properties.getString(name)); + } + return valid; + } + + /** スロットから決まる TYPE/FACING を入れる。片側だけ残ったときも、そのまま単体ブロックになる向き */ + static BlockState applySlot(BlockState state, Family family, Side side, @Nullable VerticalSlabAxis axis) { + if (family == Family.HORIZONTAL) { + if (state.contains(Properties.SLAB_TYPE)) { + return state.with(Properties.SLAB_TYPE, side == Side.POSITIVE ? SlabType.TOP : SlabType.BOTTOM); + } + return state; + } + if (state.contains(VerticalSlabBlock.FACING)) { + return state.with(VerticalSlabBlock.FACING, verticalFacing(side, axis)); + } + return state; + } + + static Direction verticalFacing(Side side, @Nullable VerticalSlabAxis axis) { + if (axis == VerticalSlabAxis.Z) { + return side == Side.POSITIVE ? Direction.SOUTH : Direction.NORTH; + } + return side == Side.POSITIVE ? Direction.EAST : Direction.WEST; + } +} diff --git a/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/persistence/Diagnostics.java b/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/persistence/Diagnostics.java new file mode 100644 index 00000000..a4dbfd9d --- /dev/null +++ b/src/gametest/java/com/forestotzka/yurufu/slabee/gametest/persistence/Diagnostics.java @@ -0,0 +1,55 @@ +package com.forestotzka.yurufu.slabee.gametest.persistence; + +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +/** + * 壊れた保存データの診断ログ。壊れたワールドを読むとブロックの数だけ出てしまうので、 + * 一定時間ごとの件数に上限を設け、超えた分は件数だけまとめて出す。 + */ +public final class Diagnostics { + private final LongSupplier clockMillis; + private final int maxPerWindow; + private final long windowMillis; + private final Consumer sink; + + private long windowStart; + private int reportedInWindow = 0; + private int suppressedInWindow = 0; + + public Diagnostics(LongSupplier clockMillis, int maxPerWindow, long windowMillis, Consumer sink) { + this.clockMillis = clockMillis; + this.maxPerWindow = maxPerWindow; + this.windowMillis = windowMillis; + this.sink = sink; + this.windowStart = clockMillis.getAsLong(); + } + + /** 何も出さない(テストで診断ログを見ないとき) */ + public static Diagnostics silent() { + return new Diagnostics(() -> 0L, 0, Long.MAX_VALUE, message -> {}); + } + + public synchronized void report(ComponentStateCodec.Issue issue, String detail) { + long now = clockMillis.getAsLong(); + if (now - windowStart >= windowMillis) { + flushSuppressed(); + windowStart = now; + reportedInWindow = 0; + } + if (reportedInWindow < maxPerWindow) { + reportedInWindow++; + sink.accept("[Slabee] saved half data: " + issue + " " + detail); + } else { + suppressedInWindow++; + } + } + + /** まとめて出していない件数を出す。区間が変わったときにも呼ばれる */ + public synchronized void flushSuppressed() { + if (suppressedInWindow > 0) { + sink.accept("[Slabee] saved half data: " + suppressedInWindow + " more problems suppressed"); + suppressedInWindow = 0; + } + } +} diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json index 4f46e96b..8636696d 100644 --- a/src/gametest/resources/fabric.mod.json +++ b/src/gametest/resources/fabric.mod.json @@ -11,7 +11,8 @@ "com.forestotzka.yurufu.slabee.gametest.HalfBreakGameTests", "com.forestotzka.yurufu.slabee.gametest.MixinGameTests", "com.forestotzka.yurufu.slabee.gametest.LightingGameTests", - "com.forestotzka.yurufu.slabee.gametest.InnerFaceGameTests" + "com.forestotzka.yurufu.slabee.gametest.InnerFaceGameTests", + "com.forestotzka.yurufu.slabee.gametest.PersistenceSpikeGameTests" ] }, "depends": { diff --git a/src/main/java/com/forestotzka/yurufu/slabee/block/ModBlockMap.java b/src/main/java/com/forestotzka/yurufu/slabee/block/ModBlockMap.java index 294e5def..f3f398f7 100644 --- a/src/main/java/com/forestotzka/yurufu/slabee/block/ModBlockMap.java +++ b/src/main/java/com/forestotzka/yurufu/slabee/block/ModBlockMap.java @@ -304,6 +304,16 @@ private record BlockTriple(Block original, Block slab, Block verticalSlab) { } } + /** 対応表にある横のハーフか(保存データの検証用。読み取りだけ) */ + public static boolean isMappedSlab(Block block) { + return block instanceof SlabBlock && slabMap.containsKey(block); + } + + /** 対応表にある縦のハーフか(保存データの検証用。読み取りだけ) */ + public static boolean isMappedVerticalSlab(Block block) { + return block instanceof VerticalSlabBlock && verticalSlabMap.containsKey(block); + } + public static Block toOriginal(Block block) { if (block == null) { return null;