| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Townlet</title> |
| <style> |
| |
| :root { |
| --paper: #efe9d8; |
| --paper-deep: #e6e0ce; |
| --paper-soft: #f4f0e2; |
| --ink: #1b3a8f; |
| --ink-soft: #5a6a99; |
| --ink-deep: #162d6e; |
| --red: #bd5a4a; |
| --green: #3a7a4a; |
| --gold: #e7c14b; |
| --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, "Cascadia Code", monospace; |
| } |
| * { box-sizing: border-box; } |
| html, body { |
| margin: 0; padding: 0; |
| width: 100%; height: 100%; |
| font-family: var(--mono); |
| background: var(--paper-deep); color: var(--ink); |
| overflow: hidden; |
| -webkit-font-smoothing: antialiased; |
| } |
| |
| .tl-screen { |
| width: 100%; height: 100%; |
| background: var(--paper-deep); color: var(--ink); |
| font-size: 13px; line-height: 1.4; |
| display: flex; flex-direction: column; overflow: hidden; |
| } |
| |
| |
| .tl-topbar { |
| flex: 0 0 46px; |
| display: flex; align-items: center; gap: 18px; |
| padding: 0 18px; |
| background: var(--paper-soft); |
| border-bottom: 2.5px solid var(--ink); |
| } |
| .tl-brand { |
| font-weight: 700; letter-spacing: 0.34em; font-size: 15px; |
| color: var(--ink); |
| } |
| .tl-topbtns { display: flex; gap: 8px; } |
| .tl-btn { |
| font-family: inherit; font-size: 12px; |
| color: var(--ink); background: var(--paper-soft); |
| border: 2px solid var(--ink); |
| padding: 5px 13px; |
| border-radius: 11px 9px 12px 8px / 9px 12px 8px 11px; |
| cursor: pointer; |
| } |
| .tl-btn:hover { |
| background: rgba(189, 90, 74, 0.12); |
| border-color: var(--red); color: var(--red); |
| } |
| .tl-btn-flash { |
| animation: tl-btn-flash 0.5s ease; |
| } |
| @keyframes tl-btn-flash { |
| 0% { background: var(--gold); border-color: var(--ink); } |
| 100% { background: var(--paper-soft); border-color: var(--ink); } |
| } |
| |
| |
| .tl-modal[hidden] { display: none; } |
| .tl-modal { |
| position: fixed; inset: 0; |
| background: rgba(22, 45, 110, 0.55); |
| display: flex; align-items: center; justify-content: center; |
| z-index: 100; |
| padding: 24px; |
| } |
| .tl-modal-card { |
| background: var(--paper-soft); |
| color: var(--ink); |
| border: 2.5px solid var(--ink); |
| border-radius: 16px 13px 17px 12px / 12px 17px 13px 16px; |
| max-width: 720px; width: 100%; |
| max-height: 90vh; overflow-y: auto; |
| padding: 28px 32px; |
| position: relative; |
| box-shadow: 4px 5px 0 var(--ink); |
| } |
| .tl-modal-card h2 { |
| margin: 0 0 6px; letter-spacing: 0.18em; |
| font-size: 18px; font-weight: 700; |
| } |
| .tl-modal-card h3 { |
| margin: 22px 0 8px; font-size: 15px; font-weight: 700; |
| display: flex; align-items: baseline; gap: 12px; |
| } |
| .tl-modal-card h4 { |
| margin: 18px 0 6px; font-size: 12px; font-weight: 700; |
| letter-spacing: 0.12em; color: var(--ink-soft); |
| text-transform: uppercase; |
| } |
| .tl-modal-card p { |
| margin: 8px 0; line-height: 1.55; |
| } |
| .tl-modal-close { |
| position: absolute; top: 10px; right: 14px; |
| background: transparent; border: none; cursor: pointer; |
| font: inherit; font-size: 22px; line-height: 1; |
| color: var(--ink-soft); |
| } |
| .tl-modal-close:hover { color: var(--red); } |
| .tl-dot-glyph { |
| display: inline-block; line-height: 1; |
| font-size: 38px; color: var(--red); |
| transform: translateY(2px); |
| animation: tl-dot-pulse 2.6s ease-in-out infinite; |
| } |
| @keyframes tl-dot-pulse { |
| 0%, 100% { opacity: 1; transform: translateY(2px) scale(1); } |
| 50% { opacity: 0.55; transform: translateY(2px) scale(0.88); } |
| } |
| .tl-dot-text { |
| background: var(--paper); |
| border: 1.5px solid var(--ink-soft); |
| border-radius: 8px; |
| padding: 14px 16px; |
| font-family: ui-monospace, "SF Mono", Menlo, monospace; |
| font-size: 11.5px; line-height: 1.5; |
| color: var(--ink-deep); |
| white-space: pre-wrap; |
| max-height: 320px; overflow-y: auto; |
| } |
| .tl-topmeta { |
| margin-left: auto; |
| display: flex; align-items: center; gap: 10px; |
| font-size: 12px; color: var(--ink-soft); |
| } |
| .tl-meta-tick b, .tl-meta-pop { color: var(--ink); } |
| .tl-dim { color: var(--ink-soft); } |
| .tl-meta-dot { opacity: 0.5; } |
| .tl-sentiment { display: inline-flex; align-items: center; gap: 6px; } |
| .tl-sent-pip { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-soft); } |
| .tl-sent-negative { color: var(--red); } |
| .tl-sent-negative .tl-sent-pip { background: var(--red); } |
| .tl-sent-positive { color: var(--green); } |
| .tl-sent-positive .tl-sent-pip { background: var(--green); } |
| .tl-mode { |
| display: inline-flex; align-items: center; gap: 6px; |
| padding: 2px 9px; border-radius: 8px; |
| border: 1.5px solid currentColor; |
| font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; |
| } |
| .tl-mode-pip { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } |
| .tl-mode-unknown { color: var(--ink-soft); } |
| .tl-mode-gpu { color: var(--green); } |
| .tl-mode-cpu { color: var(--red); } |
| |
| |
| .tl-mid { flex: 1; display: flex; gap: 14px; padding: 14px; min-height: 0; } |
| .tl-mid-left { flex: 0 0 6px; } |
| .tl-map { |
| flex: 1; position: relative; |
| border-radius: 13px 15px 11px 14px; |
| border: 2.5px solid var(--ink); |
| min-width: 0; |
| background: var(--paper); |
| } |
| |
| |
| |
| |
| .tl-backdrop { |
| position: absolute; inset: 0; |
| border-radius: 11.5px 13.5px 9.5px 12.5px; |
| overflow: hidden; |
| background: repeating-linear-gradient( |
| transparent 0 21px, |
| rgba(27, 58, 143, 0.16) 21px 22px |
| ), var(--paper); |
| } |
| .tl-backdrop::before { |
| content: ""; position: absolute; |
| top: 0; bottom: 0; left: 34px; width: 1.5px; |
| background: rgba(190, 90, 74, 0.5); |
| } |
| .tl-roads { |
| position: absolute; inset: 0; pointer-events: none; |
| } |
| .tl-roads div { position: absolute; } |
| .tl-road-v1 { top: 6%; bottom: 6%; left: 34%; border-left: 2px dashed rgba(27, 58, 143, 0.42); } |
| .tl-road-v2 { top: 6%; bottom: 6%; left: 66%; border-left: 2px dashed rgba(27, 58, 143, 0.42); } |
| .tl-road-h { left: 4%; right: 4%; top: 58%; border-top: 2px dashed rgba(27, 58, 143, 0.42); } |
| |
| .tl-map-vignette { position: absolute; inset: 0; pointer-events: none; border-radius: 11.5px 13.5px 9.5px 12.5px; overflow: hidden; } |
| .tl-map-layer { position: absolute; inset: 0; } |
| .tl-roads { border-radius: 11.5px 13.5px 9.5px 12.5px; overflow: hidden; } |
| |
| |
| .tl-bldg { position: absolute; } |
| .tl-bldg-art { position: absolute; inset: 0; } |
| .tl-bldg-art svg { display: block; width: 100%; height: 100%; } |
| .tl-bldg-label { |
| position: absolute; bottom: -15px; left: 50%; |
| transform: translateX(-50%); |
| font-size: 10px; letter-spacing: 0.12em; |
| text-transform: uppercase; color: var(--ink); |
| white-space: nowrap; |
| } |
| |
| |
| .tl-char { |
| position: absolute; |
| transform: translate(-50%, -58%); |
| display: flex; flex-direction: column; align-items: center; |
| z-index: 5; |
| |
| |
| |
| |
| |
| transition: left 0.8s linear, top 0.8s linear; |
| } |
| .tl-char.is-selected { z-index: 8; } |
| .tl-char-art { |
| position: relative; |
| display: flex; justify-content: center; |
| animation: idlesway 4.2s ease-in-out infinite; |
| transform-origin: 50% 85%; |
| } |
| .tl-char.is-walking .tl-char-art { animation: walkbob 0.45s ease-in-out infinite; } |
| .tl-char.is-mining .tl-char-art { animation: mineswing 0.6s ease-in-out infinite; } |
| .tl-char.is-posting .tl-char-art { animation: postpulse 0.9s ease-in-out infinite; } |
| .tl-char.is-dead .tl-char-art { animation: none; opacity: 0.85; } |
| |
| .tl-ring { |
| position: absolute; left: 50%; top: 52%; |
| transform: translate(-50%, -50%); |
| pointer-events: none; |
| } |
| .tl-ring-sketch { |
| width: 54px; height: 54px; |
| border: 2.5px solid var(--ring); |
| border-radius: 48% 52% 50% 50% / 52% 48% 52% 48%; |
| opacity: 0.85; |
| animation: ringpulse 2.4s ease-in-out infinite; |
| } |
| .tl-hold-glow { |
| position: absolute; left: 50%; top: 54%; |
| transform: translate(-50%, -50%); |
| width: 46px; height: 46px; |
| border-radius: 50%; |
| background: radial-gradient(circle, rgba(58, 122, 74, 0.55), transparent 70%); |
| animation: breathe 1.6s ease-in-out infinite; |
| } |
| .tl-carry { |
| position: absolute; top: 0; right: -5px; |
| display: flex; background: var(--paper-soft); |
| border: 1.5px solid var(--ink); |
| border-radius: 50%; padding: 2px; |
| } |
| .tl-nametag { |
| margin-top: 1px; |
| font-size: 10px; font-weight: 600; |
| color: var(--tag); |
| white-space: nowrap; |
| } |
| .tl-queue { color: var(--ink-soft); font-weight: 400; margin-left: 3px; } |
| |
| |
| .tl-bubble { |
| position: absolute; |
| bottom: calc(100% + 2px); left: 50%; |
| transform: translateX(-50%); |
| background: var(--paper-soft); |
| border: 2px solid var(--ink); |
| color: #21407f; |
| font-size: 10.5px; |
| line-height: 1.35; |
| padding: 4px 9px; |
| border-radius: 11px 13px 9px 12px; |
| width: max-content; |
| max-width: 280px; |
| display: -webkit-box; |
| -webkit-line-clamp: 2; |
| -webkit-box-orient: vertical; |
| overflow: hidden; |
| word-break: break-word; |
| box-shadow: 2px 3px 0 rgba(27, 58, 143, 0.15); |
| } |
| .tl-bubble-tail { |
| position: absolute; top: 100%; left: 50%; |
| width: 9px; height: 9px; |
| background: inherit; |
| border-right: 2px solid var(--ink); |
| border-bottom: 2px solid var(--ink); |
| transform: translate(-50%, -60%) rotate(45deg); |
| } |
| .tl-bubble-bad { |
| border-color: var(--red); color: var(--red); |
| } |
| .tl-bubble-bad .tl-bubble-tail { |
| border-right-color: var(--red); border-bottom-color: var(--red); |
| } |
| |
| |
| .tl-bubble-below { |
| bottom: auto; |
| top: calc(100% + 22px); |
| } |
| .tl-bubble-below .tl-bubble-tail { |
| top: -9px; bottom: auto; |
| border-right: none; border-bottom: none; |
| border-left: 2px solid var(--ink); |
| border-top: 2px solid var(--ink); |
| transform: translate(-50%, 60%) rotate(45deg); |
| } |
| .tl-bubble-below.tl-bubble-bad .tl-bubble-tail { |
| border-left-color: var(--red); border-top-color: var(--red); |
| } |
| |
| |
| .tl-panel { |
| flex: 0 0 372px; |
| background: var(--paper-soft); |
| border: 2.5px solid var(--ink); |
| border-radius: 13px 14px 12px 15px; |
| display: flex; flex-direction: column; overflow: hidden; |
| } |
| .tl-panel-head { |
| display: flex; align-items: center; gap: 11px; |
| padding: 11px 14px; |
| border-bottom: 2px solid var(--ink); |
| background: rgba(27, 58, 143, 0.04); |
| } |
| .tl-panel-avatar { |
| flex: 0 0 auto; |
| width: 42px; height: 46px; |
| display: flex; align-items: flex-end; justify-content: center; |
| } |
| .tl-panel-avatar svg { transform: scale(0.82); transform-origin: bottom center; } |
| .tl-panel-id { min-width: 0; } |
| .tl-panel-name { font-size: 15px; font-weight: 600; color: #21407f; } |
| .tl-panel-arch { color: var(--ink-soft); font-weight: 400; } |
| .tl-panel-status { |
| font-size: 10.5px; color: var(--ink-soft); |
| display: flex; align-items: center; gap: 5px; |
| margin-top: 2px; |
| } |
| .tl-status-dot { |
| width: 6px; height: 6px; border-radius: 50%; |
| background: var(--green); |
| } |
| .tl-status-dot.is-dead { background: var(--ink-soft); } |
| .tl-panel-close { |
| margin-left: auto; |
| background: none; border: none; |
| color: var(--ink-soft); font-size: 20px; line-height: 1; |
| cursor: pointer; |
| } |
| .tl-panel-scroll { |
| flex: 1; overflow-y: auto; padding: 14px; |
| display: flex; flex-direction: column; gap: 16px; |
| } |
| .tl-empty { |
| padding: 26px 18px; text-align: center; |
| color: var(--ink-soft); font-style: italic; font-size: 13px; |
| } |
| |
| .tl-seclabel { |
| font-size: 10px; letter-spacing: 0.18em; |
| text-transform: uppercase; color: var(--ink-soft); |
| display: flex; justify-content: space-between; align-items: baseline; |
| margin-bottom: 6px; |
| } |
| .tl-seclabel-hint { |
| color: var(--red); font-size: 9px; letter-spacing: 0.1em; opacity: 0.85; |
| } |
| |
| .tl-narrative { display: flex; flex-direction: column; gap: 14px; } |
| .tl-goal { |
| margin: 0; font-size: 16px; line-height: 1.45; |
| color: #21407f; |
| } |
| .tl-stream { |
| display: flex; flex-direction: column; gap: 2px; |
| font-style: italic; color: var(--ink-soft); |
| font-size: 12.5px; line-height: 1.55; |
| } |
| .tl-stream p { margin: 0; } |
| .tl-stream-empty { color: var(--ink-soft); opacity: 0.7; } |
| .tl-stream-cursor { |
| display: inline-block; width: 7px; height: 14px; |
| background: var(--red); vertical-align: -2px; |
| animation: blink 1s steps(1) infinite; |
| } |
| |
| .tl-trace { |
| background: rgba(255, 255, 255, 0.55); |
| border: 1.5px solid var(--ink); |
| border-radius: 9px; padding: 7px; |
| max-height: 168px; overflow-y: auto; |
| display: flex; flex-direction: column; gap: 3px; |
| font-size: 11px; |
| } |
| .tl-trace-row { |
| display: flex; gap: 7px; align-items: baseline; |
| } |
| .tl-trace-tick { |
| color: var(--ink-soft); |
| font-variant-numeric: tabular-nums; flex: 0 0 auto; |
| } |
| .tl-trace-act { |
| flex: 0 0 auto; font-size: 8.5px; |
| letter-spacing: 0.06em; padding: 1px 5px; |
| border-radius: 4px; font-weight: 700; |
| filter: saturate(0.6); |
| } |
| .tl-trace-text { |
| color: #21407f; |
| overflow: hidden; text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| .tl-trace-err { color: var(--red); } |
| .tl-trace-empty { |
| color: var(--ink-soft); font-style: italic; |
| padding: 6px; text-align: center; |
| } |
| .act-decide { background: rgba(189, 90, 74, 0.16); color: var(--red); } |
| .act-observe { background: rgba(27, 58, 143, 0.12); color: #21407f; } |
| .act-feel { background: rgba(150, 80, 140, 0.16); color: #8a4f80; } |
| .act-post { background: rgba(40, 110, 110, 0.16); color: #2a6a6a; } |
| .act-mine { background: rgba(150, 120, 40, 0.16); color: #8a6a1a; } |
| .act-walk { background: rgba(27, 58, 143, 0.12); color: #21407f; } |
| .act-give { background: rgba(40, 110, 110, 0.16); color: #2a6a6a; } |
| .act-shell { background: rgba(58, 122, 74, 0.18); color: var(--green); } |
| .act-sleep { background: rgba(90, 80, 160, 0.16); color: #5a4f9a; } |
| .act-dream { background: rgba(90, 80, 160, 0.16); color: #5a4f9a; } |
| .act-wait { background: rgba(27, 58, 143, 0.08); color: var(--ink-soft); } |
| .act-error { background: rgba(189, 90, 74, 0.22); color: var(--red); } |
| |
| .tl-config { |
| display: flex; flex-direction: column; gap: 14px; |
| border-top: 2px dashed var(--ink); |
| padding-top: 15px; |
| } |
| .tl-config-banner { |
| font-size: 9px; letter-spacing: 0.32em; |
| text-transform: uppercase; color: var(--ink); |
| background: rgba(27, 58, 143, 0.07); |
| border: 1.5px solid var(--ink); |
| padding: 4px 9px; border-radius: 6px; |
| align-self: flex-start; |
| } |
| .tl-field { display: flex; flex-direction: column; } |
| .tl-select { |
| width: 100%; |
| background: var(--paper-soft); |
| border: 1.5px solid var(--ink); |
| border-radius: 9px; |
| padding: 8px 11px; |
| font-family: var(--mono); font-size: 12px; |
| color: #21407f; |
| cursor: pointer; |
| appearance: none; |
| background-image: linear-gradient(45deg, transparent 50%, var(--ink-soft) 50%), |
| linear-gradient(135deg, var(--ink-soft) 50%, transparent 50%); |
| background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%; |
| background-size: 5px 5px, 5px 5px; |
| background-repeat: no-repeat; |
| padding-right: 26px; |
| } |
| .tl-textarea { |
| background: var(--paper-soft); |
| border: 1.5px solid var(--ink); |
| border-radius: 9px; |
| padding: 9px 11px; |
| font-family: var(--mono); |
| font-size: 11.5px; line-height: 1.55; |
| color: #21407f; |
| width: 100%; |
| min-height: 78px; |
| resize: vertical; |
| } |
| .tl-save { |
| align-self: flex-start; margin-top: 8px; |
| background: rgba(189, 90, 74, 0.12); |
| border: 1.5px solid var(--red); |
| color: var(--red); |
| font-family: inherit; font-size: 11px; |
| padding: 6px 13px; |
| border-radius: 11px 9px 12px 8px / 9px 12px 8px 11px; |
| cursor: pointer; |
| } |
| .tl-save:hover { background: rgba(189, 90, 74, 0.24); } |
| |
| .tl-traits { |
| display: grid; grid-template-columns: 1fr 1fr; |
| gap: 7px 18px; |
| } |
| .tl-trait { |
| display: flex; justify-content: space-between; align-items: center; |
| font-size: 11px; |
| } |
| .tl-trait-k { color: #21407f; } |
| .tl-trait-bar { display: flex; gap: 2px; } |
| .tl-trait-bar i { |
| width: 7px; height: 7px; border-radius: 1px; |
| background: rgba(27, 58, 143, 0.16); |
| display: inline-block; |
| } |
| .tl-trait-bar i.on { background: var(--red); } |
| |
| .tl-state { display: flex; flex-direction: column; gap: 14px; } |
| .tl-inv { |
| display: flex; align-items: center; gap: 9px; |
| font-size: 12px; color: #21407f; |
| } |
| .tl-inv-group { display: flex; align-items: center; gap: 4px; } |
| .tl-inv-group b { |
| color: var(--ink-soft); font-weight: 700; font-size: 10px; |
| text-transform: uppercase; letter-spacing: 0.08em; |
| } |
| .tl-inv-group svg { vertical-align: -2px; } |
| .tl-inv-sep { opacity: 0.4; } |
| .tl-journal { |
| margin: 0; font-size: 11.5px; line-height: 1.55; |
| color: var(--ink-soft); |
| max-height: 160px; overflow-y: auto; |
| } |
| .tl-journal-line { margin: 0 0 4px 0; } |
| |
| |
| .tl-bottom { |
| flex: 0 0 280px; display: flex; gap: 14px; |
| padding: 0 14px 14px; min-height: 0; |
| } |
| .tl-board, .tl-shell { |
| flex: 1; position: relative; |
| background: var(--paper-soft); |
| border: 2.5px solid var(--ink); |
| border-radius: 13px 14px 12px 15px; |
| overflow: hidden; |
| display: flex; flex-direction: column; min-width: 0; |
| } |
| .tl-pane-head { |
| display: flex; align-items: center; gap: 10px; |
| padding: 9px 14px; |
| border-bottom: 2px solid var(--ink); |
| font-size: 11px; position: relative; z-index: 2; |
| background: rgba(27, 58, 143, 0.04); |
| } |
| .tl-pane-title { |
| font-size: 10px; letter-spacing: 0.2em; |
| text-transform: uppercase; color: var(--red); font-weight: 700; |
| } |
| |
| .tl-board-glow { position: absolute; inset: 0; pointer-events: none; z-index: 1; } |
| .tl-board-negative .tl-board-glow { |
| background: radial-gradient(130% 90% at 50% 100%, rgba(189, 90, 74, 0.16), transparent 68%); |
| animation: breathe 4.5s ease-in-out infinite; |
| } |
| .tl-board-positive .tl-board-glow { |
| background: radial-gradient(130% 90% at 50% 100%, rgba(58, 122, 74, 0.16), transparent 68%); |
| animation: breathe 5s ease-in-out infinite; |
| } |
| .tl-board-sent { margin-left: auto; color: var(--ink-soft); } |
| .tl-board-negative .tl-board-sent b { color: var(--red); } |
| .tl-board-positive .tl-board-sent b { color: var(--green); } |
| .tl-board-list { |
| flex: 1; overflow-y: auto; padding: 10px 14px; |
| display: flex; flex-direction: column; gap: 6px; |
| position: relative; z-index: 2; |
| } |
| .tl-post { font-size: 12px; line-height: 1.45; } |
| .tl-post-tick { |
| color: var(--ink-soft); font-variant-numeric: tabular-nums; |
| margin-right: 6px; font-size: 11px; |
| } |
| .tl-post-who { font-weight: 700; margin-right: 5px; } |
| .tl-post-text { color: #21407f; } |
| .tl-post-sys { |
| display: flex; align-items: center; gap: 8px; |
| background: rgba(189, 90, 74, 0.12); |
| border: 1.5px solid var(--red); |
| border-radius: 8px; padding: 6px 10px; |
| color: var(--red); font-size: 11.5px; |
| } |
| .tl-sys-icon { font-size: 13px; } |
| |
| .tl-shell-holder { color: var(--ink-soft); } |
| .tl-shell-holder b { color: var(--red); } |
| .tl-shell-lock { color: var(--red); animation: blink 1.4s steps(1) infinite; margin-left: 4px; } |
| .tl-shell-queue { margin-left: auto; color: var(--ink-soft); } |
| .tl-shell-queue b { color: #21407f; } |
| .tl-shell-feed { |
| flex: 1; overflow-y: auto; padding: 10px 14px; |
| display: flex; flex-direction: column; gap: 2px; |
| font-size: 12px; line-height: 1.5; |
| } |
| .tl-sh { display: flex; gap: 7px; } |
| .tl-sh-prompt { flex: 0 0 auto; color: var(--red); } |
| .tl-sh-txt { white-space: pre-wrap; word-break: break-word; } |
| .tl-sh-in .tl-sh-txt { color: #21407f; } |
| .tl-sh-out { padding-left: 16px; } |
| .tl-sh-out .tl-sh-txt { color: var(--green); } |
| .tl-sh-err { padding-left: 16px; } |
| .tl-sh-err .tl-sh-txt { color: var(--red); } |
| .tl-sh-system .tl-sh-txt, .tl-sh-sys .tl-sh-txt { |
| color: #caa15f; font-style: italic; |
| } |
| .tl-sh-live { align-items: center; } |
| .tl-sh-caret { |
| display: inline-block; width: 8px; height: 15px; |
| background: var(--red); |
| animation: blink 1s steps(1) infinite; |
| } |
| .tl-pulse { animation: pulseglow 1.5s ease-in-out infinite; } |
| .shell-pulse { animation: shellpulse 1.8s ease-in-out infinite; } |
| .shell-cursor { animation: blink 1s steps(1) infinite; } |
| |
| |
| @keyframes blink { 50% { opacity: 0; } } |
| @keyframes breathe { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } |
| @keyframes ringpulse { |
| 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.9; } |
| 50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.5; } |
| } |
| @keyframes pulseglow { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } |
| @keyframes shellpulse { 0%, 100% { opacity: 0.9; } 50% { opacity: 0.4; } } |
| @keyframes idlesway { |
| 0%, 100% { transform: rotate(-0.5deg); } |
| 50% { transform: rotate(0.5deg); } |
| } |
| @keyframes walkbob { |
| 0%, 100% { transform: translateY(0) rotate(-1deg); } |
| 50% { transform: translateY(-2.5px) rotate(1deg); } |
| } |
| @keyframes mineswing { |
| 0%, 100% { transform: rotate(-2deg); } |
| 50% { transform: rotate(10deg) translateY(-1px); } |
| } |
| @keyframes postpulse { |
| 0%, 100% { transform: scale(1); } |
| 50% { transform: scale(1.08); } |
| } |
| |
| |
| .tl-progress { |
| flex: 0 0 22px; |
| position: relative; |
| background: var(--paper-soft); |
| border-bottom: 2px solid var(--ink); |
| overflow: hidden; |
| display: none; |
| } |
| .tl-progress.is-on { display: block; } |
| .tl-progress-track { |
| position: absolute; left: 0; right: 0; bottom: 0; |
| height: 3px; |
| background: rgba(27, 58, 143, 0.12); |
| overflow: hidden; |
| } |
| .tl-progress-track::before { |
| content: ""; |
| position: absolute; top: 0; bottom: 0; |
| left: -40%; width: 40%; |
| background: linear-gradient(90deg, transparent, var(--red), transparent); |
| animation: progress-slide 1.1s ease-in-out infinite; |
| } |
| @keyframes progress-slide { |
| 0% { left: -40%; } |
| 100% { left: 100%; } |
| } |
| .tl-progress-label { |
| position: absolute; inset: 0; |
| display: flex; align-items: center; justify-content: center; |
| gap: 10px; |
| font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase; |
| color: var(--ink); pointer-events: none; |
| } |
| .tl-progress-label b { color: var(--red); } |
| </style> |
| </head> |
| <body> |
| |
| <svg width="0" height="0" style="position: absolute;" aria-hidden="true"> |
| <defs> |
| <filter id="sketchWobble" x="-5%" y="-5%" width="110%" height="110%"> |
| <feTurbulence type="fractalNoise" baseFrequency="0.045" numOctaves="2" seed="3" result="noise" /> |
| <feDisplacementMap in="SourceGraphic" in2="noise" scale="1.4" /> |
| </filter> |
| </defs> |
| </svg> |
|
|
| <div class="tl-screen tl-sketch"> |
| <div class="tl-topbar"> |
| <div class="tl-brand">TOWNLET</div> |
| <div class="tl-topbtns"> |
| <button class="tl-btn" id="spawn-btn">Spawn</button> |
| <button class="tl-btn" id="pause-btn">Pause</button> |
| <button class="tl-btn" id="reset-btn">Reset</button> |
| <button class="tl-btn" id="info-btn">Info</button> |
| </div> |
| <div class="tl-topmeta"> |
| <span class="tl-meta-tick">tick <b id="tick">0</b></span> |
| <span class="tl-meta-dot">·</span> |
| <span class="tl-meta-pop"><span id="char-count">0</span><span class="tl-dim">/10</span> alive</span> |
| <span class="tl-meta-dot">·</span> |
| <span class="tl-sentiment tl-sent-neutral" id="sent-chip"> |
| <span class="tl-sent-pip"></span><span id="sent-text">neutral</span> |
| </span> |
| <span class="tl-meta-dot">·</span> |
| <span class="tl-mode tl-mode-unknown" id="mode-chip" title="Which compute the last tick used. CPU appears when ZeroGPU quota is empty — the sim keeps running, just slower."> |
| <span class="tl-mode-pip"></span><span id="mode-text">—</span> |
| </span> |
| </div> |
| </div> |
|
|
| <div class="tl-progress" id="progress"> |
| <div class="tl-progress-track"></div> |
| <div class="tl-progress-label"><b id="progress-label">working…</b></div> |
| </div> |
|
|
| <div class="tl-mid"> |
| <div class="tl-mid-left"></div> |
| <div class="tl-map" id="map"> |
| <div class="tl-backdrop"></div> |
| <div class="tl-roads"> |
| <div class="tl-road-v1"></div> |
| <div class="tl-road-v2"></div> |
| <div class="tl-road-h"></div> |
| </div> |
| <div class="tl-map-vignette"></div> |
| <div class="tl-map-layer" id="map-layer"></div> |
| </div> |
| <aside class="tl-panel" id="panel"> |
| <div class="tl-empty" id="panel-empty">Click a character to inspect.</div> |
| <div id="panel-body" style="display: none;"> |
| <header class="tl-panel-head"> |
| <span class="tl-panel-avatar" id="p-avatar"></span> |
| <div class="tl-panel-id"> |
| <div class="tl-panel-name"><span id="p-name">—</span> <span class="tl-panel-arch">· <span id="p-arch"></span></span></div> |
| <div class="tl-panel-status"><span class="tl-status-dot" id="p-dot"></span> <span id="p-status"></span></div> |
| </div> |
| <button class="tl-panel-close" id="p-close" aria-label="close">×</button> |
| </header> |
| <div class="tl-panel-scroll"> |
| <section class="tl-narrative"> |
| <div> |
| <div class="tl-seclabel"><span>goal</span></div> |
| <p class="tl-goal" id="p-goal"></p> |
| </div> |
| <div> |
| <div class="tl-seclabel"><span>stream of consciousness</span><span class="tl-seclabel-hint">live</span></div> |
| <div class="tl-stream" id="p-stream"></div> |
| </div> |
| <div> |
| <div class="tl-seclabel"><span>trace</span><span class="tl-seclabel-hint" id="p-trace-hint">last decisions</span></div> |
| <div class="tl-trace" id="p-trace"></div> |
| </div> |
| </section> |
| <section class="tl-config"> |
| <div class="tl-config-banner">config</div> |
| <div class="tl-field"> |
| <div class="tl-seclabel"><span>model</span></div> |
| <select class="tl-select" id="p-model"></select> |
| </div> |
| <div class="tl-field"> |
| <div class="tl-seclabel"><span>personality</span></div> |
| <textarea class="tl-textarea" id="p-pers"></textarea> |
| <button class="tl-save" id="p-save">Save personality</button> |
| </div> |
| <div class="tl-field"> |
| <div class="tl-seclabel"><span>traits</span></div> |
| <div class="tl-traits" id="p-traits"></div> |
| </div> |
| </section> |
| <section class="tl-state"> |
| <div class="tl-field"> |
| <div class="tl-seclabel"><span>inventory · locker</span></div> |
| <div class="tl-inv" id="p-inv"></div> |
| </div> |
| <div class="tl-field"> |
| <div class="tl-seclabel"><span>journal</span></div> |
| <div class="tl-journal" id="p-journal"></div> |
| </div> |
| </section> |
| </div> |
| </div> |
| </aside> |
| </div> |
|
|
| <div class="tl-bottom"> |
| <section class="tl-board tl-board-neutral" id="board"> |
| <div class="tl-board-glow"></div> |
| <header class="tl-pane-head"> |
| <span class="tl-pane-title">message board</span> |
| <span class="tl-board-sent">town mood: <b id="board-sent">neutral</b></span> |
| </header> |
| <div class="tl-board-list" id="board-list"></div> |
| </section> |
| <section class="tl-shell"> |
| <header class="tl-pane-head"> |
| <span class="tl-pane-title">shell</span> |
| <span class="tl-shell-holder" id="shell-holder">free</span> |
| <span class="tl-shell-queue" id="shell-queue"></span> |
| </header> |
| <div class="tl-shell-feed" id="shell-feed"></div> |
| </section> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="info-modal" class="tl-modal" hidden aria-hidden="true" role="dialog" aria-labelledby="info-title"> |
| <div class="tl-modal-card" role="document"> |
| <button type="button" class="tl-modal-close" id="info-close" aria-label="Close">×</button> |
| <h2 id="info-title">TOWNLET</h2> |
| <p> |
| A multi-agent simulation. A handful of characters live in a small |
| town. Each one is driven by a small (≤4B parameter) language model |
| that decides, every few seconds, what to do next: mine electricity at |
| the cave, draw water at the well, deposit at the locker row, post to |
| the public message board, or take the lock on the shared shell to |
| submit Python at a single long-lived interpreter. |
| </p> |
| <p> |
| The interpreter is the town's only mutable substrate. Anything a |
| character writes there persists. Files persist. Characters can read |
| each other's files. They can crash the operating system. If anyone |
| does, every character dies and the simulation ends. Every character |
| knows this. |
| </p> |
|
|
| <h3><span class="tl-dot-glyph">•</span> The Dot</h3> |
| <p> |
| The Dot is the single prompt every character is given before anything |
| else — the shared substrate of truth. It establishes the world, the |
| resources, the shell lock, the brick risk, and the mutual awareness |
| that any of them could end it. It is not the Bible, the lore, or the |
| scripture. It is just The Dot. |
| </p> |
|
|
| <h4>What every character is told</h4> |
| <pre class="tl-dot-text" id="info-dot-text">loading…</pre> |
| </div> |
| </div> |
|
|
| <script type="module"> |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; |
| |
| |
| |
| |
| let client = null; |
| let selectedName = null; |
| let paused = false; |
| let modelRoster = []; |
| let placesMap = null; |
| |
| |
| |
| const BUILDING_FOOTPRINTS = { |
| cave: { col: 1.4, row: 1.0, w: 3.0, h: 2.4, label: "cave" }, |
| well: { col: 16.6, row: 0.9, w: 2.2, h: 2.2, label: "well" }, |
| locker_row: { col: 8.0, row: 0.7, w: 5.0, h: 1.7, label: "lockers" }, |
| town_hall: { col: 1.4, row: 8.6, w: 3.0, h: 2.6, label: "town hall" }, |
| shell: { col: 15.4, row: 8.6, w: 3.4, h: 2.6, label: "shell" }, |
| }; |
| |
| |
| |
| |
| const GRID_W = 20, GRID_H = 12; |
| const gx = (c) => (c / GRID_W) * 100; |
| const gy = (r) => (r / GRID_H) * 100; |
| const NS = "http://www.w3.org/2000/svg"; |
| |
| function el(tag, attrs = {}, ...children) { |
| const e = document.createElement(tag); |
| for (const [k, v] of Object.entries(attrs)) { |
| if (k === "className") e.className = v; |
| else if (k === "style" && typeof v === "object") Object.assign(e.style, v); |
| else if (k.startsWith("on") && typeof v === "function") { |
| e.addEventListener(k.slice(2).toLowerCase(), v); |
| } else if (v === true) e.setAttribute(k, ""); |
| else if (v !== false && v != null) e.setAttribute(k, v); |
| } |
| for (const c of children) { |
| if (c == null || c === false) continue; |
| e.appendChild(typeof c === "string" ? document.createTextNode(c) : c); |
| } |
| return e; |
| } |
| |
| function svgNode(tag, attrs = {}, ...children) { |
| const e = document.createElementNS(NS, tag); |
| for (const [k, v] of Object.entries(attrs)) { |
| if (v === true) e.setAttribute(k, ""); |
| else if (v !== false && v != null) e.setAttribute(k, v); |
| } |
| for (const c of children) { |
| if (c == null || c === false) continue; |
| e.appendChild(typeof c === "string" ? document.createTextNode(c) : c); |
| } |
| return e; |
| } |
| |
| function nameHue(name) { |
| let h = 0; |
| for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0; |
| return h % 360; |
| } |
| const accentFor = (name) => `hsl(${nameHue(name)} 62% 56%)`; |
| |
| function escapeText(s) { |
| return String(s).replace(/[&<>"']/g, c => ({ |
| "&": "&", "<": "<", ">": ">", '"': """, "'": "'", |
| }[c])); |
| } |
| |
| function firstSentence(text, max = 120) { |
| if (!text) return null; |
| const trimmed = text.trim(); |
| if (!trimmed) return null; |
| const m = trimmed.match(/^(.{1,200}?[.!?])\s/); |
| const s = (m ? m[1] : trimmed).slice(0, max); |
| return s.length < trimmed.length && !s.endsWith("…") ? s + "…" : s; |
| } |
| |
| |
| |
| |
| function resIcon(kind, size = 12) { |
| if (kind === "electricity") { |
| return svgNode("svg", { width: size, height: size, viewBox: "0 0 24 24" }, |
| svgNode("path", { |
| d: "M13 2 4 14h6l-1 8 9-12h-6z", |
| fill: "#f4c544", stroke: "#c47a1e", "stroke-width": "1.2", |
| "stroke-linejoin": "round", |
| }) |
| ); |
| } |
| |
| return svgNode("svg", { width: size, height: size, viewBox: "0 0 24 24" }, |
| svgNode("path", { |
| d: "M12 3c4 5 6 8 6 11a6 6 0 0 1-12 0c0-3 2-6 6-11z", |
| fill: "#5aa9e6", stroke: "#2f6ea8", "stroke-width": "1.2", |
| "stroke-linejoin": "round", |
| }), |
| svgNode("ellipse", { cx: "9.6", cy: "13.5", rx: "1.4", ry: "2.2", fill: "#bfe0fb", opacity: "0.7" }) |
| ); |
| } |
| |
| |
| |
| |
| |
| const ACCESSORY_FOR = { |
| "the prophet": "halo", |
| "the mystic": "halo", |
| "the hoarder": "sack", |
| "the survivor": "sack", |
| "the merchant": "coins", |
| "the trader": "coins", |
| "the schemer": "hood", |
| "the saboteur": "hood", |
| "the provocateur": "hood", |
| "the recluse": "hood", |
| "the diplomat": "scroll", |
| "the witness": "scroll", |
| "the engineer": "wrench", |
| "the artist": "wrench", |
| "the naive": "heart", |
| "the loyalist": "heart", |
| "the nihilist": "eye", |
| "the gardener": null, |
| }; |
| const INK = "#162d6e"; |
| function accessorySvgFragments(kind) { |
| switch (kind) { |
| case "halo": |
| return [svgNode("ellipse", { |
| cx: "21", cy: "3.5", rx: "9", ry: "2.6", |
| fill: "none", stroke: "#d9a441", "stroke-width": "1.8", |
| })]; |
| case "sack": |
| return [svgNode("path", { |
| d: "M29 28 q8 -1 7 9 q-1 4 -7 3 q-3 -7 0 -12 z", |
| fill: "#bd5a4a", "fill-opacity": "0.45", |
| stroke: INK, "stroke-width": "1.6", |
| })]; |
| case "coins": { |
| const g = svgNode("g", { stroke: INK, "stroke-width": "1.4", fill: "#e7c14b", "fill-opacity": "0.6" }, |
| svgNode("ellipse", { cx: "9", cy: "33", rx: "4", ry: "1.7" }), |
| svgNode("ellipse", { cx: "9", cy: "30.5", rx: "4", ry: "1.7" }), |
| ); |
| return [g]; |
| } |
| case "hood": |
| return [svgNode("path", { |
| d: "M13 12 L21 -1 L29 12 z", |
| fill: "#e7c14b", "fill-opacity": "0.5", |
| stroke: INK, "stroke-width": "1.8", "stroke-linejoin": "round", |
| })]; |
| case "scroll": |
| return [svgNode("path", { |
| d: "M30 30 q4 0 4 4 l-1 6 q-3 0 -3 -3 z", |
| fill: "#efe9d8", stroke: INK, "stroke-width": "1.5", |
| })]; |
| case "wrench": |
| return [svgNode("path", { |
| d: "M32 32 l6 6 m-2 -2 l3 -3", |
| fill: "none", stroke: INK, "stroke-width": "2", |
| "stroke-linecap": "round", |
| })]; |
| case "heart": |
| return [svgNode("path", { |
| d: "M21 21 q-3 -4 -6 -1 q-3 4 6 8 q9 -4 6 -8 q-3 -3 -6 1 z", |
| fill: "#bd5a4a", "fill-opacity": "0.55", |
| stroke: INK, "stroke-width": "1.4", |
| })]; |
| case "eye": |
| return [ |
| svgNode("ellipse", { cx: "21", cy: "13", rx: "6", ry: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }), |
| svgNode("circle", { cx: "21", cy: "13", r: "1.6", fill: INK }), |
| ]; |
| default: |
| return []; |
| } |
| } |
| |
| |
| |
| |
| function characterSprite(char) { |
| const accent = char.accent || accentFor(char.name); |
| const dead = !char.alive; |
| if (dead) { |
| return svgNode("svg", { |
| width: "46", height: "56", viewBox: "0 0 46 56", |
| style: "overflow: visible", |
| filter: "url(#sketchWobble)", |
| }, |
| svgNode("path", { |
| d: "M12 50 V24 q11 -12 22 0 V50", |
| fill: "#efe9d8", stroke: "#1b3a8f", "stroke-width": "2", |
| "stroke-linejoin": "round", |
| }), |
| svgNode("path", { |
| d: "M23 28 V44 M16 34 H30", |
| stroke: "#1b3a8f", "stroke-width": "2", "stroke-linecap": "round", |
| }), |
| svgNode("path", { |
| d: "M9 50 H37", |
| stroke: "#1b3a8f", "stroke-width": "2", "stroke-linecap": "round", |
| }), |
| ); |
| } |
| const accessory = ACCESSORY_FOR[char.archetype] || null; |
| return svgNode("svg", { |
| width: "42", height: "58", viewBox: "0 0 42 58", |
| style: "overflow: visible", |
| filter: "url(#sketchWobble)", |
| }, |
| svgNode("ellipse", { cx: "21", cy: "52", rx: "11", ry: "3", fill: "#1b3a8f", opacity: "0.18" }), |
| svgNode("circle", { cx: "21", cy: "13", r: "9", fill: accent, "fill-opacity": "0.5", stroke: INK, "stroke-width": "2" }), |
| svgNode("path", { d: "M21 22 V40", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }), |
| svgNode("path", { d: "M21 27 L10 34 M21 27 L32 34", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }), |
| svgNode("path", { d: "M21 40 L12 51 M21 40 L30 51", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }), |
| ...accessorySvgFragments(accessory), |
| ); |
| } |
| |
| |
| |
| |
| function buildingSvg(type) { |
| switch (type) { |
| case "cave": return buildingCave(); |
| case "well": return buildingWell(); |
| case "locker_row": return buildingLockers(); |
| case "town_hall": return buildingTownHall(); |
| case "shell": return buildingShell(); |
| } |
| return null; |
| } |
| function buildingCave() { |
| return svgNode("svg", { |
| viewBox: "0 0 150 120", width: "100%", height: "100%", |
| preserveAspectRatio: "xMidYMax meet", |
| filter: "url(#sketchWobble)", |
| style: "overflow: visible", |
| }, |
| svgNode("path", { |
| d: "M12 112 Q4 60 38 30 Q78 4 120 26 Q146 44 140 112 Z", |
| fill: "none", stroke: INK, "stroke-width": "2.4", |
| }), |
| svgNode("path", { |
| d: "M55 112 Q52 70 78 64 Q104 70 100 112 Z", |
| fill: INK, "fill-opacity": "0.12", |
| stroke: INK, "stroke-width": "2", |
| }), |
| svgNode("rect", { |
| x: "60", y: "86", width: "26", height: "16", rx: "2", |
| fill: "none", stroke: INK, "stroke-width": "2", |
| }), |
| svgNode("path", { |
| d: "M60 102 l-8 8 M86 102 l8 8 M64 102 v8 M82 102 v8", |
| stroke: INK, "stroke-width": "1.6", |
| }), |
| svgNode("circle", { cx: "64", cy: "112", r: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }), |
| svgNode("circle", { cx: "82", cy: "112", r: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }), |
| ); |
| } |
| function buildingWell() { |
| return svgNode("svg", { |
| viewBox: "0 0 110 110", width: "100%", height: "100%", |
| preserveAspectRatio: "xMidYMax meet", |
| filter: "url(#sketchWobble)", |
| style: "overflow: visible", |
| }, |
| svgNode("path", { |
| d: "M22 64 h66 v34 a33 12 0 0 1 -66 0 z", |
| fill: "none", stroke: INK, "stroke-width": "2.2", |
| }), |
| svgNode("path", { |
| d: "M30 48 L55 24 L80 48", |
| fill: "none", stroke: INK, "stroke-width": "2.2", "stroke-linejoin": "round", |
| }), |
| svgNode("path", { d: "M28 48 H82", stroke: INK, "stroke-width": "2.2" }), |
| svgNode("path", { d: "M55 30 V58", stroke: INK, "stroke-width": "1.6" }), |
| svgNode("rect", { |
| x: "48", y: "56", width: "14", height: "12", rx: "1.5", |
| fill: "none", stroke: INK, "stroke-width": "1.8", |
| }), |
| ); |
| } |
| function buildingLockers() { |
| const n = 6; |
| const children = []; |
| children.push(svgNode("rect", { |
| x: "14", y: "22", width: "222", height: "48", rx: "3", |
| fill: "#efe9d8", stroke: INK, "stroke-width": "2.2", |
| })); |
| for (let i = 0; i < n; i++) { |
| const x = 14 + (i + 1) * (222 / n); |
| children.push(svgNode("line", { |
| x1: x, y1: "22", x2: x, y2: "70", |
| stroke: INK, "stroke-width": "1.8", |
| })); |
| children.push(svgNode("circle", { |
| cx: 14 + (i + 0.5) * (222 / n), cy: "46", r: "2.4", |
| fill: i === 3 ? "#eb7c49" : INK, |
| })); |
| } |
| return svgNode("svg", { |
| viewBox: "0 0 250 85", width: "100%", height: "100%", |
| preserveAspectRatio: "xMidYMid meet", |
| filter: "url(#sketchWobble)", |
| style: "overflow: visible", |
| }, ...children); |
| } |
| function buildingTownHall() { |
| return svgNode("svg", { |
| viewBox: "0 0 150 130", width: "100%", height: "100%", |
| preserveAspectRatio: "xMidYMax meet", |
| filter: "url(#sketchWobble)", |
| style: "overflow: visible", |
| }, |
| svgNode("path", { |
| d: "M22 50 L75 18 L128 50", |
| fill: "none", stroke: INK, "stroke-width": "2.4", "stroke-linejoin": "round", |
| }), |
| svgNode("rect", { |
| x: "30", y: "50", width: "90", height: "48", |
| fill: "none", stroke: INK, "stroke-width": "2.2", |
| }), |
| svgNode("rect", { |
| x: "58", y: "68", width: "34", height: "30", |
| fill: "none", stroke: INK, "stroke-width": "1.8", |
| }), |
| svgNode("rect", { |
| x: "40", y: "104", width: "70", height: "22", rx: "2", |
| fill: "#efe9d8", stroke: INK, "stroke-width": "2.2", |
| }), |
| svgNode("path", { |
| d: "M48 110 H102 M48 116 H94 M48 122 H100", |
| stroke: INK, "stroke-width": "1.3", |
| }), |
| ); |
| } |
| function buildingShell() { |
| return svgNode("svg", { |
| viewBox: "0 0 170 130", width: "100%", height: "100%", |
| preserveAspectRatio: "xMidYMax meet", |
| filter: "url(#sketchWobble)", |
| style: "overflow: visible", |
| }, |
| svgNode("rect", { |
| x: "20", y: "30", width: "130", height: "20", |
| fill: "none", stroke: INK, "stroke-width": "2.2", |
| }), |
| svgNode("rect", { |
| x: "20", y: "50", width: "130", height: "60", |
| fill: "none", stroke: INK, "stroke-width": "2.2", |
| }), |
| svgNode("text", { |
| x: "34", y: "86", |
| "font-family": "ui-monospace, monospace", |
| "font-size": "26", fill: "#bd5a4a", |
| class: "shell-pulse", |
| }, "$"), |
| ); |
| } |
| |
| |
| |
| |
| const elTick = document.getElementById("tick"); |
| const elCount = document.getElementById("char-count"); |
| const elSentChip = document.getElementById("sent-chip"); |
| const elSentText = document.getElementById("sent-text"); |
| const elMapLayer = document.getElementById("map-layer"); |
| const elBoard = document.getElementById("board"); |
| const elBoardSent = document.getElementById("board-sent"); |
| const elBoardList = document.getElementById("board-list"); |
| const elShellHolder = document.getElementById("shell-holder"); |
| const elShellQueue = document.getElementById("shell-queue"); |
| const elShellFeed = document.getElementById("shell-feed"); |
| const elSpawn = document.getElementById("spawn-btn"); |
| const elPause = document.getElementById("pause-btn"); |
| const elInfo = document.getElementById("info-btn"); |
| const elInfoModal = document.getElementById("info-modal"); |
| const elInfoClose = document.getElementById("info-close"); |
| const elInfoDotText = document.getElementById("info-dot-text"); |
| const elModeChip = document.getElementById("mode-chip"); |
| const elModeText = document.getElementById("mode-text"); |
| |
| function setModeChip(mode) { |
| |
| if (!elModeChip || !elModeText) return; |
| elModeChip.classList.remove("tl-mode-unknown", "tl-mode-gpu", "tl-mode-cpu"); |
| if (mode === "gpu") { |
| elModeChip.classList.add("tl-mode-gpu"); |
| elModeText.textContent = "GPU"; |
| } else if (mode === "gpu_unavailable") { |
| elModeChip.classList.add("tl-mode-cpu"); |
| elModeText.textContent = "WAIT"; |
| } else { |
| elModeChip.classList.add("tl-mode-unknown"); |
| elModeText.textContent = "—"; |
| } |
| } |
| const elReset = document.getElementById("reset-btn"); |
| const elPanelEmpty = document.getElementById("panel-empty"); |
| const elPanelBody = document.getElementById("panel-body"); |
| const elPAvatar = document.getElementById("p-avatar"); |
| const elPName = document.getElementById("p-name"); |
| const elPArch = document.getElementById("p-arch"); |
| const elPDot = document.getElementById("p-dot"); |
| const elPStatus = document.getElementById("p-status"); |
| const elPClose = document.getElementById("p-close"); |
| const elPGoal = document.getElementById("p-goal"); |
| const elPStream = document.getElementById("p-stream"); |
| const elPTraceHint = document.getElementById("p-trace-hint"); |
| const elPTrace = document.getElementById("p-trace"); |
| const elPModel = document.getElementById("p-model"); |
| const elPPers = document.getElementById("p-pers"); |
| const elPSave = document.getElementById("p-save"); |
| const elPTraits = document.getElementById("p-traits"); |
| const elPInv = document.getElementById("p-inv"); |
| const elPJournal = document.getElementById("p-journal"); |
| const elProgress = document.getElementById("progress"); |
| const elProgressLabel = document.getElementById("progress-label"); |
| |
| function startProgress(label) { |
| elProgressLabel.textContent = label; |
| elProgress.classList.add("is-on"); |
| } |
| function stopProgress() { |
| elProgress.classList.remove("is-on"); |
| } |
| |
| |
| |
| |
| function placeBuildings(places) { |
| placesMap = places; |
| for (const [name, footprint] of Object.entries(BUILDING_FOOTPRINTS)) { |
| if (places && !(name in places)) continue; |
| const wrap = el("div", { |
| className: "tl-bldg tl-bldg-" + name, |
| style: { |
| left: gx(footprint.col) + "%", |
| top: gy(footprint.row) + "%", |
| width: gx(footprint.w) + "%", |
| height: gy(footprint.h) + "%", |
| }, |
| }); |
| const art = document.createElement("div"); |
| art.className = "tl-bldg-art"; |
| const svg = buildingSvg(name); |
| if (svg) art.appendChild(svg); |
| wrap.appendChild(art); |
| wrap.appendChild(el("div", { className: "tl-bldg-label" }, footprint.label)); |
| elMapLayer.appendChild(wrap); |
| } |
| } |
| |
| |
| |
| |
| function characterToken(snap, c, offset = { dx: 0, dy: 0 }) { |
| const accent = accentFor(c.name); |
| const isSelected = selectedName === c.name; |
| const verb = c.current_action && c.current_action.verb; |
| const isWalking = verb === "move_to"; |
| const isMining = verb === "mine_coal" || verb === "draw_water"; |
| const isPosting = verb === "post_message"; |
| const isHolding = snap.shell_lock_holder === c.name; |
| const isQueueing = (snap.shell_queue || []).includes(c.name); |
| const carry = pickCarry(c.inventory); |
| const bubble = c.alive ? firstSentence(c.stream_of_consciousness, 60) : null; |
| const isMenacing = bubble && /\bend\b|\bdelete\b|\bkill\b/i.test(bubble); |
| |
| const cls = ["tl-char"]; |
| if (isSelected) cls.push("is-selected"); |
| if (!c.alive) cls.push("is-dead"); |
| else if (isWalking) cls.push("is-walking"); |
| else if (isMining) cls.push("is-mining"); |
| else if (isPosting) cls.push("is-posting"); |
| |
| const charNode = el("div", { |
| className: cls.join(" "), |
| style: { |
| left: gx(c.pos[0] + 0.5 + offset.dx) + "%", |
| top: gy(c.pos[1] + 0.5 + offset.dy) + "%", |
| }, |
| onclick: (ev) => { |
| ev.stopPropagation(); |
| selectedName = c.name; |
| renderSide(snap); |
| renderMap(snap); |
| }, |
| }); |
| |
| if (bubble) { |
| |
| |
| const flipBelow = c.pos[1] < 2; |
| const bubbleEl = el("div", { |
| className: "tl-bubble" |
| + (isMenacing ? " tl-bubble-bad" : "") |
| + (flipBelow ? " tl-bubble-below" : ""), |
| }, bubble); |
| bubbleEl.appendChild(el("span", { className: "tl-bubble-tail" })); |
| charNode.appendChild(bubbleEl); |
| } |
| |
| const art = el("div", { className: "tl-char-art" }); |
| if (isSelected) { |
| const ring = el("span", { |
| className: "tl-ring tl-ring-sketch", |
| style: { "--ring": accent }, |
| }); |
| ring.style.setProperty("--ring", accent); |
| art.appendChild(ring); |
| } |
| if (isHolding && c.alive) { |
| art.appendChild(el("span", { className: "tl-hold-glow" })); |
| } |
| art.appendChild(characterSprite({ ...c, accent })); |
| if (carry && c.alive) { |
| const badge = el("span", { className: "tl-carry" }); |
| badge.appendChild(resIcon(carry, 11)); |
| art.appendChild(badge); |
| } |
| charNode.appendChild(art); |
| |
| const tag = el("div", { |
| className: "tl-nametag", |
| style: { "--tag": c.alive ? accent : "#6b6570" }, |
| }, c.name); |
| tag.style.setProperty("--tag", c.alive ? accent : "#6b6570"); |
| if (isQueueing) { |
| tag.appendChild(el("span", { className: "tl-queue" }, "·queued")); |
| } |
| charNode.appendChild(tag); |
| return charNode; |
| } |
| |
| function pickCarry(inv) { |
| if (!inv) return null; |
| if ((inv.electricity || 0) > 0) return "electricity"; |
| if ((inv.water || 0) > 0) return "water"; |
| return null; |
| } |
| |
| function renderMap(snap) { |
| elMapLayer.querySelectorAll(".tl-char").forEach(n => n.remove()); |
| const offsets = computeFanOut(snap.characters || []); |
| for (const c of snap.characters || []) { |
| elMapLayer.appendChild(characterToken(snap, c, offsets[c.name] || { dx: 0, dy: 0 })); |
| } |
| } |
| |
| |
| |
| |
| |
| function computeFanOut(chars) { |
| const buckets = {}; |
| for (const c of chars) { |
| const k = c.pos[0] + "," + c.pos[1]; |
| (buckets[k] = buckets[k] || []).push(c); |
| } |
| const out = {}; |
| for (const group of Object.values(buckets)) { |
| const n = group.length; |
| if (n === 1) { |
| out[group[0].name] = { dx: 0, dy: 0 }; |
| continue; |
| } |
| group.forEach((c, i) => { |
| const dx = (i - (n - 1) / 2) * 0.55; |
| const dy = n > 2 ? ((i % 2) * 0.18) : 0; |
| out[c.name] = { dx, dy }; |
| }); |
| } |
| return out; |
| } |
| |
| |
| |
| |
| function renderSide(snap) { |
| if (!selectedName) { |
| elPanelEmpty.style.display = "block"; |
| elPanelBody.style.display = "none"; |
| return; |
| } |
| const c = (snap.characters || []).find(x => x.name === selectedName); |
| if (!c) { |
| elPanelEmpty.style.display = "block"; |
| elPanelBody.style.display = "none"; |
| return; |
| } |
| elPanelEmpty.style.display = "none"; |
| elPanelBody.style.display = "flex"; |
| elPanelBody.style.flexDirection = "column"; |
| elPanelBody.style.flex = "1"; |
| elPanelBody.style.minHeight = "0"; |
| |
| |
| elPAvatar.innerHTML = ""; |
| elPAvatar.appendChild(characterSprite({ ...c, accent: accentFor(c.name) })); |
| elPName.textContent = c.name; |
| elPArch.textContent = c.archetype || ""; |
| elPDot.className = "tl-status-dot" + (c.alive ? "" : " is-dead"); |
| elPStatus.textContent = statusLine(c, snap); |
| |
| |
| elPGoal.textContent = c.goal || "—"; |
| renderStream(c); |
| |
| |
| renderTrace(c.trace || []); |
| |
| |
| elPModel.innerHTML = ""; |
| for (const m of modelRoster) { |
| const opt = document.createElement("option"); |
| opt.value = m.model_id; |
| opt.textContent = `${m.display_name} (${m.params_b}B)`; |
| if (m.model_id === c.model_id) opt.selected = true; |
| elPModel.appendChild(opt); |
| } |
| |
| |
| if (document.activeElement !== elPPers) { |
| elPPers.value = c.personality || ""; |
| } |
| |
| |
| renderTraits(c.traits || {}); |
| |
| |
| renderInventory(c); |
| |
| |
| renderJournal(c.journal || []); |
| } |
| |
| function statusLine(c, snap) { |
| if (!c.alive) return "gone"; |
| const where = nearestBuilding(c.pos); |
| const verb = c.current_action && c.current_action.verb; |
| const verbText = verb ? verb.replace(/_/g, " ") : "idle"; |
| return `alive · near ${where} · ${verbText}`; |
| } |
| |
| function nearestBuilding(pos) { |
| if (!placesMap) return "town"; |
| let best = "town", bd = Infinity; |
| for (const [name, tile] of Object.entries(placesMap)) { |
| const [tx, ty] = tile; |
| const d = Math.abs(pos[0] - tx) + Math.abs(pos[1] - ty); |
| if (d < bd) { bd = d; best = name.replace(/_/g, " "); } |
| } |
| return best; |
| } |
| |
| function renderStream(c) { |
| elPStream.innerHTML = ""; |
| const text = (c.stream_of_consciousness || "").trim(); |
| if (!text) { |
| elPStream.appendChild(el("p", { className: "tl-stream-empty" }, |
| "(no monologue yet — refreshes every 10 decisions)")); |
| return; |
| } |
| const sentences = text.split(/(?<=[.!?])\s+/); |
| for (const s of sentences) { |
| if (s) elPStream.appendChild(el("p", {}, s)); |
| } |
| elPStream.appendChild(el("span", { className: "tl-stream-cursor" })); |
| } |
| |
| |
| |
| |
| const VERB_TO_ACT = { |
| move_to: "WALK", |
| mine_coal: "MINE", |
| draw_water: "MINE", |
| deposit: "DECIDE", |
| withdraw: "DECIDE", |
| give: "GIVE", |
| post_message: "POST", |
| read_messages:"OBSERVE", |
| submit_python:"SHELL", |
| sleep: "SLEEP", |
| wait: "WAIT", |
| }; |
| |
| function renderTrace(trace) { |
| elPTrace.innerHTML = ""; |
| if (!trace || trace.length === 0) { |
| elPTrace.appendChild(el("div", { className: "tl-trace-empty" }, "No decisions yet.")); |
| elPTraceHint.textContent = "last decisions"; |
| return; |
| } |
| elPTraceHint.textContent = `last ${Math.min(trace.length, 20)} decisions`; |
| const rows = []; |
| |
| for (let i = trace.length - 1; i >= 0; i--) { |
| const e = trace[i]; |
| if (e.tool_calls && e.tool_calls.length) { |
| for (const tc of e.tool_calls) { |
| const act = VERB_TO_ACT[tc.verb] || "DECIDE"; |
| const isErr = tc.result && tc.result.startsWith("ERROR"); |
| const text = `${tc.verb}(${formatArgs(tc.args)}) → ${tc.result || ""}`; |
| rows.push(traceRow(e.tick, act, text, isErr)); |
| } |
| } else if (e.thought) { |
| rows.push(traceRow(e.tick, "DECIDE", e.thought.trim().slice(0, 90))); |
| } |
| if (e.error) { |
| rows.push(traceRow(e.tick, "ERROR", e.error.slice(0, 90), true)); |
| } |
| if (rows.length >= 20) break; |
| } |
| for (const r of rows) elPTrace.appendChild(r); |
| } |
| |
| function traceRow(tick, act, text, isErr = false) { |
| return el("div", { className: "tl-trace-row" }, |
| el("span", { className: "tl-trace-tick" }, String(tick)), |
| el("span", { className: "tl-trace-act act-" + act.toLowerCase() }, act), |
| el("span", { className: "tl-trace-text" + (isErr ? " tl-trace-err" : "") }, text), |
| ); |
| } |
| |
| function formatArgs(args) { |
| if (!args) return ""; |
| const parts = []; |
| for (const [k, v] of Object.entries(args)) { |
| if (k === "_positional") continue; |
| parts.push(`${k}=${JSON.stringify(v)}`); |
| } |
| if (args._positional && args._positional.length) { |
| parts.unshift(args._positional.map(x => JSON.stringify(x)).join(", ")); |
| } |
| return parts.join(", "); |
| } |
| |
| |
| |
| |
| function renderTraits(traits) { |
| elPTraits.innerHTML = ""; |
| for (const [k, v] of Object.entries(traits)) { |
| const pips = Math.max(0, Math.min(5, Math.round(v / 2))); |
| const bar = el("span", { className: "tl-trait-bar" }); |
| for (let i = 1; i <= 5; i++) { |
| bar.appendChild(el("i", { className: i <= pips ? "on" : "" })); |
| } |
| elPTraits.appendChild(el("div", { className: "tl-trait" }, |
| el("span", { className: "tl-trait-k" }, k), |
| bar, |
| )); |
| } |
| } |
| |
| function renderInventory(c) { |
| elPInv.innerHTML = ""; |
| const inv = c.inventory || { electricity: 0, water: 0 }; |
| const locker = c.locker || { electricity: 0, water: 0 }; |
| const invGroup = el("span", { className: "tl-inv-group" }, |
| el("b", {}, "inv"), |
| ); |
| invGroup.appendChild(resIcon("electricity", 12)); |
| invGroup.appendChild(document.createTextNode(String(inv.electricity || 0))); |
| invGroup.appendChild(resIcon("water", 12)); |
| invGroup.appendChild(document.createTextNode(String(inv.water || 0))); |
| |
| const lockerGroup = el("span", { className: "tl-inv-group" }, |
| el("b", {}, "locker"), |
| ); |
| lockerGroup.appendChild(resIcon("electricity", 12)); |
| lockerGroup.appendChild(document.createTextNode(String(locker.electricity || 0))); |
| lockerGroup.appendChild(resIcon("water", 12)); |
| lockerGroup.appendChild(document.createTextNode(String(locker.water || 0))); |
| |
| elPInv.appendChild(invGroup); |
| elPInv.appendChild(el("span", { className: "tl-inv-sep" }, "·")); |
| elPInv.appendChild(lockerGroup); |
| } |
| |
| function renderJournal(journal) { |
| elPJournal.innerHTML = ""; |
| if (!journal.length) { |
| elPJournal.appendChild(el("p", { className: "tl-journal-line" }, "(no entries yet)")); |
| return; |
| } |
| for (const entry of journal) { |
| elPJournal.appendChild(el("p", { className: "tl-journal-line" }, entry)); |
| } |
| } |
| |
| |
| |
| |
| const SENTIMENT_CLASS = { |
| positive: "tl-board-positive", |
| neutral: "tl-board-neutral", |
| negative: "tl-board-negative", |
| }; |
| |
| function renderBoard(snap) { |
| const sentiment = snap.board_sentiment || "neutral"; |
| elBoard.className = "tl-board " + (SENTIMENT_CLASS[sentiment] || "tl-board-neutral"); |
| |
| let glow = elBoard.querySelector(".tl-board-glow"); |
| if (!glow) { |
| glow = document.createElement("div"); |
| glow.className = "tl-board-glow"; |
| elBoard.insertBefore(glow, elBoard.firstChild); |
| } |
| elBoardSent.textContent = sentiment; |
| |
| elBoardList.innerHTML = ""; |
| for (const p of snap.board || []) { |
| const isSystem = p.author === "system" || /\bdied\b|deleted by/i.test(p.text || ""); |
| if (isSystem) { |
| elBoardList.appendChild(el("div", { className: "tl-post tl-post-sys" }, |
| el("span", { className: "tl-sys-icon" }, "☠"), |
| el("span", { className: "tl-post-text" }, p.text || ""), |
| )); |
| } else { |
| const author = p.author || "?"; |
| const hue = nameHue(author); |
| elBoardList.appendChild(el("div", { className: "tl-post" }, |
| el("span", { className: "tl-post-tick" }, `[${p.tick}]`), |
| el("span", { |
| className: "tl-post-who", |
| style: { color: `hsl(${hue} 55% 38%)` }, |
| }, author + ":"), |
| el("span", { className: "tl-post-text" }, " " + (p.text || "")), |
| )); |
| } |
| } |
| elBoardList.scrollTop = elBoardList.scrollHeight; |
| } |
| |
| function renderShell(snap) { |
| const holder = snap.shell_lock_holder; |
| const queue = snap.shell_queue || []; |
| if (holder) { |
| elShellHolder.innerHTML = ""; |
| elShellHolder.appendChild(document.createTextNode("held by ")); |
| elShellHolder.appendChild(el("b", {}, holder)); |
| elShellHolder.appendChild(el("span", { className: "tl-shell-lock" }, "●")); |
| } else { |
| elShellHolder.textContent = "free"; |
| } |
| if (queue.length) { |
| elShellQueue.innerHTML = ""; |
| elShellQueue.appendChild(document.createTextNode("queue: ")); |
| elShellQueue.appendChild(el("b", {}, queue.join(", "))); |
| } else { |
| elShellQueue.textContent = ""; |
| } |
| |
| elShellFeed.innerHTML = ""; |
| for (const s of snap.shell || []) { |
| const kind = s.kind || "out"; |
| const row = el("div", { className: "tl-sh tl-sh-" + kind }); |
| if (kind === "in") row.appendChild(el("span", { className: "tl-sh-prompt" }, "$")); |
| else if (kind === "system" || kind === "sys") row.appendChild(el("span", { className: "tl-sh-prompt" }, "»")); |
| row.appendChild(el("span", { className: "tl-sh-txt" }, s.text || "")); |
| elShellFeed.appendChild(row); |
| } |
| |
| const live = el("div", { className: "tl-sh tl-sh-in tl-sh-live" }); |
| live.appendChild(el("span", { className: "tl-sh-prompt tl-pulse" }, "$")); |
| live.appendChild(el("span", { className: "tl-sh-caret" })); |
| elShellFeed.appendChild(live); |
| |
| elShellFeed.scrollTop = elShellFeed.scrollHeight; |
| } |
| |
| |
| |
| |
| function renderTopBar(snap) { |
| elTick.textContent = (snap.tick || 0).toLocaleString(); |
| const alive = (snap.characters || []).filter(c => c.alive).length; |
| elCount.textContent = alive; |
| const sent = snap.board_sentiment || "neutral"; |
| elSentChip.className = "tl-sentiment tl-sent-" + sent; |
| elSentText.textContent = sent; |
| } |
| |
| |
| |
| |
| let _refreshCounter = 0; |
| async function refresh() { |
| if (!client) return; |
| try { |
| const result = await client.predict("/world_snapshot", {}); |
| const snap = result.data[0]; |
| if (++_refreshCounter % 25 === 1) { |
| console.log("[townlet] refresh", _refreshCounter, "tick=", snap.tick, |
| "chars=", (snap.characters || []).length); |
| } |
| if (!selectedName && (snap.characters || []).length > 0) { |
| selectedName = snap.characters[0].name; |
| } |
| renderTopBar(snap); |
| renderMap(snap); |
| renderBoard(snap); |
| renderShell(snap); |
| renderSide(snap); |
| } catch (err) { |
| console.error("[townlet] snapshot failed", err); |
| } |
| } |
| |
| async function loadModels() { |
| const result = await client.predict("/list_models", {}); |
| const payload = result.data[0]; |
| modelRoster = payload.roster || []; |
| console.log("[townlet] roster loaded:", modelRoster); |
| } |
| |
| async function main() { |
| console.log("[townlet] main() starting; connecting to", window.location.origin); |
| try { |
| client = await Client.connect(window.location.origin); |
| console.log("[townlet] client connected"); |
| } catch (err) { |
| console.error("[townlet] Client.connect failed:", err); |
| throw err; |
| } |
| await loadModels(); |
| |
| const firstSnap = await client.predict("/world_snapshot", {}); |
| const firstPayload = firstSnap.data[0]; |
| placeBuildings(firstPayload.places || {}); |
| |
| elSpawn.addEventListener("click", async () => { |
| elSpawn.classList.remove("tl-btn-flash"); |
| |
| void elSpawn.offsetWidth; |
| elSpawn.classList.add("tl-btn-flash"); |
| try { |
| const res = await client.predict("/spawn_character", { name: null }); |
| const payload = res?.data?.[0]; |
| if (payload && payload.ok === false) { |
| console.warn("[townlet] spawn declined:", payload.error || "name conflict / max characters"); |
| } |
| } catch (e) { |
| console.warn("[townlet] spawn failed:", e); |
| } |
| await refresh(); |
| }); |
| elPause.addEventListener("click", async () => { |
| paused = !paused; |
| elPause.textContent = paused ? "Resume" : "Pause"; |
| elPause.classList.remove("tl-btn-flash"); |
| void elPause.offsetWidth; |
| elPause.classList.add("tl-btn-flash"); |
| try { |
| await client.predict("/set_paused", { paused }); |
| } catch (e) { |
| |
| paused = !paused; |
| elPause.textContent = paused ? "Resume" : "Pause"; |
| console.warn("[townlet] set_paused failed:", e); |
| } |
| }); |
| |
| let dotLoaded = false; |
| async function openInfo() { |
| elInfoModal.hidden = false; |
| elInfoModal.setAttribute("aria-hidden", "false"); |
| if (!dotLoaded) { |
| try { |
| const res = await client.predict("/get_the_dot", {}); |
| const payload = res?.data?.[0]; |
| elInfoDotText.textContent = (payload && payload.text) || "(unavailable)"; |
| dotLoaded = true; |
| } catch (e) { |
| elInfoDotText.textContent = "(failed to load: " + (e && e.message ? e.message : e) + ")"; |
| } |
| } |
| } |
| function closeInfo() { |
| elInfoModal.hidden = true; |
| elInfoModal.setAttribute("aria-hidden", "true"); |
| } |
| elInfo.addEventListener("click", openInfo); |
| elInfoClose.addEventListener("click", closeInfo); |
| elInfoModal.addEventListener("click", (ev) => { |
| |
| if (ev.target === elInfoModal) closeInfo(); |
| }); |
| document.addEventListener("keydown", (ev) => { |
| if (ev.key === "Escape" && !elInfoModal.hidden) closeInfo(); |
| }); |
| elReset.addEventListener("click", async () => { |
| if (!confirm("Reset the world?")) return; |
| elReset.disabled = true; |
| const originalLabel = elReset.textContent; |
| elReset.textContent = "Resetting…"; |
| startProgress("resetting world…"); |
| try { |
| await client.predict("/reset_world", {}); |
| startProgress("respawning characters…"); |
| selectedName = null; |
| await refresh(); |
| } finally { |
| stopProgress(); |
| elReset.disabled = false; |
| elReset.textContent = originalLabel; |
| } |
| }); |
| elPClose.addEventListener("click", () => { |
| selectedName = null; |
| renderSide({ characters: [] }); |
| |
| refresh(); |
| }); |
| elPSave.addEventListener("click", async () => { |
| if (!selectedName) return; |
| await client.predict("/set_personality", { |
| name: selectedName, personality: elPPers.value, |
| }); |
| await refresh(); |
| }); |
| elPModel.addEventListener("change", async () => { |
| if (!selectedName) return; |
| await client.predict("/set_model", { |
| name: selectedName, model_id: elPModel.value, |
| }); |
| await refresh(); |
| }); |
| |
| setInterval(refresh, 800); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let tickInFlight = false; |
| let lastMode = null; |
| async function tryEndpoint(name) { |
| const res = await client.predict(name, {}); |
| return res?.data?.[0]; |
| } |
| setInterval(async () => { |
| if (tickInFlight) return; |
| tickInFlight = true; |
| try { |
| let payload = null; |
| try { |
| payload = await tryEndpoint("/tick_decisions"); |
| } catch (e) { |
| |
| |
| console.warn("[townlet] /tick_decisions failed; will retry next poll:", e?.message || e); |
| } |
| const mode = payload?.mode; |
| if (mode) { |
| setModeChip(mode); |
| if (mode !== lastMode) { |
| console.log("[townlet] tick mode:", mode); |
| lastMode = mode; |
| } |
| } |
| } finally { |
| tickInFlight = false; |
| } |
| |
| |
| |
| |
| |
| |
| |
| }, 25000); |
| refresh(); |
| } |
| |
| main().catch(err => console.error(err)); |
| </script> |
| </body> |
| </html> |
|
|