1// Package curation holds the generate lane's editorial decisions as data.2//3// Some things about a volume cannot be derived from its captures: which of a4// game's maps a reader should open on, whether its markers need a light rim or5// a dark one against its art, whether one sheet is really eight places printed6// side by side, and where two sources spell one concept differently. Those are7// judgements about particular archives, and a judgement belongs in a reviewed8// data file where it can be read and argued with, not in a table halfway down a9// program.10//11// The file is curation.json, embedded so a build carries its own curation and a12// composed bundle cannot depend on what happened to be on the operator's disk.13// Its schema is documented in docs/generate.md; this package is the reader and14// the checker.15//16// Every key here is spelled in Atlas's vocabulary -- volume slug, or17// "<volume>/<world>" -- never in a source's identifiers. The tables this18// replaced were keyed by upstream numeric ids, which meant a curation entry was19// unreadable without the source's database in front of you and unmovable to a20// second source describing the same ground.21package curation2223import (24 _ "embed"25 "encoding/json"26 "fmt"27 "strings"2829 "github.com/FelineStateMachine/atlas/internal/generate/doc"30)3132//go:embed curation.json33var embedded []byte3435// ShardMode says what becomes of a sheet holding several separate places.36type ShardMode string3738const (39 // ShardNone is every world nobody curated: one sheet, one world.40 ShardNone ShardMode = ""41 // ShardIntoWorlds gives each piece its own entry in the world picker.42 ShardIntoWorlds ShardMode = "worlds"43 // ShardIntoLenses keeps one world and offers its pieces one at a time, as44 // lenses on the same ground.45 ShardIntoLenses ShardMode = "lenses"46)4748// Tables is the whole curated corpus, read.49type Tables struct {50 // Window is the source tile window a world is cut from unless it declares51 // one of its own.52 Window Window5354 preferredWorlds map[string][]string55 newestFirst map[string]bool56 outsetByVolume map[string]string57 outsetByWorld map[string]string58 pixelArt map[string]bool59 shard map[string]ShardMode60 equivalents map[string]map[string]string61}6263// Window is a tile window: the zoom it is measured at and its first tile.64type Window struct {65 SourceZoom int66 FirstTile int67}6869// Load reads the embedded curation. It is the only way to get Tables, so no70// caller can compose against curation nobody reviewed.71func Load() (Tables, error) { return parse(embedded) }7273// LoadFrom reads curation from bytes, for a test that wants to state its own74// corpus rather than the real one.75func LoadFrom(data []byte) (Tables, error) { return parse(data) }7677type wire struct {78 Schema int `json:"schema"`79 Window struct {80 SourceZoom int `json:"sourceZoom"`81 FirstTile int `json:"firstTile"`82 } `json:"window"`83 WorldOrder struct {84 Preferred map[string][]string `json:"preferred"`85 NewestFirst struct {86 Volumes []string `json:"volumes"`87 } `json:"newestFirst"`88 } `json:"worldOrder"`89 IconOutset struct {90 ByVolume map[string]string `json:"byVolume"`91 ByWorld map[string]string `json:"byWorld"`92 } `json:"iconOutset"`93 PixelArt struct {94 Volumes []string `json:"volumes"`95 } `json:"pixelArt"`96 Shard struct {97 Worlds map[string]ShardMode `json:"worlds"`98 } `json:"shard"`99 CollectionEquivalents map[string]json.RawMessage `json:"collectionEquivalents"`100}101102// Schema is the curation file layout this package reads. It moves when an103// existing field's meaning breaks.104const Schema = 1105106func parse(data []byte) (Tables, error) {107 var raw wire108 if err := json.Unmarshal(data, &raw); err != nil {109 return Tables{}, fmt.Errorf("decode curation: %w", err)110 }111 if raw.Schema != Schema {112 return Tables{}, fmt.Errorf("curation schema %d, want %d", raw.Schema, Schema)113 }114 if raw.Window.SourceZoom <= 0 || raw.Window.FirstTile < 0 {115 return Tables{}, fmt.Errorf("curation declares no shared tile window")116 }117 out := Tables{118 Window: Window{SourceZoom: raw.Window.SourceZoom, FirstTile: raw.Window.FirstTile},119 preferredWorlds: raw.WorldOrder.Preferred,120 newestFirst: make(map[string]bool, len(raw.WorldOrder.NewestFirst.Volumes)),121 outsetByVolume: raw.IconOutset.ByVolume,122 outsetByWorld: raw.IconOutset.ByWorld,123 pixelArt: make(map[string]bool, len(raw.PixelArt.Volumes)),124 shard: raw.Shard.Worlds,125 equivalents: make(map[string]map[string]string, len(raw.CollectionEquivalents)),126 }127 for _, slug := range raw.WorldOrder.NewestFirst.Volumes {128 out.newestFirst[slug] = true129 }130 for _, slug := range raw.PixelArt.Volumes {131 out.pixelArt[slug] = true132 }133 // The equivalents table carries prose keys alongside its volumes, the way134 // every other section does, so a value that is not an object is commentary135 // and not a volume.136 for key, value := range raw.CollectionEquivalents {137 var table map[string]string138 if err := json.Unmarshal(value, &table); err != nil {139 continue140 }141 out.equivalents[key] = table142 }143 for key, mode := range out.shard {144 switch mode {145 case ShardIntoWorlds, ShardIntoLenses:146 default:147 return Tables{}, fmt.Errorf("curation declares shard mode %q for %s", mode, key)148 }149 if !strings.Contains(key, "/") {150 return Tables{}, fmt.Errorf("curation shards %q, which names no world", key)151 }152 }153 for key := range out.outsetByWorld {154 if !strings.Contains(key, "/") {155 return Tables{}, fmt.Errorf("curation sets an outset on %q, which names no world", key)156 }157 }158 return out, nil159}160161// PreferredWorlds is the volume's curated world order: the slugs that come162// first, in the order they come. Everything else follows in title order.163func (t Tables) PreferredWorlds(volume string) []string { return t.preferredWorlds[volume] }164165// NewestFirst reports whether a volume's worlds are a version history, which166// reads backward: the present opens, the past waits below it.167func (t Tables) NewestFirst(volume string) bool { return t.newestFirst[volume] }168169// IconOutset is the rim a world's markers wear, or the empty string where170// nothing is curated. A world's own entry wins over its volume's.171func (t Tables) IconOutset(volume, world string) string {172 if outset, ok := t.outsetByWorld[volume+"/"+world]; ok {173 return outset174 }175 return t.outsetByVolume[volume]176}177178// PixelArt reports whether a volume is drawn on a pixel grid, which decides how179// its pyramids are reduced and whether a reader smooths them when magnified.180func (t Tables) PixelArt(volume string) bool { return t.pixelArt[volume] }181182// Shard is what becomes of a sheet holding several places, or ShardNone.183func (t Tables) Shard(volume, world string) ShardMode { return t.shard[volume+"/"+world] }184185// CollectionEquivalent is the shared name a volume's collection meets other186// sources under, keyed by the collection's artwork key, or the empty string.187func (t Tables) CollectionEquivalent(volume, iconKey string) string {188 return t.equivalents[volume][iconKey]189}190191// Declare writes the curated merge identity onto a freshly translated192// document's collections, so a collection knows what it meets other sources193// under before anything tries to meet them.194//195// It is separate from composition's own late declaration for one reason, and196// the reason is order. Composition speaks the conventions over a volume that is197// otherwise finished; a cross-source merge happens before that and reads198// identity off the collection. A document whose equivalence had not been199// declared yet would be merged on artwork keys alone -- which is the honest200// fallback for a source nobody has curated, and quietly the wrong answer for201// one that has been.202//203// Nothing here overwrites what a source said: a collection that already names204// its own identity keeps it.205func (t Tables) Declare(d *doc.Document) {206 equivalents := t.equivalents[d.Volume.Slug]207 if len(equivalents) == 0 {208 return209 }210 for worldIndex := range d.Worlds {211 world := &d.Worlds[worldIndex]212 for index := range world.Collections {213 collection := &world.Collections[index]214 if collection.Kind != doc.KindPoint || collection.Key != "" {215 continue216 }217 if shared := equivalents[collection.Icon]; shared != "" {218 collection.Key = shared219 }220 }221 }222}