diff --git a/docs/integrations/notarius.md b/docs/integrations/notarius.md index b3abbff..d7b28eb 100644 --- a/docs/integrations/notarius.md +++ b/docs/integrations/notarius.md @@ -41,6 +41,12 @@ selector, as direct argument-vector entries without shell interpretation. A CLI binding takes precedence over a matching external path in Notarius configuration. Narratio never emits `--without-reference`. +For canonical party configuration, the `party` binding is the unchanged, +validated authored roster and the `players` binding is its generated +projection. Both retain their established `narratio.input.party` and +`narratio.input.players` source IDs, and both are resolved from the prepared +manifest rather than from campaign configuration at extraction time. + The maintained D&D boundary binds only the four campaign-owned external slots: ```text diff --git a/docs/integrations/party.md b/docs/integrations/party.md index 827e482..3758e6b 100644 --- a/docs/integrations/party.md +++ b/docs/integrations/party.md @@ -47,3 +47,27 @@ 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). + +## Derived players document + +During `prepare`, Narratio copies the canonical party source bytes unchanged +to `inputs/party.yml` and writes this deterministic players-only projection to +`inputs/players.yml`: + +```yaml +schema_version: narratio.players.v1 +players: + - name: Eric + character: + id: arannis + name: Arannis + alias: + - Ari + - The Grey Owl +``` + +There is one entry per character, sorted by stable character ID. Repeated +player names remain separate entries. The optional `alias` list retains its +declared order and is omitted when empty. The projection carries no class +data. Its prepared manifest record is marked `derived_from_party`; it is not a +separate user-provided `players_file`. diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index b891046..830f89e 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -39,7 +39,10 @@ ID. Prepared stable source IDs are `narratio.input.players`, `narratio.input.party`, `narratio.input.glossary`, and `narratio.input.spell_catalog`. Artifact policy owns their canonical manifest -kind and prepared filename vocabulary. +kind and prepared filename vocabulary. Canonical party mode preserves the +party source bytes in the party record and supplies the players record from the +deterministic `derived_from_party` projection; both remain ordinary prepared +source IDs for consumers. ## Runtime Catalog diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index fc38ba6..bd19428 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -25,6 +25,12 @@ portable opaque segments. Unsafe legacy identities are rejected with migration guidance rather than being normalized into a different workspace or remote namespace. +Prepare records independent `party` and `players` input checksums. In canonical +party mode, the party record retains its campaign source identity while the +players record uses `derived_from_party`; raw roster content is never embedded +in manifest metadata. Both records remain the durable authority for consumers +of their prepared input source IDs. + The model admits these stage states: - `pending` diff --git a/docs/internal/stage-prepare.md b/docs/internal/stage-prepare.md index f39e045..4ec4c78 100644 --- a/docs/internal/stage-prepare.md +++ b/docs/internal/stage-prepare.md @@ -39,6 +39,9 @@ Materialize canonical current-session inputs before processing stages. - materializes a configured spell catalog with checksum and provenance, or safely removes an obsolete canonical spell catalog and its manifest record when the effective input is omitted. +- in canonical party mode, copies the validated raw party bytes unchanged and + deterministically generates the prepared players projection; legacy mode + continues to copy its opaque party and explicit players sources. - scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements. - clears managed `previous/` state on every invocation, then, when requirements exist: - resolves the pointer-selected previous source through the shared resolver; @@ -62,7 +65,8 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches. Prepare records a versioned semantic-configuration fingerprint for the resolved campaign/session selection, local-versus-S3 audio mode and canonical audio names, stable-input ownership/presence, previous-session identity, and -the effective previous-artifact requirement set. A change reruns prepare and +the party mode plus canonical players projection version, and the effective +previous-artifact requirement set. A change reruns prepare and stales its fixed descendants. Existing successful records without this evidence rerun once when selected. diff --git a/docs/operations.md b/docs/operations.md index 21f9abf..b5b9647 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -237,6 +237,11 @@ prepared inputs. Their canonical locations are `inputs/party.yml`, `inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies under `runs//extract/references/` so a concurrent refresh of canonical prepared files cannot change the bytes consumed by an in-flight invocation. +For a canonical party, preparation retains the validated authored party bytes +at `inputs/party.yml` and generates `inputs/players.yml` from that roster. +The manifest records their checksums separately, with the players input marked +as derived from the party; refresh preparation after changing the roster rather +than editing either prepared file. Inspect the effective stable-input inventory and prepared-file readiness with: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2393070..3f50275 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -705,7 +705,7 @@ enforce one unambiguous canonical versus legacy input mode. ## Stage 12 — Canonical Party And Derived Players Preparation -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index 5d82d32..aa9d868 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -1123,6 +1123,7 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) { glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml") spellCatalogPath := filepath.Join(paths.InputsDir, "spell_catalog.json") recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n") + m.Inputs[len(m.Inputs)-1].Source = "derived_from_party" recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputParty, partyPath, "- Arannis\n") recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputGlossary, glossaryPath, "- term: Ten Towns\n") recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputSpellCatalog, spellCatalogPath, "{\"spells\":[]}\n") diff --git a/internal/stage/extract_test.go b/internal/stage/extract_test.go index 2561c22..b7c1742 100644 --- a/internal/stage/extract_test.go +++ b/internal/stage/extract_test.go @@ -107,6 +107,9 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) selector := strings.TrimSpace(reference.selector) preparedPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents) wantSources[selector] = reference.sourceID + if reference.sourceID == artifactpolicy.SourceInputPlayers { + m.Inputs[len(m.Inputs)-1].Source = "derived_from_party" + } } result, err := (extractStage{}).Run(context.Background(), env, m) diff --git a/internal/stage/prepare.go b/internal/stage/prepare.go index ccce860..0ac7e41 100644 --- a/internal/stage/prepare.go +++ b/internal/stage/prepare.go @@ -63,8 +63,6 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc) autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc) glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc) - playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc) - partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc) spellCatalogInput := stableInputSource(env.Config.StableInputs.SpellCatalogFile, env.Config.Session.Inputs.SpellCatalogFile, sessionSrc) speakersSrc, err := resolveConfigRelativePath(speakersInput) @@ -79,14 +77,6 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S if err != nil { return nil, fmt.Errorf("prepare: glossary path: %w", err) } - playersSrc, err := resolveConfigRelativePath(playersInput) - if err != nil { - return nil, fmt.Errorf("prepare: players path: %w", err) - } - partySrc, err := resolveConfigRelativePath(partyInput) - if err != nil { - return nil, fmt.Errorf("prepare: party path: %w", err) - } spellCatalogConfigured := strings.TrimSpace(spellCatalogInput.Path) != "" spellCatalogSrc := "" if spellCatalogConfigured { @@ -106,8 +96,6 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S {path: speakersSrc, name: "speakers.yml"}, {path: autocorrectSrc, name: "autocorrect.yml"}, {path: glossarySrc, name: "glossary.yml"}, - {path: playersSrc, name: "players.yml"}, - {path: partySrc, name: "party.yml"}, } { if err := requireFile(required.path, required.name); err != nil { return nil, fmt.Errorf("prepare: %w", err) @@ -182,11 +170,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S sourceID string input config.ResolvedInputFile src string - }{ - {sourceID: artifactpolicy.SourceInputGlossary, input: glossaryInput, src: glossarySrc}, - {sourceID: artifactpolicy.SourceInputPlayers, input: playersInput, src: playersSrc}, - {sourceID: artifactpolicy.SourceInputParty, input: partyInput, src: partySrc}, - } { + }{{sourceID: artifactpolicy.SourceInputGlossary, input: glossaryInput, src: glossarySrc}} { descriptor, ok := artifactpolicy.DescribePreparedInputSource(prepared.sourceID) if !ok { return nil, fmt.Errorf("prepare: prepared-input descriptor %q is unavailable", prepared.sourceID) @@ -219,6 +203,11 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S } registerConfigInput(cfgFile.kind, cfgFile.dst, checksum, cfgFile.source) } + partyInputs, err := materializePreparedPartyInputs(env.ArtifactStore, env.Config, paths.InputsDir) + if err != nil { + return nil, fmt.Errorf("prepare: %w", err) + } + inputs = append(inputs, partyInputs...) var audioCacheStats s3AudioMaterializationStats if useS3Audio { diff --git a/internal/stage/prepare_legacy.go b/internal/stage/prepare_legacy.go new file mode 100644 index 0000000..ec96509 --- /dev/null +++ b/internal/stage/prepare_legacy.go @@ -0,0 +1,57 @@ +package stage + +import ( + "fmt" + "path/filepath" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +// materializeLegacyPartyInputs preserves the removable compatibility path for +// opaque party and explicit players files. Remove it when legacy roster inputs +// are no longer supported. +func materializeLegacyPartyInputs( + store artifacts.Store, + cfg *config.Config, + inputsDir string, + party, players artifactpolicy.PreparedInputSourceDescriptor, +) ([]manifest.InputRecord, error) { + playersInput := stableInputSource(cfg.StableInputs.PlayersFile, cfg.Session.Inputs.PlayersFile, cfg.SessionPath) + partyInput := stableInputSource(cfg.StableInputs.PartyFile, cfg.Session.Inputs.PartyFile, cfg.SessionPath) + playersSource, err := resolveConfigRelativePath(playersInput) + if err != nil { + return nil, fmt.Errorf("players path: %w", err) + } + partySource, err := resolveConfigRelativePath(partyInput) + if err != nil { + return nil, fmt.Errorf("party path: %w", err) + } + for _, required := range []struct { + path string + name string + }{ + {path: playersSource, name: "players.yml"}, + {path: partySource, name: "party.yml"}, + } { + if err := requireFile(required.path, required.name); err != nil { + return nil, err + } + } + partyPath := filepath.Join(inputsDir, party.Filename) + partyChecksum, err := copyFileIfChanged(store, partySource, partyPath) + if err != nil { + return nil, fmt.Errorf("materialize party: %w", err) + } + playersPath := filepath.Join(inputsDir, players.Filename) + playersChecksum, err := copyFileIfChanged(store, playersSource, playersPath) + if err != nil { + return nil, fmt.Errorf("materialize players: %w", err) + } + return []manifest.InputRecord{ + {Kind: party.ManifestKind, Path: partyPath, Checksum: partyChecksum, Source: partyInput.Source}, + {Kind: players.ManifestKind, Path: playersPath, Checksum: playersChecksum, Source: playersInput.Source}, + }, nil +} diff --git a/internal/stage/prepare_party.go b/internal/stage/prepare_party.go new file mode 100644 index 0000000..18928d1 --- /dev/null +++ b/internal/stage/prepare_party.go @@ -0,0 +1,66 @@ +package stage + +import ( + "fmt" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func materializePreparedPartyInputs(store artifacts.Store, cfg *config.Config, inputsDir string) ([]manifest.InputRecord, error) { + if cfg == nil { + return nil, fmt.Errorf("resolved config is required") + } + players, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputPlayers) + if !ok { + return nil, fmt.Errorf("prepared-input descriptor %q is unavailable", artifactpolicy.SourceInputPlayers) + } + party, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty) + if !ok { + return nil, fmt.Errorf("prepared-input descriptor %q is unavailable", artifactpolicy.SourceInputParty) + } + if cfg.Party.Mode == config.PartyModeCanonical { + return materializeCanonicalPartyInputs(store, cfg.Party, inputsDir, party, players) + } + return materializeLegacyPartyInputs(store, cfg, inputsDir, party, players) +} + +func materializeCanonicalPartyInputs( + store artifacts.Store, + resolved config.ResolvedParty, + inputsDir string, + party, players artifactpolicy.PreparedInputSourceDescriptor, +) ([]manifest.InputRecord, error) { + if resolved.Canonical == nil { + return nil, fmt.Errorf("canonical party data is required") + } + if len(resolved.Canonical.Raw) == 0 { + return nil, fmt.Errorf("canonical party raw bytes are required") + } + partyPath := filepath.Join(inputsDir, party.Filename) + partyChecksum, err := writeBytesIfChanged(store, partyPath, resolved.Canonical.Raw) + if err != nil { + return nil, fmt.Errorf("materialize canonical party: %w", err) + } + playersBytes, err := resolved.Canonical.PlayersYAML() + if err != nil { + return nil, fmt.Errorf("render canonical players projection: %w", err) + } + playersPath := filepath.Join(inputsDir, players.Filename) + playersChecksum, err := writeBytesIfChanged(store, playersPath, playersBytes) + if err != nil { + return nil, fmt.Errorf("materialize canonical players projection: %w", err) + } + source := strings.TrimSpace(resolved.Source.Source) + if source == "" { + source = "campaign_config" + } + return []manifest.InputRecord{ + {Kind: party.ManifestKind, Path: partyPath, Checksum: partyChecksum, Source: source}, + {Kind: players.ManifestKind, Path: playersPath, Checksum: playersChecksum, Source: "derived_from_party"}, + }, nil +} diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index 1d527a4..d0cb732 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -1,6 +1,7 @@ package stage import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -156,6 +157,101 @@ func TestPrepareStageIdempotent(t *testing.T) { } } +func TestPrepareStageMaterializesCanonicalPartyAndDerivedPlayers(t *testing.T) { + env, m := setupPrepareEnv(t) + partyBytes := []byte(`schema_version: narratio.party.v1 +characters: + zeta: + player: {name: Shared Player} + character: + name: Zeta + alias: [Z] + classes: [{name: wizard, level: 8}] + alpha: + player: {name: Shared Player} + character: + name: Alpha + classes: [{name: ranger}] +`) + document, err := config.ParseParty(partyBytes) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } + if err := os.Remove(filepath.Join(filepath.Dir(env.Config.SessionPath), "players.yml")); err != nil { + t.Fatalf("remove legacy players source: %v", err) + } + env.Config.Party = config.ResolvedParty{ + Mode: config.PartyModeCanonical, + Canonical: document.Canonical, + Source: config.PartySource{ + Path: filepath.Join(filepath.Dir(env.Config.CampaignPath), "party.yml"), + ConfigPath: env.Config.CampaignPath, + Source: "campaign_config", + }, + } + env.Config.StableInputs.PlayersFile = config.ResolvedInputFile{Source: "derived_from_party"} + writeFile(t, filepath.Join(filepath.Dir(env.Config.SessionPath), "audio", "alice.flac"), "audio") + + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("prepare.Run() error = %v", err) + } + paths := sessionPathsForEnv(env, m.SessionID) + preparedParty := filepath.Join(paths.InputsDir, "party.yml") + preparedPlayers := filepath.Join(paths.InputsDir, "players.yml") + if got, err := os.ReadFile(preparedParty); err != nil || !bytes.Equal(got, partyBytes) { + t.Fatalf("prepared party = %q, %v; want unchanged canonical bytes", got, err) + } + wantPlayers, err := document.Canonical.PlayersYAML() + if err != nil { + t.Fatalf("PlayersYAML() error = %v", err) + } + if got, err := os.ReadFile(preparedPlayers); err != nil || !bytes.Equal(got, wantPlayers) { + t.Fatalf("prepared players = %q, %v; want deterministic projection", got, err) + } + partyRecord := findManifestInput(t, m.Inputs, "party") + playersRecord := findManifestInput(t, m.Inputs, "players") + if partyRecord.Source != "campaign_config" || playersRecord.Source != "derived_from_party" { + t.Fatalf("party records = %#v / %#v, want canonical and derived provenance", partyRecord, playersRecord) + } + if partyRecord.Checksum == playersRecord.Checksum || partyRecord.Checksum == "" || playersRecord.Checksum == "" { + t.Fatalf("party records must retain independent checksums: %#v / %#v", partyRecord, playersRecord) + } + firstParty, firstPlayers := partyRecord.Checksum, playersRecord.Checksum + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("second prepare.Run() error = %v", err) + } + if got := findManifestInput(t, m.Inputs, "party").Checksum; got != firstParty { + t.Fatalf("canonical party checksum changed on repeat: %q / %q", got, firstParty) + } + if got := findManifestInput(t, m.Inputs, "players").Checksum; got != firstPlayers { + t.Fatalf("derived players checksum changed on repeat: %q / %q", got, firstPlayers) + } +} + +func TestPrepareStageLegacyPartyInputsRemainOpaqueCopies(t *testing.T) { + env, m := setupPrepareEnv(t) + root := filepath.Dir(env.Config.SessionPath) + playersBytes := []byte("legacy players\n") + partyBytes := []byte("legacy party\n") + writeFile(t, filepath.Join(root, "players.yml"), string(playersBytes)) + writeFile(t, filepath.Join(root, "party.yml"), string(partyBytes)) + writeFile(t, filepath.Join(root, "audio", "alice.flac"), "audio") + env.Config.Party = config.ResolvedParty{Mode: config.PartyModeLegacy} + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("prepare.Run() error = %v", err) + } + paths := sessionPathsForEnv(env, m.SessionID) + if got, err := os.ReadFile(filepath.Join(paths.InputsDir, "party.yml")); err != nil || !bytes.Equal(got, partyBytes) { + t.Fatalf("prepared legacy party = %q, %v", got, err) + } + if got, err := os.ReadFile(filepath.Join(paths.InputsDir, "players.yml")); err != nil || !bytes.Equal(got, playersBytes) { + t.Fatalf("prepared legacy players = %q, %v", got, err) + } + if got := findManifestInput(t, m.Inputs, "players").Source; got != "campaign_config" { + t.Fatalf("legacy players source = %q, want campaign_config", got) + } +} + func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) { tests := []struct { name string diff --git a/internal/stage/semantic_contracts_initial.go b/internal/stage/semantic_contracts_initial.go index 8bccaa2..ccb91de 100644 --- a/internal/stage/semantic_contracts_initial.go +++ b/internal/stage/semantic_contracts_initial.go @@ -11,7 +11,7 @@ import ( ) const ( - prepareSemanticConfigVersion = 1 + prepareSemanticConfigVersion = 2 transcribeSemanticConfigVersion = 1 mergeSemanticConfigVersion = 1 ) @@ -20,6 +20,8 @@ type prepareSemanticConfig struct { CampaignID string `json:"campaign_id"` SessionID string `json:"session_id"` PreviousSession string `json:"previous_session_id"` + PartyMode string `json:"party_mode"` + PlayersSchema string `json:"players_schema_version,omitempty"` Audio prepareAudioSelection `json:"audio"` StableInputs []prepareStableInputSelection `json:"stable_inputs"` PreviousArtifacts []preparePreviousRequirement `json:"previous_artifacts"` @@ -104,13 +106,21 @@ func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) { if err != nil { return prepareSemanticConfig{}, fmt.Errorf("resolve previous artifact requirements: %w", err) } + partyMode := cfg.Party.Mode + if partyMode == "" { + partyMode = config.PartyModeLegacy + } payload := prepareSemanticConfig{ CampaignID: strings.TrimSpace(cfg.Session.Campaign), SessionID: strings.TrimSpace(cfg.Session.SessionID), PreviousSession: strings.TrimSpace(cfg.Session.PreviousSessionID), + PartyMode: string(partyMode), Audio: prepareAudioSemantics(cfg), StableInputs: prepareStableInputSemantics(cfg), } + if partyMode == config.PartyModeCanonical { + payload.PlayersSchema = config.PlayersSchemaVersion + } for _, requirement := range requirements { entry := preparePreviousRequirement{Name: strings.TrimSpace(requirement.Name), Required: requirement.Required} if cfg.Pipeline.Scriptorium != nil { diff --git a/internal/stage/semantic_contracts_initial_test.go b/internal/stage/semantic_contracts_initial_test.go index 8d545d1..21312e3 100644 --- a/internal/stage/semantic_contracts_initial_test.go +++ b/internal/stage/semantic_contracts_initial_test.go @@ -26,6 +26,9 @@ func TestPrepareSemanticConfigSensitivity(t *testing.T) { {name: "stable input owner", mutate: func(env *Env) { 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 { @@ -47,6 +50,18 @@ func TestPrepareSemanticConfigSensitivity(t *testing.T) { }) } +func TestPrepareSemanticConfigRecordsCanonicalProjectionVersion(t *testing.T) { + env := initialSemanticEnv() + env.Config.Party.Mode = config.PartyModeCanonical + payload, err := buildPrepareSemanticConfig(env) + if err != nil { + t.Fatalf("buildPrepareSemanticConfig() error = %v", err) + } + if payload.PartyMode != string(config.PartyModeCanonical) || payload.PlayersSchema != config.PlayersSchemaVersion { + t.Fatalf("prepare semantic payload = %#v", payload) + } +} + func TestTranscribeSemanticConfigSensitivity(t *testing.T) { assertSemanticFingerprintChanges(t, transcribeStage{}, initialSemanticEnv(), []struct { name string