diff --git a/docs/config.md b/docs/config.md index 05defa1..8f700d2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -69,9 +69,15 @@ remote state with an unsafe legacy identity must be migrated before use. - Pipeline defaults are applied before validation. - Campaign and session identities must agree. - Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, - `players_file`, `party_file`) and the optional `spell_catalog_file` resolve - from session overrides when provided, otherwise from campaign defaults. An - empty or omitted session spell-catalog value inherits the campaign value. + `party_file`) and the optional `spell_catalog_file` resolve from session + overrides when provided, otherwise from campaign defaults. An empty or + omitted session spell-catalog value inherits the campaign value. +- `party_file` is classified when pipeline and campaign configuration are + combined. A versioned [canonical party](integrations/party.md) is + campaign-owned, derives the players input internally, and forbids both a + separate `players_file` and a session `party_file` override. An unversioned + party remains a bounded legacy input and requires `players_file`; its normal + campaign/session overrides continue to apply. - Exactly one audio mode must be configured in session input: - local (`audio_dir` or `audio_files`), or - S3 (`audio_s3.prefix`). @@ -433,8 +439,8 @@ integration. | `inputs.speakers_file` | string | Yes | stable input default | | `inputs.autocorrect_file` | string | Yes | stable input default | | `inputs.glossary_file` | string | Yes | stable input default | -| `inputs.players_file` | string | Yes | stable input default | -| `inputs.party_file` | string | Yes | stable input default | +| `inputs.players_file` | string | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party | +| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` | | `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` | ### Session @@ -449,8 +455,8 @@ integration. | `inputs.speakers_file` | string | No | overrides campaign stable input | | `inputs.autocorrect_file` | string | No | overrides campaign stable input | | `inputs.glossary_file` | string | No | overrides campaign stable input | -| `inputs.players_file` | string | No | overrides campaign stable input | -| `inputs.party_file` | string | No | overrides campaign stable input | +| `inputs.players_file` | string | No | legacy-party override; forbidden for a canonical party | +| `inputs.party_file` | string | No | legacy-party override; forbidden for a canonical campaign party | | `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value | | `inputs.audio_dir` | string | Conditional | local audio mode | | `inputs.audio_files[]` | list[string] | Conditional | local audio mode | diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 7447fc4..7a36aff 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -21,6 +21,7 @@ focused stage documents. - [Audita](./audita.md): transcript polishing (`audita process`). - [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle discovery (`notarius run`). +- [Party](./party.md): canonical campaign roster input. - [Seriatim](./seriatim.md): merge, normalize, trim, and render operations. - [Scriptorium](./scriptorium.md): artifact generation and debug rendering (`scriptorium run|render`). diff --git a/docs/integrations/party.md b/docs/integrations/party.md new file mode 100644 index 0000000..827e482 --- /dev/null +++ b/docs/integrations/party.md @@ -0,0 +1,49 @@ +# Canonical Party Input + +`party.yml` is a campaign-owned roster input. Narratio recognizes the +versioned `narratio.party.v1` document below when it resolves a pipeline, +campaign, and session together. + +```yaml +schema_version: narratio.party.v1 + +characters: + arannis: + player: + name: Eric + character: + name: Arannis + alias: + - Ari + - The Grey Owl + classes: + - name: wizard + level: 8 +``` + +`characters` is a non-empty mapping. Each key is a stable character ID using +the configured-artifact key grammar: a lowercase ASCII letter followed by zero +or more lowercase ASCII letters, digits, or underscores. Character order is +preserved where roster order matters. + +Every entry has `player.name`, `character.name`, and a non-empty +`character.classes` list. Class entries require a non-empty `name` and may +include a positive integer `level`. The optional, intentionally singular +`character.alias` field is a list. Names, aliases, and class names must be +non-empty, trimmed display strings without control characters. Character names +and aliases must be unique across the full roster under Unicode-aware +case-insensitive comparison; player names may repeat. + +The document has exactly one YAML document and accepts no unknown fields. A +wrong or malformed `schema_version` is an error. + +## Legacy migration boundary + +An unversioned party input remains supported only as opaque legacy reference +material while campaigns migrate. It requires a separate `players_file` and +retains the existing session override behavior. It cannot be mixed with a +canonical party: canonical campaigns must omit `players_file`, and sessions +must not override their party or players inputs. + +Use the canonical document for new campaigns. The configuration rules and +source-relative path behavior are defined in the [Configuration Reference](../config.md). diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index 2c2fc6e..57777d9 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -52,6 +52,14 @@ download retain that exact pipeline object and its private provenance. Removing a temporary downloaded session file therefore cannot invalidate the resolved pipeline or campaign context. +Campaign context construction also reads and classifies the campaign-owned +party source through `ParseParty`. A canonical party retains its raw bytes and +normalized roster in runtime-only `ResolvedParty` provenance, while a legacy +party remains opaque. Canonical resolution creates a virtual +`derived_from_party` players input and rejects competing campaign or session +players files and session party overrides. The compact legacy compatibility +path resolves the effective campaign/session party and players files together. + ## Canonical Party Domain `ParseParty` is the package-owned boundary for classifying a party source. @@ -103,3 +111,6 @@ confirming local session resolution retains the original pipeline. Other configuration tests continue to protect defaults and validation after assembly. `party_test.go` protects the versioned party schema, domain invariants, and deterministic players projection without involving campaign or runtime wiring. +`party_resolution_test.go` protects campaign-owned party loading, canonical +input restrictions, legacy overrides, source provenance, and virtual players +input selection. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 01b7bc6..2393070 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -655,7 +655,7 @@ resolution. ## Stage 11 — Campaign Party Resolution And Isolated Legacy Mode -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 50f3b83..4127857 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -563,6 +563,7 @@ inputs: if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign.yml: %v", err) } + mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n") return campaignPath } diff --git a/internal/app/config_loader.go b/internal/app/config_loader.go index 5260932..3a554ca 100644 --- a/internal/app/config_loader.go +++ b/internal/app/config_loader.go @@ -155,12 +155,11 @@ func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag str return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID) } } - return &pipelineCampaignConfig{ - PipelinePath: loadedPipelinePath, - CampaignPath: resolvedCampaignPath, - Pipeline: pipelineCfg, - Campaign: campaignCfg, - }, nil + loaded, err := config.LoadPipelineCampaign(loadedPipelinePath, pipelineCfg, resolvedCampaignPath, campaignCfg) + if err != nil { + return nil, err + } + return &loaded, nil } func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) { diff --git a/internal/app/remote_session_test.go b/internal/app/remote_session_test.go index 4484d79..b0d4f77 100644 --- a/internal/app/remote_session_test.go +++ b/internal/app/remote_session_test.go @@ -44,6 +44,49 @@ inputs: } } +func TestRemoteSessionLoadingRetainsCampaignCanonicalParty(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) + canonicalCampaign := `campaign_id: sample-campaign +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml + party_file: ./party.yml +` + if err := os.WriteFile(campaignPath, []byte(canonicalCampaign), 0o644); err != nil { + t.Fatalf("write canonical campaign: %v", err) + } + canonicalParty := `schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + classes: [{name: wizard}] +` + if err := os.WriteFile(filepath.Join(filepath.Dir(campaignPath), "party.yml"), []byte(canonicalParty), 0o644); err != nil { + t.Fatalf("write canonical party: %v", err) + } + fake := &storage.FakeBackend{} + seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03 +inputs: + audio_s3: + prefix: audio/ +`) + var storeInitCalls int + restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + + loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, "", config.SessionLoadOptions{SessionID: "2026-05-03"}) + if err != nil { + t.Fatalf("loadCommandConfig() error = %v", err) + } + defer func() { _ = loaded.Close() }() + if loaded.Config.Party.Mode != config.PartyModeCanonical || loaded.Config.StableInputs.PlayersFile.Source != "derived_from_party" { + t.Fatalf("remote config party = %#v, players = %#v", loaded.Config.Party, loaded.Config.StableInputs.PlayersFile) + } +} + func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) { tests := []struct { name string diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index c6965a3..6a3082f 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -1406,6 +1406,7 @@ inputs: mustWriteFile(t, pipelinePath, pipelineYAML) mustWriteFile(t, campaignPath, campaignYAML) mustWriteFile(t, sessionPath, sessionYAML) + mustWriteFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n") cfg, err := config.Load(pipelinePath, sessionPath) if err != nil { diff --git a/internal/config/campaign_config_test.go b/internal/config/campaign_config_test.go index 5147804..8a164b1 100644 --- a/internal/config/campaign_config_test.go +++ b/internal/config/campaign_config_test.go @@ -206,21 +206,17 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) { } } -func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) { +func TestLegacyCampaignRequiresPlayersInput(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) - cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) - if err != nil { - t.Fatalf("LoadWithSessionOptions() error = %v", err) - } - err = Validate(cfg) + _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err == nil { - t.Fatal("expected validation error, got nil") + t.Fatal("expected load error, got nil") } - if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") { + if !strings.Contains(err.Error(), "players_file is required with a legacy party") { t.Fatalf("error = %q, want players_file required", err.Error()) } } @@ -274,6 +270,11 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } + for _, name := range []string{"party.yml", "campaign-party.yml", "session-party.yml"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("legacy: party\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } return pipelinePath, campaignPath, sessionPath } diff --git a/internal/config/config.go b/internal/config/config.go index 7e0cb1e..18e09d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { StableInputs ResolvedStableInputs SessionSource SessionSource + Party ResolvedParty } // PipelineConfig contains durable pipeline-level settings. @@ -323,6 +324,23 @@ type ResolvedInputFile struct { Source string } +// ResolvedParty records the selected party mode, its non-secret source +// provenance, and canonical domain data when available. It is runtime-only and +// must never be copied into manifests as raw party content. +type ResolvedParty struct { + Mode PartyMode + Source PartySource + Canonical *CanonicalParty +} + +// PartySource identifies the selected party input without retaining its raw +// contents. Path is the resolved on-disk source path. +type PartySource struct { + Path string + ConfigPath string + Source string +} + // SessionSource records where session.yml came from before materialization. type SessionSource struct { Source string diff --git a/internal/config/load.go b/internal/config/load.go index f1750fb..07e47b2 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -167,12 +167,11 @@ func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sess if err != nil { return nil, err } - return LoadSessionWithPipelineCampaignOptions(LoadedPipelineCampaign{ - PipelinePath: pipelinePath, - Pipeline: pipelineCfg, - CampaignPath: campaignPath, - Campaign: campaignCfg, - }, sessionPath, sessionOpts) + loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg) + if err != nil { + return nil, err + } + return LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, sessionOpts) } // LoadedPipelineCampaign retains one already loaded pipeline and campaign for @@ -183,6 +182,35 @@ type LoadedPipelineCampaign struct { Pipeline *PipelineConfig CampaignPath string Campaign *CampaignConfig + Party ResolvedParty +} + +// LoadPipelineCampaign combines already loaded pipeline and campaign documents +// with their campaign-owned party source. Callers that later resolve a session +// retain this context rather than independently reimplementing party loading. +func LoadPipelineCampaign(pipelinePath string, pipeline *PipelineConfig, campaignPath string, campaign *CampaignConfig) (LoadedPipelineCampaign, error) { + if pipeline == nil { + return LoadedPipelineCampaign{}, fmt.Errorf("pipeline config is required") + } + if campaign == nil { + return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required") + } + party, err := resolveCampaignParty(campaignPath, campaign) + if err != nil { + return LoadedPipelineCampaign{}, err + } + if party.Mode == PartyModeCanonical { + if err := validateCanonicalPartySelection(campaign, nil); err != nil { + return LoadedPipelineCampaign{}, err + } + } + return LoadedPipelineCampaign{ + PipelinePath: pipelinePath, + Pipeline: pipeline, + CampaignPath: campaignPath, + Campaign: campaign, + Party: party, + }, nil } // LoadSessionWithPipelineCampaignOptions loads one local session and combines @@ -203,6 +231,24 @@ func LoadSessionWithPipelineCampaignOptions(loaded LoadedPipelineCampaign, sessi // the resolved pipeline/campaign context for callers that need to locate or // retrieve a session without rereading the root pipeline. func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) { + if loaded.Pipeline == nil { + return nil, fmt.Errorf("pipeline config is required") + } + if loaded.Campaign == nil { + return nil, fmt.Errorf("campaign config is required") + } + if loaded.Party.Mode == "" { + party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign) + if err != nil { + return nil, err + } + loaded.Party = party + } + if loaded.Party.Mode == PartyModeCanonical { + if err := validateCanonicalPartySelection(loaded.Campaign, nil); err != nil { + return nil, err + } + } cfg := &Config{ Pipeline: loaded.Pipeline, Campaign: loaded.Campaign, @@ -211,6 +257,12 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st CampaignPath: loaded.CampaignPath, SessionPath: sessionPath, SessionSource: sessionSource, + Party: loaded.Party, + } + if cfg.Party.Mode == PartyModeCanonical && sessionCfg != nil { + if err := validateCanonicalPartySelection(loaded.Campaign, sessionCfg); err != nil { + return nil, err + } } if sessionCfg == nil { return cfg, nil @@ -226,6 +278,22 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" { cfg.SessionSource.LocalPath = sessionPath } + if cfg.Party.Mode == PartyModeCanonical { + stableInputs.PlayersFile = virtualPlayersInput() + cfg.Session.Inputs.PlayersFile = "" + } else { + party, err := resolvePartyInput(stableInputs.PartyFile) + if err != nil { + return nil, err + } + if party.Mode == PartyModeCanonical { + return nil, fmt.Errorf("session.inputs.party_file cannot select a canonical party; canonical parties are campaign-owned") + } + cfg.Party = party + if strings.TrimSpace(stableInputs.PlayersFile.Path) == "" { + return nil, fmt.Errorf("players_file is required with a legacy party") + } + } cfg.StableInputs = stableInputs return cfg, nil } @@ -236,12 +304,11 @@ func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath stri if sessionCfg == nil { return nil, fmt.Errorf("session config is required") } - return ResolveLoadedPipelineCampaign(LoadedPipelineCampaign{ - PipelinePath: pipelinePath, - Pipeline: pipelineCfg, - CampaignPath: campaignPath, - Campaign: campaignCfg, - }, sessionPath, sessionCfg, sessionSource) + loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg) + if err != nil { + return nil, err + } + return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource) } func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) { diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index a4933bb..9f0aff7 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -176,6 +176,9 @@ inputs: if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } + if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte("legacy: party\n"), 0o644); err != nil { + t.Fatalf("write party.yml: %v", err) + } return pipelinePath, sessionPath } diff --git a/internal/config/party_legacy.go b/internal/config/party_legacy.go index 598aaab..80543c1 100644 --- a/internal/config/party_legacy.go +++ b/internal/config/party_legacy.go @@ -6,3 +6,12 @@ package config func classifyLegacyPartyDocument() *PartyDocument { return &PartyDocument{Mode: PartyModeLegacy} } + +// resolveLegacyParty preserves the deliberately opaque legacy party mode. +// This small compatibility surface is intended for removal once legacy +// campaign inputs are no longer supported. +func resolveLegacyParty(resolved ResolvedParty) (ResolvedParty, error) { + resolved.Mode = PartyModeLegacy + resolved.Canonical = nil + return resolved, nil +} diff --git a/internal/config/party_resolution.go b/internal/config/party_resolution.go new file mode 100644 index 0000000..8d019d4 --- /dev/null +++ b/internal/config/party_resolution.go @@ -0,0 +1,93 @@ +package config + +import ( + "fmt" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/fileops" +) + +const maxPartyDocumentBytes = 8 << 20 + +func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) { + configuredPath := strings.TrimSpace(input.Path) + if configuredPath == "" { + return ResolvedParty{}, fmt.Errorf("party input path is required") + } + configPath := strings.TrimSpace(input.ConfigPath) + if configPath == "" { + return ResolvedParty{}, fmt.Errorf("party input %q has no declaring configuration path", configuredPath) + } + path := configuredPath + if !filepath.IsAbs(path) { + path = filepath.Join(filepath.Dir(configPath), path) + } + path = filepath.Clean(path) + raw, err := fileops.ReadRegularFile(path, maxPartyDocumentBytes) + if err != nil { + return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err) + } + document, err := ParseParty(raw) + if err != nil { + return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err) + } + resolved := ResolvedParty{ + Mode: document.Mode, + Source: PartySource{ + Path: path, + ConfigPath: configPath, + Source: input.Source, + }, + Canonical: document.Canonical, + } + if document.IsCanonical() { + return resolved, nil + } + return resolveLegacyParty(resolved) +} + +func campaignPartyInput(campaignPath string, campaign *CampaignConfig) (ResolvedInputFile, error) { + if campaign == nil { + return ResolvedInputFile{}, fmt.Errorf("campaign config is required") + } + if strings.TrimSpace(campaign.Inputs.PartyFile) == "" { + return ResolvedInputFile{}, fmt.Errorf("campaign.inputs.party_file is required") + } + return ResolvedInputFile{ + Path: campaign.Inputs.PartyFile, + ConfigPath: campaignPath, + Source: "campaign_config", + }, nil +} + +func resolveCampaignParty(campaignPath string, campaign *CampaignConfig) (ResolvedParty, error) { + input, err := campaignPartyInput(campaignPath, campaign) + if err != nil { + return ResolvedParty{}, err + } + return resolvePartyInput(input) +} + +func validateCanonicalPartySelection(campaign *CampaignConfig, session *SessionConfig) error { + if campaign == nil { + return fmt.Errorf("campaign config is required") + } + if strings.TrimSpace(campaign.Inputs.PlayersFile) != "" { + return fmt.Errorf("campaign.inputs.players_file is not allowed with a canonical party") + } + if session == nil { + return nil + } + if strings.TrimSpace(session.Inputs.PlayersFile) != "" { + return fmt.Errorf("session.inputs.players_file is not allowed with a canonical party") + } + if strings.TrimSpace(session.Inputs.PartyFile) != "" { + return fmt.Errorf("session.inputs.party_file cannot override a canonical campaign party") + } + return nil +} + +func virtualPlayersInput() ResolvedInputFile { + return ResolvedInputFile{Source: "derived_from_party"} +} diff --git a/internal/config/party_resolution_test.go b/internal/config/party_resolution_test.go new file mode 100644 index 0000000..bdc0b68 --- /dev/null +++ b/internal/config/party_resolution_test.go @@ -0,0 +1,240 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCanonicalPartyResolvesFromCampaignAndDerivesVirtualPlayers(t *testing.T) { + dir := t.TempDir() + partyPath := filepath.Join(dir, "roster", "party.yml") + if err := os.Mkdir(filepath.Dir(partyPath), 0o755); err != nil { + t.Fatalf("create roster directory: %v", err) + } + partyYAML := []byte(`schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + classes: [{name: wizard, level: 8}] +`) + if err := os.WriteFile(partyPath, partyYAML, 0o644); err != nil { + t.Fatalf("write party: %v", err) + } + pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign +inputs: + speakers_file: speakers.yml + autocorrect_file: autocorrect.yml + glossary_file: glossary.yml + party_file: roster/party.yml +`, `session_id: session +campaign: campaign +inputs: + audio_dir: audio +`) + + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if cfg.Party.Mode != PartyModeCanonical || cfg.Party.Canonical == nil { + t.Fatalf("party = %#v, want canonical party", cfg.Party) + } + if got, want := cfg.Party.Source.Path, partyPath; got != want { + t.Fatalf("party source path = %q, want %q", got, want) + } + if cfg.Party.Source.Source != "campaign_config" || cfg.Party.Source.ConfigPath != campaignPath { + t.Fatalf("party provenance = %#v, want campaign source", cfg.Party.Source) + } + if !bytes.Equal(cfg.Party.Canonical.Raw, partyYAML) { + t.Fatal("canonical party bytes were not retained") + } + if got := cfg.StableInputs.PlayersFile; got.Source != "derived_from_party" || got.Path != "" || got.ConfigPath != "" { + t.Fatalf("derived players input = %#v, want virtual party projection source", got) + } +} + +func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing.T) { + tests := []struct { + name string + campaignExtra string + sessionExtra string + want string + }{ + {name: "campaign players", campaignExtra: " players_file: players.yml\n", want: "campaign.inputs.players_file is not allowed"}, + {name: "session players", sessionExtra: " players_file: players.yml\n", want: "session.inputs.players_file is not allowed"}, + {name: "session party", sessionExtra: " party_file: party-override.yml\n", want: "session.inputs.party_file cannot override"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), canonicalPartyFixture) + pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n"+test.campaignExtra, "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n"+test.sessionExtra) + _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestLegacyPartyPreservesCampaignAndSessionInputOverrides(t *testing.T) { + dir := t.TempDir() + writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n") + writePartyResolutionFile(t, filepath.Join(dir, "session-party.yml"), "legacy: session\n") + pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign +inputs: + speakers_file: speakers.yml + autocorrect_file: autocorrect.yml + glossary_file: glossary.yml + players_file: campaign-players.yml + party_file: party.yml +`, `session_id: session +campaign: campaign +inputs: + audio_dir: audio + players_file: session-players.yml + party_file: session-party.yml +`) + + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if cfg.Party.Mode != PartyModeLegacy || cfg.Party.Canonical != nil { + t.Fatalf("party = %#v, want opaque legacy party", cfg.Party) + } + if cfg.Party.Source.Source != "session_config" || filepath.Base(cfg.Party.Source.Path) != "session-party.yml" { + t.Fatalf("party source = %#v, want session override", cfg.Party.Source) + } + if got := cfg.StableInputs.PlayersFile; got.Path != "session-players.yml" || got.Source != "session_config" { + t.Fatalf("players input = %#v, want session override", got) + } +} + +func TestLegacyPartyAcceptsAnEffectiveSessionPlayersOverride(t *testing.T) { + dir := t.TempDir() + writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n") + pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign +inputs: + speakers_file: speakers.yml + autocorrect_file: autocorrect.yml + glossary_file: glossary.yml + party_file: party.yml +`, `session_id: session +campaign: campaign +inputs: + audio_dir: audio + players_file: session-players.yml +`) + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + if got := cfg.StableInputs.PlayersFile; got.Path != "session-players.yml" || got.Source != "session_config" { + t.Fatalf("players input = %#v, want effective session override", got) + } +} + +func TestLegacyPartyRequiresPlayersAndPartyMustBeReadableRegularFile(t *testing.T) { + tests := []struct { + name string + partySetup func(t *testing.T, dir string) + partyFile string + playersFile string + want string + }{ + {name: "missing players", partySetup: func(t *testing.T, dir string) { + writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n") + }, partyFile: "party.yml", want: "players_file is required with a legacy party"}, + {name: "missing party file", partySetup: func(t *testing.T, dir string) {}, partyFile: "missing.yml", playersFile: "players.yml", want: "read party input"}, + {name: "non-regular party file", partySetup: func(t *testing.T, dir string) { + if err := os.Mkdir(filepath.Join(dir, "party-dir"), 0o755); err != nil { + t.Fatal(err) + } + }, partyFile: "party-dir", playersFile: "players.yml", want: "read party input"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + test.partySetup(t, dir) + players := "" + if test.playersFile != "" { + players = " players_file: " + test.playersFile + "\n" + } + pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n"+players+" party_file: "+test.partyFile+"\n", "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n") + _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestResolveAndLoadedCampaignShareCanonicalPartyResolution(t *testing.T) { + dir := t.TempDir() + writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), canonicalPartyFixture) + pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n", "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n") + pipeline, err := LoadPipeline(pipelinePath) + if err != nil { + t.Fatalf("LoadPipeline() error = %v", err) + } + campaign, err := LoadCampaign(campaignPath) + if err != nil { + t.Fatalf("LoadCampaign() error = %v", err) + } + session, err := LoadSession(sessionPath) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + loaded, err := LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign) + if err != nil { + t.Fatalf("LoadPipelineCampaign() error = %v", err) + } + partial, err := ResolveLoadedPipelineCampaign(loaded, "", nil, SessionSource{}) + if err != nil || partial.Party.Mode != PartyModeCanonical { + t.Fatalf("ResolveLoadedPipelineCampaign() = %#v, %v; want canonical partial config", partial, err) + } + direct, err := Resolve(pipelinePath, pipeline, campaignPath, campaign, sessionPath, session, SessionSource{Source: "session_config", LocalPath: sessionPath}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if direct.Party.Mode != PartyModeCanonical || direct.StableInputs.PlayersFile.Source != "derived_from_party" { + t.Fatalf("Resolve() party = %#v, players = %#v", direct.Party, direct.StableInputs.PlayersFile) + } +} + +const canonicalPartyFixture = `schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + classes: [{name: wizard}] +` + +func writePartyResolutionConfig(t *testing.T, dir, campaign, session string) (string, string, string) { + t.Helper() + pipelinePath := writePartyResolutionFile(t, filepath.Join(dir, "pipeline.yml"), "workspace:\n root: "+filepath.ToSlash(filepath.Join(dir, "work"))+"\nwhisperx:\n transcribe_url: https://example.test/transcribe\nnotification:\n mode: noop\n") + campaignPath := writePartyResolutionFile(t, filepath.Join(dir, "campaign.yml"), campaign) + sessionPath := writePartyResolutionFile(t, filepath.Join(dir, "session.yml"), session) + return pipelinePath, campaignPath, sessionPath +} + +func writePartyResolutionFile(t *testing.T, path, contents string) string { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} diff --git a/internal/config/pipeline_profiles_test.go b/internal/config/pipeline_profiles_test.go index 2b61d90..e05994f 100644 --- a/internal/config/pipeline_profiles_test.go +++ b/internal/config/pipeline_profiles_test.go @@ -345,7 +345,8 @@ func TestLoadWithSessionOptionsCarriesExplicitProfilePresence(t *testing.T) { rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing")) writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n") writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\n") - campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\n") + campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n players_file: players.yml\n party_file: party.yml\n") + writePipelineSource(t, dir, "party.yml", "legacy: party\n") sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n") selected := "testing" cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected}) diff --git a/internal/config/validate.go b/internal/config/validate.go index be6db2c..7e00274 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -40,6 +40,9 @@ func Validate(cfg *Config) error { if err := validateSession(cfg.Session); err != nil { return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err) } + if err := validateResolvedPartyInputs(cfg); err != nil { + return fmt.Errorf("campaign/session config invalid: %w", err) + } if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil { return fmt.Errorf("pipeline/session config invalid: %w", err) } @@ -66,9 +69,6 @@ func validateCampaign(cfg *CampaignConfig) error { if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { return fmt.Errorf("campaign.inputs.glossary_file is required") } - if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" { - return fmt.Errorf("campaign.inputs.players_file is required") - } if strings.TrimSpace(cfg.Inputs.PartyFile) == "" { return fmt.Errorf("campaign.inputs.party_file is required") } @@ -794,12 +794,6 @@ func validateSession(cfg *SessionConfig) error { if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { return fmt.Errorf("session.inputs.glossary_file is required") } - if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" { - return fmt.Errorf("session.inputs.players_file is required") - } - if strings.TrimSpace(cfg.Inputs.PartyFile) == "" { - return fmt.Errorf("session.inputs.party_file is required") - } if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") } @@ -825,6 +819,31 @@ func validateSession(cfg *SessionConfig) error { return nil } +func validateResolvedPartyInputs(cfg *Config) error { + if cfg == nil || cfg.Session == nil { + return fmt.Errorf("session config is required") + } + if cfg.Party.Mode == PartyModeCanonical { + if cfg.Party.Canonical == nil { + return fmt.Errorf("canonical party data is required") + } + if cfg.Party.Source.Source != "campaign_config" { + return fmt.Errorf("canonical party must be sourced by campaign_config") + } + if strings.TrimSpace(cfg.StableInputs.PlayersFile.Source) != "derived_from_party" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) != "" { + return fmt.Errorf("canonical party requires derived players input") + } + return nil + } + if strings.TrimSpace(cfg.Session.Inputs.PlayersFile) == "" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) == "" { + return fmt.Errorf("players input is required with a legacy party") + } + if strings.TrimSpace(cfg.Session.Inputs.PartyFile) == "" || strings.TrimSpace(cfg.StableInputs.PartyFile.Path) == "" { + return fmt.Errorf("party input is required with a legacy party") + } + return nil +} + func validateSessionIdentifier(fieldName, value string, required bool) error { if strings.TrimSpace(value) == "" { if required {