Refactor V2 animation, furniture, and GLTF rendering
Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.
Parsing and rendering
- Shared GLB parsing helpers consolidated into GlbParserUtils
(accessor reads, weight normalization, joint-index clamping,
coordinate-space conversion, animation parse, primitive loop).
- Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
GltfLiveBoneReader — removes per-frame joint-matrix allocation
from the render hot path.
- emitVertex helper dedups three parallel loops in GltfMeshRenderer.
- TintColorResolver.resolve has a zero-alloc path when the item
declares no tint channels.
- itemAnimCache bounded to 256 entries (access-order LRU) with
atomic get-or-compute under the map's monitor.
Animation correctness
- First-in-joint-order wins when body and torso both map to the
same PlayerAnimator slot; duplicate writes log a single WARN.
- Multi-item composites honor the FullX / FullHeadX opt-in that
the single-item path already recognized.
- Seat transforms converted to Minecraft model-def space so
asymmetric furniture renders passengers at the correct offset.
- GlbValidator: IBM count / type / presence, JOINTS_0 presence,
animation channel target validation, multi-skin support.
Furniture correctness and anti-exploit
- Seat assignment synced via SynchedEntityData (server is
authoritative; eliminates client-server divergence on multi-seat).
- Force-mount authorization requires same dimension and a free
seat; cross-dimension distance checks rejected.
- Reconnection on login checks for seat takeover before re-mount
and force-loads the target chunk for cross-dimension cases.
- tiedup_furniture_lockpick_ctx carries a session UUID nonce so
stale context can't misroute a body-item lockpick.
- tiedup_locked_furniture survives death without keepInventory
(Forge 1.20.1 does not auto-copy persistent data on respawn).
Lifecycle and memory
- EntityCleanupHandler fans EntityLeaveLevelEvent out to every
per-entity state map on the client.
- DogPoseRenderHandler re-keyed by UUID (stable across dimension
change; entity int ids are recycled).
- PetBedRenderHandler, PlayerArmHideEventHandler, and
HeldItemHideHandler use receiveCanceled + sentinel sets so
Pre-time mutations are restored even when a downstream handler
cancels the render.
Tests
- JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GlbParserUtils}.
|
||||
*
|
||||
* <p>Covers the parser safety surface introduced to defeat the OOM / silent-drift
|
||||
* bugs found in the 2026-04-17 audit:</p>
|
||||
* <ul>
|
||||
* <li>{@link GlbParserUtils#readGlbSafely(java.io.InputStream, String)} header cap</li>
|
||||
* <li>{@link GlbParserUtils#readChunkLength(java.nio.ByteBuffer, String, String)} bounds</li>
|
||||
* <li>{@link GlbParserUtils#normalizeWeights(float[])} correctness</li>
|
||||
* <li>{@link GlbParserUtils#clampJointIndices(int[], int)} contract</li>
|
||||
* </ul>
|
||||
*/
|
||||
class GlbParserUtilsTest {
|
||||
|
||||
/**
|
||||
* Build a minimum-size GLB header: 4B magic, 4B version, 4B totalLength.
|
||||
* Returns the 12 bytes; no chunks follow.
|
||||
*/
|
||||
private static byte[] minimalHeader(int totalLength) {
|
||||
ByteBuffer buf = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
|
||||
buf.putInt(GlbParserUtils.GLB_MAGIC);
|
||||
buf.putInt(GlbParserUtils.GLB_VERSION);
|
||||
buf.putInt(totalLength);
|
||||
return buf.array();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("readGlbSafely — OOM/truncation guards")
|
||||
class ReadGlbSafelyTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects files exceeding MAX_GLB_SIZE cap")
|
||||
void rejectsOversized() {
|
||||
// Claim a totalLength 10x the cap, but only provide 12 bytes of header.
|
||||
byte[] header = minimalHeader(GlbParserUtils.MAX_GLB_SIZE + 1);
|
||||
IOException ex = assertThrows(IOException.class, () ->
|
||||
GlbParserUtils.readGlbSafely(
|
||||
new ByteArrayInputStream(header),
|
||||
"hostile.glb"
|
||||
)
|
||||
);
|
||||
assertTrue(
|
||||
ex.getMessage().contains("exceeds cap"),
|
||||
"message should mention cap: " + ex.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects negative totalLength")
|
||||
void rejectsNegativeTotalLength() {
|
||||
byte[] header = minimalHeader(-1);
|
||||
IOException ex = assertThrows(IOException.class, () ->
|
||||
GlbParserUtils.readGlbSafely(
|
||||
new ByteArrayInputStream(header),
|
||||
"negative.glb"
|
||||
)
|
||||
);
|
||||
// Either the "too small" or "exceeds cap" path is acceptable —
|
||||
// what matters is that negative lengths don't slip through.
|
||||
assertTrue(
|
||||
ex.getMessage().contains("too small") ||
|
||||
ex.getMessage().contains("exceeds cap"),
|
||||
"message should reject negative length: " + ex.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects wrong magic bytes")
|
||||
void rejectsWrongMagic() {
|
||||
ByteBuffer buf = ByteBuffer.allocate(12).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
buf.putInt(0xDEADBEEF); // wrong magic
|
||||
buf.putInt(GlbParserUtils.GLB_VERSION);
|
||||
buf.putInt(100);
|
||||
IOException ex = assertThrows(IOException.class, () ->
|
||||
GlbParserUtils.readGlbSafely(
|
||||
new ByteArrayInputStream(buf.array()),
|
||||
"junk.glb"
|
||||
)
|
||||
);
|
||||
assertTrue(
|
||||
ex.getMessage().contains("Not a GLB"),
|
||||
"message should reject bad magic: " + ex.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects wrong version")
|
||||
void rejectsWrongVersion() {
|
||||
ByteBuffer buf = ByteBuffer.allocate(12).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
buf.putInt(GlbParserUtils.GLB_MAGIC);
|
||||
buf.putInt(999); // future version
|
||||
buf.putInt(100);
|
||||
IOException ex = assertThrows(IOException.class, () ->
|
||||
GlbParserUtils.readGlbSafely(
|
||||
new ByteArrayInputStream(buf.array()),
|
||||
"future.glb"
|
||||
)
|
||||
);
|
||||
assertTrue(
|
||||
ex.getMessage().contains("Unsupported GLB version"),
|
||||
"message should mention version: " + ex.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects truncated body (totalLength promises more than stream provides)")
|
||||
void rejectsTruncatedBody() {
|
||||
// Claim 100 bytes total, but only supply the 12-byte header.
|
||||
byte[] header = minimalHeader(100);
|
||||
IOException ex = assertThrows(IOException.class, () ->
|
||||
GlbParserUtils.readGlbSafely(
|
||||
new ByteArrayInputStream(header),
|
||||
"truncated.glb"
|
||||
)
|
||||
);
|
||||
assertTrue(
|
||||
ex.getMessage().contains("truncated"),
|
||||
"message should mention truncation: " + ex.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("readChunkLength — buffer bounds")
|
||||
class ReadChunkLengthTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects negative length")
|
||||
void rejectsNegativeLength() {
|
||||
ByteBuffer buf = ByteBuffer.allocate(8).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
buf.putInt(-5); // negative length
|
||||
buf.putInt(GlbParserUtils.CHUNK_JSON); // type follows, space reserved
|
||||
buf.position(0);
|
||||
assertThrows(IOException.class, () ->
|
||||
GlbParserUtils.readChunkLength(buf, "JSON", "bad.glb")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects length exceeding remaining")
|
||||
void rejectsOverflowingLength() {
|
||||
// Buffer capacity 16: 4B length + 4B type + 8B payload.
|
||||
// Claim length=100 which doesn't fit.
|
||||
ByteBuffer buf = ByteBuffer.allocate(16).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
buf.putInt(100);
|
||||
buf.putInt(GlbParserUtils.CHUNK_JSON);
|
||||
buf.position(0);
|
||||
assertThrows(IOException.class, () ->
|
||||
GlbParserUtils.readChunkLength(buf, "JSON", "overflow.glb")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts valid length")
|
||||
void acceptsValidLength() throws IOException {
|
||||
ByteBuffer buf = ByteBuffer.allocate(16).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
buf.putInt(8); // 8 bytes payload (will follow)
|
||||
buf.putInt(GlbParserUtils.CHUNK_JSON);
|
||||
buf.position(0);
|
||||
// 4 (type) + 8 (payload) == 12 remaining after length read; len=8 must
|
||||
// be <= remaining-4 = 8. Passes.
|
||||
int len = GlbParserUtils.readChunkLength(buf, "JSON", "ok.glb");
|
||||
assertEquals(8, len);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("normalizeWeights")
|
||||
class NormalizeWeightsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("normalizes a tuple summing to < 1.0")
|
||||
void normalizesLowSum() {
|
||||
float[] w = { 0.5f, 0.3f, 0.1f, 0.0f }; // sum 0.9
|
||||
GlbParserUtils.normalizeWeights(w);
|
||||
float sum = w[0] + w[1] + w[2] + w[3];
|
||||
assertEquals(1.0f, sum, 1.0e-5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("normalizes a tuple summing to > 1.0")
|
||||
void normalizesHighSum() {
|
||||
float[] w = { 0.6f, 0.4f, 0.2f, 0.0f }; // sum 1.2
|
||||
GlbParserUtils.normalizeWeights(w);
|
||||
float sum = w[0] + w[1] + w[2] + w[3];
|
||||
assertEquals(1.0f, sum, 1.0e-5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves already-normalized tuple within ε untouched")
|
||||
void leavesNormalizedUntouched() {
|
||||
float[] w = { 0.4f, 0.3f, 0.2f, 0.1f }; // sum exactly 1.0
|
||||
float[] orig = w.clone();
|
||||
GlbParserUtils.normalizeWeights(w);
|
||||
assertArrayEquals(orig, w, 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves zero-sum tuple alone (un-skinned vertex)")
|
||||
void leavesZeroSumAlone() {
|
||||
float[] w = { 0.0f, 0.0f, 0.0f, 0.0f };
|
||||
GlbParserUtils.normalizeWeights(w);
|
||||
float sum = w[0] + w[1] + w[2] + w[3];
|
||||
assertEquals(0.0f, sum, 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("processes multiple tuples independently")
|
||||
void multipleTuples() {
|
||||
float[] w = {
|
||||
0.5f, 0.3f, 0.1f, 0.0f, // tuple 1: sum 0.9
|
||||
0.6f, 0.4f, 0.2f, 0.0f, // tuple 2: sum 1.2
|
||||
0.4f, 0.3f, 0.2f, 0.1f, // tuple 3: sum 1.0 (untouched)
|
||||
};
|
||||
GlbParserUtils.normalizeWeights(w);
|
||||
assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
|
||||
assertEquals(1.0f, w[4] + w[5] + w[6] + w[7], 1.0e-5f);
|
||||
assertEquals(1.0f, w[8] + w[9] + w[10] + w[11], 1.0e-5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tolerates non-multiple-of-4 length (processes well-formed prefix, ignores tail)")
|
||||
void nonMultipleOfFourLength() {
|
||||
// 7 elements: one complete tuple + 3 orphan weights.
|
||||
float[] w = { 0.5f, 0.3f, 0.1f, 0.0f, 0.9f, 0.9f, 0.9f };
|
||||
GlbParserUtils.normalizeWeights(w);
|
||||
assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
|
||||
// Trailing elements should be untouched (not normalized).
|
||||
assertEquals(0.9f, w[4], 1.0e-6f);
|
||||
assertEquals(0.9f, w[5], 1.0e-6f);
|
||||
assertEquals(0.9f, w[6], 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null array is a safe no-op")
|
||||
void nullArrayIsSafe() {
|
||||
GlbParserUtils.normalizeWeights(null);
|
||||
// No exception = pass.
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isPlayerMesh / stripArmaturePrefix")
|
||||
class NamingConventionTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("isPlayerMesh: exact name 'Player' matches")
|
||||
void isPlayerExact() {
|
||||
assertTrue(GlbParserUtils.isPlayerMesh("Player"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isPlayerMesh: prefixed name 'Player_main' matches (startsWith)")
|
||||
void isPlayerPrefixed() {
|
||||
assertTrue(GlbParserUtils.isPlayerMesh("Player_main"));
|
||||
assertTrue(GlbParserUtils.isPlayerMesh("Player_left"));
|
||||
assertTrue(GlbParserUtils.isPlayerMesh("Player_foo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isPlayerMesh: non-Player name rejected")
|
||||
void isPlayerRejectsItem() {
|
||||
assertFalse(GlbParserUtils.isPlayerMesh("Item"));
|
||||
assertFalse(GlbParserUtils.isPlayerMesh(""));
|
||||
assertFalse(GlbParserUtils.isPlayerMesh("player")); // lowercase
|
||||
assertFalse(GlbParserUtils.isPlayerMesh("MyMesh"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isPlayerMesh: null is safe and rejected")
|
||||
void isPlayerNullSafe() {
|
||||
assertFalse(GlbParserUtils.isPlayerMesh(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripArmaturePrefix: 'MyRig|bone' → 'bone'")
|
||||
void stripsPipe() {
|
||||
assertEquals("bone", GlbParserUtils.stripArmaturePrefix("MyRig|bone"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripArmaturePrefix: name without pipe is unchanged")
|
||||
void noPipeUnchanged() {
|
||||
assertEquals("body", GlbParserUtils.stripArmaturePrefix("body"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripArmaturePrefix: multiple pipes → keep everything after LAST")
|
||||
void multiplePipesLastWins() {
|
||||
assertEquals("C", GlbParserUtils.stripArmaturePrefix("A|B|C"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripArmaturePrefix: prefix-only '|body' → 'body'")
|
||||
void prefixOnly() {
|
||||
assertEquals("body", GlbParserUtils.stripArmaturePrefix("|body"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripArmaturePrefix: trailing pipe 'body|' → empty string")
|
||||
void trailingPipe() {
|
||||
assertEquals("", GlbParserUtils.stripArmaturePrefix("body|"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripArmaturePrefix: null returns null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(GlbParserUtils.stripArmaturePrefix(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("readRestRotation / readRestTranslation")
|
||||
class ReadRestPoseTests {
|
||||
|
||||
private JsonObject parseNode(String json) {
|
||||
return JsonParser.parseString(json).getAsJsonObject();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty node returns identity quaternion")
|
||||
void emptyRotation() {
|
||||
Quaternionf q = GlbParserUtils.readRestRotation(parseNode("{}"));
|
||||
assertEquals(0.0f, q.x, 1.0e-6f);
|
||||
assertEquals(0.0f, q.y, 1.0e-6f);
|
||||
assertEquals(0.0f, q.z, 1.0e-6f);
|
||||
assertEquals(1.0f, q.w, 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty node returns zero vector")
|
||||
void emptyTranslation() {
|
||||
Vector3f t = GlbParserUtils.readRestTranslation(parseNode("{}"));
|
||||
assertEquals(0.0f, t.x, 1.0e-6f);
|
||||
assertEquals(0.0f, t.y, 1.0e-6f);
|
||||
assertEquals(0.0f, t.z, 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("node with rotation reads all four components in XYZW order")
|
||||
void readsRotation() {
|
||||
Quaternionf q = GlbParserUtils.readRestRotation(
|
||||
parseNode("{\"rotation\": [0.1, 0.2, 0.3, 0.9]}")
|
||||
);
|
||||
assertEquals(0.1f, q.x, 1.0e-6f);
|
||||
assertEquals(0.2f, q.y, 1.0e-6f);
|
||||
assertEquals(0.3f, q.z, 1.0e-6f);
|
||||
assertEquals(0.9f, q.w, 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("node with translation reads all three components")
|
||||
void readsTranslation() {
|
||||
Vector3f t = GlbParserUtils.readRestTranslation(
|
||||
parseNode("{\"translation\": [1.5, -2.5, 3.0]}")
|
||||
);
|
||||
assertEquals(1.5f, t.x, 1.0e-6f);
|
||||
assertEquals(-2.5f, t.y, 1.0e-6f);
|
||||
assertEquals(3.0f, t.z, 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null node returns default values (no NPE)")
|
||||
void nullNode() {
|
||||
Quaternionf q = GlbParserUtils.readRestRotation(null);
|
||||
Vector3f t = GlbParserUtils.readRestTranslation(null);
|
||||
assertEquals(1.0f, q.w, 1.0e-6f);
|
||||
assertEquals(0.0f, t.x, 1.0e-6f);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildParentJointIndices")
|
||||
class BuildParentJointIndicesTests {
|
||||
|
||||
/** Helper to build a JsonArray of node-like JsonObjects. */
|
||||
private JsonArray parseNodes(String json) {
|
||||
return JsonParser.parseString(json).getAsJsonArray();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("flat skeleton (no children) → all roots")
|
||||
void flatSkeleton() {
|
||||
JsonArray nodes = parseNodes("[{}, {}, {}]");
|
||||
int[] nodeToJoint = { 0, 1, 2 };
|
||||
int[] parents = GlbParserUtils.buildParentJointIndices(
|
||||
nodes,
|
||||
nodeToJoint,
|
||||
3
|
||||
);
|
||||
assertArrayEquals(new int[] { -1, -1, -1 }, parents);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("simple tree: root with two children")
|
||||
void simpleTree() {
|
||||
// Node 0: root with children [1, 2]
|
||||
// Node 1: leaf
|
||||
// Node 2: leaf
|
||||
JsonArray nodes = parseNodes(
|
||||
"[{\"children\": [1, 2]}, {}, {}]"
|
||||
);
|
||||
int[] nodeToJoint = { 0, 1, 2 };
|
||||
int[] parents = GlbParserUtils.buildParentJointIndices(
|
||||
nodes,
|
||||
nodeToJoint,
|
||||
3
|
||||
);
|
||||
assertEquals(-1, parents[0]); // root
|
||||
assertEquals(0, parents[1]);
|
||||
assertEquals(0, parents[2]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("nested tree: root → child → grandchild")
|
||||
void nestedTree() {
|
||||
JsonArray nodes = parseNodes(
|
||||
"[{\"children\": [1]}, {\"children\": [2]}, {}]"
|
||||
);
|
||||
int[] nodeToJoint = { 0, 1, 2 };
|
||||
int[] parents = GlbParserUtils.buildParentJointIndices(
|
||||
nodes,
|
||||
nodeToJoint,
|
||||
3
|
||||
);
|
||||
assertArrayEquals(new int[] { -1, 0, 1 }, parents);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-joint node in hierarchy is skipped")
|
||||
void nonJointSkipped() {
|
||||
// Nodes 0 is non-joint (-1 mapping), 1 and 2 are joints.
|
||||
// Node 0 children = [1, 2] but node 0 is not a joint → no parent set.
|
||||
JsonArray nodes = parseNodes(
|
||||
"[{\"children\": [1, 2]}, {}, {}]"
|
||||
);
|
||||
int[] nodeToJoint = { -1, 0, 1 };
|
||||
int[] parents = GlbParserUtils.buildParentJointIndices(
|
||||
nodes,
|
||||
nodeToJoint,
|
||||
2
|
||||
);
|
||||
assertArrayEquals(new int[] { -1, -1 }, parents);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseAnimation")
|
||||
class ParseAnimationTests {
|
||||
|
||||
/** Build a minimal in-memory GLB accessor layout: one FLOAT SCALAR, one VEC4. */
|
||||
private JsonObject parseJson(String s) {
|
||||
return JsonParser.parseString(s).getAsJsonObject();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("animation with no matching channels returns null")
|
||||
void noMatchingChannels() {
|
||||
// Channel targets node 5, but nodeToJoint only covers 0-2.
|
||||
JsonObject anim = parseJson(
|
||||
"{" +
|
||||
"\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 5, \"path\": \"rotation\"}}]," +
|
||||
"\"samplers\": [{\"input\": 0, \"output\": 1}]" +
|
||||
"}"
|
||||
);
|
||||
JsonArray accessors = parseJson(
|
||||
"{\"a\":[{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0}]}"
|
||||
).getAsJsonArray("a");
|
||||
JsonArray bufferViews = parseJson(
|
||||
"{\"b\":[{\"byteLength\":4,\"byteOffset\":0}]}"
|
||||
).getAsJsonArray("b");
|
||||
ByteBuffer bin = ByteBuffer.allocate(16).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
int[] nodeToJoint = { 0, 1, 2 };
|
||||
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
|
||||
anim,
|
||||
accessors,
|
||||
bufferViews,
|
||||
bin,
|
||||
nodeToJoint,
|
||||
3
|
||||
);
|
||||
assertNull(
|
||||
clip,
|
||||
"no channels target the skin joints — should return null"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single rotation channel produces per-joint rotations array")
|
||||
void singleRotationChannel() {
|
||||
// Layout:
|
||||
// accessor 0: 1 SCALAR timestamp float at byteOffset 0 (4 bytes)
|
||||
// accessor 1: 1 VEC4 quat floats at byteOffset 4 (16 bytes)
|
||||
// Channel: node 1 (jointIdx 1), path "rotation", sampler 0.
|
||||
JsonObject anim = parseJson(
|
||||
"{" +
|
||||
"\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"rotation\"}}]," +
|
||||
"\"samplers\": [{\"input\": 0, \"output\": 1}]" +
|
||||
"}"
|
||||
);
|
||||
JsonArray accessors = parseJson(
|
||||
"{\"a\":[" +
|
||||
"{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0,\"byteOffset\":0}," +
|
||||
"{\"count\":1,\"componentType\":5126,\"type\":\"VEC4\",\"bufferView\":1,\"byteOffset\":0}" +
|
||||
"]}"
|
||||
).getAsJsonArray("a");
|
||||
JsonArray bufferViews = parseJson(
|
||||
"{\"b\":[" +
|
||||
"{\"byteLength\":4,\"byteOffset\":0}," +
|
||||
"{\"byteLength\":16,\"byteOffset\":4}" +
|
||||
"]}"
|
||||
).getAsJsonArray("b");
|
||||
ByteBuffer bin = ByteBuffer.allocate(20).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
bin.putFloat(0, 0.0f); // timestamp 0
|
||||
bin.putFloat(4, 0.1f); // quat x
|
||||
bin.putFloat(8, 0.2f); // quat y
|
||||
bin.putFloat(12, 0.3f); // quat z
|
||||
bin.putFloat(16, 0.9f); // quat w
|
||||
int[] nodeToJoint = { -1, 1, -1 }; // only node 1 → joint 1
|
||||
|
||||
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
|
||||
anim,
|
||||
accessors,
|
||||
bufferViews,
|
||||
bin,
|
||||
nodeToJoint,
|
||||
2
|
||||
);
|
||||
assertNotNull(clip);
|
||||
assertEquals(1, clip.frameCount());
|
||||
assertArrayEquals(new float[] { 0.0f }, clip.timestamps(), 1.0e-6f);
|
||||
|
||||
Quaternionf[][] rots = clip.rotations();
|
||||
assertEquals(2, rots.length);
|
||||
assertNull(rots[0], "joint 0 has no rotation channel");
|
||||
assertNotNull(rots[1]);
|
||||
assertEquals(0.1f, rots[1][0].x, 1.0e-6f);
|
||||
assertEquals(0.9f, rots[1][0].w, 1.0e-6f);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertMeshToMinecraftSpace")
|
||||
class ConvertMeshTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("negates X and Y on positions, normals, rest translations")
|
||||
void negatesSpatialData() {
|
||||
float[] positions = { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f };
|
||||
float[] normals = { 0.1f, 0.2f, 0.3f };
|
||||
Vector3f[] restT = { new Vector3f(7.0f, 8.0f, 9.0f) };
|
||||
Quaternionf[] restR = { new Quaternionf(0.1f, 0.2f, 0.3f, 0.9f) };
|
||||
Matrix4f[] ibm = { new Matrix4f().identity() };
|
||||
|
||||
GlbParserUtils.convertMeshToMinecraftSpace(
|
||||
positions,
|
||||
normals,
|
||||
restT,
|
||||
restR,
|
||||
ibm
|
||||
);
|
||||
|
||||
assertArrayEquals(
|
||||
new float[] { -1.0f, -2.0f, 3.0f, -4.0f, -5.0f, 6.0f },
|
||||
positions,
|
||||
1.0e-6f
|
||||
);
|
||||
assertArrayEquals(
|
||||
new float[] { -0.1f, -0.2f, 0.3f },
|
||||
normals,
|
||||
1.0e-6f
|
||||
);
|
||||
assertEquals(-7.0f, restT[0].x, 1.0e-6f);
|
||||
assertEquals(-8.0f, restT[0].y, 1.0e-6f);
|
||||
assertEquals(9.0f, restT[0].z, 1.0e-6f);
|
||||
assertEquals(-0.1f, restR[0].x, 1.0e-6f);
|
||||
assertEquals(-0.2f, restR[0].y, 1.0e-6f);
|
||||
assertEquals(0.3f, restR[0].z, 1.0e-6f);
|
||||
assertEquals(0.9f, restR[0].w, 1.0e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IBM transform C·M·C inverts itself: applying twice returns original")
|
||||
void ibmDoubleInvertsToIdentity() {
|
||||
Matrix4f orig = new Matrix4f().rotateY(0.5f).translate(1f, 2f, 3f);
|
||||
Matrix4f ibm = new Matrix4f(orig);
|
||||
|
||||
GlbParserUtils.convertMeshToMinecraftSpace(
|
||||
new float[0],
|
||||
new float[0],
|
||||
new Vector3f[0],
|
||||
new Quaternionf[0],
|
||||
new Matrix4f[] { ibm }
|
||||
);
|
||||
GlbParserUtils.convertMeshToMinecraftSpace(
|
||||
new float[0],
|
||||
new float[0],
|
||||
new Vector3f[0],
|
||||
new Quaternionf[0],
|
||||
new Matrix4f[] { ibm }
|
||||
);
|
||||
|
||||
// C·C = identity, so C·(C·M·C)·C = M
|
||||
float[] origVals = new float[16];
|
||||
float[] ibmVals = new float[16];
|
||||
orig.get(origVals);
|
||||
ibm.get(ibmVals);
|
||||
assertArrayEquals(origVals, ibmVals, 1.0e-5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty arrays are a safe no-op")
|
||||
void emptyArraysSafe() {
|
||||
GlbParserUtils.convertMeshToMinecraftSpace(
|
||||
new float[0],
|
||||
new float[0],
|
||||
new Vector3f[0],
|
||||
new Quaternionf[0],
|
||||
new Matrix4f[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("clampJointIndices")
|
||||
class ClampJointIndicesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("clamps out-of-range positive indices to 0")
|
||||
void clampsPositiveOutOfRange() {
|
||||
int[] joints = { 0, 1, 999, 5 };
|
||||
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
||||
assertArrayEquals(new int[] { 0, 1, 0, 5 }, joints);
|
||||
assertEquals(1, clamped);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("clamps negative indices to 0")
|
||||
void clampsNegatives() {
|
||||
int[] joints = { 0, -5, 3, -1 };
|
||||
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
||||
assertArrayEquals(new int[] { 0, 0, 3, 0 }, joints);
|
||||
assertEquals(2, clamped);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all-valid array: no clamps, returns 0")
|
||||
void allValidNoClamp() {
|
||||
int[] joints = { 0, 1, 2, 3 };
|
||||
int[] orig = joints.clone();
|
||||
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
||||
assertArrayEquals(orig, joints);
|
||||
assertEquals(0, clamped);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null array is a safe no-op, returns 0")
|
||||
void nullArrayIsSafe() {
|
||||
int clamped = GlbParserUtils.clampJointIndices(null, 10);
|
||||
assertEquals(0, clamped);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("jointCount boundary: index == jointCount is out-of-range")
|
||||
void boundaryEqualIsOutOfRange() {
|
||||
int[] joints = { 0, 9, 10 }; // 10 is OOB when jointCount=10
|
||||
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
||||
assertArrayEquals(new int[] { 0, 9, 0 }, joints);
|
||||
assertEquals(1, clamped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GltfPoseConverter#computeEndTick}.
|
||||
*
|
||||
* <p>This is the public entry point that controls how many ticks a GLB animation
|
||||
* occupies on the PlayerAnimator timeline. The P0-01 fix replaced a hardcoded
|
||||
* {@code endTick = 1} (which silently froze every animation to frame 0) with a
|
||||
* timestamp-derived computation. These tests pin down that behavior so a future
|
||||
* refactor can't silently re-introduce the bug.</p>
|
||||
*
|
||||
* <p>Full end-to-end tests (keyframe emission into a {@code KeyframeAnimation}
|
||||
* builder, multi-item composite, selective-part enabling) require instantiating
|
||||
* a complete {@code GltfData} and the PlayerAnimator library — deferred to a
|
||||
* GameTest or a later harness upgrade.</p>
|
||||
*/
|
||||
class GltfPoseConverterTest {
|
||||
|
||||
private static final int TICKS_PER_SECOND = 20;
|
||||
|
||||
private static GltfData.AnimationClip clip(float... timestamps) {
|
||||
int n = timestamps.length;
|
||||
Quaternionf[][] rotations = new Quaternionf[1][n];
|
||||
Vector3f[][] translations = new Vector3f[1][n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
rotations[0][i] = new Quaternionf(); // identity
|
||||
translations[0][i] = new Vector3f();
|
||||
}
|
||||
return new GltfData.AnimationClip(timestamps, rotations, translations, n);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("computeEndTick — timestamp → tick conversion")
|
||||
class ComputeEndTickTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null clip returns 1 (minimum valid endTick)")
|
||||
void nullClipReturnsOne() {
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero-frame clip returns 1")
|
||||
void zeroFrameReturnsOne() {
|
||||
GltfData.AnimationClip c = new GltfData.AnimationClip(
|
||||
new float[0],
|
||||
new Quaternionf[0][],
|
||||
null,
|
||||
0
|
||||
);
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(c));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-frame clip at t=0 returns 1")
|
||||
void singleFrameAtZero() {
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(clip(0.0f)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3 frames at 0/0.5/1.0s returns 20 ticks")
|
||||
void threeFramesOneSecondSpan() {
|
||||
// Last timestamp 1.0s → 1.0 * 20 = 20 ticks.
|
||||
assertEquals(20, GltfPoseConverter.computeEndTick(clip(0f, 0.5f, 1.0f)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("baseline normalization: timestamps 0.5/1.0/1.5s → endTick 20 (span 1s)")
|
||||
void baselineNormalizesToSpan() {
|
||||
// If baseline wasn't subtracted, this would return round(1.5*20)=30.
|
||||
// With baseline=0.5, endTick = round((1.5-0.5)*20) = 20.
|
||||
assertEquals(
|
||||
20,
|
||||
GltfPoseConverter.computeEndTick(clip(0.5f, 1.0f, 1.5f))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-frame clip at t=3.0 still returns 1 (baseline collapses to zero)")
|
||||
void singleFrameAtLargeTime() {
|
||||
// times[0] = 3.0, baseline = 3.0, (3.0 - 3.0) * 20 = 0, clamped to 1.
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(clip(3.0f)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("long clip: 2.5s span returns 50 ticks")
|
||||
void longClipTwoAndHalfSeconds() {
|
||||
GltfData.AnimationClip c = clip(0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f);
|
||||
assertEquals(50, GltfPoseConverter.computeEndTick(c));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rounding: last timestamp 0.025s (< 0.5 tick) still yields at least 1 tick")
|
||||
void subTickTimestampClampsToOne() {
|
||||
// round(0.025 * 20) = round(0.5) = 0 or 1 depending on rounding mode.
|
||||
// Math.round(float) returns (int)Math.floor(x+0.5) = 1 here.
|
||||
// But even if it were 0, the Math.max(1, ...) clamp should protect us.
|
||||
int endTick = GltfPoseConverter.computeEndTick(clip(0f, 0.025f));
|
||||
assertEquals(
|
||||
1,
|
||||
endTick,
|
||||
"Very short spans must collapse to endTick=1, not 0 (would break PlayerAnimator)"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("high-FPS source preserves total span: 60 frames over 2s → 40 ticks")
|
||||
void highFpsPreservesSpan() {
|
||||
// 60 evenly-spaced frames from 0.0 to 2.0s.
|
||||
float[] times = new float[60];
|
||||
for (int i = 0; i < 60; i++) {
|
||||
times[i] = i * (2.0f / 59.0f);
|
||||
}
|
||||
GltfData.AnimationClip c = clip(times);
|
||||
assertEquals(
|
||||
Math.round(2.0f * TICKS_PER_SECOND),
|
||||
GltfPoseConverter.computeEndTick(c)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("timestamps array longer than frameCount: uses min(len-1, frameCount-1)")
|
||||
void timestampsLongerThanFrameCount() {
|
||||
// frameCount=3 means only the first 3 timestamps matter even if array is longer.
|
||||
Quaternionf[][] rotations = new Quaternionf[1][3];
|
||||
rotations[0][0] = new Quaternionf();
|
||||
rotations[0][1] = new Quaternionf();
|
||||
rotations[0][2] = new Quaternionf();
|
||||
GltfData.AnimationClip c = new GltfData.AnimationClip(
|
||||
new float[] { 0f, 0.5f, 1.0f, 99.0f, 99.0f }, // 5 timestamps
|
||||
rotations,
|
||||
null,
|
||||
3 // but only 3 active frames
|
||||
);
|
||||
// Last valid index = min(5-1, 3-1) = 2 → times[2] = 1.0 → 20 ticks.
|
||||
assertEquals(20, GltfPoseConverter.computeEndTick(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.tiedup.remake.v2.furniture;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Tests for the pure-logic authorization core in {@link FurnitureAuthPredicate}.
|
||||
*
|
||||
* <p>These tests pin down the truth tables for the lock and force-mount gates
|
||||
* without touching any Minecraft API. The MC-aware wrappers ({@code canLockUnlock},
|
||||
* {@code canForceMount}) extract booleans from live entities and delegate here,
|
||||
* so full-stack validation is manual (in-game).</p>
|
||||
*
|
||||
* <p>The 2026-04-17 audit documented three divergent auth paths (BUG-002, BUG-003)
|
||||
* that this predicate now unifies. If anyone re-introduces the drift, the
|
||||
* isAuthorizedForLock / isAuthorizedForForceMount methods will catch it here.</p>
|
||||
*/
|
||||
class FurnitureAuthPredicateTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("isAuthorizedForLock — AND of 4 gates")
|
||||
class LockAuthorization {
|
||||
|
||||
@Test
|
||||
@DisplayName("all four gates true → authorized")
|
||||
void allTrue() {
|
||||
assertTrue(
|
||||
FurnitureAuthPredicate.isAuthorizedForLock(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing master key → denied")
|
||||
void missingKey() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForLock(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("seat not lockable → denied")
|
||||
void seatNotLockable() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForLock(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("seat not occupied → denied")
|
||||
void seatNotOccupied() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForLock(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("occupant has no collar (or sender not owner) → denied")
|
||||
void noOwnedCollar() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForLock(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all gates false → denied")
|
||||
void allFalse() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForLock(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isAuthorizedForForceMount — AND of 5 gates")
|
||||
class ForceMountAuthorization {
|
||||
|
||||
@Test
|
||||
@DisplayName("all five gates true → authorized")
|
||||
void allTrue() {
|
||||
assertTrue(
|
||||
FurnitureAuthPredicate.isAuthorizedForForceMount(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captive not alive → denied")
|
||||
void captiveDead() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForForceMount(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captive out of range → denied")
|
||||
void captiveOutOfRange() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForForceMount(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no owned collar → denied")
|
||||
void noOwnedCollar() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForForceMount(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captive not tied up (leashed) → denied (audit BUG-003)")
|
||||
void notTiedUp() {
|
||||
// This is the exact regression guard for audit BUG-003: before the
|
||||
// fix, the packet path did NOT check isTiedUp, so any collar-owning
|
||||
// captor could force-mount a captive that had never been leashed.
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForForceMount(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captive already passenger of another entity → denied (audit BUG-003)")
|
||||
void alreadyPassenger() {
|
||||
// BUG-003 regression guard: before the fix, the packet path used
|
||||
// startRiding(force=true) which would break prior mounts silently.
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForForceMount(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all gates false → denied")
|
||||
void allFalse() {
|
||||
assertFalse(
|
||||
FurnitureAuthPredicate.isAuthorizedForForceMount(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.tiedup.remake.v2.furniture;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Tests for the server-side fallback seat-geometry math extracted from the
|
||||
* three inlined {@code EntityFurniture} call sites.
|
||||
*
|
||||
* <p>The formulas themselves are trivial; these tests are a regression guard
|
||||
* against someone "cleaning up" the right-axis expression and inverting a
|
||||
* sign, or changing the seat-spacing convention (which is observable in
|
||||
* gameplay — passengers shift left/right on asymmetric furniture).</p>
|
||||
*/
|
||||
class FurnitureSeatGeometryTest {
|
||||
|
||||
private static final double EPS = 1e-9;
|
||||
|
||||
@Nested
|
||||
@DisplayName("rightAxis — entity-local right in world XZ")
|
||||
class RightAxis {
|
||||
|
||||
@Test
|
||||
@DisplayName("yaw=0 → right axis points to world -X (MC south-facing convention)")
|
||||
void yawZero() {
|
||||
Vec3 r = FurnitureSeatGeometry.rightAxis(0f);
|
||||
assertEquals(-1.0, r.x, EPS);
|
||||
assertEquals(0.0, r.y, EPS);
|
||||
assertEquals(0.0, r.z, EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("yaw=180 → right axis flips to +X")
|
||||
void yaw180() {
|
||||
Vec3 r = FurnitureSeatGeometry.rightAxis(180f);
|
||||
assertEquals(1.0, r.x, EPS);
|
||||
assertEquals(0.0, r.z, EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("yaw=90 → right axis points to world -Z")
|
||||
void yaw90() {
|
||||
Vec3 r = FurnitureSeatGeometry.rightAxis(90f);
|
||||
assertEquals(0.0, r.x, EPS);
|
||||
assertEquals(-1.0, r.z, EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Y is always 0 (right axis is horizontal)")
|
||||
void yIsAlwaysZero() {
|
||||
for (float yaw = -360f; yaw <= 360f; yaw += 37f) {
|
||||
assertEquals(0.0, FurnitureSeatGeometry.rightAxis(yaw).y, EPS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("seatOffset — even spacing around centre")
|
||||
class SeatOffset {
|
||||
|
||||
@Test
|
||||
@DisplayName("1 seat → always 0 (centered)")
|
||||
void singleSeat() {
|
||||
assertEquals(0.0, FurnitureSeatGeometry.seatOffset(0, 1), EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2 seats → -0.5, 0.5")
|
||||
void twoSeats() {
|
||||
assertEquals(-0.5, FurnitureSeatGeometry.seatOffset(0, 2), EPS);
|
||||
assertEquals(0.5, FurnitureSeatGeometry.seatOffset(1, 2), EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3 seats → -1, 0, 1")
|
||||
void threeSeats() {
|
||||
assertEquals(-1.0, FurnitureSeatGeometry.seatOffset(0, 3), EPS);
|
||||
assertEquals(0.0, FurnitureSeatGeometry.seatOffset(1, 3), EPS);
|
||||
assertEquals(1.0, FurnitureSeatGeometry.seatOffset(2, 3), EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4 seats → -1.5, -0.5, 0.5, 1.5")
|
||||
void fourSeats() {
|
||||
assertEquals(-1.5, FurnitureSeatGeometry.seatOffset(0, 4), EPS);
|
||||
assertEquals(-0.5, FurnitureSeatGeometry.seatOffset(1, 4), EPS);
|
||||
assertEquals(0.5, FurnitureSeatGeometry.seatOffset(2, 4), EPS);
|
||||
assertEquals(1.5, FurnitureSeatGeometry.seatOffset(3, 4), EPS);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user