feat(face): integrate Idle Task Protocol v1.0 end-to-end

Watchface now syncs with the Nine Hazes idle task system via its own
pkjs client — the three-artifact architecture is operational.

Wire side:
- pkjs fetches GET /v1/queue on launch, applies clock offset, converts
  epoch-ms to seconds for AppMessage, resolves task name from the
  cached dictionary (refetch on version drift)
- GameSnapshot persisted at key 0x30 (protocol-specified); task name
  at 0x32 (face-local nicety)
- AppMessage inbox widened to 256 bytes to carry the full snapshot
  dictionary in a single logical message

Display side:
- Task strip renders two lines: countdown-to-completion + used/36 +
  task count, then task name with "(+)" pending-rewards cue
- Status states render distinctly: FROZEN / ABORTED(code) / LOCKED /
  No hero yet (EMPTY)
- serverDown sentinel key: oxblood "!" icon on failed check; absence
  on successful sync clears it; volatile — never persisted
- Countdown math is server-clock corrected (clock_off), computed
  locally from bound_sec — zero radio to stay fresh

Also:
- ES5-normalized pkjs for SDK webpack 1.15/acorn parser
- Replaced face_packet.{h,c} with snapshot.{h,c} (PACKET_KEY 101
  retired; old blob orphaned harmlessly)
- Mock Task API server seeded with 3-task queue (in-memory)

Verified: seeded queue renders "0:xx 16/36 seg 3 tasks / Gather (+)";
server kill → cached strip + offline icon, no error surface.
This commit is contained in:
Mystica Venatus
2026-09-13 10:18:34 -04:00
parent 9c45250f4c
commit 55480b3fc6
14 changed files with 567 additions and 118 deletions

89
docs/PROTOCOL.md Normal file
View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,9 @@
"watchapp": { "watchapp": {
"watchface": true "watchface": true
}, },
"messageKeys": ["taskName", "taskEnd", "queueFill"], "messageKeys": ["taskName", "syncTime", "clockOff", "boundSec", "used",
"taskCount", "curType", "curRem", "status", "abortCode",
"pendItems", "pendXp", "dictV", "serverDown"],
"resources": { "resources": {
"media": [] "media": []
} }

View File

@@ -1,27 +0,0 @@
#include <pebble.h>
#include <string.h>
#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;
}

View File

@@ -1,20 +0,0 @@
#pragma once
#include <pebble.h>
// 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);

View File

@@ -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. // Replace the WHOLE file with this. Do not splice.
// =========================================================================== // ===========================================================================
#include <pebble.h> #include <pebble.h>
@@ -7,16 +7,16 @@
#include <string.h> #include <string.h>
#include "widgets.h" #include "widgets.h"
#include "face_packet.h" #include "snapshot.h"
static Window *s_window; static Window *s_window;
static Layer *s_canvas; static Layer *s_canvas;
static LayoutState s_layout; static LayoutState s_layout;
static char s_rendered[WIDGET_SLOTS][64];
static char s_clock[8], s_date[16], s_strip[64]; static char s_clock[8], s_date[16], s_strip[64];
static int s_bat_pct = -1; 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_SAMPLES_MAX 16
#define BAT_KEY 102 #define BAT_KEY 102
typedef struct __attribute__((packed)) { 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); return (int)((double)s_batlog.pct[n - 1] / -slope + 0.5);
} }
// ---- palette & fonts -------------------------------------------------------- // ---- palette & fonts ---------------------------------------------------------
static GColor pal_color(PalIndex i) { static GColor pal_color(PalIndex i) {
switch (i) { switch (i) {
case PAL_BG: return GColorWhite; 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); s_fonts[4] = fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD);
} }
// ---- values ----------------------------------------------------------------- // ---- values --------------------------------------------------------------------
static void update_values(void) { static void update_values(void) {
time_t now = time(NULL); time_t now = time(NULL);
struct tm *t = localtime(&now); struct tm *t = localtime(&now);
@@ -87,31 +87,33 @@ static void update_values(void) {
clock_is_24h_style() ? "%H:%M" : "%l:%M", t); clock_is_24h_style() ? "%H:%M" : "%l:%M", t);
strftime(s_date, sizeof(s_date), "%a %d %b", t); strftime(s_date, sizeof(s_date), "%a %d %b", t);
const FacePacket *p = face_packet_get(); const GameSnapshot *s = snapshot_get();
if (p->end_epoch > 0 && p->task_name[0]) { if (s->bound_sec && s->cur_rem > 0 && s->status == 1) { // RUNNING
int32_t secs = (int32_t)(p->end_epoch - (uint32_t)now); uint32_t end_sec = s->bound_sec + (uint32_t)(s->cur_rem - 1) * 900u;
if (secs > 0) { int64_t now_ms = (int64_t)time(NULL) * 1000 + s->clock_off;
int32_t m = secs / 60 + ((secs % 60) ? 1 : 0); int64_t remain_ms = (int64_t)end_sec * 1000 - now_ms;
snprintf(s_strip, sizeof(s_strip), "%s %ld:%02ld %u/36", if (remain_ms > 0) {
p->task_name, (long)(m / 60), (long)(m % 60), p->queue_fill); 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 { } 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 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); GRect b = layer_get_bounds(layer);
graphics_context_set_fill_color(ctx, pal_color(PAL_BG)); graphics_context_set_fill_color(ctx, pal_color(PAL_BG));
graphics_fill_rect(ctx, b, 0, GCornerNone); 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++) { for (uint8_t i = 0; i < s_layout.count; i++) {
const WidgetCfg *w = &s_layout.widgets[i]; const WidgetCfg *w = &s_layout.widgets[i];
@@ -140,18 +142,26 @@ static void canvas_update(Layer *layer, GContext *ctx) {
GFont f = (w->font_idx < 5) ? s_fonts[w->font_idx] : s_fonts[1]; 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)); 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, graphics_draw_text(ctx, text, f, box, GTextOverflowModeTrailingEllipsis,
GTextAlignmentLeft, NULL); 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) { static void request_redraw(void) {
for (int i = 0; i < WIDGET_SLOTS; i++) s_rendered[i][0] = '\0';
if (s_canvas) layer_mark_dirty(s_canvas); if (s_canvas) layer_mark_dirty(s_canvas);
} }
// ---- layout (persist-or-default) --------------------------------------------- // ---- layout (persist-or-default) ----------------------------------------------
static void layout_default(void) { static void layout_default(void) {
memset(&s_layout, 0, sizeof(s_layout)); memset(&s_layout, 0, sizeof(s_layout));
s_layout.version = LAYOUT_VERSION; s_layout.version = LAYOUT_VERSION;
@@ -170,7 +180,6 @@ static void layout_default(void) {
}; };
memcpy(s_layout.widgets, defs, sizeof(defs)); memcpy(s_layout.widgets, defs, sizeof(defs));
persist_write_data(LAYOUT_KEY, &s_layout, sizeof(s_layout)); persist_write_data(LAYOUT_KEY, &s_layout, sizeof(s_layout));
APP_LOG(APP_LOG_LEVEL_DEBUG, "[LAYOUT] defaults written");
} }
static void layout_init(void) { static void layout_init(void) {
@@ -179,64 +188,81 @@ static void layout_init(void) {
bool ok = (read == (int)sizeof(s_layout)) && bool ok = (read == (int)sizeof(s_layout)) &&
(s_layout.version == LAYOUT_VERSION) && (s_layout.version == LAYOUT_VERSION) &&
(s_layout.count > 0 && s_layout.count <= WIDGET_SLOTS); (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; if (ok) return;
} }
layout_default(); layout_default();
} }
// ---- services ------------------------------------------------------------------ // ---- services --------------------------------------------------------------------
static void tick_handler(struct tm *t, TimeUnits u) { static void tick_handler(struct tm *t, TimeUnits u) {
static int n = 0; request_redraw();
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);
} }
static void battery_handler(BatteryChargeState state) { static void battery_handler(BatteryChargeState state) {
s_bat_pct = state.charge_percent; s_bat_pct = state.charge_percent;
APP_LOG(APP_LOG_LEVEL_DEBUG, "[BAT] %d%%", s_bat_pct);
bat_sample((uint8_t)state.charge_percent); 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) { static void inbox_received(DictionaryIterator *iter, void *ctx) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "[MSG] inbox received"); APP_LOG(APP_LOG_LEVEL_DEBUG, "[MSG] inbox received");
FacePacket p = *face_packet_get(); Tuple *t;
Tuple *t = dict_find(iter, MESSAGE_KEY_taskName);
if (t) { strncpy(p.task_name, t->value->cstring, TASK_NAME_MAX - 1); // serverDown: presence means down; absence on a successful sync clears it
p.task_name[TASK_NAME_MAX - 1] = '\0'; } if ((t = dict_find(iter, MESSAGE_KEY_serverDown))) {
if ((t = dict_find(iter, MESSAGE_KEY_taskEnd))) p.end_epoch = t->value->uint32; s_server_down = t->value->uint8 != 0;
if ((t = dict_find(iter, MESSAGE_KEY_queueFill))) p.queue_fill = t->value->uint8; request_redraw();
p.fetched_epoch = (uint32_t)time(NULL); return;
face_packet_store(&p);
force_full_redraw();
} }
// ---- window lifecycle --------------------------------------------------------- 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 ----------------------------------------------------------------
static void window_load(Window *w) { static void window_load(Window *w) {
Layer *root = window_get_root_layer(w); Layer *root = window_get_root_layer(w);
GRect b = layer_get_bounds(root); GRect b = layer_get_bounds(root);
s_canvas = layer_create(b); s_canvas = layer_create(b);
layer_set_update_proc(s_canvas, canvas_update); layer_set_update_proc(s_canvas, canvas_update);
layer_add_child(root, s_canvas); 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(); update_values();
force_full_redraw(); request_redraw();
} }
static void window_unload(Window *w) { static void window_unload(Window *w) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "[UNLOAD]");
layer_destroy(s_canvas); layer_destroy(s_canvas);
s_canvas = NULL; s_canvas = NULL;
} }
// ---- app lifecycle (Muninn pattern: all state first, then window) ------------- // ---- app lifecycle ----------------------------------------------------------------------
static void init(void) { static void init(void) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] start");
fonts_load(); fonts_load();
layout_init(); layout_init();
face_packet_init(); snapshot_init();
if (persist_exists(BAT_KEY) && if (persist_exists(BAT_KEY) &&
persist_read_data(BAT_KEY, &s_batlog, sizeof(s_batlog)) == (int)sizeof(s_batlog) && 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){ window_set_window_handlers(s_window, (WindowHandlers){
.load = window_load, .unload = window_unload }); .load = window_load, .unload = window_unload });
window_stack_push(s_window, true); window_stack_push(s_window, true);
APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] window pushed");
tick_timer_service_subscribe(MINUTE_UNIT, tick_handler); tick_timer_service_subscribe(MINUTE_UNIT, tick_handler);
battery_state_service_subscribe(battery_handler); battery_state_service_subscribe(battery_handler);
battery_handler(battery_state_service_peek()); battery_handler(battery_state_service_peek());
app_message_register_inbox_received(inbox_received); app_message_register_inbox_received(inbox_received);
app_message_open(64, 64); app_message_open(256, 64);
APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] done");
} }
static void deinit(void) { static void deinit(void) {

View File

@@ -0,0 +1,47 @@
#include <pebble.h>
#include <string.h>
#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;
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include <pebble.h>
// 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

View File

@@ -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 // Nine Hazes — pkjs Task API client (Idle Task Protocol v1.0)
// configured server (default instance, self-hostable override) respecting the // ES5-compatible for Pebble SDK webpack 1.15.0 (acorn parser)
// staleness timestamp, then Pebble.sendAppMessage({taskName, taskEnd, // =============================================================================
// queueFill}). All fetches wrapped: server down -> render cached, silently.
Pebble.addEventListener('ready', () => { var CONFIG = {
console.log('Nine Hazes pkjs ready'); 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;
}

2
nine-hazes-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Bridge server state (self-hosters' data, never committed)
/nine-hazes-server/src/state

View File

@@ -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"
}
}

View File

@@ -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<string, number[]>();
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<any> {
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)`));

View File

@@ -0,0 +1 @@
{"sync_version":4,"task_name":"Cut Wood","end_epoch":1789189244,"queue_fill":12}