@walwal-harness/cli 7.1.7 → 7.1.9

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.
Files changed (89) hide show
  1. package/HR-Resource/index.json +19 -43
  2. package/HR-Resource/marketing-marketing-cross-border-ecommerce/SKILL.md +108 -223
  3. package/HR-Resource/marketing-marketing-global-marketplace-operator/SKILL.md +175 -0
  4. package/HR-Resource/marketing-marketing-google-seo-specialist/SKILL.md +91 -0
  5. package/HR-Resource/marketing-marketing-instagram-commerce-strategist/SKILL.md +81 -0
  6. package/HR-Resource/marketing-marketing-korea-global-market-localization-strategist/SKILL.md +186 -0
  7. package/HR-Resource/marketing-marketing-livestream-commerce-coach/SKILL.md +88 -272
  8. package/HR-Resource/marketing-marketing-naver-seo-specialist/SKILL.md +88 -0
  9. package/HR-Resource/marketing-marketing-short-video-editing-coach/SKILL.md +99 -395
  10. package/HR-Resource/marketing-marketing-youtube-shopping-strategist/SKILL.md +82 -0
  11. package/README.md +41 -0
  12. package/apps/harness-dashboard/app/api/env/route.ts +62 -0
  13. package/apps/harness-dashboard/app/api/log/route.ts +19 -0
  14. package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
  15. package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
  16. package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
  17. package/apps/harness-dashboard/app/globals.css +17 -0
  18. package/apps/harness-dashboard/app/layout.tsx +29 -0
  19. package/apps/harness-dashboard/app/page.tsx +35 -0
  20. package/apps/harness-dashboard/components/Drawer.tsx +97 -0
  21. package/apps/harness-dashboard/components/Header.tsx +40 -0
  22. package/apps/harness-dashboard/components/MissionTimeline.tsx +210 -0
  23. package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
  24. package/apps/harness-dashboard/components/Scene.tsx +563 -0
  25. package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
  26. package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
  27. package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
  28. package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
  29. package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
  30. package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
  31. package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +517 -0
  32. package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +156 -0
  33. package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
  34. package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
  35. package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
  36. package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
  37. package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
  38. package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
  39. package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
  40. package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
  41. package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
  42. package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
  43. package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
  44. package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
  45. package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
  46. package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
  47. package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
  48. package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
  49. package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
  50. package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
  51. package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
  52. package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
  53. package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
  54. package/apps/harness-dashboard/lib/furniture.ts +165 -0
  55. package/apps/harness-dashboard/lib/harness-root.ts +20 -0
  56. package/apps/harness-dashboard/lib/harness-state.ts +1162 -0
  57. package/apps/harness-dashboard/lib/i18n.ts +26 -0
  58. package/apps/harness-dashboard/lib/iso.ts +150 -0
  59. package/apps/harness-dashboard/lib/log-filter.ts +23 -0
  60. package/apps/harness-dashboard/lib/markdown.tsx +306 -0
  61. package/apps/harness-dashboard/lib/path-planning.ts +72 -0
  62. package/apps/harness-dashboard/lib/safe-json.ts +24 -0
  63. package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
  64. package/apps/harness-dashboard/lib/types.ts +329 -0
  65. package/apps/harness-dashboard/lib/watcher.ts +64 -0
  66. package/apps/harness-dashboard/next-env.d.ts +6 -0
  67. package/apps/harness-dashboard/next.config.mjs +9 -0
  68. package/apps/harness-dashboard/package-lock.json +4280 -0
  69. package/apps/harness-dashboard/package.json +38 -0
  70. package/apps/harness-dashboard/playwright.config.ts +23 -0
  71. package/apps/harness-dashboard/postcss.config.js +6 -0
  72. package/apps/harness-dashboard/tailwind.config.ts +49 -0
  73. package/apps/harness-dashboard/tsconfig.json +24 -0
  74. package/apps/harness-dashboard/vitest.config.ts +15 -0
  75. 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" +114 -0
  76. package/package.json +11 -1
  77. package/scripts/harness-dashboard-up.sh +24 -4
  78. package/HR-Resource/engineering-engineering-feishu-integration-developer/SKILL.md +0 -615
  79. package/HR-Resource/engineering-engineering-wechat-mini-program-developer/SKILL.md +0 -367
  80. package/HR-Resource/marketing-marketing-baidu-seo-specialist/SKILL.md +0 -243
  81. package/HR-Resource/marketing-marketing-bilibili-content-strategist/SKILL.md +0 -216
  82. package/HR-Resource/marketing-marketing-china-ecommerce-operator/SKILL.md +0 -300
  83. package/HR-Resource/marketing-marketing-china-market-localization-strategist/SKILL.md +0 -300
  84. package/HR-Resource/marketing-marketing-douyin-strategist/SKILL.md +0 -166
  85. package/HR-Resource/marketing-marketing-kuaishou-strategist/SKILL.md +0 -240
  86. package/HR-Resource/marketing-marketing-wechat-official-account/SKILL.md +0 -162
  87. package/HR-Resource/marketing-marketing-weibo-strategist/SKILL.md +0 -257
  88. package/HR-Resource/marketing-marketing-xiaohongshu-specialist/SKILL.md +0 -155
  89. package/HR-Resource/marketing-marketing-zhihu-strategist/SKILL.md +0 -179
@@ -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
+ }