Reject unversioned party documents
This commit is contained in:
@@ -62,8 +62,7 @@ effective pipeline YAML, with defaults and concrete expansion included but
|
|||||||
composition and family declarations omitted. `config sources` prints a stable
|
composition and family declarations omitted. `config sources` prints a stable
|
||||||
source projection instead of effective values: root/import/profile/default
|
source projection instead of effective values: root/import/profile/default
|
||||||
ownership plus campaign/party and generated-family records. Canonical derived
|
ownership plus campaign/party and generated-family records. Canonical derived
|
||||||
players trace to the party; a legacy configured players file is explicitly
|
players trace to the party. The [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff)
|
||||||
marked as a legacy player source. The [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff)
|
|
||||||
owns command syntax and output conventions.
|
owns command syntax and output conventions.
|
||||||
|
|
||||||
`config diff` compares normalized field values rather than YAML text or source
|
`config diff` compares normalized field values rather than YAML text or source
|
||||||
@@ -83,8 +82,8 @@ selected overlay, and retain a production `default_profile`. Convert campaign
|
|||||||
rosters to [canonical party input](integrations/party.md), remove a separate
|
rosters to [canonical party input](integrations/party.md), remove a separate
|
||||||
`players_file`, then express character work as families. Inspect the result
|
`players_file`, then express character work as families. Inspect the result
|
||||||
with `config validate`, `config show`, and `config sources`; use `config diff`
|
with `config validate`, `config show`, and `config sources`; use `config diff`
|
||||||
to review profiles before running a session. Unversioned parties and their
|
to review profiles before running a session. Unversioned party documents are
|
||||||
`players_file` remain a clearly bounded legacy compatibility path.
|
rejected.
|
||||||
|
|
||||||
### Identity segments
|
### Identity segments
|
||||||
|
|
||||||
@@ -116,12 +115,10 @@ remote state with an unsafe legacy identity must be migrated before use.
|
|||||||
`party_file`) and the optional `spell_catalog_file` resolve from session
|
`party_file`) and the optional `spell_catalog_file` resolve from session
|
||||||
overrides when provided, otherwise from campaign defaults. An empty or
|
overrides when provided, otherwise from campaign defaults. An 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
|
- `party_file` is parsed when pipeline and campaign configuration are combined.
|
||||||
combined. A versioned [canonical party](integrations/party.md) is
|
It must be a versioned [canonical party](integrations/party.md), is
|
||||||
campaign-owned, derives the players input internally, and forbids both a
|
campaign-owned, derives the players input internally, and forbids both a
|
||||||
separate `players_file` and a session `party_file` override. An unversioned
|
separate `players_file` and a session `party_file` override.
|
||||||
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`).
|
||||||
@@ -213,7 +210,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -476,7 +472,8 @@ does not provide adapter-specific input passthrough fields.
|
|||||||
`pipeline.scriptorium.artifact_families` declares a shared artifact template
|
`pipeline.scriptorium.artifact_families` declares a shared artifact template
|
||||||
for every canonical campaign character. Configuration resolution expands each
|
for every canonical campaign character. Configuration resolution expands each
|
||||||
family into ordinary `pipeline.scriptorium.artifacts` entries before analyze
|
family into ordinary `pipeline.scriptorium.artifacts` entries before analyze
|
||||||
planning or Scriptorium invocation. A legacy party cannot be used for a family.
|
planning or Scriptorium invocation. Party parsing rejects unsupported document
|
||||||
|
schemas before family expansion.
|
||||||
|
|
||||||
For each `pipeline.scriptorium.artifact_families.<name>`:
|
For each `pipeline.scriptorium.artifact_families.<name>`:
|
||||||
|
|
||||||
@@ -524,7 +521,7 @@ 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 | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party |
|
| `inputs.players_file` | string | No | retained temporarily for configuration compatibility; forbidden with the required canonical party |
|
||||||
| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` |
|
| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` |
|
||||||
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
||||||
|
|
||||||
@@ -540,8 +537,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 | legacy-party override; forbidden for a canonical party |
|
| `inputs.players_file` | string | No | retained temporarily for configuration compatibility; forbidden with the required canonical party |
|
||||||
| `inputs.party_file` | string | No | legacy-party override; forbidden for a canonical campaign party |
|
| `inputs.party_file` | string | No | retained temporarily for configuration compatibility; forbidden for a canonical campaign party |
|
||||||
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Canonical Party Input
|
# Canonical Party Input
|
||||||
|
|
||||||
`party.yml` is a campaign-owned roster input. Narratio recognizes the
|
`party.yml` is a campaign-owned roster input. It must be a single, versioned
|
||||||
versioned `narratio.party.v1` document below when it resolves a pipeline,
|
`narratio.party.v1` document when Narratio resolves a pipeline, campaign, and
|
||||||
campaign, and session together.
|
session together.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schema_version: narratio.party.v1
|
schema_version: narratio.party.v1
|
||||||
@@ -34,19 +34,20 @@ non-empty, trimmed display strings without control characters. Character names
|
|||||||
and aliases must be unique across the full roster under Unicode-aware
|
and aliases must be unique across the full roster under Unicode-aware
|
||||||
case-insensitive comparison; player names may repeat.
|
case-insensitive comparison; player names may repeat.
|
||||||
|
|
||||||
The document has exactly one YAML document and accepts no unknown fields. A
|
The document has exactly one YAML document and accepts no unknown fields.
|
||||||
wrong or malformed `schema_version` is an error.
|
`schema_version` is required: a missing, wrong, or malformed value is an error.
|
||||||
|
|
||||||
## Legacy migration boundary
|
## Migration
|
||||||
|
|
||||||
An unversioned party input remains supported only as opaque legacy reference
|
Unversioned party inputs are rejected. Replace each legacy party document with
|
||||||
material while campaigns migrate. It requires a separate `players_file` and
|
the versioned structure above, move each roster entry under `characters`, and
|
||||||
retains the existing session override behavior. It cannot be mixed with a
|
remove separate player and session-party overrides from the selected campaign
|
||||||
canonical party: canonical campaigns must omit `players_file`, and sessions
|
and session configuration. The configuration-key removal itself is staged
|
||||||
must not override their party or players inputs.
|
separately; current configurations must already omit those competing values
|
||||||
|
when using a canonical party.
|
||||||
|
|
||||||
Use the canonical document for new campaigns. The configuration rules and
|
The configuration rules and source-relative path behavior are defined in the
|
||||||
source-relative path behavior are defined in the [Configuration Reference](../config.md).
|
[Configuration Reference](../config.md).
|
||||||
|
|
||||||
## Derived players document
|
## Derived players document
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ each contributor as root, import, profile, or centralized default. The
|
|||||||
projection replaces generated concrete member paths with paired family and
|
projection replaces generated concrete member paths with paired family and
|
||||||
canonical-party records, and does the same for generated publish rules.
|
canonical-party records, and does the same for generated publish rules.
|
||||||
Campaign records identify campaign-owned fields and party inputs; canonical
|
Campaign records identify campaign-owned fields and party inputs; canonical
|
||||||
derived players point to the party source, while legacy players retain a
|
derived players point to the party source. The application command only joins these sorted
|
||||||
dedicated legacy-player role. The application command only joins these sorted
|
|
||||||
records with selection metadata and never reparses configuration files.
|
records with selection metadata and never reparses configuration files.
|
||||||
|
|
||||||
`config diff` uses a paired profile loader that parses the root, imports, and
|
`config diff` uses a paired profile loader that parses the root, imports, and
|
||||||
@@ -90,28 +89,24 @@ Values are compact deterministic JSON representations for command output, not
|
|||||||
raw YAML fragments, ownership records, or secret material. A differing digest
|
raw YAML fragments, ownership records, or secret material. A differing digest
|
||||||
with no projected difference is treated as an internal consistency error.
|
with no projected difference is treated as an internal consistency error.
|
||||||
|
|
||||||
Campaign context construction also reads and classifies the campaign-owned
|
Campaign context construction reads the campaign-owned party source through
|
||||||
party source through `ParseParty`. A canonical party retains its raw bytes and
|
`ParseParty`. The required canonical party retains its raw bytes and normalized
|
||||||
normalized roster in runtime-only `ResolvedParty` provenance, while a legacy
|
roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates
|
||||||
party remains opaque. Canonical resolution creates a virtual
|
a virtual `derived_from_party` players input and rejects competing campaign or
|
||||||
`derived_from_party` players input and rejects competing campaign or session
|
session players files and session party overrides.
|
||||||
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 parsing a party source. It
|
||||||
When a top-level `schema_version` is present, it strictly validates the
|
requires a top-level `schema_version` and strictly validates the
|
||||||
`narratio.party.v1` contract into ordered character domain values. The
|
`narratio.party.v1` contract into ordered character domain values. The
|
||||||
canonical value retains a separate exact byte copy of its source so consumers
|
canonical value retains a separate exact byte copy of its source so consumers
|
||||||
can materialize the authored party document without reserializing it. Its
|
can materialize the authored party document without reserializing it. Its
|
||||||
`PlayersYAML` method deterministically derives the versioned players-only
|
`PlayersYAML` method deterministically derives the versioned players-only
|
||||||
projection.
|
projection.
|
||||||
|
|
||||||
An unversioned source is classified by the small legacy compatibility boundary
|
An unversioned source is rejected before resolution, family expansion, or
|
||||||
in `party_legacy.go`; it deliberately exposes no parsed roster information.
|
prepare-stage materialization.
|
||||||
That boundary exists solely to isolate removable compatibility behavior from
|
|
||||||
the canonical parser.
|
|
||||||
|
|
||||||
## Diagnostics And Runtime Metadata
|
## Diagnostics And Runtime Metadata
|
||||||
|
|
||||||
@@ -153,8 +148,7 @@ 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
|
`party_resolution_test.go` protects campaign-owned party loading, canonical
|
||||||
input restrictions, legacy overrides, source provenance, and virtual players
|
input restrictions, source provenance, and virtual players input selection.
|
||||||
input selection.
|
|
||||||
|
|
||||||
## Artifact Family Resolution
|
## Artifact Family Resolution
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ branches.
|
|||||||
|
|
||||||
## Stage 2 — Reject Unversioned Party Documents
|
## Stage 2 — Reject Unversioned Party Documents
|
||||||
|
|
||||||
**Status: Pending**
|
**Status: Completed**
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,27 @@ Safe fix:
|
|||||||
|
|
||||||
Relevant reference: [Configuration](./config.md).
|
Relevant reference: [Configuration](./config.md).
|
||||||
|
|
||||||
|
## Party schema version rejected
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- configuration loading reports that `party.schema_version` is required or
|
||||||
|
unsupported.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- the campaign `party.yml` is an unversioned legacy document, or names a
|
||||||
|
schema other than `narratio.party.v1`.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- replace the file with one exact, single-document
|
||||||
|
[`narratio.party.v1` party](./integrations/party.md); do not add a version
|
||||||
|
marker to an otherwise incompatible legacy structure. Remove competing
|
||||||
|
player and session-party settings while migrating.
|
||||||
|
|
||||||
|
Relevant reference: [Canonical Party Input](./integrations/party.md).
|
||||||
|
|
||||||
## Unexpected imported or profile value
|
## Unexpected imported or profile value
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
|
|||||||
@@ -227,8 +227,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
@@ -292,8 +290,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
@@ -389,14 +385,13 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`)
|
`)
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "players.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "players.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "party.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -522,7 +517,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -542,8 +536,7 @@ inputs:
|
|||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "players.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), "[]\n")
|
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
||||||
|
|
||||||
return pipelinePath, campaignPath, sessionPath
|
return pipelinePath, campaignPath, sessionPath
|
||||||
@@ -568,13 +561,12 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
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")
|
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
||||||
return campaignPath
|
return campaignPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -400,12 +400,12 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) {
|
|||||||
"imports: none",
|
"imports: none",
|
||||||
"profile: name=testing selection=cli overlay=",
|
"profile: name=testing selection=cli overlay=",
|
||||||
"campaign: id=sample-campaign source=",
|
"campaign: id=sample-campaign source=",
|
||||||
"party: mode=legacy source=",
|
"party: mode=canonical source=",
|
||||||
"digest: ",
|
"digest: ",
|
||||||
"path\trole\tsource\n",
|
"path\trole\tsource\n",
|
||||||
"workspace.root\troot\t",
|
"workspace.root\troot\t",
|
||||||
"whisperx.language\tprofile\t",
|
"whisperx.language\tprofile\t",
|
||||||
"campaign.inputs.players_file\tlegacy_player\t",
|
"derived.players\tparty\t",
|
||||||
"trim.enabled\tdefault\tdefault",
|
"trim.enabled\tdefault\tdefault",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) {
|
func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) {
|
||||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||||
originalPaths := configureLifecycleReferences(t, cfg)
|
configureLifecycleReferences(t, cfg)
|
||||||
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||||
t.Fatalf("initial executeStages() error = %v", err)
|
t.Fatalf("initial executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -222,9 +222,18 @@ func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDo
|
|||||||
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil {
|
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil {
|
||||||
t.Fatalf("Save(downstream success) error = %v", err)
|
t.Fatalf("Save(downstream success) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(originalPaths[artifactpolicy.SourceInputParty], []byte("changed party bytes\n"), 0o644); err != nil {
|
partyDocument, err := config.ParseParty([]byte(`schema_version: narratio.party.v1
|
||||||
t.Fatalf("WriteFile(party source) error = %v", err)
|
characters:
|
||||||
|
changed:
|
||||||
|
player: {name: Eric}
|
||||||
|
character:
|
||||||
|
name: Changed
|
||||||
|
classes: [{name: wizard}]
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseParty(changed party) error = %v", err)
|
||||||
}
|
}
|
||||||
|
cfg.Party.Canonical = partyDocument.Canonical
|
||||||
|
|
||||||
prepared := loadLifecycleManifest(t, cfg)
|
prepared := loadLifecycleManifest(t, cfg)
|
||||||
prepare, err := stage.Select("prepare")
|
prepare, err := stage.Select("prepare")
|
||||||
@@ -266,17 +275,22 @@ func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.T) {
|
func TestExtractLifecycleCanonicalPartyBytesReachCanonicalReference(t *testing.T) {
|
||||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||||
configureLifecycleReferences(t, cfg)
|
configureLifecycleReferences(t, cfg)
|
||||||
overridePath := filepath.Join(filepath.Dir(cfg.SessionPath), "session-party.yml")
|
partyBytes := []byte(`schema_version: narratio.party.v1
|
||||||
if err := os.WriteFile(overridePath, []byte("session override party\n"), 0o644); err != nil {
|
characters:
|
||||||
t.Fatalf("WriteFile(session override) error = %v", err)
|
session:
|
||||||
|
player: {name: Eric}
|
||||||
|
character:
|
||||||
|
name: Session
|
||||||
|
classes: [{name: wizard}]
|
||||||
|
`)
|
||||||
|
partyDocument, err := config.ParseParty(partyBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseParty(canonical party) error = %v", err)
|
||||||
}
|
}
|
||||||
cfg.StableInputs.PartyFile = config.ResolvedInputFile{
|
cfg.Party.Canonical = partyDocument.Canonical
|
||||||
Path: "./session-party.yml", ConfigPath: cfg.SessionPath, Source: "session_config",
|
|
||||||
}
|
|
||||||
cfg.Session.Inputs.PartyFile = "./session-party.yml"
|
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -294,8 +308,8 @@ func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReadFile(prepared party) error = %v", err)
|
t.Fatalf("ReadFile(prepared party) error = %v", err)
|
||||||
}
|
}
|
||||||
if string(contents) != "session override party\n" || partyPath == overridePath {
|
if string(contents) != string(partyBytes) || partyPath == cfg.Party.Source.Path {
|
||||||
t.Fatalf("prepared party path=%q contents=%q override=%q", partyPath, contents, overridePath)
|
t.Fatalf("prepared party path=%q contents=%q source=%q", partyPath, contents, cfg.Party.Source.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`), 0o644); err != nil {
|
`), 0o644); err != nil {
|
||||||
t.Fatalf("write explicit campaign: %v", err)
|
t.Fatalf("write explicit campaign: %v", err)
|
||||||
@@ -122,7 +121,7 @@ inputs:
|
|||||||
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(explicitDir, "players.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(explicitDir, "players.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(explicitDir, "party.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(explicitDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
||||||
|
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
var storeInitCalls int
|
var storeInitCalls int
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
|
|||||||
}
|
}
|
||||||
out := make([]stableInputCheck, 0, len(items))
|
out := make([]stableInputCheck, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
if item.name == "players" && item.in.Source == "derived_from_party" {
|
||||||
|
out = append(out, stableInputCheck{Name: item.name, Path: "derived from canonical party"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
if item.optional && strings.TrimSpace(item.in.Path) == "" {
|
if item.optional && strings.TrimSpace(item.in.Path) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(campaignPath, []byte(campaign), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaign), 0o644); err != nil {
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
|
|||||||
@@ -436,8 +436,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline config: %v", err)
|
t.Fatalf("write pipeline config: %v", err)
|
||||||
@@ -448,8 +446,7 @@ inputs:
|
|||||||
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
|
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "players.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "[]\n")
|
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
||||||
|
|
||||||
return pipelinePath, campaignPath, sessionPath
|
return pipelinePath, campaignPath, sessionPath
|
||||||
|
|||||||
@@ -1316,14 +1316,17 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||||
|
|
||||||
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||||
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
|
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n")
|
||||||
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
|
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
|
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio")
|
mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio")
|
||||||
|
partyDocument, err := config.ParseParty([]byte("schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse canonical party: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
|
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
|
||||||
@@ -1331,6 +1334,15 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
PipelinePath: pipelinePath,
|
PipelinePath: pipelinePath,
|
||||||
CampaignPath: campaignPath,
|
CampaignPath: campaignPath,
|
||||||
SessionPath: sessionPath,
|
SessionPath: sessionPath,
|
||||||
|
Party: config.ResolvedParty{
|
||||||
|
Mode: config.PartyModeCanonical,
|
||||||
|
Canonical: partyDocument.Canonical,
|
||||||
|
Source: config.PartySource{
|
||||||
|
Path: filepath.Join(cfgDir, "party.yml"),
|
||||||
|
ConfigPath: campaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
},
|
||||||
|
},
|
||||||
StableInputs: config.ResolvedStableInputs{
|
StableInputs: config.ResolvedStableInputs{
|
||||||
SpeakersFile: config.ResolvedInputFile{
|
SpeakersFile: config.ResolvedInputFile{
|
||||||
Path: "./speakers.yml",
|
Path: "./speakers.yml",
|
||||||
@@ -1347,11 +1359,7 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
ConfigPath: campaignPath,
|
ConfigPath: campaignPath,
|
||||||
Source: "campaign_config",
|
Source: "campaign_config",
|
||||||
},
|
},
|
||||||
PlayersFile: config.ResolvedInputFile{
|
PlayersFile: config.ResolvedInputFile{Source: "derived_from_party"},
|
||||||
Path: "./players.yml",
|
|
||||||
ConfigPath: campaignPath,
|
|
||||||
Source: "campaign_config",
|
|
||||||
},
|
|
||||||
PartyFile: config.ResolvedInputFile{
|
PartyFile: config.ResolvedInputFile{
|
||||||
Path: "./party.yml",
|
Path: "./party.yml",
|
||||||
ConfigPath: campaignPath,
|
ConfigPath: campaignPath,
|
||||||
@@ -1366,8 +1374,6 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
SpeakersFile: "./speakers.yml",
|
SpeakersFile: "./speakers.yml",
|
||||||
AutocorrectFile: "./autocorrect.yml",
|
AutocorrectFile: "./autocorrect.yml",
|
||||||
GlossaryFile: "./glossary.yml",
|
GlossaryFile: "./glossary.yml",
|
||||||
PlayersFile: "./players.yml",
|
|
||||||
PartyFile: "./party.yml",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1390,7 +1396,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
sessionYAML := `session_id: 2026-05-03
|
sessionYAML := `session_id: 2026-05-03
|
||||||
@@ -1400,13 +1405,11 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
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")
|
mustWriteFile(t, filepath.Join(dir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
cfg, err := config.Load(pipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
|
||||||
t.Fatalf("write session template: %v", err)
|
t.Fatalf("write session template: %v", err)
|
||||||
@@ -77,8 +75,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func TestArtifactFamiliesRejectInvalidExpansionDeclarations(t *testing.T) {
|
|||||||
families string
|
families string
|
||||||
wantError string
|
wantError string
|
||||||
}{
|
}{
|
||||||
{name: "legacy party", party: "legacy: party\n", families: validFamilyYAML, wantError: "requires a canonical campaign party"},
|
{name: "unversioned party", party: "characters: {}\n", families: validFamilyYAML, wantError: "schema_version is required"},
|
||||||
{name: "invalid iteration", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "party.characters", "party.players", 1), wantError: ".for_each must be"},
|
{name: "invalid iteration", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "party.characters", "party.players", 1), wantError: ".for_each must be"},
|
||||||
{name: "invalid token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "{character_id}", "{character_name}", 1), wantError: "must contain exactly one"},
|
{name: "invalid token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "{character_id}", "{character_name}", 1), wantError: "must contain exactly one"},
|
||||||
{name: "repeated token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "meta.md", "{character_id}.md", 1), wantError: "must contain exactly one"},
|
{name: "repeated token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "meta.md", "{character_id}.md", 1), wantError: "must contain exactly one"},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ notification:
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
|
func TestCampaignStrictDecodeAcceptsCampaignID(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 players_file: ./players.yml\n party_file: ./party.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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
"campaign: 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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\nunknown: true\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",
|
"campaign_id: sample-campaign\nunknown: true\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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\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",
|
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n party_file: ./campaign-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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,14 +114,14 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
|||||||
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./campaign-players.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "", "", "derived_from_party")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n party_file: ./campaign-party.yml\n",
|
||||||
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n players_file: ./session-players.yml\n party_file: ./session-party.yml\n",
|
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
@@ -134,8 +134,8 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
|||||||
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
|
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./session-players.yml", sessionPath, "session_config")
|
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "", "", "derived_from_party")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
|
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
|
func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
|
||||||
@@ -162,7 +162,7 @@ func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
|
|||||||
sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n"
|
sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n"
|
||||||
}
|
}
|
||||||
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 players_file: ./players.yml\n party_file: ./party.yml\n"+campaignSpell,
|
"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"+campaignSpell,
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell,
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(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 players_file: ./players.yml\n party_file: ./party.yml\n"+tt.campaignLine,
|
"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"+tt.campaignLine,
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine,
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine,
|
||||||
)
|
)
|
||||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
@@ -206,24 +206,20 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLegacyCampaignRequiresPlayersInput(t *testing.T) {
|
func TestCampaignWithCanonicalPartyDoesNotRequirePlayersInput(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 party_file: ./party.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",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
if _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}); err != nil {
|
||||||
if err == nil {
|
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||||
t.Fatal("expected load error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "players_file is required with a legacy party") {
|
|
||||||
t.Fatalf("error = %q, want players_file required", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCampaignSessionMismatchFails(t *testing.T) {
|
func TestCampaignSessionMismatchFails(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 players_file: ./players.yml\n party_file: ./party.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\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,7 +234,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadMissingCampaignFileFails(t *testing.T) {
|
func TestLoadMissingCampaignFileFails(t *testing.T) {
|
||||||
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
"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",
|
||||||
)
|
)
|
||||||
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
|
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
|
||||||
@@ -271,7 +267,7 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string
|
|||||||
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"} {
|
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 {
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(canonicalPartyFixture), 0o644); err != nil {
|
||||||
t.Fatalf("write %s: %v", name, err)
|
t.Fatalf("write %s: %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,7 +327,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||||
@@ -336,7 +335,7 @@ 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 {
|
if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte(canonicalPartyFixture), 0o644); err != nil {
|
||||||
t.Fatalf("write party.yml: %v", err)
|
t.Fatalf("write party.yml: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,17 +83,12 @@ type PartyClass struct {
|
|||||||
Level *int
|
Level *int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseParty classifies and, when selected by schema_version, strictly parses
|
// ParseParty strictly parses one supported party document.
|
||||||
// a party source. An unversioned source takes the deliberately opaque legacy
|
|
||||||
// compatibility path; see party_legacy.go.
|
|
||||||
func ParseParty(data []byte) (*PartyDocument, error) {
|
func ParseParty(data []byte) (*PartyDocument, error) {
|
||||||
root, decoder, err := parsePartyFirstDocument(data)
|
root, decoder, err := parsePartyFirstDocument(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !partyDocumentSelectsCanonical(root) {
|
|
||||||
return classifyLegacyPartyDocument(), nil
|
|
||||||
}
|
|
||||||
if err := requireNoTrailingPartyDocuments(decoder); err != nil {
|
if err := requireNoTrailingPartyDocuments(decoder); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -119,25 +114,12 @@ func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) {
|
|||||||
return document.Content[0], decoder, nil
|
return document.Content[0], decoder, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func partyDocumentSelectsCanonical(root *yaml.Node) bool {
|
|
||||||
if root == nil || root.Kind != yaml.MappingNode {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for index := 0; index+1 < len(root.Content); index += 2 {
|
|
||||||
key := root.Content[index]
|
|
||||||
if key.Kind == yaml.ScalarNode && key.Tag == "!!str" && key.Value == "schema_version" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error {
|
func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error {
|
||||||
var trailing yaml.Node
|
var trailing yaml.Node
|
||||||
if err := decoder.Decode(&trailing); err == nil {
|
if err := decoder.Decode(&trailing); err == nil {
|
||||||
return fmt.Errorf("canonical party must contain exactly one YAML document")
|
return fmt.Errorf("party must contain exactly one YAML document")
|
||||||
} else if err != io.EOF {
|
} else if err != io.EOF {
|
||||||
return fmt.Errorf("decode trailing canonical party YAML: %w", err)
|
return fmt.Errorf("decode trailing party YAML: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -147,17 +129,21 @@ func parseCanonicalParty(root *yaml.Node, raw []byte) (*CanonicalParty, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version, ok := fields["schema_version"]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("party.schema_version is required; supported schema_version is %q", PartySchemaVersion)
|
||||||
|
}
|
||||||
|
schemaVersion, err := partyRequiredString(map[string]*yaml.Node{"schema_version": version}, "schema_version", "party")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("party.schema_version must be a non-empty string; supported schema_version is %q", PartySchemaVersion)
|
||||||
|
}
|
||||||
|
if schemaVersion != PartySchemaVersion {
|
||||||
|
return nil, fmt.Errorf("party.schema_version %q is unsupported; supported schema_version is %q", schemaVersion, PartySchemaVersion)
|
||||||
|
}
|
||||||
if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil {
|
if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
version, err := partyRequiredString(fields, "schema_version", "party")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if version != PartySchemaVersion {
|
|
||||||
return nil, fmt.Errorf("party.schema_version %q is unsupported", version)
|
|
||||||
}
|
|
||||||
charactersNode, ok := fields["characters"]
|
charactersNode, ok := fields["characters"]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("party.characters is required")
|
return nil, fmt.Errorf("party.characters is required")
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
// classifyLegacyPartyDocument is the bounded compatibility path for an
|
|
||||||
// unversioned party source. It intentionally retains no parsed roster data and
|
|
||||||
// must be removed once legacy campaign inputs are no longer supported.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -32,19 +32,15 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err)
|
return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err)
|
||||||
}
|
}
|
||||||
resolved := ResolvedParty{
|
return ResolvedParty{
|
||||||
Mode: document.Mode,
|
Mode: PartyModeCanonical,
|
||||||
Source: PartySource{
|
Source: PartySource{
|
||||||
Path: path,
|
Path: path,
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
Source: input.Source,
|
Source: input.Source,
|
||||||
},
|
},
|
||||||
Canonical: document.Canonical,
|
Canonical: document.Canonical,
|
||||||
}
|
}, nil
|
||||||
if document.IsCanonical() {
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
return resolveLegacyParty(resolved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func campaignPartyInput(campaignPath string, campaign *CampaignConfig) (ResolvedInputFile, error) {
|
func campaignPartyInput(campaignPath string, campaign *CampaignConfig) (ResolvedInputFile, error) {
|
||||||
|
|||||||
@@ -85,44 +85,7 @@ func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLegacyPartyPreservesCampaignAndSessionInputOverrides(t *testing.T) {
|
func TestUnversionedPartyIsRejectedBeforeResolution(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()
|
dir := t.TempDir()
|
||||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n")
|
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n")
|
||||||
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign
|
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign
|
||||||
@@ -135,49 +98,10 @@ inputs:
|
|||||||
campaign: campaign
|
campaign: campaign
|
||||||
inputs:
|
inputs:
|
||||||
audio_dir: audio
|
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) {
|
if _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}); err == nil || !strings.Contains(err.Error(), "schema_version is required") {
|
||||||
tests := []struct {
|
t.Fatalf("LoadWithSessionOptions() error = %v, want schema rejection before resolution", err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,13 +210,20 @@ characters:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePartyClassifiesUnversionedDocumentAsLegacy(t *testing.T) {
|
func TestParsePartyRejectsMissingOrUnknownSchemaVersion(t *testing.T) {
|
||||||
document, err := ParseParty([]byte("players:\n - not canonical\n---\nlegacy: remains opaque\n"))
|
for _, test := range []struct {
|
||||||
if err != nil {
|
name string
|
||||||
t.Fatalf("ParseParty() error = %v", err)
|
yaml string
|
||||||
}
|
want string
|
||||||
if document.Mode != PartyModeLegacy || document.Canonical != nil || document.IsCanonical() {
|
}{
|
||||||
t.Fatalf("document = %#v, want opaque legacy classification", document)
|
{name: "missing", yaml: "characters: {}\n", want: "schema_version is required"},
|
||||||
|
{name: "unknown", yaml: "schema_version: narratio.party.v2\ncharacters: {}\n", want: "supported schema_version is \"narratio.party.v1\""},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := ParseParty([]byte(test.yaml)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ParseParty() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +283,4 @@ func TestCanonicalPartyPlayersYAMLRequiresCanonicalParty(t *testing.T) {
|
|||||||
if _, err := party.PlayersYAML(); err == nil {
|
if _, err := party.PlayersYAML(); err == nil {
|
||||||
t.Fatal("PlayersYAML() error = nil, want canonical party requirement")
|
t.Fatal("PlayersYAML() error = nil, want canonical party requirement")
|
||||||
}
|
}
|
||||||
legacy, err := ParseParty([]byte("legacy: party\n"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseParty() legacy error = %v", err)
|
|
||||||
}
|
|
||||||
if _, err := legacy.PlayersYAML(); err == nil {
|
|
||||||
t.Fatal("legacy PlayersYAML() error = nil, want canonical party requirement")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,8 +429,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\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")
|
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 party_file: party.yml\n")
|
||||||
writePipelineSource(t, dir, "party.yml", "legacy: party\n")
|
writePipelineSource(t, dir, "party.yml", canonicalPartyFixture)
|
||||||
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})
|
||||||
|
|||||||
@@ -627,6 +627,4 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
@@ -42,7 +41,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
@@ -69,7 +67,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
@@ -99,7 +96,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
@@ -150,7 +146,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
@@ -176,7 +171,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
|
|||||||
@@ -699,8 +699,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -713,8 +711,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`,
|
`,
|
||||||
wantErr: "session.inputs.audio_s3.prefix must be a relative path",
|
wantErr: "session.inputs.audio_s3.prefix must be a relative path",
|
||||||
},
|
},
|
||||||
@@ -728,8 +724,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`,
|
`,
|
||||||
wantErr: "session.inputs.audio_s3.prefix must not contain path traversal",
|
wantErr: "session.inputs.audio_s3.prefix must not contain path traversal",
|
||||||
},
|
},
|
||||||
@@ -744,8 +738,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`,
|
`,
|
||||||
wantErr: "mutually exclusive",
|
wantErr: "mutually exclusive",
|
||||||
},
|
},
|
||||||
@@ -793,8 +785,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
|
||||||
`
|
`
|
||||||
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
|
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
|
||||||
cfg, err := Load(pipelinePath, sessionPath)
|
cfg, err := Load(pipelinePath, sessionPath)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) {
|
|||||||
}
|
}
|
||||||
partyMode := cfg.Party.Mode
|
partyMode := cfg.Party.Mode
|
||||||
if partyMode == "" {
|
if partyMode == "" {
|
||||||
partyMode = config.PartyModeLegacy
|
return prepareSemanticConfig{}, fmt.Errorf("prepare semantic configuration requires a resolved canonical party")
|
||||||
}
|
}
|
||||||
payload := prepareSemanticConfig{
|
payload := prepareSemanticConfig{
|
||||||
CampaignID: strings.TrimSpace(cfg.Session.Campaign),
|
CampaignID: strings.TrimSpace(cfg.Session.Campaign),
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ func TestPrepareSemanticConfigSensitivity(t *testing.T) {
|
|||||||
{name: "stable input owner", mutate: func(env *Env) {
|
{name: "stable input owner", mutate: func(env *Env) {
|
||||||
env.Config.StableInputs.PartyFile.Source = "campaign_config"
|
env.Config.StableInputs.PartyFile.Source = "campaign_config"
|
||||||
}},
|
}},
|
||||||
{name: "party mode", mutate: func(env *Env) {
|
|
||||||
env.Config.Party.Mode = config.PartyModeCanonical
|
|
||||||
}},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assertSemanticFingerprintUnchanged(t, prepareStage{}, initialSemanticEnv(), []struct {
|
assertSemanticFingerprintUnchanged(t, prepareStage{}, initialSemanticEnv(), []struct {
|
||||||
@@ -129,6 +126,7 @@ func initialSemanticEnv() *Env {
|
|||||||
StableInputs: config.ResolvedStableInputs{
|
StableInputs: config.ResolvedStableInputs{
|
||||||
PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"},
|
PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"},
|
||||||
},
|
},
|
||||||
|
Party: config.ResolvedParty{Mode: config.PartyModeCanonical},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user