Compare commits

...

17 Commits

20 changed files with 694 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
pnpm-lock.yaml
.task
node_modules/
.pls_cache/

45
Taskfile.yml Normal file
View File

@ -0,0 +1,45 @@
# https://taskfile.dev
version: "3"
tasks:
schemas:
sources: [ 'schemas-yaml/*' ]
generates: [ 'schemas-json/*' ]
cmds:
- fd -e yml -p ./schemas-yaml -x task schema SCHEMA='{}'
schema:
vars:
TRANSFORM:
sh: |
echo {{.SCHEMA}} | \
perl -lnE's/yml$/pl/; s/^/.\//; say if -f $_'
DEST:
sh: echo {{.SCHEMA}} | perl -pe's/ya?ml/json/g'
cmds:
- transerialize {{.SCHEMA}} {{.TRANSFORM}} {{.DEST}}
validate:
deps: [ schemas ]
silent: true
cmds:
- |
ajv validate \
--all-errors \
--errors=json \
--verbose \
--data \
-c ajv-keywords \
-c ./src/sumOf.cjs \
-c ./src/maxSpells.cjs \
-r schemas-json/classes.json \
-r schemas-json/languages.json \
-r schemas-json/races.json \
-r schemas-json/spells.json \
-s schemas-json/character.json \
-d {{.CLI_ARGS}}
test:
deps: [ schemas ]
cmds:
- vitest run

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "hyperborea-character-sheet",
"version": "0.1.0",
"description": "Validating Hyperborea character sheets via JSON Schema",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "gitea@git.babyl.ca:yanick/hyperborea-character-sheet.git"
},
"keywords": [],
"author": "Yanick Champoux <yanick@babyl.ca> (http://techblog.babyl.ca/)",
"license": "ISC",
"devDependencies": {
"prettier": "^2.6.2",
"vitest": "^0.10.0"
},
"dependencies": {
"ajv": "^8.11.0",
"ajv-cli": "^5.0.0",
"ajv-keywords": "^5.1.0",
"json-pointer": "^0.6.2",
"lodash": "^4.17.21"
}
}

8
samples/bad-verg.yml Normal file
View File

@ -0,0 +1,8 @@
name: Verg-La
statistics:
strength: 11
dexterity: 13
constitution: 10
intelligence: 18
wisdom: 15
charisma: 11

89
samples/verg.yml Normal file
View File

@ -0,0 +1,89 @@
name: Verg-La
player: Yanick
level: 4
class:
generic: magician
subclass: cryomancer
health:
max: 13
log: [ 4, 3, 2, 4 ]
statistics:
strength: 11
dexterity: 13
constitution: 10
intelligence: 18
wisdom: 15
charisma: 11
gender: male
age: 18
height: 6'2"
appearance: |
Tall, skinny bloke clad in a white cloak.
Looks like he could use some hot cocoa.
alignment: Neutral
experience:
total: 17660
log:
- date: '2020-11-05'
amount: 2500
- date: '2020-12-10'
amount: 500
- date: '2021-01-21'
amount: 1500
- date: '2021-03-18'
amount: 600
- date: '2021-04-29'
amount: 1000
- date: '2021-05-13'
amount: 400
- date: '2021-06-10'
amount: 2600
- date: '2021-08-05'
amount: 1000
- date: '2021-08-26'
amount: 560
- date: '2021-09-02'
amount: 700
- date: '2021-09-30'
amount: 1000
- date: '2021-11-04'
amount: 3300
- date: '2022-02-10'
amount: 1000
- date: '2022-03-31'
amount: 1000
spells:
- Magic Ice Dart
- Freezing Hands
- Detect Magic
- Identify
- Ray of Enfeeblement
gear:
- <hm>hand axe x2
- <hm>silver dagger
- <hm>short spear
- backpack
- bandages
- blanket
- beeswax candles x3
- chalk
- crampons
- small hammer
- ink and quill
- bull's eye lantern
- desc: lamp oil
qty: 2
- parchment x3
- hard leather pouch
- soft leather pouch
- silk rope
- large sack
- iron spikes x12
- tinderbox
- wineskin (full)
- writing stick
- iron rations
- spell book

173
schemas-json/character.json Normal file
View File

@ -0,0 +1,173 @@
{
"$defs" : {
"experience" : {
"properties" : {
"items" : {
"properties" : {
"amount" : {
"type" : "number"
},
"date" : {
"type" : "string"
},
"notes" : {
"type" : "string"
}
},
"type" : "object"
},
"log" : {
"type" : "array"
},
"total" : {
"sumOf" : {
"list" : {
"$data" : "1/log"
},
"map" : "amount"
},
"type" : "number"
}
},
"type" : "object"
},
"health" : {
"properties" : {
"current" : {
"type" : "number"
},
"log" : {
"description" : "history of health rolls",
"items" : {
"type" : "number"
},
"maxItems" : {
"$data" : "/level"
},
"minItems" : {
"$data" : "/level"
},
"type" : "array"
},
"max" : {
"sumOf" : {
"list" : {
"$data" : "1/log"
}
},
"type" : "number"
}
},
"required" : [
"max"
],
"type" : "object"
},
"statistic" : {
"maximum" : 20,
"minimum" : 1,
"type" : "number"
}
},
"$id" : "https://hyperboria.babyl.ca/character.json",
"additionalProperties" : false,
"properties" : {
"age" : {
"type" : "number"
},
"alignment" : {
"type" : "string"
},
"appearance" : {
"type" : "string"
},
"class" : {
"$ref" : "/classes.json"
},
"experience" : {
"$ref" : "#/$defs/experience"
},
"gender" : {
"type" : "string"
},
"health" : {
"$ref" : "#/$defs/health"
},
"height" : {
"type" : "string"
},
"languages" : {
"items" : {
"$ref" : "/languages.json"
},
"minItems" : 1,
"type" : "array"
},
"level" : {
"minimum" : 1,
"type" : "number"
},
"name" : {
"type" : "string"
},
"player" : {
"type" : "string"
},
"race" : {
"$ref" : "/races.json"
},
"spells" : {
"items" : {
"$ref" : "/spells.json"
},
"maxSpells" : {
"class" : {
"$data" : "/class"
},
"level" : {
"$data" : "/level"
}
},
"type" : "array"
},
"statistics" : {
"allRequired" : true,
"properties" : {
"charisma" : {
"$ref" : "#/$defs/statistic"
},
"constitution" : {
"$ref" : "#/$defs/statistic"
},
"dexterity" : {
"$ref" : "#/$defs/statistic"
},
"intelligence" : {
"$ref" : "#/$defs/statistic"
},
"strength" : {
"$ref" : "#/$defs/statistic"
},
"wisdom" : {
"$ref" : "#/$defs/statistic"
}
},
"type" : "object"
}
},
"required" : [
"name",
"player",
"statistics",
"class",
"level",
"health",
"experience",
"age",
"height",
"appearance",
"alignment"
],
"title" : "Hyperboria character sheet",
"type" : "object"
}

66
schemas-json/classes.json Normal file
View File

@ -0,0 +1,66 @@
{
"$defs" : {
"fighter" : [
"barbarian",
"berserker",
"cataphract",
"hunstman",
"paladin",
"ranger",
"warlock"
],
"magician" : [
"cryomancer",
"illusionist",
"necromancer",
"pyromancer",
"witch"
]
},
"$id" : "https://hyperboria.babyl.ca/classes.json",
"oneOf" : [
{
"enum" : [
"fighter",
"magician"
]
},
{
"properties" : {
"generic" : {
"const" : "fighter"
},
"subclass" : {
"enum" : [
"barbarian",
"berserker",
"cataphract",
"hunstman",
"paladin",
"ranger",
"warlock"
]
}
},
"type" : "object"
},
{
"properties" : {
"generic" : {
"const" : "magician"
},
"subclass" : {
"enum" : [
"cryomancer",
"illusionist",
"necromancer",
"pyromancer",
"witch"
]
}
},
"type" : "object"
}
],
"title" : "Classes of characters for Hyperborea"
}

View File

@ -0,0 +1,8 @@
{
"$id" : "https://hyperboria.babyl.ca/languages.json",
"enum" : [
"Common",
"Thracian"
],
"title" : "Languages spoken in Hyperboria"
}

7
schemas-json/races.json Normal file
View File

@ -0,0 +1,7 @@
{
"$id" : "https://hyperboria.babyl.ca/races.json",
"enum" : [
"Viking"
],
"title" : "Character races"
}

11
schemas-json/spells.json Normal file
View File

@ -0,0 +1,11 @@
{
"$id" : "https://hyperboria.babyl.ca/spells.json",
"enum" : [
"Magic Ice Dart",
"Freezing Hands",
"Detect Magic",
"Identify",
"Ray of Enfeeblement"
],
"title" : "List of known spells"
}

107
schemas-yaml/character.yml Normal file
View File

@ -0,0 +1,107 @@
$id: https://hyperboria.babyl.ca/character.json
title: Hyperboria character sheet
additionalProperties: false
type: object
required:
- name
- player
- statistics
- class
- level
- health
- experience
- age
- height
- appearance
- alignment
properties:
name: &string
type: string
player: *string
class: { $ref: "/classes.json" }
statistics:
type: object
allRequired: true
properties:
strength: &stat
$ref: "#/$defs/statistic"
dexterity: *stat
constitution: *stat
intelligence: *stat
wisdom: *stat
charisma: *stat
level: { type: number, minimum: 1 }
health: { $ref: "#/$defs/health" }
experience: { $ref: '#/$defs/experience' }
gender: *string
age: &number
type: number
height: *string
appearance: *string
alignment: *string
race: { $ref: /races.json }
languages:
type: array
minItems: 1
items:
$ref: /languages.json
spells:
type: array
items: { $ref: /spells.json }
maxSpells:
class: { $data: /class }
level: { $data: /level }
gear: { $ref: '#/$defs/gear' }
$defs:
gear:
type: array
items:
oneOf:
- *string
- type: object
properties:
desc:
type: string
description: description of the equipment
qty:
type: number
description: quantity of the item in the character's possession
required: [ desc ]
additionalProperties: false
examples:
- { desc: 'lamp oil', qty: 2 }
statistic:
type: number
minimum: 1
maximum: 20
health:
type: object
required: [max]
properties:
max:
type: number
sumOf: { list: { $data: 1/log } }
current: { type: number }
log:
type: array
description: history of health rolls
items: &number { type: number }
minItems: { $data: /level }
maxItems: { $data: /level }
experience:
type: object
properties:
total:
type: number
sumOf:
list: { $data: '1/log' }
map: amount
log:
type: array
items:
type: object
properties:
date: *string
amount: *number
notes: *string

17
schemas-yaml/classes.pl Normal file
View File

@ -0,0 +1,17 @@
sub {
my $schema = $_->{oneOf} = [];
push @$schema, { enum => [ keys $_->{'$defs'}->%* ] };
for my $generic ( keys $_->{'$defs'}->%* ) {
push @$schema, {
type => 'object',
properties => {
generic => { const => $generic },
subclass => { enum => $_->{'$defs'}{$generic} }
}
}
}
return $_;
}

12
schemas-yaml/classes.yml Normal file
View File

@ -0,0 +1,12 @@
$id: https://hyperboria.babyl.ca/classes.json
title: Classes of characters for Hyperborea
$defs:
fighter:
- barbarian
- berserker
- cataphract
- hunstman
- paladin
- ranger
- warlock
magician: [cryomancer, illusionist, necromancer, pyromancer, witch]

View File

@ -0,0 +1,6 @@
---
$id: https://hyperboria.babyl.ca/languages.json
title: Languages spoken in Hyperboria
enum:
- Common
- Thracian

5
schemas-yaml/races.yml Normal file
View File

@ -0,0 +1,5 @@
---
$id: https://hyperboria.babyl.ca/races.json
title: Character races
enum:
- Viking

9
schemas-yaml/spells.yml Normal file
View File

@ -0,0 +1,9 @@
---
$id: https://hyperboria.babyl.ca/spells.json
title: List of known spells
enum:
- Magic Ice Dart
- Freezing Hands
- Detect Magic
- Identify
- Ray of Enfeeblement

30
src/maxSpells.cjs Normal file
View File

@ -0,0 +1,30 @@
const _ = require("lodash");
const resolvePointer = require('./resolvePointer.cjs');
module.exports = (ajv) =>
ajv.addKeyword({
keyword: "maxSpells",
validate: function validate(
schema,
data,
_parent,
{ rootData, instancePath }
) {
if (schema.class.$data) {
schema.class = resolvePointer(rootData, instancePath, schema.class.$data);
}
if( schema.class !== 'magician' && schema.class?.generic !== 'magician' && data.length ) {
validate.errors = [
{
message: "non-magician can't have spells",
},
];
return false;
}
return true;
},
$data: true,
errors: true,
});

13
src/resolvePointer.cjs Normal file
View File

@ -0,0 +1,13 @@
const ptr = require("json-pointer");
module.exports = function resolvePointer(data, rootPath, relativePath) {
if (relativePath[0] === "/") return ptr.get(data, relativePath);
const m = relativePath.match(/^(\d+)(.*)/);
relativePath = m[2];
for (let i = 0; i < parseInt(m[1]); i++) {
rootPath = rootPath.replace(/\/[^\/]+$/, "");
}
return ptr.get(data, rootPath + relativePath);
}

20
src/statistics.test.js Normal file
View File

@ -0,0 +1,20 @@
import { test, expect } from "vitest";
import Ajv from "ajv";
import characterSchema from "../schemas-json/character.json";
const ajv = new Ajv();
const validate = ajv.compile(characterSchema.$defs.statistic);
test("good statistic", () => {
expect(validate(12)).toBeTruthy();
expect(validate.errors).toBeNull();
});
test("bad statistic", () => {
expect(validate(21)).toBeFalsy();
expect(validate.errors[0]).toMatchObject({
message: "must be <= 20",
});
});

36
src/sumOf.cjs Normal file
View File

@ -0,0 +1,36 @@
const _ = require("lodash");
const resolvePointer = require('./resolvePointer.cjs');
module.exports = (ajv) =>
ajv.addKeyword({
keyword: "sumOf",
validate: function validate(
{ list, map },
total,
_parent,
{ rootData, instancePath }
) {
if (list.$data) {
list = resolvePointer(rootData, instancePath, list.$data);
}
if (map) list = _.map(list, map);
if (_.sum(list) === total) return true;
validate.errors = [
{
keyword: "sumOf",
message: "should add up to sum total",
params: {
list,
},
},
];
return false;
},
$data: true,
errors: true,
});