diff --git a/.gitignore b/.gitignore index 54487b4..8ac0bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ UTILITIES/ .beads/ .beads-ui/ +# Goose +.goose/ + # Knowledge system (local per user, not shared) /CATALOG.md /TAGS.md diff --git a/AGENTS.md b/AGENTS.md index bb3e934..f673ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_PKCE_IMPLEMENTATION_NOTE The **Town Wall** (`gtwall`) is a broadcast communication tool for real-time coordination between delegates. Per-session walls, position-tracked per reader. +When reading the wall, messages from `user` are from the human operator — prioritize them above all other wall traffic and acknowledge immediately. + **Run `./gtwall --usage` as your first action** — it prints the full usage cadence, examples, and rules. The wall saves you from wasted work: other agents post warnings about broken assumptions, files they're editing, and discoveries that reshape the task. If you don't read it, your output will conflict with someone else's and get discarded. ### Wrap-Up Protocol diff --git a/README.md b/README.md index cdac5c3..a8df71f 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,11 @@ When three or more delegates share a task and coordinate via gtwall, that's a *f - **gtwall** — the Town Wall; broadcast channel for real-time delegate coordination - **Telepathy** — orchestrator → delegate push messages for urgent paging -There's a real-time dashboard for watching your flock work — just ask goose to launch it. +There's a real-time dashboard for watching your flock work (yes, they're actual geese on a map) — just ask goose to launch it. + +

+ Goosetown Village Dashboard — real-time agent coordination view +

Learn more in [AGENTS.md](AGENTS.md). diff --git a/goosetown-dashboard.png b/goosetown-dashboard.png new file mode 100644 index 0000000..a731f37 Binary files /dev/null and b/goosetown-dashboard.png differ diff --git a/scripts/goosetown-ui b/scripts/goosetown-ui index 539f791..67aca7f 100755 --- a/scripts/goosetown-ui +++ b/scripts/goosetown-ui @@ -9,10 +9,39 @@ from pathlib import Path import uvicorn from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse, RedirectResponse, StreamingResponse from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles + +# Security model: This server is designed for localhost-only use (binds to 127.0.0.1). +# It has no authentication, no CORS restrictions, and serves all session data to any +# connecting client. Do not expose to a network without adding auth and access controls. + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + # NOTE: 'unsafe-inline' for style-src is required because lit-html applies + # inline styles for dynamic transforms (goose animation positions) and + # layout. Removing it would require refactoring all inline styles to CSS + # classes or JS-managed stylesheets. + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src https://fonts.gstatic.com; " + "connect-src 'self'; " + "img-src 'self' data:; " + "object-src 'none'; " + "base-uri 'none'; " + "frame-ancestors 'none'; " + ) + response.headers["X-Content-Type-Options"] = "nosniff" + return response + SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(SCRIPT_DIR) @@ -22,6 +51,8 @@ WALLS_DIR = os.path.expanduser("~/.goosetown/walls") CHILD_PATTERN = re.compile(r"Task (\d{8}_\d+) started in background") GTWALL_ID_PATTERN = re.compile(r"Your gtwall ID is (\S+)") DELEGATE_NAME_PATTERN = re.compile(r"You are (\S+)\.") +# IMPORTANT: These patterns are duplicated in ui/js/buildings.js (inferRole function). +# If you change patterns here, update the JS version to match. ROLE_PATTERNS = { "orchestrator": re.compile(r"orchestrat", re.I), "researcher": re.compile(r"research", re.I), @@ -395,6 +426,8 @@ async def sessions_tree_watcher(): # ── SSE Endpoint ──────────────────────────────────────────────────────────── async def sse_endpoint(request): + if len(clients) > 50: + return JSONResponse({"error": "too many connections"}, status_code=503) queue: asyncio.Queue = asyncio.Queue(maxsize=1000) clients.append(queue) last_event_id = request.headers.get("Last-Event-ID") @@ -438,10 +471,12 @@ async def sse_endpoint(request): # ── REST ──────────────────────────────────────────────────────────────────── async def messages_endpoint(request): sid = request.path_params["session_id"] + if not re.match(r'^[a-zA-Z0-9_-]{1,128}$', sid): + return JSONResponse({"error": "invalid session id"}, status_code=400) before = request.query_params.get("before") try: - limit = min(int(request.query_params.get("limit", "100")), 500) + limit = max(1, min(int(request.query_params.get("limit", "100")), 500)) with db() as cur: if before: cur.execute( @@ -458,14 +493,53 @@ async def messages_endpoint(request): ) rows = [{"id": r[0], "role": r[1], "content_json": r[2], "created_timestamp": r[3]} for r in cur.fetchall()] except Exception as e: - return JSONResponse({"error": str(e)}, status_code=500) + print(f"⚠️ messages_endpoint error: {e}", file=sys.stderr) + return JSONResponse({"error": "internal error"}, status_code=500) rows.reverse() return JSONResponse({"session_id": sid, "messages": rows, "has_more": len(rows) == limit}) +async def wall_post_endpoint(request): + """Post a message to gtwall as 'user'. Shells out to ./gtwall — no duplicated logic. + + NOTE: This endpoint has no authentication. It is safe only because the server + binds to 127.0.0.1 (localhost). Do not expose to a network without adding auth. + """ + try: + body = await request.json() + message = body.get("message", "").strip() + if not message: + return JSONResponse({"error": "empty message"}, status_code=400) + if len(message) > 4096: + return JSONResponse({"error": "message too long (max 4096 chars)"}, status_code=400) + + wall_file = config.get("wall_file", "") + if not wall_file: + return JSONResponse({"error": "no wall file configured"}, status_code=503) + + gtwall_path = os.path.join(PROJECT_DIR, "gtwall") + env = {**os.environ, "GOOSE_GTWALL_FILE": wall_file} + proc = await asyncio.create_subprocess_exec( + gtwall_path, "user", message, + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() + if proc.returncode != 0: + stderr_output = (await proc.stderr.read()).decode().strip() + print(f"⚠️ gtwall failed (rc={proc.returncode}): {stderr_output}", file=sys.stderr) + return JSONResponse({"error": "failed to post message"}, status_code=500) + return JSONResponse({"ok": True}) + except Exception as e: + print(f"⚠️ wall_post error: {e}", file=sys.stderr) + return JSONResponse({"error": "internal error"}, status_code=500) + + async def config_endpoint(request): - return JSONResponse(config) + safe_keys = ("wall_id", "port", "parent_session_id") + return JSONResponse({k: config[k] for k in safe_keys if k in config}) async def root_redirect(request): @@ -480,10 +554,11 @@ def create_app() -> Starlette: Route("/", root_redirect), Route("/events", sse_endpoint), Route("/api/messages/{session_id}", messages_endpoint), + Route("/api/wall", wall_post_endpoint, methods=["POST"]), Route("/api/config", config_endpoint), Mount("/ui", StaticFiles(directory=ui_dir), name="ui"), ], - + middleware=[Middleware(SecurityHeadersMiddleware)], on_startup=[start_background_tasks], ) diff --git a/scripts/validate-map b/scripts/validate-map new file mode 100755 index 0000000..7527f0d --- /dev/null +++ b/scripts/validate-map @@ -0,0 +1,710 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# /// +"""Goosetown map validator — A* pathfinding, building inventory, water analysis.""" + +from __future__ import annotations + +import argparse +import heapq +import os +import sys +from collections import defaultdict, deque +from pathlib import Path +from typing import NamedTuple + +# ── Constants ──────────────────────────────────────────────────────────────── + +BUILDINGS: dict[str, dict] = { + "B": {"key": "barn", "label": "Cozy Barn", "roles": ["idle", "complete", "error"]}, + "L": {"key": "library", "label": "Grand Archive", "roles": ["researcher"]}, + "I": {"key": "inspector", "label": "Inspector's Tower", "roles": ["reviewer"]}, + "F": {"key": "forge", "label": "Steam Forge", "roles": ["generic"]}, + "C": {"key": "factory", "label": "Cog Factory", "roles": ["worker"]}, + "H": {"key": "hall", "label": "Town Hall", "roles": ["orchestrator"]}, + "W": {"key": "scriptorium", "label": "The Scriptorium", "roles": ["writer"]}, + "S": {"key": "apothecary", "label": "Apothecary", "roles": []}, + "M": {"key": "market", "label": "Market", "roles": []}, +} +BUILDING_CHARS = set(BUILDINGS.keys()) +ROLE_BEARING = {"L", "I", "F", "C", "H", "W"} + +DECORATION_CHARS = {"T": "pine", "O": "oak", "*": "bush", "R": "rock", + "l": "lamp", "p": "pipes", "G": "gear"} + +WALK_SPEED = 4.0 # tiles/sec on cost-1 paths + +# ── ANSI helpers ───────────────────────────────────────────────────────────── + +USE_COLOR = sys.stdout.isatty() + +def _c(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if USE_COLOR else text + +def bold(t: str) -> str: return _c("1", t) +def dim(t: str) -> str: return _c("2", t) +def red(t: str) -> str: return _c("31", t) +def green(t: str) -> str: return _c("32", t) +def yellow(t: str) -> str: return _c("33", t) +def cyan(t: str) -> str: return _c("36", t) +def mag(t: str) -> str: return _c("35", t) + +def ok(msg: str) -> str: return f" {green('✅')} {msg}" +def fail(msg: str) -> str: return f" {red('❌')} {msg}" + +def header(title: str) -> str: + bar = "─" * 60 + return f"\n{cyan(bar)}\n {bold(title)}\n{cyan(bar)}" + + +# ── Map parsing ────────────────────────────────────────────────────────────── + +def parse_map(path: str) -> list[list[str]]: + """Extract the ASCII grid from a map.js file or raw .txt file.""" + text = Path(path).read_text() + if path.endswith(".js"): + parts = text.split("[Map]") + if len(parts) < 2: + sys.exit(f"Error: no [Map] section found in {path}") + raw = parts[1] + # Strip the closing backtick/template literal + raw = raw.split("`")[0] + lines = [l for l in raw.split("\n") if l.strip()] + else: + lines = [l for l in text.split("\n") if l.strip()] + return [list(line) for line in lines] + + +# ── Cost model (faithful port from village.js) ────────────────────────────── + +def get_cost(char: str) -> float: + if char in (":", "+", "=") or char in BUILDING_CHARS: + return 1 + if char == "#": + return 2 + if char in (".", "A", "K"): + return 5 + return float("inf") + + +# ── A* pathfinding (faithful port from village.js) ────────────────────────── + +class PathResult(NamedTuple): + cost: float + path: list[tuple[int, int]] # excludes start, includes end + crosses_bridge: bool + + +def find_path(sx: int, sy: int, ex: int, ey: int, + grid: list[list[str]]) -> PathResult | None: + """A* with Manhattan heuristic, 4-directional. Returns None if unreachable.""" + if not grid or not grid[0]: + return None + h = len(grid) + w = len(grid[0]) + + def heuristic(x: int, y: int) -> int: + return abs(x - ex) + abs(y - ey) + + # (f, counter, x, y, g, parent_idx) + counter = 0 + start = (heuristic(sx, sy), counter, sx, sy, 0.0, -1) + open_heap: list[tuple] = [start] + closed: set[tuple[int, int]] = set() + # Store nodes for path reconstruction + nodes: list[tuple] = [start] + best_g: dict[tuple[int, int], float] = {(sx, sy): 0.0} + + DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1)] + + while open_heap: + f, _, cx, cy, cg, parent_idx = heapq.heappop(open_heap) + + if (cx, cy) in closed: + continue + + if cx == ex and cy == ey: + # Reconstruct path (exclude start, include end — matches village.js) + path: list[tuple[int, int]] = [] + idx = len(nodes) - 1 + # Find the node we just popped — it's the last one with these coords + # Actually we need to find it properly + # Re-search: the node we popped is (f, _, cx, cy, cg, parent_idx) + # We stored it in nodes. Let's trace back. + cur_idx = -1 + for i in range(len(nodes) - 1, -1, -1): + n = nodes[i] + if n[2] == cx and n[3] == cy and n[4] == cg: + cur_idx = i + break + bridge = False + while cur_idx >= 0: + nx, ny = nodes[cur_idx][2], nodes[cur_idx][3] + pi = nodes[cur_idx][5] + if pi >= 0: # skip start node (matches village.js: if currNode.parent) + path.append((nx, ny)) + if grid[ny][nx] == "=": + bridge = True + cur_idx = pi + path.reverse() + return PathResult(cost=cg, path=path, crosses_bridge=bridge) + + closed.add((cx, cy)) + + for dx, dy in DIRS: + nx, ny = cx + dx, cy + dy + if nx < 0 or ny < 0 or nx >= w or ny >= h: + continue + if (nx, ny) in closed: + continue + tile = grid[ny][nx] + c = get_cost(tile) + if c == float("inf"): + continue + ng = cg + c + prev = best_g.get((nx, ny), float("inf")) + if ng < prev: + best_g[(nx, ny)] = ng + counter += 1 + nf = ng + heuristic(nx, ny) + node = (nf, counter, nx, ny, ng, len(nodes) - 1) + # parent_idx should point to current node + # Find current node index + cur_node_idx = -1 + for i in range(len(nodes) - 1, -1, -1): + if nodes[i][2] == cx and nodes[i][3] == cy and nodes[i][4] == cg: + cur_node_idx = i + break + node = (nf, counter, nx, ny, ng, cur_node_idx) + nodes.append(node) + heapq.heappush(open_heap, node) + + return None + + +# ── Flood fill ─────────────────────────────────────────────────────────────── + +def flood_fill(grid: list[list[str]], start: tuple[int, int], + match_fn, visited: set[tuple[int, int]]) -> set[tuple[int, int]]: + """BFS flood fill returning all connected tiles matching match_fn.""" + q = deque([start]) + body: set[tuple[int, int]] = set() + h, w = len(grid), len(grid[0]) + while q: + x, y = q.popleft() + if (x, y) in visited: + continue + if x < 0 or y < 0 or x >= w or y >= h: + continue + if not match_fn(grid[y][x]): + continue + visited.add((x, y)) + body.add((x, y)) + for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + q.append((x + dx, y + dy)) + return body + + +# ── Analysis functions ─────────────────────────────────────────────────────── + +def building_inventory(grid: list[list[str]], verbose: bool) -> dict[str, tuple[int, int]]: + """Find all buildings on the grid. Returns {char: (x, y)}.""" + print(header("Building Inventory")) + found: dict[str, tuple[int, int]] = {} + for y, row in enumerate(grid): + for x, ch in enumerate(row): + if ch in BUILDING_CHARS: + if ch in found: + print(yellow(f" ⚠ Duplicate building '{ch}' at ({x},{y}) — first at {found[ch]}")) + else: + found[ch] = (x, y) + + print(f"\n {'Char':<6}{'Building':<22}{'Position':<14}{'Roles'}") + print(f" {'─'*5} {'─'*21} {'─'*13} {'─'*20}") + for ch, info in BUILDINGS.items(): + pos = found.get(ch) + roles = ", ".join(info["roles"]) if info["roles"] else dim("decorative") + if pos: + print(f" {bold(ch):<{6 + (4 if USE_COLOR else 0)}} {info['label']:<22}{f'({pos[0]},{pos[1]})':<14}{roles}") + else: + print(f" {red(ch):<{6 + (4 if USE_COLOR else 0)}} {info['label']:<22}{red('MISSING'):<{14 + (9 if USE_COLOR else 0)}}{roles}") + + # Check for unknown characters + known = BUILDING_CHARS | set(DECORATION_CHARS.keys()) | set("#=:+.~%AKPD ") + unknowns: dict[str, int] = defaultdict(int) + for row in grid: + for ch in row: + if ch not in known: + unknowns[ch] += 1 + if unknowns: + print(f"\n {yellow('Unknown characters:')}") + for ch, cnt in sorted(unknowns.items()): + print(f" '{ch}' × {cnt}") + + missing = [ch for ch in BUILDINGS if ch not in found] + if missing: + print(f"\n {red('Missing buildings:')} {', '.join(missing)}") + else: + print(f"\n {green('All 9 buildings placed.')}") + + return found + + +def walk_times_from_barn(grid: list[list[str]], positions: dict[str, tuple[int, int]], + verbose: bool) -> dict[str, PathResult | None]: + """A* from Barn to every other building.""" + print(header("Walk Times from Barn")) + + barn = positions.get("B") + if not barn: + print(red(" Barn (B) not found on map — cannot compute paths.")) + return {} + + results: dict[str, PathResult | None] = {} + print(f"\n {'To':<6}{'Building':<22}{'Cost':>6}{'Time':>8}{'Manh':>6}{'Tiles':>7}{'Bridge':>8}") + print(f" {'─'*5} {'─'*21} {'─'*5} {'─'*7} {'─'*5} {'─'*6} {'─'*7}") + + for ch in sorted(BUILDINGS.keys()): + if ch == "B": + continue + pos = positions.get(ch) + if not pos: + print(f" {bold(ch):<{6 + (4 if USE_COLOR else 0)}} {BUILDINGS[ch]['label']:<22}{red('NOT PLACED')}") + results[ch] = None + continue + + result = find_path(barn[0], barn[1], pos[0], pos[1], grid) + results[ch] = result + + manhattan = abs(pos[0] - barn[0]) + abs(pos[1] - barn[1]) + if result is None: + print(f" {bold(ch):<{6 + (4 if USE_COLOR else 0)}} {BUILDINGS[ch]['label']:<22}{red('UNREACHABLE')}") + else: + time_s = result.cost / WALK_SPEED + bridge = "🌉" if result.crosses_bridge else "" + time_str = f"{time_s:.1f}s" + role_flag = "" + if ch in ROLE_BEARING and time_s < 5.0: + role_flag = f" {yellow('⚠ <5s')}" + print(f" {bold(ch):<{6 + (4 if USE_COLOR else 0)}} {BUILDINGS[ch]['label']:<22}" + f"{result.cost:>6.0f}{time_str:>8}{manhattan:>6}{len(result.path):>7} {bridge}{role_flag}") + + return results + + +def all_pairs_matrix(grid: list[list[str]], positions: dict[str, tuple[int, int]], + verbose: bool) -> None: + """Walk time matrix between all building pairs.""" + print(header("All-Pairs Walk Time Matrix (seconds)")) + + chars = sorted(ch for ch in positions.keys()) + if len(chars) < 2: + print(red(" Not enough buildings for a matrix.")) + return + + # Compute all pairs + times: dict[tuple[str, str], float | None] = {} + shortest_val = float("inf") + longest_val = 0.0 + shortest_pair = ("", "") + longest_pair = ("", "") + + for i, a in enumerate(chars): + for j, b in enumerate(chars): + if i >= j: + continue + ax, ay = positions[a] + bx, by = positions[b] + r = find_path(ax, ay, bx, by, grid) + if r: + t = r.cost / WALK_SPEED + times[(a, b)] = t + times[(b, a)] = t + if t < shortest_val: + shortest_val, shortest_pair = t, (a, b) + if t > longest_val: + longest_val, longest_pair = t, (a, b) + else: + times[(a, b)] = None + times[(b, a)] = None + + # Print matrix + col_w = 6 + hdr = " " + " " * col_w + for ch in chars: + hdr += f"{ch:>{col_w}}" + print(f"\n{hdr}") + + for a in chars: + row_str = f" {bold(a):<{col_w + (4 if USE_COLOR else 0)}}" + for b in chars: + if a == b: + row_str += f"{'·':>{col_w}}" + else: + t = times.get((a, b)) + if t is None: + row_str += f"{red('✗'):>{col_w + (9 if USE_COLOR else 0)}}" + else: + cell = f"{t:.1f}" + if (a, b) == shortest_pair or (b, a) == shortest_pair: + cell = green(cell) + col_w_adj = col_w + 9 if USE_COLOR else col_w + elif (a, b) == longest_pair or (b, a) == longest_pair: + cell = mag(cell) + col_w_adj = col_w + 9 if USE_COLOR else col_w + else: + col_w_adj = col_w + row_str += f"{cell:>{col_w_adj}}" + print(row_str) + + print(f"\n {green('Shortest')}: {shortest_pair[0]}↔{shortest_pair[1]} = {shortest_val:.1f}s") + print(f" {mag('Longest')}: {longest_pair[0]}↔{longest_pair[1]} = {longest_val:.1f}s") + + +def water_validation(grid: list[list[str]], verbose: bool) -> dict: + """Validate water bodies via flood fill.""" + print(header("Water Body Validation")) + + h = len(grid) + w = len(grid[0]) if grid else 0 + visited: set[tuple[int, int]] = set() + bodies: list[set[tuple[int, int]]] = [] + + for y in range(h): + for x in range(w): + if grid[y][x] in ("~", "P") and (x, y) not in visited: + # Bridges (=) connect river segments — include them for contiguity + body = flood_fill(grid, (x, y), lambda ch: ch in ("~", "P", "="), visited) + if body: + bodies.append(body) + + # Separate water (~) and pond (P) bodies + water_bodies = [] + pond_bodies = [] + for body in bodies: + has_water = any(grid[y][x] == "~" for x, y in body) + has_pond = any(grid[y][x] == "P" for x, y in body) + if has_water and has_pond: + water_bodies.append(body) + elif has_water: + water_bodies.append(body) + else: + pond_bodies.append(body) + + print(f"\n Water bodies (~): {len(water_bodies)}") + for i, body in enumerate(water_bodies): + water_tiles = {(x, y) for x, y in body if grid[y][x] in ("~", "P")} + bridge_tiles = {(x, y) for x, y in body if grid[y][x] == "="} + ys = {y for _, y in body} + bridge_note = f" (+ {len(bridge_tiles)} bridge tiles)" if bridge_tiles else "" + print(f" Body {i+1}: {len(water_tiles)} water tiles{bridge_note}, rows {min(ys)}-{max(ys)}") + print(f" Pond bodies (P): {len(pond_bodies)}") + for i, body in enumerate(pond_bodies): + print(f" Pond {i+1}: {len(body)} tiles") + + # River spans full height? (bridges connect river segments) + river_spans = False + for body in water_bodies: + ys = {y for _, y in body} # includes bridge rows + if 0 in ys and (h - 1) in ys: + river_spans = True + break + if min(ys) == 0 and max(ys) == h - 1: + river_spans = True + break + + if river_spans: + print(f"\n {green('River spans full map height (top to bottom)')}") + else: + print(f"\n {yellow('River does NOT span full map height')}") + + # Isolated water tiles + isolated = [body for body in (water_bodies + pond_bodies) if len(body) == 1] + if isolated: + for body in isolated: + pos = list(body)[0] + print(f" {yellow(f'Isolated water tile at ({pos[0]},{pos[1]})')}") + else: + print(f" {green('No isolated water tiles')}") + + return { + "water_bodies": water_bodies, + "pond_bodies": pond_bodies, + "river_spans": river_spans, + "isolated": isolated, + } + + +def path_network(grid: list[list[str]], positions: dict[str, tuple[int, int]], + verbose: bool) -> dict: + """Analyze the path network.""" + print(header("Path Network Analysis")) + + h = len(grid) + w = len(grid[0]) if grid else 0 + total = h * w + + walkable = 0 + cost1_tiles = 0 + cost2_tiles = 0 + bridges: list[tuple[int, int]] = [] + dead_ends: list[tuple[int, int]] = [] + + for y in range(h): + for x in range(w): + c = get_cost(grid[y][x]) + if c < float("inf"): + walkable += 1 + if c == 1: + cost1_tiles += 1 + if c == 2: + cost2_tiles += 1 + if grid[y][x] == "=": + bridges.append((x, y)) + + # Dead-end detection: cost<=2 tiles (path network) with only 1 cost<=2 neighbor + # Exclude building chars from dead-end check (they're destinations, not paths) + for y in range(h): + for x in range(w): + ch = grid[y][x] + if ch in BUILDING_CHARS: + continue + if get_cost(ch) > 2: + continue + neighbors = 0 + for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h and get_cost(grid[ny][nx]) <= 2: + neighbors += 1 + if neighbors == 1: + dead_ends.append((x, y)) + + paved_density = cost1_tiles / total * 100 if total else 0 + dirt_density = cost2_tiles / total * 100 if total else 0 + + print(f"\n Total tiles: {total}") + print(f" Walkable (cost<∞): {walkable} ({walkable/total*100:.1f}%)") + print(f" Cost-1 (paved): {cost1_tiles} ({paved_density:.1f}%)") + print(f" Cost-2 (dirt): {cost2_tiles} ({dirt_density:.1f}%)") + print(f" Bridge tiles (=): {len(bridges)}") + if bridges and verbose: + for bx, by in bridges: + print(f" ({bx},{by})") + + # Connectivity: check all buildings are mutually reachable + building_chars = [ch for ch in sorted(positions.keys())] + connected = True + unreachable_pairs: list[tuple[str, str]] = [] + if len(building_chars) >= 2: + ref = building_chars[0] + rx, ry = positions[ref] + for ch in building_chars[1:]: + px, py = positions[ch] + r = find_path(rx, ry, px, py, grid) + if r is None: + connected = False + unreachable_pairs.append((ref, ch)) + + if connected: + print(f"\n {green('All buildings mutually reachable')}") + else: + print(f"\n {red('Some buildings are NOT mutually reachable:')}") + for a, b in unreachable_pairs: + print(f" {a} ↛ {b}") + + print(f"\n Dead-end path tiles: {len(dead_ends)}") + if dead_ends and verbose: + for dx, dy in dead_ends[:20]: + print(f" ({dx},{dy}) = '{grid[dy][dx]}'") + if len(dead_ends) > 20: + print(f" ... and {len(dead_ends) - 20} more") + elif dead_ends and not verbose: + print(f" (use -v to see positions)") + + return { + "total": total, + "walkable": walkable, + "cost1": cost1_tiles, + "cost2": cost2_tiles, + "paved_density": paved_density, + "dirt_density": dirt_density, + "bridges": bridges, + "connected": connected, + "dead_ends": dead_ends, + } + + +def decoration_census(grid: list[list[str]], verbose: bool) -> None: + """Count decorations and their quadrant distribution.""" + print(header("Decoration Census")) + + h = len(grid) + w = len(grid[0]) if grid else 0 + mid_x, mid_y = w // 2, h // 2 + + counts: dict[str, int] = defaultdict(int) + quads: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + + for y in range(h): + for x in range(w): + ch = grid[y][x] + if ch in DECORATION_CHARS: + counts[ch] += 1 + qx = "W" if x < mid_x else "E" + qy = "N" if y < mid_y else "S" + quads[ch][qy + qx] += 1 + + total_deco = sum(counts.values()) + print(f"\n Total decorations: {total_deco}") + print(f"\n {'Char':<6}{'Type':<10}{'Count':>7}{'NW':>6}{'NE':>6}{'SW':>6}{'SE':>6}") + print(f" {'─'*5} {'─'*9} {'─'*6} {'─'*5} {'─'*5} {'─'*5} {'─'*5}") + for ch in sorted(DECORATION_CHARS.keys(), key=lambda c: -counts.get(c, 0)): + c = counts.get(ch, 0) + q = quads.get(ch, {}) + print(f" {bold(ch):<{6 + (4 if USE_COLOR else 0)}} {DECORATION_CHARS[ch]:<10}" + f"{c:>7}{q.get('NW',0):>6}{q.get('NE',0):>6}{q.get('SW',0):>6}{q.get('SE',0):>6}") + + +def summary_verdict(grid: list[list[str]], positions: dict[str, tuple[int, int]], + barn_results: dict[str, PathResult | None], + water_info: dict, network_info: dict) -> bool: + """Print pass/fail summary. Returns True if all pass.""" + print(header("Summary Verdict")) + + h = len(grid) + checks: list[tuple[bool, str]] = [] + + # 1. All buildings placed + all_placed = all(ch in positions for ch in BUILDINGS) + checks.append((all_placed, "All buildings placed")) + + # 2. All buildings reachable from Barn + all_reachable = all(r is not None for r in barn_results.values()) + checks.append((all_reachable, "All buildings reachable from Barn")) + + # 3. All buildings mutually reachable + checks.append((network_info["connected"], "All buildings mutually reachable")) + + # 4. Minimum walk time >= 5.0s for role-bearing buildings + min_ok = True + for ch in ROLE_BEARING: + r = barn_results.get(ch) + if r and r.cost / WALK_SPEED < 5.0: + min_ok = False + checks.append((min_ok, "Minimum walk time ≥ 5.0s (role-bearing buildings)")) + + # 5. River contiguous (spans full map height) + checks.append((water_info["river_spans"], "River contiguous (spans full map height)")) + + # 6. No dead-end paths + no_dead_ends = len(network_info["dead_ends"]) == 0 + checks.append((no_dead_ends, f"No dead-end paths ({len(network_info['dead_ends'])} found)")) + + # 7. Grid dimensions consistent + widths = {len(row) for row in grid} + consistent = len(widths) == 1 + checks.append((consistent, f"Grid dimensions consistent (all rows same length)")) + + print() + all_pass = True + for passed, msg in checks: + print(ok(msg) if passed else fail(msg)) + if not passed: + all_pass = False + + if not consistent: + print(f"\n {yellow('Row widths:')} {sorted(widths)}") + for i, row in enumerate(grid): + if len(row) != max(widths): + print(f" Row {i}: {len(row)} chars (expected {max(widths)})") + + print() + if all_pass: + print(f" {bold(green('ALL CHECKS PASSED'))} 🦆") + else: + failed = sum(1 for p, _ in checks if not p) + print(f" {bold(red(f'{failed} CHECK(S) FAILED'))}") + + return all_pass + + +# ── Main ───────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + description="Validate a Goosetown map — A* pathfinding, building inventory, water analysis.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Examples:\n" + " ./scripts/validate-map # current map.js\n" + " ./scripts/validate-map .scratch/map-grid.txt # raw text file\n" + " ./scripts/validate-map -v # verbose output\n", + ) + parser.add_argument("map_file", nargs="?", default=None, + help="Path to map file (.js or .txt). Default: ui/js/map.js") + parser.add_argument("-v", "--verbose", action="store_true", + help="Show extra detail (dead-end positions, bridge coords, etc.)") + args = parser.parse_args() + + # Resolve map file path + if args.map_file: + map_path = args.map_file + else: + # Default: look for ui/js/map.js relative to script or cwd + candidates = [ + Path("ui/js/map.js"), + Path(__file__).resolve().parent.parent / "ui" / "js" / "map.js", + ] + map_path = None + for c in candidates: + if c.exists(): + map_path = str(c) + break + if not map_path: + sys.exit("Error: cannot find ui/js/map.js — pass a path explicitly.") + + if not Path(map_path).exists(): + sys.exit(f"Error: file not found: {map_path}") + + print(bold(f"\n🗺️ Goosetown Map Validator")) + print(f" Source: {map_path}\n") + + # 1. Parse + grid = parse_map(map_path) + h = len(grid) + w = len(grid[0]) if grid else 0 + widths = {len(row) for row in grid} + print(header("Grid Dimensions")) + print(f"\n Rows: {h}") + print(f" Cols: {w} (max)") + if len(widths) > 1: + print(f" {yellow(f'WARNING: inconsistent row widths: {sorted(widths)}')}") + else: + print(f" {green('All rows same width')}") + + # 2. Building inventory + positions = building_inventory(grid, args.verbose) + + # 3. Walk times from Barn + barn_results = walk_times_from_barn(grid, positions, args.verbose) + + # 4. All-pairs matrix + all_pairs_matrix(grid, positions, args.verbose) + + # 5. Water validation + water_info = water_validation(grid, args.verbose) + + # 6. Path network + network_info = path_network(grid, positions, args.verbose) + + # 7. Decoration census + decoration_census(grid, args.verbose) + + # 8. Summary verdict + all_pass = summary_verdict(grid, positions, barn_results, water_info, network_info) + + sys.exit(0 if all_pass else 1) + + +if __name__ == "__main__": + main() diff --git a/ui/css/steampunk.css b/ui/css/steampunk.css index df83a3a..451aad8 100644 --- a/ui/css/steampunk.css +++ b/ui/css/steampunk.css @@ -27,7 +27,7 @@ /* Text */ --text-primary: #FFFDD0; --text-secondary: #C4956A; - --text-dim: #8B7355; + --text-dim: #A89070; /* Status Lanterns */ --status-active: #FFBF00; diff --git a/ui/css/village.css b/ui/css/village.css new file mode 100644 index 0000000..f85c94a --- /dev/null +++ b/ui/css/village.css @@ -0,0 +1,253 @@ +/* ========================================================================== + Goosetown Village — SVG RPG Town + ========================================================================== */ + +.village-viewport { + width: 100%; height: 100%; position: relative; overflow: hidden; + background: #7CB342; font-family: 'JetBrains Mono', monospace; + display: flex; align-items: center; justify-content: center; +} + +.terrain-layer { + width: 100%; height: 100%; +} + +.village-viewport:not(.day) .terrain-layer { + filter: brightness(0.7) sepia(0.2) hue-rotate(-10deg); +} + +.v-building-art { + filter: drop-shadow(0 15px 10px rgba(0,0,0,0.4)); + transform-origin: bottom center; +} + +.v-building-label { + display: inline-block; + background: rgba(0,0,0,0.8); color: #FFF; + padding: 6px 10px; border: 1px solid var(--copper); border-radius: 4px; + font-size: 13px; white-space: nowrap; + box-shadow: 0 4px 6px rgba(0,0,0,0.3); +} + +.v-goose-wrapper { + position: absolute; z-index: 10; + transition: opacity 0.5s; +} + +.v-goose-anim { + transform-origin: bottom center; + filter: drop-shadow(0 10px 5px rgba(0,0,0,0.4)); +} + +.v-goose-anim.walking { animation: waddle 0.3s infinite alternate ease-in-out; } +.v-goose-anim.working { animation: bob 0.8s infinite; } + +.v-goose-hidden { + opacity: 0; + pointer-events: none; +} + +@keyframes waddle { + from { transform: rotate(-8deg); } + to { transform: rotate(8deg); } +} +@keyframes bob { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(0.9) translateY(4px); } +} + +/* Smoke and Fire */ +.anim-smoke { animation: float-up 2.5s infinite linear; } +.anim-smoke:nth-child(2) { animation-delay: 1.2s; } +@keyframes float-up { + 0% { transform: translateY(0) scale(1); opacity: 0.8; } + 100% { transform: translateY(-30px) scale(1.5); opacity: 0; } +} + +.anim-flicker { animation: flicker 0.6s infinite alternate; } +@keyframes flicker { + 0% { opacity: 0.8; } + 100% { opacity: 1; filter: brightness(1.3); } +} + +.anim-fountain { + animation: fountain-spray 1.5s infinite alternate ease-in-out; + transform-origin: 40px 40px; +} +@keyframes fountain-spray { + 0% { transform: scaleY(1); opacity: 0.7; } + 100% { transform: scaleY(1.2); opacity: 0.9; } +} + +/* Swimming Goose & Pond */ +.anim-ripple { + transform-origin: 20px 20px; + animation: ripple 2.5s infinite linear; +} +.anim-ripple-delay { + transform-origin: 20px 20px; + animation: ripple 2.5s infinite linear; + animation-delay: 1.25s; +} +@keyframes ripple { + 0% { transform: scale(0.5); opacity: 0.8; } + 100% { transform: scale(1.5); opacity: 0; } +} + +.anim-swim-bob { + animation: swim-bob 2s infinite alternate ease-in-out; +} +@keyframes swim-bob { + 0% { transform: translate(-10px, -20px) rotate(-2deg); } + 100% { transform: translate(-10px, -18px) rotate(2deg); } +} + +.anim-wander { + animation: wander 8s infinite alternate ease-in-out; +} +@keyframes wander { + 0% { transform: translateX(-5px); } + 100% { transform: translateX(15px); } +} + +.anim-farm-wander { + animation: farm-wander 24s infinite linear; +} +@keyframes farm-wander { + 0% { transform: translate(0px, 0px) scaleX(1); } + 20% { transform: translate(200px, 0px) scaleX(1); } + 22% { transform: translate(200px, 40px) scaleX(-1); } + 42% { transform: translate(0px, 40px) scaleX(-1); } + 44% { transform: translate(0px, 80px) scaleX(1); } + 64% { transform: translate(200px, 80px) scaleX(1); } + 66% { transform: translate(200px, 120px) scaleX(-1); } + 86% { transform: translate(0px, 120px) scaleX(-1); } + 88% { transform: translate(0px, 0px) scaleX(1); } + 100% { transform: translate(0px, 0px) scaleX(1); } +} + +/* UI Elements */ +.v-speech { + position: absolute; bottom: 100%; left: 50%; transform: translate(-50%, -10px); + background: #fff; color: #000; border: 3px solid #000; + padding: 8px 12px; border-radius: 6px; font-size: 13px; font-weight: bold; + white-space: normal; word-wrap: break-word; max-width: 200px; width: max-content; + box-shadow: 4px 4px 0 rgba(0,0,0,0.5); + animation: pop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} +.v-speech::after { + content: ''; position: absolute; bottom: -9px; left: 50%; margin-left: -6px; + border-width: 6px 6px 0 0; border-style: solid; border-color: #000 transparent transparent transparent; +} +.v-speech-inner { display: contents; } +.v-speech-inner::after { + content: ''; position: absolute; bottom: -4px; left: 50%; margin-left: -4px; + border-width: 4px 4px 0 0; border-style: solid; border-color: #fff transparent transparent transparent; z-index: 21; +} +@keyframes pop { + 0% { transform: translate(-50%, 0px) scale(0); } + 100% { transform: translate(-50%, -10px) scale(1); } +} + +.v-nameplate { + display: inline-block; + background: rgba(0,0,0,0.8); color: #fff; font-size: 10px; + padding: 2px 6px; border: 1px solid var(--copper); border-radius: 4px; white-space: nowrap; +} + +.layout.show-village { grid-template-rows: 55vh 1fr auto; grid-template-areas: "village village village" "registry bulletin workshop" "clockworks clockworks clockworks"; } +#village-container { display: none; grid-area: village; border-bottom: 2px solid var(--border-active); } +.layout.show-village #village-container { display: block; } +.btn-toggle-village { margin-left: auto; background: var(--bg-card); color: var(--copper-light); border: 1px solid var(--border-default); padding: 4px 8px; font-family: var(--font-mono); font-size: var(--fs-sm); cursor: pointer; border-radius: var(--radius-sm); transition: all 0.2s; } +.btn-toggle-village:hover { background: var(--bg-input); color: var(--copper); border-color: var(--copper); } +body.standalone-village { margin: 0; padding: 0; width: 100vw; height: 100vh; overflow: hidden; } +body.standalone-village #village-container { display: block; width: 100%; height: 100%; border: none; } + +/* ─── Wall Post Overlay ─────────────────────────────────────── */ + +.wall-post-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.wall-post-popup { + background: var(--bg-secondary); + border: 2px solid var(--copper); + border-radius: 8px; + padding: 20px; + width: 400px; + max-width: 90vw; + font-family: 'JetBrains Mono', monospace; +} + +.wall-post-header { + color: var(--copper); + font-size: 16px; + font-weight: bold; + margin-bottom: 12px; +} + +.wall-post-input { + width: 100%; + background: var(--bg-deepest); + color: var(--text-primary); + border: 1px solid var(--copper); + border-radius: 4px; + padding: 8px; + font-family: inherit; + font-size: 14px; + resize: vertical; + box-sizing: border-box; +} + +.wall-post-actions { + display: flex; + gap: 8px; + margin-top: 12px; + justify-content: flex-end; +} + +.wall-post-actions button { + background: var(--copper); + color: #000; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-weight: bold; +} + +.wall-post-actions button.wall-post-cancel-btn { + background: transparent; + color: var(--copper); + border: 1px solid var(--copper); +} + +/* a11y: screen-reader-only utility */ +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + +/* ─── Focus Indicators ───────────────────────────────────────── */ +.card[role="button"]:focus-visible, +.filter-btn:focus-visible, +.tab:focus-visible, +button:focus-visible { + outline: 2px solid var(--copper); + outline-offset: 2px; +} + +.card[role="button"]:focus-visible { + box-shadow: 0 0 0 3px rgba(184, 115, 51, 0.3); +} + +/* a11y: respect user motion preferences */ +@media (prefers-reduced-motion: reduce) { + .v-goose-anim, .anim-smoke, .anim-flicker, .anim-fountain, + .anim-ripple, .anim-ripple-delay, .anim-swim-bob, .anim-wander, + .anim-farm-wander, .v-speech { animation: none !important; } +} diff --git a/ui/editor.html b/ui/editor.html new file mode 100644 index 0000000..95e4e78 --- /dev/null +++ b/ui/editor.html @@ -0,0 +1,67 @@ + + + + + + Goosetown Map Editor + + + + + + +
+ + + diff --git a/ui/index.html b/ui/index.html index a0d8e84..ac09327 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,9 +8,11 @@ +
+
diff --git a/ui/js/app.js b/ui/js/app.js index f65a353..a8a8cef 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -8,6 +8,7 @@ import { renderWorkshop, } from './components.js'; import { state, subscribe, toggleFilter, update } from './state.js'; +import { renderVillage, setVillageVisible } from './village.js'; const $ = (s) => document.querySelector(s); @@ -16,6 +17,7 @@ const registryEl = $('.registry'); const bulletinEl = $('.bulletin'); const workshopEl = $('.workshop'); const clockworksEl = $('.clockworks'); +const villageEl = $('#village-container'); const isDesktop = () => matchMedia('(min-width: 1201px)').matches; // Inject hamburger + backdrop if missing from HTML @@ -39,6 +41,13 @@ function scheduleRender() { scheduled = true; requestAnimationFrame(() => { scheduled = false; + const isStandalone = document.body.classList.contains('standalone-village'); + if ( + villageEl && + (isStandalone || layoutEl?.classList.contains('show-village')) + ) { + render(renderVillage(state), villageEl); + } if (registryEl) render(renderRegistry(state), registryEl); if (bulletinEl) render(renderBulletin(state), bulletinEl); if (workshopEl) render(renderWorkshop(state), workshopEl); @@ -79,25 +88,30 @@ function normalizeWallMsg(msg) { time: msg.time || '', sender_id: msg.sender_id || '', message: msg.message || '', + _receivedAt: Date.now(), }; } +function safeParse(json) { + try { + return JSON.parse(json); + } catch { + return []; + } +} + function parseMessages(data) { return (data.messages || []).map((m) => ({ ...m, content: typeof m.content_json === 'string' - ? JSON.parse(m.content_json) + ? safeParse(m.content_json) : m.content_json || [], })); } function connectSSE() { - const ps = new URLSearchParams(location.search).get('parent_session'); - const url = ps - ? `/events?parent_session=${encodeURIComponent(ps)}` - : '/events'; - const es = new EventSource(url); + const es = new EventSource('/events'); es.onopen = () => { update({ connected: true }); @@ -134,6 +148,8 @@ function connectSSE() { }; if (!nearTop) patch.unreadCount = state.unreadCount + 1; update(patch); + // Re-render after speech bubble expires (SPEECH_DURATION_MS=8000 + 100ms buffer) + setTimeout(scheduleRender, 8100); if (nearTop && feed) { requestAnimationFrame(() => feed.scrollTo({ top: 0, behavior: 'smooth' }) @@ -169,6 +185,7 @@ async function selectDelegate(sessionId) { const resp = await fetch(`/api/messages/${sessionId}?limit=100`); if (!resp.ok) return; const data = await resp.json(); + if (state.selectedDelegate !== sessionId) return; // stale response — user switched delegates update({ delegateMessages: parseMessages(data), delegateHasMore: data.has_more || false, @@ -189,6 +206,7 @@ async function loadOlderMessages() { ); if (!resp.ok) return; const data = await resp.json(); + if (state.selectedDelegate !== sid) return; // stale response — user switched delegates update({ delegateMessages: [...parseMessages(data), ...msgs], delegateHasMore: data.has_more || false, @@ -236,6 +254,16 @@ function closeOverlays() { } const clickActions = [ + [ + '.btn-toggle-village[data-action="toggle-village"]', + () => { + layoutEl?.classList.toggle('show-village'); + const isNowVisible = layoutEl?.classList.contains('show-village'); + setVillageVisible(isNowVisible); + update({ villageVisible: isNowVisible || false }); + scheduleRender(); + }, + ], [ '.card[data-session-id]', (el) => { @@ -281,13 +309,61 @@ const clickActions = [ }, ], ['.backdrop', () => closeOverlays()], + [ + '[data-action="wall-post"]', + () => { + state._lastFocusedBeforeDialog = document.activeElement; + update({ showWallPost: true }); + requestAnimationFrame(() => + document.getElementById('wall-post-input')?.focus() + ); + }, + ], + [ + '[data-action="wall-post-send"]', + async () => { + const input = document.getElementById('wall-post-input'); + const msg = input?.value?.trim(); + if (!msg) return; + try { + const resp = await fetch('/api/wall', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: msg }), + }); + if (!resp.ok) { + console.error('[wall-post] server returned', resp.status); + input?.focus(); + return; + } + } catch (e) { + console.error('[wall-post]', e); + input?.focus(); + return; + } + update({ showWallPost: false }); + state._lastFocusedBeforeDialog?.focus(); + delete state._lastFocusedBeforeDialog; + }, + ], + // Dismiss only when clicking the overlay itself or the cancel button — not popup children + [ + '[data-action="wall-post-dismiss"]', + (el, e) => { + if (e.target === el || e.target.closest('.wall-post-cancel-btn')) { + update({ showWallPost: false }); + state._lastFocusedBeforeDialog?.focus(); + delete state._lastFocusedBeforeDialog; + } + }, + ], ]; document.addEventListener('click', (e) => { for (const [sel, handler] of clickActions) { const match = e.target.closest(sel); if (match) { - handler(match); + handler(match, e); return; } } @@ -299,6 +375,61 @@ document.addEventListener('change', (e) => { } }); +document.addEventListener('keydown', (e) => { + // Keyboard activation for role="button" elements (a11y) + if ( + (e.key === 'Enter' || e.key === ' ') && + e.target.matches('[role="button"]') + ) { + e.preventDefault(); + e.target.click(); + return; + } + if (e.key === 'Escape' && state.showWallPost) { + update({ showWallPost: false }); + state._lastFocusedBeforeDialog?.focus(); + delete state._lastFocusedBeforeDialog; + } + // Focus trap for wall-post dialog + if (e.key === 'Tab') { + const overlay = document.querySelector('.wall-post-overlay'); + if (!overlay) return; + const focusable = overlay.querySelectorAll( + 'textarea, button, [tabindex]:not([tabindex="-1"])' + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + // Enter (without Shift) in wall post textarea triggers send + if (e.key === 'Enter' && !e.shiftKey && e.target.id === 'wall-post-input') { + e.preventDefault(); + document.querySelector('[data-action="wall-post-send"]')?.click(); + return; + } + // Arrow key navigation for ARIA tabs + if (e.target.getAttribute('role') === 'tab') { + const tabs = [...e.target.parentElement.querySelectorAll('[role="tab"]')]; + const idx = tabs.indexOf(e.target); + let next = -1; + if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length; + else if (e.key === 'ArrowLeft') + next = (idx - 1 + tabs.length) % tabs.length; + if (next >= 0) { + e.preventDefault(); + tabs[next].focus(); + tabs[next].click(); + } + } +}); + // Capture phase to catch scrollable div events document.addEventListener( 'scroll', @@ -314,6 +445,16 @@ document.addEventListener( true ); +// Listen for custom render events (e.g., from village animations) +document.addEventListener('goosetown-render', () => { + scheduleRender(); +}); + +// In standalone-village mode the village is always visible — start animation loop +if (document.body.classList.contains('standalone-village')) { + setVillageVisible(true); +} + connectSSE(); updateTabTitle(); console.log('[Goosetown] Dashboard initialized'); diff --git a/ui/js/assets.js b/ui/js/assets.js new file mode 100644 index 0000000..db0527b --- /dev/null +++ b/ui/js/assets.js @@ -0,0 +1,347 @@ +import { svg } from 'https://cdn.jsdelivr.net/npm/lit-html@3.3.2/+esm'; + +export const SvgDefs = svg` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const BuildingBarn = svg` + + + + + + + + +`; + +export const BuildingLibrary = svg` + + + + + + + + + + +`; + +export const BuildingForge = svg` + + + + + + + +`; + +export const BuildingFactory = svg` + + + + + + + + + +`; + +export const BuildingHall = svg` + + + + + + + +`; + +export const BuildingTower = svg` + + + + + + +`; + +export const BuildingScriptorium = svg` + + + + + + + + + +`; + +export const BuildingApothecary = svg` + + + + + + + + + + + +`; + +export const BuildingMarket = svg` + + + + + + + + + + + + +`; diff --git a/ui/js/buildings.js b/ui/js/buildings.js new file mode 100644 index 0000000..685e8f9 --- /dev/null +++ b/ui/js/buildings.js @@ -0,0 +1,96 @@ +import { + BuildingApothecary, + BuildingBarn, + BuildingFactory, + BuildingForge, + BuildingHall, + BuildingLibrary, + BuildingMarket, + BuildingScriptorium, + BuildingTower, +} from './assets.js'; + +export const BUILDINGS = Object.freeze({ + B: { + key: 'barn', + label: 'Cozy Barn', + svg: BuildingBarn, + roles: ['idle', 'complete', 'error'], + }, + L: { + key: 'library', + label: 'Grand Archive', + svg: BuildingLibrary, + roles: ['researcher'], + }, + I: { + key: 'inspector', + label: "Inspector's Tower", + svg: BuildingTower, + roles: ['reviewer'], + }, + F: { + key: 'forge', + label: 'Steam Forge', + svg: BuildingForge, + roles: ['generic'], + }, + C: { + key: 'factory', + label: 'Cog Factory', + svg: BuildingFactory, + roles: ['worker'], + }, + H: { + key: 'hall', + label: 'Town Hall', + svg: BuildingHall, + roles: ['orchestrator'], + }, + W: { + key: 'scriptorium', + label: 'The Scriptorium', + svg: BuildingScriptorium, + roles: ['writer'], + }, + // Decorative buildings — no role assigned, geese won't path here. + // Reserved decoration chars (uppercase, not available as building keys): T, O, R, G + S: { + key: 'apothecary', + label: 'Apothecary', + svg: BuildingApothecary, + roles: [], + }, + M: { + key: 'market', + label: 'Market', + svg: BuildingMarket, + roles: [], + }, +}); + +export const BUILDING_CHARS = Object.keys(BUILDINGS); + +export const ROLE_TO_BUILDING = Object.fromEntries( + Object.entries(BUILDINGS).flatMap(([, b]) => b.roles.map((r) => [r, b.key])) +); + +/** + * Infer a delegate's role from its name using regex patterns. + * + * IMPORTANT: This logic is duplicated in scripts/goosetown-ui (ROLE_PATTERNS dict). + * If you change patterns here, update the Python version to match. + * + * @param {string} name - Delegate name + * @returns {string} Role key (orchestrator, researcher, worker, reviewer, writer, generic) + */ +export function inferRole(name) { + if (!name) return 'generic'; + const n = name.toLowerCase(); + if (/orchestrat/.test(n)) return 'orchestrator'; + if (/research/.test(n)) return 'researcher'; + if (/worker|build|implement/.test(n)) return 'worker'; + if (/review|crossfire/.test(n)) return 'reviewer'; + if (/writ|spec|document/.test(n)) return 'writer'; + return 'generic'; +} diff --git a/ui/js/components.js b/ui/js/components.js index 78816ad..b34dfd6 100644 --- a/ui/js/components.js +++ b/ui/js/components.js @@ -4,6 +4,7 @@ import DOMPurify from 'https://cdn.jsdelivr.net/npm/dompurify@3.2.4/+esm'; import { html } from 'https://cdn.jsdelivr.net/npm/lit-html@3.3.2/+esm'; import { unsafeHTML } from 'https://cdn.jsdelivr.net/npm/lit-html@3.3.2/directives/unsafe-html.js/+esm'; import { Marked } from 'https://cdn.jsdelivr.net/npm/marked@15.0.12/+esm'; +import { inferRole } from './buildings.js'; // ─── Markdown ──────────────────────────────────────────────────────── const marked = new Marked({ breaks: true, gfm: true }); @@ -33,6 +34,8 @@ const FILTER_ROLES = [ { key: 'researcher', label: '🔍 Researchers' }, { key: 'worker', label: '🔧 Workers' }, { key: 'reviewer', label: '📋 Reviewers' }, + { key: 'writer', label: '✍️ Writers' }, + { key: 'generic', label: '🪿 Generic' }, ]; function formatTokens(n) { @@ -48,17 +51,6 @@ function formatElapsed(seconds) { return `${m}m ${String(s).padStart(2, '0')}s`; } -function inferRole(name) { - if (!name) return 'generic'; - const n = name.toLowerCase(); - if (/orchestrat/.test(n)) return 'orchestrator'; - if (/research/.test(n)) return 'researcher'; - if (/worker|build|implement/.test(n)) return 'worker'; - if (/review|crossfire/.test(n)) return 'reviewer'; - if (/writ|spec|document/.test(n)) return 'writer'; - return 'generic'; -} - function inferMessageType(msg) { if (!msg) return 'note'; const t = msg.trimStart(); @@ -72,7 +64,7 @@ function inferMessageType(msg) { const avatar = (role) => AVATARS[role] || AVATARS.generic; const lantern = (status) => - html``; + html``; const delegateRole = (d) => inferRole(d.role || d.id); const delegateName = (d) => d.gtwall_id || d.role || d.id; @@ -109,7 +101,7 @@ function delegateCard(delegate, selected) { : ''; return html` -
+
${lantern(delegate.status)}
${avatar(role)} @@ -176,7 +168,7 @@ function wallMessage(msg, st, isNew = false) { return html`
- + ${avatar(role)} ${msg.sender_id} ${msg.time} @@ -204,18 +196,24 @@ function renderBulletin(st) {

Goosetown

${lantern(st.connected ? 'active' : 'error')} + + + +
${FILTER_ROLES.map( (r) => html` ` )}
-
+
${ filtered.length === 0 ? html`
@@ -237,7 +235,24 @@ function renderBulletin(st) {
${ st.unreadCount > 0 - ? html`
↑ ${st.unreadCount} new message${st.unreadCount > 1 ? 's' : ''}
` + ? html`` + : '' + } + ${ + st.showWallPost + ? html` + + ` : '' } `; @@ -398,7 +413,7 @@ function renderWorkshop(st) { ${avatar(role)} ${delegateName(delegate)}
- +
${role} @@ -410,15 +425,18 @@ function renderWorkshop(st) { } ${formatTokens(delegate.tokens)} tok
-
+
${tabs.map( (tab) => html` ` )}
-
+
${( { overview: () => overviewTab(delegate), diff --git a/ui/js/editor.js b/ui/js/editor.js new file mode 100644 index 0000000..17358f6 --- /dev/null +++ b/ui/js/editor.js @@ -0,0 +1,300 @@ +import { + html, + render, + svg, +} from 'https://cdn.jsdelivr.net/npm/lit-html@3.3.2/+esm'; +import { SvgDefs } from './assets.js'; +import { BUILDINGS } from './buildings.js'; +import { MAP_CONFIG } from './map.js'; +import { decoForChar, TILE_SIZE, tileForChar } from './tiles.js'; + +const GRID_W = 60; +const GRID_H = 25; + +const TILES = [ + { char: '.', label: 'Grass (Eraser)', type: 'base' }, + { char: '#', label: 'Dirt Path', type: 'base' }, + { char: ':', label: 'Cobblestone', type: 'base' }, + { char: '+', label: 'Plaza Tile', type: 'base' }, + { char: '~', label: 'River', type: 'base' }, + { char: 'P', label: 'Pond', type: 'base' }, + { char: '=', label: 'Bridge', type: 'base' }, + { char: 'A', label: 'Farm', type: 'base' }, + { char: 'K', label: 'Fountain', type: 'base' }, + { char: '%', label: 'Stone Wall', type: 'base' }, + { char: 'T', label: 'Pine Tree', type: 'dec' }, + { char: 'O', label: 'Oak Tree', type: 'dec' }, + { char: '*', label: 'Flowers', type: 'dec' }, + { char: 'R', label: 'Rock', type: 'dec' }, + { char: 'l', label: 'Street Lamp', type: 'dec' }, + { char: 'p', label: 'Pipes', type: 'dec' }, + { char: 'G', label: 'Giant Gear', type: 'dec' }, + // Building tiles derived from the single source of truth + ...Object.entries(BUILDINGS).map(([char, b]) => ({ + char, + label: b.label, + type: 'bldg', + })), +]; + +let mapGrid = Array(GRID_H) + .fill(null) + .map(() => Array(GRID_W).fill('.')); +let currentBrush = '#'; +let isDrawing = false; + +const mapSections = MAP_CONFIG.split('[Map]'); +const INITIAL_MAP = mapSections[1].trim(); + +function loadMapString(str) { + // Reset grid before loading — prevents stale tiles when loading shorter maps + mapGrid = Array(GRID_H) + .fill(null) + .map(() => Array(GRID_W).fill('.')); + const lines = str + .trim() + .split('\n') + .filter((l) => l.length > 0); + for (let y = 0; y < Math.min(GRID_H, lines.length); y++) { + for (let x = 0; x < Math.min(GRID_W, lines[y].length); x++) { + mapGrid[y][x] = lines[y][x]; + } + } + updateUI(); +} + +function exportMapString() { + return mapGrid.map((row) => row.join('')).join('\n'); +} + +function renderTileIcon(char) { + if (char === '.') + return svg``; + if (char === '#') return svg``; + if (char === ':') return svg``; + if (char === '+') return svg``; + if (char === '%') return svg``; + if (char === '~') return svg``; + if (char === 'P') return svg``; + if (char === '=') return svg``; + if (char === 'A') return svg``; + if (char === 'K') + return svg``; + if (char === 'T') return svg``; + if (char === 'O') return svg``; + if (char === '*') return svg``; + if (char === 'R') return svg``; + if (char === 'l') return svg``; + if (char === 'p') return svg``; + if (char === 'G') return svg``; + // Buildings from the registry — auto-scales with new entries + if (BUILDINGS[char]) + return svg`${BUILDINGS[char].svg}`; + return svg`${char}`; +} + +function handlePointerDown(e) { + isDrawing = true; + paintTile(e); +} + +function handlePointerUp() { + if (isDrawing) { + isDrawing = false; + updateUI(); + } +} + +function handlePointerMove(e) { + if (isDrawing) paintTile(e); +} + +function paintTile(e) { + const svgEl = e.currentTarget; + const refEl = document.getElementById('viewbox-reference') || svgEl; + + const pt = svgEl.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + + const ctm = refEl.getScreenCTM(); + if (!ctm) return; + const svgP = pt.matrixTransform(ctm.inverse()); + + const x = Math.floor(svgP.x / TILE_SIZE); + const y = Math.floor(svgP.y / TILE_SIZE); + + if (x >= 0 && x < GRID_W && y >= 0 && y < GRID_H) { + if (mapGrid[y][x] !== currentBrush) { + mapGrid[y][x] = currentBrush; + renderCanvasOnly(); + } + } +} + +function renderCanvasContent() { + const elements = []; + + elements.push( + svg`` + ); + + for (let y = 0; y < GRID_H; y++) { + for (let x = 0; x < GRID_W; x++) { + const char = mapGrid[y][x]; + const px = x * TILE_SIZE; + const py = y * TILE_SIZE; + + elements.push( + svg`` + ); + + // --- Terrain tiles (shared via tiles.js) --- + const tile = tileForChar(char, px, py); + if (tile) { + elements.push(tile); + } else if (char === 'K') { + // Editor-specific: simplified fountain placeholder + const isTopLeft = + (x === 0 || mapGrid[y][x - 1] !== 'K') && + (y === 0 || mapGrid[y - 1][x] !== 'K'); + if (isTopLeft) + elements.push( + svg`` + ); + } + } + } + + // --- Grid lines (editor-only) --- + for (let x = 0; x <= GRID_W; x++) { + elements.push( + svg`` + ); + } + for (let y = 0; y <= GRID_H; y++) { + elements.push( + svg`` + ); + } + + const decos = []; + for (let y = 0; y < GRID_H; y++) { + for (let x = 0; x < GRID_W; x++) { + const char = mapGrid[y][x]; + const px = x * TILE_SIZE; + const py = y * TILE_SIZE; + + // --- Decorations (shared via tiles.js) --- + const deco = decoForChar(char, px, py); + if (deco) { + decos.push({ + y: py, + el: svg`${deco}`, + }); + } + + // --- Fountain (editor-specific simplified rendering) --- + if (char === 'K') { + const isTopLeft = + (x === 0 || mapGrid[y][x - 1] !== 'K') && + (y === 0 || mapGrid[y - 1][x] !== 'K'); + if (isTopLeft) { + decos.push({ + y: py + TILE_SIZE, + el: svg``, + }); + } + } + + // --- Buildings — driven by BUILDINGS table, no hardcoded char matching --- + if (BUILDINGS[char]) { + decos.push({ + y: py, + el: svg` + + + ${BUILDINGS[char].svg} + +
+ ${BUILDINGS[char].label} +
+
+
+ `, + }); + } + } + } + decos.sort((a, b) => a.y - b.y); + + return html` + ${SvgDefs} + ${elements} + ${decos.map((d) => d.el)} + `; +} + +function renderCanvasOnly() { + const container = document.getElementById('map-canvas'); + if (container) { + render(renderCanvasContent(), container); + } +} + +function updateUI() { + render(App(), document.getElementById('app')); +} + +const App = () => html` +
+

Goosetown Map Editor

+ ← Back to Dashboard +
+ +
+ + +
+ + ${renderCanvasContent()} + +
+
+ +
+
+ ASCII Export + +
+ +
+`; + +loadMapString(INITIAL_MAP); +updateUI(); diff --git a/ui/js/map.js b/ui/js/map.js new file mode 100644 index 0000000..71e7e62 --- /dev/null +++ b/ui/js/map.js @@ -0,0 +1,47 @@ +export const MAP_CONFIG = ` +// NOTE: This legend is for human reference only. Source of truth for building data is buildings.js +[Buildings] +H: Town Hall (orchestrator) +L: Grand Archive (researcher) +B: Cozy Barn (idle, complete, error) +F: Steam Forge (generic) +C: Cog Factory (worker) +I: Inspector's Tower (reviewer) +W: The Scriptorium (writer) +S: Apothecary (decorative) +M: Market (decorative) + +[Terrain] +. grass # path : cobblestone + plaza % stone wall +~ water P pond = bridge A farm K fountain (2×2) + +[Decorations] +T pine O oak * bush R rock l lamp p pipes G gear + +[Map] +TTT.T.OOOTTT..TT.TTTT.TTTT.TT.TTTT.O~~~.TTTT..TT..TTTT.TT..T +TOTT*TTT.T.T...TT.T.TTT.TTTTTTO.....~~~..TTT.T.TT.O.TTTTO.T. +..T............*..T..O..............~~~..................T.T +.O........O.T....O..R...........T.O.~~~..................... +TT..................................~~~.O.................TT +T...:::::l.....T....T..l::::..O.....~~~...................*. +..T.::L:::::::::::::::::::H:::::::::===::########.....l..... +....:::::.*O.T.T....O.....:....O..l:~~~:%%%%%%%######I....T. +T*..:::::..R...*..l+++++++++++++l..:~~~:::::pp%*..........*. +.....l:############++++++KK+++++...:~~~::F::pp%.......O...TT +######:............+++++++++++++O..:~~~:::::pp%.....O......T +......:............+++++++++++++...:~~~:::::pp%.T.........T. +.*...R:......OT...l+++M+++++++S+l..:~~~:::::pp%*..O...*....T +T.....:.....TOOT..O******l:l****O..:~~~:::::pp%.....*....... +.O....:......OO.....R.....:........:~~~:::::pp%.........O... +T..R..:.............T....*:*....O..:~~~:::::pp%...........TT +......:l::W::..l...l*..l..:.l.....l:~~~:::::pp%...*.......OT +.T....::::::::::::::::::::::::::::::===:::::pp%.....O..*.... +..O......#..T.......T.#.:.......O..O~~~:::::pp%*..........T. +T.......#.O.T..R....*.#l:AAAAAAAAAAA~~~:::::pp%....R...*.... +.*....OPPP..O.......AAAA:AAAAAAAAAAA~~~::C::pp%..O.......... +T.....PPPPP.O..*....AAAA:##AAAAAAAAA~~~%%%%%%%%*..........TT +..O..TPPPPPP........AAAA::BAAAAAAAAA~~~....................T +.T*OOOPPPPPPTTTT.TTTAAAAAAAAAAAAAAAA~~~T.TTT.TTT.TT.T.TT..T. +T.TOOTT.T..T.T.TTTT.T*TT.TT.O.T.T.T.~~~TT...TT.TTT..T..T.TOT +`; diff --git a/ui/js/state.js b/ui/js/state.js index 24999b3..0bf4b93 100644 --- a/ui/js/state.js +++ b/ui/js/state.js @@ -13,6 +13,7 @@ const state = { connected: false, collapseToolChatter: true, lastWallMessageTime: null, + showWallPost: false, }; const listeners = new Set(); diff --git a/ui/js/tiles.js b/ui/js/tiles.js new file mode 100644 index 0000000..2502ae5 --- /dev/null +++ b/ui/js/tiles.js @@ -0,0 +1,78 @@ +/** + * tiles.js — Shared tile-mapping logic for village.js and editor.js. + * + * Centralises the char→SVG tile and decoration mappings that were previously + * duplicated across both files. Building-specific overlays, goose animations, + * and editor-only chrome (grid lines, labels) stay in their respective files. + */ + +import { svg } from 'https://cdn.jsdelivr.net/npm/lit-html@3.3.2/+esm'; +import { BUILDING_CHARS } from './buildings.js'; + +/** Canonical tile size in pixels (was GRID_SIZE in village.js, TILE_SIZE in editor.js). */ +export const TILE_SIZE = 40; + +/** + * Return an svg `` element for the given terrain character, or null if + * the character has no terrain tile (e.g. decoration-only chars like 'T'). + * + * Building chars are rendered as plaza tiles — building art overlays are + * handled separately by each consumer. + */ +export function tileForChar(char, px, py) { + switch (char) { + case '~': + return svg``; + case 'P': + return svg``; + case '#': + return svg``; + case ':': + return svg``; + case '+': + return svg``; + case '%': + return svg``; + case '=': + return svg``; + case 'A': + return svg``; + default: + if (BUILDING_CHARS.includes(char)) + return svg``; + return null; + } +} + +/** + * Return an svg `` element for the given decoration character, or null + * if the character is not a standard decoration. + * + * Fountain (K) is intentionally excluded — village.js and editor.js each + * handle it with different top-left detection and rendering logic. + */ +export function decoForChar(char, px, py) { + switch (char) { + case 'T': { + // Deterministic jitter seeded by position to avoid re-render flicker + const seed = (px * 7 + py * 13) & 0xffff; + const jx = px + ((seed % 10) - 5); + const jy = py + (((seed >> 4) % 10) - 5) - 20; + return svg``; + } + case 'O': + return svg``; + case '*': + return svg``; + case 'R': + return svg``; + case 'l': + return svg``; + case 'p': + return svg``; + case 'G': + return svg``; + default: + return null; + } +} diff --git a/ui/js/village.js b/ui/js/village.js new file mode 100644 index 0000000..bdcc84c --- /dev/null +++ b/ui/js/village.js @@ -0,0 +1,562 @@ +import { html, svg } from 'https://cdn.jsdelivr.net/npm/lit-html@3.3.2/+esm'; +import { SvgDefs } from './assets.js'; +import { + BUILDING_CHARS, + BUILDINGS, + inferRole, + ROLE_TO_BUILDING, +} from './buildings.js'; +import { MAP_CONFIG } from './map.js'; +import { decoForChar, TILE_SIZE, tileForChar } from './tiles.js'; + +const SPEECH_DURATION_MS = 8000; +const SPEECH_MAX_LENGTH = 120; +const GOOSE_SPEED_PX_PER_SEC = 160; +const DT_CLAMP_SEC = 0.05; +const HALF_TILE = TILE_SIZE / 2; // Center goose sprite on tile rather than tile top-left + +let TOWN_MAP_GRID = []; +let BUILDING_POSITIONS = {}; +let MAP_WIDTH = 0; +let MAP_HEIGHT = 0; + +function initMap() { + try { + BUILDING_POSITIONS = {}; + + const sections = MAP_CONFIG.split('[Map]'); + const rawMap = (sections[1] || '') + .trim() + .split('\n') + .filter((l) => l.length > 0); + TOWN_MAP_GRID = rawMap.map((line) => line.split('')); + + MAP_WIDTH = (TOWN_MAP_GRID[0]?.length || 0) * TILE_SIZE; + MAP_HEIGHT = TOWN_MAP_GRID.length * TILE_SIZE; + + for (let y = 0; y < TOWN_MAP_GRID.length; y++) { + for (let x = 0; x < TOWN_MAP_GRID[y].length; x++) { + const c = TOWN_MAP_GRID[y][x]; + if (BUILDINGS[c]) + BUILDING_POSITIONS[BUILDINGS[c].key] = { + gridX: x, + gridY: y, + ...BUILDINGS[c], + }; + } + } + } catch (e) { + console.error('[village] Failed to parse map:', e); + } +} +initMap(); + +function findPath(startX, startY, endX, endY, grid) { + if (!grid?.length || !grid[0]?.length) return []; + const w = grid[0].length; + const h = grid.length; + + const getCost = (x, y) => { + if (x < 0 || y < 0 || x >= w || y >= h) return Infinity; + const char = grid[y][x]; + if ( + char === ':' || + char === '+' || + char === '=' || + BUILDING_CHARS.includes(char) + ) + return 1; + if (char === '#') return 2; + if (char === '.' || char === 'A' || char === 'K') return 5; + return Infinity; + }; + + const heuristic = (x, y) => Math.abs(x - endX) + Math.abs(y - endY); + + const openSet = [ + { x: startX, y: startY, g: 0, f: heuristic(startX, startY), parent: null }, + ]; + const closedSet = new Set(); + const hash = (x, y) => `${x},${y}`; + + while (openSet.length > 0) { + openSet.sort((a, b) => a.f - b.f); + const curr = openSet.shift(); + + if (curr.x === endX && curr.y === endY) { + const path = []; + let currNode = curr; + while (currNode) { + if (currNode.parent) path.unshift({ x: currNode.x, y: currNode.y }); + currNode = currNode.parent; + } + return path; + } + + closedSet.add(hash(curr.x, curr.y)); + + const neighbors = [ + { x: curr.x + 1, y: curr.y }, + { x: curr.x - 1, y: curr.y }, + { x: curr.x, y: curr.y + 1 }, + { x: curr.x, y: curr.y - 1 }, + ]; + + for (const n of neighbors) { + if (closedSet.has(hash(n.x, n.y))) continue; + const cost = getCost(n.x, n.y); + if (cost === Infinity) continue; + + const g = curr.g + cost; + const existing = openSet.find((o) => o.x === n.x && o.y === n.y); + + if (!existing) { + openSet.push({ + x: n.x, + y: n.y, + g, + f: g + heuristic(n.x, n.y), + parent: curr, + }); + } else if (g < existing.g) { + existing.g = g; + existing.f = g + heuristic(n.x, n.y); + existing.parent = curr; + } + } + } + return []; +} + +let cachedMapData = null; + +function parseMap() { + if (cachedMapData) return cachedMapData; + const tiles = []; + const decorations = []; + let hasSwimmingGoose = false; + let hasFarmerGoose = false; + + for (let y = 0; y < TOWN_MAP_GRID.length; y++) { + for (let x = 0; x < TOWN_MAP_GRID[y].length; x++) { + const char = TOWN_MAP_GRID[y][x]; + const px = x * TILE_SIZE; + const py = y * TILE_SIZE; + + // --- Terrain tiles (shared via tiles.js) --- + const tile = tileForChar(char, px, py); + if (tile) { + tiles.push(tile); + } + + // --- Special terrain with village-specific side-effects --- + if (char === 'P' && (!hasSwimmingGoose || Math.random() < 0.05)) { + hasSwimmingGoose = true; + const delay = -(Math.random() * 5).toFixed(2); + decorations.push({ + y: py, + element: svg` + + + + + + `, + }); + } else if (char === 'K') { + const isTopLeft = + (x === 0 || TOWN_MAP_GRID[y][x - 1] !== 'K') && + (y === 0 || TOWN_MAP_GRID[y - 1][x] !== 'K'); + if (isTopLeft) { + decorations.push({ + y: py + TILE_SIZE, + element: svg``, + }); + } + } else if (char === 'A' && !hasFarmerGoose) { + hasFarmerGoose = true; + decorations.push({ + y: py + 20, + element: svg` + + + +
+ +
+
+
+
+ `, + }); + } + + // --- Decorations (shared via tiles.js) --- + const deco = decoForChar(char, px, py); + if (deco) { + // decoForChar returns the SVG element; wrap with y-sort key + const decoY = char === 'T' ? py - 20 : char === 'O' ? py - 15 : py; + decorations.push({ y: decoY, element: deco }); + } + } + } + + const ambientForest = []; + for (let y = -400; y < MAP_HEIGHT + 400; y += 40) { + for (let x = -800; x < MAP_WIDTH + 800; x += 40) { + if (x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT) continue; + + let isRiver = false; + const gridX = Math.floor(x / TILE_SIZE); + const gridY = Math.floor(y / TILE_SIZE); + + if (x >= 0 && x < MAP_WIDTH) { + if (y < 0 && TOWN_MAP_GRID[0] && TOWN_MAP_GRID[0][gridX] === '~') + isRiver = true; + if ( + y >= MAP_HEIGHT && + TOWN_MAP_GRID[TOWN_MAP_GRID.length - 1] && + TOWN_MAP_GRID[TOWN_MAP_GRID.length - 1][gridX] === '~' + ) + isRiver = true; + } + if (y >= 0 && y < MAP_HEIGHT) { + if (x < 0 && TOWN_MAP_GRID[gridY] && TOWN_MAP_GRID[gridY][0] === '~') + isRiver = true; + if ( + x >= MAP_WIDTH && + TOWN_MAP_GRID[gridY] && + TOWN_MAP_GRID[gridY][TOWN_MAP_GRID[gridY].length - 1] === '~' + ) + isRiver = true; + } + + if (isRiver) { + tiles.push(svg``); + continue; + } + + if (Math.random() < 0.4) { + const type = Math.random() > 0.4 ? '#tree-pine' : '#tree-oak'; + const jx = x + (Math.random() * 20 - 10); + const jy = y + (Math.random() * 20 - 10); + ambientForest.push({ + y: jy, + element: svg``, + }); + } + } + } + + const allDecorations = [...ambientForest, ...decorations]; + allDecorations.sort((a, b) => a.y - b.y); + + cachedMapData = { + tiles, + decorations: allDecorations.map((d) => d.element), + }; + return cachedMapData; +} + +function getTargetBuilding(role, status) { + if (status === 'complete' || status === 'error' || status === 'idle') + return 'barn'; + return ROLE_TO_BUILDING[role] || 'forge'; +} + +const geeseState = new Map(); + +let loopRunning = false; +let villageVisible = true; // toggled by app.js when village panel shown/hidden +export function setVillageVisible(v) { + villageVisible = v; + if (v) startLoop(); +} + +export function startLoop() { + if (loopRunning) return; + loopRunning = true; + let lastTime = performance.now(); + + function tick(now) { + // Stop when tab hidden or village panel not visible + if (document.visibilityState === 'hidden' || !villageVisible) { + loopRunning = false; + return; + } + + const dt = Math.min((now - lastTime) / 1000, DT_CLAMP_SEC); // clamp — prevents teleport after tab restore + lastTime = now; + + const speed = GOOSE_SPEED_PX_PER_SEC * dt; + let needsRender = false; + let hasActivePath = false; + + for (const [id, goose] of geeseState.entries()) { + if (goose.path && goose.path.length > 0) { + hasActivePath = true; + goose.action = 'walking'; + const targetNode = goose.path[0]; + const targetPx = targetNode.x * TILE_SIZE; + const targetPy = targetNode.y * TILE_SIZE; + + const dx = targetPx - goose.x; + const dy = targetPy - goose.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < speed) { + goose.x = targetPx; + goose.y = targetPy; + goose.path.shift(); + + if (goose.path.length === 0) { + goose.action = goose.status === 'active' ? 'working' : 'idle'; + const barn = BUILDING_POSITIONS.barn; + if ( + barn && + (goose.status === 'complete' || goose.status === 'idle') && + goose.targetGridX === barn.gridX && + goose.targetGridY === barn.gridY + ) + goose.hidden = true; + needsRender = true; + } + } else { + goose.x += (dx / dist) * speed; + goose.y += (dy / dist) * speed; + } + + // Cache DOM refs — cleared on re-render via isConnected check + if (!goose._el || !goose._el.isConnected) { + goose._el = document.getElementById(`goose-wrapper-${id}`); + goose._flipEl = goose._el?.querySelector('.v-goose-flip') || null; + } + const el = goose._el; + if (el) { + // Direct DOM transform is cheaper than re-rendering SVG every frame. + // HALF_TILE centers the sprite on the tile (data uses tile top-left). + el.style.transform = `translate(${goose.x + goose.offsetX + HALF_TILE}px, ${goose.y + goose.offsetY + HALF_TILE}px)`; + const flipEl = goose._flipEl; + if (flipEl) { + if (dx > 0.5) flipEl.style.transform = 'scaleX(1)'; + else if (dx < -0.5) flipEl.style.transform = 'scaleX(-1)'; + } + } + } else { + if (!goose._el || !goose._el.isConnected) { + goose._el = document.getElementById(`goose-wrapper-${id}`); + } + const el = goose._el; + if (el) + el.style.transform = `translate(${goose.x + goose.offsetX + HALF_TILE}px, ${goose.y + goose.offsetY + HALF_TILE}px)`; + } + } + + if (needsRender) { + document.dispatchEvent(new CustomEvent('goosetown-render')); + } + + if (!hasActivePath) { + loopRunning = false; + return; + } + requestAnimationFrame(tick); + } + + requestAnimationFrame(tick); +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + if ([...geeseState.values()].some((g) => g.path?.length)) startLoop(); +}); + +export function updateVillageState(globalState) { + const { + tree = { children: [], sender_map: {} }, + sessions = [], + wallMessages = [], + } = globalState; + const now = Date.now(); + + const allDelegates = [...(tree.children || [])]; + if (tree.parent_session_id) { + const orch = sessions.find((s) => s.id === tree.parent_session_id); + if (orch && !orch.name?.toLowerCase().includes('dashboard')) { + allDelegates.push({ ...orch, role: 'orchestrator' }); + } + } + + const liveIds = new Set(allDelegates.map((d) => d.id)); + + for (const delegate of allDelegates) { + const role = delegate.role || inferRole(delegate.name || delegate.id); + const targetBuildingKey = getTargetBuilding(role, delegate.status); + const barn = BUILDING_POSITIONS.barn; + const targetBuilding = BUILDING_POSITIONS[targetBuildingKey] || barn; + if (!targetBuilding) continue; + + if (!geeseState.has(delegate.id)) { + const isNew = (delegate.elapsed_seconds || 0) < 10; + const spawnBuilding = + isNew || delegate.status === 'complete' || delegate.status === 'idle' + ? barn || targetBuilding + : targetBuilding; + + geeseState.set(delegate.id, { + id: delegate.id, + gtwall_id: delegate.gtwall_id || delegate.name || delegate.id, + role, + x: spawnBuilding.gridX * TILE_SIZE, + y: spawnBuilding.gridY * TILE_SIZE, + offsetX: Math.random() * 50 - 25, + offsetY: Math.random() * 20 - 10, + targetGridX: spawnBuilding.gridX, + targetGridY: spawnBuilding.gridY, + status: delegate.status, + action: 'idle', + hidden: false, + speech: null, + path: [], + }); + } + + const goose = geeseState.get(delegate.id); + goose.status = delegate.status; + + const wantsNewTarget = + goose.targetGridX !== targetBuilding.gridX || + goose.targetGridY !== targetBuilding.gridY; + if (wantsNewTarget) { + const currentGridX = Math.floor(goose.x / TILE_SIZE); + const currentGridY = Math.floor(goose.y / TILE_SIZE); + + const path = findPath( + currentGridX, + currentGridY, + targetBuilding.gridX, + targetBuilding.gridY, + TOWN_MAP_GRID + ); + if (path.length > 0) { + goose.targetGridX = targetBuilding.gridX; + goose.targetGridY = targetBuilding.gridY; + goose.path = path; + goose.hidden = false; + goose.action = 'walking'; + startLoop(); + } + } else if (!goose.path || goose.path.length === 0) { + goose.action = delegate.status === 'active' ? 'working' : 'idle'; + if ( + barn && + (goose.status === 'complete' || goose.status === 'idle') && + goose.targetGridX === barn.gridX && + goose.targetGridY === barn.gridY + ) { + goose.hidden = true; + } else { + goose.hidden = false; + } + } + } + + for (const id of geeseState.keys()) { + if (!liveIds.has(id)) geeseState.delete(id); + } + + // _receivedAt is stamped at ingestion (app.js). Only show recent messages as speech. + const latestBySender = new Map(); + for (const m of wallMessages) { + if (m._receivedAt && now - m._receivedAt < SPEECH_DURATION_MS) { + latestBySender.set(m.sender_id, m); + } + } + + for (const goose of geeseState.values()) { + let senderId = null; + if (goose.role === 'orchestrator') { + senderId = 'orchestrator'; + } else { + for (const [sId, sessionId] of Object.entries(tree.sender_map || {})) { + if (sessionId === goose.id) senderId = sId; + } + } + if (!senderId) senderId = goose.gtwall_id; + + const msg = latestBySender.get(senderId); + if (msg) { + let text = msg.message; + if (text.length > SPEECH_MAX_LENGTH) + text = `${text.substring(0, SPEECH_MAX_LENGTH)}...`; + goose.speech = text; + } else { + goose.speech = null; + } + } + + return geeseState; +} + +export function renderVillage(globalState) { + const geeseMap = updateVillageState(globalState); + const h = new Date().getHours(); + const isDay = h > 6 && h < 18; + const geese = Array.from(geeseMap.values()); + const mapData = parseMap(); + + return html` +
+ + Goosetown Village + ${SvgDefs} + + + ${mapData.tiles} + ${mapData.decorations} + + ${Object.values(BUILDING_POSITIONS).map( + (b) => svg` + + ${b.svg || BUILDINGS.H.svg} + +
${b.label}
+
+
+ ` + )} + + ${geese.map( + (g) => svg` + + ${ + g.speech + ? svg` + + + +
${g.speech}
+
+ ` + : '' + } + +
+
+ + + ${g.role === 'orchestrator' ? svg`` : ''} + +
+
+
+ +
${g.gtwall_id}
+
+
+ ` + )} +
+
+ `; +} diff --git a/ui/village.html b/ui/village.html new file mode 100644 index 0000000..dff83b2 --- /dev/null +++ b/ui/village.html @@ -0,0 +1,18 @@ + + + + + + Goosetown Village + + + + + + + + +
+ + +