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 } }