@walwal-harness/cli 7.1.8 → 7.1.10
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/HR-Resource/ceo/SKILL.md +4 -4
- package/HR-Resource/cqo/SKILL.md +3 -3
- package/HR-Resource/cto/SKILL.md +4 -4
- package/README.md +58 -5
- package/apps/harness-dashboard/app/api/env/route.ts +62 -0
- package/apps/harness-dashboard/app/api/log/route.ts +19 -0
- package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
- package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
- package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
- package/apps/harness-dashboard/app/globals.css +17 -0
- package/apps/harness-dashboard/app/layout.tsx +29 -0
- package/apps/harness-dashboard/app/page.tsx +35 -0
- package/apps/harness-dashboard/components/Drawer.tsx +97 -0
- package/apps/harness-dashboard/components/Header.tsx +40 -0
- package/apps/harness-dashboard/components/MissionTimeline.tsx +214 -0
- package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
- package/apps/harness-dashboard/components/Scene.tsx +563 -0
- package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
- package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
- package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
- package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
- package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
- package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
- package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +520 -0
- package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +160 -0
- package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
- package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
- package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
- package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
- package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
- package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
- package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
- package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
- package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
- package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
- package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
- package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
- package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
- package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
- package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
- package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
- package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
- package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
- package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
- package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
- package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
- package/apps/harness-dashboard/lib/furniture.ts +165 -0
- package/apps/harness-dashboard/lib/harness-root.ts +20 -0
- package/apps/harness-dashboard/lib/harness-state.ts +1193 -0
- package/apps/harness-dashboard/lib/i18n.ts +26 -0
- package/apps/harness-dashboard/lib/iso.ts +150 -0
- package/apps/harness-dashboard/lib/log-filter.ts +23 -0
- package/apps/harness-dashboard/lib/markdown.tsx +306 -0
- package/apps/harness-dashboard/lib/path-planning.ts +72 -0
- package/apps/harness-dashboard/lib/safe-json.ts +24 -0
- package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
- package/apps/harness-dashboard/lib/types.ts +329 -0
- package/apps/harness-dashboard/lib/watcher.ts +64 -0
- package/apps/harness-dashboard/next-env.d.ts +6 -0
- package/apps/harness-dashboard/next.config.mjs +9 -0
- package/apps/harness-dashboard/package-lock.json +4280 -0
- package/apps/harness-dashboard/package.json +38 -0
- package/apps/harness-dashboard/playwright.config.ts +23 -0
- package/apps/harness-dashboard/postcss.config.js +6 -0
- package/apps/harness-dashboard/tailwind.config.ts +49 -0
- package/apps/harness-dashboard/tsconfig.json +24 -0
- package/apps/harness-dashboard/vitest.config.ts +15 -0
- package/assets/templates/AGENTS-ko.md.template +19 -6
- package/assets/templates/AGENTS.md.template +20 -7
- package/assets/templates/HARNESS.md +75 -199
- package/assets/templates/config.json +2 -2
- package/bin/init.js +11 -11
- package/commands/goal.md +6 -4
- package/commands/hot-fix.md +16 -13
- package/commands/submission.md +28 -0
- package/conventions/ceo.md +3 -3
- package/conventions/shared.md +4 -3
- package/package.json +11 -1
- package/scripts/harness-dashboard-up.sh +24 -4
- package/scripts/harness-session-start.sh +1 -1
- package/scripts/harness-stop.sh +1 -1
- package/scripts/harness-user-prompt-submit.sh +12 -4
- package/scripts/harness-worker-evidence-validate.sh +8 -3
- package/scripts/import-agency-agents.js +1 -1
- package/scripts/init-agents-md.sh +17 -2
- package/assets/templates/AGENTS-ko /341/204/207/341/205/251/341/206/250/341/204/211/341/205/241/341/204/207/341/205/251/341/206/253.md" +0 -114
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type Lang = "ko" | "en";
|
|
2
|
+
|
|
3
|
+
const DICT: Record<string, { ko: string; en: string }> = {
|
|
4
|
+
"banner.run_dispatcher_first.ko": {
|
|
5
|
+
ko: "Dispatcher를 먼저 실행하세요.",
|
|
6
|
+
en: "Run Dispatcher first.",
|
|
7
|
+
},
|
|
8
|
+
"banner.progress_corrupt": {
|
|
9
|
+
ko: "progress.json이 손상되었습니다.",
|
|
10
|
+
en: "progress.json is corrupt.",
|
|
11
|
+
},
|
|
12
|
+
"drawer.tab.agent_log": { ko: "에이전트 로그", en: "Agent Log" },
|
|
13
|
+
"drawer.tab.room_metrics": { ko: "룸 메트릭", en: "Room Metrics" },
|
|
14
|
+
"drawer.tab.archive_list": { ko: "아카이브", en: "Archive" },
|
|
15
|
+
"header.subtitle": {
|
|
16
|
+
ko: "walwal-harness 라이브 운영 대시보드",
|
|
17
|
+
en: "walwal-harness live operations dashboard",
|
|
18
|
+
},
|
|
19
|
+
"goal.empty": { ko: "활성 GOAL 없음", en: "No active goal" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function t(key: string, lang: Lang = "ko"): string {
|
|
23
|
+
const entry = DICT[key];
|
|
24
|
+
if (!entry) return key;
|
|
25
|
+
return entry[lang] ?? entry.ko ?? key;
|
|
26
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { RoomId } from "./types";
|
|
2
|
+
|
|
3
|
+
// Isometric projection (2:1 dimetric). Tile basis vectors:
|
|
4
|
+
// X axis (world right) → screen ( +TILE_W/2, +TILE_H/2 )
|
|
5
|
+
// Y axis (world down) → screen ( -TILE_W/2, +TILE_H/2 )
|
|
6
|
+
// Caller multiplies by world coordinates and adds an origin offset.
|
|
7
|
+
export const TILE_W = 64;
|
|
8
|
+
export const TILE_H = 32;
|
|
9
|
+
|
|
10
|
+
export interface ScreenPoint {
|
|
11
|
+
sx: number;
|
|
12
|
+
sy: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function worldToScreen(
|
|
16
|
+
wx: number,
|
|
17
|
+
wy: number,
|
|
18
|
+
origin: ScreenPoint = { sx: 0, sy: 0 }
|
|
19
|
+
): ScreenPoint {
|
|
20
|
+
return {
|
|
21
|
+
sx: origin.sx + (wx - wy) * (TILE_W / 2),
|
|
22
|
+
sy: origin.sy + (wx + wy) * (TILE_H / 2),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Each room is a world-coordinate axis-aligned rectangle. Floor plan is laid out
|
|
27
|
+
// in a 12x8 world grid. Rectangles do not overlap interior cells.
|
|
28
|
+
export interface RoomRect {
|
|
29
|
+
id: RoomId;
|
|
30
|
+
wx: number;
|
|
31
|
+
wy: number;
|
|
32
|
+
ww: number;
|
|
33
|
+
wh: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Rooms are now spaced apart so the gaps between them form a real corridor
|
|
37
|
+
// network (1 unit wide). The corridor mesh is rendered in Floor3D.
|
|
38
|
+
export const ROOM_RECTS: RoomRect[] = [
|
|
39
|
+
{ id: "ceo", wx: 0, wy: 0, ww: 3, wh: 2 },
|
|
40
|
+
{ id: "meeting", wx: 4, wy: 0, ww: 3, wh: 2 },
|
|
41
|
+
{ id: "coo", wx: 8, wy: 0, ww: 3, wh: 2 },
|
|
42
|
+
{ id: "cto-team", wx: 0, wy: 3, ww: 5, wh: 3 },
|
|
43
|
+
{ id: "cqo-team", wx: 6, wy: 3, ww: 5, wh: 3 },
|
|
44
|
+
{ id: "service-ops", wx: 0, wy: 7, ww: 4, wh: 2 },
|
|
45
|
+
{ id: "archive", wx: 5, wy: 7, ww: 6, wh: 2 },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export interface CorridorRect {
|
|
49
|
+
wx: number;
|
|
50
|
+
wy: number;
|
|
51
|
+
ww: number;
|
|
52
|
+
wh: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Corridor segments — vertical gaps between rooms in the same row, plus the
|
|
56
|
+
// horizontal corridors that run between row blocks.
|
|
57
|
+
export const CORRIDOR_RECTS: CorridorRect[] = [
|
|
58
|
+
{ wx: 3, wy: 0, ww: 1, wh: 2 }, // ceo ↔ meeting
|
|
59
|
+
{ wx: 7, wy: 0, ww: 1, wh: 2 }, // meeting ↔ coo
|
|
60
|
+
{ wx: 5, wy: 3, ww: 1, wh: 3 }, // cto-team ↔ cqo-team
|
|
61
|
+
{ wx: 4, wy: 7, ww: 1, wh: 2 }, // service-ops ↔ archive
|
|
62
|
+
{ wx: 0, wy: 2, ww: 11, wh: 1 }, // horizontal corridor (top ↔ middle row)
|
|
63
|
+
{ wx: 0, wy: 6, ww: 11, wh: 1 }, // horizontal corridor (middle ↔ bottom row)
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// Free-roam waypoint pool: union of room interiors + corridor cells + a few
|
|
67
|
+
// outdoor strolling spots. Coordinates are in world space. The minifig roam
|
|
68
|
+
// loop picks a random target from this list when idle, so the office reads as
|
|
69
|
+
// "people moving naturally" instead of marching a rectangular track.
|
|
70
|
+
export const ROAM_POOL: Array<[number, number]> = [
|
|
71
|
+
// room interiors (center + a corner each)
|
|
72
|
+
[1.5, 1], [5.5, 1], [9.5, 1],
|
|
73
|
+
[2.5, 4.5], [8.5, 4.5],
|
|
74
|
+
[2, 8], [8, 8],
|
|
75
|
+
// corridor — top row gaps
|
|
76
|
+
[3.5, 1], [3.5, 1.5], [7.5, 1], [7.5, 1.5],
|
|
77
|
+
// corridor — between top and middle row (runs full width)
|
|
78
|
+
[1, 2.5], [3, 2.5], [5.5, 2.5], [8, 2.5], [10, 2.5],
|
|
79
|
+
// corridor — between middle and bottom row
|
|
80
|
+
[1, 6.5], [3, 6.5], [5.5, 6.5], [8, 6.5], [10, 6.5],
|
|
81
|
+
// corridor — between cto/cqo teams
|
|
82
|
+
[5.5, 4], [5.5, 5],
|
|
83
|
+
// corridor — between service-ops/archive
|
|
84
|
+
[4.5, 7.5], [4.5, 8.5],
|
|
85
|
+
// outdoor strolling spots
|
|
86
|
+
[-1, -0.6], [12, -0.6], [-1, 4], [12, 4], [-1, 9.5], [12, 9.5],
|
|
87
|
+
[3, 10], [8, 10],
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Canvas origin chosen so every projected room point falls inside viewBox 0..1200 x 0..720.
|
|
91
|
+
// World grid spans x:[0..9], y:[0..7]. Projected x range = (0-7..9-0) = -7..+9 cells →
|
|
92
|
+
// width (-7..+9)*TILE_W/2 = (-224..+288). Shift x so min becomes ~80.
|
|
93
|
+
// Projected y range = (0+0..9+7) = 0..16 cells → height 16*TILE_H/2 = 256. Shift y so top
|
|
94
|
+
// is at ~140 (leaves room for header inside layout).
|
|
95
|
+
export const ISO_ORIGIN: ScreenPoint = { sx: 80 + 224, sy: 140 };
|
|
96
|
+
|
|
97
|
+
export function roomPolygon(rect: RoomRect): string {
|
|
98
|
+
const o = ISO_ORIGIN;
|
|
99
|
+
const a = worldToScreen(rect.wx, rect.wy, o);
|
|
100
|
+
const b = worldToScreen(rect.wx + rect.ww, rect.wy, o);
|
|
101
|
+
const c = worldToScreen(rect.wx + rect.ww, rect.wy + rect.wh, o);
|
|
102
|
+
const d = worldToScreen(rect.wx, rect.wy + rect.wh, o);
|
|
103
|
+
return `${a.sx},${a.sy} ${b.sx},${b.sy} ${c.sx},${c.sy} ${d.sx},${d.sy}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function roomCenter(rect: RoomRect): ScreenPoint {
|
|
107
|
+
return worldToScreen(rect.wx + rect.ww / 2, rect.wy + rect.wh / 2, ISO_ORIGIN);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Returns N seat positions inside a room rectangle, arranged in a grid that fits
|
|
111
|
+
// gracefully even when the room is small. Used by Minifig placement.
|
|
112
|
+
export function seatPositions(rect: RoomRect, count: number): ScreenPoint[] {
|
|
113
|
+
if (count <= 0) return [];
|
|
114
|
+
const cols = Math.min(count, Math.max(2, Math.floor(rect.ww)));
|
|
115
|
+
const rows = Math.ceil(count / cols);
|
|
116
|
+
const padX = rect.ww / (cols + 1);
|
|
117
|
+
const padY = rect.wh / (rows + 1);
|
|
118
|
+
const out: ScreenPoint[] = [];
|
|
119
|
+
for (let i = 0; i < count; i++) {
|
|
120
|
+
const col = i % cols;
|
|
121
|
+
const row = Math.floor(i / cols);
|
|
122
|
+
const wx = rect.wx + padX * (col + 1);
|
|
123
|
+
const wy = rect.wy + padY * (row + 1);
|
|
124
|
+
out.push(worldToScreen(wx, wy, ISO_ORIGIN));
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Given a world rectangle, returns the smallest screen-space bounding box.
|
|
130
|
+
// Used by tests to assert non-overlap.
|
|
131
|
+
export function rectBounds(rect: RoomRect): {
|
|
132
|
+
minX: number;
|
|
133
|
+
maxX: number;
|
|
134
|
+
minY: number;
|
|
135
|
+
maxY: number;
|
|
136
|
+
} {
|
|
137
|
+
const o = ISO_ORIGIN;
|
|
138
|
+
const corners = [
|
|
139
|
+
worldToScreen(rect.wx, rect.wy, o),
|
|
140
|
+
worldToScreen(rect.wx + rect.ww, rect.wy, o),
|
|
141
|
+
worldToScreen(rect.wx + rect.ww, rect.wy + rect.wh, o),
|
|
142
|
+
worldToScreen(rect.wx, rect.wy + rect.wh, o),
|
|
143
|
+
];
|
|
144
|
+
return {
|
|
145
|
+
minX: Math.min(...corners.map((c) => c.sx)),
|
|
146
|
+
maxX: Math.max(...corners.map((c) => c.sx)),
|
|
147
|
+
minY: Math.min(...corners.map((c) => c.sy)),
|
|
148
|
+
maxY: Math.max(...corners.map((c) => c.sy)),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface LogFilterOptions {
|
|
5
|
+
agentId?: string;
|
|
6
|
+
limit?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readProgressLog(rootDir: string, opts: LogFilterOptions = {}): string[] {
|
|
10
|
+
const limit = opts.limit ?? 50;
|
|
11
|
+
const file = path.join(rootDir, ".harness", "progress.log");
|
|
12
|
+
if (!existsSync(file)) return [];
|
|
13
|
+
let raw: string;
|
|
14
|
+
try {
|
|
15
|
+
raw = readFileSync(file, "utf8");
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
20
|
+
const filtered = opts.agentId ? lines.filter((l) => l.includes(opts.agentId!)) : lines;
|
|
21
|
+
// Most-recent-first (we tail from the bottom).
|
|
22
|
+
return filtered.slice(-limit);
|
|
23
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Inline renderer — handles **bold**, `code`, *italic*
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function renderInline(text: string): React.ReactNode[] {
|
|
8
|
+
const parts: React.ReactNode[] = [];
|
|
9
|
+
// Pattern order matters: bold, inline-code, italic
|
|
10
|
+
const re = /(\*\*(.+?)\*\*|`(.+?)`|\*(.+?)\*)/g;
|
|
11
|
+
let last = 0;
|
|
12
|
+
let match: RegExpExecArray | null;
|
|
13
|
+
let key = 0;
|
|
14
|
+
|
|
15
|
+
while ((match = re.exec(text)) !== null) {
|
|
16
|
+
if (match.index > last) {
|
|
17
|
+
parts.push(text.slice(last, match.index));
|
|
18
|
+
}
|
|
19
|
+
if (match[0].startsWith("**")) {
|
|
20
|
+
parts.push(
|
|
21
|
+
<strong key={key++} className="text-white font-semibold">
|
|
22
|
+
{match[2]}
|
|
23
|
+
</strong>
|
|
24
|
+
);
|
|
25
|
+
} else if (match[0].startsWith("`")) {
|
|
26
|
+
parts.push(
|
|
27
|
+
<code
|
|
28
|
+
key={key++}
|
|
29
|
+
className="bg-gray-800 text-amber-300 text-[11px] px-1 rounded"
|
|
30
|
+
>
|
|
31
|
+
{match[3]}
|
|
32
|
+
</code>
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
parts.push(
|
|
36
|
+
<em key={key++} className="italic text-gray-300">
|
|
37
|
+
{match[4]}
|
|
38
|
+
</em>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
last = match.index + match[0].length;
|
|
42
|
+
}
|
|
43
|
+
if (last < text.length) parts.push(text.slice(last));
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Table renderer
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
function renderTable(rows: string[]): React.ReactNode {
|
|
51
|
+
// rows[0] = header, rows[1] = separator, rows[2+] = data
|
|
52
|
+
const parseRow = (row: string): string[] =>
|
|
53
|
+
row
|
|
54
|
+
.split("|")
|
|
55
|
+
.map((c) => c.trim())
|
|
56
|
+
.filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
|
57
|
+
|
|
58
|
+
const headers = parseRow(rows[0]);
|
|
59
|
+
const dataRows = rows.slice(2).map(parseRow);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div key={Math.random()} className="overflow-x-auto">
|
|
63
|
+
<table className="w-full border-collapse font-mono text-[11px]">
|
|
64
|
+
<thead>
|
|
65
|
+
<tr>
|
|
66
|
+
{headers.map((h, i) => (
|
|
67
|
+
<th
|
|
68
|
+
key={i}
|
|
69
|
+
className="border border-gray-700/60 bg-gray-800/50 px-2 py-1 text-left text-cyan-300/90"
|
|
70
|
+
>
|
|
71
|
+
{renderInline(h)}
|
|
72
|
+
</th>
|
|
73
|
+
))}
|
|
74
|
+
</tr>
|
|
75
|
+
</thead>
|
|
76
|
+
<tbody>
|
|
77
|
+
{dataRows.map((row, ri) => (
|
|
78
|
+
<tr key={ri} className="border-b border-gray-700/40">
|
|
79
|
+
{row.map((cell, ci) => (
|
|
80
|
+
<td
|
|
81
|
+
key={ci}
|
|
82
|
+
className="border border-gray-700/40 px-2 py-1 text-gray-300"
|
|
83
|
+
>
|
|
84
|
+
{renderInline(cell)}
|
|
85
|
+
</td>
|
|
86
|
+
))}
|
|
87
|
+
</tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Block renderer state machine
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
function renderBlocks(content: string): React.ReactNode[] {
|
|
99
|
+
const lines = content.split("\n");
|
|
100
|
+
const nodes: React.ReactNode[] = [];
|
|
101
|
+
let i = 0;
|
|
102
|
+
let keyCounter = 0;
|
|
103
|
+
const k = () => keyCounter++;
|
|
104
|
+
|
|
105
|
+
while (i < lines.length) {
|
|
106
|
+
const line = lines[i];
|
|
107
|
+
|
|
108
|
+
// ---- Fenced code block ----
|
|
109
|
+
if (line.startsWith("```")) {
|
|
110
|
+
const lang = line.slice(3).trim();
|
|
111
|
+
const codeLines: string[] = [];
|
|
112
|
+
i++;
|
|
113
|
+
while (i < lines.length && !lines[i].startsWith("```")) {
|
|
114
|
+
codeLines.push(lines[i]);
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
i++; // skip closing ```
|
|
118
|
+
nodes.push(
|
|
119
|
+
<pre
|
|
120
|
+
key={k()}
|
|
121
|
+
className="overflow-x-auto rounded border border-gray-700/60 bg-[#0d1117] p-3 my-2"
|
|
122
|
+
>
|
|
123
|
+
<code className="text-emerald-300/90 text-[11px] font-mono whitespace-pre">
|
|
124
|
+
{lang ? (
|
|
125
|
+
<span className="block text-[10px] text-gray-600 mb-1">{lang}</span>
|
|
126
|
+
) : null}
|
|
127
|
+
{codeLines.join("\n")}
|
|
128
|
+
</code>
|
|
129
|
+
</pre>
|
|
130
|
+
);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- HR ----
|
|
135
|
+
if (/^---+$/.test(line.trim())) {
|
|
136
|
+
nodes.push(<hr key={k()} className="border-gray-700/50 my-3" />);
|
|
137
|
+
i++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- H1 ----
|
|
142
|
+
if (line.startsWith("# ")) {
|
|
143
|
+
nodes.push(
|
|
144
|
+
<h1 key={k()} className="text-[16px] font-bold text-white mt-3 mb-1">
|
|
145
|
+
{renderInline(line.slice(2))}
|
|
146
|
+
</h1>
|
|
147
|
+
);
|
|
148
|
+
i++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- H2 ----
|
|
153
|
+
if (line.startsWith("## ")) {
|
|
154
|
+
nodes.push(
|
|
155
|
+
<h2
|
|
156
|
+
key={k()}
|
|
157
|
+
className="text-[13px] font-bold text-cyan-300/90 mt-4 mb-0.5 pb-0.5 border-b border-gray-700/40"
|
|
158
|
+
>
|
|
159
|
+
{renderInline(line.slice(3))}
|
|
160
|
+
</h2>
|
|
161
|
+
);
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- H3 ----
|
|
167
|
+
if (line.startsWith("### ")) {
|
|
168
|
+
nodes.push(
|
|
169
|
+
<h3 key={k()} className="text-[12px] font-semibold text-gray-200 mt-3 mb-0.5">
|
|
170
|
+
{renderInline(line.slice(4))}
|
|
171
|
+
</h3>
|
|
172
|
+
);
|
|
173
|
+
i++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- H4 ----
|
|
178
|
+
if (line.startsWith("#### ")) {
|
|
179
|
+
nodes.push(
|
|
180
|
+
<h4 key={k()} className="text-[11px] font-semibold text-gray-300 mt-2 mb-0.5">
|
|
181
|
+
{renderInline(line.slice(5))}
|
|
182
|
+
</h4>
|
|
183
|
+
);
|
|
184
|
+
i++;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- Table ----
|
|
189
|
+
if (line.includes("|") && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i + 1])) {
|
|
190
|
+
const tableRows: string[] = [line];
|
|
191
|
+
i++;
|
|
192
|
+
while (i < lines.length && lines[i].includes("|")) {
|
|
193
|
+
tableRows.push(lines[i]);
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
if (tableRows.length >= 2) {
|
|
197
|
+
nodes.push(
|
|
198
|
+
<div key={k()} className="my-2">
|
|
199
|
+
{renderTable(tableRows)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- Checkbox list items ----
|
|
207
|
+
if (/^- \[x\]/i.test(line)) {
|
|
208
|
+
nodes.push(
|
|
209
|
+
<div key={k()} className="flex items-start gap-1.5 text-[12px] text-gray-300 pl-2">
|
|
210
|
+
<span className="text-emerald-400 shrink-0">✓</span>
|
|
211
|
+
<span>{renderInline(line.slice(6).trim())}</span>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
i++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (/^- \[ \]/.test(line)) {
|
|
218
|
+
nodes.push(
|
|
219
|
+
<div key={k()} className="flex items-start gap-1.5 text-[12px] text-gray-400 pl-2">
|
|
220
|
+
<span className="text-gray-600 shrink-0">○</span>
|
|
221
|
+
<span>{renderInline(line.slice(6).trim())}</span>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- Bullet list ----
|
|
229
|
+
if (/^(\s*)([-*])\s/.test(line)) {
|
|
230
|
+
const listItems: React.ReactNode[] = [];
|
|
231
|
+
while (i < lines.length && /^(\s*)([-*])\s/.test(lines[i])) {
|
|
232
|
+
const indent = lines[i].match(/^(\s*)/)?.[1].length ?? 0;
|
|
233
|
+
const text = lines[i].replace(/^\s*[-*]\s/, "");
|
|
234
|
+
listItems.push(
|
|
235
|
+
<li
|
|
236
|
+
key={k()}
|
|
237
|
+
style={{ paddingLeft: indent > 0 ? `${indent * 8}px` : undefined }}
|
|
238
|
+
className="flex items-start gap-1.5"
|
|
239
|
+
>
|
|
240
|
+
<span className="text-gray-600 shrink-0 mt-0.5">•</span>
|
|
241
|
+
<span>{renderInline(text)}</span>
|
|
242
|
+
</li>
|
|
243
|
+
);
|
|
244
|
+
i++;
|
|
245
|
+
}
|
|
246
|
+
nodes.push(
|
|
247
|
+
<ul key={k()} className="space-y-0.5 text-[12px] text-gray-300 my-1">
|
|
248
|
+
{listItems}
|
|
249
|
+
</ul>
|
|
250
|
+
);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- Numbered list ----
|
|
255
|
+
if (/^\d+\.\s/.test(line)) {
|
|
256
|
+
const listItems: React.ReactNode[] = [];
|
|
257
|
+
let num = 1;
|
|
258
|
+
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
|
|
259
|
+
const text = lines[i].replace(/^\d+\.\s/, "");
|
|
260
|
+
listItems.push(
|
|
261
|
+
<li key={k()} className="flex items-start gap-1.5">
|
|
262
|
+
<span className="text-gray-500 shrink-0 tabular-nums">{num}.</span>
|
|
263
|
+
<span>{renderInline(text)}</span>
|
|
264
|
+
</li>
|
|
265
|
+
);
|
|
266
|
+
i++;
|
|
267
|
+
num++;
|
|
268
|
+
}
|
|
269
|
+
nodes.push(
|
|
270
|
+
<ol key={k()} className="space-y-0.5 text-[12px] text-gray-300 my-1">
|
|
271
|
+
{listItems}
|
|
272
|
+
</ol>
|
|
273
|
+
);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- Blank line ----
|
|
278
|
+
if (line.trim() === "") {
|
|
279
|
+
i++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- Regular paragraph ----
|
|
284
|
+
nodes.push(
|
|
285
|
+
<p key={k()} className="text-gray-300 text-[12px] leading-relaxed">
|
|
286
|
+
{renderInline(line)}
|
|
287
|
+
</p>
|
|
288
|
+
);
|
|
289
|
+
i++;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return nodes;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Public component
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
export function MarkdownView({ source }: { source: string }) {
|
|
299
|
+
// Strip YAML frontmatter (between --- ... ---)
|
|
300
|
+
const content = source.replace(/^---[\s\S]*?---\n+/, "").trim();
|
|
301
|
+
return (
|
|
302
|
+
<div className="space-y-1.5 text-[12px] leading-relaxed">
|
|
303
|
+
{renderBlocks(content)}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ROOM_RECTS, CORRIDOR_RECTS } from "./iso";
|
|
2
|
+
import { doorsForRoom } from "./furniture";
|
|
3
|
+
import type { RoomId } from "./types";
|
|
4
|
+
|
|
5
|
+
export type Zone = RoomId | "corridor" | "outdoor";
|
|
6
|
+
|
|
7
|
+
export function zoneAt(x: number, z: number): Zone {
|
|
8
|
+
for (const r of ROOM_RECTS) {
|
|
9
|
+
if (x >= r.wx && x <= r.wx + r.ww && z >= r.wy && z <= r.wy + r.wh) {
|
|
10
|
+
return r.id;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
for (const c of CORRIDOR_RECTS) {
|
|
14
|
+
if (x >= c.wx && x <= c.wx + c.ww && z >= c.wy && z <= c.wy + c.wh) {
|
|
15
|
+
return "corridor";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return "outdoor";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returns the corridor-side foot of the room's primary door — i.e. a point
|
|
22
|
+
// just outside the wall, so a minifig walking to it leaves the room cleanly
|
|
23
|
+
// through the door opening.
|
|
24
|
+
export function exitWaypoint(roomId: RoomId): [number, number] | null {
|
|
25
|
+
const rect = ROOM_RECTS.find((r) => r.id === roomId);
|
|
26
|
+
if (!rect) return null;
|
|
27
|
+
const doors = doorsForRoom(rect);
|
|
28
|
+
if (doors.length === 0) return null;
|
|
29
|
+
// Pick the door that points "down" (toward south corridor) when available;
|
|
30
|
+
// it gives the most natural look because all rooms have a south or north
|
|
31
|
+
// corridor adjacency.
|
|
32
|
+
const preferred =
|
|
33
|
+
doors.find((d) => d.side === "south") ??
|
|
34
|
+
doors.find((d) => d.side === "north") ??
|
|
35
|
+
doors[0];
|
|
36
|
+
const offset = 0.6;
|
|
37
|
+
switch (preferred.side) {
|
|
38
|
+
case "north":
|
|
39
|
+
return [preferred.position[0], preferred.position[1] - offset];
|
|
40
|
+
case "south":
|
|
41
|
+
return [preferred.position[0], preferred.position[1] + offset];
|
|
42
|
+
case "east":
|
|
43
|
+
return [preferred.position[0] + offset, preferred.position[1]];
|
|
44
|
+
case "west":
|
|
45
|
+
return [preferred.position[0] - offset, preferred.position[1]];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Plans a wall-respecting path from `from` to `to`.
|
|
50
|
+
// Rule: leaving a room or entering a room must pass through that room's door.
|
|
51
|
+
// Movement within the same zone (or between corridor / outdoor) is direct.
|
|
52
|
+
export function planPath(
|
|
53
|
+
from: [number, number],
|
|
54
|
+
to: [number, number]
|
|
55
|
+
): Array<[number, number]> {
|
|
56
|
+
const fromZone = zoneAt(from[0], from[1]);
|
|
57
|
+
const toZone = zoneAt(to[0], to[1]);
|
|
58
|
+
const waypoints: Array<[number, number]> = [];
|
|
59
|
+
|
|
60
|
+
const isRoom = (z: Zone): z is RoomId => z !== "corridor" && z !== "outdoor";
|
|
61
|
+
|
|
62
|
+
if (isRoom(fromZone) && fromZone !== toZone) {
|
|
63
|
+
const exit = exitWaypoint(fromZone);
|
|
64
|
+
if (exit) waypoints.push(exit);
|
|
65
|
+
}
|
|
66
|
+
if (isRoom(toZone) && fromZone !== toZone) {
|
|
67
|
+
const entry = exitWaypoint(toZone);
|
|
68
|
+
if (entry) waypoints.push(entry);
|
|
69
|
+
}
|
|
70
|
+
waypoints.push(to);
|
|
71
|
+
return waypoints;
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export type SafeJsonResult<T> =
|
|
4
|
+
| { ok: true; value: T }
|
|
5
|
+
| { ok: false; reason: "missing" | "corrupt"; error?: unknown };
|
|
6
|
+
|
|
7
|
+
export function readJsonSafe<T = unknown>(filePath: string): SafeJsonResult<T> {
|
|
8
|
+
let raw: string;
|
|
9
|
+
try {
|
|
10
|
+
raw = readFileSync(filePath, "utf8");
|
|
11
|
+
} catch (error) {
|
|
12
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
13
|
+
if (code === "ENOENT") {
|
|
14
|
+
return { ok: false, reason: "missing", error };
|
|
15
|
+
}
|
|
16
|
+
return { ok: false, reason: "corrupt", error };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(raw) as T;
|
|
20
|
+
return { ok: true, value };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return { ok: false, reason: "corrupt", error };
|
|
23
|
+
}
|
|
24
|
+
}
|