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