diff --git a/src/gid.js b/src/gid.js new file mode 100644 index 0000000..2fc57b2 --- /dev/null +++ b/src/gid.js @@ -0,0 +1,30 @@ +'use strict'; + +const MAX_ATLAS = 255; +const MAX_TILE = 1048575; +const MAX_ROT = 3; +const MAX_U32 = 0xFFFFFFFF; + +function checkInt(name, value, min, max) { + if (!Number.isInteger(value) || value < min || value > max) { + throw new RangeError(`${name} ${value} out of range [${min},${max}]`); + } +} + +function encodeGid(atlas, tile, rotation = 0) { + checkInt('atlas', atlas, 0, MAX_ATLAS); + checkInt('tile', tile, 0, MAX_TILE); + checkInt('rotation', rotation, 0, MAX_ROT); + return ((atlas << 24) | (tile << 4) | (rotation << 2)) >>> 0; +} + +function decodeGid(gid) { + checkInt('gid', gid, 0, MAX_U32); + return { + atlas: (gid >>> 24) & 0xFF, + tile: (gid >>> 4) & 0xFFFFF, + rotation: (gid >>> 2) & 0x3, + }; +} + +module.exports = { encodeGid, decodeGid, MAX_ATLAS, MAX_TILE, MAX_ROT }; diff --git a/tests/gid.test.js b/tests/gid.test.js new file mode 100644 index 0000000..4e0a4f8 --- /dev/null +++ b/tests/gid.test.js @@ -0,0 +1,83 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { encodeGid, decodeGid } = require('../src/gid'); + +test('encodeGid: known reference value (0,12,1) = 196', () => { + assert.equal(encodeGid(0, 12, 1), 196); +}); + +test('encodeGid: default rotation is 0', () => { + assert.equal(encodeGid(0, 12), 192); +}); + +test('encodeGid: zero tuple yields 0', () => { + assert.equal(encodeGid(0, 0, 0), 0); +}); + +test('encodeGid: high atlas (255) produces unsigned u32', () => { + // 255 << 24 = 0xFF000000 = 4278190080 (must NOT be negative) + assert.equal(encodeGid(255, 0, 0), 4278190080); +}); + +test('encodeGid: max tile_id (1048575) fits 20 bits', () => { + // tile << 4 = 0xFFFFF0 = 16777200 + assert.equal(encodeGid(0, 1048575, 0), 16777200); +}); + +test('encodeGid: throws on out-of-range atlas', () => { + assert.throws(() => encodeGid(256, 0, 0), /atlas/); + assert.throws(() => encodeGid(-1, 0, 0), /atlas/); +}); + +test('encodeGid: throws on out-of-range tile', () => { + assert.throws(() => encodeGid(0, 1048576, 0), /tile/); + assert.throws(() => encodeGid(0, -1, 0), /tile/); +}); + +test('encodeGid: throws on out-of-range rotation', () => { + assert.throws(() => encodeGid(0, 0, 4), /rotation/); + assert.throws(() => encodeGid(0, 0, -1), /rotation/); +}); + +test('encodeGid: throws on non-integer inputs', () => { + assert.throws(() => encodeGid(1.5, 0, 0), /atlas/); + assert.throws(() => encodeGid(0, '12', 0), /tile/); +}); + +test('decodeGid: empty (0) returns zeros', () => { + assert.deepEqual(decodeGid(0), { atlas: 0, tile: 0, rotation: 0 }); +}); + +test('decodeGid: 196 unpacks to (0,12,1)', () => { + assert.deepEqual(decodeGid(196), { atlas: 0, tile: 12, rotation: 1 }); +}); + +test('decodeGid: 16777232 from real demo map unpacks to (1,1,0)', () => { + // demo_v2.map.json wall layer uses 16777232 = (1<<24)|(1<<4) + assert.deepEqual(decodeGid(16777232), { atlas: 1, tile: 1, rotation: 0 }); +}); + +test('decodeGid: high atlas (255,0,0) round-trip', () => { + assert.deepEqual(decodeGid(4278190080), { atlas: 255, tile: 0, rotation: 0 }); +}); + +test('decodeGid: throws on non-integer or negative', () => { + assert.throws(() => decodeGid(-1), /gid/); + assert.throws(() => decodeGid(1.5), /gid/); + assert.throws(() => decodeGid(0x100000000), /gid/); // > u32 max +}); + +test('round-trip: 100 random tuples encode→decode losslessly', () => { + for (let i = 0; i < 100; i++) { + const atlas = Math.floor(Math.random() * 256); + const tile = Math.floor(Math.random() * 1048576); + const rotation = Math.floor(Math.random() * 4); + const gid = encodeGid(atlas, tile, rotation); + const decoded = decodeGid(gid); + assert.equal(decoded.atlas, atlas, `atlas mismatch for ${gid}`); + assert.equal(decoded.tile, tile, `tile mismatch for ${gid}`); + assert.equal(decoded.rotation, rotation, `rotation mismatch for ${gid}`); + } +});