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

@@ -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")

View File

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

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)
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 {

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

View File

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

View File

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