Remove legacy party configuration

This commit is contained in:
2026-09-02 12:04:35 +00:00
parent 61095e69ce
commit 82d75e7beb
35 changed files with 110 additions and 364 deletions

View File

@@ -106,10 +106,10 @@ family declarations, and runtime provenance.
`config sources` reports the same fully validated resolution without printing `config sources` reports the same fully validated resolution without printing
effective values. Its header identifies the root, ordered imports, selected 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`. remaining tab-separated records are sorted as `path`, `role`, and `source`.
Roles distinguish root, import, profile, centralized default, campaign, party, 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 one family record and one party record at the same logical path. The output
never reads or prints secret values. never reads or prints secret values.

View File

@@ -79,8 +79,8 @@ Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
as the complete copyable migration reference. Split stable pipeline settings as the complete copyable migration reference. Split stable pipeline settings
into explicit additive imports, place production/testing differences in one into explicit additive imports, place production/testing differences in one
selected overlay, and retain a production `default_profile`. Convert campaign selected overlay, and retain a production `default_profile`. Convert campaign
rosters to [canonical party input](integrations/party.md), remove a separate rosters to [canonical party input](integrations/party.md), then express
`players_file`, then express character work as families. Inspect the result character work as families. Inspect the result
with `config validate`, `config show`, and `config sources`; use `config diff` with `config validate`, `config show`, and `config sources`; use `config diff`
to review profiles before running a session. Unversioned party documents are to review profiles before running a session. Unversioned party documents are
rejected. rejected.
@@ -112,13 +112,13 @@ 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`,
`party_file`) and the optional `spell_catalog_file` resolve from session `party_file`) are campaign-owned; the first three may be overridden by the
overrides when provided, otherwise from campaign defaults. An empty or session. The optional `spell_catalog_file` also inherits a campaign value
omitted session spell-catalog value inherits the campaign value. when omitted by the session.
- `party_file` is parsed when pipeline and campaign configuration are combined. - `party_file` is parsed when pipeline and campaign configuration are combined.
It must be a versioned [canonical party](integrations/party.md), is It must be a versioned [canonical party](integrations/party.md), is
campaign-owned, derives the players input internally, and forbids both a campaign-owned and derives the players input internally. `players_file` and
separate `players_file` and a session `party_file` override. session `party_file` are unsupported configuration keys.
- 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`).
@@ -521,7 +521,6 @@ 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 | 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.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` |
@@ -537,8 +536,6 @@ 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 | 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.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

@@ -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 Unversioned party inputs are rejected. Replace each legacy party document with
the versioned structure above, move each roster entry under `characters`, and the versioned structure above, move each roster entry under `characters`, and
remove separate player and session-party overrides from the selected campaign remove separate player and session-party settings: those keys are unsupported.
and session configuration. The configuration-key removal itself is staged
separately; current configurations must already omit those competing values
when using a canonical party.
The configuration rules and source-relative path behavior are defined in the The configuration rules and source-relative path behavior are defined in the
[Configuration Reference](../config.md). [Configuration Reference](../config.md).

View File

@@ -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 Campaign context construction reads the campaign-owned party source through
`ParseParty`. The required canonical party retains its raw bytes and normalized `ParseParty`. The required canonical party retains its raw bytes and normalized
roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates
a virtual `derived_from_party` players input and rejects competing campaign or a deterministic `derived_from_party` players projection. The party is the only
session players files and session party overrides. configured roster authority; player and session-party fields are unsupported.
## Canonical Party Domain ## Canonical Party Domain

View File

@@ -185,7 +185,7 @@ fields and mode shell that supported them.
## Stage 3 — Remove Legacy Party And Players Configuration ## Stage 3 — Remove Legacy Party And Players Configuration
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -92,8 +92,8 @@ Safe fix:
- replace the file with one exact, single-document - replace the file with one exact, single-document
[`narratio.party.v1` party](./integrations/party.md); do not add a version [`narratio.party.v1` party](./integrations/party.md); do not add a version
marker to an otherwise incompatible legacy structure. Remove competing marker to an otherwise incompatible legacy structure. The versioned party is
player and session-party settings while migrating. the only configured roster authority.
Relevant reference: [Canonical Party Input](./integrations/party.md). Relevant reference: [Canonical Party Input](./integrations/party.md).

View File

@@ -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 [party](campaigns/sample-campaign/party.yml) fixture, plus an optional
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that [spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
follows the Notarius v0.6 contract. Narratio derives the players projection 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 - [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
reserves the expected filename and directory shape. Replace it with a real reserves the expected filename and directory shape. Replace it with a real
FLAC file before running transcription. FLAC file before running transcription.

View File

@@ -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 { if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil {
return err 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 return err
} }
} }

View File

@@ -400,7 +400,7 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) {
"imports: none", "imports: none",
"profile: name=testing selection=cli overlay=", "profile: name=testing selection=cli overlay=",
"campaign: id=sample-campaign source=", "campaign: id=sample-campaign source=",
"party: mode=canonical source=", "party: source=",
"digest: ", "digest: ",
"path\trole\tsource\n", "path\trole\tsource\n",
"workspace.root\troot\t", "workspace.root\troot\t",
@@ -423,7 +423,7 @@ func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
output := out.String() output := out.String()
partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml") partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml")
for _, want := range []string{ for _, want := range []string{
"party: mode=canonical source=" + partyPath, "party: source=" + partyPath,
"derived.players\tparty\t" + partyPath, "derived.players\tparty\t" + partyPath,
"scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath, "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath,
"scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath, "scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath,

View File

@@ -233,7 +233,7 @@ characters:
if err != nil { if err != nil {
t.Fatalf("ParseParty(changed party) error = %v", err) t.Fatalf("ParseParty(changed party) error = %v", err)
} }
cfg.Party.Canonical = partyDocument.Canonical cfg.Party.Canonical = partyDocument
prepared := loadLifecycleManifest(t, cfg) prepared := loadLifecycleManifest(t, cfg)
prepare, err := stage.Select("prepare") prepare, err := stage.Select("prepare")
@@ -290,7 +290,7 @@ characters:
if err != nil { if err != nil {
t.Fatalf("ParseParty(canonical party) error = %v", err) 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 { if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)

View File

@@ -56,17 +56,18 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
name string name string
in config.ResolvedInputFile in config.ResolvedInputFile
optional bool optional bool
derived bool
}{ }{
{name: "speakers", in: cfg.StableInputs.SpeakersFile}, {name: "speakers", in: cfg.StableInputs.SpeakersFile},
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile}, {name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
{name: "glossary", in: cfg.StableInputs.GlossaryFile}, {name: "glossary", in: cfg.StableInputs.GlossaryFile},
{name: "players", in: cfg.StableInputs.PlayersFile}, {name: "players", derived: true},
{name: "party", in: cfg.StableInputs.PartyFile}, {name: "party", in: cfg.StableInputs.PartyFile},
{name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true}, {name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true},
} }
out := make([]stableInputCheck, 0, len(items)) out := make([]stableInputCheck, 0, len(items))
for _, item := range 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"}) out = append(out, stableInputCheck{Name: item.name, Path: "derived from canonical party"})
continue continue
} }

View File

@@ -82,8 +82,8 @@ inputs:
t.Fatalf("loadCommandConfig() error = %v", err) t.Fatalf("loadCommandConfig() error = %v", err)
} }
defer func() { _ = loaded.Close() }() defer func() { _ = loaded.Close() }()
if loaded.Config.Party.Mode != config.PartyModeCanonical || loaded.Config.StableInputs.PlayersFile.Source != "derived_from_party" { if loaded.Config.Party.Canonical == nil {
t.Fatalf("remote config party = %#v, players = %#v", loaded.Config.Party, loaded.Config.StableInputs.PlayersFile) t.Fatalf("remote config party = %#v", loaded.Config.Party)
} }
} }

View File

@@ -1335,8 +1335,7 @@ func testConfig(t *testing.T) *config.Config {
CampaignPath: campaignPath, CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
Party: config.ResolvedParty{ Party: config.ResolvedParty{
Mode: config.PartyModeCanonical, Canonical: partyDocument,
Canonical: partyDocument.Canonical,
Source: config.PartySource{ Source: config.PartySource{
Path: filepath.Join(cfgDir, "party.yml"), Path: filepath.Join(cfgDir, "party.yml"),
ConfigPath: campaignPath, ConfigPath: campaignPath,
@@ -1359,7 +1358,6 @@ func testConfig(t *testing.T) *config.Config {
ConfigPath: campaignPath, ConfigPath: campaignPath,
Source: "campaign_config", Source: "campaign_config",
}, },
PlayersFile: config.ResolvedInputFile{Source: "derived_from_party"},
PartyFile: config.ResolvedInputFile{ PartyFile: config.ResolvedInputFile{
Path: "./party.yml", Path: "./party.yml",
ConfigPath: campaignPath, ConfigPath: campaignPath,

View File

@@ -40,7 +40,7 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er
if len(families) == 0 { if len(families) == 0 {
return nil 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") return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party")
} }

View File

@@ -183,7 +183,7 @@ func TestArtifactFamiliesRejectGeneratedKeyAndOutputCollisions(t *testing.T) {
Scriptorium: &ScriptoriumConfig{Artifacts: test.artifacts}, Scriptorium: &ScriptoriumConfig{Artifacts: test.artifacts},
resolution: &pipelineResolutionMetadata{artifactFamilies: map[string]ScriptoriumArtifactFamilyConfig{"character_meta": base}}, 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) { if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("expandPipelineArtifactFamilies() error = %v, want %q", err, test.wantError) t.Fatalf("expandPipelineArtifactFamilies() error = %v, want %q", err, test.wantError)
} }
@@ -290,12 +290,12 @@ func TestArtifactFamiliesRejectInvalidMemberDependenciesAndSources(t *testing.T)
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("concrete member source error = %v", err)
} }
pipeline.Scriptorium.Artifacts["explicit"] = ScriptoriumArtifactConfig{Inputs: map[string]ScriptoriumInputConfig{"bad": {Source: "narratio.member_artifact."}}} pipeline.Scriptorium.Artifacts["explicit"] = ScriptoriumArtifactConfig{Inputs: map[string]ScriptoriumInputConfig{"bad": {Source: "narratio.member_artifact."}}}
pipeline.resolution.artifactFamiliesExpanded = false 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) t.Fatalf("malformed concrete member source error = %v", err)
} }
} }

View File

@@ -114,7 +114,6 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config") 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.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.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") 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.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.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.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") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
} }

View File

@@ -64,7 +64,6 @@ type CampaignInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"` SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"` AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"` GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"` PartyFile string `yaml:"party_file"`
SpellCatalogFile string `yaml:"spell_catalog_file"` SpellCatalogFile string `yaml:"spell_catalog_file"`
} }
@@ -357,8 +356,6 @@ type SessionInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"` SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"` AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"` GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
SpellCatalogFile string `yaml:"spell_catalog_file"` SpellCatalogFile string `yaml:"spell_catalog_file"`
} }
@@ -373,7 +370,6 @@ type ResolvedStableInputs struct {
SpeakersFile ResolvedInputFile SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile PartyFile ResolvedInputFile
SpellCatalogFile ResolvedInputFile SpellCatalogFile ResolvedInputFile
} }
@@ -385,11 +381,10 @@ type ResolvedInputFile struct {
Source string Source string
} }
// ResolvedParty records the selected party mode, its non-secret source // ResolvedParty records the campaign-owned canonical party and its non-secret
// provenance, and canonical domain data when available. It is runtime-only and // source provenance. It is runtime-only and must never be copied into manifests
// must never be copied into manifests as raw party content. // as raw party content.
type ResolvedParty struct { type ResolvedParty struct {
Mode PartyMode
Source PartySource Source PartySource
Canonical *CanonicalParty Canonical *CanonicalParty
} }

View File

@@ -126,9 +126,8 @@ func EffectivePipelineSources(cfg *PipelineConfig, party ResolvedParty) ([]Effec
} }
// EffectiveCampaignSources projects selected campaign and party ownership // EffectiveCampaignSources projects selected campaign and party ownership
// without retaining or rendering campaign values. Canonical players are a // without retaining or rendering campaign values. Players are derived from the
// derived party value, while a legacy players file remains explicitly marked // canonical party source.
// as legacy input provenance.
func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, party ResolvedParty) []EffectivePipelineSourceRecord { func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, party ResolvedParty) []EffectivePipelineSourceRecord {
if campaign == nil { if campaign == nil {
return nil return nil
@@ -143,21 +142,11 @@ func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, par
if strings.TrimSpace(campaign.Inputs.SpellCatalogFile) != "" { if strings.TrimSpace(campaign.Inputs.SpellCatalogFile) != "" {
records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.spell_catalog_file", Role: "campaign", Source: campaignSource}) records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.spell_catalog_file", Role: "campaign", Source: campaignSource})
} }
if party.Mode == PartyModeCanonical { partySource := normalizedProvenancePath(party.Source.Path)
partySource := normalizedProvenancePath(party.Source.Path) records = append(records,
records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource},
EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource}, EffectivePipelineSourceRecord{Path: "derived.players", 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),
})
}
}
sort.Slice(records, func(left, right int) bool { sort.Slice(records, func(left, right int) bool {
if records[left].Path != records[right].Path { if records[left].Path != records[right].Path {
return records[left].Path < records[right].Path return records[left].Path < records[right].Path

View File

@@ -10,7 +10,7 @@ func TestIdentityValidationRejectsUnsafeSegments(t *testing.T) {
for _, value := range unsafe { for _, value := range unsafe {
t.Run("campaign/"+strings.ReplaceAll(value, "/", "_"), func(t *testing.T) { t.Run("campaign/"+strings.ReplaceAll(value, "/", "_"), func(t *testing.T) {
cfg := &CampaignConfig{CampaignID: value, Inputs: CampaignInputsConfig{ 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 { if err := validateCampaign(cfg); err == nil {
t.Fatalf("validateCampaign(%q) error = nil, want rejection", value) t.Fatalf("validateCampaign(%q) error = nil, want rejection", value)

View File

@@ -278,14 +278,9 @@ func LoadPipelineCampaignWithParty(pipelinePath string, pipeline *PipelineConfig
if campaign == nil { if campaign == nil {
return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required") return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required")
} }
if party.Mode == "" { if party.Canonical == nil {
return LoadedPipelineCampaign{}, fmt.Errorf("resolved campaign party is required") 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 { if err := expandPipelineArtifactFamilies(pipeline, party); err != nil {
return LoadedPipelineCampaign{}, err return LoadedPipelineCampaign{}, err
} }
@@ -322,18 +317,13 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
if loaded.Campaign == nil { if loaded.Campaign == nil {
return nil, fmt.Errorf("campaign config is required") return nil, fmt.Errorf("campaign config is required")
} }
if loaded.Party.Mode == "" { if loaded.Party.Canonical == nil {
party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign) party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign)
if err != nil { if err != nil {
return nil, err return nil, err
} }
loaded.Party = party 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,
@@ -344,11 +334,6 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
SessionSource: sessionSource, SessionSource: sessionSource,
Party: loaded.Party, 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
} }
@@ -363,22 +348,6 @@ 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
} }
@@ -456,15 +425,9 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
campaignPath, campaignPath,
sessionPath, sessionPath,
), ),
PlayersFile: selectStableInput(
campaignCfg.Inputs.PlayersFile,
sessionCfg.Inputs.PlayersFile,
campaignPath,
sessionPath,
),
PartyFile: selectStableInput( PartyFile: selectStableInput(
campaignCfg.Inputs.PartyFile, campaignCfg.Inputs.PartyFile,
sessionCfg.Inputs.PartyFile, "",
campaignPath, campaignPath,
sessionPath, sessionPath,
), ),
@@ -479,8 +442,6 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.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 sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path
return stable, nil return stable, nil
} }

View File

@@ -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" { 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) 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) t.Fatalf("canonical party = %#v", cfg.Party)
} }
characters := cfg.Party.Canonical.Characters characters := cfg.Party.Canonical.Characters

View File

@@ -20,35 +20,6 @@ const (
PlayersSchemaVersion = "narratio.players.v1" 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 // CanonicalParty is the normalized domain value for narratio.party.v1. Raw is
// retained independently so later preparation can copy the authored party file // retained independently so later preparation can copy the authored party file
// byte-for-byte rather than serializing normalized values. // byte-for-byte rather than serializing normalized values.
@@ -84,7 +55,7 @@ type PartyClass struct {
} }
// ParseParty strictly parses one supported party document. // ParseParty strictly parses one supported party document.
func ParseParty(data []byte) (*PartyDocument, error) { func ParseParty(data []byte) (*CanonicalParty, error) {
root, decoder, err := parsePartyFirstDocument(data) root, decoder, err := parsePartyFirstDocument(data)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -96,7 +67,7 @@ func ParseParty(data []byte) (*PartyDocument, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &PartyDocument{Mode: PartyModeCanonical, Canonical: party}, nil return party, nil
} }
func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) { func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) {

View File

@@ -28,18 +28,17 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
if err != nil { if err != nil {
return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err) return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err)
} }
document, err := ParseParty(raw) party, err := ParseParty(raw)
if err != nil { if err != nil {
return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err) return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err)
} }
return ResolvedParty{ return ResolvedParty{
Mode: PartyModeCanonical,
Source: PartySource{ Source: PartySource{
Path: path, Path: path,
ConfigPath: configPath, ConfigPath: configPath,
Source: input.Source, Source: input.Source,
}, },
Canonical: document.Canonical, Canonical: party,
}, nil }, nil
} }
@@ -64,26 +63,3 @@ func resolveCampaignParty(campaignPath string, campaign *CampaignConfig) (Resolv
} }
return resolvePartyInput(input) 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

@@ -44,7 +44,7 @@ inputs:
if err := Validate(cfg); err != nil { if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err) 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) t.Fatalf("party = %#v, want canonical party", cfg.Party)
} }
if got, want := cfg.Party.Source.Path, partyPath; got != want { if got, want := cfg.Party.Source.Path, partyPath; got != want {
@@ -56,9 +56,6 @@ inputs:
if !bytes.Equal(cfg.Party.Canonical.Raw, partyYAML) { if !bytes.Equal(cfg.Party.Canonical.Raw, partyYAML) {
t.Fatal("canonical party bytes were not retained") 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) { func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing.T) {
@@ -68,9 +65,9 @@ func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing
sessionExtra string sessionExtra string
want string want string
}{ }{
{name: "campaign players", campaignExtra: " players_file: players.yml\n", want: "campaign.inputs.players_file is not allowed"}, {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: "session.inputs.players_file is not allowed"}, {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: "session.inputs.party_file cannot override"}, {name: "session party", sessionExtra: " party_file: party-override.yml\n", want: "field party_file not found"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -126,15 +123,15 @@ func TestResolveAndLoadedCampaignShareCanonicalPartyResolution(t *testing.T) {
t.Fatalf("LoadPipelineCampaign() error = %v", err) t.Fatalf("LoadPipelineCampaign() error = %v", err)
} }
partial, err := ResolveLoadedPipelineCampaign(loaded, "", nil, SessionSource{}) 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) 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}) direct, err := Resolve(pipelinePath, pipeline, campaignPath, campaign, sessionPath, session, SessionSource{Source: "session_config", LocalPath: sessionPath})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
if direct.Party.Mode != PartyModeCanonical || direct.StableInputs.PlayersFile.Source != "derived_from_party" { if direct.Party.Canonical == nil {
t.Fatalf("Resolve() party = %#v, players = %#v", direct.Party, direct.StableInputs.PlayersFile) t.Fatalf("Resolve() party = %#v", direct.Party)
} }
} }

View File

@@ -27,13 +27,10 @@ characters:
if err != nil { if err != nil {
t.Fatalf("ParseParty() error = %v", err) t.Fatalf("ParseParty() error = %v", err)
} }
if !document.IsCanonical() || document.Mode != PartyModeCanonical { if !bytes.Equal(document.Raw, data) {
t.Fatalf("document mode = %#v, want canonical", document)
}
if !bytes.Equal(document.Canonical.Raw, data) {
t.Fatal("canonical raw bytes were not preserved") t.Fatal("canonical raw bytes were not preserved")
} }
characters := document.Canonical.Characters characters := document.Characters
if len(characters) != 1 || characters[0].ID != "arannis" { if len(characters) != 1 || characters[0].ID != "arannis" {
t.Fatalf("characters = %#v, want ordered arannis character", characters) t.Fatalf("characters = %#v, want ordered arannis character", characters)
} }
@@ -190,7 +187,7 @@ characters:
if err != nil { if err != nil {
t.Fatalf("ParseParty() error = %v", err) 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) t.Fatalf("character count = %d, want 2", got)
} }
} }
@@ -249,7 +246,7 @@ characters:
if err != nil { if err != nil {
t.Fatalf("PlayersYAML() error = %v", err) t.Fatalf("PlayersYAML() error = %v", err)
} }
again, err := document.Canonical.PlayersYAML() again, err := document.PlayersYAML()
if err != nil { if err != nil {
t.Fatalf("PlayersYAML() repeat error = %v", err) t.Fatalf("PlayersYAML() repeat error = %v", err)
} }

View File

@@ -17,7 +17,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
party_file: ./party.yml
` `
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)
@@ -41,7 +40,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
party_file: ./party.yml
` `
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)
@@ -67,7 +65,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
party_file: ./party.yml
` `
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)
@@ -96,7 +93,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
party_file: ./party.yml
` `
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)
@@ -146,7 +142,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
party_file: ./party.yml
` `
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)
@@ -171,7 +166,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
party_file: ./party.yml
` `
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)

View File

@@ -845,23 +845,11 @@ func validateResolvedPartyInputs(cfg *Config) error {
if cfg == nil || cfg.Session == nil { if cfg == nil || cfg.Session == nil {
return fmt.Errorf("session config is required") return fmt.Errorf("session config is required")
} }
if cfg.Party.Mode == PartyModeCanonical { if cfg.Party.Canonical == nil {
if cfg.Party.Canonical == nil { return fmt.Errorf("canonical party data is required")
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) == "" { if cfg.Party.Source.Source != "campaign_config" {
return fmt.Errorf("players input is required with a legacy party") return fmt.Errorf("canonical party must be sourced by campaign_config")
}
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 return nil
} }

View File

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

View File

@@ -23,10 +23,7 @@ func materializePreparedPartyInputs(store artifacts.Store, cfg *config.Config, i
if !ok { if !ok {
return nil, fmt.Errorf("prepared-input descriptor %q is unavailable", artifactpolicy.SourceInputParty) 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 materializeCanonicalPartyInputs(store, cfg.Party, inputsDir, party, players)
}
return materializeLegacyPartyInputs(store, cfg, inputsDir, party, players)
} }
func materializeCanonicalPartyInputs( func materializeCanonicalPartyInputs(

View File

@@ -132,43 +132,17 @@ func currentPreparePartySourceInputs(env *Env) ([]prepareExpectedInput, error) {
if !ok { if !ok {
return nil, fmt.Errorf("players prepared-input descriptor is unavailable") 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 {
if env.Config.Party.Canonical == nil || len(env.Config.Party.Canonical.Raw) == 0 { return nil, fmt.Errorf("canonical party data is unavailable")
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
} }
playersBytes, err := env.Config.Party.Canonical.PlayersYAML()
cfg := env.Config if err != nil {
inputs := []struct { return nil, err
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},
} }
expected := make([]prepareExpectedInput, 0, len(inputs)) return []prepareExpectedInput{
for _, input := range inputs { {kind: party.ManifestKind, checksum: checksumPrepareBytes(env.Config.Party.Canonical.Raw)},
resolved := stableInputSource(input.resolved, input.fallback, cfg.SessionPath) {kind: players.ManifestKind, checksum: checksumPrepareBytes(playersBytes)},
path, err := resolveConfigRelativePath(resolved) }, nil
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
} }
func currentPrepareAudioSourceInputs(ctx context.Context, env *Env, m *manifest.Manifest) ([]prepareExpectedInput, error) { func currentPrepareAudioSourceInputs(ctx context.Context, env *Env, m *manifest.Manifest) ([]prepareExpectedInput, error) {

View File

@@ -18,9 +18,13 @@ func TestPrepareResumeValidatesCurrentLocalSourcesAndPreparedCopies(t *testing.T
mutate func(*testing.T, *Env, string) mutate func(*testing.T, *Env, string)
}{ }{
{ {
name: "stable input bytes changed", name: "canonical party bytes changed",
mutate: func(t *testing.T, env *Env, _ string) { 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) env, m, _ := prepareResumeLocalFixture(t)
cfgDir := filepath.Dir(env.Config.CampaignPath) cfgDir := filepath.Dir(env.Config.CampaignPath)
relocated := filepath.Join(cfgDir, "relocated-party.yml") 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" env.Config.StableInputs.PartyFile.Path = "./relocated-party.yml"
assertPrepareResume(t, env, m, true) assertPrepareResume(t, env, m, true)
} }
@@ -135,7 +139,7 @@ func setCanonicalPrepareParty(t *testing.T, env *Env, raw []byte) {
t.Fatalf("ParseParty() error = %v", err) t.Fatalf("ParseParty() error = %v", err)
} }
env.Config.Party = config.ResolvedParty{ 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,
} }
} }

View File

@@ -177,19 +177,14 @@ characters:
if err != nil { if err != nil {
t.Fatalf("ParseParty() error = %v", err) 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{ env.Config.Party = config.ResolvedParty{
Mode: config.PartyModeCanonical, Canonical: document,
Canonical: document.Canonical,
Source: config.PartySource{ Source: config.PartySource{
Path: filepath.Join(filepath.Dir(env.Config.CampaignPath), "party.yml"), Path: filepath.Join(filepath.Dir(env.Config.CampaignPath), "party.yml"),
ConfigPath: env.Config.CampaignPath, ConfigPath: env.Config.CampaignPath,
Source: "campaign_config", 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") writeFile(t, filepath.Join(filepath.Dir(env.Config.SessionPath), "audio", "alice.flac"), "audio")
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { 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) { if got, err := os.ReadFile(preparedParty); err != nil || !bytes.Equal(got, partyBytes) {
t.Fatalf("prepared party = %q, %v; want unchanged canonical bytes", got, err) t.Fatalf("prepared party = %q, %v; want unchanged canonical bytes", got, err)
} }
wantPlayers, err := document.Canonical.PlayersYAML() wantPlayers, err := document.PlayersYAML()
if err != nil { if err != nil {
t.Fatalf("PlayersYAML() error = %v", err) 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) { func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -888,15 +859,18 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
`) `)
writeFile(t, sessionPath, "session_id: 2026-05-03\n") 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, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "players.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"), "[]\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{ cfg := &config.Config{
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
@@ -907,6 +881,9 @@ inputs:
SessionPath: sessionPath, SessionPath: sessionPath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
Party: config.ResolvedParty{Canonical: party, Source: config.PartySource{
Path: filepath.Join(cfgDir, "party.yml"), ConfigPath: campaignPath, Source: "campaign_config",
}},
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
@@ -930,11 +907,6 @@ inputs:
ConfigPath: campaignPath, ConfigPath: campaignPath,
Source: "campaign_config", Source: "campaign_config",
}, },
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{ PartyFile: config.ResolvedInputFile{
Path: "./party.yml", Path: "./party.yml",
ConfigPath: campaignPath, ConfigPath: campaignPath,

View File

@@ -20,7 +20,6 @@ type prepareSemanticConfig struct {
CampaignID string `json:"campaign_id"` CampaignID string `json:"campaign_id"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
PreviousSession string `json:"previous_session_id"` PreviousSession string `json:"previous_session_id"`
PartyMode string `json:"party_mode"`
PlayersSchema string `json:"players_schema_version,omitempty"` PlayersSchema string `json:"players_schema_version,omitempty"`
Audio prepareAudioSelection `json:"audio"` Audio prepareAudioSelection `json:"audio"`
StableInputs []prepareStableInputSelection `json:"stable_inputs"` StableInputs []prepareStableInputSelection `json:"stable_inputs"`
@@ -106,21 +105,17 @@ func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) {
if err != nil { if err != nil {
return prepareSemanticConfig{}, fmt.Errorf("resolve previous artifact requirements: %w", err) return prepareSemanticConfig{}, fmt.Errorf("resolve previous artifact requirements: %w", err)
} }
partyMode := cfg.Party.Mode if cfg.Party.Canonical == nil {
if partyMode == "" {
return prepareSemanticConfig{}, fmt.Errorf("prepare semantic configuration requires a resolved canonical party") return prepareSemanticConfig{}, fmt.Errorf("prepare semantic configuration requires a resolved canonical party")
} }
payload := prepareSemanticConfig{ payload := prepareSemanticConfig{
CampaignID: strings.TrimSpace(cfg.Session.Campaign), CampaignID: strings.TrimSpace(cfg.Session.Campaign),
SessionID: strings.TrimSpace(cfg.Session.SessionID), SessionID: strings.TrimSpace(cfg.Session.SessionID),
PreviousSession: strings.TrimSpace(cfg.Session.PreviousSessionID), PreviousSession: strings.TrimSpace(cfg.Session.PreviousSessionID),
PartyMode: string(partyMode), PlayersSchema: config.PlayersSchemaVersion,
Audio: prepareAudioSemantics(cfg), Audio: prepareAudioSemantics(cfg),
StableInputs: prepareStableInputSemantics(cfg), StableInputs: prepareStableInputSemantics(cfg),
} }
if partyMode == config.PartyModeCanonical {
payload.PlayersSchema = config.PlayersSchemaVersion
}
for _, requirement := range requirements { for _, requirement := range requirements {
entry := preparePreviousRequirement{Name: strings.TrimSpace(requirement.Name), Required: requirement.Required} entry := preparePreviousRequirement{Name: strings.TrimSpace(requirement.Name), Required: requirement.Required}
if cfg.Pipeline.Scriptorium != nil { if cfg.Pipeline.Scriptorium != nil {
@@ -159,8 +154,7 @@ func prepareStableInputSemantics(cfg *config.Config) []prepareStableInputSelecti
prepareStableInput("speakers", stable.SpeakersFile, cfg.Session.Inputs.SpeakersFile), prepareStableInput("speakers", stable.SpeakersFile, cfg.Session.Inputs.SpeakersFile),
prepareStableInput("autocorrect", stable.AutocorrectFile, cfg.Session.Inputs.AutocorrectFile), prepareStableInput("autocorrect", stable.AutocorrectFile, cfg.Session.Inputs.AutocorrectFile),
prepareStableInput("glossary", stable.GlossaryFile, cfg.Session.Inputs.GlossaryFile), prepareStableInput("glossary", stable.GlossaryFile, cfg.Session.Inputs.GlossaryFile),
prepareStableInput("players", stable.PlayersFile, cfg.Session.Inputs.PlayersFile), prepareStableInput("party", stable.PartyFile, ""),
prepareStableInput("party", stable.PartyFile, cfg.Session.Inputs.PartyFile),
prepareStableInput("spell_catalog", stable.SpellCatalogFile, cfg.Session.Inputs.SpellCatalogFile), prepareStableInput("spell_catalog", stable.SpellCatalogFile, cfg.Session.Inputs.SpellCatalogFile),
} }
} }

View File

@@ -49,12 +49,11 @@ func TestPrepareSemanticConfigSensitivity(t *testing.T) {
func TestPrepareSemanticConfigRecordsCanonicalProjectionVersion(t *testing.T) { func TestPrepareSemanticConfigRecordsCanonicalProjectionVersion(t *testing.T) {
env := initialSemanticEnv() env := initialSemanticEnv()
env.Config.Party.Mode = config.PartyModeCanonical
payload, err := buildPrepareSemanticConfig(env) payload, err := buildPrepareSemanticConfig(env)
if err != nil { if err != nil {
t.Fatalf("buildPrepareSemanticConfig() error = %v", err) 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) t.Fatalf("prepare semantic payload = %#v", payload)
} }
} }
@@ -120,13 +119,13 @@ func initialSemanticEnv() *Env {
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", Campaign: "campaign", SessionID: "2026-05-03", Campaign: "campaign",
Inputs: config.SessionInputsConfig{ Inputs: config.SessionInputsConfig{
AudioFiles: []string{"/audio/speaker.flac"}, PartyFile: "party.yml", AudioFiles: []string{"/audio/speaker.flac"},
}, },
}, },
StableInputs: config.ResolvedStableInputs{ StableInputs: config.ResolvedStableInputs{
PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"}, 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{}},
}} }}
} }

View File

@@ -401,12 +401,16 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
campaignPath := filepath.Join(cfgDir, "campaign.yml") campaignPath := filepath.Join(cfgDir, "campaign.yml")
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\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, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "players.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"), "[]\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 retries := 3
concurrency := 2 concurrency := 2
@@ -415,6 +419,9 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
CampaignPath: campaignPath, CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"}, 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{ Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: workspace}, Workspace: config.WorkspaceConfig{Root: workspace},
WhisperX: config.WhisperXConfig{ WhisperX: config.WhisperXConfig{
@@ -434,15 +441,12 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
SpeakersFile: "./speakers.yml", SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml", AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml", GlossaryFile: "./glossary.yml",
PlayersFile: "./players.yml",
PartyFile: "./party.yml",
}, },
}, },
StableInputs: config.ResolvedStableInputs{ StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"}, SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"},
AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.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"}, 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"}, PartyFile: config.ResolvedInputFile{Path: "./party.yml", ConfigPath: campaignPath, Source: "campaign_config"},
}, },
} }