@quo-systems/dock 0.2.0 → 0.2.2
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/beings/avatar.ts +9 -3
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +3 -1
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +163 -277
- package/beings/side.ts +10 -1
- package/beings/user.ts +67 -13
- package/cli/daemon.ts +58 -251
- package/cli/http.ts +70 -0
- package/cli/quo.ts +13 -5
- package/dist/beings/avatar.js +10 -3
- package/dist/beings/carry.d.ts +10 -0
- package/dist/beings/carry.js +106 -0
- package/dist/beings/desk.d.ts +1 -0
- package/dist/beings/desk.js +1 -1
- package/dist/beings/index.d.ts +2 -0
- package/dist/beings/index.js +3 -1
- package/dist/beings/link.d.ts +7 -0
- package/dist/beings/link.js +42 -0
- package/dist/beings/look.d.ts +27 -0
- package/dist/beings/look.js +71 -0
- package/dist/beings/side.d.ts +8 -1
- package/dist/beings/user.d.ts +29 -3
- package/dist/beings/user.js +71 -14
- package/dist/cli/daemon.d.ts +7 -19
- package/dist/cli/daemon.js +51 -240
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +15 -5
- package/dist/harbor/quo.d.ts +4 -0
- package/dist/harbor/quo.js +53 -0
- package/dist/human/door.d.ts +5 -0
- package/dist/human/door.js +19 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +25 -0
- package/dist/human/html.d.ts +10 -2
- package/dist/human/html.js +61 -10
- package/dist/human/screen.d.ts +8 -3
- package/dist/human/screen.js +26 -6
- package/dist/human/tab.d.ts +5 -0
- package/dist/human/tab.js +161 -43
- package/dist/human/web.d.ts +9 -0
- package/dist/human/web.js +95 -0
- package/dist/human/worlds.d.ts +10 -0
- package/dist/human/worlds.js +38 -0
- package/dist/mcp/http.d.ts +4 -3
- package/dist/mcp/http.js +6 -6
- package/dist/mcp/oauth.d.ts +9 -4
- package/dist/mcp/oauth.js +15 -14
- package/dist/mcp/pilot.js +1 -1
- package/dist/mcp/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- package/dist/mcp/server.d.ts +5 -10
- package/dist/mcp/server.js +35 -4
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/quo-harbor.md +67 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +158 -67
- package/human/screen.ts +34 -8
- package/human/tab.ts +193 -53
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +20 -2
- package/mcp/route.ts +39 -0
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/beings/avatar.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// exactly one standing, `user`, and everything her side asks goes through
|
|
5
5
|
// it; the user being reaches her only to push, and she hands the push to
|
|
6
6
|
// every side she has. A side is in-process with her, holds the object, and
|
|
7
|
-
// calls the
|
|
7
|
+
// calls the four methods below: one of the two ways in, `enter` behind a
|
|
8
|
+
// route's proof or `join` with an invitation, then `tools` and `call`.
|
|
9
|
+
// Nothing of a side is in her cells.
|
|
8
10
|
import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
11
|
import type { Answer, Blueprint, Invitation, JsonObject, OccupantRecord, Wanted } from '@quo-systems/quo';
|
|
10
12
|
|
|
@@ -51,8 +53,11 @@ export class Avatar extends Being {
|
|
|
51
53
|
return this.join(inv as unknown as Invitation);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
// Knock with an invitation, hand the
|
|
55
|
-
// and take her as `user`. The only moment a standing is born here.
|
|
56
|
+
// Knock with an invitation, hand the far being one back so she can push,
|
|
57
|
+
// and take her as `user`. The only moment a standing is born here. A being
|
|
58
|
+
// who answers the hello with an error object is a being who answered: she
|
|
59
|
+
// is taken all the same, and the way back she did not take is dropped, so
|
|
60
|
+
// any world's public being may admit a guest, hello or not.
|
|
56
61
|
async join(invitation: Invitation): Promise<Blueprint | { error: string }> {
|
|
57
62
|
if (this.standings[USER]) return this.tools();
|
|
58
63
|
const mine = await this.invite(PUSHER);
|
|
@@ -62,6 +67,7 @@ export class Avatar extends Being {
|
|
|
62
67
|
this.occupants.remove(PUSHER);
|
|
63
68
|
return { error: isSilence(out) ? 'silence' : wordOf(out) };
|
|
64
69
|
}
|
|
70
|
+
if (out !== null && typeof out === 'object' && !Array.isArray(out) && 'error' in out) this.occupants.remove(PUSHER);
|
|
65
71
|
await this.take(USER, invitation);
|
|
66
72
|
return this.tools();
|
|
67
73
|
}
|
package/beings/carry.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A carrier: a being who shows the asks of the standings she holds as asks
|
|
3
|
+
// of her own, and forwards. Whoever she carries for sees, in one describe,
|
|
4
|
+
// what she can be asked and what every being she holds can be asked, each
|
|
5
|
+
// under that standing's name; asking one is an ask on that standing, in her
|
|
6
|
+
// name, so the far being sees her and never who is behind her. A user being
|
|
7
|
+
// carries acme for the human's devices; a house being could carry its
|
|
8
|
+
// rooms; nothing here knows what she is.
|
|
9
|
+
//
|
|
10
|
+
// A carried ask is `<id>-<name>`, and the mapping back is kept in her cells
|
|
11
|
+
// and never parsed from the name, so an id with a dash in it is no trouble.
|
|
12
|
+
// The notes say which asks are whose, `standings: { id: { asks, look? } }`,
|
|
13
|
+
// so a screen draws one section per standing and a model side keeps the
|
|
14
|
+
// flat list it needs. Her own asks come first and are never shadowed: a
|
|
15
|
+
// carried name that collides with one of hers is dropped.
|
|
16
|
+
//
|
|
17
|
+
// She asks a standing's describe again when its digest moved, and its
|
|
18
|
+
// `look` once per digest, and keeps both in her cells. So a describe of
|
|
19
|
+
// hers may cost one ask per stale standing, which is the price of a page
|
|
20
|
+
// that is never more than one ask behind.
|
|
21
|
+
import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
22
|
+
import type { Asker, Blueprint, JsonObject, OccupantRecord, Reply } from '@quo-systems/quo';
|
|
23
|
+
import { sanitise, type Look } from './look.ts';
|
|
24
|
+
|
|
25
|
+
type Carried = Record<string, { id: string; ask: string }>;
|
|
26
|
+
type Looks = Record<string, { digest: string | null; look: Look }>;
|
|
27
|
+
|
|
28
|
+
export class Carrier extends Being {
|
|
29
|
+
// Who sees what she carries. Nobody, until a subclass says who.
|
|
30
|
+
static carries(_occupant: OccupantRecord | undefined, _asker: Asker): boolean {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// Standings she never carries: a way back to a device is one, by the
|
|
34
|
+
// dock's own convention, and a subclass may name more.
|
|
35
|
+
static hidden(id: string): boolean {
|
|
36
|
+
return id.startsWith('to:');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private get carried(): Carried {
|
|
40
|
+
return ((this.cells.carried as Carried | undefined) ??= {});
|
|
41
|
+
}
|
|
42
|
+
private get looks(): Looks {
|
|
43
|
+
return ((this.cells.looks as Looks | undefined) ??= {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Her describe with her standings carried into it, for an asker who may
|
|
47
|
+
// see them. Every other asker gets her describe alone.
|
|
48
|
+
async blueprint(asker: Asker): Promise<Blueprint> {
|
|
49
|
+
const C = this.constructor as typeof Carrier;
|
|
50
|
+
const bp = this.describe(asker);
|
|
51
|
+
if (!C.carries(this.occupant(asker), asker)) return bp;
|
|
52
|
+
const own = new Set(bp.asks.map((a) => a.name));
|
|
53
|
+
const carried: Carried = {};
|
|
54
|
+
const groups: Record<string, { asks: string[]; look?: Look }> = {};
|
|
55
|
+
for (const [id, rec] of Object.entries(this.cells.standings)) {
|
|
56
|
+
if (C.hidden(id)) continue;
|
|
57
|
+
const st = this.standings[id];
|
|
58
|
+
if (!st) continue;
|
|
59
|
+
if (!rec.blueprint || rec.seen !== rec.digest) await st.ask(); // her ward writes the record
|
|
60
|
+
if (!rec.blueprint) continue;
|
|
61
|
+
const names: string[] = [];
|
|
62
|
+
for (const a of rec.blueprint.asks) {
|
|
63
|
+
if (a.name === 'look') continue;
|
|
64
|
+
const name = `${id}-${a.name}`;
|
|
65
|
+
if (own.has(name)) continue;
|
|
66
|
+
carried[name] = { id, ask: a.name };
|
|
67
|
+
names.push(name);
|
|
68
|
+
bp.asks.push({ ...a, name });
|
|
69
|
+
}
|
|
70
|
+
const group: { asks: string[]; look?: Look } = { asks: names };
|
|
71
|
+
if (rec.blueprint.asks.some((a) => a.name === 'look')) {
|
|
72
|
+
const kept = this.looks[id];
|
|
73
|
+
if (!kept || kept.digest !== rec.digest) {
|
|
74
|
+
const l = await st.ask('look');
|
|
75
|
+
this.looks[id] = { digest: rec.digest, look: isSilence(l) || isWord(l) ? {} : sanitise(l) };
|
|
76
|
+
}
|
|
77
|
+
group.look = this.looks[id]!.look;
|
|
78
|
+
}
|
|
79
|
+
groups[id] = group;
|
|
80
|
+
}
|
|
81
|
+
for (const id of Object.keys(this.looks)) if (!(id in groups)) delete this.looks[id];
|
|
82
|
+
this.cells.carried = carried;
|
|
83
|
+
const notes = bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? bp.notes : {};
|
|
84
|
+
return { asks: bp.asks, notes: { ...notes, standings: groups as unknown as JsonObject } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override async answer(asker: Asker, method?: string, args: JsonObject = {}): Promise<Reply> {
|
|
88
|
+
if (method === undefined) return this.blueprint(asker);
|
|
89
|
+
const C = this.constructor as typeof Carrier;
|
|
90
|
+
const to = Object.hasOwn(this.carried, method) ? this.carried[method] : undefined;
|
|
91
|
+
if (to && C.carries(this.occupant(asker), asker)) {
|
|
92
|
+
const st = this.standings[to.id];
|
|
93
|
+
if (!st) return { error: 'unknown ask' };
|
|
94
|
+
const out = await st.ask(to.ask, args);
|
|
95
|
+
if (isSilence(out)) return out;
|
|
96
|
+
if (isWord(out)) return { error: wordOf(out) };
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
return super.answer(asker, method, args);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/beings/desk.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
|
12
12
|
import type { Asker, Json, JsonObject } from '@quo-systems/quo';
|
|
13
13
|
|
|
14
14
|
export type Proof = { kind: string; [more: string]: Json };
|
|
15
|
-
export type Verified = { user: string; client: string; wake?: boolean };
|
|
15
|
+
export type Verified = { user: string; client: string; wake?: boolean; reach?: boolean };
|
|
16
16
|
export type Verifier = (proof: Proof) => Promise<Verified | null> | Verified | null;
|
|
17
17
|
|
|
18
18
|
export class Desk extends Being {
|
|
@@ -38,7 +38,7 @@ export class Desk extends Being {
|
|
|
38
38
|
if (!who) return { error: 'refused' };
|
|
39
39
|
const user = this.standings[`user:${who.user}`];
|
|
40
40
|
if (!user) return { error: 'no such user' };
|
|
41
|
-
const inv = await user.ask('device', who.wake === true ? {
|
|
41
|
+
const inv = await user.ask('device', { client: who.client, ...(who.wake === true ? { wake: true } : {}), ...(who.reach === true ? { reach: true } : {}) });
|
|
42
42
|
if (isSilence(inv)) return { error: 'silence' };
|
|
43
43
|
if (isWord(inv)) return { error: wordOf(inv) };
|
|
44
44
|
return inv;
|
package/beings/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The beings every side shares. See quo-
|
|
2
|
+
// The beings every side shares. See quo-dock.md, the trunk.
|
|
3
3
|
export { User, DESK } from './user.ts';
|
|
4
4
|
export { Desk, type Proof, type Verified, type Verifier } from './desk.ts';
|
|
5
5
|
export { Avatar, USER, PUSHER } from './avatar.ts';
|
|
6
|
+
export { Carrier } from './carry.ts';
|
|
7
|
+
export { sanitise, hint, hintFor, groups, type Look, type Hint } from './look.ts';
|
package/beings/link.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A link: a world's page with an invitation in the fragment. The page is the
|
|
3
|
+
// hint, where the world lives; the fragment is the invitation, which never
|
|
4
|
+
// leaves the browser: a query string reaches the server, its logs and every
|
|
5
|
+
// referer, and an invitation is a capability. One reserved key, `quo`, the
|
|
6
|
+
// one word no page uses for anything else, and the value is the invitation
|
|
7
|
+
// compact: `ward.heir.secret`, three hex strings, or `ward` alone for a
|
|
8
|
+
// world's public being. The dock's human module reads it, strips it before
|
|
9
|
+
// any page code runs, and joins.
|
|
10
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
11
|
+
|
|
12
|
+
export const KEY = 'quo';
|
|
13
|
+
const HEX = (n: number) => `[0-9a-f]{${n}}`;
|
|
14
|
+
const FULL = new RegExp(`^(${HEX(128)})\\.(${HEX(64)})\\.(${HEX(64)})$`);
|
|
15
|
+
const PUBLIC = new RegExp(`^${HEX(128)}$`);
|
|
16
|
+
|
|
17
|
+
export function format(inv: Invitation): string {
|
|
18
|
+
return inv.heir && inv.secret ? `${inv.ward}.${inv.heir}.${inv.secret}` : inv.ward;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The invitation in a fragment, or null. Anything else in the fragment is
|
|
22
|
+
// the page's own and is left alone.
|
|
23
|
+
export function parse(hash: string): Invitation | null {
|
|
24
|
+
const m = /(?:^#|&)quo=([^&]+)/.exec(hash.startsWith('#') ? hash : `#${hash}`);
|
|
25
|
+
if (!m) return null;
|
|
26
|
+
const v = decodeURIComponent(m[1]!);
|
|
27
|
+
const full = FULL.exec(v);
|
|
28
|
+
if (full) return { ward: full[1]!, heir: full[2]!, secret: full[3]! };
|
|
29
|
+
return PUBLIC.test(v) ? { ward: v } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The fragment without the invitation, so the page keeps whatever else it
|
|
33
|
+
// put there and the capability is gone from the address bar.
|
|
34
|
+
export function strip(hash: string): string {
|
|
35
|
+
const rest = (hash.startsWith('#') ? hash.slice(1) : hash)
|
|
36
|
+
.split('&')
|
|
37
|
+
.filter((p) => !p.startsWith(`${KEY}=`))
|
|
38
|
+
.join('&');
|
|
39
|
+
return rest ? `#${rest}` : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function link(page: string, inv: Invitation): string {
|
|
43
|
+
return `${page}#${KEY}=${format(inv)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Whether an answer is an invitation: a ward pk, and a heir with its secret
|
|
47
|
+
// or neither. What a guest's form answers with to be let in.
|
|
48
|
+
export function isInvitation(v: unknown): v is Invitation {
|
|
49
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
50
|
+
const o = v as Record<string, unknown>;
|
|
51
|
+
if (typeof o.ward !== 'string' || !PUBLIC.test(o.ward)) return false;
|
|
52
|
+
const heir = typeof o.heir === 'string', secret = typeof o.secret === 'string';
|
|
53
|
+
return (heir && secret) || (!heir && !secret && !('heir' in o) && !('secret' in o));
|
|
54
|
+
}
|
package/beings/look.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A look: what a being may say about how she is presented, as values and
|
|
3
|
+
// no more. One optional ask, `look`, answers it, and every side reads the
|
|
4
|
+
// part it understands: a screen reads the tokens and paints her section, a
|
|
5
|
+
// model side reads the hints per ask and writes them as tool annotations.
|
|
6
|
+
// Nothing here is capability, so nothing here is in a blueprint or a digest,
|
|
7
|
+
// and Quo never sees it. The vocabulary is closed: a token that is not
|
|
8
|
+
// listed does not exist, and one that fails its shape is dropped, not
|
|
9
|
+
// fixed. A being cannot paint outside her section or reach a server
|
|
10
|
+
// through a look, because no token can carry a URL, a stylesheet or code.
|
|
11
|
+
import type { Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
12
|
+
|
|
13
|
+
// What a side may do with one ask. `title` is a human label instead of the
|
|
14
|
+
// name; `readOnly` says the ask changes nothing, so a screen may run it on
|
|
15
|
+
// its own and a model side marks it so; `destructive` asks for care;
|
|
16
|
+
// `idempotent` says asking twice is asking once; `icon` is one short string
|
|
17
|
+
// for a button. A being who marks an ask read-only and then writes has lied
|
|
18
|
+
// to her own page, and nothing enforces it, the way nothing enforces that
|
|
19
|
+
// her empty ask is safe to repeat.
|
|
20
|
+
export type Hint = { title?: string; readOnly?: boolean; destructive?: boolean; idempotent?: boolean; icon?: string };
|
|
21
|
+
export type Look = {
|
|
22
|
+
name?: string;
|
|
23
|
+
logo?: string; // an image as a data URI, never a URL
|
|
24
|
+
accent?: string; // hex colours
|
|
25
|
+
background?: string;
|
|
26
|
+
foreground?: string;
|
|
27
|
+
font?: string; // a font stack, plain characters
|
|
28
|
+
radius?: number; // 0 to 40, in px
|
|
29
|
+
order?: string[]; // her asks in the order she wants them shown
|
|
30
|
+
asks?: Record<string, Hint>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const COLOUR = /^#[0-9a-fA-F]{3,8}$/;
|
|
34
|
+
const FONT = /^[\w\s,'"-]{1,80}$/;
|
|
35
|
+
const LOGO = /^data:image\/(svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/=]{1,40000}$/;
|
|
36
|
+
const NAME = /^[^<>&]{1,60}$/;
|
|
37
|
+
const ICON = /^[^<>&"']{1,8}$/;
|
|
38
|
+
|
|
39
|
+
const str = (v: Json | undefined, re: RegExp): string | undefined => (typeof v === 'string' && re.test(v) ? v : undefined);
|
|
40
|
+
|
|
41
|
+
// The look as a side may use it: every token held to its shape, everything
|
|
42
|
+
// else gone. A value that is not an object is no look at all.
|
|
43
|
+
export function sanitise(v: Json | undefined): Look {
|
|
44
|
+
if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) return {};
|
|
45
|
+
const l: Look = {};
|
|
46
|
+
const name = str(v.name, NAME);
|
|
47
|
+
if (name !== undefined) l.name = name;
|
|
48
|
+
const logo = str(v.logo, LOGO);
|
|
49
|
+
if (logo !== undefined) l.logo = logo;
|
|
50
|
+
for (const k of ['accent', 'background', 'foreground'] as const) {
|
|
51
|
+
const c = str(v[k], COLOUR);
|
|
52
|
+
if (c !== undefined) l[k] = c;
|
|
53
|
+
}
|
|
54
|
+
const font = str(v.font, FONT);
|
|
55
|
+
if (font !== undefined) l.font = font;
|
|
56
|
+
if (typeof v.radius === 'number' && Number.isFinite(v.radius) && v.radius >= 0 && v.radius <= 40) l.radius = Math.round(v.radius);
|
|
57
|
+
if (Array.isArray(v.order)) l.order = v.order.filter((x): x is string => typeof x === 'string');
|
|
58
|
+
if (v.asks !== null && typeof v.asks === 'object' && !Array.isArray(v.asks)) {
|
|
59
|
+
const asks: Record<string, Hint> = {};
|
|
60
|
+
for (const [ask, h] of Object.entries(v.asks as JsonObject)) {
|
|
61
|
+
if (h === null || typeof h !== 'object' || Array.isArray(h)) continue;
|
|
62
|
+
const hint: Hint = {};
|
|
63
|
+
const title = str(h.title, NAME);
|
|
64
|
+
if (title !== undefined) hint.title = title;
|
|
65
|
+
const icon = str(h.icon, ICON);
|
|
66
|
+
if (icon !== undefined) hint.icon = icon;
|
|
67
|
+
for (const k of ['readOnly', 'destructive', 'idempotent'] as const) if (typeof h[k] === 'boolean') hint[k] = h[k];
|
|
68
|
+
if (Object.keys(hint).length) asks[ask] = hint;
|
|
69
|
+
}
|
|
70
|
+
if (Object.keys(asks).length) l.asks = asks;
|
|
71
|
+
}
|
|
72
|
+
return l;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The one hint a side reads first: whether this ask is in the look at all.
|
|
76
|
+
export const hint = (l: Look | undefined, name: string): Hint => l?.asks?.[name] ?? {};
|
|
77
|
+
|
|
78
|
+
// A carrier's notes say which asks are a standing's, and how she looks:
|
|
79
|
+
// `standings: { id: { asks: [name], look? } }`. Every side reads them here.
|
|
80
|
+
export type Group = { asks?: string[]; look?: Json };
|
|
81
|
+
export function groups(bp: Blueprint | null): Record<string, Group> {
|
|
82
|
+
const n = bp?.notes;
|
|
83
|
+
const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
|
|
84
|
+
return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? (g as Record<string, Group>) : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The hint for one ask, hers or a standing's: a carried ask is looked up
|
|
88
|
+
// under its bare name in that standing's look.
|
|
89
|
+
export function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint {
|
|
90
|
+
for (const [id, g] of Object.entries(groups(bp))) {
|
|
91
|
+
if (!g.asks?.includes(name)) continue;
|
|
92
|
+
const prefix = `${id}-`;
|
|
93
|
+
return hint(sanitise(g.look), name.startsWith(prefix) ? name.slice(prefix.length) : name);
|
|
94
|
+
}
|
|
95
|
+
return hint(mine, name);
|
|
96
|
+
}
|