Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
DevPlace CI / test (push) Failing after 44m45s

Continuous Delivery could never be turned off once a crop was planted. Add a
one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool
game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant,
autoreplant claim or Refactor. Autoreplant now also respects crop locks through
the shared _crop_lock predicate. Plan and measurements in happyplanting.md.

Also includes the pending community sync, node network and related work in the
tree; the full suite passed (4999) with all of it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 02:23:43 +02:00
co-authored by Claude Sonnet 5.5
parent ce899fc5e1
commit 02ab747cd0
203 changed files with 11624 additions and 301 deletions
+67
View File
@@ -89,3 +89,70 @@ test("hello limits advertise the effective concurrency", () => {
const limits = new Limits({ local: { maxConcurrent: 1 } });
assert.deepEqual(limits.helloLimits(), { max_concurrent: 1 });
});
function serverLimits(window, clock, local = {}) {
const limits = new Limits({ clock, local });
limits.applyServerSettings({ active_from: window.from, active_to: window.to, timezone: window.zone });
return limits;
}
test("the server window is evaluated in the server timezone", () => {
const moment = () => new Date(Date.UTC(2026, 0, 15, 12, 0));
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "UTC" }, moment).inActiveHours(), true);
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), false);
assert.equal(serverLimits({ from: "20:00", to: "23:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
assert.equal(serverLimits({ from: "22:00", to: "06:00", zone: "America/New_York" }, moment).inActiveHours(), false);
assert.equal(serverLimits({ from: "21:00", to: "06:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
});
test("an invalid server timezone falls back to UTC and empty or equal bounds mean always", () => {
const moment = () => new Date(Date.UTC(2026, 0, 15, 12, 0));
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "Not/AZone" }, moment).inActiveHours(), true);
assert.equal(serverLimits({ from: "13:00", to: "17:00", zone: "Not/AZone" }, moment).inActiveHours(), false);
assert.equal(serverLimits({ from: "", to: "", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
assert.equal(serverLimits({ from: "10:00", to: "10:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
const bare = new Limits({ clock: moment });
bare.applyServerSettings({});
assert.equal(bare.inActiveHours(), true);
});
test("both the donor window and the server window must be open", () => {
const noon = at(12);
const open = { from: "00:00", to: "23:59", zone: "UTC" };
const shut = { from: "00:00", to: "00:01", zone: "UTC" };
assert.equal(serverLimits(open, noon, { activeFrom: "09:00", activeTo: "17:00" }).admit(0), null);
assert.equal(serverLimits(shut, noon, { activeFrom: "09:00", activeTo: "17:00" }).admit(0), REFUSALS.OUTSIDE_HOURS);
assert.equal(serverLimits(open, noon, { activeFrom: "13:00", activeTo: "17:00" }).admit(0), REFUSALS.OUTSIDE_HOURS);
});
test("a settings_update replaces the server window", () => {
const limits = serverLimits({ from: "13:00", to: "17:00", zone: "UTC" }, () => new Date(Date.UTC(2026, 0, 15, 12, 0)));
assert.equal(limits.inActiveHours(), false);
limits.applyServerSettings({ active_from: "", active_to: "", timezone: "UTC" });
assert.equal(limits.inActiveHours(), true);
});
const BASE = 1767225600;
const STEP = 40009;
const SAMPLES = 200;
const PARITY = [
{ from: "09:00", to: "17:00", zone: "Europe/Amsterdam", bits: "01010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000001010101010000" },
{ from: "22:00", to: "06:00", zone: "America/New_York", bits: "00010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000000101010101000010101010100000010101010000001010101" },
{ from: "08:30", to: "12:15", zone: "Asia/Kolkata", bits: "00000001010000000000101000000000010101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101010000000000101" },
{ from: "00:00", to: "12:00", zone: "Pacific/Auckland", bits: "01101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101011010101010101101010101010100101010101010010101010" },
{ from: "10:00", to: "10:00", zone: "UTC", bits: "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" },
{ from: "09:00", to: "17:00", zone: "Not/AZone", bits: "01010000101010101000000101010100000010101010000001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010" },
{ from: "23:00", to: "01:00", zone: "Australia/Lord_Howe", bits: "00000000000010000000000001010000000000001000000000000100000000000010000000000001010000000000001000000000000100000000000010000000000000010000000000001000000000000100000000000010000000000000010000000000" },
{ from: "", to: "", zone: "Europe/Amsterdam", bits: "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" },
];
test("server window parity with the Python scheduler over 200 sampled instants", () => {
for (const item of PARITY) {
assert.equal(item.bits.length, SAMPLES);
for (let index = 0; index < SAMPLES; index += 1) {
const moment = new Date((BASE + index * STEP) * 1000);
const limits = serverLimits({ from: item.from, to: item.to, zone: item.zone }, () => moment);
assert.equal(limits.inActiveHours(), item.bits[index] === "1", `${item.from}-${item.to} ${item.zone} #${index}`);
}
}
});
+29
View File
@@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const root = (path) => fileURLToPath(new URL(`../${path}`, import.meta.url));
const manifest = JSON.parse(readFileSync(root("manifest.json"), "utf8"));
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
test("the manifest declares icons and the action icon, and every file is a PNG of its declared size", () => {
assert.deepEqual(Object.keys(manifest.icons), ["16", "32", "48", "128"]);
for (const [size, path] of Object.entries(manifest.icons)) {
assert.ok(existsSync(root(path)), path);
const data = readFileSync(root(path));
assert.equal(data.subarray(0, 8).equals(PNG_SIGNATURE), true, path);
assert.equal(data.subarray(12, 16).toString(), "IHDR", path);
assert.equal(data.readUInt32BE(16), Number(size), `${path} width`);
assert.equal(data.readUInt32BE(20), Number(size), `${path} height`);
}
for (const [size, path] of Object.entries(manifest.action.default_icon)) {
assert.equal(manifest.icons[size], path);
}
});
test("build.sh ships the icons", () => {
const script = readFileSync(root("build.sh"), "utf8");
assert.match(script, /manifest\.json src ui icons README\.md/);
assert.match(script, /\("src", "ui", "icons"\)/);
});
+20
View File
@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { describeState, displayLabel } from "../ui/popup.js";
test("a suspended state shows the end time in DD/MM/YYYY and says it retries", () => {
const text = describeState({ state: "suspended", suspendedUntil: "2026-03-05T10:07:00Z", detail: "x" });
assert.match(text, /^Suspended until \d{2}\/\d{2}\/2026 \d{2}:\d{2}, retrying automatically$/);
assert.equal(describeState({ state: "suspended", suspendedUntil: "" }), "Suspended, retrying automatically");
});
test("other states show detail then last error", () => {
assert.equal(describeState({ state: "online", detail: "ok", lastError: "e" }), "ok");
assert.equal(describeState({ state: "backoff", detail: "", lastError: "e" }), "e");
});
test("the server label is the authority and the local label is the fallback", () => {
assert.equal(displayLabel({ deviceLabel: "Server name" }, { label: "typed" }), "Server name");
assert.equal(displayLabel({ deviceLabel: "" }, { label: "typed" }), "typed");
assert.equal(displayLabel({}, { label: "typed" }), "typed");
});
+109
View File
@@ -78,6 +78,8 @@ class FakeTimers {
}
}
const NOW = Date.parse("2026-01-01T00:00:00Z");
function build(overrides = {}) {
FakeSocket.instances = [];
const timers = new FakeTimers();
@@ -104,6 +106,7 @@ function build(overrides = {}) {
WebSocketImpl: FakeSocket,
timers,
random: overrides.random || (() => 0.5),
clock: () => NOW,
clientVersion: "9.9.9",
platform: "firefox",
});
@@ -321,3 +324,109 @@ test("settings_update can only lower the effective limits", async () => {
assert.equal(context.limits.concurrency, 1);
assert.equal(context.limits.bandwidthBytes, 10 * 1024 * 1024);
});
function suspendedTicket(until) {
return async () =>
new Response(JSON.stringify({ error: { status: 403, message: "x", reason: "suspended", until } }), {
status: 403,
});
}
test("a suspended ticket waits until the end instead of halting, then retries", async () => {
const context = build({ random: () => 0, ticketFetch: suspendedTicket("2026-01-01T01:00:00Z") });
await context.client.start();
const snapshot = context.state.snapshot();
assert.equal(snapshot.state, STATES.SUSPENDED);
assert.equal(snapshot.suspendedUntil, "2026-01-01T01:00:00.000Z");
assert.equal(context.timers.timeouts.at(-1).ms, 3600 * 1000);
assert.equal(context.client.wanted, true);
await context.client.ensure();
assert.equal(FakeSocket.instances.length, 0);
});
test("suspension waits are clamped and an unusable until waits five minutes", async () => {
const cases = [
["2025-12-31T00:00:00Z", 60],
["2030-01-01T00:00:00Z", 24 * 3600],
["", 300],
["tomorrow", 300],
];
for (const [until, seconds] of cases) {
const context = build({ random: () => 0, ticketFetch: suspendedTicket(until) });
await context.client.start();
assert.equal(context.timers.timeouts.at(-1).ms, seconds * 1000, until);
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
}
const jittered = build({ random: () => 1, ticketFetch: suspendedTicket("") });
await jittered.client.start();
assert.equal(jittered.timers.timeouts.at(-1).ms, 330 * 1000);
});
test("the retry after a suspension asks for a new ticket", async () => {
let calls = 0;
const context = build({
random: () => 0,
ticketFetch: async () => {
calls += 1;
if (calls === 1) {
return suspendedTicket("2026-01-01T00:02:00Z")();
}
return new Response(JSON.stringify({ ticket: "tk2", ws_path: "/nodes/ws" }), { status: 200 });
},
});
await context.client.start();
context.timers.timeouts.at(-1).fn();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(calls, 2);
assert.equal(FakeSocket.instances.length, 1);
});
test("a revoked ticket or other 403 reason still halts", async () => {
const context = build({
ticketFetch: async () =>
new Response(JSON.stringify({ error: { status: 403, message: "x", reason: "revoked" } }), { status: 403 }),
});
await context.client.start();
assert.equal(context.state.snapshot().state, STATES.HALTED);
});
test("a suspended kick with until closes the socket and holds until then", async () => {
const context = build({ random: () => 0 });
const socket = await online(context);
await socket.receive({ type: "kick", reason: "suspended", until: "2026-01-01T00:10:00Z" });
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
assert.equal(context.timers.timeouts.at(-1).ms, 600 * 1000);
assert.equal(socket.closed.code, 1000);
assert.equal(context.timers.intervals[0].cleared, true);
});
test("a suspended kick without until waits five minutes", async () => {
const context = build({ random: () => 0 });
const socket = await online(context);
await socket.receive({ type: "kick", reason: "suspended" });
assert.equal(context.timers.timeouts.at(-1).ms, 300 * 1000);
});
test("a close 4403 with reason suspended holds, any other 4403 halts", async () => {
const context = build({ random: () => 0 });
const socket = await online(context);
await socket.serverClose(4403, "suspended");
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
});
test("welcome stores the server device label and clears an old suspension", async () => {
const context = build();
await context.state.set({ suspendedUntil: "2026-01-01T00:00:00.000Z" });
await context.client.start();
const socket = FakeSocket.instances[0];
socket.open();
await socket.receive(welcome({ device_label: "Work laptop" }));
assert.equal(context.state.snapshot().deviceLabel, "Work laptop");
assert.equal(context.state.snapshot().suspendedUntil, "");
});
test("a welcome without a label leaves the label empty so the popup falls back", async () => {
const context = build();
await online(context);
assert.equal(context.state.snapshot().deviceLabel, "");
});