{
"id": "pet.trezy.species",
"defs": {
"main": {
"key": "any",
"type": "record",
"record": {
"type": "object",
"required": [
"id",
"displayName",
"tier",
"sprite",
"behaviors"
],
"properties": {
"id": {
"type": "string",
"maxLength": 64,
"description": "The species key. Stored on every ledger row already sold, so renaming one is not safe — a rename is a new species plus a migration, never an edit."
},
"tier": {
"type": "string",
"maxLength": 64,
"description": "The id of a pet.trezy.tier record, which carries the price. Deliberately NOT an enum: adding a tier must be publishing one record, not editing this file, regenerating and redeploying the client. knownValues documents the tiers that exist today and constrains nothing.",
"knownValues": [
"common",
"uncommon",
"rare",
"epic",
"legendary"
]
},
"price": {
"type": "integer",
"minimum": 0,
"description": "Overrides the tier's price for this species alone. Absent is the normal case — an override per species would mean the tiers are wrong."
},
"groups": {
"type": "array",
"items": {
"type": "string",
"maxLength": 64
},
"description": "The `pet.trezy.group` records this species belongs to, by id. AN ARRAY BECAUSE MEMBERSHIP OVERLAPS AND MIXES LEVELS: a bat is `mammals`, `bats`, `fliers` and `nocturnal`, and a penguin is `birds` and `swimmers` without being a mammal at all. A single field would have forced a species to pick one true thing about itself and drop the others — which is why the vocabulary can hold a class, a family and a habit side by side rather than having to settle on one rank. Order is not significant; readers sort by the group's own `rank`."
},
"sprite": {
"ref": "#sprite",
"type": "ref",
"description": "Everything needed to draw the animal, including the atlas itself as a blob. Generated from the artist's Aseprite export by scripts/build-species.ts and never hand-edited."
},
"activity": {
"type": "string",
"maxLength": 64,
"description": "When the animal keeps its hours. ⚠ A FIELD RATHER THAN A GROUP, BECAUSE THESE ARE MUTUALLY EXCLUSIVE AND GROUPS ARE NOT. `groups` overlap on purpose — a bat is a mammal and a flier at once — so nothing there can enforce exactly-one, and a species tagged both `nocturnal` and `diurnal` would be nonsense no reader could resolve. It also has to be exactly one for the mechanic that will read it: a pet dozes outside its own hours, and 'outside' is undefined for an animal with two answers.\n\nThe vocabulary, widest first: `diurnal` is daylight; `nocturnal` is night; `crepuscular` is both twilights; `matutinal` is dawn ONLY and `vespertinal` is dusk ONLY, the two halves crepuscular rolls together; `cathemeral` is no fixed rhythm, which is the honest answer for a slime.\n\n⚠ `matutinal` AND `vespertinal` ARE PUBLISHED AHEAD OF ANY USER, DELIBERATELY. No species on the roster is either one today. They are listed so the vocabulary is settled before somebody needs it — a reader implementing dozing should handle all six now rather than discover two of them later. NOT an enum, for the same reason `tier` is not: the list documents what exists and constrains nothing, so a seventh is a record edit rather than a client deploy.\n\n(Spelled `vespertinal` to match the -al pattern of the others; `vespertine` is the commoner form in the literature and means the same thing. Do not 'correct' one to the other — the string is stored on records.)",
"knownValues": [
"diurnal",
"nocturnal",
"crepuscular",
"matutinal",
"vespertinal",
"cathemeral"
]
},
"behaviors": {
"type": "array",
"items": {
"ref": "#behavior",
"type": "ref"
},
"minLength": 1,
"description": "How the species acts when left alone, as weighted options for the behaviour tree. Authored, unlike `sprite` — this is temperament, a game-design judgement that no export contains."
},
"displayName": {
"type": "string",
"maxLength": 64
},
"purchasable": {
"type": "boolean",
"description": "ABSENT MEANS TRUE. False means the species exists in every other sense — tier, sprite, ownable, on the leaderboard — but the shop will not sell it because it is reached another way. A reader writing `if purchasable then` makes every species unsellable."
}
}
},
"description": "A species the game knows about, published in the game's own repo (did:plc:ollch4g7tes2reb5eceiwuol). Only records from that DID are ever read — record.index:pet.trezy.species refuses the rest. THIS RECORD IS THE WHOLE SPECIES: identity, economy, artwork and temperament. The client compiles in nothing about any species and derives nothing from a filename; it draws what these records say and caches the answer. A species not published here does not exist to the game, and changing one here changes it for every client without an app deploy."
},
"rect": {
"type": "object",
"required": [
"x",
"y",
"w",
"h"
],
"properties": {
"h": {
"type": "integer",
"minimum": 0
},
"w": {
"type": "integer",
"minimum": 0
},
"x": {
"type": "integer"
},
"y": {
"type": "integer"
}
}
},
"size": {
"type": "object",
"required": [
"w",
"h"
],
"properties": {
"h": {
"type": "integer",
"minimum": 1
},
"w": {
"type": "integer",
"minimum": 1
}
}
},
"datum": {
"type": "object",
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"maxLength": 64
},
"value": {
"type": "string",
"maxLength": 256
}
}
},
"frame": {
"type": "object",
"required": [
"x",
"y",
"w",
"h",
"duration"
],
"properties": {
"h": {
"type": "integer",
"minimum": 1
},
"w": {
"type": "integer",
"minimum": 1
},
"x": {
"type": "integer",
"minimum": 0
},
"y": {
"type": "integer",
"minimum": 0
},
"duration": {
"type": "integer",
"minimum": 1,
"description": "Milliseconds, as Aseprite exports it."
}
},
"description": "One frame: where it sits in the atlas, and how long it is held."
},
"slice": {
"type": "object",
"required": [
"name",
"bounds"
],
"properties": {
"data": {
"type": "array",
"items": {
"ref": "#datum",
"type": "ref"
},
"description": "The slice's user-data, as key/value pairs. Aseprite stores this as a free-text string like `type=ground-contact`; it is split at build time so no reader has to. `type` is the only key the game reads today, and unknown keys are carried through rather than dropped."
},
"name": {
"type": "string",
"maxLength": 64
},
"bounds": {
"ref": "#rect",
"type": "ref"
}
}
},
"sprite": {
"type": "object",
"required": [
"atlas",
"size",
"frameSize",
"animations",
"slices"
],
"properties": {
"size": {
"ref": "#size",
"type": "ref",
"description": "The atlas's own pixel dimensions. Needed to place background-position offsets without waiting for the image to load."
},
"atlas": {
"type": "blob",
"accept": [
"image/png"
],
"maxSize": 1000000,
"description": "The packed spritesheet PNG, stored in this repo. A blob rather than a URL into the app's public directory, so the artwork and the frame rectangles that index into it version together and CANNOT disagree — a repacked atlas with stale coordinates draws slivers of the wrong animal, which is the exact drift this record exists to end. Pixel-art atlases are a few KB; the cap is generous by three orders of magnitude and is only there to catch a mistake."
},
"slices": {
"type": "array",
"items": {
"ref": "#slice",
"type": "ref"
},
"description": "Authored regions: the hitbox a pointer can grab, and the ground-contact box that decides where the animal's feet are. Absent or unlabelled slices fall back to a measured box, so this may be empty."
},
"frameSize": {
"ref": "#size",
"type": "ref",
"description": "One frame's dimensions — the window the animal is drawn inside, typically 64×64 with the creature somewhere within it rather than filling it."
},
"animations": {
"type": "array",
"items": {
"ref": "#animation",
"type": "ref"
},
"minLength": 1,
"description": "Named animations. AN ARRAY, NOT A MAP, because lexicon has no map type and an `unknown` blob here would put this back to being parsed by hand at both ends. The client keys it by name on read."
}
},
"description": "The drawable half of a species. Mirrors the client's SpriteSheet shape exactly so the client does no parsing: it reads these fields straight into the renderer. The Aseprite JSON is NOT carried verbatim — that format numbers frames per tag and stores tag membership as index ranges into an insertion-ordered object, which has caused two separate animation bugs in this project. The ranges are resolved here, once, at build time."
},
"behavior": {
"type": "object",
"required": [
"type",
"weight"
],
"properties": {
"type": {
"type": "string",
"maxLength": 32,
"description": "NOT an enum, for the same reason `tier` is not: a new behaviour should be publishable. A client that meets a type it does not implement must skip that option and keep the rest, never refuse the species.",
"knownValues": [
"idle",
"move",
"slide",
"jump"
]
},
"speed": {
"type": "integer",
"minimum": 0,
"description": "World pixels per second. Characterisation rather than realism — the turtle is slow because a slow turtle is funny, and the spread across species is what makes a mixed stage read as a menagerie."
},
"weight": {
"type": "integer",
"minimum": 0,
"description": "Relative likelihood against the species's other options. Zero is a legitimate way to keep a behaviour published but dormant."
},
"animation": {
"type": "string",
"maxLength": 64,
"description": "For `idle` and `move`: the animation to play throughout."
},
"maxDuration": {
"type": "integer",
"minimum": 0,
"description": "For `idle`: seconds, the longest."
},
"minDuration": {
"type": "integer",
"minimum": 0,
"description": "For `idle`: seconds, the shortest the pet will stand still."
},
"endAnimation": {
"type": "string",
"maxLength": 64,
"description": "For `slide` and `jump`: the recovery, played once."
},
"endMoveFrame": {
"type": "integer",
"minimum": 0,
"description": "For `jump`: the frame of the end animation at which it lands."
},
"loopAnimation": {
"type": "string",
"maxLength": 64,
"description": "For `slide` and `jump`: the sustained middle, repeated while travelling."
},
"startAnimation": {
"type": "string",
"maxLength": 64,
"description": "For `slide` and `jump`: the wind-up, played once."
},
"startMoveFrame": {
"type": "integer",
"minimum": 0,
"description": "For `jump`: the frame of the start animation at which the pet actually leaves the ground, so the launch matches the drawing."
}
},
"description": "One weighted option in the behaviour tree. The params a type needs differ by type, which lexicon cannot express as a discriminated union, so every param is optional here and build-species.ts validates the combination that each type actually requires."
},
"animation": {
"type": "object",
"required": [
"name",
"frames"
],
"properties": {
"name": {
"type": "string",
"maxLength": 64,
"description": "The tag name from Aseprite — `idle`, `run`, `slide-loop`. Behaviours reference these by name, and a behaviour naming an animation that is not here does not throw: the pet simply stops animating. build-species.ts refuses to emit that combination."
},
"frames": {
"type": "array",
"items": {
"ref": "#frame",
"type": "ref"
},
"minLength": 1
}
}
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}