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
|
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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user