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

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

View File

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

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ func TestIdentityValidationRejectsUnsafeSegments(t *testing.T) {
for _, value := range unsafe {
t.Run("campaign/"+strings.ReplaceAll(value, "/", "_"), func(t *testing.T) {
cfg := &CampaignConfig{CampaignID: value, Inputs: CampaignInputsConfig{
SpeakersFile: "speakers.yml", AutocorrectFile: "autocorrect.yml", GlossaryFile: "glossary.yml", PlayersFile: "players.yml", PartyFile: "party.yml",
SpeakersFile: "speakers.yml", AutocorrectFile: "autocorrect.yml", GlossaryFile: "glossary.yml", PartyFile: "party.yml",
}}
if err := validateCampaign(cfg); err == nil {
t.Fatalf("validateCampaign(%q) error = nil, want rejection", value)

View File

@@ -278,14 +278,9 @@ func LoadPipelineCampaignWithParty(pipelinePath string, pipeline *PipelineConfig
if campaign == nil {
return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required")
}
if party.Mode == "" {
if party.Canonical == nil {
return LoadedPipelineCampaign{}, fmt.Errorf("resolved campaign party is required")
}
if party.Mode == PartyModeCanonical {
if err := validateCanonicalPartySelection(campaign, nil); err != nil {
return LoadedPipelineCampaign{}, err
}
}
if err := expandPipelineArtifactFamilies(pipeline, party); err != nil {
return LoadedPipelineCampaign{}, err
}
@@ -322,18 +317,13 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
if loaded.Campaign == nil {
return nil, fmt.Errorf("campaign config is required")
}
if loaded.Party.Mode == "" {
if loaded.Party.Canonical == nil {
party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign)
if err != nil {
return nil, err
}
loaded.Party = party
}
if loaded.Party.Mode == PartyModeCanonical {
if err := validateCanonicalPartySelection(loaded.Campaign, nil); err != nil {
return nil, err
}
}
cfg := &Config{
Pipeline: loaded.Pipeline,
Campaign: loaded.Campaign,
@@ -344,11 +334,6 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
SessionSource: sessionSource,
Party: loaded.Party,
}
if cfg.Party.Mode == PartyModeCanonical && sessionCfg != nil {
if err := validateCanonicalPartySelection(loaded.Campaign, sessionCfg); err != nil {
return nil, err
}
}
if sessionCfg == nil {
return cfg, nil
}
@@ -363,22 +348,6 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
cfg.SessionSource.LocalPath = sessionPath
}
if cfg.Party.Mode == PartyModeCanonical {
stableInputs.PlayersFile = virtualPlayersInput()
cfg.Session.Inputs.PlayersFile = ""
} else {
party, err := resolvePartyInput(stableInputs.PartyFile)
if err != nil {
return nil, err
}
if party.Mode == PartyModeCanonical {
return nil, fmt.Errorf("session.inputs.party_file cannot select a canonical party; canonical parties are campaign-owned")
}
cfg.Party = party
if strings.TrimSpace(stableInputs.PlayersFile.Path) == "" {
return nil, fmt.Errorf("players_file is required with a legacy party")
}
}
cfg.StableInputs = stableInputs
return cfg, nil
}
@@ -456,15 +425,9 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
campaignPath,
sessionPath,
),
PlayersFile: selectStableInput(
campaignCfg.Inputs.PlayersFile,
sessionCfg.Inputs.PlayersFile,
campaignPath,
sessionPath,
),
PartyFile: selectStableInput(
campaignCfg.Inputs.PartyFile,
sessionCfg.Inputs.PartyFile,
"",
campaignPath,
sessionPath,
),
@@ -479,8 +442,6 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path
return stable, nil
}

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" {
t.Fatalf("same-member input source = %q", got)
}
if cfg.Party.Mode != PartyModeCanonical || cfg.Party.Canonical == nil || len(cfg.Party.Canonical.Characters) != 2 {
if cfg.Party.Canonical == nil || len(cfg.Party.Canonical.Characters) != 2 {
t.Fatalf("canonical party = %#v", cfg.Party)
}
characters := cfg.Party.Canonical.Characters

View File

@@ -20,35 +20,6 @@ const (
PlayersSchemaVersion = "narratio.players.v1"
)
// PartyMode records whether a source document uses the canonical party
// contract or the bounded compatibility path for older opaque files.
type PartyMode string
const (
PartyModeCanonical PartyMode = "canonical"
PartyModeLegacy PartyMode = "legacy"
)
// PartyDocument classifies a party input. Canonical is populated only for a
// versioned canonical document; legacy contents intentionally remain opaque.
type PartyDocument struct {
Mode PartyMode
Canonical *CanonicalParty
}
// IsCanonical reports whether the document has validated canonical data.
func (d *PartyDocument) IsCanonical() bool {
return d != nil && d.Mode == PartyModeCanonical && d.Canonical != nil
}
// PlayersYAML renders the derived players projection for a canonical document.
func (d *PartyDocument) PlayersYAML() ([]byte, error) {
if !d.IsCanonical() {
return nil, fmt.Errorf("canonical party is required")
}
return d.Canonical.PlayersYAML()
}
// CanonicalParty is the normalized domain value for narratio.party.v1. Raw is
// retained independently so later preparation can copy the authored party file
// byte-for-byte rather than serializing normalized values.
@@ -84,7 +55,7 @@ type PartyClass struct {
}
// ParseParty strictly parses one supported party document.
func ParseParty(data []byte) (*PartyDocument, error) {
func ParseParty(data []byte) (*CanonicalParty, error) {
root, decoder, err := parsePartyFirstDocument(data)
if err != nil {
return nil, err
@@ -96,7 +67,7 @@ func ParseParty(data []byte) (*PartyDocument, error) {
if err != nil {
return nil, err
}
return &PartyDocument{Mode: PartyModeCanonical, Canonical: party}, nil
return party, nil
}
func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) {

View File

@@ -28,18 +28,17 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
if err != nil {
return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err)
}
document, err := ParseParty(raw)
party, err := ParseParty(raw)
if err != nil {
return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err)
}
return ResolvedParty{
Mode: PartyModeCanonical,
Source: PartySource{
Path: path,
ConfigPath: configPath,
Source: input.Source,
},
Canonical: document.Canonical,
Canonical: party,
}, nil
}
@@ -64,26 +63,3 @@ func resolveCampaignParty(campaignPath string, campaign *CampaignConfig) (Resolv
}
return resolvePartyInput(input)
}
func validateCanonicalPartySelection(campaign *CampaignConfig, session *SessionConfig) error {
if campaign == nil {
return fmt.Errorf("campaign config is required")
}
if strings.TrimSpace(campaign.Inputs.PlayersFile) != "" {
return fmt.Errorf("campaign.inputs.players_file is not allowed with a canonical party")
}
if session == nil {
return nil
}
if strings.TrimSpace(session.Inputs.PlayersFile) != "" {
return fmt.Errorf("session.inputs.players_file is not allowed with a canonical party")
}
if strings.TrimSpace(session.Inputs.PartyFile) != "" {
return fmt.Errorf("session.inputs.party_file cannot override a canonical campaign party")
}
return nil
}
func virtualPlayersInput() ResolvedInputFile {
return ResolvedInputFile{Source: "derived_from_party"}
}

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -41,7 +40,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -67,7 +65,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -96,7 +93,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -146,7 +142,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -171,7 +166,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)

View File

@@ -845,23 +845,11 @@ func validateResolvedPartyInputs(cfg *Config) error {
if cfg == nil || cfg.Session == nil {
return fmt.Errorf("session config is required")
}
if cfg.Party.Mode == PartyModeCanonical {
if cfg.Party.Canonical == nil {
return fmt.Errorf("canonical party data is required")
}
if cfg.Party.Source.Source != "campaign_config" {
return fmt.Errorf("canonical party must be sourced by campaign_config")
}
if strings.TrimSpace(cfg.StableInputs.PlayersFile.Source) != "derived_from_party" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) != "" {
return fmt.Errorf("canonical party requires derived players input")
}
return nil
if cfg.Party.Canonical == nil {
return fmt.Errorf("canonical party data is required")
}
if strings.TrimSpace(cfg.Session.Inputs.PlayersFile) == "" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) == "" {
return fmt.Errorf("players input is required with a legacy party")
}
if strings.TrimSpace(cfg.Session.Inputs.PartyFile) == "" || strings.TrimSpace(cfg.StableInputs.PartyFile.Path) == "" {
return fmt.Errorf("party input is required with a legacy party")
if cfg.Party.Source.Source != "campaign_config" {
return fmt.Errorf("canonical party must be sourced by campaign_config")
}
return nil
}