clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / internal / enrich / contribution.go
contribution.go 12.3K
1package enrich23import (4 "bytes"5 "crypto/sha256"6 "encoding/hex"7 "encoding/json"8 "fmt"910 "github.com/FelineStateMachine/atlas/format/semconv"11)1213// A contribution is what an enricher has to say, written down.14//15// The interface below hands one back as a Go value, but the value is a16// convenience over this: a contribution has a canonical serialized form, and17// that form is the contract. It can be written to a file, diffed between two18// runs, replayed against the same volume years later, or handed to a reviewer19// who wants to see what an enricher would do before it does it.20//21// The operation vocabulary is deliberately additive. There is no operation that22// removes a feature, empties a collection, or deletes a world, so "never remove23// or rewrite what a source said" is not a rule an enricher has to remember --24// it is a property of what an enricher is able to express. The two operations25// that touch existing data, setting an attribute and setting prose, refuse to26// overwrite; they only ever fill something empty.2728// The canonical form's identity.29const (30 ContributionDoc = "atlas-enrich-contribution"31 ContributionVersion = 132)3334// Contribution is one enricher's whole offering to one volume.35type Contribution struct {36 Contribution string `json:"contribution"`37 Version int `json:"version"`38 // Enricher is the name of whoever made it.39 Enricher string `json:"enricher"`40 Volume string `json:"volume"`41 Ops []Op `json:"ops,omitempty"`42}4344// The operations an enricher may express.45const (46 // OpSetAttr writes one semantic-conventions attribute onto a world, a47 // collection, or a feature. It fills an empty key; it never overwrites.48 OpSetAttr = "set-attr"49 // OpSetProse writes a feature's description, where the feature has none.50 OpSetProse = "set-prose"51 // OpAddFeature adds a feature to a collection the world already has.52 OpAddFeature = "add-feature"53 // OpAddCollection adds a whole collection, features and all.54 OpAddCollection = "add-collection"55 // OpAddWorld adds a whole ground the volume did not picture.56 OpAddWorld = "add-world"57 // OpSetIcon resolves a collection's artwork: the asset it carries and58 // whether that asset is a picture or a glyph.59 OpSetIcon = "set-icon"60 // OpAddAsset carries one piece of artwork into the volume.61 OpAddAsset = "add-asset"62 // OpSetLens attaches a picture of a world, or updates one already attached.63 OpSetLens = "set-lens"64 // OpLedger writes one account of the contribution onto a world.65 OpLedger = "ledger"66)6768// Op is one operation. It is one struct rather than an interface because the69// canonical form is the contract: a flat, ordered list of records with a named70// kind is what serializes, diffs and replays predictably, and what a reviewer71// can read.72type Op struct {73 Kind string `json:"op"`74 World string `json:"world,omitempty"`75 // Collection and Feature name what the operation is about, by identity.76 Collection int64 `json:"collection,omitempty"`77 Feature int64 `json:"feature,omitempty"`78 // Entity says which entity an attribute attaches to, in the conventions'79 // own words.80 Entity semconv.Entity `json:"entity,omitempty"`81 Key string `json:"key,omitempty"`82 Value string `json:"value,omitempty"`83 // Picture says an icon asset is drawn as-is rather than tinted.84 Picture bool `json:"picture,omitempty"`8586 NewCollection *Collection `json:"newCollection,omitempty"`87 NewFeature *Feature `json:"newFeature,omitempty"`88 NewWorld *World `json:"newWorld,omitempty"`89 Lens *Lens `json:"lens,omitempty"`90 Asset *Icon `json:"asset,omitempty"`91 Account *Account `json:"account,omitempty"`92}9394// Empty reports a contribution that changes nothing. No change is no build:95// a volume nothing has to say about is left exactly where it was.96func (c Contribution) Empty() bool { return len(c.Ops) == 0 }9798// Marshal writes the canonical form: indented, with a trailing newline, keys in99// declaration order, and maps sorted by the encoder. Two runs of the same100// enricher over the same volume write the same bytes.101func (c Contribution) Marshal() ([]byte, error) {102 if c.Contribution == "" {103 c.Contribution = ContributionDoc104 }105 if c.Version == 0 {106 c.Version = ContributionVersion107 }108 var out bytes.Buffer109 encoder := json.NewEncoder(&out)110 encoder.SetIndent("", " ")111 // Escaping HTML would rewrite an ampersand in somebody's title, and a112 // canonical form that alters what it carries is not one.113 encoder.SetEscapeHTML(false)114 if err := encoder.Encode(c); err != nil {115 return nil, fmt.Errorf("marshal contribution: %w", err)116 }117 return out.Bytes(), nil118}119120// Digest is the canonical form's SHA-256, lowercase hex: what a log line names121// a contribution by, and what says two runs produced the same offering.122func (c Contribution) Digest() (string, error) {123 data, err := c.Marshal()124 if err != nil {125 return "", err126 }127 sum := sha256.Sum256(data)128 return hex.EncodeToString(sum[:]), nil129}130131// UnmarshalContribution reads the canonical form back.132func UnmarshalContribution(data []byte) (Contribution, error) {133 var c Contribution134 if err := json.Unmarshal(data, &c); err != nil {135 return Contribution{}, fmt.Errorf("decode contribution: %w", err)136 }137 if c.Contribution != ContributionDoc {138 return Contribution{}, fmt.Errorf("contribution says %q, not %q", c.Contribution, ContributionDoc)139 }140 if c.Version != ContributionVersion {141 return Contribution{}, fmt.Errorf("contribution version %d, want %d", c.Version, ContributionVersion)142 }143 return c, nil144}145146// Apply folds a contribution into a volume, in the order its operations were147// written. Every operation is checked before it lands: an attribute against the148// conventions registry and against what is already there, an identifier against149// the world's one identifier space, artwork against the bytes already carried150// under its name.151//152// A refused operation fails the whole application. A contribution is one153// enricher's account of one volume, and half of an account is not an account.154func Apply(v *Volume, c Contribution) error {155 if c.Volume != "" && c.Volume != v.Slug {156 return fmt.Errorf("contribution from %s is about volume %s, not %s", c.Enricher, c.Volume, v.Slug)157 }158 for index, op := range c.Ops {159 if err := applyOp(v, op); err != nil {160 return fmt.Errorf("%s op %d (%s): %w", c.Enricher, index, op.Kind, err)161 }162 }163 return nil164}165166func applyOp(v *Volume, op Op) error {167 switch op.Kind {168 case OpAddWorld:169 return addWorld(v, op)170 case OpAddAsset:171 return addAsset(v, op)172 }173 world := v.World(op.World)174 if world == nil {175 return fmt.Errorf("volume %s pictures no world %s", v.Slug, op.World)176 }177 switch op.Kind {178 case OpSetAttr:179 return setAttr(world, op)180 case OpSetProse:181 return setProse(world, op)182 case OpAddFeature:183 return addFeature(world, op)184 case OpAddCollection:185 return addCollection(world, op)186 case OpSetIcon:187 return setIcon(world, op)188 case OpSetLens:189 return setLens(world, op)190 case OpLedger:191 if op.Account == nil {192 return fmt.Errorf("no account")193 }194 world.Ledger = append(world.Ledger, *op.Account)195 return nil196 }197 return fmt.Errorf("no such operation")198}199200func setAttr(w *World, op Op) error {201 if err := semconv.Check(op.Entity, op.Key, op.Value); err != nil {202 return err203 }204 switch op.Entity {205 case semconv.EntityWorld:206 if held, taken := w.Attrs[op.Key]; taken && held != op.Value {207 return fmt.Errorf("world %s already says %s=%q", w.Slug, op.Key, held)208 }209 w.Attrs = withAttr(w.Attrs, op.Key, op.Value)210 return nil211 case semconv.EntityCollection:212 collection := w.Collection(op.Collection)213 if collection == nil {214 return fmt.Errorf("world %s has no collection %d", w.Slug, op.Collection)215 }216 if held, taken := collection.Attrs[op.Key]; taken && held != op.Value {217 return fmt.Errorf("collection %q already says %s=%q", collection.Title, op.Key, held)218 }219 collection.Attrs = withAttr(collection.Attrs, op.Key, op.Value)220 return nil221 case semconv.EntityFeature:222 _, feature := w.Feature(op.Feature)223 if feature == nil {224 return fmt.Errorf("world %s has no feature %d", w.Slug, op.Feature)225 }226 if held, taken := feature.Attrs[op.Key]; taken && held != op.Value {227 return fmt.Errorf("feature %q already says %s=%q", feature.Title, op.Key, held)228 }229 feature.Attrs = withAttr(feature.Attrs, op.Key, op.Value)230 return nil231 }232 return fmt.Errorf("attributes attach to a world, a collection or a feature, not to %q", op.Entity)233}234235func setProse(w *World, op Op) error {236 _, feature := w.Feature(op.Feature)237 if feature == nil {238 return fmt.Errorf("world %s has no feature %d", w.Slug, op.Feature)239 }240 if feature.Description != "" && feature.Description != op.Value {241 return fmt.Errorf("feature %q already has prose; an enricher fills what is empty and rewrites nothing",242 feature.Title)243 }244 feature.Description = op.Value245 return nil246}247248func addFeature(w *World, op Op) error {249 if op.NewFeature == nil {250 return fmt.Errorf("no feature")251 }252 collection := w.Collection(op.Collection)253 if collection == nil {254 return fmt.Errorf("world %s has no collection %d", w.Slug, op.Collection)255 }256 if _, held := w.Feature(op.NewFeature.ID); held != nil {257 return fmt.Errorf("feature id %d is already spoken for in %s", op.NewFeature.ID, w.Slug)258 }259 collection.Features = append(collection.Features, *op.NewFeature)260 return nil261}262263func addCollection(w *World, op Op) error {264 if op.NewCollection == nil {265 return fmt.Errorf("no collection")266 }267 if w.Collection(op.NewCollection.ID) != nil {268 return fmt.Errorf("collection id %d is already spoken for in %s", op.NewCollection.ID, w.Slug)269 }270 for _, feature := range op.NewCollection.Features {271 if _, held := w.Feature(feature.ID); held != nil {272 return fmt.Errorf("feature id %d is already spoken for in %s", feature.ID, w.Slug)273 }274 }275 w.Collections = append(w.Collections, *op.NewCollection)276 return nil277}278279func addWorld(v *Volume, op Op) error {280 if op.NewWorld == nil {281 return fmt.Errorf("no world")282 }283 if v.World(op.NewWorld.Slug) != nil {284 return fmt.Errorf("volume %s already pictures %s", v.Slug, op.NewWorld.Slug)285 }286 v.Worlds = append(v.Worlds, *op.NewWorld)287 return nil288}289290// setIcon gives a collection artwork: the key it names in the volume's icon291// set, and, where the caller is working on a volume whose artwork has already292// been resolved to assets, the asset name too.293func setIcon(w *World, op Op) error {294 collection := w.Collection(op.Collection)295 if collection == nil {296 return fmt.Errorf("world %s has no collection %d", w.Slug, op.Collection)297 }298 if collection.Icon != "" && collection.Icon != op.Key {299 return fmt.Errorf("collection %q already names artwork %s", collection.Title, collection.Icon)300 }301 if collection.IconAsset != "" && collection.IconAsset != op.Value {302 return fmt.Errorf("collection %q already carries %s", collection.Title, collection.IconAsset)303 }304 if op.Key != "" {305 collection.Icon = op.Key306 }307 if op.Value != "" {308 collection.IconAsset = op.Value309 collection.IconPicture = op.Picture310 }311 return nil312}313314func setLens(w *World, op Op) error {315 if op.Lens == nil {316 return fmt.Errorf("no lens")317 }318 for index := range w.Lenses {319 if w.Lenses[index].Name != op.Lens.Name {320 continue321 }322 // A lens of the same name pictures the same ground through the same323 // pyramid: updating it is re-derivation, and a new stamp is the point.324 // Pointing it at another tile set would be a rewrite of what the source325 // said its picture was.326 if w.Lenses[index].TileSet != op.Lens.TileSet {327 return fmt.Errorf("lens %q already pictures %s; an enricher does not repoint a source's lens",328 op.Lens.Name, w.Lenses[index].TileSet)329 }330 w.Lenses[index] = *op.Lens331 return nil332 }333 w.Lenses = append(w.Lenses, *op.Lens)334 return nil335}336337func addAsset(v *Volume, op Op) error {338 if op.Asset == nil || op.Asset.File == "" {339 return fmt.Errorf("no asset")340 }341 for _, held := range v.Icons {342 if held.File != op.Asset.File {343 continue344 }345 if !bytes.Equal(held.Data, op.Asset.Data) {346 return fmt.Errorf("asset %s is already carried with different bytes", op.Asset.File)347 }348 return nil349 }350 v.Icons = append(v.Icons, *op.Asset)351 return nil352}353354// withAttr sets one attribute on a copy of the map, never on the map itself:355// two worlds may share their source's attributes by reference, and speaking for356// one must not put words in another's mouth.357func withAttr(attrs map[string]string, key, value string) map[string]string {358 out := make(map[string]string, len(attrs)+1)359 for held, value := range attrs {360 out[held] = value361 }362 out[key] = value363 return out364}