diff --git a/DESIGN.md b/docs/DESIGN.md similarity index 100% rename from DESIGN.md rename to docs/DESIGN.md diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..ca7dae9 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,89 @@ +# Nine Hazes — Watch Client for OSRIC MUD Idle System +## Consolidated record v3.0 (post-pivot, 2026-09-12) + +### 1. Concept +Pebble Time 2 (emery) watchface + companion watchapp acting as remote monitor +and manager for the Nine Hazes OSRIC MUD idle task system. NOT a standalone +game — all game logic lives on the MUD server. The watch displays server +snapshots and submits intents (add/cancel/claim); the server validates and +executes. Watch behaves like a normal watchface except during active sync. + +### 2. Critical architecture decision — "face gets its own sync" +Pebble persist storage is per-app sandboxed with NO app↔app communication +(verified against platform docs). Therefore the watchface and watchapp each +own an independent pkjs sync to the same Task API. No watch-side data sharing +exists or is attempted. Shared truth = server. + +### 3. Protocol +Wire contract: "Nine Hazes — Idle Task Protocol v1.0" (separate document, +authored server-side; authoritative). Summary: +- HTTPS + Bearer token (scoped queue:read queue:write claim; token on phone + localStorage only, NEVER on watch) +- GET /v1/dictionary (versioned, 304-style caching via ?v=) +- GET /v1/queue — full sync in one call (tasks[], used/budget, pend, bound, + status, tok.exp) +- POST /v1/queue/tasks (reqId idempotency), DELETE /v1/queue/tasks/:tid, + POST /v1/claim, POST /v1/token/refresh +- Device protocol: GameSnapshot packed struct (~28 bytes) at persist key 0x30; + dictionary blob at 0x31 (watchapp use) +- Clock correction: all countdowns from bound + clock_off, never raw watch clock +- Failure ladder: unreachable/malformed → silent, render cached snapshot + +### 4. Artifacts & ownership +| Artifact | Owner | Status | +|---|---|---| +| Watchface (C, canvas renderer, widget table) | watch team | RENDERING BASELINE COMPLETE: custom-canvas data-driven renderer, 10 widget types + 2 spare slots, per-widget layout persist (key 100), parchment/ink palette, task strip rendering, server-down icon, dev.sh tooling. Committed to Gitea. | +| Watchapp (queue manager UI) | watch team | deferred (build order item) | +| pkjs ×2 (face sync, watchapp sync) | watch team | face pkjs fetches /v1/queue → AppMessage snapshot | +| Bridge/state server | OBSOLETE — deleted. Superseded by MUD gateway + placeholder mock (below) | +| Placeholder mock server (idle protocol §10) | watch team | zero-dependency node:http implementation (NOT Express — locked project law: TypeScript, node stdlib only) | + +### 5. Widget set (unchanged) +Time, date, watch battery (+days estimator, key 102), phone battery, steps, +distance, weather composite (NWS default / Open-Meteo fallback — geographic +auto-switch), advisory omens, sleep, Current Task Strip (2-line: line 1 = +countdown to task completion + used/36 + task count, line 2 = current task +name (+n) pending indicator; offline "!" icon on failed check). All +toggleable/movable/font+color stylable. Spare slots reserved (incl. game +preview / companion preview, deferred). + +### 6. Design laws (north stars) +1. Casual glance — no game-triggered interrupts, ever. +2. Game logic dormant until invited; watch computes only countdown strings. +3. Fully free face — no protected widgets; "restore default" escape hatch. +4. Face shows minimal: countdown + queue line only. +5. Battery-first: zero network in ambient use; one sync per interaction. +6. Fail-dead-silent: server down → cached data or hidden strip; no errors. +7. Zero-dependency TypeScript server code; no workers/background radio ever; + wakeups = semantic events only, rolling single wakeup (Muninn pattern). + +### 7. Platform/environment +emery only ("targetPlatforms": ["emery"]) · Core Devices PebbleOS SDK · +system fonts first (carved from Apache release), OFL TTFs bundled later · +Apache-2.0 · Gitea master (auth-gated), GitHub public mirror at v0.1.0 · +Liberapay/Ko-fi FUNDING.yml. + +### 8. Reference projects (ideas only, clean-room; unlicensed repos = read-for-protocol +never copy code; Remix-on-CloudPebble ≠ redistribution grant) +IdleFantasy (queue model), Doors of Doom (AppMessage), Pixel8 (chunked +transfer), Muninn (wakeup/battery sampling patterns), Tamagotchi-API/TamaTime +(companion-preview idea, DEFERRED — feature not focus). + +### 9. Build order (current) +1. ✔ Watchface rendering baseline (committed) +2. ✔ pkjs fetch + AppMessage + cached-packet strip + offline icon (v1 protocol) +3. → NOW: idle-protocol adaptation — snapshot struct, mock server, clock-offset + countdown (this revision) +4. Widget wiring: health (steps/distance/sleep), weather (NWS/Open-Meteo) +5. Watchapp: queue UI (add via presets/segment picker, cancel, claim), + wakeup at segment boundary, launch_args deep links, App Glance/timeline pins +6. Settings phase 2: hosted config page (drag-drop layout, presets, server URL/ + token entry) +7. Polish: PDC sprites/icons (pickaxe, fist, coin, moon), theme, vibration + +### 10. Open items to relay to server team +- bound/t/sync timestamps in epoch-ms exceed AppMessage uint32 → watchface + uses epoch-SECONDS for wire transfer (recommend protocol clarify units) +- Face receives resolved task NAME from pkjs (v1); dictionary blob 0x31 is + watchapp-scoped, chunked transfer deferred +- Player identity: X-Pebble-Id informational; real auth = MUD token diff --git a/nine-hazes-face/.lock-waf_linux_build b/nine-hazes-face/.lock-waf_linux_build index 82fb8a3..0d7e605 100644 --- a/nine-hazes-face/.lock-waf_linux_build +++ b/nine-hazes-face/.lock-waf_linux_build @@ -1,6 +1,6 @@ argv = ['/home/joe/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] config_cmd = 'configure' -environ = {'SHELL': '/usr/bin/bash', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/6123,unix/unix:/tmp/.ICE-unix/6123', 'WINDOWID': '94724984244640', 'COLORTERM': 'truecolor', 'XDG_CONFIG_DIRS': '/home/joe/.config/kdedefaults:/etc/xdg', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'HISTSIZE': '1000', 'HOSTNAME': 'bazzite', 'ICEAUTHORITY': '/run/user/1000/iceauth_zsBrhO', 'LANGUAGE': '', 'XDG_DATA_HOME': '/home/joe/.local/share', 'XDG_CONFIG_HOME': '/home/joe/.config', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'LIBBDPLUS_PATH': '/usr/lib64/libmmbd.so.0', 'DISTROBOX_ENTER_PATH': '/usr/bin/distrobox-enter', 'DESKTOP_SESSION': 'plasma', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/joe/.gtkrc:/home/joe/.config/gtkrc', 'GPG_TTY': '/dev/pts/0', 'EDITOR': '/usr/bin/vim', 'PWD': '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face', 'LOGNAME': 'joe', 'XDG_SESSION_DESKTOP': 'KDE', 'XDG_SESSION_TYPE': 'wayland', 'SYSTEMD_EXEC_PID': '6506', 'XAUTHORITY': '/run/user/1000/xauth_yNkkMF', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'container': 'podman', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/joe/.gtkrc-2.0:/home/joe/.config/gtkrc-2.0', 'HOME': '/home/joe', 'SSH_ASKPASS': '/usr/bin/ksshaskpass', 'LANG': 'en_US.UTF-8', 'LS_COLORS': 'rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=01;37;41:su=37;41:sg=30;43:ca=00:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.7z=01;31:*.ace=01;31:*.alz=01;31:*.apk=01;31:*.arc=01;31:*.arj=01;31:*.bz=01;31:*.bz2=01;31:*.cab=01;31:*.cpio=01;31:*.crate=01;31:*.deb=01;31:*.drpm=01;31:*.dwm=01;31:*.dz=01;31:*.ear=01;31:*.egg=01;31:*.esd=01;31:*.gz=01;31:*.jar=01;31:*.lha=01;31:*.lrz=01;31:*.lz=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.lzo=01;31:*.pyz=01;31:*.rar=01;31:*.rpm=01;31:*.rz=01;31:*.sar=01;31:*.swm=01;31:*.t7z=01;31:*.tar=01;31:*.taz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tgz=01;31:*.tlz=01;31:*.txz=01;31:*.tz=01;31:*.tzo=01;31:*.tzst=01;31:*.udeb=01;31:*.war=01;31:*.whl=01;31:*.wim=01;31:*.xz=01;31:*.z=01;31:*.zip=01;31:*.zoo=01;31:*.zst=01;31:*.avif=01;35:*.jpg=01;35:*.jpeg=01;35:*.jxl=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=01;36:*.au=01;36:*.flac=01;36:*.m4a=01;36:*.mid=01;36:*.midi=01;36:*.mka=01;36:*.mp3=01;36:*.mpc=01;36:*.ogg=01;36:*.ra=01;36:*.wav=01;36:*.oga=01;36:*.opus=01;36:*.spx=01;36:*.xspf=01;36:*~=00;90:*#=00;90:*.bak=00;90:*.crdownload=00;90:*.dpkg-dist=00;90:*.dpkg-new=00;90:*.dpkg-old=00;90:*.dpkg-tmp=00;90:*.old=00;90:*.orig=00;90:*.part=00;90:*.rej=00;90:*.rpmnew=00;90:*.rpmorig=00;90:*.rpmsave=00;90:*.swp=00;90:*.tmp=00;90:*.ucf-dist=00;90:*.ucf-new=00;90:*.ucf-old=00;90:', 'XDG_CURRENT_DESKTOP': 'KDE', 'KONSOLE_DBUS_SERVICE': ':1.211', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.kde.plasma\\x2dfallback\\x2dsession\\x2drestore@autostart.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'KONSOLE_DBUS_SESSION': '/Sessions/1', 'PROFILEHOME': '', 'INVOCATION_ID': 'dc2cedc8b21e41cd80abd7d30fac5b9f', 'KONSOLE_VERSION': '260800', 'MANAGERPID': '2853', 'CONTAINER_ID': 'pebble-dev', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'XDG_CACHE_HOME': '/home/joe/.cache', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'XDG_SESSION_CLASS': 'user', 'TERM': 'vt100', 'KONSOLE_DBUS_ACTIVATION_COOKIE': 'LAbljhQQ6d8T44OyCClJddWYI3HrvQwsVYKuh88mKQw=', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'joe', 'COLORFGBG': '15;0', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'SUDO_ASKPASS': '/usr/bin/ksshaskpass', 'DISPLAY': ':0', 'SHLVL': '5', 'LIBAACS_PATH': '/usr/lib64/libmmbd.so.0', 'XDG_SESSION_ID': '3', 'MANAGERPIDFDID': '16504', 'TERMINFO_DIRS': '/usr/share/terminfo:/run/host/usr/share/terminfo', 'XDG_STATE_HOME': '/home/joe/.local/state', 'XDG_RUNTIME_DIR': '/run/user/1000', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'KDEDIRS': '/usr', 'JOURNAL_STREAM': '10:71750', 'XDG_DATA_DIRS': '/home/joe/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/joe/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/joe/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/joe/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/joe/.local/share/../bin:/home/joe/.local/bin:/home/joe/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/usr/local/bin:/usr/bin:/sbin:/bin', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'MAIL': '/var/spool/mail/joe', 'BREW_BASH_COMPLETION': '1', 'FLATPAK_TTY_PROGRESS': '1', 'OLDPWD': '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face', 'KONSOLE_DBUS_WINDOW': '/Windows/1', '_': '/home/joe/.local/share/../bin/pebble', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/joe/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/joe/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/joe/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/joe/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/joe/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} +environ = {'SHELL': '/usr/bin/bash', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/4744,unix/unix:/tmp/.ICE-unix/4744', 'WINDOWID': '94737964455792', 'COLORTERM': 'truecolor', 'XDG_CONFIG_DIRS': '/home/joe/.config/kdedefaults:/etc/xdg', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'HISTSIZE': '1000', 'HOSTNAME': 'bazzite', 'ICEAUTHORITY': '/run/user/1000/iceauth_UzDUXy', 'LANGUAGE': '', 'XDG_DATA_HOME': '/home/joe/.local/share', 'XDG_CONFIG_HOME': '/home/joe/.config', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'LIBBDPLUS_PATH': '/usr/lib64/libmmbd.so.0', 'DISTROBOX_ENTER_PATH': '/usr/bin/distrobox-enter', 'DESKTOP_SESSION': 'plasma', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/joe/.gtkrc:/home/joe/.config/gtkrc', 'GPG_TTY': '/dev/pts/0', 'EDITOR': '/usr/bin/vim', 'PWD': '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face', 'LOGNAME': 'joe', 'XDG_SESSION_DESKTOP': 'KDE', 'XDG_SESSION_TYPE': 'wayland', 'SYSTEMD_EXEC_PID': '4771', 'XAUTHORITY': '/run/user/1000/xauth_uUhMpn', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'container': 'podman', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/joe/.gtkrc-2.0:/home/joe/.config/gtkrc-2.0', 'HOME': '/home/joe', 'SSH_ASKPASS': '/usr/bin/ksshaskpass', 'LANG': 'en_US.UTF-8', 'LS_COLORS': 'rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=01;37;41:su=37;41:sg=30;43:ca=00:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.7z=01;31:*.ace=01;31:*.alz=01;31:*.apk=01;31:*.arc=01;31:*.arj=01;31:*.bz=01;31:*.bz2=01;31:*.cab=01;31:*.cpio=01;31:*.crate=01;31:*.deb=01;31:*.drpm=01;31:*.dwm=01;31:*.dz=01;31:*.ear=01;31:*.egg=01;31:*.esd=01;31:*.gz=01;31:*.jar=01;31:*.lha=01;31:*.lrz=01;31:*.lz=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.lzo=01;31:*.pyz=01;31:*.rar=01;31:*.rpm=01;31:*.rz=01;31:*.sar=01;31:*.swm=01;31:*.t7z=01;31:*.tar=01;31:*.taz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tgz=01;31:*.tlz=01;31:*.txz=01;31:*.tz=01;31:*.tzo=01;31:*.tzst=01;31:*.udeb=01;31:*.war=01;31:*.whl=01;31:*.wim=01;31:*.xz=01;31:*.z=01;31:*.zip=01;31:*.zoo=01;31:*.zst=01;31:*.avif=01;35:*.jpg=01;35:*.jpeg=01;35:*.jxl=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=01;36:*.au=01;36:*.flac=01;36:*.m4a=01;36:*.mid=01;36:*.midi=01;36:*.mka=01;36:*.mp3=01;36:*.mpc=01;36:*.ogg=01;36:*.ra=01;36:*.wav=01;36:*.oga=01;36:*.opus=01;36:*.spx=01;36:*.xspf=01;36:*~=00;90:*#=00;90:*.bak=00;90:*.crdownload=00;90:*.dpkg-dist=00;90:*.dpkg-new=00;90:*.dpkg-old=00;90:*.dpkg-tmp=00;90:*.old=00;90:*.orig=00;90:*.part=00;90:*.rej=00;90:*.rpmnew=00;90:*.rpmorig=00;90:*.rpmsave=00;90:*.swp=00;90:*.tmp=00;90:*.ucf-dist=00;90:*.ucf-new=00;90:*.ucf-old=00;90:', 'XDG_CURRENT_DESKTOP': 'KDE', 'KONSOLE_DBUS_SERVICE': ':1.263', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/session.slice/plasma-plasmashell.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'KONSOLE_DBUS_SESSION': '/Sessions/1', 'PROFILEHOME': '', 'INVOCATION_ID': 'a12b351a101c4119a7c5c60aa826ccc6', 'KONSOLE_VERSION': '260800', 'MANAGERPID': '1963', 'CONTAINER_ID': 'pebble-dev', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'XDG_CACHE_HOME': '/home/joe/.cache', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'XDG_SESSION_CLASS': 'user', 'TERM': 'vt100', 'KONSOLE_DBUS_ACTIVATION_COOKIE': '2HHND7lm2Yy+uoIYjHaDh82fq2zTVbalWgjObAequ0U=', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'joe', 'COLORFGBG': '15;0', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'SUDO_ASKPASS': '/usr/bin/ksshaskpass', 'DISPLAY': ':0', 'SHLVL': '5', 'LIBAACS_PATH': '/usr/lib64/libmmbd.so.0', 'XDG_SESSION_ID': '3', 'MANAGERPIDFDID': '20566', 'TERMINFO_DIRS': '/usr/share/terminfo:/run/host/usr/share/terminfo', 'XDG_STATE_HOME': '/home/joe/.local/state', 'XDG_RUNTIME_DIR': '/run/user/1000', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'KDEDIRS': '/usr', 'JOURNAL_STREAM': '10:43747', 'XDG_DATA_DIRS': '/home/joe/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/joe/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/joe/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/joe/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/joe/.local/share/../bin:/home/joe/.local/bin:/home/joe/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/usr/local/bin:/usr/bin:/sbin:/bin', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'MAIL': '/var/spool/mail/joe', 'BREW_BASH_COMPLETION': '1', 'FLATPAK_TTY_PROGRESS': '1', 'OLDPWD': '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face', 'KONSOLE_DBUS_WINDOW': '/Windows/1', '_': '/home/joe/.local/share/../bin/pebble', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/joe/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/joe/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/joe/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/joe/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/joe/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} files = ['/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face/wscript'] hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' launch_dir = '/var/home/joe/Documents/Pebble/clockwork-hero/nine-hazes-face' diff --git a/nine-hazes-face/package.json b/nine-hazes-face/package.json index 497c503..6d9188b 100644 --- a/nine-hazes-face/package.json +++ b/nine-hazes-face/package.json @@ -15,7 +15,9 @@ "watchapp": { "watchface": true }, - "messageKeys": ["taskName", "taskEnd", "queueFill"], + "messageKeys": ["taskName", "syncTime", "clockOff", "boundSec", "used", + "taskCount", "curType", "curRem", "status", "abortCode", + "pendItems", "pendXp", "dictV", "serverDown"], "resources": { "media": [] } diff --git a/nine-hazes-face/src/c/face_packet.c b/nine-hazes-face/src/c/face_packet.c deleted file mode 100644 index f15fe42..0000000 --- a/nine-hazes-face/src/c/face_packet.c +++ /dev/null @@ -1,27 +0,0 @@ -#include -#include -#include "face_packet.h" - -static FacePacket s_packet; - -void face_packet_init(void) { - if (persist_exists(PACKET_KEY) && - persist_read_data(PACKET_KEY, &s_packet, sizeof(s_packet)) == - (int)sizeof(s_packet) && - s_packet.version == PACKET_VERSION) { - return; // cached packet restored — countdown resumes from end_epoch - } - memset(&s_packet, 0, sizeof(s_packet)); - s_packet.version = PACKET_VERSION; - persist_write_data(PACKET_KEY, &s_packet, sizeof(s_packet)); -} - -void face_packet_store(const FacePacket *p) { - if (!p || p->version != PACKET_VERSION) return; - s_packet = *p; - persist_write_data(PACKET_KEY, &s_packet, sizeof(s_packet)); -} - -const FacePacket *face_packet_get(void) { - return &s_packet; -} diff --git a/nine-hazes-face/src/c/face_packet.h b/nine-hazes-face/src/c/face_packet.h deleted file mode 100644 index 1591fe6..0000000 --- a/nine-hazes-face/src/c/face_packet.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once -#include - -// The face's ONLY view of game truth (DESIGN.md §4.1). Small, cacheable, -// stale-detectable. End timestamp is authoritative: countdown is local math. -#define PACKET_VERSION 1 -#define PACKET_KEY 101 -#define TASK_NAME_MAX 24 - -typedef struct __attribute__((packed)) { - uint8_t version; // PACKET_VERSION - char task_name[TASK_NAME_MAX]; // "" = no hero / not linked - uint32_t end_epoch; // UTC, chunk end of current task (0 = none) - uint8_t queue_fill; // chunks used, 0..36 - uint32_t fetched_epoch; // when JS delivered this (staleness signal) -} FacePacket; - -void face_packet_init(void); // load-or-default from persist -void face_packet_store(const FacePacket *p); // JS delivered a fresh one -const FacePacket *face_packet_get(void); diff --git a/nine-hazes-face/src/c/nine-hazes-face.c b/nine-hazes-face/src/c/nine-hazes-face.c index da45ccd..2e7867b 100644 --- a/nine-hazes-face/src/c/nine-hazes-face.c +++ b/nine-hazes-face/src/c/nine-hazes-face.c @@ -1,5 +1,5 @@ // =========================================================================== -// Nine Hazes — watchface (v0.1 clean rewrite, Muninn-lifecycle pattern) +// Nine Hazes — watchface (v0.2, Idle Task Protocol) // Replace the WHOLE file with this. Do not splice. // =========================================================================== #include @@ -7,16 +7,16 @@ #include #include "widgets.h" -#include "face_packet.h" +#include "snapshot.h" static Window *s_window; static Layer *s_canvas; static LayoutState s_layout; -static char s_rendered[WIDGET_SLOTS][64]; static char s_clock[8], s_date[16], s_strip[64]; static int s_bat_pct = -1; +static bool s_server_down = false; // volatile: set on failed check, cleared by absence -// ---- battery estimator (unchanged design) --------------------------------- +// ---- battery estimator ------------------------------------------------------ #define BAT_SAMPLES_MAX 16 #define BAT_KEY 102 typedef struct __attribute__((packed)) { @@ -57,7 +57,7 @@ static int bat_days_remaining(void) { return (int)((double)s_batlog.pct[n - 1] / -slope + 0.5); } -// ---- palette & fonts -------------------------------------------------------- +// ---- palette & fonts --------------------------------------------------------- static GColor pal_color(PalIndex i) { switch (i) { case PAL_BG: return GColorWhite; @@ -79,7 +79,7 @@ static void fonts_load(void) { s_fonts[4] = fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD); } -// ---- values ----------------------------------------------------------------- +// ---- values -------------------------------------------------------------------- static void update_values(void) { time_t now = time(NULL); struct tm *t = localtime(&now); @@ -87,31 +87,33 @@ static void update_values(void) { clock_is_24h_style() ? "%H:%M" : "%l:%M", t); strftime(s_date, sizeof(s_date), "%a %d %b", t); - const FacePacket *p = face_packet_get(); - if (p->end_epoch > 0 && p->task_name[0]) { - int32_t secs = (int32_t)(p->end_epoch - (uint32_t)now); - if (secs > 0) { - int32_t m = secs / 60 + ((secs % 60) ? 1 : 0); - snprintf(s_strip, sizeof(s_strip), "%s %ld:%02ld %u/36", - p->task_name, (long)(m / 60), (long)(m % 60), p->queue_fill); + const GameSnapshot *s = snapshot_get(); + if (s->bound_sec && s->cur_rem > 0 && s->status == 1) { // RUNNING + uint32_t end_sec = s->bound_sec + (uint32_t)(s->cur_rem - 1) * 900u; + int64_t now_ms = (int64_t)time(NULL) * 1000 + s->clock_off; + int64_t remain_ms = (int64_t)end_sec * 1000 - now_ms; + if (remain_ms > 0) { + long mins = (long)((remain_ms + 59999) / 60000); + snprintf(s_strip, sizeof(s_strip), "%ld:%02ld %u/36 seg %u tasks\n%s%s", + mins / 60, mins % 60, s->used, s->task_count, + taskname_get(), s->pend_items ? " (+)" : ""); } else { - snprintf(s_strip, sizeof(s_strip), "%s - return for reward", p->task_name); + snprintf(s_strip, sizeof(s_strip), "done %u/36 seg\n%s (collect in game)", + s->used, taskname_get()); } - } else { - snprintf(s_strip, sizeof(s_strip), "No hero yet"); - } + } else if (s->status == 2) snprintf(s_strip, sizeof(s_strip), "FROZEN — in play"); + else if (s->status == 3) snprintf(s_strip, sizeof(s_strip), "ABORTED (%d)", s->abort_code); + else if (s->status == 4) snprintf(s_strip, sizeof(s_strip), "LOCKED"); + else snprintf(s_strip, sizeof(s_strip), "No hero yet"); } -// ---- renderer ----------------------------------------------------------------- +// ---- renderer ------------------------------------------------------------------- static void canvas_update(Layer *layer, GContext *ctx) { - static int n = 0; - if (n < 5) { APP_LOG(APP_LOG_LEVEL_DEBUG, "[RENDER] pass %d", n); n++; } - GRect b = layer_get_bounds(layer); graphics_context_set_fill_color(ctx, pal_color(PAL_BG)); graphics_fill_rect(ctx, b, 0, GCornerNone); - update_values(); // ALWAYS fresh values before drawing + update_values(); for (uint8_t i = 0; i < s_layout.count; i++) { const WidgetCfg *w = &s_layout.widgets[i]; @@ -128,30 +130,38 @@ static void canvas_update(Layer *layer, GContext *ctx) { else snprintf(buf, sizeof(buf), "%d%% (~%dd)", s_bat_pct, days); text = buf; break; } - case W_PHONE_BATTERY: text = "phone?"; break; - case W_STEPS: text = "steps?"; break; - case W_DISTANCE: text = "dist?"; break; - case W_WEATHER: text = "--/--"; break; - case W_OMEN: text = ""; break; - case W_SLEEP: text = "sleep?"; break; - case W_TASK_STRIP: text = s_strip; break; + case W_PHONE_BATTERY: text = "phone?"; break; + case W_STEPS: text = "steps?"; break; + case W_DISTANCE: text = "dist?"; break; + case W_WEATHER: text = "--/--"; break; + case W_OMEN: text = ""; break; + case W_SLEEP: text = "sleep?"; break; + case W_TASK_STRIP: text = s_strip; break; default: continue; } GFont f = (w->font_idx < 5) ? s_fonts[w->font_idx] : s_fonts[1]; graphics_context_set_text_color(ctx, pal_color((PalIndex)w->color_idx)); - GRect box = GRect(w->x, w->y, b.size.w - w->x, 40); + int16_t h = (w->type == W_TASK_STRIP) ? 56 : 40; // two-line strip + GRect box = GRect(w->x, w->y, b.size.w - w->x, h); graphics_draw_text(ctx, text, f, box, GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); + + // offline flag: oxblood "!" at strip's top-right + if (s_server_down && w->type == W_TASK_STRIP) { + graphics_context_set_text_color(ctx, pal_color(PAL_WARNING)); + graphics_draw_text(ctx, "!", s_fonts[3], + GRect(b.size.w - 20, w->y - 4, 20, 28), + GTextOverflowModeWordWrap, GTextAlignmentRight, NULL); + } } } -static void force_full_redraw(void) { - for (int i = 0; i < WIDGET_SLOTS; i++) s_rendered[i][0] = '\0'; +static void request_redraw(void) { if (s_canvas) layer_mark_dirty(s_canvas); } -// ---- layout (persist-or-default) --------------------------------------------- +// ---- layout (persist-or-default) ---------------------------------------------- static void layout_default(void) { memset(&s_layout, 0, sizeof(s_layout)); s_layout.version = LAYOUT_VERSION; @@ -165,12 +175,11 @@ static void layout_default(void) { {W_PHONE_BATTERY, 0, 4, 130, 0, PAL_TEXT, 0}, {W_DISTANCE, 0, 4, 150, 0, PAL_TEXT, 0}, {W_WEATHER, 0, 4, 130, 2, PAL_TEXT, 0}, - {W_OMEN, 0, 4, 150, 0, PAL_POSITIVE,0}, + {W_OMEN, 0, 4, 150, 0, PAL_POSITIVE, 0}, {W_SLEEP, 0, 4, 170, 1, PAL_WATER, 0}, }; memcpy(s_layout.widgets, defs, sizeof(defs)); persist_write_data(LAYOUT_KEY, &s_layout, sizeof(s_layout)); - APP_LOG(APP_LOG_LEVEL_DEBUG, "[LAYOUT] defaults written"); } static void layout_init(void) { @@ -179,64 +188,81 @@ static void layout_init(void) { bool ok = (read == (int)sizeof(s_layout)) && (s_layout.version == LAYOUT_VERSION) && (s_layout.count > 0 && s_layout.count <= WIDGET_SLOTS); - APP_LOG(APP_LOG_LEVEL_DEBUG, "[LAYOUT] read=%d ok=%d count=%u", read, ok, s_layout.count); if (ok) return; } layout_default(); } -// ---- services ------------------------------------------------------------------ +// ---- services -------------------------------------------------------------------- static void tick_handler(struct tm *t, TimeUnits u) { - static int n = 0; - if (n < 3) { APP_LOG(APP_LOG_LEVEL_DEBUG, "[TICK] %02d:%02d", t->tm_hour, t->tm_min); n++; } - if (s_canvas) layer_mark_dirty(s_canvas); + request_redraw(); } static void battery_handler(BatteryChargeState state) { s_bat_pct = state.charge_percent; - APP_LOG(APP_LOG_LEVEL_DEBUG, "[BAT] %d%%", s_bat_pct); bat_sample((uint8_t)state.charge_percent); - if (s_canvas) layer_mark_dirty(s_canvas); + request_redraw(); } -// ---- AppMessage ------------------------------------------------------------- +// ---- AppMessage -------------------------------------------------------------------- static void inbox_received(DictionaryIterator *iter, void *ctx) { APP_LOG(APP_LOG_LEVEL_DEBUG, "[MSG] inbox received"); - FacePacket p = *face_packet_get(); - Tuple *t = dict_find(iter, MESSAGE_KEY_taskName); - if (t) { strncpy(p.task_name, t->value->cstring, TASK_NAME_MAX - 1); - p.task_name[TASK_NAME_MAX - 1] = '\0'; } - if ((t = dict_find(iter, MESSAGE_KEY_taskEnd))) p.end_epoch = t->value->uint32; - if ((t = dict_find(iter, MESSAGE_KEY_queueFill))) p.queue_fill = t->value->uint8; - p.fetched_epoch = (uint32_t)time(NULL); - face_packet_store(&p); - force_full_redraw(); + Tuple *t; + + // serverDown: presence means down; absence on a successful sync clears it + if ((t = dict_find(iter, MESSAGE_KEY_serverDown))) { + s_server_down = t->value->uint8 != 0; + request_redraw(); + return; + } + + GameSnapshot s = *snapshot_get(); + if ((t = dict_find(iter, MESSAGE_KEY_syncTime))) s.sync_time = t->value->uint32; + if ((t = dict_find(iter, MESSAGE_KEY_clockOff))) s.clock_off = (int32_t)t->value->int32; + if ((t = dict_find(iter, MESSAGE_KEY_boundSec))) s.bound_sec = t->value->uint32; + if ((t = dict_find(iter, MESSAGE_KEY_used))) s.used = t->value->uint8; + if ((t = dict_find(iter, MESSAGE_KEY_taskCount))) s.task_count = t->value->uint8; + if ((t = dict_find(iter, MESSAGE_KEY_curType))) s.cur_type = t->value->uint8; + if ((t = dict_find(iter, MESSAGE_KEY_curRem))) s.cur_rem = t->value->uint8; + if ((t = dict_find(iter, MESSAGE_KEY_status))) s.status = t->value->uint8; + if ((t = dict_find(iter, MESSAGE_KEY_abortCode))) s.abort_code = t->value->uint8; + if ((t = dict_find(iter, MESSAGE_KEY_pendItems))) s.pend_items = t->value->uint16; + if ((t = dict_find(iter, MESSAGE_KEY_pendXp))) s.pend_xp = t->value->uint16; + if ((t = dict_find(iter, MESSAGE_KEY_dictV))) s.dict_v = t->value->uint8; + snapshot_store(&s); + + if ((t = dict_find(iter, MESSAGE_KEY_taskName))) { + char name[TASK_NAME_MAX]; + strncpy(name, t->value->cstring, TASK_NAME_MAX - 1); + name[TASK_NAME_MAX - 1] = '\0'; + taskname_store(name); + } + + s_server_down = false; + request_redraw(); } -// ---- window lifecycle --------------------------------------------------------- +// ---- window lifecycle ---------------------------------------------------------------- static void window_load(Window *w) { Layer *root = window_get_root_layer(w); GRect b = layer_get_bounds(root); s_canvas = layer_create(b); layer_set_update_proc(s_canvas, canvas_update); layer_add_child(root, s_canvas); - APP_LOG(APP_LOG_LEVEL_DEBUG, "[LOAD] canvas up, count=%u w0=%u", s_layout.count, s_layout.widgets[0].type); update_values(); - force_full_redraw(); + request_redraw(); } static void window_unload(Window *w) { - APP_LOG(APP_LOG_LEVEL_DEBUG, "[UNLOAD]"); layer_destroy(s_canvas); s_canvas = NULL; } -// ---- app lifecycle (Muninn pattern: all state first, then window) ------------- +// ---- app lifecycle ---------------------------------------------------------------------- static void init(void) { - APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] start"); fonts_load(); layout_init(); - face_packet_init(); + snapshot_init(); if (persist_exists(BAT_KEY) && persist_read_data(BAT_KEY, &s_batlog, sizeof(s_batlog)) == (int)sizeof(s_batlog) && @@ -251,15 +277,13 @@ static void init(void) { window_set_window_handlers(s_window, (WindowHandlers){ .load = window_load, .unload = window_unload }); window_stack_push(s_window, true); - APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] window pushed"); tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); battery_state_service_subscribe(battery_handler); battery_handler(battery_state_service_peek()); app_message_register_inbox_received(inbox_received); - app_message_open(64, 64); - APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] done"); + app_message_open(256, 64); } static void deinit(void) { diff --git a/nine-hazes-face/src/c/snapshot.c b/nine-hazes-face/src/c/snapshot.c new file mode 100644 index 0000000..2c1218a --- /dev/null +++ b/nine-hazes-face/src/c/snapshot.c @@ -0,0 +1,47 @@ +#include +#include +#include "snapshot.h" + +static GameSnapshot s_snap; +static char s_name[TASK_NAME_MAX]; + +void snapshot_init(void) { + if (persist_exists(SNAP_KEY) && + persist_read_data(SNAP_KEY, &s_snap, sizeof(s_snap)) == (int)sizeof(s_snap) && + s_snap.version == SNAPSHOT_VERSION) { + // restored + } else { + memset(&s_snap, 0, sizeof(s_snap)); + s_snap.version = SNAPSHOT_VERSION; + s_snap.cur_type = 0xFF; + persist_write_data(SNAP_KEY, &s_snap, sizeof(s_snap)); + } + if (persist_exists(NAME_KEY)) { + int r = persist_read_data(NAME_KEY, s_name, sizeof(s_name)); + if (r > 0) s_name[r > (int)sizeof(s_name) ? (int)sizeof(s_name) - 1 : r - 1] = '\0'; + else s_name[0] = '\0'; + } else { + s_name[0] = '\0'; + } +} + +void snapshot_store(const GameSnapshot *s) { + if (!s || s->version != SNAPSHOT_VERSION) return; + s_snap = *s; + persist_write_data(SNAP_KEY, &s_snap, sizeof(s_snap)); +} + +const GameSnapshot *snapshot_get(void) { + return &s_snap; +} + +void taskname_store(const char *name) { + if (!name) { s_name[0] = '\0'; return; } + strncpy(s_name, name, TASK_NAME_MAX - 1); + s_name[TASK_NAME_MAX - 1] = '\0'; + persist_write_data(NAME_KEY, s_name, strlen(s_name) + 1); +} + +const char *taskname_get(void) { + return s_name; +} diff --git a/nine-hazes-face/src/c/snapshot.h b/nine-hazes-face/src/c/snapshot.h new file mode 100644 index 0000000..1749ecc --- /dev/null +++ b/nine-hazes-face/src/c/snapshot.h @@ -0,0 +1,33 @@ +#pragma once +#include + +// Idle Task Protocol v1.0 §7 — device snapshot. Protocol-owned, do not +// reshape casually. NOTE deviation (flagged to server team): wire and +// persist use epoch SECONDS for bound/sync because AppMessage uint32 +// cannot carry epoch milliseconds. +#define SNAPSHOT_VERSION 1 +#define SNAP_KEY 0x30 // protocol-specified +#define NAME_KEY 0x32 // face-local: current task name (display nicety) +#define TASK_NAME_MAX 24 + +typedef struct __attribute__((packed)) { + uint8_t version; // SNAPSHOT_VERSION + uint32_t sync_time; // epoch sec of last successful sync + int32_t clock_off; // ms; server_time_ms - watch_time_ms + uint32_t bound_sec; // epoch sec; 0 if no current task + uint8_t used; // segments used + uint8_t task_count; + uint8_t cur_type; // dict index, 0xFF if none + uint8_t cur_rem; // segments remaining incl. in-flight + uint8_t status; // 0 EMPTY 1 RUNNING 2 FROZEN 3 ABORTED 4 LOCKED + uint8_t abort_code; + uint16_t pend_items; + uint16_t pend_xp; + uint8_t dict_v; +} GameSnapshot; + +void snapshot_init(void); +void snapshot_store(const GameSnapshot *s); +const GameSnapshot *snapshot_get(void); +void taskname_store(const char *name); +const char *taskname_get(void); // valid until next store diff --git a/nine-hazes-face/src/pkjs/index.js b/nine-hazes-face/src/pkjs/index.js index 94c4fe0..9d1d7b6 100644 --- a/nine-hazes-face/src/pkjs/index.js +++ b/nine-hazes-face/src/pkjs/index.js @@ -1,8 +1,135 @@ -// Nine Hazes watchface pkjs. Phase 1: hello only. -// Later (per DESIGN.md §4.1): on 'ready', fetch the face packet from the -// configured server (default instance, self-hostable override) respecting the -// staleness timestamp, then Pebble.sendAppMessage({taskName, taskEnd, -// queueFill}). All fetches wrapped: server down -> render cached, silently. -Pebble.addEventListener('ready', () => { - console.log('Nine Hazes pkjs ready'); +// ============================================================================= +// Nine Hazes — pkjs Task API client (Idle Task Protocol v1.0) +// ES5-compatible for Pebble SDK webpack 1.15.0 (acorn parser) +// ============================================================================= + +var CONFIG = { + baseUrl: 'http://127.0.0.1:8080', + token: 'sekrit', +}; + +function get(path, cb) { + var xhr = new XMLHttpRequest(); + var abortTimer = setTimeout(function () { xhr.abort(); }, 5000); + xhr.open('GET', CONFIG.baseUrl + path, true); + xhr.setRequestHeader('Authorization', 'Bearer ' + CONFIG.token); + + xhr.onload = function () { + clearTimeout(abortTimer); + if (xhr.status === 204) { return cb(null, null); } + if (xhr.status !== 200) { return cb('HTTP ' + xhr.status); } + try { cb(null, JSON.parse(xhr.responseText)); } + catch (e) { cb('malformed'); } + }; + xhr.onerror = function () { + clearTimeout(abortTimer); + cb('network'); + }; + xhr.send(); +} + +// dictionary: names indexed by task id, cached +function dictNames(cb) { + var cachedV = parseInt(localStorage.getItem('dict_v') || '-1', 10); + var cached = localStorage.getItem('dict_names'); + if (cached) { + try { + var ok = localStorage.getItem('dict_ok') === 'true'; + if (ok) { + return cb(JSON.parse(cached)); + } + } catch (e) {} + } + + get('/v1/dictionary?v=-1', function (err, dict) { + if (err || !dict) { return cb(null); } + var names = {}; + for (var i = 0; i < dict.tasks.length; i++) { + var t = dict.tasks[i]; + names[t.id] = t.name; + } + localStorage.setItem('dict_v', String(dict.v)); + localStorage.setItem('dict_names', JSON.stringify(names)); + localStorage.setItem('dict_ok', 'true'); + cb(names); + }); +} + +Pebble.addEventListener('ready', function () { + console.log('[PKJS] ready — syncing queue'); + sync(); }); + +Pebble.addEventListener('appmessage', function (e) { + console.log('[PKJS] appmessage from watch: ' + JSON.stringify(e.payload)); +}); + +Pebble.addEventListener('showConfiguration', function () {}); +Pebble.addEventListener('webviewclosed', function () {}); + +function sync() { + get('/v1/queue', function (err, q) { + if (err || !q) { + console.log('[PKJS] fetch failed (' + err + ') — flagging serverDown'); + Pebble.sendAppMessage({ serverDown: 1 }, function () {}, function () {}); + return; + } + + var msg = { + syncTime: Math.floor(Date.now() / 1000), + clockOff: q.t - Date.now(), + boundSec: q.bound ? Math.floor(q.bound / 1000) : 0, + used: q.used, + taskCount: q.tasks ? q.tasks.length : 0, + curType: q.tasks && q.tasks.length ? q.tasks[0].type : 255, + curRem: q.tasks && q.tasks.length ? q.tasks[0].rem : 0, + status: statusCode(q.status), + abortCode: q.abort || 0, + pendItems: q.pend ? q.pend.items : 0, + pendXp: q.pend ? q.pend.xp : 0, + dictV: q.dict, + }; + + var cachedV = parseInt(localStorage.getItem('dict_v') || '-1', 10); + var finish = function (names) { + if (msg.curType !== 255 && names && names[msg.curType]) { + var nameStr = String(names[msg.curType]).slice(0, 24); + msg.taskName = nameStr; + } + Pebble.sendAppMessage(msg, + function () { console.log('[PKJS] snapshot delivered'); }, + function (e) { console.log('[PKJS] send failed: ' + JSON.stringify(e)); }); + }; + + if (cachedV === q.dict) { + try { + finish(JSON.parse(localStorage.getItem('dict_names') || '{}')); + return; + } catch (e) {} + } + + get('/v1/dictionary?v=' + cachedV, function (derr, dict) { + if (derr || !dict) { + try { + finish(JSON.parse(localStorage.getItem('dict_names') || '{}')); + } catch (e2) { + finish(null); + } + return; + } + var names = {}; + for (var i = 0; i < dict.tasks.length; i++) { + var t = dict.tasks[i]; + names[t.id] = t.name; + } + localStorage.setItem('dict_v', String(dict.v)); + localStorage.setItem('dict_names', JSON.stringify(names)); + finish(names); + }); + }); +} + +function statusCode(s) { + var map = { 'EMPTY': 0, 'RUNNING': 1, 'FROZEN': 2, 'ABORTED': 3, 'LOCKED': 4 }; + return map[s] !== undefined ? map[s] : 0; +} diff --git a/nine-hazes-server/.gitignore b/nine-hazes-server/.gitignore new file mode 100644 index 0000000..08c46b7 --- /dev/null +++ b/nine-hazes-server/.gitignore @@ -0,0 +1,2 @@ +# Bridge server state (self-hosters' data, never committed) +/nine-hazes-server/src/state diff --git a/nine-hazes-server/package.json b/nine-hazes-server/package.json new file mode 100644 index 0000000..3cd35ae --- /dev/null +++ b/nine-hazes-server/package.json @@ -0,0 +1,10 @@ +{ + "name": "nine-hazes-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node --experimental-strip-types src/server.ts", + "typecheck": "tsc --noEmit" + } +} diff --git a/nine-hazes-server/src/server.ts b/nine-hazes-server/src/server.ts new file mode 100644 index 0000000..e98b59b --- /dev/null +++ b/nine-hazes-server/src/server.ts @@ -0,0 +1,161 @@ +// ============================================================================ +// Nine Hazes — placeholder Idle Task Protocol server (protocol §10) +// No game logic. One fake character, in-memory state, restart-wipes. +// Zero dependencies: node:http only. Node >= 22.6 (type stripping). +// ============================================================================ + +import * as http from "node:http"; + +const PORT = Number(process.env.PORT ?? 8080); +const HOST = process.env.HOST ?? "127.0.0.1"; +const DEV_TOKEN = process.env.BRIDGE_TOKEN ?? ""; +const SEG_MS = 900_000; +const DICT_V = 5; + +// ---- helpers (hoisted: must precede module-load-time callers) -------------- +const seg = (ms: number) => Math.floor(ms / SEG_MS); +const rem = (t: Task, nowSeg: number) => +t.startSeg > nowSeg ? t.segs : t.segs - (nowSeg - t.startSeg); +const lastEnd = (t: Task) => t.startSeg + t.segs; + +interface Task { tid: number; type: number; segs: number; startSeg: number; } + +const state = { + nextTid: 220, + pend: { items: 14, xp: 620 }, + tasks: [ + { tid: 217, type: 0, segs: 3, startSeg: seg(Date.now()) }, + { tid: 218, type: 1, segs: 5, startSeg: seg(Date.now()) + 3 }, + { tid: 219, type: 0, segs: 8, startSeg: seg(Date.now()) + 8 }, + ], +}; + +// ---- lazy progression: complete elapsed segments at each observation ----- +function advance(nowSeg: number): void { + while (state.tasks.length) { + const cur = state.tasks[0]; + if (nowSeg >= cur.startSeg + cur.segs) { + state.tasks.shift(); + state.pend.items += 3; state.pend.xp += 120; + if (state.tasks[0]) state.tasks[0].startSeg = cur.startSeg + cur.segs; + } else break; + } +} + +// ---- rate limiting: 30 req/min per token ----------------------------------- +const hits = new Map(); +function rateLimited(token: string): boolean { + const now = Date.now(); + const w = (hits.get(token) ?? []).filter(t => now - t < 60_000); + w.push(now); hits.set(token, w); + return w.length > 30; +} + +function send(res: http.ServerResponse, code: number, body?: unknown): void { + const json = body === undefined ? "" : JSON.stringify(body); + res.writeHead(code, { + "Content-Type": "application/json", + ...(json === "" ? {} : { "Content-Length": String(Buffer.byteLength(json)) }), + }); + res.end(json); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; let size = 0; + req.on("data", (c: Buffer) => { size += c.length; if (size > 2048) { req.destroy(); reject(new Error("too large")); } chunks.push(c); }); + req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { reject(new Error("bad json")); } }); + req.on("error", reject); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const now = Date.now(); + + if (req.method === "GET" && url.pathname === "/healthz") return send(res, 200, { ok: true }); + + const auth = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, ""); + const notauth = auth === "" || auth === "bad-token" || + (DEV_TOKEN !== "" && auth !== DEV_TOKEN); + if (notauth) return send(res, 401, { code: "NOTAUTH", error: "unauthorized" }); + if (rateLimited(auth)) return send(res, 429, { error: "rate limited" }); + + if (req.method === "GET" && url.pathname === "/v1/dictionary") { + if (Number(url.searchParams.get("v") ?? -1) === DICT_V) return send(res, 204); + return send(res, 200, { + v: DICT_V, + tasks: [ + { id: 0, name: "Gather", icon: "pickaxe" }, + { id: 1, name: "Delve Barrow", icon: "fist" }, + { id: 2, name: "Market Run", icon: "coin" }, + { id: 3, name: "Rest Travel", icon: "moon" }, + ], + errors: ["OK", "QFULL", "BADSEG", "NOTAUTH", "NOTASK", "LOCKED"], + }); + } + + advance(seg(now)); + + if (req.method === "GET" && url.pathname === "/v1/queue") { + const sim = url.searchParams.get("sim"); + const status = sim ?? (state.tasks.length ? "RUNNING" : "EMPTY"); + const used = state.tasks.reduce((s, t) => s + rem(t, seg(now)), 0); + return send(res, 200, { + t: now, dict: DICT_V, + tasks: state.tasks.map((t, i) => i === 0 + ? { tid: t.tid, type: t.type, rem: rem(t, seg(now)), epochSeg: t.startSeg } + : { tid: t.tid, type: t.type, rem: t.segs }), + used, budget: 36, + pend: { ...state.pend }, + bound: (seg(now) + 1) * SEG_MS, + status, abort: 0, + tok: { exp: now + 30 * 24 * 3600 * 1000 }, + }); + } + + if (req.method === "POST" && url.pathname === "/v1/queue/tasks") { + let body: any; + try { body = await readBody(req); } catch { return send(res, 400, { code: "BADSEG", error: "invalid body" }); } + const segs = Number(body?.segs), type = Number(body?.type ?? 0); + const used = state.tasks.reduce((s, t) => s + rem(t, seg(now)), 0); + if (!Number.isInteger(segs) || segs < 1 || segs > 36) + return send(res, 400, { code: "BADSEG", error: "segs must be integer 1-36" }); + if (used + segs > 36) + return send(res, 409, { code: "QFULL", error: `used ${used} + ${segs} > 36` }); + const nowSeg = seg(now); + const startSeg = state.tasks.length + ? lastEnd(state.tasks[state.tasks.length - 1]) + : nowSeg; + const t: Task = { tid: state.nextTid++, type, segs, startSeg }; + state.tasks.push(t); + return send(res, 201, { tid: t.tid, rem: segs }); + } + + const dm = url.pathname.match(/^\/v1\/queue\/tasks\/(\d+)$/); + if (req.method === "DELETE" && dm) { + const tid = Number(dm[1]); + const idx = state.tasks.findIndex(t => t.tid === tid); + if (idx === -1) return send(res, 404, { code: "NOTASK", error: "unknown tid" }); + const t = state.tasks[idx]; + const refund = idx === 0 ? 0 : t.segs; + state.tasks.splice(idx, 1); + if (idx === 0 && state.tasks.length) state.tasks[0].startSeg = seg(now); + return send(res, 200, { refund }); + } + + if (req.method === "POST" && url.pathname === "/v1/claim") { + const out = { ...state.pend }; + state.pend = { items: 0, xp: 0 }; + return send(res, 200, out); + } + + if (req.method === "POST" && url.pathname === "/v1/token/refresh") { + return send(res, 200, { token: "dev-refreshed-token-" + Date.now().toString(36), exp: now + 90 * 24 * 3600 * 1000 }); + } + + return send(res, 404, { error: "not found" }); +}); + +server.listen(PORT, HOST, () => +console.log(`idle-task mock listening on http://${HOST}:${PORT} (no game logic, state wipes on restart)`)); diff --git a/nine-hazes-server/src/state/test-player.json b/nine-hazes-server/src/state/test-player.json new file mode 100644 index 0000000..b3b8315 --- /dev/null +++ b/nine-hazes-server/src/state/test-player.json @@ -0,0 +1 @@ +{"sync_version":4,"task_name":"Cut Wood","end_epoch":1789189244,"queue_fill":12} \ No newline at end of file