@kylecheng3146/agent-ops 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/packages/cli/src/args.js +16 -0
- package/dist/packages/cli/src/bin.js +40 -8
- package/dist/packages/cli/src/cli.js +2 -0
- package/dist/packages/cli/src/commands/batch.js +3 -1
- package/dist/packages/cli/src/commands/update.js +18 -0
- package/dist/packages/cli/src/commands/verify.js +3 -1
- package/dist/packages/cli/src/hook-process.js +48 -0
- package/dist/packages/cli/src/office-entry.js +34 -6
- package/dist/packages/cli/src/run-deps.js +8 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -0
- package/dist/runtime/src/install/update.js +10 -2
- package/dist/runtime/src/office/activity.js +105 -0
- package/dist/runtime/src/office/art.js +999 -0
- package/dist/runtime/src/office/collect.js +137 -10
- package/dist/runtime/src/office/layout.js +200 -0
- package/dist/runtime/src/office/page.js +760 -645
- package/dist/runtime/src/office/scene.js +27 -31
- package/dist/runtime/src/office/sessions.js +18 -1
- package/dist/runtime/src/office/snapshot.js +62 -1
- package/dist/runtime/src/parallel/finish.js +4 -2
- package/dist/runtime/src/review/batch.js +10 -0
- package/dist/runtime/src/review/execute.js +9 -0
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/docs/en/guides/configuration.md +7 -0
- package/docs/en/spec/harness-adapters.md +16 -2
- package/docs/zh-TW/guides/configuration.md +6 -0
- package/docs/zh-TW/spec/harness-adapters.md +12 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { readWorktreeRecord, parseWorktreeListPorcelain } from "../parallel/service.js";
|
|
@@ -10,9 +10,11 @@ import { parseTaskStateSource } from "../task/store.js";
|
|
|
10
10
|
import { findReviewAttestation } from "../review/attestation.js";
|
|
11
11
|
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
12
12
|
import { readPrivateFile } from "../security/permissions.js";
|
|
13
|
+
import { redactSecrets } from "../security/redact.js";
|
|
13
14
|
import { OFFICE_SESSION_RETENTION_MS, readOfficeSessions } from "./sessions.js";
|
|
14
15
|
import { buildOfficeSnapshot, mergeOfficeSnapshots } from "./snapshot.js";
|
|
15
16
|
import { forgetOfficeRepos, readOfficeRepos } from "./repos.js";
|
|
17
|
+
import { readActivities } from "./activity.js";
|
|
16
18
|
const run = promisify(execFile);
|
|
17
19
|
export const officeGit = async (cwd, args) => {
|
|
18
20
|
try {
|
|
@@ -23,6 +25,119 @@ export const officeGit = async (cwd, args) => {
|
|
|
23
25
|
}
|
|
24
26
|
};
|
|
25
27
|
const MAX_PATHS = 200;
|
|
28
|
+
// The office reads two kinds of proof files, never anything a task record merely points at:
|
|
29
|
+
// acceptance execution artifacts (by an exact path shape) and review report artifacts (by an exact name).
|
|
30
|
+
const ACCEPTANCE_ARTIFACT = /^\.agent-ops\/tasks\/acceptance\/[a-f0-9]{64}\.json$/u;
|
|
31
|
+
const TASK_ID = /^[A-Za-z0-9._-]{1,128}$/u;
|
|
32
|
+
const REVIEW_FILE_BYTES = 512 * 1024, ACCEPTANCE_FILE_BYTES = 4 * 1024 * 1024, REVIEW_FILES = 20, FINDINGS = 10;
|
|
33
|
+
const OUTPUT_BYTES = 2048, SUMMARY_BYTES = 1024, TITLE_BYTES = 300, DETAILS_BYTES = 1024, ADVICE_BYTES = 512;
|
|
34
|
+
/** At most `limit` bytes of UTF-8 text from the start, cut on a character boundary; the ellipsis counts. */
|
|
35
|
+
function headBytes(value, limit) {
|
|
36
|
+
if (Buffer.byteLength(value, "utf8") <= limit)
|
|
37
|
+
return value;
|
|
38
|
+
let out = "", used = 0;
|
|
39
|
+
for (const character of value) {
|
|
40
|
+
const size = Buffer.byteLength(character, "utf8");
|
|
41
|
+
if (used + size > limit - 3)
|
|
42
|
+
break;
|
|
43
|
+
out += character;
|
|
44
|
+
used += size;
|
|
45
|
+
}
|
|
46
|
+
return out + "…";
|
|
47
|
+
}
|
|
48
|
+
/** At most `limit` bytes of UTF-8 text from the end, cut on a character boundary; the ellipsis counts. */
|
|
49
|
+
function tailBytes(value, limit) {
|
|
50
|
+
if (Buffer.byteLength(value, "utf8") <= limit)
|
|
51
|
+
return value;
|
|
52
|
+
const characters = [...value];
|
|
53
|
+
let used = 0, start = characters.length;
|
|
54
|
+
while (start > 0) {
|
|
55
|
+
const size = Buffer.byteLength(characters[start - 1], "utf8");
|
|
56
|
+
if (used + size > limit - 3)
|
|
57
|
+
break;
|
|
58
|
+
used += size;
|
|
59
|
+
start--;
|
|
60
|
+
}
|
|
61
|
+
return "…" + characters.slice(start).join("");
|
|
62
|
+
}
|
|
63
|
+
/** Redact first, then cut, so a cut can never expose part of a secret the full text would hide. */
|
|
64
|
+
const clean = (value, limit) => typeof value === "string" ? headBytes(redactSecrets(value), limit) : "";
|
|
65
|
+
/** A private proof file read without following links, or null when absent, oversized or unreadable. */
|
|
66
|
+
async function proofFile(root, relative, limit) {
|
|
67
|
+
const source = await readPrivateFile(join(root, ...relative.split("/")), root).catch(() => null);
|
|
68
|
+
if (source === null || Buffer.byteLength(source, "utf8") > limit)
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(source);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function failureOutput(root, evidence) {
|
|
78
|
+
const artifact = evidence?.acceptance?.executionArtifact;
|
|
79
|
+
if (artifact === undefined || !ACCEPTANCE_ARTIFACT.test(artifact))
|
|
80
|
+
return null;
|
|
81
|
+
const source = await readPrivateFile(join(root, ...artifact.split("/")), root).catch(() => null);
|
|
82
|
+
// An execution artifact is named by its own content hash; a swapped or edited file no longer matches.
|
|
83
|
+
if (source === null || Buffer.byteLength(source, "utf8") > ACCEPTANCE_FILE_BYTES ||
|
|
84
|
+
artifact !== ".agent-ops/tasks/acceptance/" + createHash("sha256").update(source).digest("hex") + ".json")
|
|
85
|
+
return null;
|
|
86
|
+
let value;
|
|
87
|
+
try {
|
|
88
|
+
value = JSON.parse(source);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const output = typeof value === "object" && value !== null ? value.output : null;
|
|
94
|
+
if (typeof output !== "object" || output === null)
|
|
95
|
+
return null;
|
|
96
|
+
const { stdout, stderr } = output;
|
|
97
|
+
const text = [stdout, stderr].filter((part) => typeof part === "string" && part.length > 0).join("\n");
|
|
98
|
+
return text.length === 0 ? null : tailBytes(redactSecrets(text), OUTPUT_BYTES);
|
|
99
|
+
}
|
|
100
|
+
function reviewRound(target, report) {
|
|
101
|
+
if (typeof report !== "object" || report === null)
|
|
102
|
+
return null;
|
|
103
|
+
const value = report;
|
|
104
|
+
const findings = (Array.isArray(value.findings) ? value.findings : []).slice(0, FINDINGS).map(item => {
|
|
105
|
+
const finding = (typeof item === "object" && item !== null ? item : {});
|
|
106
|
+
return { severity: clean(finding.severity, 32), blocking: finding.blocking === true, title: clean(finding.title, TITLE_BYTES),
|
|
107
|
+
details: clean(finding.details, DETAILS_BYTES), recommendation: clean(finding.recommendation, ADVICE_BYTES) };
|
|
108
|
+
});
|
|
109
|
+
return { target: clean(target, 32) || "unknown", summary: clean(value.summary, SUMMARY_BYTES), findings };
|
|
110
|
+
}
|
|
111
|
+
/** The newest review report artifact for a task, or null. */
|
|
112
|
+
async function latestReview(root, taskId) {
|
|
113
|
+
if (!TASK_ID.test(taskId))
|
|
114
|
+
return null;
|
|
115
|
+
const directory = join(root, ".agent-ops", "reviews"), suffix = "." + taskId + ".reports.json";
|
|
116
|
+
const names = (await readdir(directory).catch(() => []))
|
|
117
|
+
.filter(name => name.endsWith(suffix) && /^[a-f0-9]{64}$/u.test(name.slice(0, -suffix.length)));
|
|
118
|
+
const dated = await Promise.all(names.map(async (name) => {
|
|
119
|
+
const info = await lstat(join(directory, name)).catch(() => null);
|
|
120
|
+
return info === null || !info.isFile() ? null : { name, at: info.mtimeMs };
|
|
121
|
+
}));
|
|
122
|
+
let best = null;
|
|
123
|
+
for (const entry of dated.filter((item) => item !== null).sort((a, b) => b.at - a.at).slice(0, REVIEW_FILES)) {
|
|
124
|
+
const value = await proofFile(root, ".agent-ops/reviews/" + entry.name, REVIEW_FILE_BYTES);
|
|
125
|
+
if (typeof value !== "object" || value === null)
|
|
126
|
+
continue;
|
|
127
|
+
const report = value;
|
|
128
|
+
const status = report.status === "PASS" || report.status === "FAIL" || report.status === "NOT_RUN" ? report.status : null;
|
|
129
|
+
if (status === null || typeof report.createdAt !== "string" || Number.isNaN(Date.parse(report.createdAt)))
|
|
130
|
+
continue;
|
|
131
|
+
if (best !== null && Date.parse(best.createdAt) >= Date.parse(report.createdAt))
|
|
132
|
+
continue;
|
|
133
|
+
const adversarial = typeof report.adversarial === "object" && report.adversarial !== null ? report.adversarial : null;
|
|
134
|
+
const rounds = [reviewRound(report.harness, report.report), adversarial === null ? null : reviewRound(adversarial.target, adversarial.report)]
|
|
135
|
+
.filter((round) => round !== null);
|
|
136
|
+
best = { status, createdAt: report.createdAt, reason: typeof report.reason === "string" ? clean(report.reason, SUMMARY_BYTES) : null, rounds,
|
|
137
|
+
refuted: adversarial === null ? null : adversarial.refuted === true };
|
|
138
|
+
}
|
|
139
|
+
return best;
|
|
140
|
+
}
|
|
26
141
|
const OFFICE_PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
|
|
27
142
|
function officePhase(value) {
|
|
28
143
|
return value === undefined ? undefined : OFFICE_PHASES.has(value) ? value : "unknown";
|
|
@@ -63,13 +178,21 @@ async function taskView(root, record) {
|
|
|
63
178
|
}
|
|
64
179
|
const evidence = [...latest.values()].map(item => item.evidence);
|
|
65
180
|
let passed = 0;
|
|
181
|
+
const criteria = [];
|
|
66
182
|
for (const criterion of record.task.criteria) {
|
|
67
183
|
const rows = evidence.filter(item => item.criterionId === criterion.id);
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
acceptance: rows.flatMap(item => item.acceptance?.checks.map(check => ({ criterionId: criterion.id, status: check.status })) ?? [])
|
|
71
|
-
});
|
|
184
|
+
const checks = rows.flatMap(item => item.acceptance?.checks.map(check => ({ criterionId: criterion.id, status: check.status })) ?? []);
|
|
185
|
+
const result = criteriaProgress([criterion], { results: rows.map(item => ({ status: item.status })), acceptance: checks });
|
|
72
186
|
passed += result.passed;
|
|
187
|
+
// The row is PASS exactly when it counts toward `passed`, so the list and the n/m summary agree.
|
|
188
|
+
const seen = [...rows.map(item => item.status), ...checks.map(check => check.status)];
|
|
189
|
+
const status = result.passed === 1 ? "PASS" : seen.includes("FAIL") ? "FAIL" : seen.includes("UNKNOWN") ? "UNKNOWN" : null;
|
|
190
|
+
const last = rows.reduce((current, item) => current === null || Date.parse(item.finishedAt) >= Date.parse(current.finishedAt) ? item : current, null);
|
|
191
|
+
const failing = status === "FAIL" || status === "UNKNOWN" ? rows.filter(row => row.acceptance?.executionArtifact !== undefined && row.status !== "PASS")
|
|
192
|
+
.reduce((current, item) => current === null || Date.parse(item.finishedAt) >= Date.parse(current.finishedAt) ? item : current, null) : null;
|
|
193
|
+
criteria.push({ id: criterion.id, description: criterion.description, status, finishedAt: last?.finishedAt ?? null,
|
|
194
|
+
failureClass: status === "PASS" || last === null ? null : last.failureClass, exitCode: status === "PASS" ? null : last?.exitCode ?? null,
|
|
195
|
+
output: await failureOutput(root, failing) });
|
|
73
196
|
}
|
|
74
197
|
const total = record.task.criteria.length;
|
|
75
198
|
const verify = evidence.some(item => item.status === "FAIL" || item.status === "UNKNOWN")
|
|
@@ -90,7 +213,8 @@ async function taskView(root, record) {
|
|
|
90
213
|
: evidence.length > 0
|
|
91
214
|
? "implementing"
|
|
92
215
|
: "planning";
|
|
93
|
-
return { taskId: record.task.id, status: record.status, phase, progress,
|
|
216
|
+
return { taskId: record.task.id, status: record.status, phase, progress, title: record.task.title, criteria,
|
|
217
|
+
review: await latestReview(root, record.task.id), completedAt: record.completedAt };
|
|
94
218
|
}
|
|
95
219
|
async function taskState(root) {
|
|
96
220
|
const source = await readPrivateFile(join(root, ".agent-ops", "tasks", "state.json"), root).catch(() => null);
|
|
@@ -186,7 +310,7 @@ async function readSessions(mainRoot, commonDir, now, trustedRoots = [mainRoot])
|
|
|
186
310
|
: observedPhase ?? task.phase;
|
|
187
311
|
return task === null
|
|
188
312
|
? session
|
|
189
|
-
: { ...session, taskId: task.taskId, phase, taskStatus: task.status, progress: task.progress,
|
|
313
|
+
: { ...session, taskId: task.taskId, phase, taskStatus: task.status, progress: task.progress, title: task.title, criteria: task.criteria, review: task.review,
|
|
190
314
|
completedAt: task.completedAt, status: task.status === "complete" ? "idle" : session.status };
|
|
191
315
|
}));
|
|
192
316
|
return views.filter(session => session.completedAt === undefined || session.completedAt === null ||
|
|
@@ -240,8 +364,10 @@ export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, n
|
|
|
240
364
|
for (const { path, record } of discovered) {
|
|
241
365
|
const session = sessions.find(item => item.sessionId === record.sessionId);
|
|
242
366
|
const task = await attachedTaskView(path, record.sessionId, session?.taskId);
|
|
367
|
+
const ahead = /^[0-9a-f]{40,64}$/u.test(record.base) ? Number.parseInt((await git(path, ["rev-list", "--count", record.base + "..HEAD"])).stdout.trim(), 10) : Number.NaN;
|
|
243
368
|
const base = { name: record.name, path, branch: record.branch, sessionId: record.sessionId,
|
|
244
|
-
...(record.runId === undefined ? {} : { runId: record.runId }), diff: await worktreeDiff(git, path, record.base)
|
|
369
|
+
...(record.runId === undefined ? {} : { runId: record.runId }), diff: await worktreeDiff(git, path, record.base),
|
|
370
|
+
...(/^[0-9a-f]{40,64}$/u.test(record.base) ? { base: record.base.slice(0, 12) } : {}), ...(Number.isInteger(ahead) && ahead >= 0 ? { ahead } : {}) };
|
|
245
371
|
const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
|
|
246
372
|
const phase = task === null
|
|
247
373
|
? observedPhase
|
|
@@ -251,10 +377,11 @@ export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, n
|
|
|
251
377
|
worktrees.push(task === null
|
|
252
378
|
? session === undefined ? base : { ...base, ...(officePhase(session.phase) === undefined ? {} : { phase: officePhase(session.phase) }), status: session.status,
|
|
253
379
|
...(session.taskId === undefined ? {} : { taskId: session.taskId }), host: session.host ?? session.harness }
|
|
254
|
-
: { ...base, phase, status: task.status, taskId: task.taskId, progress: task.progress,
|
|
380
|
+
: { ...base, phase, status: task.status, taskId: task.taskId, progress: task.progress, title: task.title, criteria: task.criteria, review: task.review,
|
|
255
381
|
...(task.completedAt === null ? {} : { completedAt: task.completedAt }), host: session?.host ?? session?.harness });
|
|
256
382
|
}
|
|
257
|
-
return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir),
|
|
383
|
+
return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir),
|
|
384
|
+
activities: await readActivities(commonDir).catch(() => []), now };
|
|
258
385
|
}
|
|
259
386
|
/**
|
|
260
387
|
* Every registered repository that still exists and still opts in, as one
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { roomSprites } from "./art.js";
|
|
2
|
+
export const ROOM_ORDER = ["planning", "implementing", "verifying", "reviewing", "integrating", "lobby"];
|
|
3
|
+
const W = 576, H = 320;
|
|
4
|
+
const WALL = 40, ROW1 = { top: 40, bottom: 152 }, MIDDLE = { top: 152, bottom: 192 }, ROW2 = { top: 192, bottom: 312 };
|
|
5
|
+
const VERTICAL = [188, 380], THICK = 8, COLUMN = [4, 196, 388], ROOM_W = 184;
|
|
6
|
+
/** Bounding box of the opaque pixels in the bottom `rows` rows of a sprite. */
|
|
7
|
+
function baseOf(rows, depth = 10) {
|
|
8
|
+
let left = Infinity, right = -1, top = Infinity;
|
|
9
|
+
for (let y = Math.max(0, rows.length - depth); y < rows.length; y++)
|
|
10
|
+
for (let x = 0; x < rows[y].length; x++) {
|
|
11
|
+
if (rows[y][x] === ".")
|
|
12
|
+
continue;
|
|
13
|
+
left = Math.min(left, x);
|
|
14
|
+
right = Math.max(right, x);
|
|
15
|
+
top = Math.min(top, y);
|
|
16
|
+
}
|
|
17
|
+
return right < 0 ? { x: 0, y: rows.length, w: 0, h: 0 } : { x: left, y: top, w: right - left + 1, h: rows.length - top };
|
|
18
|
+
}
|
|
19
|
+
export function officeLayout() {
|
|
20
|
+
const sprites = roomSprites();
|
|
21
|
+
const room = (id, row, column, floor, slots) => {
|
|
22
|
+
const band = row === 1 ? ROW1 : ROW2;
|
|
23
|
+
return { id, x: COLUMN[column], y: band.top, w: ROOM_W, h: band.bottom - band.top, floor, slots };
|
|
24
|
+
};
|
|
25
|
+
// Feet sit on the path-finding grid so a walk ends exactly on them.
|
|
26
|
+
const onGrid = (p) => ({ x: Math.round(p.x / STEP) * STEP, y: Math.round(p.y / STEP) * STEP });
|
|
27
|
+
const stand = (x, y, pose = "down0") => ({ x, y, pose, z: y + 50, feet: onGrid({ x: x + 17, y: y + 49 }) });
|
|
28
|
+
const seat = (x, y, pose, z, feet) => ({ x, y, pose, z, feet: onGrid(feet) });
|
|
29
|
+
const rooms = [
|
|
30
|
+
room("planning", 1, 0, "carpet", [seat(9, 28, "down0", 69, { x: 30, y: 140 }), seat(43, 28, "down0", 69, { x: 64, y: 140 }), seat(77, 28, "down0", 69, { x: 98, y: 140 })]),
|
|
31
|
+
room("implementing", 1, 1, "carpet", [seat(228, 52, "up0", 99, { x: 245, y: 120 }), seat(318, 52, "up0", 99, { x: 345, y: 120 }), stand(240, 96)]),
|
|
32
|
+
room("verifying", 1, 2, "vinyl", [seat(480, 64, "up0", 113, { x: 497, y: 112 }), stand(406, 96), stand(452, 96)]),
|
|
33
|
+
room("reviewing", 2, 2, "carpet", [seat(516, 204, "down0", 223, { x: 520, y: 270 }), seat(428, 246, "down0", 271, { x: 446, y: 306 }), seat(502, 246, "down0", 271, { x: 520, y: 308 })]),
|
|
34
|
+
room("integrating", 2, 1, "vinyl", [seat(306, 206, "up0", 256, { x: 323, y: 254 }), stand(208, 254), stand(328, 254)]),
|
|
35
|
+
room("lobby", 2, 0, "checker", [seat(22, 202, "up0", 252, { x: 39, y: 252 }), stand(138, 210), stand(56, 240)])
|
|
36
|
+
];
|
|
37
|
+
const items = [];
|
|
38
|
+
const put = (sprite, x, y, inRoom, extra = {}) => {
|
|
39
|
+
const rows = sprites[extra.variant === undefined ? sprite : sprite + "Pass"];
|
|
40
|
+
const footprint = (extra.foot ?? [baseOf(rows)]).map(r => ({ x: x + r.x, y: y + r.y, w: r.w, h: r.h })).filter(r => r.w > 0 && r.h > 0);
|
|
41
|
+
items.push({ sprite, x, y, z: extra.z ?? y + rows.length, room: inRoom, ...(extra.variant === undefined ? {} : { variant: extra.variant }), footprint });
|
|
42
|
+
};
|
|
43
|
+
// Cubicles are solid except the open front where the chair goes.
|
|
44
|
+
const cubicleFoot = [{ x: 0, y: 0, w: 86, h: 46 }, { x: 0, y: 46, w: 6, h: 12 }, { x: 80, y: 46, w: 6, h: 12 }];
|
|
45
|
+
// planning: meeting table against the wall, people seated behind it
|
|
46
|
+
[16, 50, 84].forEach(x => put("chairFront", x, 44, "planning"));
|
|
47
|
+
put("confTable", 6, 68, "planning");
|
|
48
|
+
put("board", 118, 42, "planning");
|
|
49
|
+
[22, 56, 90].forEach(x => put("chairBack", x, 100, "planning", { z: 128 }));
|
|
50
|
+
put("deskPlant", 158, 128, "planning");
|
|
51
|
+
// implementing: two cubicles on the wall; the floor in front stays open so both doors connect
|
|
52
|
+
put("cubicle", 198, 40, "implementing", { foot: cubicleFoot });
|
|
53
|
+
put("cubicleMirror", 288, 40, "implementing", { foot: cubicleFoot });
|
|
54
|
+
put("chairBack", 232, 70, "implementing", { z: 100 });
|
|
55
|
+
put("chairBack", 322, 70, "implementing", { z: 100 });
|
|
56
|
+
put("trash", 200, 136, "implementing");
|
|
57
|
+
put("plant", 300, 100, "implementing");
|
|
58
|
+
// verifying: racks, a test bench and filing along the wall
|
|
59
|
+
put("rackPass", 388, 46, "verifying");
|
|
60
|
+
put("rack", 416, 46, "verifying", { variant: "verify" });
|
|
61
|
+
put("bench", 460, 54, "verifying", { variant: "verify" });
|
|
62
|
+
put("filing", 548, 54, "verifying");
|
|
63
|
+
put("trash", 392, 136, "verifying");
|
|
64
|
+
put("stool", 504, 104, "verifying");
|
|
65
|
+
put("plant", 536, 100, "verifying");
|
|
66
|
+
// reviewing: bookcase and filing on the wall, the reviewer's desk, two armchairs
|
|
67
|
+
put("lowBookcase", 392, 180, "reviewing");
|
|
68
|
+
put("filing", 444, 178, "reviewing");
|
|
69
|
+
put("execDesk", 500, 222, "reviewing");
|
|
70
|
+
put("armchair", 432, 270, "reviewing");
|
|
71
|
+
put("coffeeTable", 462, 286, "reviewing");
|
|
72
|
+
put("armchair", 506, 270, "reviewing");
|
|
73
|
+
put("plant", 536, 258, "reviewing");
|
|
74
|
+
// integrating: shelving, copier, sorting table, a hand truck and stacked crates
|
|
75
|
+
put("metalShelf", 200, 178, "integrating");
|
|
76
|
+
put("copier", 256, 194, "integrating");
|
|
77
|
+
put("sortTable", 296, 202, "integrating");
|
|
78
|
+
put("handTruck", 356, 204, "integrating");
|
|
79
|
+
put("crates", 252, 258, "integrating");
|
|
80
|
+
put("trash", 236, 294, "integrating");
|
|
81
|
+
// lobby: kitchenette on the wall, sofa, cafe table, plant by the entrance
|
|
82
|
+
put("counter", 8, 184, "lobby");
|
|
83
|
+
put("fridge", 92, 176, "lobby");
|
|
84
|
+
put("vending", 116, 176, "lobby");
|
|
85
|
+
put("cooler", 146, 188, "lobby");
|
|
86
|
+
put("sofa", 84, 230, "lobby");
|
|
87
|
+
put("cafeTable", 120, 270, "lobby");
|
|
88
|
+
put("plant", 8, 258, "lobby");
|
|
89
|
+
put("trash", 92, 296, "lobby");
|
|
90
|
+
const decor = [
|
|
91
|
+
{ sprite: "tv", x: 30, y: 3, wall: "back" }, { sprite: "frame", x: 112, y: 9, wall: "back" }, { sprite: "frame", x: 132, y: 13, wall: "back" },
|
|
92
|
+
{ sprite: "blinds", x: 210, y: 4, wall: "back" }, { sprite: "clock", x: 281, y: 9, wall: "back" }, { sprite: "blinds", x: 320, y: 4, wall: "back" },
|
|
93
|
+
{ sprite: "status", x: 400, y: 3, wall: "back", variant: "verify" }, { sprite: "poster", x: 520, y: 3, wall: "back" },
|
|
94
|
+
{ sprite: "diploma", x: 404, y: 160, wall: "middle" }, { sprite: "diploma", x: 424, y: 163, wall: "middle" },
|
|
95
|
+
{ sprite: "cubby", x: 258, y: 158, wall: "middle" }, { sprite: "clock", x: 60, y: 160, wall: "middle" }, { sprite: "frame", x: 152, y: 162, wall: "middle" }
|
|
96
|
+
];
|
|
97
|
+
const side = (id, x, y, between) => ({ id, kind: "side", x, y, between, gap: { x, y: y + 3, w: THICK, h: 28 } });
|
|
98
|
+
const doors = [
|
|
99
|
+
side("planning-implementing", VERTICAL[0], 96, ["planning", "implementing"]),
|
|
100
|
+
side("implementing-verifying", VERTICAL[1], 96, ["implementing", "verifying"]),
|
|
101
|
+
{ id: "verifying-reviewing", kind: "front", x: 468, y: MIDDLE.top + 2, between: ["verifying", "reviewing"], gap: { x: 470, y: MIDDLE.top, w: 26, h: MIDDLE.bottom - MIDDLE.top } },
|
|
102
|
+
side("reviewing-integrating", VERTICAL[1], 258, ["reviewing", "integrating"]),
|
|
103
|
+
side("integrating-lobby", VERTICAL[0], 258, ["integrating", "lobby"])
|
|
104
|
+
];
|
|
105
|
+
const entrance = { x: 56, y: ROW2.bottom, w: 34, h: H - ROW2.bottom };
|
|
106
|
+
const area = (sprite, x, y) => ({ x, y, w: sprites[sprite][0].length, h: sprites[sprite].length });
|
|
107
|
+
return {
|
|
108
|
+
width: W, height: H,
|
|
109
|
+
faces: { back: { x: 0, y: 3, w: W, h: 31 }, middle: { x: 0, y: MIDDLE.top + 3, w: W, h: 31 } },
|
|
110
|
+
walls: { back: { x: 0, y: 0, w: W, h: WALL }, middle: { x: 0, y: MIDDLE.top, w: W, h: MIDDLE.bottom - MIDDLE.top },
|
|
111
|
+
vertical: VERTICAL.flatMap(x => [{ x, y: ROW1.top, w: THICK, h: ROW1.bottom - ROW1.top }, { x, y: ROW2.top, w: THICK, h: ROW2.bottom - ROW2.top }]),
|
|
112
|
+
sides: [{ x: 0, y: WALL, w: 4, h: H - WALL }, { x: W - 4, y: WALL, w: 4, h: H - WALL }], bottom: { x: 0, y: ROW2.bottom, w: W, h: H - ROW2.bottom } },
|
|
113
|
+
rooms, doors, entrance, spawn: { x: entrance.x + 17, y: ROW2.bottom - 2 },
|
|
114
|
+
items, decor,
|
|
115
|
+
rugs: [{ x: 6, y: 50, w: 120, h: 46, fill: "b", border: "c" }, { x: 426, y: 264, w: 100, h: 40, fill: "f", border: "g" }],
|
|
116
|
+
board: { x: 118, y: 42 },
|
|
117
|
+
staff: {
|
|
118
|
+
stations: { qa: rooms[2].slots[0], desk: rooms[3].slots[0], armchair: rooms[3].slots[1], integrator: rooms[4].slots[0] },
|
|
119
|
+
// Two sit on the sofa, in front of its back; two stand chatting at the cafe table.
|
|
120
|
+
rest: [seat(83, 212, "sit", 262, { x: 100, y: 268 }), seat(101, 212, "sit", 262, { x: 118, y: 268 }),
|
|
121
|
+
seat(88, 241, "right0", 291, { x: 106, y: 290 }), seat(148, 241, "left0", 291, { x: 166, y: 290 })]
|
|
122
|
+
},
|
|
123
|
+
hotspots: [
|
|
124
|
+
{ kind: "task", room: "planning", rects: [area("board", 118, 42)] },
|
|
125
|
+
// The monitor sits at (30, 1) inside a cubicle, one pixel further in once outlined.
|
|
126
|
+
{ kind: "diff", room: "implementing", rects: [{ x: 198 + 31, y: 40 + 2, w: 26, h: 22 }, { x: 288 + 31, y: 40 + 2, w: 26, h: 22 }] },
|
|
127
|
+
{ kind: "verify", room: "verifying", rects: [area("statusPass", 400, 3), area("benchPass", 460, 54)] },
|
|
128
|
+
{ kind: "review", room: "reviewing", rects: [area("execDesk", 500, 222)] },
|
|
129
|
+
{ kind: "integration", room: "integrating", rects: [area("sortTable", 296, 202)] }
|
|
130
|
+
],
|
|
131
|
+
open: [...rooms.map(({ x, y, w, h }) => ({ x, y, w, h })), ...doors.map(door => door.gap), entrance],
|
|
132
|
+
blocked: items.flatMap(item => item.footprint)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Size of one path-finding cell, in art pixels. */
|
|
136
|
+
export const STEP = 2;
|
|
137
|
+
/** A person's feet: a 14x4 box centred on x, ending at y. */
|
|
138
|
+
export const FOOT = { w: 14, h: 4 };
|
|
139
|
+
/**
|
|
140
|
+
* Free cells for a person's feet. Self-contained: the page embeds it with
|
|
141
|
+
* toString, so it may not reference imports or module-level values.
|
|
142
|
+
*/
|
|
143
|
+
export function walkGrid(layout) {
|
|
144
|
+
const step = 2, footW = 14, footH = 4, width = layout.width, height = layout.height;
|
|
145
|
+
const solid = new Array(width * height).fill(1);
|
|
146
|
+
layout.open.forEach(r => { for (let y = r.y; y < r.y + r.h; y++)
|
|
147
|
+
for (let x = r.x; x < r.x + r.w; x++)
|
|
148
|
+
if (x >= 0 && y >= 0 && x < width && y < height)
|
|
149
|
+
solid[y * width + x] = 0; });
|
|
150
|
+
layout.blocked.forEach(r => { for (let y = r.y; y < r.y + r.h; y++)
|
|
151
|
+
for (let x = r.x; x < r.x + r.w; x++)
|
|
152
|
+
if (x >= 0 && y >= 0 && x < width && y < height)
|
|
153
|
+
solid[y * width + x] = 1; });
|
|
154
|
+
// Summed area table: a feet box is free when it covers no solid pixel.
|
|
155
|
+
const sum = new Array((width + 1) * (height + 1)).fill(0);
|
|
156
|
+
for (let y = 0; y < height; y++)
|
|
157
|
+
for (let x = 0; x < width; x++)
|
|
158
|
+
sum[(y + 1) * (width + 1) + x + 1] = solid[y * width + x] + sum[y * (width + 1) + x + 1] + sum[(y + 1) * (width + 1) + x] - sum[y * (width + 1) + x];
|
|
159
|
+
const cols = Math.floor(width / step), rows = Math.floor(height / step), free = new Array(cols * rows).fill(0);
|
|
160
|
+
for (let cy = 0; cy < rows; cy++)
|
|
161
|
+
for (let cx = 0; cx < cols; cx++) {
|
|
162
|
+
const x0 = cx * step - footW / 2, y0 = cy * step - footH, x1 = x0 + footW, y1 = y0 + footH;
|
|
163
|
+
if (x0 < 0 || y0 < 0 || x1 > width || y1 > height)
|
|
164
|
+
continue;
|
|
165
|
+
const blocked = sum[y1 * (width + 1) + x1] - sum[y0 * (width + 1) + x1] - sum[y1 * (width + 1) + x0] + sum[y0 * (width + 1) + x0];
|
|
166
|
+
free[cy * cols + cx] = blocked === 0 ? 1 : 0;
|
|
167
|
+
}
|
|
168
|
+
return { cols, rows, free };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Shortest four-way walk between two feet points, as feet points every cell,
|
|
172
|
+
* or null when unreachable. Self-contained for the page, like walkGrid.
|
|
173
|
+
*/
|
|
174
|
+
export function findPath(grid, from, to) {
|
|
175
|
+
const step = 2, cols = grid.cols, rows = grid.rows;
|
|
176
|
+
const cell = (p) => Math.max(0, Math.min(rows - 1, Math.round(p.y / step))) * cols + Math.max(0, Math.min(cols - 1, Math.round(p.x / step)));
|
|
177
|
+
const start = cell(from), goal = cell(to);
|
|
178
|
+
if (!grid.free[start] || !grid.free[goal])
|
|
179
|
+
return null;
|
|
180
|
+
const previous = new Array(cols * rows).fill(-1), queue = [start];
|
|
181
|
+
previous[start] = start;
|
|
182
|
+
for (let head = 0; head < queue.length && previous[goal] === -1; head++) {
|
|
183
|
+
const current = queue[head], cx = current % cols, cy = Math.floor(current / cols);
|
|
184
|
+
const next = [cx > 0 ? current - 1 : -1, cx < cols - 1 ? current + 1 : -1, cy > 0 ? current - cols : -1, cy < rows - 1 ? current + cols : -1];
|
|
185
|
+
for (const candidate of next)
|
|
186
|
+
if (candidate >= 0 && grid.free[candidate] && previous[candidate] === -1) {
|
|
187
|
+
previous[candidate] = current;
|
|
188
|
+
queue.push(candidate);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (previous[goal] === -1)
|
|
192
|
+
return null;
|
|
193
|
+
const path = [];
|
|
194
|
+
for (let at = goal;; at = previous[at]) {
|
|
195
|
+
path.push({ x: (at % cols) * step, y: Math.floor(at / cols) * step });
|
|
196
|
+
if (at === start)
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
return path.reverse();
|
|
200
|
+
}
|