diff --git a/docs/integrations/dnd-combat-turn-artifacts.md b/docs/integrations/dnd-combat-turn-artifacts.md new file mode 100644 index 0000000..ccbf78a --- /dev/null +++ b/docs/integrations/dnd-combat-turn-artifacts.md @@ -0,0 +1,73 @@ +# D&D Combat-Turn Artifact Contract + +This document defines the durable artifact and serialization boundary for +D&D combat turns. It does not define extraction, validation beyond the codec's +structural checks, normalization, prompts, or a selectable pipeline lane. + +## Artifact identity + +| Property | Value | +| --- | --- | +| Artifact kind | `dnd/combat-turn-list` | +| Schema ID | `notarius.dnd.combat_turns` | +| Schema name | `notarius_dnd_combat_turns_v1` | +| Schema version | `v1` | +| Media type | `application/json` | + +The top-level JSON object contains the required `combat_turns` array, which +may be empty. Every object rejects unknown fields. + +## JSON shape + +Each combat turn contains these required fields: + +| Field | Shape | +| --- | --- | +| `actor` | Non-empty string. | +| `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. | +| `round` | Required JSON field containing a positive integer or `null`. | +| `actions` | Required array with at least one action. | +| `summary` | Non-empty string. | +| `source_refs` | Required array with at least one source reference. | + +Each action contains these required fields: + +| Field | Shape | +| --- | --- | +| `category` | One of `attack`, `spell`, `movement`, `item`, `ability_check`, `saving_throw`, `condition`, or `other`. | +| `declaration` | Non-empty string describing what was declared. | +| `targets` | Required array of strings; the array may be empty, but entries may not be empty. | +| `resolution` | Required JSON field containing a non-empty string or `null`. | + +Source references use the shared source-reference shape: + +```json +{ + "source_id": "session-alpha", + "start_unit_id": 1, + "end_unit_id": 2 +} +``` + +`source_id` must be non-empty and both unit IDs must be positive integers. The +codec does not resolve references against a source document or enforce source +range ordering; those checks belong to the later source-reference validation +boundary. + +## Codec behavior + +The codec exposes two representations of the same typed artifact: + +- Candidate encode/decode preserves invalid enum values, nullable values, + required-array presence, required strings, targets, and source references so + later validators can report them. Candidate decoding still requires valid + JSON, one JSON value, known fields, and the explicitly present `round` and + `resolution` keys; `null` is distinct from a missing key. +- Approved encode/decode enforces the structural rules in this contract. + +The codec owns the durable JSON Schema, whose object layers all set +`additionalProperties` to `false`. Codec metadata contains only +`combat_turn_count`. + +The maintained compact fixture is +`internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json`. diff --git a/internal/modules/dnd/codec/combatturns/assets/schemas/dnd_combat_turns.v1.json b/internal/modules/dnd/codec/combatturns/assets/schemas/dnd_combat_turns.v1.json new file mode 100644 index 0000000..b7aa233 --- /dev/null +++ b/internal/modules/dnd/codec/combatturns/assets/schemas/dnd_combat_turns.v1.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.combat_turns", + "type": "object", + "additionalProperties": false, + "required": ["combat_turns"], + "properties": { + "combat_turns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"], + "properties": { + "actor": { + "type": "string", + "minLength": 1 + }, + "turn_kind": { + "type": "string", + "enum": ["turn", "reaction", "legendary_action", "lair_action", "other"] + }, + "round": { + "type": ["integer", "null"], + "minimum": 1 + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["category", "declaration", "targets", "resolution"], + "properties": { + "category": { + "type": "string", + "enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"] + }, + "declaration": { + "type": "string", + "minLength": 1 + }, + "targets": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "resolution": { + "type": ["string", "null"], + "minLength": 1 + } + } + } + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "start_unit_id": { + "type": "integer", + "minimum": 1 + }, + "end_unit_id": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/codec/combatturns/codec.go b/internal/modules/dnd/codec/combatturns/codec.go new file mode 100644 index 0000000..01342f2 --- /dev/null +++ b/internal/modules/dnd/codec/combatturns/codec.go @@ -0,0 +1,259 @@ +package combatturns + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +const ( + SchemaID = "notarius.dnd.combat_turns" + SchemaName = "notarius_dnd_combat_turns_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +//go:embed assets/schemas/dnd_combat_turns.v1.json +var schemaAssets embed.FS + +var _ contracts.ArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil) + +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.CombatTurnListKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_combat_turns.v1.json") + if err != nil { + return contracts.ArtifactSchema{} + } + return contracts.ArtifactSchema{ + ID: SchemaID, + Name: SchemaName, + Version: SchemaVersion, + JSONSchema: append([]byte(nil), raw...), + } +} + +func (c *Codec) MediaType() string { return MediaType } + +func (c *Codec) Metadata(value dnd.CombatTurnList) map[string]any { + return map[string]any{"combat_turn_count": len(value.CombatTurns)} +} + +func (c *Codec) Encode(value dnd.CombatTurnList) ([]byte, error) { + if err := validate(value); err != nil { + return nil, fmt.Errorf("encode dnd combat turn list: %w", err) + } + return c.EncodeCandidate(value) +} + +// EncodeCandidate provides the durable representation before typed validators +// have approved a value. +func (c *Codec) EncodeCandidate(value dnd.CombatTurnList) ([]byte, error) { + content, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode dnd combat turn list: %w", err) + } + return content, nil +} + +func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) { + value, err := c.DecodeCandidate(content) + if err != nil { + return dnd.CombatTurnList{}, err + } + if err := validate(value); err != nil { + return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err) + } + return value, nil +} + +// DecodeCandidate reads one strict durable JSON value before semantic +// validators have approved it. Nullable round and resolution fields are +// decoded through raw values so a missing key is not confused with null. +func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var wire combatTurnListWire + if err := decoder.Decode(&wire); err != nil { + return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values") + } + + value := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, len(wire.CombatTurns))} + if wire.CombatTurns == nil { + value.CombatTurns = nil + } + for index, turn := range wire.CombatTurns { + round, err := decodeNullableInt(turn.Round, fmt.Sprintf("combat_turns[%d].round", index)) + if err != nil { + return dnd.CombatTurnList{}, err + } + actions := make([]dnd.CombatAction, len(turn.Actions)) + if turn.Actions == nil { + actions = nil + } + for actionIndex, action := range turn.Actions { + resolution, err := decodeNullableString(action.Resolution, fmt.Sprintf("combat_turns[%d].actions[%d].resolution", index, actionIndex)) + if err != nil { + return dnd.CombatTurnList{}, err + } + actions[actionIndex] = dnd.CombatAction{ + Category: action.Category, + Declaration: action.Declaration, + Targets: action.Targets, + Resolution: resolution, + } + } + value.CombatTurns[index] = dnd.CombatTurn{ + Actor: turn.Actor, + TurnKind: turn.TurnKind, + Round: round, + Actions: actions, + Summary: turn.Summary, + SourceRefs: turn.SourceRefs, + } + } + return value, nil +} + +type combatTurnListWire struct { + CombatTurns []combatTurnWire `json:"combat_turns"` +} + +type combatTurnWire struct { + Actor string `json:"actor"` + TurnKind dnd.CombatTurnKind `json:"turn_kind"` + Round json.RawMessage `json:"round"` + Actions []combatActionWire `json:"actions"` + Summary string `json:"summary"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + +type combatActionWire struct { + Category dnd.CombatActionCategory `json:"category"` + Declaration string `json:"declaration"` + Targets []string `json:"targets"` + Resolution json.RawMessage `json:"resolution"` +} + +func decodeNullableInt(raw json.RawMessage, field string) (*int, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%s must be present", field) + } + trimmed := bytes.TrimSpace(raw) + if bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + var value int + if err := json.Unmarshal(trimmed, &value); err != nil { + return nil, fmt.Errorf("%s must be an integer or null: %w", field, err) + } + return &value, nil +} + +func decodeNullableString(raw json.RawMessage, field string) (*string, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%s must be present", field) + } + trimmed := bytes.TrimSpace(raw) + if bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + var value string + if err := json.Unmarshal(trimmed, &value); err != nil { + return nil, fmt.Errorf("%s must be a string or null: %w", field, err) + } + return &value, nil +} + +func validate(value dnd.CombatTurnList) error { + if value.CombatTurns == nil { + return fmt.Errorf("combat_turns must be present") + } + for index, turn := range value.CombatTurns { + prefix := fmt.Sprintf("combat_turns[%d]", index) + if strings.TrimSpace(turn.Actor) == "" { + return fmt.Errorf("%s.actor must not be empty", prefix) + } + if !validTurnKind(turn.TurnKind) { + return fmt.Errorf("%s.turn_kind must be supported", prefix) + } + if turn.Round != nil && *turn.Round <= 0 { + return fmt.Errorf("%s.round must be positive or null", prefix) + } + if len(turn.Actions) == 0 { + return fmt.Errorf("%s.actions must contain at least one action", prefix) + } + if strings.TrimSpace(turn.Summary) == "" { + return fmt.Errorf("%s.summary must not be empty", prefix) + } + if len(turn.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) + } + for actionIndex, action := range turn.Actions { + actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex) + if !validActionCategory(action.Category) { + return fmt.Errorf("%s.category must be supported", actionPrefix) + } + if strings.TrimSpace(action.Declaration) == "" { + return fmt.Errorf("%s.declaration must not be empty", actionPrefix) + } + if action.Targets == nil { + return fmt.Errorf("%s.targets must be present", actionPrefix) + } + for targetIndex, target := range action.Targets { + if strings.TrimSpace(target) == "" { + return fmt.Errorf("%s.targets[%d] must not be empty", actionPrefix, targetIndex) + } + } + if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" { + return fmt.Errorf("%s.resolution must not be empty or null", actionPrefix) + } + } + for refIndex, ref := range turn.SourceRefs { + refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) + if strings.TrimSpace(ref.SourceID) == "" { + return fmt.Errorf("%s.source_id must not be empty", refPrefix) + } + if ref.StartUnitID <= 0 { + return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) + } + if ref.EndUnitID <= 0 { + return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + } + } + } + return nil +} + +func validTurnKind(value dnd.CombatTurnKind) bool { + switch value { + case dnd.CombatTurnKindTurn, dnd.CombatTurnKindReaction, dnd.CombatTurnKindLegendaryAction, dnd.CombatTurnKindLairAction, dnd.CombatTurnKindOther: + return true + default: + return false + } +} + +func validActionCategory(value dnd.CombatActionCategory) bool { + switch value { + case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther: + return true + default: + return false + } +} diff --git a/internal/modules/dnd/codec/combatturns/codec_test.go b/internal/modules/dnd/codec/combatturns/codec_test.go new file mode 100644 index 0000000..2d95d92 --- /dev/null +++ b/internal/modules/dnd/codec/combatturns/codec_test.go @@ -0,0 +1,287 @@ +package combatturns + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func validList() dnd.CombatTurnList { + round := 1 + resolution := "The wight is hit." + return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Aria", + TurnKind: dnd.CombatTurnKindTurn, + Round: &round, + Actions: []dnd.CombatAction{{ + Category: dnd.CombatActionCategoryAttack, + Declaration: "Aria swings her sword", + Targets: []string{"wight"}, + Resolution: &resolution, + }}, + Summary: "Aria attacks the wight.", + SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, + }}} +} + +func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json") + if err != nil { + t.Fatalf("read durable fixture: %v", err) + } + codec := New() + value, err := codec.Decode(raw) + if err != nil { + t.Fatalf("Decode() error = %v, want nil", err) + } + if want := validList(); !reflect.DeepEqual(value, want) { + t.Fatalf("Decode() = %#v, want %#v", value, want) + } + encoded, err := codec.Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v, want nil", err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil { + t.Fatalf("compact durable fixture: %v", err) + } + if !bytes.Equal(encoded, compact.Bytes()) { + t.Fatalf("Encode() = %s, want stable durable JSON %s", encoded, compact.Bytes()) + } + second, err := codec.Encode(value) + if err != nil || !bytes.Equal(second, encoded) { + t.Fatalf("second Encode() = %s, %v; want deterministic bytes", second, err) + } +} + +func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { + codec := New() + schema := codec.Schema() + if codec.Kind() != dnd.CombatTurnListKind || codec.MediaType() != MediaType { + t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType()) + } + if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { + t.Fatalf("schema = %#v, want durable combat-turn schema", schema) + } + var document map[string]any + if err := json.Unmarshal(schema.JSONSchema, &document); err != nil || document["$id"] != SchemaID { + t.Fatalf("durable schema document = %#v, %v", document, err) + } + + registry := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { + t.Fatalf("RegisterArtifactCodec() error = %v", err) + } + spec, ok := registry.Spec(dnd.CombatTurnListKind) + if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { + t.Fatalf("registered spec = %#v, %t", spec, ok) + } +} + +func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) { + codec := New() + validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}],"summary":"attack","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` + tests := []struct { + name string + raw string + want string + }{ + {name: "malformed", raw: `{`, want: "decode dnd combat turn list"}, + {name: "unknown top-level", raw: `{"combat_turns":[],"unexpected":true}`, want: "unknown field"}, + {name: "unknown turn field", raw: strings.Replace(validJSON, `"summary":"attack"`, `"summary":"attack","unexpected":true`, 1), want: "unknown field"}, + {name: "unknown action field", raw: strings.Replace(validJSON, `"resolution":"hits"`, `"resolution":"hits","unexpected":true`, 1), want: "unknown field"}, + {name: "unknown source reference field", raw: strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), want: "unknown field"}, + {name: "trailing", raw: `{"combat_turns":[]} {}`, want: "multiple JSON values"}, + {name: "missing top-level array", raw: `{}`, want: "combat_turns must be present"}, + {name: "missing round", raw: strings.Replace(validJSON, `,"round":1`, ``, 1), want: "round must be present"}, + {name: "missing resolution", raw: strings.Replace(validJSON, `,"resolution":"hits"`, ``, 1), want: "resolution must be present"}, + {name: "invalid round type", raw: strings.Replace(validJSON, `"round":1`, `"round":1.5`, 1), want: "round must be an integer or null"}, + {name: "unsupported turn kind", raw: strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), want: "turn_kind must be supported"}, + {name: "empty actions", raw: strings.Replace(validJSON, `"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}]`, `"actions":[]`, 1), want: "actions must contain at least one action"}, + {name: "empty target", raw: strings.Replace(validJSON, `"targets":["wight"]`, `"targets":[" "]`, 1), want: "targets[0] must not be empty"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := codec.Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { + round := -1 + resolution := " " + candidate := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: " ", + TurnKind: "unsupported", + Round: &round, + Actions: []dnd.CombatAction{{ + Category: "unsupported", + Declaration: " ", + Targets: nil, + Resolution: &resolution, + }}, + Summary: " ", + SourceRefs: []source.SourceRef{{ + SourceID: "", + StartUnitID: 0, + EndUnitID: -1, + }}, + }}} + + content, err := New().EncodeCandidate(candidate) + if err != nil || !json.Valid(content) { + t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err) + } + decoded, err := New().DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } +} + +func TestCodecCandidatePreservesNilAndPresentEmptyArrays(t *testing.T) { + codec := New() + for name, value := range map[string]dnd.CombatTurnList{ + "nil combat turns": {}, + "empty combat turns": {CombatTurns: []dnd.CombatTurn{}}, + } { + t.Run(name, func(t *testing.T) { + content, err := codec.EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, value) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value) + } + }) + } + + withoutTargets := validList() + withoutTargets.CombatTurns[0].Actions[0].Targets = nil + withEmptyTargets := validList() + withEmptyTargets.CombatTurns[0].Actions[0].Targets = []string{} + for name, value := range map[string]dnd.CombatTurnList{ + "nil targets": withoutTargets, + "empty targets": withEmptyTargets, + } { + t.Run(name, func(t *testing.T) { + content, err := codec.EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, value) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value) + } + }) + } + + withoutActions := validList() + withoutActions.CombatTurns[0].Actions = nil + withEmptyActions := validList() + withEmptyActions.CombatTurns[0].Actions = []dnd.CombatAction{} + withoutSourceRefs := validList() + withoutSourceRefs.CombatTurns[0].SourceRefs = nil + withEmptySourceRefs := validList() + withEmptySourceRefs.CombatTurns[0].SourceRefs = []source.SourceRef{} + for name, value := range map[string]dnd.CombatTurnList{ + "nil actions": withoutActions, + "empty actions": withEmptyActions, + "nil source refs": withoutSourceRefs, + "empty source refs": withEmptySourceRefs, + } { + t.Run(name, func(t *testing.T) { + content, err := codec.EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, value) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value) + } + }) + } +} + +func TestCodecAcceptsExplicitNullableFieldsAndEmptyTargets(t *testing.T) { + value := validList() + value.CombatTurns[0].Round = nil + value.CombatTurns[0].Actions[0].Resolution = nil + value.CombatTurns[0].Actions[0].Targets = []string{} + + codec := New() + content, err := codec.Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v, want nil for explicit nullable fields", err) + } + decoded, err := codec.Decode(content) + if err != nil || !reflect.DeepEqual(decoded, value) { + t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value) + } +} + +func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) { + tests := []struct { + name string + value dnd.CombatTurnList + want string + }{ + {name: "nil combat turns", value: dnd.CombatTurnList{}, want: "combat_turns must be present"}, + {name: "empty actor", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }), want: "actor must not be empty"}, + {name: "unsupported turn kind", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unsupported" }), want: "turn_kind must be supported"}, + {name: "non-positive round", value: mutate(validList(), func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }), want: "round must be positive or null"}, + {name: "nil actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }), want: "actions must contain at least one action"}, + {name: "empty actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = []dnd.CombatAction{} }), want: "actions must contain at least one action"}, + {name: "empty summary", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }), want: "summary must not be empty"}, + {name: "nil source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }), want: "source_refs must contain at least one reference"}, + {name: "empty source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = []source.SourceRef{} }), want: "source_refs must contain at least one reference"}, + {name: "unsupported action category", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unsupported" }), want: "category must be supported"}, + {name: "empty declaration", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }), want: "declaration must not be empty"}, + {name: "nil targets", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }), want: "targets must be present"}, + {name: "empty target", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }), want: "targets[0] must not be empty"}, + {name: "empty resolution", value: mutate(validList(), func(value *dnd.CombatTurnList) { + resolution := " " + value.CombatTurns[0].Actions[0].Resolution = &resolution + }), want: "resolution must not be empty or null"}, + {name: "empty source ID", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].SourceID = " " }), want: "source_id must not be empty"}, + {name: "non-positive source start", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), want: "start_unit_id must be positive"}, + {name: "non-positive source end", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), want: "end_unit_id must be positive"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := New().Encode(test.value); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) { + codec := New() + first := codec.Schema() + first.JSONSchema[0] = '[' + second := codec.Schema() + if !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' { + t.Fatal("Schema() returned shared bytes") + } + metadata := codec.Metadata(validList()) + metadata["other"] = true + if next := codec.Metadata(validList()); len(next) != 1 || next["combat_turn_count"] != 1 { + t.Fatalf("Metadata() = %#v, want only combat_turn_count", next) + } +} + +func mutate(value dnd.CombatTurnList, change func(*dnd.CombatTurnList)) dnd.CombatTurnList { + change(&value) + return value +} diff --git a/internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json b/internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json new file mode 100644 index 0000000..5ffdd8f --- /dev/null +++ b/internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json @@ -0,0 +1 @@ +{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"Aria swings her sword","targets":["wight"],"resolution":"The wight is hit."}],"summary":"Aria attacks the wight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]} diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 882a5e2..1b8858b 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -10,6 +10,8 @@ const SpellListKind contracts.ArtifactKind = "dnd/spell-list" const NPCListKind contracts.ArtifactKind = "dnd/npc-list" +const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list" + type SpellList struct { SpellCasts []SpellCast `json:"spell_casts"` } @@ -39,3 +41,46 @@ type NPCRelationship struct { Target string `json:"target"` Relationship string `json:"relationship"` } + +type CombatTurnList struct { + CombatTurns []CombatTurn `json:"combat_turns"` +} + +type CombatTurnKind string + +const ( + CombatTurnKindTurn CombatTurnKind = "turn" + CombatTurnKindReaction CombatTurnKind = "reaction" + CombatTurnKindLegendaryAction CombatTurnKind = "legendary_action" + CombatTurnKindLairAction CombatTurnKind = "lair_action" + CombatTurnKindOther CombatTurnKind = "other" +) + +type CombatTurn struct { + Actor string `json:"actor"` + TurnKind CombatTurnKind `json:"turn_kind"` + Round *int `json:"round"` + Actions []CombatAction `json:"actions"` + Summary string `json:"summary"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + +type CombatActionCategory string + +const ( + CombatActionCategoryAttack CombatActionCategory = "attack" + CombatActionCategorySpell CombatActionCategory = "spell" + CombatActionCategoryMovement CombatActionCategory = "movement" + CombatActionCategoryItem CombatActionCategory = "item" + CombatActionCategoryAbilityCheck CombatActionCategory = "ability_check" + CombatActionCategorySavingThrow CombatActionCategory = "saving_throw" + CombatActionCategoryCondition CombatActionCategory = "condition" + CombatActionCategoryOther CombatActionCategory = "other" +) + +type CombatAction struct { + Category CombatActionCategory `json:"category"` + Declaration string `json:"declaration"` + Targets []string `json:"targets"` + Resolution *string `json:"resolution"` +}