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:
89
docs/PROTOCOL.md
Normal file
89
docs/PROTOCOL.md
Normal 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
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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 {
|
} else if (s->status == 2) snprintf(s_strip, sizeof(s_strip), "FROZEN — in play");
|
||||||
snprintf(s_strip, sizeof(s_strip), "No hero yet");
|
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];
|
||||||
@@ -128,30 +130,38 @@ static void canvas_update(Layer *layer, GContext *ctx) {
|
|||||||
else snprintf(buf, sizeof(buf), "%d%% (~%dd)", s_bat_pct, days);
|
else snprintf(buf, sizeof(buf), "%d%% (~%dd)", s_bat_pct, days);
|
||||||
text = buf; break;
|
text = buf; break;
|
||||||
}
|
}
|
||||||
case W_PHONE_BATTERY: text = "phone?"; break;
|
case W_PHONE_BATTERY: text = "phone?"; break;
|
||||||
case W_STEPS: text = "steps?"; break;
|
case W_STEPS: text = "steps?"; break;
|
||||||
case W_DISTANCE: text = "dist?"; break;
|
case W_DISTANCE: text = "dist?"; break;
|
||||||
case W_WEATHER: text = "--/--"; break;
|
case W_WEATHER: text = "--/--"; break;
|
||||||
case W_OMEN: text = ""; break;
|
case W_OMEN: text = ""; break;
|
||||||
case W_SLEEP: text = "sleep?"; break;
|
case W_SLEEP: text = "sleep?"; break;
|
||||||
case W_TASK_STRIP: text = s_strip; break;
|
case W_TASK_STRIP: text = s_strip; break;
|
||||||
default: continue;
|
default: continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -165,12 +175,11 @@ static void layout_default(void) {
|
|||||||
{W_PHONE_BATTERY, 0, 4, 130, 0, PAL_TEXT, 0},
|
{W_PHONE_BATTERY, 0, 4, 130, 0, PAL_TEXT, 0},
|
||||||
{W_DISTANCE, 0, 4, 150, 0, PAL_TEXT, 0},
|
{W_DISTANCE, 0, 4, 150, 0, PAL_TEXT, 0},
|
||||||
{W_WEATHER, 0, 4, 130, 2, 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},
|
{W_SLEEP, 0, 4, 170, 1, PAL_WATER, 0},
|
||||||
};
|
};
|
||||||
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();
|
|
||||||
|
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) {
|
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) {
|
||||||
|
|||||||
47
nine-hazes-face/src/c/snapshot.c
Normal file
47
nine-hazes-face/src/c/snapshot.c
Normal 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;
|
||||||
|
}
|
||||||
33
nine-hazes-face/src/c/snapshot.h
Normal file
33
nine-hazes-face/src/c/snapshot.h
Normal 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
|
||||||
@@ -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
2
nine-hazes-server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Bridge server state (self-hosters' data, never committed)
|
||||||
|
/nine-hazes-server/src/state
|
||||||
10
nine-hazes-server/package.json
Normal file
10
nine-hazes-server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
161
nine-hazes-server/src/server.ts
Normal file
161
nine-hazes-server/src/server.ts
Normal 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)`));
|
||||||
1
nine-hazes-server/src/state/test-player.json
Normal file
1
nine-hazes-server/src/state/test-player.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sync_version":4,"task_name":"Cut Wood","end_epoch":1789189244,"queue_fill":12}
|
||||||
Reference in New Issue
Block a user