Prepare canonical party and derived players inputs

This commit is contained in:
2026-08-30 14:08:35 +00:00
parent 61000a9466
commit c4435b76c4
15 changed files with 306 additions and 21 deletions

View File

@@ -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 binding takes precedence over a matching external path in Notarius
configuration. Narratio never emits `--without-reference`. 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: The maintained D&D boundary binds only the four campaign-owned external slots:
```text ```text

View File

@@ -47,3 +47,27 @@ must not override their party or players inputs.
Use the canonical document for new campaigns. The configuration rules and Use the canonical document for new campaigns. The configuration rules and
source-relative path behavior are defined in the [Configuration Reference](../config.md). 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`.

View File

@@ -39,7 +39,10 @@ ID.
Prepared stable source IDs are `narratio.input.players`, Prepared stable source IDs are `narratio.input.players`,
`narratio.input.party`, `narratio.input.glossary`, and `narratio.input.party`, `narratio.input.glossary`, and
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest `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 ## Runtime Catalog

View File

@@ -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 guidance rather than being normalized into a different workspace or remote
namespace. 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: The model admits these stage states:
- `pending` - `pending`

View File

@@ -39,6 +39,9 @@ Materialize canonical current-session inputs before processing stages.
- materializes a configured spell catalog with checksum and provenance, or - materializes a configured spell catalog with checksum and provenance, or
safely removes an obsolete canonical spell catalog and its manifest record safely removes an obsolete canonical spell catalog and its manifest record
when the effective input is omitted. 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. - scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
- clears managed `previous/` state on every invocation, then, when requirements exist: - clears managed `previous/` state on every invocation, then, when requirements exist:
- resolves the pointer-selected previous source through the shared resolver; - 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 Prepare records a versioned semantic-configuration fingerprint for the
resolved campaign/session selection, local-versus-S3 audio mode and canonical resolved campaign/session selection, local-versus-S3 audio mode and canonical
audio names, stable-input ownership/presence, previous-session identity, and 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 stales its fixed descendants. Existing successful records without this
evidence rerun once when selected. evidence rerun once when selected.

View File

@@ -237,6 +237,11 @@ prepared inputs. Their canonical locations are `inputs/party.yml`,
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies `inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
prepared files cannot change the bytes consumed by an in-flight invocation. 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 Inspect the effective stable-input inventory and
prepared-file readiness with: prepared-file readiness with:

View File

@@ -705,7 +705,7 @@ enforce one unambiguous canonical versus legacy input mode.
## Stage 12 — Canonical Party And Derived Players Preparation ## Stage 12 — Canonical Party And Derived Players Preparation
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -1123,6 +1123,7 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml") glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
spellCatalogPath := filepath.Join(paths.InputsDir, "spell_catalog.json") spellCatalogPath := filepath.Join(paths.InputsDir, "spell_catalog.json")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n") 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.SourceInputParty, partyPath, "- Arannis\n")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputGlossary, glossaryPath, "- term: Ten Towns\n") recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputGlossary, glossaryPath, "- term: Ten Towns\n")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputSpellCatalog, spellCatalogPath, "{\"spells\":[]}\n") recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputSpellCatalog, spellCatalogPath, "{\"spells\":[]}\n")

View File

@@ -107,6 +107,9 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T)
selector := strings.TrimSpace(reference.selector) selector := strings.TrimSpace(reference.selector)
preparedPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents) preparedPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents)
wantSources[selector] = reference.sourceID 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) result, err := (extractStage{}).Run(context.Background(), env, m)

View File

@@ -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) speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc)
autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, 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) 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) spellCatalogInput := stableInputSource(env.Config.StableInputs.SpellCatalogFile, env.Config.Session.Inputs.SpellCatalogFile, sessionSrc)
speakersSrc, err := resolveConfigRelativePath(speakersInput) speakersSrc, err := resolveConfigRelativePath(speakersInput)
@@ -79,14 +77,6 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare: glossary path: %w", err) 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) != "" spellCatalogConfigured := strings.TrimSpace(spellCatalogInput.Path) != ""
spellCatalogSrc := "" spellCatalogSrc := ""
if spellCatalogConfigured { if spellCatalogConfigured {
@@ -106,8 +96,6 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
{path: speakersSrc, name: "speakers.yml"}, {path: speakersSrc, name: "speakers.yml"},
{path: autocorrectSrc, name: "autocorrect.yml"}, {path: autocorrectSrc, name: "autocorrect.yml"},
{path: glossarySrc, name: "glossary.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 { if err := requireFile(required.path, required.name); err != nil {
return nil, fmt.Errorf("prepare: %w", err) 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 sourceID string
input config.ResolvedInputFile input config.ResolvedInputFile
src string src string
}{ }{{sourceID: artifactpolicy.SourceInputGlossary, input: glossaryInput, src: glossarySrc}} {
{sourceID: artifactpolicy.SourceInputGlossary, input: glossaryInput, src: glossarySrc},
{sourceID: artifactpolicy.SourceInputPlayers, input: playersInput, src: playersSrc},
{sourceID: artifactpolicy.SourceInputParty, input: partyInput, src: partySrc},
} {
descriptor, ok := artifactpolicy.DescribePreparedInputSource(prepared.sourceID) descriptor, ok := artifactpolicy.DescribePreparedInputSource(prepared.sourceID)
if !ok { if !ok {
return nil, fmt.Errorf("prepare: prepared-input descriptor %q is unavailable", prepared.sourceID) 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) 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 var audioCacheStats s3AudioMaterializationStats
if useS3Audio { if useS3Audio {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,6 +1,7 @@
package stage package stage
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "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) { func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -11,7 +11,7 @@ import (
) )
const ( const (
prepareSemanticConfigVersion = 1 prepareSemanticConfigVersion = 2
transcribeSemanticConfigVersion = 1 transcribeSemanticConfigVersion = 1
mergeSemanticConfigVersion = 1 mergeSemanticConfigVersion = 1
) )
@@ -20,6 +20,8 @@ type prepareSemanticConfig struct {
CampaignID string `json:"campaign_id"` CampaignID string `json:"campaign_id"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
PreviousSession string `json:"previous_session_id"` PreviousSession string `json:"previous_session_id"`
PartyMode string `json:"party_mode"`
PlayersSchema string `json:"players_schema_version,omitempty"`
Audio prepareAudioSelection `json:"audio"` Audio prepareAudioSelection `json:"audio"`
StableInputs []prepareStableInputSelection `json:"stable_inputs"` StableInputs []prepareStableInputSelection `json:"stable_inputs"`
PreviousArtifacts []preparePreviousRequirement `json:"previous_artifacts"` PreviousArtifacts []preparePreviousRequirement `json:"previous_artifacts"`
@@ -104,13 +106,21 @@ func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) {
if err != nil { if err != nil {
return prepareSemanticConfig{}, fmt.Errorf("resolve previous artifact requirements: %w", err) return prepareSemanticConfig{}, fmt.Errorf("resolve previous artifact requirements: %w", err)
} }
partyMode := cfg.Party.Mode
if partyMode == "" {
partyMode = config.PartyModeLegacy
}
payload := prepareSemanticConfig{ payload := prepareSemanticConfig{
CampaignID: strings.TrimSpace(cfg.Session.Campaign), CampaignID: strings.TrimSpace(cfg.Session.Campaign),
SessionID: strings.TrimSpace(cfg.Session.SessionID), SessionID: strings.TrimSpace(cfg.Session.SessionID),
PreviousSession: strings.TrimSpace(cfg.Session.PreviousSessionID), PreviousSession: strings.TrimSpace(cfg.Session.PreviousSessionID),
PartyMode: string(partyMode),
Audio: prepareAudioSemantics(cfg), Audio: prepareAudioSemantics(cfg),
StableInputs: prepareStableInputSemantics(cfg), StableInputs: prepareStableInputSemantics(cfg),
} }
if partyMode == config.PartyModeCanonical {
payload.PlayersSchema = config.PlayersSchemaVersion
}
for _, requirement := range requirements { for _, requirement := range requirements {
entry := preparePreviousRequirement{Name: strings.TrimSpace(requirement.Name), Required: requirement.Required} entry := preparePreviousRequirement{Name: strings.TrimSpace(requirement.Name), Required: requirement.Required}
if cfg.Pipeline.Scriptorium != nil { if cfg.Pipeline.Scriptorium != nil {

View File

@@ -26,6 +26,9 @@ 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 {
@@ -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) { func TestTranscribeSemanticConfigSensitivity(t *testing.T) {
assertSemanticFingerprintChanges(t, transcribeStage{}, initialSemanticEnv(), []struct { assertSemanticFingerprintChanges(t, transcribeStage{}, initialSemanticEnv(), []struct {
name string name string