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.
|
- Pipeline defaults are applied before validation.
|
||||||
- Campaign and session identities must agree.
|
- Campaign and session identities must agree.
|
||||||
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
||||||
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
|
`party_file`) and the optional `spell_catalog_file` resolve from session
|
||||||
from session overrides when provided, otherwise from campaign defaults. An
|
overrides when provided, otherwise from campaign defaults. An empty or
|
||||||
empty or omitted session spell-catalog value inherits the campaign value.
|
omitted session spell-catalog value inherits the campaign value.
|
||||||
|
- `party_file` is classified when pipeline and campaign configuration are
|
||||||
|
combined. A versioned [canonical party](integrations/party.md) is
|
||||||
|
campaign-owned, derives the players input internally, and forbids both a
|
||||||
|
separate `players_file` and a session `party_file` override. An unversioned
|
||||||
|
party remains a bounded legacy input and requires `players_file`; its normal
|
||||||
|
campaign/session overrides continue to apply.
|
||||||
- Exactly one audio mode must be configured in session input:
|
- Exactly one audio mode must be configured in session input:
|
||||||
- local (`audio_dir` or `audio_files`), or
|
- local (`audio_dir` or `audio_files`), or
|
||||||
- S3 (`audio_s3.prefix`).
|
- S3 (`audio_s3.prefix`).
|
||||||
@@ -433,8 +439,8 @@ integration.
|
|||||||
| `inputs.speakers_file` | string | Yes | stable input default |
|
| `inputs.speakers_file` | string | Yes | stable input default |
|
||||||
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
||||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||||
| `inputs.players_file` | string | Yes | stable input default |
|
| `inputs.players_file` | string | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party |
|
||||||
| `inputs.party_file` | string | Yes | stable input default |
|
| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` |
|
||||||
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
@@ -449,8 +455,8 @@ integration.
|
|||||||
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
|
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.players_file` | string | No | overrides campaign stable input |
|
| `inputs.players_file` | string | No | legacy-party override; forbidden for a canonical party |
|
||||||
| `inputs.party_file` | string | No | overrides campaign stable input |
|
| `inputs.party_file` | string | No | legacy-party override; forbidden for a canonical campaign party |
|
||||||
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ focused stage documents.
|
|||||||
- [Audita](./audita.md): transcript polishing (`audita process`).
|
- [Audita](./audita.md): transcript polishing (`audita process`).
|
||||||
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
||||||
discovery (`notarius run`).
|
discovery (`notarius run`).
|
||||||
|
- [Party](./party.md): canonical campaign roster input.
|
||||||
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
||||||
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
||||||
(`scriptorium run|render`).
|
(`scriptorium run|render`).
|
||||||
|
|||||||
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
|
a temporary downloaded session file therefore cannot invalidate the resolved
|
||||||
pipeline or campaign context.
|
pipeline or campaign context.
|
||||||
|
|
||||||
|
Campaign context construction also reads and classifies the campaign-owned
|
||||||
|
party source through `ParseParty`. A canonical party retains its raw bytes and
|
||||||
|
normalized roster in runtime-only `ResolvedParty` provenance, while a legacy
|
||||||
|
party remains opaque. Canonical resolution creates a virtual
|
||||||
|
`derived_from_party` players input and rejects competing campaign or session
|
||||||
|
players files and session party overrides. The compact legacy compatibility
|
||||||
|
path resolves the effective campaign/session party and players files together.
|
||||||
|
|
||||||
## Canonical Party Domain
|
## Canonical Party Domain
|
||||||
|
|
||||||
`ParseParty` is the package-owned boundary for classifying a party source.
|
`ParseParty` is the package-owned boundary for classifying a party source.
|
||||||
@@ -103,3 +111,6 @@ confirming local session resolution retains the original pipeline. Other
|
|||||||
configuration tests continue to protect defaults and validation after assembly.
|
configuration tests continue to protect defaults and validation after assembly.
|
||||||
`party_test.go` protects the versioned party schema, domain invariants, and
|
`party_test.go` protects the versioned party schema, domain invariants, and
|
||||||
deterministic players projection without involving campaign or runtime wiring.
|
deterministic players projection without involving campaign or runtime wiring.
|
||||||
|
`party_resolution_test.go` protects campaign-owned party loading, canonical
|
||||||
|
input restrictions, legacy overrides, source provenance, and virtual players
|
||||||
|
input selection.
|
||||||
|
|||||||
@@ -655,7 +655,7 @@ resolution.
|
|||||||
|
|
||||||
## Stage 11 — Campaign Party Resolution And Isolated Legacy Mode
|
## Stage 11 — Campaign Party Resolution And Isolated Legacy Mode
|
||||||
|
|
||||||
**Status: Pending**
|
**Status: Completed**
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
|
|||||||
@@ -563,6 +563,7 @@ inputs:
|
|||||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write campaign.yml: %v", err)
|
t.Fatalf("write campaign.yml: %v", err)
|
||||||
}
|
}
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||||
return campaignPath
|
return campaignPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,12 +155,11 @@ func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag str
|
|||||||
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &pipelineCampaignConfig{
|
loaded, err := config.LoadPipelineCampaign(loadedPipelinePath, pipelineCfg, resolvedCampaignPath, campaignCfg)
|
||||||
PipelinePath: loadedPipelinePath,
|
if err != nil {
|
||||||
CampaignPath: resolvedCampaignPath,
|
return nil, err
|
||||||
Pipeline: pipelineCfg,
|
}
|
||||||
Campaign: campaignCfg,
|
return &loaded, nil
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) {
|
func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) {
|
||||||
|
|||||||
@@ -44,6 +44,49 @@ inputs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteSessionLoadingRetainsCampaignCanonicalParty(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
canonicalCampaign := `campaign_id: sample-campaign
|
||||||
|
inputs:
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
party_file: ./party.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(campaignPath, []byte(canonicalCampaign), 0o644); err != nil {
|
||||||
|
t.Fatalf("write canonical campaign: %v", err)
|
||||||
|
}
|
||||||
|
canonicalParty := `schema_version: narratio.party.v1
|
||||||
|
characters:
|
||||||
|
arannis:
|
||||||
|
player: {name: Eric}
|
||||||
|
character:
|
||||||
|
name: Arannis
|
||||||
|
classes: [{name: wizard}]
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(filepath.Dir(campaignPath), "party.yml"), []byte(canonicalParty), 0o644); err != nil {
|
||||||
|
t.Fatalf("write canonical party: %v", err)
|
||||||
|
}
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
|
||||||
|
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, "", config.SessionLoadOptions{SessionID: "2026-05-03"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCommandConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = loaded.Close() }()
|
||||||
|
if loaded.Config.Party.Mode != config.PartyModeCanonical || loaded.Config.StableInputs.PlayersFile.Source != "derived_from_party" {
|
||||||
|
t.Fatalf("remote config party = %#v, players = %#v", loaded.Config.Party, loaded.Config.StableInputs.PlayersFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
|
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1406,6 +1406,7 @@ inputs:
|
|||||||
mustWriteFile(t, pipelinePath, pipelineYAML)
|
mustWriteFile(t, pipelinePath, pipelineYAML)
|
||||||
mustWriteFile(t, campaignPath, campaignYAML)
|
mustWriteFile(t, campaignPath, campaignYAML)
|
||||||
mustWriteFile(t, sessionPath, sessionYAML)
|
mustWriteFile(t, sessionPath, sessionYAML)
|
||||||
|
mustWriteFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
cfg, err := config.Load(pipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -206,21 +206,17 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
|
func TestLegacyCampaignRequiresPlayersInput(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
|
||||||
}
|
|
||||||
err = Validate(cfg)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected validation error, got nil")
|
t.Fatal("expected load error, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") {
|
if !strings.Contains(err.Error(), "players_file is required with a legacy party") {
|
||||||
t.Fatalf("error = %q, want players_file required", err.Error())
|
t.Fatalf("error = %q, want players_file required", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +270,11 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string
|
|||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
}
|
}
|
||||||
|
for _, name := range []string{"party.yml", "campaign-party.yml", "session-party.yml"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("legacy: party\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return pipelinePath, campaignPath, sessionPath
|
return pipelinePath, campaignPath, sessionPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Config struct {
|
|||||||
|
|
||||||
StableInputs ResolvedStableInputs
|
StableInputs ResolvedStableInputs
|
||||||
SessionSource SessionSource
|
SessionSource SessionSource
|
||||||
|
Party ResolvedParty
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineConfig contains durable pipeline-level settings.
|
// PipelineConfig contains durable pipeline-level settings.
|
||||||
@@ -323,6 +324,23 @@ type ResolvedInputFile struct {
|
|||||||
Source string
|
Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolvedParty records the selected party mode, its non-secret source
|
||||||
|
// provenance, and canonical domain data when available. It is runtime-only and
|
||||||
|
// must never be copied into manifests as raw party content.
|
||||||
|
type ResolvedParty struct {
|
||||||
|
Mode PartyMode
|
||||||
|
Source PartySource
|
||||||
|
Canonical *CanonicalParty
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartySource identifies the selected party input without retaining its raw
|
||||||
|
// contents. Path is the resolved on-disk source path.
|
||||||
|
type PartySource struct {
|
||||||
|
Path string
|
||||||
|
ConfigPath string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
// SessionSource records where session.yml came from before materialization.
|
// SessionSource records where session.yml came from before materialization.
|
||||||
type SessionSource struct {
|
type SessionSource struct {
|
||||||
Source string
|
Source string
|
||||||
|
|||||||
@@ -167,12 +167,11 @@ func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sess
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return LoadSessionWithPipelineCampaignOptions(LoadedPipelineCampaign{
|
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
|
||||||
PipelinePath: pipelinePath,
|
if err != nil {
|
||||||
Pipeline: pipelineCfg,
|
return nil, err
|
||||||
CampaignPath: campaignPath,
|
}
|
||||||
Campaign: campaignCfg,
|
return LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, sessionOpts)
|
||||||
}, sessionPath, sessionOpts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadedPipelineCampaign retains one already loaded pipeline and campaign for
|
// LoadedPipelineCampaign retains one already loaded pipeline and campaign for
|
||||||
@@ -183,6 +182,35 @@ type LoadedPipelineCampaign struct {
|
|||||||
Pipeline *PipelineConfig
|
Pipeline *PipelineConfig
|
||||||
CampaignPath string
|
CampaignPath string
|
||||||
Campaign *CampaignConfig
|
Campaign *CampaignConfig
|
||||||
|
Party ResolvedParty
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPipelineCampaign combines already loaded pipeline and campaign documents
|
||||||
|
// with their campaign-owned party source. Callers that later resolve a session
|
||||||
|
// retain this context rather than independently reimplementing party loading.
|
||||||
|
func LoadPipelineCampaign(pipelinePath string, pipeline *PipelineConfig, campaignPath string, campaign *CampaignConfig) (LoadedPipelineCampaign, error) {
|
||||||
|
if pipeline == nil {
|
||||||
|
return LoadedPipelineCampaign{}, fmt.Errorf("pipeline config is required")
|
||||||
|
}
|
||||||
|
if campaign == nil {
|
||||||
|
return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required")
|
||||||
|
}
|
||||||
|
party, err := resolveCampaignParty(campaignPath, campaign)
|
||||||
|
if err != nil {
|
||||||
|
return LoadedPipelineCampaign{}, err
|
||||||
|
}
|
||||||
|
if party.Mode == PartyModeCanonical {
|
||||||
|
if err := validateCanonicalPartySelection(campaign, nil); err != nil {
|
||||||
|
return LoadedPipelineCampaign{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LoadedPipelineCampaign{
|
||||||
|
PipelinePath: pipelinePath,
|
||||||
|
Pipeline: pipeline,
|
||||||
|
CampaignPath: campaignPath,
|
||||||
|
Campaign: campaign,
|
||||||
|
Party: party,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSessionWithPipelineCampaignOptions loads one local session and combines
|
// LoadSessionWithPipelineCampaignOptions loads one local session and combines
|
||||||
@@ -203,6 +231,24 @@ func LoadSessionWithPipelineCampaignOptions(loaded LoadedPipelineCampaign, sessi
|
|||||||
// the resolved pipeline/campaign context for callers that need to locate or
|
// the resolved pipeline/campaign context for callers that need to locate or
|
||||||
// retrieve a session without rereading the root pipeline.
|
// retrieve a session without rereading the root pipeline.
|
||||||
func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||||
|
if loaded.Pipeline == nil {
|
||||||
|
return nil, fmt.Errorf("pipeline config is required")
|
||||||
|
}
|
||||||
|
if loaded.Campaign == nil {
|
||||||
|
return nil, fmt.Errorf("campaign config is required")
|
||||||
|
}
|
||||||
|
if loaded.Party.Mode == "" {
|
||||||
|
party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
loaded.Party = party
|
||||||
|
}
|
||||||
|
if loaded.Party.Mode == PartyModeCanonical {
|
||||||
|
if err := validateCanonicalPartySelection(loaded.Campaign, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Pipeline: loaded.Pipeline,
|
Pipeline: loaded.Pipeline,
|
||||||
Campaign: loaded.Campaign,
|
Campaign: loaded.Campaign,
|
||||||
@@ -211,6 +257,12 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
|
|||||||
CampaignPath: loaded.CampaignPath,
|
CampaignPath: loaded.CampaignPath,
|
||||||
SessionPath: sessionPath,
|
SessionPath: sessionPath,
|
||||||
SessionSource: sessionSource,
|
SessionSource: sessionSource,
|
||||||
|
Party: loaded.Party,
|
||||||
|
}
|
||||||
|
if cfg.Party.Mode == PartyModeCanonical && sessionCfg != nil {
|
||||||
|
if err := validateCanonicalPartySelection(loaded.Campaign, sessionCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if sessionCfg == nil {
|
if sessionCfg == nil {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -226,6 +278,22 @@ func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath st
|
|||||||
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
|
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
|
||||||
cfg.SessionSource.LocalPath = sessionPath
|
cfg.SessionSource.LocalPath = sessionPath
|
||||||
}
|
}
|
||||||
|
if cfg.Party.Mode == PartyModeCanonical {
|
||||||
|
stableInputs.PlayersFile = virtualPlayersInput()
|
||||||
|
cfg.Session.Inputs.PlayersFile = ""
|
||||||
|
} else {
|
||||||
|
party, err := resolvePartyInput(stableInputs.PartyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if party.Mode == PartyModeCanonical {
|
||||||
|
return nil, fmt.Errorf("session.inputs.party_file cannot select a canonical party; canonical parties are campaign-owned")
|
||||||
|
}
|
||||||
|
cfg.Party = party
|
||||||
|
if strings.TrimSpace(stableInputs.PlayersFile.Path) == "" {
|
||||||
|
return nil, fmt.Errorf("players_file is required with a legacy party")
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg.StableInputs = stableInputs
|
cfg.StableInputs = stableInputs
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
@@ -236,12 +304,11 @@ func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath stri
|
|||||||
if sessionCfg == nil {
|
if sessionCfg == nil {
|
||||||
return nil, fmt.Errorf("session config is required")
|
return nil, fmt.Errorf("session config is required")
|
||||||
}
|
}
|
||||||
return ResolveLoadedPipelineCampaign(LoadedPipelineCampaign{
|
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
|
||||||
PipelinePath: pipelinePath,
|
if err != nil {
|
||||||
Pipeline: pipelineCfg,
|
return nil, err
|
||||||
CampaignPath: campaignPath,
|
}
|
||||||
Campaign: campaignCfg,
|
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource)
|
||||||
}, sessionPath, sessionCfg, sessionSource)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {
|
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ inputs:
|
|||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte("legacy: party\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write party.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return pipelinePath, sessionPath
|
return pipelinePath, sessionPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ package config
|
|||||||
func classifyLegacyPartyDocument() *PartyDocument {
|
func classifyLegacyPartyDocument() *PartyDocument {
|
||||||
return &PartyDocument{Mode: PartyModeLegacy}
|
return &PartyDocument{Mode: PartyModeLegacy}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLegacyParty preserves the deliberately opaque legacy party mode.
|
||||||
|
// This small compatibility surface is intended for removal once legacy
|
||||||
|
// campaign inputs are no longer supported.
|
||||||
|
func resolveLegacyParty(resolved ResolvedParty) (ResolvedParty, error) {
|
||||||
|
resolved.Mode = PartyModeLegacy
|
||||||
|
resolved.Canonical = nil
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|||||||
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"))
|
rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing"))
|
||||||
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n")
|
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n")
|
||||||
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\n")
|
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\n")
|
||||||
campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\n")
|
campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n players_file: players.yml\n party_file: party.yml\n")
|
||||||
|
writePipelineSource(t, dir, "party.yml", "legacy: party\n")
|
||||||
sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n")
|
sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n")
|
||||||
selected := "testing"
|
selected := "testing"
|
||||||
cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected})
|
cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected})
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ func Validate(cfg *Config) error {
|
|||||||
if err := validateSession(cfg.Session); err != nil {
|
if err := validateSession(cfg.Session); err != nil {
|
||||||
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
||||||
}
|
}
|
||||||
|
if err := validateResolvedPartyInputs(cfg); err != nil {
|
||||||
|
return fmt.Errorf("campaign/session config invalid: %w", err)
|
||||||
|
}
|
||||||
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
|
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
|
||||||
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
||||||
}
|
}
|
||||||
@@ -66,9 +69,6 @@ func validateCampaign(cfg *CampaignConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||||
return fmt.Errorf("campaign.inputs.glossary_file is required")
|
return fmt.Errorf("campaign.inputs.glossary_file is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
|
|
||||||
return fmt.Errorf("campaign.inputs.players_file is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||||
return fmt.Errorf("campaign.inputs.party_file is required")
|
return fmt.Errorf("campaign.inputs.party_file is required")
|
||||||
}
|
}
|
||||||
@@ -794,12 +794,6 @@ func validateSession(cfg *SessionConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||||
return fmt.Errorf("session.inputs.glossary_file is required")
|
return fmt.Errorf("session.inputs.glossary_file is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
|
|
||||||
return fmt.Errorf("session.inputs.players_file is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
|
||||||
return fmt.Errorf("session.inputs.party_file is required")
|
|
||||||
}
|
|
||||||
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
|
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
|
||||||
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
|
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
|
||||||
}
|
}
|
||||||
@@ -825,6 +819,31 @@ func validateSession(cfg *SessionConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateResolvedPartyInputs(cfg *Config) error {
|
||||||
|
if cfg == nil || cfg.Session == nil {
|
||||||
|
return fmt.Errorf("session config is required")
|
||||||
|
}
|
||||||
|
if cfg.Party.Mode == PartyModeCanonical {
|
||||||
|
if cfg.Party.Canonical == nil {
|
||||||
|
return fmt.Errorf("canonical party data is required")
|
||||||
|
}
|
||||||
|
if cfg.Party.Source.Source != "campaign_config" {
|
||||||
|
return fmt.Errorf("canonical party must be sourced by campaign_config")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.StableInputs.PlayersFile.Source) != "derived_from_party" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) != "" {
|
||||||
|
return fmt.Errorf("canonical party requires derived players input")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Session.Inputs.PlayersFile) == "" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) == "" {
|
||||||
|
return fmt.Errorf("players input is required with a legacy party")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Session.Inputs.PartyFile) == "" || strings.TrimSpace(cfg.StableInputs.PartyFile.Path) == "" {
|
||||||
|
return fmt.Errorf("party input is required with a legacy party")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateSessionIdentifier(fieldName, value string, required bool) error {
|
func validateSessionIdentifier(fieldName, value string, required bool) error {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
if required {
|
if required {
|
||||||
|
|||||||
Reference in New Issue
Block a user