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

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

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 &pipelineCampaignConfig{
PipelinePath: loadedPipelinePath,
CampaignPath: resolvedCampaignPath,
Pipeline: pipelineCfg,
Campaign: campaignCfg,
}, nil
loaded, err := config.LoadPipelineCampaign(loadedPipelinePath, pipelineCfg, resolvedCampaignPath, campaignCfg)
if err != nil {
return nil, err
}
return &loaded, nil
}
func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) {

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) {
tests := []struct {
name string

View File

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

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,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
err = Validate(cfg)
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil {
t.Fatal("expected validation error, got nil")
t.Fatal("expected load error, got nil")
}
if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") {
if !strings.Contains(err.Error(), "players_file is required with a legacy party") {
t.Fatalf("error = %q, want players_file required", err.Error())
}
}
@@ -274,6 +270,11 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
for _, name := range []string{"party.yml", "campaign-party.yml", "session-party.yml"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("legacy: party\n"), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
return pipelinePath, campaignPath, sessionPath
}

View File

@@ -12,6 +12,7 @@ type Config struct {
StableInputs ResolvedStableInputs
SessionSource SessionSource
Party ResolvedParty
}
// PipelineConfig contains durable pipeline-level settings.
@@ -323,6 +324,23 @@ type ResolvedInputFile struct {
Source string
}
// ResolvedParty records the selected party mode, its non-secret source
// provenance, and canonical domain data when available. It is runtime-only and
// must never be copied into manifests as raw party content.
type ResolvedParty struct {
Mode PartyMode
Source PartySource
Canonical *CanonicalParty
}
// PartySource identifies the selected party input without retaining its raw
// contents. Path is the resolved on-disk source path.
type PartySource struct {
Path string
ConfigPath string
Source string
}
// SessionSource records where session.yml came from before materialization.
type SessionSource struct {
Source string

View File

@@ -167,12 +167,11 @@ func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sess
if err != nil {
return nil, err
}
return LoadSessionWithPipelineCampaignOptions(LoadedPipelineCampaign{
PipelinePath: pipelinePath,
Pipeline: pipelineCfg,
CampaignPath: campaignPath,
Campaign: campaignCfg,
}, sessionPath, sessionOpts)
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
if err != nil {
return nil, err
}
return LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, sessionOpts)
}
// LoadedPipelineCampaign retains one already loaded pipeline and campaign for
@@ -183,6 +182,35 @@ type LoadedPipelineCampaign struct {
Pipeline *PipelineConfig
CampaignPath string
Campaign *CampaignConfig
Party ResolvedParty
}
// LoadPipelineCampaign combines already loaded pipeline and campaign documents
// with their campaign-owned party source. Callers that later resolve a session
// retain this context rather than independently reimplementing party loading.
func LoadPipelineCampaign(pipelinePath string, pipeline *PipelineConfig, campaignPath string, campaign *CampaignConfig) (LoadedPipelineCampaign, error) {
if pipeline == nil {
return LoadedPipelineCampaign{}, fmt.Errorf("pipeline config is required")
}
if campaign == nil {
return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required")
}
party, err := resolveCampaignParty(campaignPath, campaign)
if err != nil {
return LoadedPipelineCampaign{}, err
}
if party.Mode == PartyModeCanonical {
if err := validateCanonicalPartySelection(campaign, nil); err != nil {
return LoadedPipelineCampaign{}, err
}
}
return LoadedPipelineCampaign{
PipelinePath: pipelinePath,
Pipeline: pipeline,
CampaignPath: campaignPath,
Campaign: campaign,
Party: party,
}, nil
}
// LoadSessionWithPipelineCampaignOptions loads one local session and combines
@@ -203,6 +231,24 @@ func LoadSessionWithPipelineCampaignOptions(loaded LoadedPipelineCampaign, sessi
// the resolved pipeline/campaign context for callers that need to locate or
// retrieve a session without rereading the root pipeline.
func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
if loaded.Pipeline == nil {
return nil, fmt.Errorf("pipeline config is required")
}
if loaded.Campaign == nil {
return nil, fmt.Errorf("campaign config is required")
}
if loaded.Party.Mode == "" {
party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign)
if err != nil {
return nil, err
}
loaded.Party = party
}
if loaded.Party.Mode == PartyModeCanonical {
if err := validateCanonicalPartySelection(loaded.Campaign, nil); err != nil {
return nil, err
}
}
cfg := &Config{
Pipeline: loaded.Pipeline,
Campaign: loaded.Campaign,
@@ -211,6 +257,12 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
CampaignPath: loaded.CampaignPath,
SessionPath: sessionPath,
SessionSource: sessionSource,
Party: loaded.Party,
}
if cfg.Party.Mode == PartyModeCanonical && sessionCfg != nil {
if err := validateCanonicalPartySelection(loaded.Campaign, sessionCfg); err != nil {
return nil, err
}
}
if sessionCfg == nil {
return cfg, nil
@@ -226,6 +278,22 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
cfg.SessionSource.LocalPath = sessionPath
}
if cfg.Party.Mode == PartyModeCanonical {
stableInputs.PlayersFile = virtualPlayersInput()
cfg.Session.Inputs.PlayersFile = ""
} else {
party, err := resolvePartyInput(stableInputs.PartyFile)
if err != nil {
return nil, err
}
if party.Mode == PartyModeCanonical {
return nil, fmt.Errorf("session.inputs.party_file cannot select a canonical party; canonical parties are campaign-owned")
}
cfg.Party = party
if strings.TrimSpace(stableInputs.PlayersFile.Path) == "" {
return nil, fmt.Errorf("players_file is required with a legacy party")
}
}
cfg.StableInputs = stableInputs
return cfg, nil
}
@@ -236,12 +304,11 @@ func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath stri
if sessionCfg == nil {
return nil, fmt.Errorf("session config is required")
}
return ResolveLoadedPipelineCampaign(LoadedPipelineCampaign{
PipelinePath: pipelinePath,
Pipeline: pipelineCfg,
CampaignPath: campaignPath,
Campaign: campaignCfg,
}, sessionPath, sessionCfg, sessionSource)
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
if err != nil {
return nil, err
}
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource)
}
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {

View File

@@ -176,6 +176,9 @@ inputs:
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte("legacy: party\n"), 0o644); err != nil {
t.Fatalf("write party.yml: %v", err)
}
return pipelinePath, sessionPath
}

View File

@@ -6,3 +6,12 @@ package config
func classifyLegacyPartyDocument() *PartyDocument {
return &PartyDocument{Mode: PartyModeLegacy}
}
// resolveLegacyParty preserves the deliberately opaque legacy party mode.
// This small compatibility surface is intended for removal once legacy
// campaign inputs are no longer supported.
func resolveLegacyParty(resolved ResolvedParty) (ResolvedParty, error) {
resolved.Mode = PartyModeLegacy
resolved.Canonical = nil
return resolved, nil
}

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"))
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n")
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\n")
campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\n")
campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n players_file: players.yml\n party_file: party.yml\n")
writePipelineSource(t, dir, "party.yml", "legacy: party\n")
sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n")
selected := "testing"
cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected})

View File

@@ -40,6 +40,9 @@ func Validate(cfg *Config) error {
if err := validateSession(cfg.Session); err != nil {
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
}
if err := validateResolvedPartyInputs(cfg); err != nil {
return fmt.Errorf("campaign/session config invalid: %w", err)
}
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
return fmt.Errorf("pipeline/session config invalid: %w", err)
}
@@ -66,9 +69,6 @@ func validateCampaign(cfg *CampaignConfig) error {
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("campaign.inputs.glossary_file is required")
}
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
return fmt.Errorf("campaign.inputs.players_file is required")
}
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("campaign.inputs.party_file is required")
}
@@ -794,12 +794,6 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("session.inputs.glossary_file is required")
}
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
return fmt.Errorf("session.inputs.players_file is required")
}
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("session.inputs.party_file is required")
}
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
}
@@ -825,6 +819,31 @@ func validateSession(cfg *SessionConfig) error {
return nil
}
func validateResolvedPartyInputs(cfg *Config) error {
if cfg == nil || cfg.Session == nil {
return fmt.Errorf("session config is required")
}
if cfg.Party.Mode == PartyModeCanonical {
if cfg.Party.Canonical == nil {
return fmt.Errorf("canonical party data is required")
}
if cfg.Party.Source.Source != "campaign_config" {
return fmt.Errorf("canonical party must be sourced by campaign_config")
}
if strings.TrimSpace(cfg.StableInputs.PlayersFile.Source) != "derived_from_party" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) != "" {
return fmt.Errorf("canonical party requires derived players input")
}
return nil
}
if strings.TrimSpace(cfg.Session.Inputs.PlayersFile) == "" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) == "" {
return fmt.Errorf("players input is required with a legacy party")
}
if strings.TrimSpace(cfg.Session.Inputs.PartyFile) == "" || strings.TrimSpace(cfg.StableInputs.PartyFile.Path) == "" {
return fmt.Errorf("party input is required with a legacy party")
}
return nil
}
func validateSessionIdentifier(fieldName, value string, required bool) error {
if strings.TrimSpace(value) == "" {
if required {