diff --git a/docs/cli.md b/docs/cli.md index 151222f..49231fa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -106,10 +106,10 @@ family declarations, and runtime provenance. `config sources` reports the same fully validated resolution without printing effective values. Its header identifies the root, ordered imports, selected -profile and overlay, selected campaign, party mode/source, and digest. The +profile and overlay, selected campaign, party source, and digest. The remaining tab-separated records are sorted as `path`, `role`, and `source`. Roles distinguish root, import, profile, centralized default, campaign, party, -legacy-player, and generated family ownership. A generated party member has +and generated family ownership. A generated party member has one family record and one party record at the same logical path. The output never reads or prints secret values. diff --git a/docs/config.md b/docs/config.md index 17a58b3..b7bf61b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -79,8 +79,8 @@ Use the [production/testing bundle](../examples/production-testing/pipeline.yml) as the complete copyable migration reference. Split stable pipeline settings into explicit additive imports, place production/testing differences in one selected overlay, and retain a production `default_profile`. Convert campaign -rosters to [canonical party input](integrations/party.md), remove a separate -`players_file`, then express character work as families. Inspect the result +rosters to [canonical party input](integrations/party.md), then express +character work as families. Inspect the result with `config validate`, `config show`, and `config sources`; use `config diff` to review profiles before running a session. Unversioned party documents are rejected. @@ -112,13 +112,13 @@ 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`, - `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`) are campaign-owned; the first three may be overridden by the + session. The optional `spell_catalog_file` also inherits a campaign value + when omitted by the session. - `party_file` is parsed when pipeline and campaign configuration are combined. It must be 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. + campaign-owned and derives the players input internally. `players_file` and + session `party_file` are unsupported configuration keys. - Exactly one audio mode must be configured in session input: - local (`audio_dir` or `audio_files`), or - S3 (`audio_s3.prefix`). @@ -521,7 +521,6 @@ 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 | No | retained temporarily for configuration compatibility; forbidden with the required 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` | @@ -537,8 +536,6 @@ 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 | retained temporarily for configuration compatibility; forbidden with the required canonical party | -| `inputs.party_file` | string | No | retained temporarily for configuration compatibility; 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/party.md b/docs/integrations/party.md index 6ad79e0..ef4a507 100644 --- a/docs/integrations/party.md +++ b/docs/integrations/party.md @@ -41,10 +41,7 @@ The document has exactly one YAML document and accepts no unknown fields. Unversioned party inputs are rejected. Replace each legacy party document with the versioned structure above, move each roster entry under `characters`, and -remove separate player and session-party overrides from the selected campaign -and session configuration. The configuration-key removal itself is staged -separately; current configurations must already omit those competing values -when using a canonical party. +remove separate player and session-party settings: those keys are unsupported. 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 8346bbf..bb6be25 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -92,8 +92,8 @@ with no projected difference is treated as an internal consistency error. Campaign context construction reads the campaign-owned party source through `ParseParty`. The required canonical party retains its raw bytes and normalized roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates -a virtual `derived_from_party` players input and rejects competing campaign or -session players files and session party overrides. +a deterministic `derived_from_party` players projection. The party is the only +configured roster authority; player and session-party fields are unsupported. ## Canonical Party Domain diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9c00fe7..93be24b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -185,7 +185,7 @@ fields and mode shell that supported them. ## Stage 3 — Remove Legacy Party And Players Configuration -**Status: Pending** +**Status: Completed** ### Goal diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 82e1617..4dd99e1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -92,8 +92,8 @@ Safe fix: - replace the file with one exact, single-document [`narratio.party.v1` party](./integrations/party.md); do not add a version - marker to an otherwise incompatible legacy structure. Remove competing - player and session-party settings while migrating. + marker to an otherwise incompatible legacy structure. The versioned party is + the only configured roster authority. Relevant reference: [Canonical Party Input](./integrations/party.md). diff --git a/examples/README.md b/examples/README.md index 507d5a4..bfb0c96 100644 --- a/examples/README.md +++ b/examples/README.md @@ -53,7 +53,7 @@ with the sample campaign and a compatible local- or S3-audio session. [party](campaigns/sample-campaign/party.yml) fixture, plus an optional [spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that follows the Notarius v0.6 contract. Narratio derives the players projection - from this party source; the campaign deliberately has no `players_file`. + from this party source. - [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that reserves the expected filename and directory shape. Replace it with a real FLAC file before running transcription. diff --git a/internal/app/config_commands.go b/internal/app/config_commands.go index f308aec..e47702d 100644 --- a/internal/app/config_commands.go +++ b/internal/app/config_commands.go @@ -274,7 +274,7 @@ func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) erro if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil { return err } - if _, err := fmt.Fprintf(out, "party: mode=%s source=%s\n", resolved.Party.Mode, config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil { + if _, err := fmt.Fprintf(out, "party: source=%s\n", config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil { return err } } diff --git a/internal/app/config_commands_test.go b/internal/app/config_commands_test.go index ca9c39e..82418b3 100644 --- a/internal/app/config_commands_test.go +++ b/internal/app/config_commands_test.go @@ -400,7 +400,7 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) { "imports: none", "profile: name=testing selection=cli overlay=", "campaign: id=sample-campaign source=", - "party: mode=canonical source=", + "party: source=", "digest: ", "path\trole\tsource\n", "workspace.root\troot\t", @@ -423,7 +423,7 @@ func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) { output := out.String() partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml") for _, want := range []string{ - "party: mode=canonical source=" + partyPath, + "party: source=" + partyPath, "derived.players\tparty\t" + partyPath, "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath, "scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath, diff --git a/internal/app/extract_lifecycle_test.go b/internal/app/extract_lifecycle_test.go index 606162c..aaef2bb 100644 --- a/internal/app/extract_lifecycle_test.go +++ b/internal/app/extract_lifecycle_test.go @@ -233,7 +233,7 @@ characters: if err != nil { t.Fatalf("ParseParty(changed party) error = %v", err) } - cfg.Party.Canonical = partyDocument.Canonical + cfg.Party.Canonical = partyDocument prepared := loadLifecycleManifest(t, cfg) prepare, err := stage.Select("prepare") @@ -290,7 +290,7 @@ characters: if err != nil { t.Fatalf("ParseParty(canonical party) error = %v", err) } - cfg.Party.Canonical = partyDocument.Canonical + cfg.Party.Canonical = partyDocument if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil { t.Fatalf("executeStages() error = %v", err) diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go index 4d31154..71f9a30 100644 --- a/internal/app/operator_inspection.go +++ b/internal/app/operator_inspection.go @@ -56,17 +56,18 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck { name string in config.ResolvedInputFile optional bool + derived bool }{ {name: "speakers", in: cfg.StableInputs.SpeakersFile}, {name: "autocorrect", in: cfg.StableInputs.AutocorrectFile}, {name: "glossary", in: cfg.StableInputs.GlossaryFile}, - {name: "players", in: cfg.StableInputs.PlayersFile}, + {name: "players", derived: true}, {name: "party", in: cfg.StableInputs.PartyFile}, {name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true}, } out := make([]stableInputCheck, 0, len(items)) for _, item := range items { - if item.name == "players" && item.in.Source == "derived_from_party" { + if item.derived { out = append(out, stableInputCheck{Name: item.name, Path: "derived from canonical party"}) continue } diff --git a/internal/app/remote_session_test.go b/internal/app/remote_session_test.go index b0d4f77..7bda59a 100644 --- a/internal/app/remote_session_test.go +++ b/internal/app/remote_session_test.go @@ -82,8 +82,8 @@ inputs: 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) + if loaded.Config.Party.Canonical == nil { + t.Fatalf("remote config party = %#v", loaded.Config.Party) } } diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index b6e1cd9..4c790fe 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -1335,8 +1335,7 @@ func testConfig(t *testing.T) *config.Config { CampaignPath: campaignPath, SessionPath: sessionPath, Party: config.ResolvedParty{ - Mode: config.PartyModeCanonical, - Canonical: partyDocument.Canonical, + Canonical: partyDocument, Source: config.PartySource{ Path: filepath.Join(cfgDir, "party.yml"), ConfigPath: campaignPath, @@ -1359,7 +1358,6 @@ func testConfig(t *testing.T) *config.Config { ConfigPath: campaignPath, Source: "campaign_config", }, - PlayersFile: config.ResolvedInputFile{Source: "derived_from_party"}, PartyFile: config.ResolvedInputFile{ Path: "./party.yml", ConfigPath: campaignPath, diff --git a/internal/config/artifact_families.go b/internal/config/artifact_families.go index c04cb6b..460ba0e 100644 --- a/internal/config/artifact_families.go +++ b/internal/config/artifact_families.go @@ -40,7 +40,7 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er if len(families) == 0 { return nil } - if party.Mode != PartyModeCanonical || party.Canonical == nil { + if party.Canonical == nil { return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party") } diff --git a/internal/config/artifact_families_test.go b/internal/config/artifact_families_test.go index 4edacab..e69710c 100644 --- a/internal/config/artifact_families_test.go +++ b/internal/config/artifact_families_test.go @@ -183,7 +183,7 @@ func TestArtifactFamiliesRejectGeneratedKeyAndOutputCollisions(t *testing.T) { Scriptorium: &ScriptoriumConfig{Artifacts: test.artifacts}, resolution: &pipelineResolutionMetadata{artifactFamilies: map[string]ScriptoriumArtifactFamilyConfig{"character_meta": base}}, } - err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}) + err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Canonical: document}) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("expandPipelineArtifactFamilies() error = %v, want %q", err, test.wantError) } @@ -290,12 +290,12 @@ func TestArtifactFamiliesRejectInvalidMemberDependenciesAndSources(t *testing.T) if err != nil { t.Fatal(err) } - if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "outside an artifact family") { + if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Canonical: document}); err == nil || !strings.Contains(err.Error(), "outside an artifact family") { t.Fatalf("concrete member source error = %v", err) } pipeline.Scriptorium.Artifacts["explicit"] = ScriptoriumArtifactConfig{Inputs: map[string]ScriptoriumInputConfig{"bad": {Source: "narratio.member_artifact."}}} pipeline.resolution.artifactFamiliesExpanded = false - if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "malformed member artifact source") { + if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Canonical: document}); err == nil || !strings.Contains(err.Error(), "malformed member artifact source") { t.Fatalf("malformed concrete member source error = %v", err) } } diff --git a/internal/config/campaign_config_test.go b/internal/config/campaign_config_test.go index 563e42e..ee0786a 100644 --- a/internal/config/campaign_config_test.go +++ b/internal/config/campaign_config_test.go @@ -114,7 +114,6 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") - assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "", "", "derived_from_party") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config") } @@ -134,7 +133,6 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") - assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "", "", "derived_from_party") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config") } diff --git a/internal/config/config.go b/internal/config/config.go index fda3728..0b0d2bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,7 +64,6 @@ type CampaignInputsConfig struct { SpeakersFile string `yaml:"speakers_file"` AutocorrectFile string `yaml:"autocorrect_file"` GlossaryFile string `yaml:"glossary_file"` - PlayersFile string `yaml:"players_file"` PartyFile string `yaml:"party_file"` SpellCatalogFile string `yaml:"spell_catalog_file"` } @@ -357,8 +356,6 @@ type SessionInputsConfig struct { SpeakersFile string `yaml:"speakers_file"` AutocorrectFile string `yaml:"autocorrect_file"` GlossaryFile string `yaml:"glossary_file"` - PlayersFile string `yaml:"players_file"` - PartyFile string `yaml:"party_file"` SpellCatalogFile string `yaml:"spell_catalog_file"` } @@ -373,7 +370,6 @@ type ResolvedStableInputs struct { SpeakersFile ResolvedInputFile AutocorrectFile ResolvedInputFile GlossaryFile ResolvedInputFile - PlayersFile ResolvedInputFile PartyFile ResolvedInputFile SpellCatalogFile ResolvedInputFile } @@ -385,11 +381,10 @@ 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. +// ResolvedParty records the campaign-owned canonical party and its non-secret +// source provenance. It is runtime-only and must never be copied into manifests +// as raw party content. type ResolvedParty struct { - Mode PartyMode Source PartySource Canonical *CanonicalParty } diff --git a/internal/config/effective_pipeline.go b/internal/config/effective_pipeline.go index 4a7be01..140adb7 100644 --- a/internal/config/effective_pipeline.go +++ b/internal/config/effective_pipeline.go @@ -126,9 +126,8 @@ func EffectivePipelineSources(cfg *PipelineConfig, party ResolvedParty) ([]Effec } // EffectiveCampaignSources projects selected campaign and party ownership -// without retaining or rendering campaign values. Canonical players are a -// derived party value, while a legacy players file remains explicitly marked -// as legacy input provenance. +// without retaining or rendering campaign values. Players are derived from the +// canonical party source. func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, party ResolvedParty) []EffectivePipelineSourceRecord { if campaign == nil { return nil @@ -143,21 +142,11 @@ func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, par if strings.TrimSpace(campaign.Inputs.SpellCatalogFile) != "" { records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.spell_catalog_file", Role: "campaign", Source: campaignSource}) } - if party.Mode == PartyModeCanonical { - partySource := normalizedProvenancePath(party.Source.Path) - records = append(records, - EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource}, - EffectivePipelineSourceRecord{Path: "derived.players", Role: "party", Source: partySource}, - ) - } else { - partySource := normalizedProvenancePath(party.Source.Path) - records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource}) - if strings.TrimSpace(campaign.Inputs.PlayersFile) != "" { - records = append(records, EffectivePipelineSourceRecord{ - Path: "campaign.inputs.players_file", Role: "legacy_player", Source: campaignInputSourcePath(campaignPath, campaign.Inputs.PlayersFile), - }) - } - } + partySource := normalizedProvenancePath(party.Source.Path) + records = append(records, + EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource}, + EffectivePipelineSourceRecord{Path: "derived.players", Role: "party", Source: partySource}, + ) sort.Slice(records, func(left, right int) bool { if records[left].Path != records[right].Path { return records[left].Path < records[right].Path diff --git a/internal/config/identity_validation_test.go b/internal/config/identity_validation_test.go index 490f0f7..be77e3f 100644 --- a/internal/config/identity_validation_test.go +++ b/internal/config/identity_validation_test.go @@ -10,7 +10,7 @@ func TestIdentityValidationRejectsUnsafeSegments(t *testing.T) { for _, value := range unsafe { t.Run("campaign/"+strings.ReplaceAll(value, "/", "_"), func(t *testing.T) { cfg := &CampaignConfig{CampaignID: value, Inputs: CampaignInputsConfig{ - SpeakersFile: "speakers.yml", AutocorrectFile: "autocorrect.yml", GlossaryFile: "glossary.yml", PlayersFile: "players.yml", PartyFile: "party.yml", + SpeakersFile: "speakers.yml", AutocorrectFile: "autocorrect.yml", GlossaryFile: "glossary.yml", PartyFile: "party.yml", }} if err := validateCampaign(cfg); err == nil { t.Fatalf("validateCampaign(%q) error = nil, want rejection", value) diff --git a/internal/config/load.go b/internal/config/load.go index 1c77e98..139e419 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -278,14 +278,9 @@ func LoadPipelineCampaignWithParty(pipelinePath string, pipeline *PipelineConfig if campaign == nil { return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required") } - if party.Mode == "" { + if party.Canonical == nil { return LoadedPipelineCampaign{}, fmt.Errorf("resolved campaign party is required") } - if party.Mode == PartyModeCanonical { - if err := validateCanonicalPartySelection(campaign, nil); err != nil { - return LoadedPipelineCampaign{}, err - } - } if err := expandPipelineArtifactFamilies(pipeline, party); err != nil { return LoadedPipelineCampaign{}, err } @@ -322,18 +317,13 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st if loaded.Campaign == nil { return nil, fmt.Errorf("campaign config is required") } - if loaded.Party.Mode == "" { + if loaded.Party.Canonical == nil { 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, @@ -344,11 +334,6 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st 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 } @@ -363,22 +348,6 @@ 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 } @@ -456,15 +425,9 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig campaignPath, sessionPath, ), - PlayersFile: selectStableInput( - campaignCfg.Inputs.PlayersFile, - sessionCfg.Inputs.PlayersFile, - campaignPath, - sessionPath, - ), PartyFile: selectStableInput( campaignCfg.Inputs.PartyFile, - sessionCfg.Inputs.PartyFile, + "", campaignPath, sessionPath, ), @@ -479,8 +442,6 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path - sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path - sessionCfg.Inputs.PartyFile = stable.PartyFile.Path sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path return stable, nil } diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 7a258d9..5edc274 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -209,7 +209,7 @@ func TestExamplesLoadAndValidate(t *testing.T) { if got := cfg.Pipeline.Scriptorium.Artifacts["character_items_arannis"].Inputs["character_meta"].Source; got != "narratio.artifact.character_meta_arannis" { t.Fatalf("same-member input source = %q", got) } - if cfg.Party.Mode != PartyModeCanonical || cfg.Party.Canonical == nil || len(cfg.Party.Canonical.Characters) != 2 { + if cfg.Party.Canonical == nil || len(cfg.Party.Canonical.Characters) != 2 { t.Fatalf("canonical party = %#v", cfg.Party) } characters := cfg.Party.Canonical.Characters diff --git a/internal/config/party.go b/internal/config/party.go index a9dc090..4d5e867 100644 --- a/internal/config/party.go +++ b/internal/config/party.go @@ -20,35 +20,6 @@ const ( PlayersSchemaVersion = "narratio.players.v1" ) -// PartyMode records whether a source document uses the canonical party -// contract or the bounded compatibility path for older opaque files. -type PartyMode string - -const ( - PartyModeCanonical PartyMode = "canonical" - PartyModeLegacy PartyMode = "legacy" -) - -// PartyDocument classifies a party input. Canonical is populated only for a -// versioned canonical document; legacy contents intentionally remain opaque. -type PartyDocument struct { - Mode PartyMode - Canonical *CanonicalParty -} - -// IsCanonical reports whether the document has validated canonical data. -func (d *PartyDocument) IsCanonical() bool { - return d != nil && d.Mode == PartyModeCanonical && d.Canonical != nil -} - -// PlayersYAML renders the derived players projection for a canonical document. -func (d *PartyDocument) PlayersYAML() ([]byte, error) { - if !d.IsCanonical() { - return nil, fmt.Errorf("canonical party is required") - } - return d.Canonical.PlayersYAML() -} - // CanonicalParty is the normalized domain value for narratio.party.v1. Raw is // retained independently so later preparation can copy the authored party file // byte-for-byte rather than serializing normalized values. @@ -84,7 +55,7 @@ type PartyClass struct { } // ParseParty strictly parses one supported party document. -func ParseParty(data []byte) (*PartyDocument, error) { +func ParseParty(data []byte) (*CanonicalParty, error) { root, decoder, err := parsePartyFirstDocument(data) if err != nil { return nil, err @@ -96,7 +67,7 @@ func ParseParty(data []byte) (*PartyDocument, error) { if err != nil { return nil, err } - return &PartyDocument{Mode: PartyModeCanonical, Canonical: party}, nil + return party, nil } func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) { diff --git a/internal/config/party_resolution.go b/internal/config/party_resolution.go index 60772cf..8781e46 100644 --- a/internal/config/party_resolution.go +++ b/internal/config/party_resolution.go @@ -28,18 +28,17 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) { if err != nil { return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err) } - document, err := ParseParty(raw) + party, err := ParseParty(raw) if err != nil { return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err) } return ResolvedParty{ - Mode: PartyModeCanonical, Source: PartySource{ Path: path, ConfigPath: configPath, Source: input.Source, }, - Canonical: document.Canonical, + Canonical: party, }, nil } @@ -64,26 +63,3 @@ func resolveCampaignParty(campaignPath string, campaign *CampaignConfig) (Resolv } 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 index 9b966ad..04ee0c4 100644 --- a/internal/config/party_resolution_test.go +++ b/internal/config/party_resolution_test.go @@ -44,7 +44,7 @@ inputs: if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } - if cfg.Party.Mode != PartyModeCanonical || cfg.Party.Canonical == nil { + if cfg.Party.Canonical == nil { t.Fatalf("party = %#v, want canonical party", cfg.Party) } if got, want := cfg.Party.Source.Path, partyPath; got != want { @@ -56,9 +56,6 @@ inputs: 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) { @@ -68,9 +65,9 @@ func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing 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"}, + {name: "campaign players", campaignExtra: " players_file: players.yml\n", want: "field players_file not found"}, + {name: "session players", sessionExtra: " players_file: players.yml\n", want: "field players_file not found"}, + {name: "session party", sessionExtra: " party_file: party-override.yml\n", want: "field party_file not found"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -126,15 +123,15 @@ func TestResolveAndLoadedCampaignShareCanonicalPartyResolution(t *testing.T) { t.Fatalf("LoadPipelineCampaign() error = %v", err) } partial, err := ResolveLoadedPipelineCampaign(loaded, "", nil, SessionSource{}) - if err != nil || partial.Party.Mode != PartyModeCanonical { + if err != nil || partial.Party.Canonical == nil { 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) + if direct.Party.Canonical == nil { + t.Fatalf("Resolve() party = %#v", direct.Party) } } diff --git a/internal/config/party_test.go b/internal/config/party_test.go index 13bd53c..8ad2c43 100644 --- a/internal/config/party_test.go +++ b/internal/config/party_test.go @@ -27,13 +27,10 @@ characters: if err != nil { t.Fatalf("ParseParty() error = %v", err) } - if !document.IsCanonical() || document.Mode != PartyModeCanonical { - t.Fatalf("document mode = %#v, want canonical", document) - } - if !bytes.Equal(document.Canonical.Raw, data) { + if !bytes.Equal(document.Raw, data) { t.Fatal("canonical raw bytes were not preserved") } - characters := document.Canonical.Characters + characters := document.Characters if len(characters) != 1 || characters[0].ID != "arannis" { t.Fatalf("characters = %#v, want ordered arannis character", characters) } @@ -190,7 +187,7 @@ characters: if err != nil { t.Fatalf("ParseParty() error = %v", err) } - if got := len(document.Canonical.Characters); got != 2 { + if got := len(document.Characters); got != 2 { t.Fatalf("character count = %d, want 2", got) } } @@ -249,7 +246,7 @@ characters: if err != nil { t.Fatalf("PlayersYAML() error = %v", err) } - again, err := document.Canonical.PlayersYAML() + again, err := document.PlayersYAML() if err != nil { t.Fatalf("PlayersYAML() repeat error = %v", err) } diff --git a/internal/config/session_template_test.go b/internal/config/session_template_test.go index 3530367..018814b 100644 --- a/internal/config/session_template_test.go +++ b/internal/config/session_template_test.go @@ -17,7 +17,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -41,7 +40,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -67,7 +65,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -96,7 +93,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -146,7 +142,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -171,7 +166,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) diff --git a/internal/config/validate.go b/internal/config/validate.go index b7720ea..ac65195 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -845,23 +845,11 @@ 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 cfg.Party.Canonical == nil { + return fmt.Errorf("canonical party data is required") } - 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") + if cfg.Party.Source.Source != "campaign_config" { + return fmt.Errorf("canonical party must be sourced by campaign_config") } return nil } diff --git a/internal/stage/prepare_legacy.go b/internal/stage/prepare_legacy.go deleted file mode 100644 index ec96509..0000000 --- a/internal/stage/prepare_legacy.go +++ /dev/null @@ -1,57 +0,0 @@ -package stage - -import ( - "fmt" - "path/filepath" - - "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" - "gitea.maximumdirect.net/eric/narratio/internal/artifacts" - "gitea.maximumdirect.net/eric/narratio/internal/config" - "gitea.maximumdirect.net/eric/narratio/internal/manifest" -) - -// materializeLegacyPartyInputs preserves the removable compatibility path for -// opaque party and explicit players files. Remove it when legacy roster inputs -// are no longer supported. -func materializeLegacyPartyInputs( - store artifacts.Store, - cfg *config.Config, - inputsDir string, - party, players artifactpolicy.PreparedInputSourceDescriptor, -) ([]manifest.InputRecord, error) { - playersInput := stableInputSource(cfg.StableInputs.PlayersFile, cfg.Session.Inputs.PlayersFile, cfg.SessionPath) - partyInput := stableInputSource(cfg.StableInputs.PartyFile, cfg.Session.Inputs.PartyFile, cfg.SessionPath) - playersSource, err := resolveConfigRelativePath(playersInput) - if err != nil { - return nil, fmt.Errorf("players path: %w", err) - } - partySource, err := resolveConfigRelativePath(partyInput) - if err != nil { - return nil, fmt.Errorf("party path: %w", err) - } - for _, required := range []struct { - path string - name string - }{ - {path: playersSource, name: "players.yml"}, - {path: partySource, name: "party.yml"}, - } { - if err := requireFile(required.path, required.name); err != nil { - return nil, err - } - } - partyPath := filepath.Join(inputsDir, party.Filename) - partyChecksum, err := copyFileIfChanged(store, partySource, partyPath) - if err != nil { - return nil, fmt.Errorf("materialize party: %w", err) - } - playersPath := filepath.Join(inputsDir, players.Filename) - playersChecksum, err := copyFileIfChanged(store, playersSource, playersPath) - if err != nil { - return nil, fmt.Errorf("materialize players: %w", err) - } - return []manifest.InputRecord{ - {Kind: party.ManifestKind, Path: partyPath, Checksum: partyChecksum, Source: partyInput.Source}, - {Kind: players.ManifestKind, Path: playersPath, Checksum: playersChecksum, Source: playersInput.Source}, - }, nil -} diff --git a/internal/stage/prepare_party.go b/internal/stage/prepare_party.go index 18928d1..c6f7e35 100644 --- a/internal/stage/prepare_party.go +++ b/internal/stage/prepare_party.go @@ -23,10 +23,7 @@ func materializePreparedPartyInputs(store artifacts.Store, cfg *config.Config, i if !ok { return nil, fmt.Errorf("prepared-input descriptor %q is unavailable", artifactpolicy.SourceInputParty) } - if cfg.Party.Mode == config.PartyModeCanonical { - return materializeCanonicalPartyInputs(store, cfg.Party, inputsDir, party, players) - } - return materializeLegacyPartyInputs(store, cfg, inputsDir, party, players) + return materializeCanonicalPartyInputs(store, cfg.Party, inputsDir, party, players) } func materializeCanonicalPartyInputs( diff --git a/internal/stage/prepare_resume.go b/internal/stage/prepare_resume.go index 405151d..ba7ec00 100644 --- a/internal/stage/prepare_resume.go +++ b/internal/stage/prepare_resume.go @@ -132,43 +132,17 @@ func currentPreparePartySourceInputs(env *Env) ([]prepareExpectedInput, error) { if !ok { return nil, fmt.Errorf("players prepared-input descriptor is unavailable") } - if env.Config.Party.Mode == config.PartyModeCanonical { - if env.Config.Party.Canonical == nil || len(env.Config.Party.Canonical.Raw) == 0 { - return nil, fmt.Errorf("canonical party data is unavailable") - } - playersBytes, err := env.Config.Party.Canonical.PlayersYAML() - if err != nil { - return nil, err - } - return []prepareExpectedInput{ - {kind: party.ManifestKind, checksum: checksumPrepareBytes(env.Config.Party.Canonical.Raw)}, - {kind: players.ManifestKind, checksum: checksumPrepareBytes(playersBytes)}, - }, nil + if env.Config.Party.Canonical == nil || len(env.Config.Party.Canonical.Raw) == 0 { + return nil, fmt.Errorf("canonical party data is unavailable") } - - cfg := env.Config - inputs := []struct { - kind string - resolved config.ResolvedInputFile - fallback string - }{ - {kind: party.ManifestKind, resolved: cfg.StableInputs.PartyFile, fallback: cfg.Session.Inputs.PartyFile}, - {kind: players.ManifestKind, resolved: cfg.StableInputs.PlayersFile, fallback: cfg.Session.Inputs.PlayersFile}, + playersBytes, err := env.Config.Party.Canonical.PlayersYAML() + if err != nil { + return nil, err } - expected := make([]prepareExpectedInput, 0, len(inputs)) - for _, input := range inputs { - resolved := stableInputSource(input.resolved, input.fallback, cfg.SessionPath) - path, err := resolveConfigRelativePath(resolved) - if err != nil { - return nil, err - } - checksum, err := env.ArtifactStore.Checksum(path) - if err != nil { - return nil, err - } - expected = append(expected, prepareExpectedInput{kind: input.kind, checksum: checksum}) - } - return expected, nil + return []prepareExpectedInput{ + {kind: party.ManifestKind, checksum: checksumPrepareBytes(env.Config.Party.Canonical.Raw)}, + {kind: players.ManifestKind, checksum: checksumPrepareBytes(playersBytes)}, + }, nil } func currentPrepareAudioSourceInputs(ctx context.Context, env *Env, m *manifest.Manifest) ([]prepareExpectedInput, error) { diff --git a/internal/stage/prepare_resume_test.go b/internal/stage/prepare_resume_test.go index 30f3446..d7702fd 100644 --- a/internal/stage/prepare_resume_test.go +++ b/internal/stage/prepare_resume_test.go @@ -18,9 +18,13 @@ func TestPrepareResumeValidatesCurrentLocalSourcesAndPreparedCopies(t *testing.T mutate func(*testing.T, *Env, string) }{ { - name: "stable input bytes changed", + name: "canonical party bytes changed", mutate: func(t *testing.T, env *Env, _ string) { - writeFile(t, filepath.Join(filepath.Dir(env.Config.CampaignPath), "party.yml"), "changed: true\n") + party, err := config.ParseParty([]byte("schema_version: narratio.party.v1\ncharacters:\n changed:\n player: {name: Eric}\n character:\n name: Changed\n classes: [{name: wizard}]\n")) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } + env.Config.Party.Canonical = party }, }, { @@ -58,7 +62,7 @@ func TestPrepareResumeAcceptsRelocatedEquivalentLocalSource(t *testing.T) { env, m, _ := prepareResumeLocalFixture(t) cfgDir := filepath.Dir(env.Config.CampaignPath) relocated := filepath.Join(cfgDir, "relocated-party.yml") - writeFile(t, relocated, "[]\n") + writeFile(t, relocated, "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") env.Config.StableInputs.PartyFile.Path = "./relocated-party.yml" assertPrepareResume(t, env, m, true) } @@ -135,7 +139,7 @@ func setCanonicalPrepareParty(t *testing.T, env *Env, raw []byte) { t.Fatalf("ParseParty() error = %v", err) } env.Config.Party = config.ResolvedParty{ - Mode: config.PartyModeCanonical, Source: config.PartySource{Source: "campaign_config"}, Canonical: document.Canonical, + Source: config.PartySource{Source: "campaign_config"}, Canonical: document, } } diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index d0cb732..d2dd5d0 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -177,19 +177,14 @@ characters: if err != nil { t.Fatalf("ParseParty() error = %v", err) } - if err := os.Remove(filepath.Join(filepath.Dir(env.Config.SessionPath), "players.yml")); err != nil { - t.Fatalf("remove legacy players source: %v", err) - } env.Config.Party = config.ResolvedParty{ - Mode: config.PartyModeCanonical, - Canonical: document.Canonical, + Canonical: document, Source: config.PartySource{ Path: filepath.Join(filepath.Dir(env.Config.CampaignPath), "party.yml"), ConfigPath: env.Config.CampaignPath, Source: "campaign_config", }, } - env.Config.StableInputs.PlayersFile = config.ResolvedInputFile{Source: "derived_from_party"} writeFile(t, filepath.Join(filepath.Dir(env.Config.SessionPath), "audio", "alice.flac"), "audio") if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { @@ -201,7 +196,7 @@ characters: if got, err := os.ReadFile(preparedParty); err != nil || !bytes.Equal(got, partyBytes) { t.Fatalf("prepared party = %q, %v; want unchanged canonical bytes", got, err) } - wantPlayers, err := document.Canonical.PlayersYAML() + wantPlayers, err := document.PlayersYAML() if err != nil { t.Fatalf("PlayersYAML() error = %v", err) } @@ -228,30 +223,6 @@ characters: } } -func TestPrepareStageLegacyPartyInputsRemainOpaqueCopies(t *testing.T) { - env, m := setupPrepareEnv(t) - root := filepath.Dir(env.Config.SessionPath) - playersBytes := []byte("legacy players\n") - partyBytes := []byte("legacy party\n") - writeFile(t, filepath.Join(root, "players.yml"), string(playersBytes)) - writeFile(t, filepath.Join(root, "party.yml"), string(partyBytes)) - writeFile(t, filepath.Join(root, "audio", "alice.flac"), "audio") - env.Config.Party = config.ResolvedParty{Mode: config.PartyModeLegacy} - if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { - t.Fatalf("prepare.Run() error = %v", err) - } - paths := sessionPathsForEnv(env, m.SessionID) - if got, err := os.ReadFile(filepath.Join(paths.InputsDir, "party.yml")); err != nil || !bytes.Equal(got, partyBytes) { - t.Fatalf("prepared legacy party = %q, %v", got, err) - } - if got, err := os.ReadFile(filepath.Join(paths.InputsDir, "players.yml")); err != nil || !bytes.Equal(got, playersBytes) { - t.Fatalf("prepared legacy players = %q, %v", got, err) - } - if got := findManifestInput(t, m.Inputs, "players").Source; got != "campaign_config" { - t.Fatalf("legacy players source = %q, want campaign_config", got) - } -} - func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) { tests := []struct { name string @@ -888,15 +859,18 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml `) writeFile(t, sessionPath, "session_id: 2026-05-03\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") - writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n") - writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n") + partyBytes := []byte("schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") + writeFile(t, filepath.Join(cfgDir, "party.yml"), string(partyBytes)) + party, err := config.ParseParty(partyBytes) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } cfg := &config.Config{ Pipeline: &config.PipelineConfig{ @@ -907,6 +881,9 @@ inputs: SessionPath: sessionPath, CampaignPath: campaignPath, PipelinePath: pipelinePath, + Party: config.ResolvedParty{Canonical: party, Source: config.PartySource{ + Path: filepath.Join(cfgDir, "party.yml"), ConfigPath: campaignPath, Source: "campaign_config", + }}, Session: &config.SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", @@ -930,11 +907,6 @@ inputs: ConfigPath: campaignPath, Source: "campaign_config", }, - PlayersFile: config.ResolvedInputFile{ - Path: "./players.yml", - ConfigPath: campaignPath, - Source: "campaign_config", - }, PartyFile: config.ResolvedInputFile{ Path: "./party.yml", ConfigPath: campaignPath, diff --git a/internal/stage/semantic_contracts_initial.go b/internal/stage/semantic_contracts_initial.go index f254feb..0f39985 100644 --- a/internal/stage/semantic_contracts_initial.go +++ b/internal/stage/semantic_contracts_initial.go @@ -20,7 +20,6 @@ type prepareSemanticConfig struct { CampaignID string `json:"campaign_id"` SessionID string `json:"session_id"` PreviousSession string `json:"previous_session_id"` - PartyMode string `json:"party_mode"` PlayersSchema string `json:"players_schema_version,omitempty"` Audio prepareAudioSelection `json:"audio"` StableInputs []prepareStableInputSelection `json:"stable_inputs"` @@ -106,21 +105,17 @@ func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) { if err != nil { return prepareSemanticConfig{}, fmt.Errorf("resolve previous artifact requirements: %w", err) } - partyMode := cfg.Party.Mode - if partyMode == "" { + if cfg.Party.Canonical == nil { return prepareSemanticConfig{}, fmt.Errorf("prepare semantic configuration requires a resolved canonical party") } payload := prepareSemanticConfig{ CampaignID: strings.TrimSpace(cfg.Session.Campaign), SessionID: strings.TrimSpace(cfg.Session.SessionID), PreviousSession: strings.TrimSpace(cfg.Session.PreviousSessionID), - PartyMode: string(partyMode), + PlayersSchema: config.PlayersSchemaVersion, Audio: prepareAudioSemantics(cfg), StableInputs: prepareStableInputSemantics(cfg), } - if partyMode == config.PartyModeCanonical { - payload.PlayersSchema = config.PlayersSchemaVersion - } for _, requirement := range requirements { entry := preparePreviousRequirement{Name: strings.TrimSpace(requirement.Name), Required: requirement.Required} if cfg.Pipeline.Scriptorium != nil { @@ -159,8 +154,7 @@ func prepareStableInputSemantics(cfg *config.Config) []prepareStableInputSelecti prepareStableInput("speakers", stable.SpeakersFile, cfg.Session.Inputs.SpeakersFile), prepareStableInput("autocorrect", stable.AutocorrectFile, cfg.Session.Inputs.AutocorrectFile), prepareStableInput("glossary", stable.GlossaryFile, cfg.Session.Inputs.GlossaryFile), - prepareStableInput("players", stable.PlayersFile, cfg.Session.Inputs.PlayersFile), - prepareStableInput("party", stable.PartyFile, cfg.Session.Inputs.PartyFile), + prepareStableInput("party", stable.PartyFile, ""), prepareStableInput("spell_catalog", stable.SpellCatalogFile, cfg.Session.Inputs.SpellCatalogFile), } } diff --git a/internal/stage/semantic_contracts_initial_test.go b/internal/stage/semantic_contracts_initial_test.go index 273aba6..3baee82 100644 --- a/internal/stage/semantic_contracts_initial_test.go +++ b/internal/stage/semantic_contracts_initial_test.go @@ -49,12 +49,11 @@ func TestPrepareSemanticConfigSensitivity(t *testing.T) { func TestPrepareSemanticConfigRecordsCanonicalProjectionVersion(t *testing.T) { env := initialSemanticEnv() - env.Config.Party.Mode = config.PartyModeCanonical payload, err := buildPrepareSemanticConfig(env) if err != nil { t.Fatalf("buildPrepareSemanticConfig() error = %v", err) } - if payload.PartyMode != string(config.PartyModeCanonical) || payload.PlayersSchema != config.PlayersSchemaVersion { + if payload.PlayersSchema != config.PlayersSchemaVersion { t.Fatalf("prepare semantic payload = %#v", payload) } } @@ -120,13 +119,13 @@ func initialSemanticEnv() *Env { Session: &config.SessionConfig{ SessionID: "2026-05-03", Campaign: "campaign", Inputs: config.SessionInputsConfig{ - AudioFiles: []string{"/audio/speaker.flac"}, PartyFile: "party.yml", + AudioFiles: []string{"/audio/speaker.flac"}, }, }, StableInputs: config.ResolvedStableInputs{ PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"}, }, - Party: config.ResolvedParty{Mode: config.PartyModeCanonical}, + Party: config.ResolvedParty{Canonical: &config.CanonicalParty{}}, }} } diff --git a/internal/stage/transcribe_test.go b/internal/stage/transcribe_test.go index 3e8b29c..19bb740 100644 --- a/internal/stage/transcribe_test.go +++ b/internal/stage/transcribe_test.go @@ -401,12 +401,16 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani campaignPath := filepath.Join(cfgDir, "campaign.yml") writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") - writeFile(t, campaignPath, "campaign_id: sample-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") + writeFile(t, campaignPath, "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") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") - writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n") - writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n") + partyBytes := []byte("schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") + writeFile(t, filepath.Join(cfgDir, "party.yml"), string(partyBytes)) + party, err := config.ParseParty(partyBytes) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } retries := 3 concurrency := 2 @@ -415,6 +419,9 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani CampaignPath: campaignPath, SessionPath: sessionPath, Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"}, + Party: config.ResolvedParty{Canonical: party, Source: config.PartySource{ + Path: filepath.Join(cfgDir, "party.yml"), ConfigPath: campaignPath, Source: "campaign_config", + }}, Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: workspace}, WhisperX: config.WhisperXConfig{ @@ -434,15 +441,12 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani SpeakersFile: "./speakers.yml", AutocorrectFile: "./autocorrect.yml", GlossaryFile: "./glossary.yml", - PlayersFile: "./players.yml", - PartyFile: "./party.yml", }, }, StableInputs: config.ResolvedStableInputs{ SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"}, AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"}, GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"}, - PlayersFile: config.ResolvedInputFile{Path: "./players.yml", ConfigPath: campaignPath, Source: "campaign_config"}, PartyFile: config.ResolvedInputFile{Path: "./party.yml", ConfigPath: campaignPath, Source: "campaign_config"}, }, }