Resolve canonical parties with campaign configuration

This commit is contained in:
2026-08-30 14:01:51 +00:00
parent 7e4ceb3d48
commit 61000a9466
18 changed files with 607 additions and 45 deletions

View File

@@ -69,9 +69,15 @@ remote state with an unsafe legacy identity must be migrated before use.
- Pipeline defaults are applied before validation. - Pipeline defaults are applied before validation.
- Campaign and session identities must agree. - Campaign and session identities must agree.
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, - Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve `party_file`) and the optional `spell_catalog_file` resolve from session
from session overrides when provided, otherwise from campaign defaults. An overrides when provided, otherwise from campaign defaults. An empty or
empty or omitted session spell-catalog value inherits the campaign value. 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: - Exactly one audio mode must be configured in session input:
- local (`audio_dir` or `audio_files`), or - local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`). - S3 (`audio_s3.prefix`).
@@ -433,8 +439,8 @@ integration.
| `inputs.speakers_file` | string | Yes | stable input default | | `inputs.speakers_file` | string | Yes | stable input default |
| `inputs.autocorrect_file` | string | Yes | stable input default | | `inputs.autocorrect_file` | string | Yes | stable input default |
| `inputs.glossary_file` | string | Yes | stable input default | | `inputs.glossary_file` | string | Yes | stable input default |
| `inputs.players_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 input default | | `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` | | `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
### Session ### Session
@@ -449,8 +455,8 @@ integration.
| `inputs.speakers_file` | string | No | overrides campaign stable input | | `inputs.speakers_file` | string | No | overrides campaign stable input |
| `inputs.autocorrect_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.glossary_file` | string | No | overrides campaign stable input |
| `inputs.players_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 | overrides campaign stable input | | `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.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_dir` | string | Conditional | local audio mode |
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode | | `inputs.audio_files[]` | list[string] | Conditional | local audio mode |

View File

@@ -21,6 +21,7 @@ focused stage documents.
- [Audita](./audita.md): transcript polishing (`audita process`). - [Audita](./audita.md): transcript polishing (`audita process`).
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle - [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
discovery (`notarius run`). discovery (`notarius run`).
- [Party](./party.md): canonical campaign roster input.
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations. - [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering - [Scriptorium](./scriptorium.md): artifact generation and debug rendering
(`scriptorium run|render`). (`scriptorium run|render`).

View File

@@ -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).

View File

@@ -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 a temporary downloaded session file therefore cannot invalidate the resolved
pipeline or campaign context. 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 ## Canonical Party Domain
`ParseParty` is the package-owned boundary for classifying a party source. `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. configuration tests continue to protect defaults and validation after assembly.
`party_test.go` protects the versioned party schema, domain invariants, and `party_test.go` protects the versioned party schema, domain invariants, and
deterministic players projection without involving campaign or runtime wiring. 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.

View File

@@ -655,7 +655,7 @@ resolution.
## Stage 11 — Campaign Party Resolution And Isolated Legacy Mode ## Stage 11 — Campaign Party Resolution And Isolated Legacy Mode
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -563,6 +563,7 @@ inputs:
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err) t.Fatalf("write campaign.yml: %v", err)
} }
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
return campaignPath return campaignPath
} }

View File

@@ -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 nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
} }
} }
return &pipelineCampaignConfig{ loaded, err := config.LoadPipelineCampaign(loadedPipelinePath, pipelineCfg, resolvedCampaignPath, campaignCfg)
PipelinePath: loadedPipelinePath, if err != nil {
CampaignPath: resolvedCampaignPath, return nil, err
Pipeline: pipelineCfg, }
Campaign: campaignCfg, return &loaded, nil
}, nil
} }
func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) { func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) {

View File

@@ -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) { func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -1406,6 +1406,7 @@ inputs:
mustWriteFile(t, pipelinePath, pipelineYAML) mustWriteFile(t, pipelinePath, pipelineYAML)
mustWriteFile(t, campaignPath, campaignYAML) mustWriteFile(t, campaignPath, campaignYAML)
mustWriteFile(t, sessionPath, sessionYAML) mustWriteFile(t, sessionPath, sessionYAML)
mustWriteFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
cfg, err := config.Load(pipelinePath, sessionPath) cfg, err := config.Load(pipelinePath, sessionPath)
if err != nil { if err != nil {

View File

@@ -206,21 +206,17 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
} }
} }
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) { func TestLegacyCampaignRequiresPlayersInput(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
err = Validate(cfg)
if err == nil { 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()) 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 { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) 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 return pipelinePath, campaignPath, sessionPath
} }

View File

@@ -12,6 +12,7 @@ type Config struct {
StableInputs ResolvedStableInputs StableInputs ResolvedStableInputs
SessionSource SessionSource SessionSource SessionSource
Party ResolvedParty
} }
// PipelineConfig contains durable pipeline-level settings. // PipelineConfig contains durable pipeline-level settings.
@@ -323,6 +324,23 @@ type ResolvedInputFile struct {
Source string 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. // SessionSource records where session.yml came from before materialization.
type SessionSource struct { type SessionSource struct {
Source string Source string

View File

@@ -167,12 +167,11 @@ func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sess
if err != nil { if err != nil {
return nil, err return nil, err
} }
return LoadSessionWithPipelineCampaignOptions(LoadedPipelineCampaign{ loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
PipelinePath: pipelinePath, if err != nil {
Pipeline: pipelineCfg, return nil, err
CampaignPath: campaignPath, }
Campaign: campaignCfg, return LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, sessionOpts)
}, sessionPath, sessionOpts)
} }
// LoadedPipelineCampaign retains one already loaded pipeline and campaign for // LoadedPipelineCampaign retains one already loaded pipeline and campaign for
@@ -183,6 +182,35 @@ type LoadedPipelineCampaign struct {
Pipeline *PipelineConfig Pipeline *PipelineConfig
CampaignPath string CampaignPath string
Campaign *CampaignConfig 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 // 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 // the resolved pipeline/campaign context for callers that need to locate or
// retrieve a session without rereading the root pipeline. // retrieve a session without rereading the root pipeline.
func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) { 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{ cfg := &Config{
Pipeline: loaded.Pipeline, Pipeline: loaded.Pipeline,
Campaign: loaded.Campaign, Campaign: loaded.Campaign,
@@ -211,6 +257,12 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
CampaignPath: loaded.CampaignPath, CampaignPath: loaded.CampaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
SessionSource: sessionSource, 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 { if sessionCfg == nil {
return cfg, nil return cfg, nil
@@ -226,6 +278,22 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" { if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
cfg.SessionSource.LocalPath = sessionPath 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 cfg.StableInputs = stableInputs
return cfg, nil return cfg, nil
} }
@@ -236,12 +304,11 @@ func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath stri
if sessionCfg == nil { if sessionCfg == nil {
return nil, fmt.Errorf("session config is required") return nil, fmt.Errorf("session config is required")
} }
return ResolveLoadedPipelineCampaign(LoadedPipelineCampaign{ loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
PipelinePath: pipelinePath, if err != nil {
Pipeline: pipelineCfg, return nil, err
CampaignPath: campaignPath, }
Campaign: campaignCfg, return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource)
}, sessionPath, sessionCfg, sessionSource)
} }
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) { func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {

View File

@@ -176,6 +176,9 @@ inputs:
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) 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 return pipelinePath, sessionPath
} }

View File

@@ -6,3 +6,12 @@ package config
func classifyLegacyPartyDocument() *PartyDocument { func classifyLegacyPartyDocument() *PartyDocument {
return &PartyDocument{Mode: PartyModeLegacy} 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
}

View File

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

View File

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

View File

@@ -345,7 +345,8 @@ func TestLoadWithSessionOptionsCarriesExplicitProfilePresence(t *testing.T) {
rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing")) rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing"))
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n") writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n")
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\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") sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n")
selected := "testing" selected := "testing"
cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected}) cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected})

View File

@@ -40,6 +40,9 @@ func Validate(cfg *Config) error {
if err := validateSession(cfg.Session); err != nil { if err := validateSession(cfg.Session); err != nil {
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err) 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 { if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
return fmt.Errorf("pipeline/session config invalid: %w", err) return fmt.Errorf("pipeline/session config invalid: %w", err)
} }
@@ -66,9 +69,6 @@ func validateCampaign(cfg *CampaignConfig) error {
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("campaign.inputs.glossary_file is required") 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) == "" { if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("campaign.inputs.party_file is required") return fmt.Errorf("campaign.inputs.party_file is required")
} }
@@ -794,12 +794,6 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("session.inputs.glossary_file is required") 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) == "" { if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") 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 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 { func validateSessionIdentifier(fieldName, value string, required bool) error {
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
if required { if required {