mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(examples): Understone v0.9 — colour roles for every object type
Graphics polish: distinct terrain and structures now read by COLOUR on the Watch, not only by glyph. One unified palette, shared by every world — the fix is to grow the set of distinct object-type roles, not to fork per-world. - Roads were the tell: road shared the "floor" green with grass, so a path vanished into the meadow on the lobby TV. Likewise forest shared "tree", the three town buildings all shared "town", and the Cinder Wastes' molten slag borrowed "water" and rendered BLUE. Each is now its own role: road (stone), forest (lush green) with scrub (its barren ember-brown counterpart for volcanic/desert dense terrain that must NOT read as woods), lava (molten orange), barren (wasteland taupe), and inn/shop/ healer split out of the generic town. - Both worlds remap onto the shared vocabulary; in each, no two distinct terrain/building types share a colour. A live render caught the Cinder cinder-fields rendering green under the generic "forest" role — hence the scrub role, so the volcanic waste reads warm. The text frame renderer stays monochrome (it never read colour), so frames and goldens are untouched — this is Watch-only. - The bug class is now closed by construction: a test asserts the Watch PALETTE carries a hex for EVERY Color role, so a role can never ship unpaintable and silently fall back (which is exactly how road hid). - Color.assignable() is the single source for the overlay-vs-assignable split (runtime actor/item colours and the DEFAULT fallback are not author-pickable); the authoring manual's colour vocabulary generates from it, so it can't drift. Tests 373 -> 382. floor/tree/forest are three greens kept deliberately distinct (forest is olive-hued); verified on a real render along with the scrub fix.
This commit is contained in:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "understone"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "Understone — a BBS-style ANSI door game served over MCP."
|
||||
requires-python = ">=3.11"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -183,6 +183,42 @@ def test_cli_newpack_authoring_md_states_color_advisory_and_spawn_walkable(
|
||||
assert "must be on walkable terrain" in manual
|
||||
|
||||
|
||||
def test_cli_newpack_authoring_md_color_roles_generated_from_enum(tmp_path: Path) -> None:
|
||||
"""AUTHORING.md's colour-role vocabulary is generated from the Color enum.
|
||||
|
||||
The v0.9 fix: the assignable roles were hand-listed (and went stale — road
|
||||
and the per-building roles were missing). They are now generated from
|
||||
``Color.assignable()`` — the single source for the overlay-vs-assignable
|
||||
split — so the manual lists exactly what the Watch can paint and cannot
|
||||
drift. This asserts the NEW roles appear, that every assignable enum role
|
||||
appears, and that the non-assignable roles (overlays + DEFAULT) are NOT
|
||||
offered as author-assignable.
|
||||
"""
|
||||
from understone.screen.palette import Color
|
||||
|
||||
dest = tmp_path / "mypack"
|
||||
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
|
||||
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
|
||||
|
||||
# A sampling of the new v0.9 roles is offered in the manual, backticked.
|
||||
for role in ("road", "forest", "lava", "barren", "inn", "shop", "healer"):
|
||||
assert f"`{role}`" in manual, f"new colour role {role!r} missing from manual"
|
||||
|
||||
# EVERY assignable enum role appears (generated, so the full set is present).
|
||||
color_section = manual[manual.index("`color` — a palette role string") :].split("###", 1)[0]
|
||||
for role in Color.assignable():
|
||||
assert f"`{role.value}`" in manual, f"assignable role {role.value!r} missing from manual"
|
||||
|
||||
# The non-assignable roles (runtime overlays + the DEFAULT fallback) are NOT
|
||||
# offered as terrain/location colours.
|
||||
non_assignable = {c for c in Color} - set(Color.assignable())
|
||||
assert Color.DEFAULT in non_assignable # the fallback is not author-pickable
|
||||
for role in non_assignable:
|
||||
assert f"`{role.value}`" not in color_section, (
|
||||
f"non-assignable role {role.value!r} wrongly offered as author-assignable"
|
||||
)
|
||||
|
||||
|
||||
def test_cli_newpack_authoring_md_has_validate_coverage_split(tmp_path: Path) -> None:
|
||||
"""AUTHORING.md honestly separates machine-enforced rules from eyeball-only.
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ import understone
|
||||
|
||||
|
||||
def test_version_present() -> None:
|
||||
assert understone.__version__ == "0.8.0"
|
||||
assert understone.__version__ == "0.9.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ Vale live" line that appears only when a Game carries a watch URL.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -20,6 +21,7 @@ from understone.engine.log import Event
|
||||
from understone.engine.rng import GameRNG
|
||||
from understone.game import Game
|
||||
from understone.persistence import Store
|
||||
from understone.screen.palette import Color
|
||||
from understone.world.loader import load_world
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -72,8 +74,6 @@ def test_world_payload_legend_is_complete(world: World) -> None:
|
||||
glyphs = {ch for row in rows for ch in row}
|
||||
assert glyphs <= set(legend)
|
||||
# And every legend colour is a real palette colour name (no stray roles).
|
||||
from understone.screen.palette import Color
|
||||
|
||||
valid = {c.value for c in Color}
|
||||
assert set(legend.values()) <= valid
|
||||
|
||||
@@ -112,6 +112,141 @@ def test_world_payload_carries_reskinned_glyphs(world: World) -> None:
|
||||
assert by_name["The Understone Deep"] == "∩"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.9 colour-role split — the payload now carries the EXPANDED vocabulary, so
|
||||
# distinct terrain/building types read by hue on the Watch and not just by glyph.
|
||||
# These pin the literal fixes: road no longer shares grass's colour, forest no
|
||||
# longer shares tree's, the town buildings each carry their own role, and the
|
||||
# Cinder slag is lava (orange), no longer water (blue).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes"
|
||||
|
||||
|
||||
def _terrain_kinds(world: World) -> dict[str, str]:
|
||||
"""Return the distinct terrain kinds in *world* as ``{key: colour role}``.
|
||||
|
||||
``world.terrain`` is the painted 2-D grid (one ``TerrainDef`` per cell); the
|
||||
distinct kinds are recovered by deduplicating it on ``key``. Every kind in a
|
||||
shipped world appears on the map, so this sees all of them.
|
||||
"""
|
||||
kinds: dict[str, str] = {}
|
||||
for row in world.terrain:
|
||||
for cell in row:
|
||||
kinds[cell.key] = cell.color
|
||||
return kinds
|
||||
|
||||
|
||||
def _legend_for_terrain_key(world: World, key: str) -> str:
|
||||
"""Return the legend colour the payload carries for terrain ``key``.
|
||||
|
||||
Resolves the terrain key to its glyph, then reads that glyph's colour out of
|
||||
the built payload's legend — so the assertion is on what the Watch receives,
|
||||
not on the raw JSON.
|
||||
"""
|
||||
payload = watch.build_world_payload(world)
|
||||
legend = payload["legend"]
|
||||
assert isinstance(legend, dict)
|
||||
glyph = next(cell.glyph for row in world.terrain for cell in row if cell.key == key)
|
||||
return legend[glyph]
|
||||
|
||||
|
||||
def test_vale_payload_road_is_not_floor(world: World) -> None:
|
||||
"""REGRESSION (the literal bug the slice fixes): road has its OWN colour.
|
||||
|
||||
Before v0.9 the Vale road shared ``floor`` with grass, so a path was
|
||||
indistinguishable from open ground on the Watch. The road now carries
|
||||
``road``; grass keeps ``floor``; they must differ.
|
||||
"""
|
||||
road = _legend_for_terrain_key(world, "road")
|
||||
grass = _legend_for_terrain_key(world, "grass")
|
||||
assert road == "road"
|
||||
assert grass == "floor"
|
||||
assert road != grass
|
||||
|
||||
|
||||
def test_vale_payload_forest_is_not_tree(world: World) -> None:
|
||||
"""REGRESSION: forest has its OWN colour, no longer shared with tree.
|
||||
|
||||
Dense forest scrub used to share ``tree`` with the tree wall, so the two
|
||||
read identically. Forest now carries ``forest``; tree keeps ``tree``.
|
||||
"""
|
||||
forest = _legend_for_terrain_key(world, "forest")
|
||||
tree = _legend_for_terrain_key(world, "tree")
|
||||
assert forest == "forest"
|
||||
assert tree == "tree"
|
||||
assert forest != tree
|
||||
|
||||
|
||||
def test_vale_payload_buildings_carry_distinct_roles(world: World) -> None:
|
||||
"""Each Vale town building rides its own role (inn/shop/healer), not ``town``."""
|
||||
payload = watch.build_world_payload(world)
|
||||
by_name = {loc["name"]: loc["color"] for loc in payload["locations"]} # type: ignore[index,union-attr]
|
||||
assert by_name["The Sleeping Drake"] == "inn"
|
||||
assert by_name["Gravel & Sons Outfitters"] == "shop"
|
||||
assert by_name["The Quiet Shrine"] == "healer"
|
||||
assert by_name["The Understone Deep"] == "dungeon"
|
||||
# No two distinct buildings share a colour role.
|
||||
roles = list(by_name.values())
|
||||
assert len(set(roles)) == len(roles)
|
||||
|
||||
|
||||
def test_cinder_payload_slag_is_lava_not_water() -> None:
|
||||
"""The Cinder slag carries ``lava`` (orange), never ``water`` (blue) again.
|
||||
|
||||
This is the Cinder half of the bug: molten slag shared ``water``, so the
|
||||
lava rendered BLUE on the Watch. After the remap the legend carries ``lava``
|
||||
and ``water`` appears NOWHERE in the Cinder payload (no water in this world).
|
||||
"""
|
||||
cinder = load_world(CINDER)
|
||||
payload = watch.build_world_payload(cinder)
|
||||
legend = payload["legend"]
|
||||
assert isinstance(legend, dict)
|
||||
assert _legend_for_terrain_key(cinder, "slag") == "lava"
|
||||
assert "water" not in legend.values()
|
||||
|
||||
|
||||
def test_cinder_payload_carries_expanded_roles() -> None:
|
||||
"""The Cinder terrain reads by hue: ash→barren, basalt→road, cinder→scrub.
|
||||
|
||||
Cinder-fields use ``scrub`` (dusky ember-brown), NOT ``forest`` (green) —
|
||||
a volcanic waste must not render as lush woods. ``forest`` is for green
|
||||
worlds; ``scrub`` is its barren counterpart.
|
||||
"""
|
||||
cinder = load_world(CINDER)
|
||||
assert _legend_for_terrain_key(cinder, "ash") == "barren"
|
||||
assert _legend_for_terrain_key(cinder, "basalt") == "road"
|
||||
assert _legend_for_terrain_key(cinder, "cinder") == "scrub"
|
||||
legend = watch.build_world_payload(cinder)["legend"]
|
||||
assert isinstance(legend, dict)
|
||||
assert "forest" not in legend.values() # no green woods in a volcanic waste
|
||||
# Obsidian spire reuses the wall role (a rock barrier), same as caldera.
|
||||
assert _legend_for_terrain_key(cinder, "spire") == "wall"
|
||||
assert _legend_for_terrain_key(cinder, "caldera") == "wall"
|
||||
|
||||
|
||||
def test_both_worlds_terrain_roles_are_distinct_per_world() -> None:
|
||||
"""No two DISTINCT terrain types share a colour role within a world.
|
||||
|
||||
The point of the slice: after the remap each terrain kind reads by its own
|
||||
hue. (A role MAY be shared by two types that are deliberately the same
|
||||
barrier — spire/caldera both ``wall`` in Cinder — so this checks distinct
|
||||
KEYS that map to the same role are only the intended wall pair.)
|
||||
"""
|
||||
for world_dir, allowed_shared in (
|
||||
(PACK, set()),
|
||||
(CINDER, {("caldera", "spire")}),
|
||||
):
|
||||
w = load_world(world_dir)
|
||||
by_role: dict[str, list[str]] = {}
|
||||
for key, role in _terrain_kinds(w).items():
|
||||
by_role.setdefault(role, []).append(key)
|
||||
for role, keys in by_role.items():
|
||||
if len(keys) > 1:
|
||||
pair = tuple(sorted(keys))
|
||||
assert pair in allowed_shared, f"unexpected shared role {role!r}: {keys}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State payload (dynamic)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -346,3 +481,57 @@ def test_watch_html_has_day_phase_machinery() -> None:
|
||||
assert ".map-frame.night" in html
|
||||
assert ".map-frame.twilight" in html
|
||||
assert "Noto Sans Mono" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PALETTE completeness — the v0.9 invariant that kills the "silent fallback"
|
||||
# bug class. The road bug existed because a Color role with no hex in the JS
|
||||
# PALETTE map fell back to default; this pins that EVERY role has a hex.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _watch_palette_keys() -> set[str]:
|
||||
"""Parse the JS ``var PALETTE = { ... }`` map out of WATCH_HTML, return its keys.
|
||||
|
||||
The map uses bare (unquoted) JS identifier keys — ``road: "#b89a6a",`` — so
|
||||
this slices the object literal and collects every ``key:`` token. Keeping the
|
||||
parse here (not a hard-coded list) means the test reads whatever the page
|
||||
actually ships, so a typo'd or dropped key surfaces as a missing role.
|
||||
"""
|
||||
html = watch.WATCH_HTML
|
||||
start = html.index("var PALETTE = {")
|
||||
body = html[start : html.index("};", start)]
|
||||
# Each entry is `<ident>: "<hex>"`; capture the identifier before the colon.
|
||||
return set(re.findall(r"(\w+)\s*:\s*\"#", body))
|
||||
|
||||
|
||||
def test_watch_palette_covers_every_color_role() -> None:
|
||||
"""EVERY Color enum value has an entry in the JS PALETTE map — no fallbacks.
|
||||
|
||||
This is the literal fix for the road bug: a shipped role with no hex paints
|
||||
as ``default`` silently. Asserting ``{c.value} <= palette_keys`` means adding
|
||||
a Color without a Watch hex trips here instead of shipping a grey/green road.
|
||||
"""
|
||||
palette_keys = _watch_palette_keys()
|
||||
roles = {c.value for c in Color}
|
||||
missing = roles - palette_keys
|
||||
assert not missing, f"Color roles with no PALETTE hex (silent fallback): {sorted(missing)}"
|
||||
|
||||
|
||||
def test_watch_palette_distinct_new_terrain_hexes() -> None:
|
||||
"""The expanded terrain roles carry DISTINCT hexes (the point of the slice).
|
||||
|
||||
A guard that the seven new roles didn't accidentally collapse onto one hex
|
||||
(which would re-introduce the very "two types, one colour" bug v0.9 fixes).
|
||||
Parsed straight from the shipped map.
|
||||
"""
|
||||
html = watch.WATCH_HTML
|
||||
start = html.index("var PALETTE = {")
|
||||
body = html[start : html.index("};", start)]
|
||||
pairs = dict(re.findall(r"(\w+)\s*:\s*\"(#[0-9a-fA-F]{6})\"", body))
|
||||
new_roles = ["road", "forest", "lava", "barren", "inn", "shop", "healer"]
|
||||
hexes = [pairs[r] for r in new_roles]
|
||||
assert all(r in pairs for r in new_roles), "a new v0.9 role is missing its hex"
|
||||
assert len(set(hexes)) == len(hexes), f"new roles share a hex: {hexes}"
|
||||
# The molten role must NOT reuse water's blue (the Cinder slag bug).
|
||||
assert pairs["lava"] != pairs["water"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.8.0"
|
||||
__version__ = "0.9.0"
|
||||
|
||||
@@ -203,6 +203,7 @@ def build_authoring_md() -> str:
|
||||
"""
|
||||
md = _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands())
|
||||
md = md.replace("{{PALETTE}}", _render_palette())
|
||||
md = md.replace("{{COLOR_ROLES}}", _render_color_roles())
|
||||
return md.replace("{{VALIDATE_COVERAGE}}", _render_validate_coverage())
|
||||
|
||||
|
||||
@@ -293,6 +294,24 @@ def _render_palette() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_color_roles() -> str:
|
||||
"""Render the author-assignable colour roles, generated from the Color enum.
|
||||
|
||||
The Watch knows how to paint exactly the roles in ``screen.palette.Color``;
|
||||
``Color.assignable()`` is the single source for which of those an author may
|
||||
put on terrain or a location (the runtime overlay roles an actor/item wears,
|
||||
and the DEFAULT fallback, are filtered out there). Generated from the enum,
|
||||
so the documented vocabulary can never drift from what the Watch can
|
||||
actually colour — the same can't-drift discipline as the bands and the
|
||||
safe-glyph palette. ``color`` itself stays advisory: the loader does not
|
||||
validate it, so a typo is harmless and an unknown role just paints as the
|
||||
default; these are simply the roles the Watch recognises.
|
||||
"""
|
||||
from understone.screen.palette import Color
|
||||
|
||||
return ", ".join(f"`{role.value}`" for role in Color.assignable())
|
||||
|
||||
|
||||
def _render_validate_coverage() -> str:
|
||||
"""Render the list of rules the loader actually enforces, generated from it.
|
||||
|
||||
@@ -403,11 +422,13 @@ An object whose keys are the single-character legend symbols used in the map.
|
||||
* `encounter_rate` — `0.0`..`1.0`, the per-step chance a walk rolls the event
|
||||
table on this terrain.
|
||||
* `color` — a palette role string. It is **advisory and not validated**: the
|
||||
loader stores it but the v1 text renderer draws glyphs only, so any string
|
||||
loads and an unrecognised role simply maps to the default at render time. The
|
||||
roles a future colour renderer will recognise are `floor`, `wall`, `water`,
|
||||
`tree`, `town`, and `dungeon`; pick the closest, but a typo here is harmless,
|
||||
not a load error.
|
||||
loader stores it but the text frame draws glyphs only (it is monochrome), so
|
||||
any string loads and an unrecognised role simply maps to the default at render
|
||||
time. Where colour DOES show is the live Watch page, which paints each role a
|
||||
distinct hue. The roles the Watch knows how to paint — pick the closest fit —
|
||||
are: {{COLOR_ROLES}}. A typo here is harmless, not a load error; it just
|
||||
paints as the default. The four runtime overlay colours (the hero, rival
|
||||
players, monsters, dropped items) are set by the engine, not assignable here.
|
||||
|
||||
### `monsters.json`
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Colour vocabulary for cells.
|
||||
|
||||
Colours are *stored* on cells but never rendered in v1 — the text
|
||||
renderer emits glyphs only. The enum exists so a future ANSI renderer can
|
||||
map roles to SGR codes without touching the grid model.
|
||||
Colours are *stored* on cells and rendered by the live Watch page, which maps
|
||||
each role to a hue (see ``watch.PALETTE``). The text frame renderer stays
|
||||
monochrome — it emits glyphs only — so a cell's colour rides the grid model
|
||||
untouched until a colour-aware renderer (the Watch today, an ANSI terminal
|
||||
later) reads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +13,15 @@ from enum import Enum
|
||||
|
||||
|
||||
class Color(Enum):
|
||||
"""Semantic colour roles for grid cells."""
|
||||
"""Semantic colour roles for grid cells.
|
||||
|
||||
One global vocabulary, shared by every world — there are no per-world or
|
||||
per-theme palettes. Roles are split into two families: the runtime overlay
|
||||
colours an actor or item wears (``PLAYER``/``OTHER_PLAYER``/``MONSTER``/
|
||||
``ITEM``) and the author-assignable terrain/location roles a pack paints its
|
||||
map with (everything else). A future colour renderer maps each role to a
|
||||
hue; the Watch already does (see ``watch.PALETTE``).
|
||||
"""
|
||||
|
||||
DEFAULT = "default"
|
||||
WALL = "wall"
|
||||
@@ -24,3 +34,32 @@ class Color(Enum):
|
||||
TREE = "tree"
|
||||
TOWN = "town"
|
||||
DUNGEON = "dungeon"
|
||||
# Expanded terrain/location roles (v0.9) — so distinct types read by hue and
|
||||
# not only by glyph. ROAD splits paths off FLOOR; FOREST is lush dense
|
||||
# vegetation; SCRUB is its barren counterpart — rough, non-lush dense terrain
|
||||
# (volcanic cinder, desert scrub) that must NOT read as green woods; LAVA
|
||||
# gives molten ground its own orange (no longer mis-sharing WATER's blue);
|
||||
# BARREN gives open wasteland ground a taupe; INN/SHOP/HEALER give each town
|
||||
# building its own hue (TOWN stays as a generic fallback).
|
||||
ROAD = "road"
|
||||
FOREST = "forest"
|
||||
SCRUB = "scrub"
|
||||
LAVA = "lava"
|
||||
BARREN = "barren"
|
||||
INN = "inn"
|
||||
SHOP = "shop"
|
||||
HEALER = "healer"
|
||||
|
||||
@classmethod
|
||||
def assignable(cls) -> list[Color]:
|
||||
"""The roles a pack may paint terrain or a location with.
|
||||
|
||||
One source of truth for the overlay-vs-assignable split. Excludes the
|
||||
runtime overlay colours an actor/item wears (``PLAYER``/
|
||||
``OTHER_PLAYER``/``MONSTER``/``ITEM``) and the ``DEFAULT`` fallback —
|
||||
none of which an author assigns. Consumers (the authoring manual and
|
||||
its test) read this so the documented vocabulary can never drift from
|
||||
the enum. Returned in definition order.
|
||||
"""
|
||||
overlay = {cls.DEFAULT, cls.PLAYER, cls.OTHER_PLAYER, cls.MONSTER, cls.ITEM}
|
||||
return [role for role in cls if role not in overlay]
|
||||
|
||||
@@ -380,8 +380,11 @@ _WATCH_HTML_TEMPLATE = """\
|
||||
}
|
||||
}
|
||||
|
||||
// Palette colour-name -> phosphor-tinted hex. Mirrors understone.screen.palette
|
||||
// Color values; the base map is coloured from this, never from the server.
|
||||
// Palette colour-name -> phosphor-tinted hex. ONE global map, shared by every
|
||||
// world (no per-world or per-theme palettes). Mirrors understone.screen.palette
|
||||
// Color values 1:1 — a guard test asserts every Color role has an entry here,
|
||||
// so a shipped role can never silently fall back to default. The base map is
|
||||
// coloured from this, never from the server.
|
||||
var PALETTE = {
|
||||
default: "#7dffa0",
|
||||
wall: "#5a6b60",
|
||||
@@ -393,7 +396,16 @@ _WATCH_HTML_TEMPLATE = """\
|
||||
water: "#4aa6c8",
|
||||
tree: "#3fae6a",
|
||||
town: "#ffd089",
|
||||
dungeon: "#c98bff"
|
||||
dungeon: "#c98bff",
|
||||
// v0.9 expanded terrain/location roles, chosen for hue separation:
|
||||
road: "#b89a6a",
|
||||
forest: "#6a9f3f",
|
||||
scrub: "#9c6038",
|
||||
lava: "#ff7a3c",
|
||||
barren: "#9a8b7a",
|
||||
inn: "#ff9d4d",
|
||||
shop: "#ffd24d",
|
||||
healer: "#5fd6b0"
|
||||
};
|
||||
|
||||
function colorFor(name) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"kind": "inn",
|
||||
"name": "The Sleeping Drake",
|
||||
"glyph": "⌂",
|
||||
"color": "town",
|
||||
"color": "inn",
|
||||
"actions": ["rest", "gamble", "leave"],
|
||||
"flavor": [
|
||||
"Lamplight pools on worn oak tables.",
|
||||
@@ -16,7 +16,7 @@
|
||||
"kind": "shop",
|
||||
"name": "Gravel & Sons Outfitters",
|
||||
"glyph": "$",
|
||||
"color": "town",
|
||||
"color": "shop",
|
||||
"actions": ["buy", "sell", "forge", "leave"],
|
||||
"flavor": [
|
||||
"Racks of steel and leather line the walls.",
|
||||
@@ -30,7 +30,7 @@
|
||||
"kind": "healer",
|
||||
"name": "The Quiet Shrine",
|
||||
"glyph": "✚",
|
||||
"color": "town",
|
||||
"color": "healer",
|
||||
"actions": ["heal", "leave"],
|
||||
"flavor": [
|
||||
"Incense curls beneath a still blue flame.",
|
||||
|
||||
@@ -25,14 +25,14 @@
|
||||
"glyph": "=",
|
||||
"walkable": true,
|
||||
"encounter_rate": 0.02,
|
||||
"color": "floor"
|
||||
"color": "road"
|
||||
},
|
||||
"f": {
|
||||
"key": "forest",
|
||||
"glyph": "↑",
|
||||
"walkable": true,
|
||||
"encounter_rate": 0.25,
|
||||
"color": "tree"
|
||||
"color": "forest"
|
||||
},
|
||||
"#": {
|
||||
"key": "wall",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"kind": "inn",
|
||||
"name": "The Forge-Rest",
|
||||
"glyph": "⌂",
|
||||
"color": "town",
|
||||
"color": "inn",
|
||||
"actions": ["rest", "gamble", "leave"],
|
||||
"flavor": [
|
||||
"Heat-bricked walls hold back the ashfall outside.",
|
||||
@@ -16,7 +16,7 @@
|
||||
"kind": "shop",
|
||||
"name": "The Slag Market",
|
||||
"glyph": "$",
|
||||
"color": "town",
|
||||
"color": "shop",
|
||||
"actions": ["buy", "sell", "forge", "leave"],
|
||||
"flavor": [
|
||||
"Stalls of cooled obsidian and scavenged plate crowd the stone.",
|
||||
@@ -30,7 +30,7 @@
|
||||
"kind": "healer",
|
||||
"name": "The Ember Shrine",
|
||||
"glyph": "✚",
|
||||
"color": "town",
|
||||
"color": "healer",
|
||||
"actions": ["heal", "leave"],
|
||||
"flavor": [
|
||||
"A still blue pilot-flame burns at the heart of the shrine.",
|
||||
|
||||
@@ -4,35 +4,35 @@
|
||||
"glyph": "░",
|
||||
"walkable": true,
|
||||
"encounter_rate": 0.1,
|
||||
"color": "floor"
|
||||
"color": "barren"
|
||||
},
|
||||
"A": {
|
||||
"key": "spire",
|
||||
"glyph": "▲",
|
||||
"walkable": false,
|
||||
"encounter_rate": 0.0,
|
||||
"color": "tree"
|
||||
"color": "wall"
|
||||
},
|
||||
"~": {
|
||||
"key": "slag",
|
||||
"glyph": "≈",
|
||||
"walkable": false,
|
||||
"encounter_rate": 0.0,
|
||||
"color": "water"
|
||||
"color": "lava"
|
||||
},
|
||||
"=": {
|
||||
"key": "basalt",
|
||||
"glyph": "=",
|
||||
"walkable": true,
|
||||
"encounter_rate": 0.02,
|
||||
"color": "floor"
|
||||
"color": "road"
|
||||
},
|
||||
"c": {
|
||||
"key": "cinder",
|
||||
"glyph": "▒",
|
||||
"walkable": true,
|
||||
"encounter_rate": 0.25,
|
||||
"color": "tree"
|
||||
"color": "scrub"
|
||||
},
|
||||
"#": {
|
||||
"key": "caldera",
|
||||
|
||||
Reference in New Issue
Block a user