Resolve canonical parties with campaign configuration
This commit is contained in:
@@ -69,9 +69,15 @@ remote state with an unsafe legacy identity must be migrated before use.
|
||||
- Pipeline defaults are applied before validation.
|
||||
- Campaign and session identities must agree.
|
||||
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
||||
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
|
||||
from session overrides when provided, otherwise from campaign defaults. An
|
||||
empty or omitted session spell-catalog value inherits the campaign value.
|
||||
`party_file`) and the optional `spell_catalog_file` resolve from session
|
||||
overrides when provided, otherwise from campaign defaults. An empty or
|
||||
omitted session spell-catalog value inherits the campaign value.
|
||||
- `party_file` is classified when pipeline and campaign configuration are
|
||||
combined. A versioned [canonical party](integrations/party.md) is
|
||||
campaign-owned, derives the players input internally, and forbids both a
|
||||
separate `players_file` and a session `party_file` override. An unversioned
|
||||
party remains a bounded legacy input and requires `players_file`; its normal
|
||||
campaign/session overrides continue to apply.
|
||||
- Exactly one audio mode must be configured in session input:
|
||||
- local (`audio_dir` or `audio_files`), or
|
||||
- S3 (`audio_s3.prefix`).
|
||||
@@ -433,8 +439,8 @@ integration.
|
||||
| `inputs.speakers_file` | string | Yes | stable input default |
|
||||
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||
| `inputs.players_file` | string | Yes | stable input default |
|
||||
| `inputs.party_file` | string | Yes | stable input default |
|
||||
| `inputs.players_file` | string | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party |
|
||||
| `inputs.party_file` | string | Yes | stable 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` |
|
||||
|
||||
### Session
|
||||
@@ -449,8 +455,8 @@ integration.
|
||||
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.players_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.party_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.players_file` | string | No | legacy-party override; forbidden for a canonical party |
|
||||
| `inputs.party_file` | string | No | legacy-party override; forbidden for a canonical campaign party |
|
||||
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||
|
||||
@@ -21,6 +21,7 @@ focused stage documents.
|
||||
- [Audita](./audita.md): transcript polishing (`audita process`).
|
||||
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
||||
discovery (`notarius run`).
|
||||
- [Party](./party.md): canonical campaign roster input.
|
||||
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
||||
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
||||
(`scriptorium run|render`).
|
||||
|
||||
49
docs/integrations/party.md
Normal file
49
docs/integrations/party.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Canonical Party Input
|
||||
|
||||
`party.yml` is a campaign-owned roster input. Narratio recognizes the
|
||||
versioned `narratio.party.v1` document below when it resolves a pipeline,
|
||||
campaign, and session together.
|
||||
|
||||
```yaml
|
||||
schema_version: narratio.party.v1
|
||||
|
||||
characters:
|
||||
arannis:
|
||||
player:
|
||||
name: Eric
|
||||
character:
|
||||
name: Arannis
|
||||
alias:
|
||||
- Ari
|
||||
- The Grey Owl
|
||||
classes:
|
||||
- name: wizard
|
||||
level: 8
|
||||
```
|
||||
|
||||
`characters` is a non-empty mapping. Each key is a stable character ID using
|
||||
the configured-artifact key grammar: a lowercase ASCII letter followed by zero
|
||||
or more lowercase ASCII letters, digits, or underscores. Character order is
|
||||
preserved where roster order matters.
|
||||
|
||||
Every entry has `player.name`, `character.name`, and a non-empty
|
||||
`character.classes` list. Class entries require a non-empty `name` and may
|
||||
include a positive integer `level`. The optional, intentionally singular
|
||||
`character.alias` field is a list. Names, aliases, and class names must be
|
||||
non-empty, trimmed display strings without control characters. Character names
|
||||
and aliases must be unique across the full roster under Unicode-aware
|
||||
case-insensitive comparison; player names may repeat.
|
||||
|
||||
The document has exactly one YAML document and accepts no unknown fields. A
|
||||
wrong or malformed `schema_version` is an error.
|
||||
|
||||
## Legacy migration boundary
|
||||
|
||||
An unversioned party input remains supported only as opaque legacy reference
|
||||
material while campaigns migrate. It requires a separate `players_file` and
|
||||
retains the existing session override behavior. It cannot be mixed with a
|
||||
canonical party: canonical campaigns must omit `players_file`, and sessions
|
||||
must not override their party or players inputs.
|
||||
|
||||
Use the canonical document for new campaigns. The configuration rules and
|
||||
source-relative path behavior are defined in the [Configuration Reference](../config.md).
|
||||
@@ -52,6 +52,14 @@ download retain that exact pipeline object and its private provenance. Removing
|
||||
a temporary downloaded session file therefore cannot invalidate the resolved
|
||||
pipeline or campaign context.
|
||||
|
||||
Campaign context construction also reads and classifies the campaign-owned
|
||||
party source through `ParseParty`. A canonical party retains its raw bytes and
|
||||
normalized roster in runtime-only `ResolvedParty` provenance, while a legacy
|
||||
party remains opaque. Canonical resolution creates a virtual
|
||||
`derived_from_party` players input and rejects competing campaign or session
|
||||
players files and session party overrides. The compact legacy compatibility
|
||||
path resolves the effective campaign/session party and players files together.
|
||||
|
||||
## Canonical Party Domain
|
||||
|
||||
`ParseParty` is the package-owned boundary for classifying a party source.
|
||||
@@ -103,3 +111,6 @@ confirming local session resolution retains the original pipeline. Other
|
||||
configuration tests continue to protect defaults and validation after assembly.
|
||||
`party_test.go` protects the versioned party schema, domain invariants, and
|
||||
deterministic players projection without involving campaign or runtime wiring.
|
||||
`party_resolution_test.go` protects campaign-owned party loading, canonical
|
||||
input restrictions, legacy overrides, source provenance, and virtual players
|
||||
input selection.
|
||||
|
||||
@@ -655,7 +655,7 @@ resolution.
|
||||
|
||||
## Stage 11 — Campaign Party Resolution And Isolated Legacy Mode
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
93
internal/config/party_resolution.go
Normal file
93
internal/config/party_resolution.go
Normal 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"}
|
||||
}
|
||||
240
internal/config/party_resolution_test.go
Normal file
240
internal/config/party_resolution_test.go
Normal 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
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user