Prepare canonical party and derived players inputs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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/<run_id>/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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
57
internal/stage/prepare_legacy.go
Normal file
57
internal/stage/prepare_legacy.go
Normal 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
|
||||
}
|
||||
66
internal/stage/prepare_party.go
Normal file
66
internal/stage/prepare_party.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user