Add spell catalog overlay resolution
This commit is contained in:
64
docs/integrations/dnd-spell-catalog-overlays.md
Normal file
64
docs/integrations/dnd-spell-catalog-overlays.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# D&D Spell-Catalog Overlay Contract
|
||||
|
||||
This document defines the JSON format accepted by the D&D spell catalog
|
||||
resolver. An overlay supplies campaign-specific spell names and aliases for
|
||||
recognition. It does not supply spell rules, levels, classes, effects, or
|
||||
source evidence.
|
||||
|
||||
## Shape
|
||||
|
||||
An overlay bundle has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
|
||||
"catalogs": [
|
||||
{
|
||||
"id": "campaign.example",
|
||||
"ruleset": "dnd-5e-2014",
|
||||
"source": {
|
||||
"title": "Example campaign spells",
|
||||
"version": "1",
|
||||
"url": "",
|
||||
"license": ""
|
||||
},
|
||||
"spells": [
|
||||
{
|
||||
"name": "Aegis of Emberfall",
|
||||
"aliases": ["Emberfall Aegis"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The top-level `schema_version` and `catalogs` fields are required. The schema
|
||||
version must be exactly `notarius.dnd.spell-catalog-overlay.v1`, and at least
|
||||
one catalog is required. Catalogs require a unique, non-empty, trimmed `id`,
|
||||
the exact `dnd-5e-2014` `ruleset`, a `source`, and a non-empty `spells` array.
|
||||
|
||||
`source.title` is required and must be non-empty and trimmed. `source.version`,
|
||||
`source.url`, and `source.license` are optional strings and may be empty.
|
||||
Each spell requires a non-empty, trimmed `name`. `aliases` may be omitted or
|
||||
may be an array of trimmed, non-empty strings; JSON `null` is not an alias
|
||||
array. Overlay objects contain no other supported spell fields.
|
||||
|
||||
Decoding is strict: unknown fields, malformed JSON, trailing JSON values, and
|
||||
non-string optional source fields are rejected.
|
||||
|
||||
## Composition
|
||||
|
||||
The resolver always starts with the embedded D&D 5e 2014 SRD catalog. Overlay
|
||||
catalogs are sorted by `id` before composition, so the input order does not
|
||||
affect the result. A new canonical name adds a recognition entry. A canonical
|
||||
name matching an existing canonical name augments that spell and keeps the
|
||||
established canonical display spelling. Repeated aliases for the same spell
|
||||
are idempotent.
|
||||
|
||||
Canonical-name display conflicts and canonical/alias or alias/alias collisions
|
||||
between different spells are errors, including collisions with the embedded
|
||||
catalog. Canonical names and aliases use the catalog's case, whitespace, and
|
||||
common-apostrophe normalization rules. The effective catalog returns canonical
|
||||
names in sorted order and produces a semantic SHA-256 digest that is stable
|
||||
under JSON formatting, object-key, catalog, spell, and alias reordering.
|
||||
@@ -70,6 +70,17 @@ license details live beside the asset in `SOURCES.md`. This domain-owned data is
|
||||
separate from `internal/modules/dnd/shared`, which is reserved for reusable
|
||||
prompt and source-reference machinery.
|
||||
|
||||
`ResolveEffectiveCatalog` builds the immutable recognition view used by later
|
||||
D&D consumers. It starts with the embedded SRD catalog and optionally applies
|
||||
one strict JSON overlay from the `spell_catalog` item in a materialized
|
||||
reference set. Overlay catalogs are ordered by ID, may add names and aliases,
|
||||
and may augment an existing canonical spell without replacing its display
|
||||
name. Cross-spell lookup collisions are errors. The effective view exposes
|
||||
sorted canonical names, normalized lookup, overlay identities, and a semantic
|
||||
digest; overlay content remains contextual reference material rather than
|
||||
source evidence. Its external JSON contract is defined in the
|
||||
[spell-catalog overlay contract](../integrations/dnd-spell-catalog-overlays.md).
|
||||
|
||||
## Input Adapter
|
||||
|
||||
### `internal/modules/seriatim/input/transcript`
|
||||
|
||||
427
internal/modules/dnd/spells/catalog/effective.go
Normal file
427
internal/modules/dnd/spells/catalog/effective.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
const (
|
||||
SpellCatalogReferenceSlot = "spell_catalog"
|
||||
overlaySchemaVersion = "notarius.dnd.spell-catalog-overlay.v1"
|
||||
)
|
||||
|
||||
// EffectiveCatalog is the immutable spell-name recognition catalog assembled
|
||||
// from the embedded SRD catalog and an optional set of campaign overlays.
|
||||
type EffectiveCatalog struct {
|
||||
baseID string
|
||||
ruleset string
|
||||
overlayIDs []string
|
||||
canonicalNames []string
|
||||
lookup map[string]string
|
||||
digest string
|
||||
}
|
||||
|
||||
func (c EffectiveCatalog) BaseID() string { return c.baseID }
|
||||
func (c EffectiveCatalog) Ruleset() string { return c.ruleset }
|
||||
func (c EffectiveCatalog) Digest() string { return c.digest }
|
||||
func (c EffectiveCatalog) OverlayIDs() []string { return append([]string(nil), c.overlayIDs...) }
|
||||
|
||||
// CanonicalNames returns the globally sorted canonical spell names without
|
||||
// exposing the catalog's internal storage.
|
||||
func (c EffectiveCatalog) CanonicalNames() []string {
|
||||
return append([]string(nil), c.canonicalNames...)
|
||||
}
|
||||
|
||||
// Lookup matches canonical names and aliases after applying the same
|
||||
// normalization used by the embedded catalog. The returned string is the
|
||||
// established canonical display name.
|
||||
func (c EffectiveCatalog) Lookup(name string) (string, bool) {
|
||||
canonical, ok := c.lookup[lookupKey(name)]
|
||||
return canonical, ok
|
||||
}
|
||||
|
||||
// ResolveEffectiveCatalog loads the embedded SRD catalog and applies the
|
||||
// optional spell-catalog overlay found in the cloned reference set. It does
|
||||
// not resolve paths or perform filesystem access.
|
||||
func ResolveEffectiveCatalog(references contracts.ReferenceSet) (EffectiveCatalog, error) {
|
||||
base, err := LoadSRD5E2014()
|
||||
if err != nil {
|
||||
return EffectiveCatalog{}, err
|
||||
}
|
||||
|
||||
cloned := cloneReferenceSet(references)
|
||||
slot, ok := cloned.Slots[SpellCatalogReferenceSlot]
|
||||
if !ok || len(slot.Items) == 0 {
|
||||
return composeEffectiveCatalog(base, nil)
|
||||
}
|
||||
if len(slot.Items) != 1 {
|
||||
return EffectiveCatalog{}, fmt.Errorf("reference slot %q must contain zero or one item", SpellCatalogReferenceSlot)
|
||||
}
|
||||
|
||||
item := slot.Items[0]
|
||||
mediaType, _, err := mime.ParseMediaType(item.MediaType)
|
||||
if err != nil {
|
||||
return EffectiveCatalog{}, fmt.Errorf("reference slot %q item media type %q is invalid: %w", SpellCatalogReferenceSlot, item.MediaType, err)
|
||||
}
|
||||
if !strings.EqualFold(mediaType, "application/json") {
|
||||
return EffectiveCatalog{}, fmt.Errorf("reference slot %q item media type %q must be application/json", SpellCatalogReferenceSlot, item.MediaType)
|
||||
}
|
||||
overlays, err := decodeOverlayBundle(item.Content)
|
||||
if err != nil {
|
||||
return EffectiveCatalog{}, err
|
||||
}
|
||||
return composeEffectiveCatalog(base, overlays)
|
||||
}
|
||||
|
||||
type overlayBundle struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Catalogs []overlayCatalog `json:"catalogs"`
|
||||
}
|
||||
|
||||
type overlayCatalog struct {
|
||||
ID string `json:"id"`
|
||||
Ruleset string `json:"ruleset"`
|
||||
Source overlaySource `json:"source"`
|
||||
Spells []overlaySpell `json:"spells"`
|
||||
}
|
||||
|
||||
type overlaySource struct {
|
||||
Title string `json:"title"`
|
||||
Version json.RawMessage `json:"version"`
|
||||
URL json.RawMessage `json:"url"`
|
||||
License json.RawMessage `json:"license"`
|
||||
}
|
||||
|
||||
type overlaySpell struct {
|
||||
Name string `json:"name"`
|
||||
Aliases json.RawMessage `json:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
func decodeOverlayBundle(content []byte) ([]overlayCatalog, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.DisallowUnknownFields()
|
||||
var bundle overlayBundle
|
||||
if err := decoder.Decode(&bundle); err != nil {
|
||||
return nil, fmt.Errorf("decode spell catalog overlay: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return nil, fmt.Errorf("decode spell catalog overlay: multiple JSON values")
|
||||
}
|
||||
if bundle.SchemaVersion != overlaySchemaVersion {
|
||||
return nil, fmt.Errorf("spell catalog overlay schema_version %q does not match %q", bundle.SchemaVersion, overlaySchemaVersion)
|
||||
}
|
||||
if len(bundle.Catalogs) == 0 {
|
||||
return nil, fmt.Errorf("spell catalog overlay catalogs must not be empty")
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]struct{}, len(bundle.Catalogs))
|
||||
for catalogIndex := range bundle.Catalogs {
|
||||
catalog := &bundle.Catalogs[catalogIndex]
|
||||
if catalog.ID != strings.TrimSpace(catalog.ID) || catalog.ID == "" {
|
||||
return nil, fmt.Errorf("catalog[%d] id must be non-empty and trimmed", catalogIndex)
|
||||
}
|
||||
if _, exists := seenIDs[catalog.ID]; exists {
|
||||
return nil, fmt.Errorf("catalog id %q is duplicated", catalog.ID)
|
||||
}
|
||||
seenIDs[catalog.ID] = struct{}{}
|
||||
if catalog.Ruleset != SRD5E2014Ruleset {
|
||||
return nil, fmt.Errorf("catalog %q ruleset %q does not match %q", catalog.ID, catalog.Ruleset, SRD5E2014Ruleset)
|
||||
}
|
||||
if catalog.Source.Title != strings.TrimSpace(catalog.Source.Title) || catalog.Source.Title == "" {
|
||||
return nil, fmt.Errorf("catalog %q source title must be non-empty and trimmed", catalog.ID)
|
||||
}
|
||||
for field, raw := range map[string]json.RawMessage{
|
||||
"version": catalog.Source.Version,
|
||||
"url": catalog.Source.URL,
|
||||
"license": catalog.Source.License,
|
||||
} {
|
||||
if _, err := decodeOptionalString(raw); err != nil {
|
||||
return nil, fmt.Errorf("catalog %q source %s: %w", catalog.ID, field, err)
|
||||
}
|
||||
}
|
||||
if len(catalog.Spells) == 0 {
|
||||
return nil, fmt.Errorf("catalog %q spells must not be empty", catalog.ID)
|
||||
}
|
||||
for spellIndex := range catalog.Spells {
|
||||
spell := &catalog.Spells[spellIndex]
|
||||
if spell.Name != strings.TrimSpace(spell.Name) || spell.Name == "" {
|
||||
return nil, fmt.Errorf("catalog %q spell[%d] name must be non-empty and trimmed", catalog.ID, spellIndex)
|
||||
}
|
||||
_, err := decodeAliases(spell.Aliases)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("catalog %q spell %q aliases: %w", catalog.ID, spell.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bundle.Catalogs, nil
|
||||
}
|
||||
|
||||
func decodeOptionalString(raw json.RawMessage) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return "", fmt.Errorf("must be a string when present")
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return "", fmt.Errorf("must be a string when present: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeAliases(raw json.RawMessage) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return nil, fmt.Errorf("must be an array when present")
|
||||
}
|
||||
var aliases []string
|
||||
if err := json.Unmarshal(raw, &aliases); err != nil {
|
||||
return nil, fmt.Errorf("must be an array of strings: %w", err)
|
||||
}
|
||||
for index, alias := range aliases {
|
||||
if alias != strings.TrimSpace(alias) || alias == "" {
|
||||
return nil, fmt.Errorf("value at index %d must be non-empty and trimmed", index)
|
||||
}
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
type effectiveSpell struct {
|
||||
name string
|
||||
canonicalKey string
|
||||
aliases map[string]string
|
||||
}
|
||||
|
||||
type effectiveBuilder struct {
|
||||
spells map[string]*effectiveSpell
|
||||
canonicalByKey map[string]string
|
||||
lookup map[string]string
|
||||
}
|
||||
|
||||
func composeEffectiveCatalog(base Catalog, overlays []overlayCatalog) (EffectiveCatalog, error) {
|
||||
builder := effectiveBuilder{
|
||||
spells: make(map[string]*effectiveSpell, len(base.spells)),
|
||||
canonicalByKey: make(map[string]string, len(base.lookup)),
|
||||
lookup: make(map[string]string, len(base.lookup)),
|
||||
}
|
||||
for _, spell := range base.Spells() {
|
||||
if err := builder.addCanonical(spell.Name); err != nil {
|
||||
return EffectiveCatalog{}, fmt.Errorf("base catalog: %w", err)
|
||||
}
|
||||
for _, alias := range spell.Aliases {
|
||||
if err := builder.addAlias(spell.Name, alias); err != nil {
|
||||
return EffectiveCatalog{}, fmt.Errorf("base catalog: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(overlays, func(i, j int) bool { return overlays[i].ID < overlays[j].ID })
|
||||
overlayIDs := make([]string, len(overlays))
|
||||
for i, overlay := range overlays {
|
||||
overlayIDs[i] = overlay.ID
|
||||
for _, spell := range overlay.Spells {
|
||||
canonical, err := builder.ensureCanonical(spell.Name)
|
||||
if err != nil {
|
||||
return EffectiveCatalog{}, fmt.Errorf("catalog %q: %w", overlay.ID, err)
|
||||
}
|
||||
aliases, err := decodeAliases(spell.Aliases)
|
||||
if err != nil {
|
||||
return EffectiveCatalog{}, fmt.Errorf("catalog %q spell %q aliases: %w", overlay.ID, spell.Name, err)
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if err := builder.addAlias(canonical, alias); err != nil {
|
||||
return EffectiveCatalog{}, fmt.Errorf("catalog %q spell %q: %w", overlay.ID, spell.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canonicalNames := make([]string, 0, len(builder.spells))
|
||||
for _, spell := range builder.spells {
|
||||
canonicalNames = append(canonicalNames, spell.name)
|
||||
}
|
||||
sort.Strings(canonicalNames)
|
||||
|
||||
digest, err := effectiveDigest(base, overlays, builder, canonicalNames)
|
||||
if err != nil {
|
||||
return EffectiveCatalog{}, err
|
||||
}
|
||||
return EffectiveCatalog{
|
||||
baseID: base.ID(),
|
||||
ruleset: base.Ruleset(),
|
||||
overlayIDs: overlayIDs,
|
||||
canonicalNames: canonicalNames,
|
||||
lookup: cloneStringMap(builder.lookup),
|
||||
digest: digest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *effectiveBuilder) ensureCanonical(name string) (string, error) {
|
||||
key := lookupKey(name)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("spell %q has an empty lookup key", name)
|
||||
}
|
||||
if canonical, exists := b.canonicalByKey[key]; exists {
|
||||
if canonical != name {
|
||||
return "", fmt.Errorf("canonical lookup key %q has conflicting display names %q and %q", key, canonical, name)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
if canonical, exists := b.lookup[key]; exists {
|
||||
return "", fmt.Errorf("canonical lookup key %q collides with alias of spell %q", key, canonical)
|
||||
}
|
||||
spell := &effectiveSpell{name: name, canonicalKey: key, aliases: make(map[string]string)}
|
||||
b.spells[name] = spell
|
||||
b.canonicalByKey[key] = name
|
||||
b.lookup[key] = name
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (b *effectiveBuilder) addCanonical(name string) error {
|
||||
_, err := b.ensureCanonical(name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *effectiveBuilder) addAlias(canonical string, alias string) error {
|
||||
key := lookupKey(alias)
|
||||
if key == "" {
|
||||
return fmt.Errorf("spell %q has an empty alias lookup key", canonical)
|
||||
}
|
||||
if existing, exists := b.lookup[key]; exists && existing != canonical {
|
||||
return fmt.Errorf("lookup key %q maps to spells %q and %q", key, existing, canonical)
|
||||
}
|
||||
spell, exists := b.spells[canonical]
|
||||
if !exists {
|
||||
return fmt.Errorf("spell %q is not present", canonical)
|
||||
}
|
||||
if key == spell.canonicalKey {
|
||||
return nil
|
||||
}
|
||||
b.lookup[key] = canonical
|
||||
if previous, exists := spell.aliases[key]; !exists || alias < previous {
|
||||
spell.aliases[key] = alias
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type effectiveDigestDocument struct {
|
||||
BaseID string `json:"base_id"`
|
||||
BaseRuleset string `json:"base_ruleset"`
|
||||
BaseSource Source `json:"base_source"`
|
||||
Overlays []effectiveDigestOverlay `json:"overlays"`
|
||||
Spells []effectiveDigestSpell `json:"spells"`
|
||||
}
|
||||
|
||||
type effectiveDigestOverlay struct {
|
||||
ID string `json:"id"`
|
||||
Ruleset string `json:"ruleset"`
|
||||
Source struct {
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
License string `json:"license"`
|
||||
} `json:"source"`
|
||||
}
|
||||
|
||||
type effectiveDigestSpell struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Aliases []effectiveDigestAlias `json:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
type effectiveDigestAlias struct {
|
||||
Key string `json:"key"`
|
||||
Display string `json:"display"`
|
||||
}
|
||||
|
||||
func effectiveDigest(base Catalog, overlays []overlayCatalog, builder effectiveBuilder, canonicalNames []string) (string, error) {
|
||||
document := effectiveDigestDocument{
|
||||
BaseID: base.ID(),
|
||||
BaseRuleset: base.Ruleset(),
|
||||
BaseSource: base.Source(),
|
||||
Overlays: make([]effectiveDigestOverlay, len(overlays)),
|
||||
Spells: make([]effectiveDigestSpell, 0, len(canonicalNames)),
|
||||
}
|
||||
for index, overlay := range overlays {
|
||||
document.Overlays[index].ID = overlay.ID
|
||||
document.Overlays[index].Ruleset = overlay.Ruleset
|
||||
version, err := decodeOptionalString(overlay.Source.Version)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("catalog %q source version: %w", overlay.ID, err)
|
||||
}
|
||||
url, err := decodeOptionalString(overlay.Source.URL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("catalog %q source url: %w", overlay.ID, err)
|
||||
}
|
||||
license, err := decodeOptionalString(overlay.Source.License)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("catalog %q source license: %w", overlay.ID, err)
|
||||
}
|
||||
document.Overlays[index].Source.Title = overlay.Source.Title
|
||||
document.Overlays[index].Source.Version = version
|
||||
document.Overlays[index].Source.URL = url
|
||||
document.Overlays[index].Source.License = license
|
||||
}
|
||||
for _, name := range canonicalNames {
|
||||
spell := builder.spells[name]
|
||||
digestSpell := effectiveDigestSpell{Key: spell.canonicalKey, Name: spell.name}
|
||||
keys := make([]string, 0, len(spell.aliases))
|
||||
for key := range spell.aliases {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
digestSpell.Aliases = append(digestSpell.Aliases, effectiveDigestAlias{Key: key, Display: spell.aliases[key]})
|
||||
}
|
||||
document.Spells = append(document.Spells, digestSpell)
|
||||
}
|
||||
raw, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode effective spell catalog digest: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func cloneStringMap(values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneReferenceSet(in contracts.ReferenceSet) contracts.ReferenceSet {
|
||||
if len(in.Slots) == 0 {
|
||||
return contracts.ReferenceSet{}
|
||||
}
|
||||
out := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(in.Slots))}
|
||||
for name, slot := range in.Slots {
|
||||
slot.Slot.AcceptedMediaTypes = append([]string(nil), slot.Slot.AcceptedMediaTypes...)
|
||||
items := make([]contracts.ReferenceItem, len(slot.Items))
|
||||
for index, item := range slot.Items {
|
||||
item.Content = append([]byte(nil), item.Content...)
|
||||
items[index] = item
|
||||
}
|
||||
slot.Items = items
|
||||
out.Slots[name] = slot
|
||||
}
|
||||
return out
|
||||
}
|
||||
279
internal/modules/dnd/spells/catalog/effective_test.go
Normal file
279
internal/modules/dnd/spells/catalog/effective_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestResolveEffectiveCatalogBaseAndImmutability(t *testing.T) {
|
||||
effective, err := ResolveEffectiveCatalog(contracts.ReferenceSet{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if effective.BaseID() != SRD5E2014ID || effective.Ruleset() != SRD5E2014Ruleset {
|
||||
t.Fatalf("identity = %q/%q", effective.BaseID(), effective.Ruleset())
|
||||
}
|
||||
if got := effective.OverlayIDs(); len(got) != 0 {
|
||||
t.Fatalf("overlay IDs = %#v", got)
|
||||
}
|
||||
names := effective.CanonicalNames()
|
||||
if len(names) != 319 || !sort.StringsAreSorted(names) {
|
||||
t.Fatalf("canonical names count/order = %d/%t", len(names), sort.StringsAreSorted(names))
|
||||
}
|
||||
if got, ok := effective.Lookup(" HUNTER'S MARK "); !ok || got != "Hunter’s Mark" {
|
||||
t.Fatalf("Hunter's Mark lookup = %q, present=%t", got, ok)
|
||||
}
|
||||
if got, ok := effective.Lookup("Definitely Not A Spell"); ok || got != "" {
|
||||
t.Fatalf("unknown lookup = %q, present=%t", got, ok)
|
||||
}
|
||||
if !strings.HasPrefix(effective.Digest(), "sha256:") {
|
||||
t.Fatalf("digest = %q", effective.Digest())
|
||||
}
|
||||
|
||||
names[0] = "changed"
|
||||
ids := effective.OverlayIDs()
|
||||
ids = append(ids, "changed")
|
||||
again, err := ResolveEffectiveCatalog(contracts.ReferenceSet{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reflect.DeepEqual(names, again.CanonicalNames()) || len(again.OverlayIDs()) != 0 {
|
||||
t.Fatal("effective catalog exposed mutable result storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveCatalogAddsAndAugmentsSpells(t *testing.T) {
|
||||
overlay := testOverlayJSON(t, testOverlayCatalog(
|
||||
"campaign.example",
|
||||
testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
|
||||
testOverlaySpell("Cure Wounds", "Healing Touch"),
|
||||
))
|
||||
effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := effective.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) {
|
||||
t.Fatalf("overlay IDs = %#v", got)
|
||||
}
|
||||
if len(effective.CanonicalNames()) != 320 {
|
||||
t.Fatalf("canonical name count = %d, want 320", len(effective.CanonicalNames()))
|
||||
}
|
||||
if got, ok := effective.Lookup("aegis of emberfall"); !ok || got != "Aegis of Emberfall" {
|
||||
t.Fatalf("new spell lookup = %q, present=%t", got, ok)
|
||||
}
|
||||
if got, ok := effective.Lookup("Emberfall Aegis"); !ok || got != "Aegis of Emberfall" {
|
||||
t.Fatalf("new alias lookup = %q, present=%t", got, ok)
|
||||
}
|
||||
if got, ok := effective.Lookup("Healing Touch"); !ok || got != "Cure Wounds" {
|
||||
t.Fatalf("augmentation alias lookup = %q, present=%t", got, ok)
|
||||
}
|
||||
for _, name := range effective.CanonicalNames() {
|
||||
if name == "Emberfall Aegis" || name == "Healing Touch" {
|
||||
t.Fatalf("alias %q was exposed as a canonical name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveCatalogTreatsRepeatedAliasesAsIdempotent(t *testing.T) {
|
||||
single := testOverlayJSON(t, testOverlayCatalog(
|
||||
"campaign.example",
|
||||
testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
|
||||
))
|
||||
repeated := testOverlayJSON(t, testOverlayCatalog(
|
||||
"campaign.example",
|
||||
testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
|
||||
testOverlaySpell("Aegis of Emberfall", "emberfall aegis"),
|
||||
))
|
||||
first, err := ResolveEffectiveCatalog(overlayReference([]byte(single), "application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := ResolveEffectiveCatalog(overlayReference([]byte(repeated), "application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Digest() != second.Digest() || !reflect.DeepEqual(first.CanonicalNames(), second.CanonicalNames()) {
|
||||
t.Fatalf("repeated alias changed effective result: digest=%q/%q", first.Digest(), second.Digest())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveCatalogDigestIgnoresOrderingAndFormatting(t *testing.T) {
|
||||
first := `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.z","ruleset":"dnd-5e-2014","source":{"title":"Z spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Z Alias","Another Alias"]}]},{"id":"campaign.a","ruleset":"dnd-5e-2014","source":{"title":"A spells","version":"1","url":"","license":""},"spells":[{"name":"Cure Wounds","aliases":["Healing Touch","Cure Mend"]}]}]}`
|
||||
second := `{
|
||||
"catalogs": [
|
||||
{"source":{"license":"","url":"","version":"1","title":"A spells"},"spells":[{"aliases":["Cure Mend","Healing Touch"],"name":"Cure Wounds"}],"ruleset":"dnd-5e-2014","id":"campaign.a"},
|
||||
{"spells":[{"aliases":["Another Alias","Z Alias"],"name":"Aegis of Emberfall"}],"source":{"title":"Z spells","version":"1","url":"","license":""},"ruleset":"dnd-5e-2014","id":"campaign.z"}
|
||||
],
|
||||
"schema_version":"notarius.dnd.spell-catalog-overlay.v1"
|
||||
}`
|
||||
firstCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(first), "application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(second), "application/json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()) || firstCatalog.Digest() != secondCatalog.Digest() {
|
||||
t.Fatalf("reordered overlays changed effective result: names=%t digest=%q/%q", reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()), firstCatalog.Digest(), secondCatalog.Digest())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveCatalogRejectsInvalidOverlays(t *testing.T) {
|
||||
valid := validOverlayJSON()
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "wrong schema version", content: strings.Replace(valid, "notarius.dnd.spell-catalog-overlay.v1", "notarius.dnd.spell-catalog-overlay.v2", 1)},
|
||||
{name: "empty catalogs", content: `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[]}`},
|
||||
{name: "duplicate IDs", content: testOverlayJSON(t, testOverlayCatalog("campaign.example", testOverlaySpell("Aegis of Emberfall")), testOverlayCatalog("campaign.example", testOverlaySpell("Another Spell")))},
|
||||
{name: "wrong ruleset", content: strings.Replace(valid, `"ruleset":"dnd-5e-2014"`, `"ruleset":"dnd-5e-other"`, 1)},
|
||||
{name: "empty ID", content: strings.Replace(valid, `"id":"campaign.example"`, `"id":""`, 1)},
|
||||
{name: "empty source title", content: strings.Replace(valid, `"title":"Example campaign spells"`, `"title":""`, 1)},
|
||||
{name: "empty spells", content: testOverlayJSON(t, testOverlayCatalog("campaign.example"))},
|
||||
{name: "empty spell name", content: strings.Replace(valid, `"name":"Aegis of Emberfall"`, `"name":""`, 1)},
|
||||
{name: "null aliases", content: strings.Replace(valid, `"aliases":["Emberfall Aegis"]`, `"aliases":null`, 1)},
|
||||
{name: "unknown bundle field", content: strings.Replace(valid, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs"`, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","unexpected":true,"catalogs"`, 1)},
|
||||
{name: "unknown catalog field", content: strings.Replace(valid, `"id":"campaign.example","ruleset"`, `"id":"campaign.example","unexpected":true,"ruleset"`, 1)},
|
||||
{name: "unknown source field", content: strings.Replace(valid, `"title":"Example campaign spells","version"`, `"title":"Example campaign spells","unexpected":true,"version"`, 1)},
|
||||
{name: "unknown spell field", content: strings.Replace(valid, `"name":"Aegis of Emberfall","aliases"`, `"name":"Aegis of Emberfall","unexpected":true,"aliases"`, 1)},
|
||||
{name: "trailing JSON", content: valid + `{}`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := ResolveEffectiveCatalog(overlayReference([]byte(test.content), "application/json")); err == nil {
|
||||
t.Fatal("invalid overlay was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveCatalogRejectsReferenceMultiplicityAndMediaType(t *testing.T) {
|
||||
valid := []byte(validOverlayJSON())
|
||||
tests := []struct {
|
||||
name string
|
||||
refs contracts.ReferenceSet
|
||||
}{
|
||||
{
|
||||
name: "multiple items",
|
||||
refs: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
SpellCatalogReferenceSlot: {Items: []contracts.ReferenceItem{
|
||||
{MediaType: "application/json", Content: valid},
|
||||
{MediaType: "application/json", Content: valid},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "non JSON item",
|
||||
refs: overlayReference(valid, "text/plain"),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := ResolveEffectiveCatalog(test.refs); err == nil {
|
||||
t.Fatal("invalid reference set was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, refs := range []contracts.ReferenceSet{
|
||||
{}, {Slots: map[string]contracts.ResolvedReferenceSlot{SpellCatalogReferenceSlot: {}}},
|
||||
} {
|
||||
if effective, err := ResolveEffectiveCatalog(refs); err != nil || len(effective.CanonicalNames()) != 319 {
|
||||
t.Fatalf("missing overlay should use base catalog: effective=%#v err=%v", effective, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveCatalogRejectsCrossSpellCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spells []overlaySpell
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "same key display conflict with embedded canonical",
|
||||
spells: []overlaySpell{testOverlaySpell("cure wounds")},
|
||||
},
|
||||
{
|
||||
name: "same key display conflict between canonical names",
|
||||
spells: []overlaySpell{testOverlaySpell("Moon Beam"), testOverlaySpell("moon beam")},
|
||||
},
|
||||
{
|
||||
name: "canonical versus alias",
|
||||
spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Beta Spell"), testOverlaySpell("Beta Spell")},
|
||||
},
|
||||
{
|
||||
name: "alias versus canonical",
|
||||
spells: []overlaySpell{testOverlaySpell("Alpha Spell"), testOverlaySpell("Beta Spell", "Alpha Spell")},
|
||||
},
|
||||
{
|
||||
name: "alias versus alias",
|
||||
spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Shared Name"), testOverlaySpell("Beta Spell", "Shared Name")},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
content := test.content
|
||||
if content == "" {
|
||||
content = testOverlayJSON(t, testOverlayCatalog("campaign.example", test.spells...))
|
||||
}
|
||||
if _, err := ResolveEffectiveCatalog(overlayReference([]byte(content), "application/json")); err == nil {
|
||||
t.Fatal("cross-spell collision was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func overlayReference(content []byte, mediaType string) contracts.ReferenceSet {
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
SpellCatalogReferenceSlot: {
|
||||
Items: []contracts.ReferenceItem{{
|
||||
SlotName: SpellCatalogReferenceSlot,
|
||||
MediaType: mediaType,
|
||||
Content: append([]byte(nil), content...),
|
||||
}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func testOverlaySpell(name string, aliases ...string) overlaySpell {
|
||||
content, err := json.Marshal(aliases)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return overlaySpell{Name: name, Aliases: content}
|
||||
}
|
||||
|
||||
func testOverlayCatalog(id string, spells ...overlaySpell) overlayCatalog {
|
||||
return overlayCatalog{
|
||||
ID: id,
|
||||
Ruleset: SRD5E2014Ruleset,
|
||||
Source: overlaySource{
|
||||
Title: id + " spells",
|
||||
Version: json.RawMessage(`"1"`),
|
||||
URL: json.RawMessage(`""`),
|
||||
License: json.RawMessage(`""`),
|
||||
},
|
||||
Spells: spells,
|
||||
}
|
||||
}
|
||||
|
||||
func testOverlayJSON(t *testing.T, catalogs ...overlayCatalog) string {
|
||||
t.Helper()
|
||||
content, err := json.Marshal(overlayBundle{SchemaVersion: overlaySchemaVersion, Catalogs: catalogs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func validOverlayJSON() string {
|
||||
return `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Example campaign spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}`
|
||||
}
|
||||
Reference in New Issue
Block a user