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

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}