refactor!: schema

This commit is contained in:
cha0s 2024-06-12 01:38:05 -05:00
parent 815d53d1e8
commit 405c2b3c6f
17 changed files with 393 additions and 209 deletions

View File

@ -0,0 +1,3 @@
export default {
frame: {type: 'uint16'},
};

View File

@ -1,4 +1,4 @@
export default {
x: 'uint16',
y: 'uint16',
x: {type: 'uint16'},
y: {type: 'uint16'},
}

View File

@ -1,4 +1,4 @@
export default {
x: 'uint16',
y: 'uint16',
x: {type: 'uint16'},
y: {type: 'uint16'},
}

View File

@ -1,6 +1,6 @@
export default {
up: 'float32',
right: 'float32',
down: 'float32',
left: 'float32',
up: {type: 'float32'},
right: {type: 'float32'},
down: {type: 'float32'},
left: {type: 'float32'},
};

View File

@ -1,4 +1,4 @@
export default {
x: 'float32',
y: 'float32',
x: {type: 'float32'},
y: {type: 'float32'},
}

View File

@ -1,4 +1,4 @@
export default {
x: 'float32',
y: 'float32',
x: {type: 'float32'},
y: {type: 'float32'},
};

View File

@ -1,3 +1,3 @@
export default {
image: 'string',
image: {type: 'string'},
};

View File

@ -1,6 +1,6 @@
export default {
x0: 'float32',
x1: 'float32',
y0: 'float32',
y1: 'float32',
x0: {type: 'float32'},
x1: {type: 'float32'},
y0: {type: 'float32'},
y1: {type: 'float32'},
}

View File

@ -1,3 +1,3 @@
export default {
world: 'uint16',
world: {type: 'uint16'},
}

View File

@ -22,7 +22,7 @@ export default class Arbitrary extends Base {
createMany(entries) {
if (entries.length > 0) {
const allocated = this.allocateMany(entries.length);
const keys = Object.keys(this.constructor.schema.specification);
const keys = Object.keys(this.constructor.properties);
for (let i = 0; i < entries.length; ++i) {
const [entityId, values = {}] = entries[i];
this.map[entityId] = allocated[i];
@ -32,7 +32,7 @@ export default class Arbitrary extends Base {
}
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
const {defaultValue} = this.constructor.schema.specification[j];
const {defaultValue} = this.constructor.properties[j];
if (j in values) {
this.data[allocated[i]][j] = values[j];
}
@ -45,7 +45,12 @@ export default class Arbitrary extends Base {
}
deserialize(entityId, view, offset) {
this.constructor.schema.deserialize(view, this.get(entityId), offset);
const {properties} = this.constructor;
const instance = this.get(entityId);
const deserialized = this.constructor.schema.deserialize(view, offset);
for (const key in properties) {
instance[key] = deserialized[key];
}
}
serialize(entityId, view, offset) {
@ -64,8 +69,10 @@ export default class Arbitrary extends Base {
this.$$reset();
}
$$reset() {
for (const [i, {defaultValue}] of Component.constructor.schema) {
this[`$$${i}`] = defaultValue;
const {properties} = Component.constructor;
for (const key in properties) {
const {defaultValue} = properties[key];
this[`$$${key}`] = defaultValue;
}
}
toJSON() {
@ -82,15 +89,15 @@ export default class Arbitrary extends Base {
this.$$reset();
},
};
for (const [i] of Component.constructor.schema) {
properties[i] = {
for (const key in Component.constructor.properties) {
properties[key] = {
get: function get() {
return this[`$$${i}`];
return this[`$$${key}`];
},
set: function set(v) {
if (this[`$$${i}`] !== v) {
this[`$$${i}`] = v;
Component.markChange(this.entity, i, v);
set: function set(value) {
if (this[`$$${key}`] !== value) {
this[`$$${key}`] = value;
Component.markChange(this.entity, key, value);
}
},
};

View File

@ -5,17 +5,23 @@ import Arbitrary from './arbitrary.js';
test('creates instances', () => {
class CreatingArbitrary extends Arbitrary {
static schema = new Schema({foo: {defaultValue: 'bar', type: 'string'}});
static schema = new Schema({
type: 'object',
properties: {foo: {defaultValue: 'bar', type: 'string'}},
});
}
const Component = new CreatingArbitrary();
Component.create(1)
Component.create(1);
expect(Component.get(1).entity)
.to.equal(1);
});
test('does not serialize default values', () => {
class CreatingArbitrary extends Arbitrary {
static schema = new Schema({foo: {defaultValue: 'bar', type: 'string'}, bar: 'uint8'});
static schema = new Schema({
type: 'object',
properties: {foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'}},
});
}
const Component = new CreatingArbitrary();
Component.create(1)
@ -28,7 +34,10 @@ test('does not serialize default values', () => {
test('reuses instances', () => {
class ReusingArbitrary extends Arbitrary {
static schema = new Schema({foo: {type: 'string'}});
static schema = new Schema({
type: 'object',
properties: {foo: {type: 'string'}},
});
}
const Component = new ReusingArbitrary();
Component.create(1);

View File

@ -39,9 +39,10 @@ export default class Base {
static filterDefaults(instance) {
const json = {};
for (const [i, {defaultValue}] of this.schema) {
if (i in instance && instance[i] !== defaultValue) {
json[i] = instance[i];
for (const key in this.properties) {
const {defaultValue} = this.properties[key];
if (key in instance && instance[key] !== defaultValue) {
json[key] = instance[key];
}
}
return json;
@ -77,6 +78,10 @@ export default class Base {
return {...original, ...update};
}
static get properties() {
return this.schema.specification.properties;
}
sizeOf(entityId) {
return this.constructor.schema.sizeOf(this.get(entityId));
}

View File

@ -9,6 +9,6 @@ export default function Component(specificationOrClass) {
// Why the rigamarole? Maybe we'll implement a flat component for direct binary storage
// eventually.
return class AdhocComponent extends Arbitrary {
static schema = new Schema(specificationOrClass);
static schema = new Schema({type: 'object', properties: specificationOrClass});
};
}

View File

@ -6,13 +6,13 @@ import System from './system.js';
const Empty = {};
const Name = {
name: 'string',
name: {type: 'string'},
};
const Position = {
x: {type: 'int32', defaultValue: 32},
y: 'int32',
z: 'int32',
y: {type: 'int32'},
z: {type: 'int32'},
};
test('adds and remove systems at runtime', () => {
@ -109,9 +109,9 @@ test('inserts components into entities', () => {
test('ticks systems', () => {
const Momentum = {
x: 'int32',
y: 'int32',
z: 'int32',
x: {type: 'int32'},
y: {type: 'int32'},
z: {type: 'int32'},
};
class TickEcs extends Ecs {
static Types = {Momentum, Position};
@ -203,7 +203,7 @@ test('schedules entities to be deleted when ticking systems', () => {
test('adds components to and remove components from entities when ticking systems', () => {
class TickingEcs extends Ecs {
static Types = {Foo: {bar: 'uint8'}};
static Types = {Foo: {bar: {type: 'uint8'}}};
}
const ecs = new TickingEcs();
let addLength, removeLength;

View File

@ -5,7 +5,7 @@ import Query from './query.js';
const A = new (Component({a: {type: 'int32', defaultValue: 420}}));
const B = new (Component({b: {type: 'int32', defaultValue: 69}}));
const C = new (Component({c: 'int32'}));
const C = new (Component({c: {type: 'int32'}}));
const Types = {A, B, C};
Types.A.createMany([[2], [3]]);

View File

@ -34,51 +34,54 @@ export default class Schema {
constructor(specification) {
this.specification = this.constructor.normalize(specification);
// Try to calculate static size.
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
this.$$size = 0;
break;
}
this.$$size += size;
}
static deserialize(view, offset, specification) {
const viewGetMethod = this.viewGetMethods[specification.type];
if (viewGetMethod) {
const value = view[viewGetMethod](offset.value, true);
offset.value += this.size(specification);
return value;
}
}
[Symbol.iterator]() {
return Object.entries(this.specification).values();
}
deserialize(destination, view, offset = 0) {
let cursor = offset;
for (const key in this.specification) {
const {type} = this.specification[key];
const viewGetMethod = Schema.viewGetMethods[type];
let value;
if (viewGetMethod) {
value = view[viewGetMethod](cursor, true);
cursor += Schema.sizeOfType(type);
}
else {
switch (type) {
case 'string': {
const length = view.getUint32(cursor, true);
cursor += 4;
const {buffer, byteOffset} = view;
const decoder = new TextDecoder();
value = decoder.decode(new DataView(buffer, byteOffset + cursor, length));
cursor += length;
break;
}
switch (specification.type) {
case 'array': {
const length = view.getUint32(offset.value, true);
offset.value += 4;
const value = [];
for (let i = 0; i < length; ++i) {
value.push(this.deserialize(view, offset, specification.subtype));
}
return value;
}
case 'object': {
const value = {};
for (const key in specification.properties) {
value[key] = this.deserialize(view, offset, specification.properties[key]);
}
return value;
}
case 'string': {
const length = view.getUint32(offset.value, true);
offset.value += 4;
const {buffer, byteOffset} = view;
const decoder = new TextDecoder();
const value = decoder.decode(new DataView(buffer, byteOffset + offset.value, length));
offset.value += length;
return value;
}
destination[key] = value;
}
}
static defaultValueForType(type) {
switch (type) {
deserialize(view, offset = 0) {
const wrapped = {value: offset};
return this.constructor.deserialize(view, wrapped, this.specification);
}
static defaultValue(specification) {
if (specification.defaultValue) {
return specification.defaultValue;
}
switch (specification.type) {
case 'uint8': case 'int8':
case 'uint16': case 'int16':
case 'uint32': case 'int32':
@ -86,107 +89,194 @@ export default class Schema {
case 'float32': case 'float64': {
return 0;
}
case 'array': {
return [];
}
case 'object': {
const object = {};
for (const key in specification.properties) {
object[key] = this.defaultValue(specification.properties[key]);
}
return object;
}
case 'string': {
return '';
}
}
}
defaultValue() {
return this.constructor.defaultValue(this.specification);
}
has(key) {
return key in this.specification;
}
static normalize(specification) {
const normalized = Object.create(null);
for (const i in specification) {
normalized[i] = 'string' === typeof specification[i]
? {type: specification[i]}
: specification[i];
if (!this.validateType(normalized[i].type)) {
throw new TypeError(`unknown schema type: ${normalized[i].type}`);
let normalized = specification;
switch (specification.type) {
case 'array': {
normalized.subtype = this.normalize(specification.subtype);
break;
}
normalized[i].defaultValue = normalized[i].defaultValue || this.defaultValueForType(normalized[i].type);
case 'object': {
for (const key in specification.properties) {
normalized.properties[key] = this.normalize(specification.properties[key])
}
break;
}
case 'uint8':
case 'int8':
case 'uint16':
case 'int16':
case 'uint32':
case 'int32':
case 'uint64':
case 'int64':
case 'float32':
case 'float64':
case 'string': {
break;
}
default:
throw new TypeError(`invalid specification: ${JSON.stringify(specification)}`);
}
return normalized;
return {...normalized, defaultValue: this.defaultValue(normalized)};
}
readSize(view, cursor) {
let fullSize = 0;
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
switch (type) {
case 'string': {
const length = view.getUint32(cursor, true);
cursor += 4 + length;
fullSize += 4;
fullSize += length;
break;
}
static readSize(view, offset, specification) {
const size = this.size(specification);
if (size > 0) {
return size;
}
switch (specification.type) {
case 'array': {
const length = view.getUint32(offset.value, true);
offset.value += 4;
let arraySize = 0;
for (let i = 0; i < length; ++i) {
arraySize += this.readSize(view, offset, specification.subtype);
}
return 4 + arraySize;
}
else {
cursor += size;
fullSize += size;
case 'object': {
let objectSize = 0;
for (const key in specification.properties) {
objectSize += this.readSize(view, offset, specification.properties[key]);
}
return objectSize;
}
case 'string': {
const length = view.getUint32(offset.value, true);
offset.value += 4 + length;
return 4 + length;
}
}
}
readSize(view, offset) {
const wrapped = {value: offset};
return this.constructor.readSize(view, wrapped, this.specification);
}
static serialize(source, view, offset, specification) {
const viewSetMethod = this.viewSetMethods[specification.type];
if (viewSetMethod) {
view[viewSetMethod](offset, source, true);
return this.size(specification);
}
switch (specification.type) {
case 'array': {
view.setUint32(offset, source.length, true);
offset += 4;
let arraySize = 0;
for (const element of source) {
arraySize += this.serialize(
element,
view,
offset + arraySize,
specification.subtype,
);
}
return 4 + arraySize;
}
case 'object': {
let objectSize = 0;
for (const key in specification.properties) {
objectSize += this.serialize(
source[key],
view,
offset + objectSize,
specification.properties[key],
);
}
return objectSize;
}
case 'string': {
const encoder = new TextEncoder();
const bytes = encoder.encode(source);
view.setUint32(offset, bytes.length, true);
offset += 4;
for (let i = 0; i < bytes.length; ++i) {
view.setUint8(offset++, bytes[i]);
}
return 4 + bytes.length;
}
}
return fullSize;
}
serialize(source, view, offset = 0) {
let cursor = offset;
for (const key in this.specification) {
const {type} = this.specification[key];
const viewSetMethod = Schema.viewSetMethods[type];
if (viewSetMethod) {
view[viewSetMethod](cursor, source[key], true);
cursor += Schema.sizeOfType(type);
}
else {
switch (type) {
case 'string': {
const lengthOffset = cursor;
cursor += 4;
const encoder = new TextEncoder();
const bytes = encoder.encode(source[key]);
for (let i = 0; i < bytes.length; ++i) {
view.setUint8(cursor++, bytes[i]);
}
view.setUint32(lengthOffset, bytes.length, true);
break;
}
}
}
this.constructor.serialize(source, view, offset, this.specification);
}
static sizeOf(concrete, specification) {
const size = this.size(specification);
if (size > 0) {
return size;
}
}
get size() {
return this.$$size;
}
sizeOf(concrete) {
let fullSize = 0;
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
switch (type) {
case 'string':
fullSize += 4;
fullSize += (encoder.encode(concrete[i])).length;
break;
const {type} = specification;
switch (type) {
case 'array': {
fullSize += 4;
for (const element of concrete) {
fullSize += this.sizeOf(element, specification.subtype);
}
break;
}
else {
fullSize += size;
case 'object': {
for (const key in specification.properties) {
fullSize += this.sizeOf(concrete[key], specification.properties[key]);
}
break;
}
case 'string':
fullSize += 4;
fullSize += (encoder.encode(concrete)).length;
break;
}
return fullSize;
}
static sizeOfType(type) {
switch (type) {
sizeOf(concrete) {
return this.constructor.sizeOf(concrete, this.specification);
}
static size(specification) {
switch (specification.type) {
case 'array': return 0;
case 'object': {
let size = 0;
for (const key in specification.properties) {
const propertySize = this.size(specification.properties[key]);
if (0 === propertySize) {
return 0;
}
size += propertySize;
}
return size;
}
case 'uint8': case 'int8': {
return 1;
}
@ -203,16 +293,8 @@ export default class Schema {
}
}
static validateType(type) {
return [
'uint8', 'int8',
'uint16', 'int16',
'uint32', 'int32',
'uint64', 'int64',
'float32', 'float64',
'string',
]
.includes(type);
size() {
return this.constructor.size(this.specification);
}
}

View File

@ -2,57 +2,135 @@ import {expect, test} from 'vitest';
import Schema from './schema.js';
test('validates a schema', () => {
expect(() => {
new Schema({test: 'unknown'})
})
.to.throw();
expect(() => {
new Schema({test: 'unknown'})
})
.to.throw();
test('defaults values', () => {
[
'uint8',
'int8',
'uint16',
'int16',
'uint32',
'int32',
'uint64',
'int64',
'float32',
'float64',
].forEach((type) => {
expect(Schema.defaultValue({type}))
.to.equal(0);
expect(new Schema({type}).specification.defaultValue)
.to.equal(0);
});
expect(Schema.defaultValue({type: 'string'}))
.to.equal('');
expect(new Schema({type: 'string'}).specification.defaultValue)
.to.equal('');
expect(Schema.defaultValue({type: 'array', subtype: {type: 'string'}}))
.to.deep.equal([]);
expect(new Schema({type: 'array', subtype: {type: 'string'}}).specification.defaultValue)
.to.deep.equal([]);
expect(Schema.defaultValue({
type: 'object',
properties: {
foo: {type: 'uint8'},
bar: {type: 'string'},
baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}},
},
}))
.to.deep.equal({foo: 0, bar: '', baz: {blah: []}});
expect(new Schema({
type: 'object',
properties: {
foo: {type: 'uint8'},
bar: {type: 'string'},
baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}},
},
}).specification.defaultValue)
.to.deep.equal({foo: 0, bar: '', baz: {blah: []}});
});
test('calculates the size of an instance', () => {
test('validates a schema', () => {
[
'uint8',
'int8',
'uint16',
'int16',
'uint32',
'int32',
'uint64',
'int64',
'float32',
'float64',
'string',
].forEach((type) => {
expect(() => {
new Schema({type});
new Schema({type: 'array', subtype: {type}});
new Schema({type: 'object', properties: {foo: {type}}});
})
.to.not.throw();
});
});
test('calculates the size of concrete instances', () => {
expect(
(new Schema({foo: 'uint8', bar: 'uint32'}))
.sizeOf({foo: 69, bar: 420})
)
.to.equal(5);
expect(
(new Schema({foo: 'string'}))
.sizeOf({foo: 'hi'})
(new Schema({type: 'string'}))
.sizeOf('hi')
)
.to.equal(4 + (new TextEncoder().encode('hi')).length);
expect(
(new Schema(
{type: 'object', properties: {
foo: {type: 'uint8'},
bar: {type: 'uint32'},
baz: {type: 'string'},
}}
))
.sizeOf({foo: 69, bar: 420, baz: 'aα'})
)
.to.equal(
1
+ 4
+ 4 + (new TextEncoder().encode('aα')).length
);
expect(
(new Schema({type: 'array', subtype: {type: 'string'}}))
.sizeOf(['hallo', 'hαllo'])
)
.to.equal(
4
+ 4 + (new TextEncoder().encode('hallo')).length
+ 4 + (new TextEncoder().encode('hαllo')).length
);
});
test('can encode and decode', () => {
test('encodes and decodes', () => {
const entries = [
['uint8', 255],
['int8', -128],
['int8', 127],
['uint16', 65535],
['int16', -32768],
['int16', 32767],
['uint32', 4294967295],
['int32', -2147483648],
['int32', 2147483647],
['uint64', 18446744073709551615n],
['int64', -9223372036854775808n],
['int64', 9223372036854775807n],
['float32', 0.5],
['float64', 1.234],
['string', 'hello world'],
['string', 'α'],
[{type: 'uint8'}, 255],
[{type: 'int8'}, -128],
[{type: 'int8'}, 127],
[{type: 'uint16'}, 65535],
[{type: 'int16'}, -32768],
[{type: 'int16'}, 32767],
[{type: 'uint32'}, 4294967295],
[{type: 'int32'}, -2147483648],
[{type: 'int32'}, 2147483647],
[{type: 'uint64'}, 18446744073709551615n],
[{type: 'int64'}, -9223372036854775808n],
[{type: 'int64'}, 9223372036854775807n],
[{type: 'float32'}, 0.5],
[{type: 'float64'}, 1.234],
[{type: 'string'}, 'hello world'],
[{type: 'string'}, 'α'],
[{type: 'array', subtype: {type: 'uint8'}}, [1, 2, 3, 4]],
[{type: 'array', subtype: {type: 'string'}}, ['one', 'two', 'three', 'four']],
[{type: 'object', properties: {foo: {type: 'uint8'}, bar: {type: 'string'}}}, {foo: 64, bar: 'baz'}],
];
const specification = entries.reduce((r, [type]) => ({...r, [Object.keys(r).length]: type}), {});
const data = entries.reduce((r, [, value]) => ({...r, [Object.keys(r).length]: value}), {});
const schema = new Schema(specification);
const view = new DataView(new ArrayBuffer(schema.sizeOf(data)));
schema.serialize(data, view);
const result = {};
schema.deserialize(result, view);
expect(data)
.to.deep.equal(result);
entries.forEach(([specification, concrete]) => {
const schema = new Schema(specification);
const view = new DataView(new ArrayBuffer(schema.sizeOf(concrete)));
schema.serialize(concrete, view);
expect(concrete)
.to.deep.equal(schema.deserialize(view));
});
});