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
+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, "");
});