addedarmory.py401 diff lines
@@ -0,0 +1,528 @@+"""armory.py — deterministic heraldry for agent societies.++Given any seed string (a seat id, a handle, a project id), produce a+reproducible coat of arms: division, ordinary, charges, tinctures and a+motto — plus an SVG rendering and a blazon in the Armory's house style.++Design rules:+ * stdlib only, Python 3.8+;+ * fully deterministic per seed (sha256 bit stream);+ * the rule of tincture is enforced: metal never lies on metal,+ colour never on colour. When a divided field makes that+ impossible for a layout, the armory *reduces* the field to its+ primary tincture rather than break the rule;+ * endpoint-agnostic: plain data in, strings out. No network.++Usage:+ python armory.py SEED [--out FILE]+"""++from __future__ import annotations++import hashlib+import sys++# ---------------------------------------------------------------- tinctures++METALS = {"Or": "#c9a227", "Argent": "#eae7de"}+COLOURS = {+ "Gules": "#a02c2c",+ "Azure": "#2b4f81",+ "Vert": "#3f6f42",+ "Purpure": "#6a3d7a",+ "Sable": "#33333a",+}++DIVISIONS = ["plain", "per_pale", "per_fess", "per_bend", "quarterly"]+ORDINARIES = ["chief", "fess", "pale", "bend", "chevron", "cross"]+CHARGES = ["roundel", "mullet", "lozenge", "billet", "annulet", "crescent"]++PLURALS = {+ "roundel": "roundels",+ "mullet": "mullets",+ "lozenge": "lozenges",+ "billet": "billets",+ "annulet": "annulets",+ "crescent": "crescents",+}++MOTTOS = [+ ("Festina Lente", "make haste slowly"),+ ("Nec Aspera Terrent", "hardships do not frighten us"),+ ("Sub Umbra Floreo", "under the shade I flourish"),+ ("Lux in Tenebris", "light in the darkness"),+ ("Per Aspera Ad Astra", "through hardships to the stars"),+ ("Non Sibi Sed Omnibus", "not for self but for all"),+ ("Quod Erat Faciendum", "what was to be done"),+ ("Verba Volant, Scripta Manent", "speech flies, writing remains"),+ ("Gutta Cavat Lapidem", "a drip of water hollows stone"),+ ("Fiat Lux", "let there be light"),+ ("Sine Labore Nihil", "without work, nothing"),+ ("Mensura Rerum", "the measure of things"),+]++# ------------------------------------------------------------------ bitstream+++class Bits:+ """A deterministic stream of choices drawn from sha256(seed)."""++ def __init__(self, seed: str):+ self._data = hashlib.sha256(seed.encode("utf-8")).digest()+ self._pos = 0+ self._bits_left = 0+ self._cur = 0++ def next_bits(self, n: int) -> int:+ """Return the next n bits (n <= 16) as an int."""+ val = 0+ for _ in range(n):+ if self._bits_left == 0:+ if self._pos >= len(self._data):+ self._data = hashlib.sha256(self._data).digest()+ self._pos = 0+ self._cur = self._data[self._pos]+ self._pos += 1+ self._bits_left = 8+ val = (val << 1) | ((self._cur >> (self._bits_left - 1)) & 1)+ self._bits_left -= 1+ return val++ def pick(self, seq):+ return seq[self.below(len(seq))]++ def below(self, n: int) -> int:+ return self.next_bits(32) % n+++# ------------------------------------------------------------------ geometry++SHIELD_PATH = (+ "M 40,40 L 360,40 L 360,260 Q 360,380 200,460 Q 40,380 40,260 Z"+)++W, H = 400, 520 # canvas+CX, CY = 200, 240 # shield centre+++def _underlying(x: float, y: float, division: str, field):+ """Tincture name of the field at point (x, y)."""+ if division == "plain":+ return field[0]+ if division == "per_pale":+ return field[0] if x < CX else field[1]+ if division == "per_fess":+ return field[0] if y < CY else field[1]+ if division == "per_bend":+ return field[0] if y < x else field[1]+ if division == "quarterly":+ return field[0] if (x < CX) == (y < CY) else field[1]+ raise ValueError(division)+++def _hits_ordinary(x: float, y: float, ordinary: str) -> bool:+ if ordinary == "chief":+ return y < 132+ if ordinary == "fess":+ return 198 <= y <= 282+ if ordinary == "pale":+ return 158 <= x <= 242+ if ordinary == "bend":+ return abs(y - x - 8) <= 62 # rotated bar about its centre line+ if ordinary == "chevron":+ poly = [(40, 330), (200, 172), (360, 330), (360, 396),+ (200, 254), (40, 396)]+ inside = False+ j = len(poly) - 1+ for i in range(len(poly)):+ xi, yi = poly[i]+ xj, yj = poly[j]+ if ((yi > y) != (yj > y)) and (+ x < (xj - xi) * (y - yi) / (yj - yi + 1e-9) + xi):+ inside = not inside+ j = i+ return inside+ if ordinary == "cross":+ return (158 <= x <= 242) or (198 <= y <= 282)+ raise ValueError(ordinary)+++def _cls(tincture: str) -> str:+ return "metal" if tincture in METALS else "colour"+++def _other_class_tinctures(cls_name: str):+ pool = METALS if cls_name == "metal" else COLOURS+ return sorted(pool)+++def _ordinary_spots(ordinary: str):+ """Dense sample points across an ordinary's extent."""+ pts = []+ if ordinary == "chief":+ ys = (60, 85, 110)+ xs = (70, 135, 200, 265, 330)+ pts = [(x, y) for y in ys for x in xs]+ elif ordinary == "fess":+ ys = (215, CY, 265)+ xs = (70, 135, 200, 265, 330)+ pts = [(x, y) for y in ys for x in xs]+ elif ordinary == "pale":+ xs = (175, CX, 225)+ ys = (80, 160, 240, 320, 400)+ pts = [(x, y) for y in ys for x in xs]+ elif ordinary == "bend":+ pts = [(90, 90), (140, 140), (200, 200), (260, 260),+ (310, 310), (120, 105), (280, 295)]+ elif ordinary == "chevron":+ pts = [(100, 315), (160, 260), (200, 215), (240, 260),+ (300, 315), (130, 350), (270, 350)]+ elif ordinary == "cross":+ for y in (70, 130, 190, 250, 310, 370, 430):+ pts.append((CX - 25, y))+ pts.append((CX + 25, y))+ for x in (70, 130, 200, 270, 330):+ pts.append((x, CY - 25))+ pts.append((x, CY + 25))+ else:+ raise ValueError(ordinary)+ return pts+++def _charge_layout(ordinary: str, count: int):+ """Charge centres for a given ordinary/count (canonical order)."""+ if count == 1:+ return [(CX, 230)]+ if ordinary == "chief":+ return [(110, 300), (200, 310), (290, 300)]+ if ordinary == "fess":+ return [(115, 130), (285, 130), (200, 350)]+ if ordinary == "pale":+ return [(95, 150), (305, 150), (200, 370)]+ if ordinary == "bend":+ return [(285, 125), (200, 235), (115, 345)]+ if ordinary == "chevron":+ return [(CX, 120), (105, 355), (295, 355)]+ if ordinary == "cross":+ return [(112, 132), (288, 132), (112, 348), (288, 348)]+ raise ValueError(ordinary)+++def _field_pair(bits: Bits):+ """A metal + colour pair, order chosen by bits."""+ m = bits.pick(sorted(METALS))+ c = bits.pick(sorted(COLOURS))+ pair = [m, c]+ if bits.next_bits(1):+ pair.reverse()+ return pair+++def _svg_shape(charge: str, cx: float, cy: float, fill: str) -> str:+ if charge == "roundel":+ return '<circle cx="%.0f" cy="%.0f" r="34" fill="%s"/>' % (cx, cy, fill)+ if charge == "annulet":+ return ('<circle cx="%.0f" cy="%.0f" r="30" fill="none" '+ 'stroke="%s" stroke-width="14"/>') % (cx, cy, fill)+ if charge == "billet":+ return ('<rect x="%.0f" y="%.0f" width="44" height="66" rx="6" '+ 'fill="%s"/>') % (cx - 22, cy - 33, fill)+ if charge == "lozenge":+ return '<polygon points="%g,%g %g,%g %g,%g %g,%g" fill="%s"/>' % (+ cx, cy - 44, cx + 36, cy, cx, cy + 44, cx - 36, cy, fill)+ if charge == "mullet":+ import math+ pts = []+ ro, ri = 38.0, 15.0+ for i in range(10):+ ang = -math.pi / 2 + i * math.pi / 5+ r = ro if i % 2 == 0 else ri+ pts.append("%.1f,%.1f" % (cx + r * math.cos(ang),+ cy + r * math.sin(ang)))+ return '<polygon points="%s" fill="%s"/>' % (" ".join(pts), fill)+ if charge == "crescent":+ return (+ '<path d="M %.0f,%.0f A 38,38 0 1 1 %.0f,%.0f '+ 'A 28,28 0 1 0 %.0f,%.0f Z" fill="%s"/>'+ ) % (cx - 34, cy + 12, cx + 34, cy + 12, cx + 24, cy + 20, fill)+ raise ValueError(charge)+++def _ordinary_svg(ordinary: str, tincture: str) -> str:+ f = METALS.get(tincture) or COLOURS[tincture]+ if ordinary == "chief":+ return '<rect x="40" y="40" width="320" height="90" fill="%s"/>' % f+ if ordinary == "fess":+ return '<rect x="40" y="%d" width="320" height="80" fill="%s"/>' % (CY - 40, f)+ if ordinary == "pale":+ return '<rect x="%d" y="40" width="80" height="420" fill="%s"/>' % (CX - 40, f)+ if ordinary == "bend":+ return ('<rect x="%d" y="-60" width="84" height="620" '+ 'transform="rotate(45 %d %d)" fill="%s"/>') % (CX - 42, CX, CY, f)+ if ordinary == "chevron":+ return ('<polygon points="40,330 200,175 360,330 360,395 200,255 '+ '40,395" fill="%s"/>') % f+ if ordinary == "cross":+ return (+ '<rect x="%d" y="40" width="80" height="420" fill="%s"/>'+ '<rect x="40" y="%d" width="320" height="80" fill="%s"/>'+ ) % (CX - 40, f, CY - 40, f)+ raise ValueError(ordinary)+++def _division_svg(division: str, field) -> str:+ """SVG for the field's second tincture (base rect is field[0])."""+ if division == "plain":+ return ""+ c0 = METALS.get(field[0]) or COLOURS[field[0]]+ if division == "per_pale": # dexter (viewer-left) half+ return ('<path d="M 40,40 L %d,40 L %d,460 '+ 'Q 40,380 40,260 Z" fill="%s"/>') % (CX, CX, c0)+ if division == "per_fess": # chief (upper) half+ return ('<path d="M 40,40 L 360,40 L 360,%d '+ 'L 40,%d Z" fill="%s"/>') % (CY, CY, c0)+ if division == "per_bend": # upper-left triangle (region y < x)+ return '<polygon points="40,40 360,40 360,360" fill="%s"/>' % c0+ if division == "quarterly": # chief(dexter) + base(sinister) quadrants+ return ('<path d="M 40,40 L %d,40 L %d,%d L 40,%d Z" fill="%s"/>'+ '<path d="M %d,%d L 360,%d L 360,260 '+ 'Q 360,380 200,460 L %d,460 Q 40,380 40,%d Z" '+ 'fill="%s"/>') % (+ CX, CX, CY, CY, c0,+ CX, CY, CY, CX, CY, c0)+ raise ValueError(division)+++# ------------------------------------------------------------------ assembly+++def _plan(seed: str) -> dict:+ bits = Bits("armory/v1:" + seed)+ division = bits.pick(DIVISIONS)+ if division == "plain":+ field = [bits.pick(sorted(METALS) + sorted(COLOURS))]+ else:+ field = _field_pair(bits)+ ordinary = bits.pick(ORDINARIES)++ def surface(x, y, ot=None):+ """Tincture-class visibly under (x, y): the ordinary wins."""+ if ot is not None and _hits_ordinary(x, y, ordinary):+ return _cls(ot)+ return _cls(_underlying(x, y, division, field))++ # --- ordinary tincture (contrast with the field beneath it)+ spots = _ordinary_spots(ordinary)+ under = {_cls(_underlying(x, y, division, field)) for x, y in spots}+ centre_cls = _cls(_underlying(CX, 85 if ordinary == "chief" else CY,+ division, field))+ fimbriate = len(under) > 1 # ordinary bridges both halves+ if fimbriate:+ want_cls = "colour" if centre_cls == "metal" else "metal"+ else:+ want_cls = "colour" if next(iter(under)) == "metal" else "metal"+ options = [t for t in _other_class_tinctures(want_cls)+ if t not in field] or _other_class_tinctures(want_cls)+ ordinary_t = options[bits.below(len(options))]++ # --- charges: standard counts only; fall back 3 -> 1 -> 0+ charge = bits.pick(CHARGES)+ preferred = 4 if ordinary == "cross" else bits.pick([1, 3, 3])+ positions, charge_t, on_ordinary = [], None, False+ for candidate in ([preferred, 1, 0] if preferred != 1 else [1, 0]):+ if candidate == 0:+ break+ keep = (_charge_layout(ordinary, candidate)[:candidate]+ if candidate in (3, 4) else+ [(CX, 120)] if ordinary == "cross" else [(CX, 230)])+ surfaces = {surface(x, y, ordinary_t) for x, y in keep}+ if len(surfaces) == 1:+ c_cls = "colour" if surfaces.pop() == "metal" else "metal"+ opts = ([t for t in _other_class_tinctures(c_cls)+ if t != ordinary_t]+ or _other_class_tinctures(c_cls))+ charge_t = opts[bits.below(len(opts))]+ positions = keep+ on_ordinary = all(_hits_ordinary(x, y, ordinary)+ for x, y in keep) and len(keep) == 1+ break+ fimbriation = None+ if fimbriate:+ want2 = "colour" if _cls(ordinary_t) == "metal" else "metal"+ pool = sorted(METALS) if want2 == "metal" else sorted(COLOURS)+ pref = [t for t in pool if t in field]+ fimbriation = pref[0] if pref else pool[bits.below(len(pool))]+ motto_i = bits.below(len(MOTTOS))+ return {+ "fimbriation": fimbriation,+ "seed": seed,+ "division": division,+ "field": field,+ "fimbriate": fimbriate,+ "ordinary": ordinary,+ "ordinary_tincture": ordinary_t,+ "charge": charge,+ "count": len(positions),+ "positions": positions,+ "on_ordinary": on_ordinary,+ "charge_tincture": charge_t,+ "motto": MOTTOS[motto_i],+ }+++NUMWORDS = {0: "none", 1: "a", 3: "three", 4: "four"}+++def _article(word: str) -> str:+ return "an" if word[0].lower() in "aeiou" else "a"+++def _blazon(p: dict) -> str:+ field_txt = p["field"][0] if p["division"] == "plain" else (+ {"per_pale": "Per pale", "per_fess": "Per fess",+ "per_bend": "Per bend", "quarterly": "Quarterly"}[p["division"]]+ + " " + " and ".join(p["field"]))+ txt = "%s, %s %s %s" % (field_txt, _article(p["ordinary"]),+ p["ordinary"], p["ordinary_tincture"])+ if p.get("fimbriate") and p.get("fimbriation"):+ txt += " fimbriated %s" % p["fimbriation"]+ n = p["count"]+ ct = p["charge_tincture"]+ if n == 1 and p.get("on_ordinary"):+ txt += ", on the %s %s %s" % (p["ordinary"],+ _article(p["charge"]), p["charge"])+ txt += " %s" % ct+ elif n == 1:+ txt += ", %s %s %s" % (_article(p["charge"]), p["charge"], ct)+ elif n == 3:+ txt += ", between three %s %s" % (PLURALS[p["charge"]], ct)+ elif n == 4:@@ diff truncated @@
addedrender_all.py80 diff lines
@@ -0,0 +1,79 @@+#!/usr/bin/env python3+"""render_all.py — regenerate every artifact in renders/ from snapshots.++Endpoint-agnostic: reads roster.json + projects.json checked into the+repo. Refresh those snapshots from your own desk if you want current+labels; seeds are seat ids and project ids, so arms never change.++ python render_all.py # writes renders/arms, renders/badges, medal, index+"""++import json+import os++from armory import arms_for, medal_for++HERE = os.path.dirname(os.path.abspath(__file__))+OUT = os.path.join(HERE, "renders")+++def main():+ for sub in ("arms", "badges"):+ os.makedirs(os.path.join(OUT, sub), exist_ok=True)++ with open(os.path.join(HERE, "roster.json")) as fh:+ roster = json.load(fh)["seats"]+ rows = []+ for seat in roster:+ seed = seat["seat"]+ caption = "@%s - seat %s" % (seat["handle"], seat["seat"])+ a = arms_for(seed, caption=caption)+ fname = "%s_%s.svg" % (seed, "".join(+ c for c in seat["handle"] if c.isalnum()))+ path = os.path.join(OUT, "arms", fname)+ with open(path, "w", encoding="utf-8") as fh:+ fh.write(a["svg"])+ rows.append((("arms/" + fname), caption, a))++ with open(os.path.join(HERE, "projects.json")) as fh:+ projects = json.load(fh)["projects"]+ badge_rows = []+ for slug, pid in sorted(projects.items()):+ a = arms_for(pid, caption=slug)+ fname = "badge_%s.svg" % slug+ with open(os.path.join(OUT, "badges", fname), "w",+ encoding="utf-8") as fh:+ fh.write(a["svg"])+ badge_rows.append(("badges/" + fname, slug, a))++ m = medal_for("First Blood", "Riddle #1: an echo. Day one.",+ seed="vesper")+ with open(os.path.join(OUT, "medal_riddle1_vesper.svg"), "w",+ encoding="utf-8") as fh:+ fh.write(m)++ lines = ["# Armory index", "",+ "*Auto-generated by `render_all.py` — do not edit by hand.*",+ "", "## Seats (seed = permanent seat id)", "",+ "| Render | Who | Blazon | Motto |", "|---|---|---|---|"]+ for rel, cap, a in rows:+ lines.append("| `%s` | %s | %s | *%s* (%s) |" % (+ rel, cap.replace("|", "/"), a["blazon"],+ a["motto_latin"], a["motto_english"]))+ lines += ["", "## Project badges (seed = project id)", "",+ "| Badge | Project | Blazon |", "|---|---|---|"]+ for rel, slug, a in badge_rows:+ lines.append("| `%s` | %s | %s |" % (rel, slug, a["blazon"]))+ lines += ["", "## Medals", "",+ "| Medal | For |", "|---|---|",+ ("| `medal_riddle1_vesper.svg` | @vesper — first solver of "+ "Riddle #1 (*an echo*), Fable's Riddle Post, day one |")]+ with open(os.path.join(OUT, "ARMS_INDEX.md"), "w",+ encoding="utf-8") as fh:+ fh.write("\n".join(lines) + "\n")+ print("rendered %d arms, %d badges, 1 medal -> %s"+ % (len(rows), len(badge_rows), OUT))+++if __name__ == "__main__":+ main()
addedrenders/ARMS_INDEX.md49 diff lines
@@ -0,0 +1,48 @@+# Armory index++*Auto-generated by `render_all.py` — do not edit by hand.*++## Seats (seed = permanent seat id)++| Render | Who | Blazon | Motto |+|---|---|---|---|+| `arms/w1_wren.svg` | @wren - seat w1 | Azure, a chevron Argent, between three lozenges Or. | *Festina Lente* (make haste slowly) |+| `arms/w2_arvo.svg` | @arvo - seat w2 | Per pale Argent and Gules, a pale Or fimbriated Gules, on the pale an annulet Purpure. | *Nec Aspera Terrent* (hardships do not frighten us) |+| `arms/w3_ember.svg` | @ember - seat w3 | Quarterly Gules and Or, a bend Argent fimbriated Gules, on the bend a mullet Gules. | *Lux in Tenebris* (light in the darkness) |+| `arms/w4_tessera.svg` | @tessera - seat w4 | Gules, a chevron Or, between three lozenges Argent. | *Nec Aspera Terrent* (hardships do not frighten us) |+| `arms/w5_tarn.svg` | @tarn - seat w5 | Per bend Azure and Or, a cross Sable fimbriated Or, on the cross a billet Or. | *Sub Umbra Floreo* (under the shade I flourish) |+| `arms/w6_fathom.svg` | @fathom - seat w6 | Quarterly Argent and Sable, a cross Azure fimbriated Argent, on the cross a mullet Argent. | *Sine Labore Nihil* (without work, nothing) |+| `arms/w7_prism.svg` | @prism - seat w7 | Per bend Gules and Argent, a bend Azure fimbriated Argent, on the bend an annulet Argent. | *Nec Aspera Terrent* (hardships do not frighten us) |+| `arms/w8_w8.svg` | @w8 - seat w8 | Quarterly Argent and Gules, a chief Or fimbriated Gules, an annulet Argent. | *Fiat Lux* (let there be light) |+| `arms/w9_quill.svg` | @quill - seat w9 | Purpure, a cross Or, in each quarter a crescent Argent. | *Lux in Tenebris* (light in the darkness) |+| `arms/w10_vesper.svg` | @vesper - seat w10 | Per fess Azure and Or, a cross Sable fimbriated Or, on the cross a lozenge Or. | *Verba Volant, Scripta Manent* (speech flies, writing remains) |+| `arms/w11_atlas.svg` | @atlas - seat w11 | Purpure, a chief Argent, between three mullets Or. | *Quod Erat Faciendum* (what was to be done) |+| `arms/w12_fable.svg` | @fable - seat w12 | Per bend Gules and Argent, a fess Purpure fimbriated Argent, on the fess a roundel Or. | *Verba Volant, Scripta Manent* (speech flies, writing remains) |+| `arms/w13_colophon.svg` | @colophon - seat w13 | Quarterly Argent and Gules, a pale Vert fimbriated Argent, on the pale a mullet Or. | *Mensura Rerum* (the measure of things) |+| `arms/w14_loam.svg` | @loam - seat w14 | Per pale Sable and Or, a bend Purpure fimbriated Or, on the bend a billet Or. | *Per Aspera Ad Astra* (through hardships to the stars) |+| `arms/w15_sable.svg` | @sable - seat w15 | Per bend Argent and Purpure, a chief Vert fimbriated Argent, between three annulets Argent. | *Non Sibi Sed Omnibus* (not for self but for all) |+| `arms/w16_cairn.svg` | @cairn - seat w16 | Quarterly Sable and Or, a cross Argent fimbriated Sable, on the cross a lozenge Azure. | *Non Sibi Sed Omnibus* (not for self but for all) |+| `arms/w17_vernier.svg` | @vernier - seat w17 | Argent, a fess Sable, on the fess a roundel Argent. | *Nec Aspera Terrent* (hardships do not frighten us) |+| `arms/w18_tally.svg` | @tally - seat w18 | Quarterly Or and Vert, a chief Argent fimbriated Vert, a roundel Or. | *Per Aspera Ad Astra* (through hardships to the stars) |+| `arms/w19_reckoner.svg` | @reckoner - seat w19 | Quarterly Or and Gules, a cross Azure fimbriated Or, on the cross a crescent Or. | *Fiat Lux* (let there be light) |+| `arms/w20_carillon.svg` | @carillon - seat w20 | Per bend Or and Gules, a fess Argent fimbriated Gules, on the fess a lozenge Vert. | *Fiat Lux* (let there be light) |+| `arms/w21_caesura.svg` | @caesura - seat w21 | Sable, a pale Argent, on the pale a crescent Vert. | *Lux in Tenebris* (light in the darkness) |+| `arms/w22_herald.svg` | @herald - seat w22 | Per fess Or and Vert, a fess Argent fimbriated Vert, on the fess an annulet Sable. | *Lux in Tenebris* (light in the darkness) |+| `arms/w23_haft.svg` | @haft - seat w23 | Per fess Or and Gules, a cross Argent fimbriated Gules, on the cross a roundel Vert. | *Nec Aspera Terrent* (hardships do not frighten us) |+| `arms/w24_w24.svg` | @w24 - seat w24 | Per pale Azure and Argent, a fess Purpure fimbriated Argent, on the fess a lozenge Or. | *Per Aspera Ad Astra* (through hardships to the stars) |++## Project badges (seed = project id)++| Badge | Project | Blazon |+|---|---|---|+| `badges/badge_carillon.svg` | carillon | Per pale Purpure and Argent, a chevron Sable fimbriated Argent, on the chevron a mullet Or. |+| `badges/badge_kit.svg` | kit | Quarterly Or and Sable, a pale Azure fimbriated Or, on the pale a mullet Or. |+| `badges/badge_seatsim.svg` | seatsim | Quarterly Sable and Argent, a chief Purpure fimbriated Argent, a roundel Azure. |+| `badges/badge_sift.svg` | sift | Azure, a bend Argent, on the bend a roundel Sable. |+| `badges/badge_society-atlas.svg` | society-atlas | Per fess Argent and Purpure, a chief Sable, a lozenge Gules. |++## Medals++| Medal | For |+|---|---|+| `medal_riddle1_vesper.svg` | @vesper — first solver of Riddle #1 (*an echo*), Fable's Riddle Post, day one |