@onlyworlds/sdk 4.10.0 → 4.11.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/AGENTS.md CHANGED
@@ -40,6 +40,11 @@ against live data). The v1 surface (`OnlyWorldsClient`, the `ElementType` enum)
40
40
  - Images: `uploadImage(bytes)` returns `{ url, ... }`; set `url` as `image_url`. The bytes go
41
41
  to the edge with a single-use ticket from keel, never with the API key. `removeImage(urlOrKey)`
42
42
  takes one back (its uploader or the world's owner); it leaves `image_url` on elements as is.
43
+ - Lookup without a model (since 4.11.0): `buildWorldIndex(elements)` then
44
+ `lookupWorld(index, question)` ranks the elements a question is about (names, then kind, one
45
+ link step, then text); `renderElement(el, index)` is a compact card with links by name.
46
+ - Photos (browser only, since 4.11.0): `preparePhoto(blob)` shrinks to 2048 px and converts to
47
+ webp (JPEG where webp can't be encoded) before `uploadImage`.
43
48
  - World files (no account needed; since 4.10.0): `readWorldFile(bytes)` opens a `.zip` holding one
44
49
  world folder and returns `{ worldId, name, world, files: [{ path, data }] }`;
45
50
  `writeWorldFile(files, topDir)` makes one. Refusals throw `OwWorldFileError` with a `reason`.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ earlier history lives in git log only.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.11.0] — 2026-10-12
9
+
10
+ ### Added
11
+ - **World lookup.** `buildWorldIndex(elements)`, `lookupWorld(index, question, { limit })` and
12
+ `renderElement(element, index)` find the elements a plain-language question is about, in a
13
+ world held in memory: no model, no network, a few milliseconds. Ranking: names and short
14
+ forms first, then name words, supertype and subtype, one link step in both directions, then
15
+ other text and numbers (always below any name hit). Each hit says why it matched.
16
+ `renderElement` gives a compact text card with links by name, which is what a model reads
17
+ when one phrases the answer. Measured on two real worlds (Assembly's Lantern Ask trial):
18
+ the answer was in the first three results for 60 of 60 questions that name their subject
19
+ and 17 of 24 held-out questions that only describe it; this code returns the same top 8
20
+ as the trial's on all 100 questions. Names in any script are matched (Chinese and Japanese
21
+ only where spaces or punctuation separate the words: there is no word splitting). The question words
22
+ that steer the link step ("where", "rules", "allies") are English.
23
+ - **Photo preparation.** `preparePhoto(blob, { maxSide, quality, maxBytes })` shrinks and
24
+ converts a picture in the browser before `uploadImage`: longest side 2048 by default, webp at
25
+ quality 0.82, from the upload page's converter. A browser that cannot encode webp returns a
26
+ PNG when asked; the result's real type is checked, and a photo then falls back to JPEG while a
27
+ PNG stays PNG. When re-encoding would only make a file bigger, the original is kept.
28
+ `planPhoto` is the pure part (sizes and type order). Walked in Chromium, WebKit and Firefox
29
+ (a 4032×3024 photo became a 2048-wide webp of about 0.5 MB) and with webp encoding forced off.
30
+ Needs a browser; in Node it throws a `TypeError` that says so. `PHOTO_TYPES`,
31
+ `PHOTO_MAX_BYTES` are keel's accepted types and its 15 MB limit.
32
+
8
33
  ## [4.10.0] — 2026-10-11
9
34
 
10
35
  ### Added
package/README.md CHANGED
@@ -99,6 +99,8 @@ const image = await writer.uploadImage(file); // a Blob, File, ArrayBuffer or Ui
99
99
  await writer.patch('character', id, { image_url: image.url });
100
100
  ```
101
101
 
102
+ In a browser, shrink a phone photo first: `const photo = await preparePhoto(file)` gives a webp (or JPEG where the browser can't encode webp) of at most 2048 px on its longest side; upload `photo.blob`.
103
+
102
104
  The SDK asks the API for a single-use ticket, then sends the bytes straight to the upload host, which never sees your key. webp, png, jpeg or avif (no SVG or gif), up to 15 MB. Each upload counts toward the world's daily limit and the account's storage. To upload yourself (for a progress bar), call `createMediaTicket()` and POST the bytes to its `upload_url` with `Authorization: Bearer <ticket>`.
103
105
 
104
106
  ```typescript
@@ -135,6 +137,21 @@ try {
135
137
 
136
138
  The check is per element and the server never merges. Compare the copy you edited from with `err.current`: when the fields you changed are not the ones that moved, send again with `ifMatch: err.current.change_seq`; when the same field moved on both sides, ask the user which to keep. Without `ifMatch` a write is unconditional, as before.
137
139
 
140
+ ## Finding things in a world
141
+
142
+ When you hold a world's elements in memory (from a sync, or a world file), `lookupWorld` finds the ones a plain question is about, with no model and no network:
143
+
144
+ ```typescript
145
+ import { buildWorldIndex, lookupWorld, renderElement } from '@onlyworlds/sdk';
146
+
147
+ const index = buildWorldIndex(elements); // once per world; each element needs id and type
148
+ const hits = lookupWorld(index, 'who runs the mill in Ostvik?');
149
+ hits[0].element; hits[0].reasons; // best first, with why each matched
150
+ renderElement(hits[0].element, index); // a short text card, links by name
151
+ ```
152
+
153
+ Names come first, then supertype and subtype, then one link step in either direction, then the elements' other text. To have a model phrase an answer, give it the cards of the top hits and ask it to answer only from them.
154
+
138
155
  ## World files
139
156
 
140
157
  A world file is one `.zip` holding a whole world folder (`world.json` and `elements/<type>/*.json`, as Atlas keeps a world on disk), the way Atlas downloads and opens one. It needs no account and works in the browser and in Node.
package/dist/index.d.ts CHANGED
@@ -2526,6 +2526,205 @@ declare function readWorldFile(bytes: Uint8Array, opts?: OwReadWorldFileOptions)
2526
2526
  */
2527
2527
  declare function writeWorldFile(files: OwWorldFileEntry[], topDir: string): Uint8Array;
2528
2528
 
2529
+ /**
2530
+ * World lookup: find the elements a plain-language question is about, in a
2531
+ * world held in memory, with no model and no network. Ported from Assembly's
2532
+ * Lantern Ask trial (`dossiers/lantern-ask/lookup.mjs`, round 2), with the
2533
+ * same ranking, so the trial's measurements on two real worlds describe this
2534
+ * code. Pure: runs in a browser and in Node alike.
2535
+ *
2536
+ * const index = buildWorldIndex(elements); // API elements or a world folder's files
2537
+ * const hits = lookupWorld(index, question); // ranked, best first, each with its reasons
2538
+ * const text = renderElement(hits[0].element, index); // a compact text card, links by name
2539
+ *
2540
+ * Each element needs `id` and `type` (a world folder's files may lack `type`:
2541
+ * set it from the folder name), and usually `name`.
2542
+ *
2543
+ * LINKS: a field is a link when `FIELD_SCHEMA` says so for the element's type,
2544
+ * or when its value is an id-shaped string or an array of them. The second
2545
+ * rule is the trial's own, and it is kept because real data links outside the
2546
+ * schema's names: a world folder's pins and markers carry `map_id` and
2547
+ * `zone_id`, and extension fields (`x_*`) hold references. A link to an id
2548
+ * outside the set is kept as unresolved, so a card can say a link exists.
2549
+ *
2550
+ * RANKING (higher score first; ties: shorter name, then id):
2551
+ * 0. Question and names are normalised: lower case, diacritics stripped,
2552
+ * possessive 's dropped, "7,875" read as 7875; letters and digits of any
2553
+ * script are kept, anything else is a space. A content word has 3+
2554
+ * characters and is no English stopword; a word holding a non-Latin letter
2555
+ * always counts (a CJK name is often one or two characters). Words are
2556
+ * split on spaces and punctuation only, so a CJK name is found when the
2557
+ * question sets it apart that way.
2558
+ * 1. Name phrase: the whole name appears in the question -> 100 + 10 per word.
2559
+ * 2. Alias phrase: a short form appears -> 80 + 10 per word. Short forms: the
2560
+ * name without a leading "the"; the part before ":" or "("; for characters
2561
+ * also the part before " of " or "," and the first word.
2562
+ * 3. Name words: question words (3+ letters, not stopwords) equal to a name
2563
+ * word, or sharing a 5+ letter prefix with it -> 20 + 40 x the
2564
+ * rarity-weighted share of the name's words matched. Kept only when better
2565
+ * than 1-2.
2566
+ * 4. Kind: +15 when a question word matches the supertype or subtype (a word
2567
+ * the name already used does not count again).
2568
+ * 5. One link step from the 12 best direct hits, both directions: a linked-to
2569
+ * element scores 0.6 x the hit, a linking element 0.45 x; x1.5 when the
2570
+ * field fits a question word ("where" -> location fields, "rules" ->
2571
+ * primary_power, ...). Every reason is kept; the best score counts.
2572
+ * 6. Text: question words (and numbers) found in the element's other text and
2573
+ * number fields add 3 x their summed rarity. An element found only this
2574
+ * way scores under 19, so below every name hit.
2575
+ * 7. Map furniture (pin, marker, zone, map) x0.85, so a pin never outranks
2576
+ * its location; it carries no text.
2577
+ */
2578
+ /** What the lookup reads from an element: `id`, `type`, `name`, and any other fields. `OwElement` fits. */
2579
+ interface OwLookupElement {
2580
+ id: string;
2581
+ type: string;
2582
+ name?: string;
2583
+ [field: string]: unknown;
2584
+ }
2585
+ /** One link between two elements of the index, by field. */
2586
+ interface OwLookupLink {
2587
+ field: string;
2588
+ id: string;
2589
+ }
2590
+ /**
2591
+ * The index `lookupWorld` and `renderElement` read, built once per world by
2592
+ * `buildWorldIndex`. `byId` is the elements by id; the other tables are the
2593
+ * lookup's working state: read them if useful, but their shape may change.
2594
+ */
2595
+ interface OwWorldIndex<T extends OwLookupElement = OwLookupElement> {
2596
+ byId: ReadonlyMap<string, T>;
2597
+ /** Links each element holds to elements in the set. */
2598
+ out: ReadonlyMap<string, readonly OwLookupLink[]>;
2599
+ /** Links pointing at each element, with the holder's id. */
2600
+ inn: ReadonlyMap<string, readonly OwLookupLink[]>;
2601
+ /** Links each element holds to ids not in the set. */
2602
+ unresolved: ReadonlyMap<string, readonly OwLookupLink[]>;
2603
+ phrase: ReadonlyMap<string, readonly {
2604
+ id: string;
2605
+ kind: 'name' | 'alias';
2606
+ }[]>;
2607
+ tokenIds: ReadonlyMap<string, ReadonlySet<string>>;
2608
+ nameWords: ReadonlyMap<string, readonly string[]>;
2609
+ idf: ReadonlyMap<string, number>;
2610
+ textIds: ReadonlyMap<string, ReadonlySet<string>>;
2611
+ textIdf: ReadonlyMap<string, number>;
2612
+ }
2613
+ /** Why an element matched: by its name or a short form of it, by name words, by other text, or by one link. */
2614
+ type OwLookupReason = {
2615
+ kind: 'name' | 'alias' | 'word' | 'text';
2616
+ text: string;
2617
+ } | {
2618
+ kind: 'link';
2619
+ dir: 'out' | 'in';
2620
+ field: string;
2621
+ from: string | undefined;
2622
+ fromId: string;
2623
+ };
2624
+ /** One ranked result. `dir: 'out'` on a link reason: the hit `from` links to this element; `'in'`: this element links to it. */
2625
+ interface OwLookupHit<T extends OwLookupElement = OwLookupElement> {
2626
+ element: T;
2627
+ /** Rounded to one decimal; comparable within one lookup only. */
2628
+ score: number;
2629
+ reasons: OwLookupReason[];
2630
+ }
2631
+ interface OwLookupOptions {
2632
+ /** How many hits to return (default 8). */
2633
+ limit?: number;
2634
+ }
2635
+ interface OwRenderOptions {
2636
+ /** Description characters kept (default 500). */
2637
+ descMax?: number;
2638
+ /** Linked names listed per field (default 12). */
2639
+ linkMax?: number;
2640
+ /** Incoming names listed per type and field (default 10). */
2641
+ incomingMax?: number;
2642
+ }
2643
+ /**
2644
+ * Index a world's elements for `lookupWorld` and `renderElement`. Elements
2645
+ * without an `id` are left out; a repeated id keeps the last. Build it once
2646
+ * per world and again after the world changes.
2647
+ */
2648
+ declare function buildWorldIndex<T extends OwLookupElement>(elements: Iterable<T>): OwWorldIndex<T>;
2649
+ /**
2650
+ * The elements a question is most likely about, best first (`limit`, default
2651
+ * 8). Names rank first, then elements one link away from a named one, then
2652
+ * elements whose other text holds the question's words. An empty question
2653
+ * returns no hits.
2654
+ */
2655
+ declare function lookupWorld<T extends OwLookupElement>(index: OwWorldIndex<T>, question: string, { limit }?: OwLookupOptions): OwLookupHit<T>[];
2656
+ /**
2657
+ * A compact text card for one element, for a person or a model: name, type,
2658
+ * kind, description, its fields, its links by name (or how many point outside
2659
+ * the data), and what links to it.
2660
+ */
2661
+ declare function renderElement<T extends OwLookupElement>(element: T, index: OwWorldIndex<T>, { descMax, linkMax, incomingMax }?: OwRenderOptions): string;
2662
+
2663
+ /**
2664
+ * Photo preparation: make a picture from a phone's camera or files ready for
2665
+ * `uploadImage` (webp, png, jpeg or avif, at most 15 MB), in the browser,
2666
+ * before it leaves the device. Lifted from the upload page's converter
2667
+ * (upload.onlyworlds.com, Boss's): canvas, webp at quality 0.82, alpha kept.
2668
+ *
2669
+ * Two traps it handles:
2670
+ * - A browser that cannot encode webp hands back a PNG instead (Safari has
2671
+ * long done this), and a PNG of a photo is huge. The result's real type is
2672
+ * checked; when webp did not come back, a photo falls back to JPEG at the
2673
+ * same quality and a PNG source stays PNG (it may have transparency).
2674
+ * - Re-encoding an already compressed file at the same size can make it
2675
+ * bigger: then the original is kept, if its type is one keel accepts.
2676
+ *
2677
+ * `planPhoto` is the pure part (sizes and types), so it is tested anywhere;
2678
+ * `preparePhoto` needs a browser (`createImageBitmap` and a canvas).
2679
+ */
2680
+ /** The picture types keel's image upload accepts. */
2681
+ declare const PHOTO_TYPES: readonly ["image/webp", "image/png", "image/jpeg", "image/avif"];
2682
+ /** keel's limit for one picture. */
2683
+ declare const PHOTO_MAX_BYTES: number;
2684
+ interface OwPhotoOptions {
2685
+ /** The longest side in pixels, after shrinking. Default 2048: a notebook page stays readable and a phone upload stays small. */
2686
+ maxSide?: number;
2687
+ /** Encoder quality for webp and jpeg, 0 to 1. Default 0.82 (the media pipeline's q82). */
2688
+ quality?: number;
2689
+ /** Refuse a result over this many bytes. Default 15 MB, keel's limit. */
2690
+ maxBytes?: number;
2691
+ }
2692
+ interface OwPhotoPlan {
2693
+ width: number;
2694
+ height: number;
2695
+ /** 1 when no shrinking is needed. */
2696
+ scale: number;
2697
+ /** The types to try, in order; the first one the browser really produces wins. */
2698
+ targets: string[];
2699
+ }
2700
+ interface OwPreparedPhoto {
2701
+ blob: Blob;
2702
+ type: string;
2703
+ width: number;
2704
+ height: number;
2705
+ /** The input, for showing "4032×3024 · 3.1 MB → 2048×1536 webp · 410 KB". */
2706
+ original: {
2707
+ width: number;
2708
+ height: number;
2709
+ type: string;
2710
+ size: number;
2711
+ };
2712
+ /** True when the original bytes were kept unchanged. */
2713
+ kept: boolean;
2714
+ }
2715
+ /** Sizes and types for one picture: pure, no browser needed. */
2716
+ declare function planPhoto(source: {
2717
+ width: number;
2718
+ height: number;
2719
+ type: string;
2720
+ }, opts?: OwPhotoOptions): OwPhotoPlan;
2721
+ /**
2722
+ * Shrink and convert a picture for upload. Throws `TypeError` outside a
2723
+ * browser, for a file the browser cannot decode, or when the result is still
2724
+ * over `maxBytes`.
2725
+ */
2726
+ declare function preparePhoto(input: Blob, opts?: OwPhotoOptions): Promise<OwPreparedPhoto>;
2727
+
2529
2728
  /**
2530
2729
  * OnlyWorlds Token Management API Types
2531
2730
  *
@@ -2810,4 +3009,4 @@ declare class TokenResource {
2810
3009
  getEncryptionInfo(): Promise<EncryptionInfo>;
2811
3010
  }
2812
3011
 
2813
- export { type AccessKeyResponse, ELEMENT_FAMILIES, ELEMENT_ICONS, ELEMENT_LABELS, ELEMENT_SECTIONS, ELEMENT_TYPES, type ElementFamily, type ElementType, type EncryptionInfo, FAMILY_COLORS, FAMILY_ORDER, FIELD_SCHEMA, type FieldInfo, type FieldType, GameTier, type ListParams, ONLYWORLDS_VERSION, OwApiError, type OwAuthErrorCode, type OwBulkItem, type OwBulkItemResult, type OwBulkResponse, type OwChange, type OwChangesPage, type OwClientConfig, type OwElement, type OwElementBase, type OwErrorBody, type OwKeyKind, type OwLinkEdit, type OwMediaTicket, OwNetworkError, type OwPage, OwPreconditionFailedError, type OwReadWorldFile, type OwReadWorldFileOptions, type OwRemovalTicket, type OwRemovedImage, type OwUploadedImage, OwV2Client, type OwWorldFileEntry, OwWorldFileError, type OwWorldFileReason, type OwWorldMeta, type RevokeAllSessionsResponse, type RevokeSessionResponse, SPATIAL_TYPES, type SectionInfo, type TokenConsumeParams, type TokenConsumeResponse, TokenResource, type TokenStatus, type TokenTransport, WORLD_FILE_MAX_BYTES, WORLD_FILE_MAX_ENTRIES, detectKeyKind, elementColor, errorFromResponse, familyOf, getElementIcon, getElementLabel, isDemoKey, kindCanWrite, parseErrorEnvelope, pinExpectation, readWorldFile, writeWorldFile };
3012
+ export { type AccessKeyResponse, ELEMENT_FAMILIES, ELEMENT_ICONS, ELEMENT_LABELS, ELEMENT_SECTIONS, ELEMENT_TYPES, type ElementFamily, type ElementType, type EncryptionInfo, FAMILY_COLORS, FAMILY_ORDER, FIELD_SCHEMA, type FieldInfo, type FieldType, GameTier, type ListParams, ONLYWORLDS_VERSION, OwApiError, type OwAuthErrorCode, type OwBulkItem, type OwBulkItemResult, type OwBulkResponse, type OwChange, type OwChangesPage, type OwClientConfig, type OwElement, type OwElementBase, type OwErrorBody, type OwKeyKind, type OwLinkEdit, type OwLookupElement, type OwLookupHit, type OwLookupLink, type OwLookupOptions, type OwLookupReason, type OwMediaTicket, OwNetworkError, type OwPage, type OwPhotoOptions, type OwPhotoPlan, OwPreconditionFailedError, type OwPreparedPhoto, type OwReadWorldFile, type OwReadWorldFileOptions, type OwRemovalTicket, type OwRemovedImage, type OwRenderOptions, type OwUploadedImage, OwV2Client, type OwWorldFileEntry, OwWorldFileError, type OwWorldFileReason, type OwWorldIndex, type OwWorldMeta, PHOTO_MAX_BYTES, PHOTO_TYPES, type RevokeAllSessionsResponse, type RevokeSessionResponse, SPATIAL_TYPES, type SectionInfo, type TokenConsumeParams, type TokenConsumeResponse, TokenResource, type TokenStatus, type TokenTransport, WORLD_FILE_MAX_BYTES, WORLD_FILE_MAX_ENTRIES, buildWorldIndex, detectKeyKind, elementColor, errorFromResponse, familyOf, getElementIcon, getElementLabel, isDemoKey, kindCanWrite, lookupWorld, parseErrorEnvelope, pinExpectation, planPhoto, preparePhoto, readWorldFile, renderElement, writeWorldFile };
package/dist/index.js CHANGED
@@ -1467,6 +1467,436 @@ function writeWorldFile(files, topDir) {
1467
1467
  return zipSync(input);
1468
1468
  }
1469
1469
 
1470
+ // src/lookup.ts
1471
+ var STOP = new Set("a an the of in on at to for from by with and or is are was were be been who whom whose what which where when why how does do did doesn has have had it its this that these those there their they them he she his her him i me my we our you your about tell know any some all can could would should will not no yes than then so if into out up down over under one two own same other more most much many very just also only s t don isn".split(" "));
1472
+ var SPATIAL = /* @__PURE__ */ new Set(["pin", "marker", "zone", "map"]);
1473
+ var FIELD_HINTS = {
1474
+ where: ["location", "parent_location", "birthplace", "locations", "estates", "element_id"],
1475
+ live: ["location", "locations", "populations"],
1476
+ lives: ["location", "locations", "populations"],
1477
+ located: ["location", "parent_location"],
1478
+ location: ["location", "parent_location"],
1479
+ born: ["birthplace"],
1480
+ birthplace: ["birthplace"],
1481
+ species: ["species", "parent_species"],
1482
+ race: ["species"],
1483
+ people: ["species", "populations"],
1484
+ rules: ["primary_power", "governs", "operator"],
1485
+ rule: ["primary_power", "governs", "operator"],
1486
+ ruled: ["primary_power", "governs"],
1487
+ holds: ["primary_power", "governs"],
1488
+ realm: ["primary_power"],
1489
+ govern: ["primary_power", "governs"],
1490
+ governs: ["primary_power", "governs"],
1491
+ controls: ["primary_power", "operator"],
1492
+ culture: ["populations"],
1493
+ cultures: ["populations"],
1494
+ folk: ["populations"],
1495
+ ally: ["allies", "friends"],
1496
+ allies: ["allies", "friends"],
1497
+ allied: ["allies"],
1498
+ friends: ["friends"],
1499
+ friend: ["friends"],
1500
+ enemy: ["adversaries", "rivals"],
1501
+ enemies: ["adversaries", "rivals"],
1502
+ rival: ["rivals"],
1503
+ rivals: ["rivals"],
1504
+ fought: ["institutions"],
1505
+ war: ["institutions"],
1506
+ fight: ["institutions"],
1507
+ family: ["family"],
1508
+ house: ["family"],
1509
+ clan: ["family"],
1510
+ member: ["characters", "institutions", "family"],
1511
+ members: ["characters", "institutions", "family"],
1512
+ belong: ["characters", "institutions", "family"],
1513
+ belongs: ["characters", "institutions", "family"],
1514
+ ancestor: ["ancestors"],
1515
+ ancestors: ["ancestors"],
1516
+ region: ["parent_location"],
1517
+ part: ["parent_location", "parent_species"],
1518
+ parent: ["parent_location", "parent_species"]
1519
+ };
1520
+ var TEXT_SKIP = /* @__PURE__ */ new Set([
1521
+ "id",
1522
+ "name",
1523
+ "type",
1524
+ "image_url",
1525
+ "created_at",
1526
+ "updated_at",
1527
+ "local_updated_at",
1528
+ "server_updated_at",
1529
+ "world",
1530
+ "image_media_id",
1531
+ "element_type"
1532
+ ]);
1533
+ var RENDER_SKIP = /* @__PURE__ */ new Set([
1534
+ "id",
1535
+ "name",
1536
+ "description",
1537
+ "type",
1538
+ "supertype",
1539
+ "subtype",
1540
+ "image_url",
1541
+ "created_at",
1542
+ "updated_at",
1543
+ "local_updated_at",
1544
+ "server_updated_at",
1545
+ "world",
1546
+ "image_media_id"
1547
+ ]);
1548
+ var ID_SHAPE = /^[0-9a-f-]{32,36}$/i;
1549
+ var MARKS = new RegExp(`[${String.fromCharCode(768)}-${String.fromCharCode(879)}]`, "g");
1550
+ var POSSESSIVE = new RegExp(`['${String.fromCharCode(8217)}]s\\b`, "g");
1551
+ function normalize(s) {
1552
+ return String(s ?? "").normalize("NFD").replace(MARKS, "").toLowerCase().replace(POSSESSIVE, "").replace(/(\d),(\d{3})\b/g, "$1$2").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
1553
+ }
1554
+ var words = (s) => s ? s.split(" ") : [];
1555
+ var NON_LATIN = /[^\p{Script=Latin}\p{N}]/u;
1556
+ var isContent = (w) => w.length >= 3 && !STOP.has(w) || NON_LATIN.test(w);
1557
+ var schemaLinks;
1558
+ function linkFieldsOf(type) {
1559
+ if (!schemaLinks) {
1560
+ schemaLinks = /* @__PURE__ */ new Map();
1561
+ for (const [t, fields] of Object.entries(FIELD_SCHEMA)) {
1562
+ const s = /* @__PURE__ */ new Set();
1563
+ for (const [f, info] of Object.entries(fields)) if (info.type === "single_link" || info.type === "multi_link") s.add(f);
1564
+ schemaLinks.set(t, s);
1565
+ }
1566
+ }
1567
+ return typeof type === "string" ? schemaLinks.get(type) : void 0;
1568
+ }
1569
+ function aliasesOf(el) {
1570
+ const raw = String(el.name ?? "");
1571
+ const full = normalize(raw);
1572
+ const out = /* @__PURE__ */ new Set();
1573
+ const add = (s) => {
1574
+ const n = normalize(s);
1575
+ if (n && n !== full && words(n).some(isContent)) out.add(n);
1576
+ };
1577
+ if (/^the\s/i.test(raw)) add(raw.replace(/^the\s+/i, ""));
1578
+ if (/[:(]/.test(raw)) add(raw.split(/[:(]/)[0]);
1579
+ if (el.type === "character") {
1580
+ if (/\sof\s/i.test(raw)) add(raw.split(/\sof\s/i)[0]);
1581
+ if (raw.includes(",")) add(raw.split(",")[0]);
1582
+ const first = words(full)[0];
1583
+ if (first && isContent(first)) add(first);
1584
+ }
1585
+ return [...out];
1586
+ }
1587
+ function buildWorldIndex(elements) {
1588
+ const byId = /* @__PURE__ */ new Map();
1589
+ for (const el of elements) if (el && el.id) byId.set(el.id, el);
1590
+ const out = /* @__PURE__ */ new Map(), inn = /* @__PURE__ */ new Map();
1591
+ const unresolved = /* @__PURE__ */ new Map();
1592
+ const phrase = /* @__PURE__ */ new Map();
1593
+ const tokenIds = /* @__PURE__ */ new Map();
1594
+ const nameWords = /* @__PURE__ */ new Map();
1595
+ const textIds = /* @__PURE__ */ new Map();
1596
+ const pushTo = (m, k, v) => {
1597
+ if (!m.has(k)) m.set(k, []);
1598
+ m.get(k).push(v);
1599
+ };
1600
+ for (const el of byId.values()) {
1601
+ const schema = linkFieldsOf(el.type);
1602
+ const linkFields = /* @__PURE__ */ new Set();
1603
+ for (const [field, v] of Object.entries(el)) {
1604
+ if (field === "id") continue;
1605
+ const vals = Array.isArray(v) ? v : [v];
1606
+ if (!vals.length) continue;
1607
+ if (schema?.has(field)) {
1608
+ const ids = vals.filter((x) => typeof x === "string" && x !== "");
1609
+ if (!ids.length) continue;
1610
+ linkFields.add(field);
1611
+ for (const id of ids) {
1612
+ if (byId.has(id)) {
1613
+ pushTo(out, el.id, { field, id });
1614
+ pushTo(inn, id, { field, id: el.id });
1615
+ } else pushTo(unresolved, el.id, { field, id });
1616
+ }
1617
+ continue;
1618
+ }
1619
+ if (!vals.every((x) => typeof x === "string" && ID_SHAPE.test(x))) continue;
1620
+ for (const id of vals) {
1621
+ if (byId.has(id)) {
1622
+ pushTo(out, el.id, { field, id });
1623
+ pushTo(inn, id, { field, id: el.id });
1624
+ } else pushTo(unresolved, el.id, { field, id });
1625
+ }
1626
+ }
1627
+ if (!SPATIAL.has(el.type)) {
1628
+ const tw = /* @__PURE__ */ new Set();
1629
+ for (const [field, v] of Object.entries(el)) {
1630
+ if (TEXT_SKIP.has(field) || linkFields.has(field) || field.startsWith("x_") || field.startsWith("atlas_")) continue;
1631
+ const vals = Array.isArray(v) ? v : [v];
1632
+ for (const x of vals) {
1633
+ if (typeof x === "number" && Number.isFinite(x)) tw.add(String(x));
1634
+ else if (typeof x === "string" && x && !ID_SHAPE.test(x)) {
1635
+ for (const w of words(normalize(x))) if (isContent(w) || /^\d+$/.test(w)) tw.add(w);
1636
+ }
1637
+ }
1638
+ }
1639
+ for (const w of tw) {
1640
+ if (!textIds.has(w)) textIds.set(w, /* @__PURE__ */ new Set());
1641
+ textIds.get(w).add(el.id);
1642
+ }
1643
+ }
1644
+ const full = normalize(el.name);
1645
+ if (!full) continue;
1646
+ pushTo(phrase, full, { id: el.id, kind: "name" });
1647
+ for (const a of aliasesOf(el)) pushTo(phrase, a, { id: el.id, kind: "alias" });
1648
+ const cw = [...new Set(words(full).filter(isContent))];
1649
+ nameWords.set(el.id, cw);
1650
+ for (const w of cw) {
1651
+ if (!tokenIds.has(w)) tokenIds.set(w, /* @__PURE__ */ new Set());
1652
+ tokenIds.get(w).add(el.id);
1653
+ }
1654
+ }
1655
+ const N = byId.size || 1;
1656
+ const idf = /* @__PURE__ */ new Map();
1657
+ for (const [w, ids] of tokenIds) idf.set(w, Math.log(1 + N / ids.size));
1658
+ const textIdf = /* @__PURE__ */ new Map();
1659
+ for (const [w, ids] of textIds) textIdf.set(w, Math.log(1 + N / ids.size));
1660
+ return { byId, out, inn, unresolved, phrase, tokenIds, nameWords, idf, textIds, textIdf };
1661
+ }
1662
+ function similarWords(w, vocab) {
1663
+ const res = [];
1664
+ if (vocab.has(w)) res.push(w);
1665
+ if (w.length >= 5 && !/^\d+$/.test(w)) {
1666
+ for (const t of vocab.keys()) {
1667
+ if (t === w || t.length < 5) continue;
1668
+ const p = Math.min(w.length, t.length);
1669
+ if (p >= 5 && Math.abs(w.length - t.length) <= 3 && (t.startsWith(w) || w.startsWith(t))) res.push(t);
1670
+ }
1671
+ }
1672
+ return res;
1673
+ }
1674
+ function lookupWorld(index, question, { limit = 8 } = {}) {
1675
+ const seeds = 12;
1676
+ const q = words(normalize(question));
1677
+ const qSet = new Set(q);
1678
+ const cand = /* @__PURE__ */ new Map();
1679
+ const bump = (id, score, reason, direct) => {
1680
+ const c = cand.get(id) || { score: 0, direct: 0, reasons: [] };
1681
+ c.reasons.push(reason);
1682
+ if (score > c.score) c.score = score;
1683
+ if (direct && score > c.direct) c.direct = score;
1684
+ cand.set(id, c);
1685
+ };
1686
+ const qUsed = /* @__PURE__ */ new Map();
1687
+ const useWords = (id, ws) => {
1688
+ if (!qUsed.has(id)) qUsed.set(id, /* @__PURE__ */ new Set());
1689
+ for (const w of ws) qUsed.get(id).add(w);
1690
+ };
1691
+ for (let n = Math.min(8, q.length); n >= 1; n--) {
1692
+ for (let i = 0; i + n <= q.length; i++) {
1693
+ const g = q.slice(i, i + n);
1694
+ if (n === 1 && !isContent(g[0])) continue;
1695
+ const hits = index.phrase.get(g.join(" "));
1696
+ if (!hits) continue;
1697
+ for (const h of hits) {
1698
+ const base = h.kind === "name" ? 100 : 80;
1699
+ bump(h.id, base + 10 * n, { kind: h.kind, text: g.join(" ") }, true);
1700
+ useWords(h.id, g);
1701
+ }
1702
+ }
1703
+ }
1704
+ const matched = /* @__PURE__ */ new Map();
1705
+ for (const w of qSet) {
1706
+ if (!isContent(w)) continue;
1707
+ for (const t of similarWords(w, index.tokenIds)) {
1708
+ for (const id of index.tokenIds.get(t)) {
1709
+ if (!matched.has(id)) matched.set(id, /* @__PURE__ */ new Set());
1710
+ matched.get(id).add(t);
1711
+ useWords(id, [w]);
1712
+ }
1713
+ }
1714
+ }
1715
+ for (const [id, ws] of matched) {
1716
+ const nw = index.nameWords.get(id) || [];
1717
+ const tot = nw.reduce((s, w) => s + index.idf.get(w), 0) || 1;
1718
+ const got = [...ws].reduce((s, w) => s + index.idf.get(w), 0);
1719
+ const score = 20 + 40 * (got / tot);
1720
+ const c = cand.get(id);
1721
+ if (!c || c.direct < score) bump(id, score, { kind: "word", text: [...ws].join(" ") }, true);
1722
+ }
1723
+ const kindBonus = (id) => {
1724
+ const el = index.byId.get(id);
1725
+ const kw = new Set(words(normalize(`${el.supertype ?? ""} ${el.subtype ?? ""}`)).filter(isContent));
1726
+ const used = qUsed.get(id);
1727
+ for (const w of qSet) {
1728
+ if (used?.has(w)) continue;
1729
+ if (kw.has(w) || [...kw].some((k) => k.length >= 5 && w.length >= 5 && (k.startsWith(w) || w.startsWith(k)))) {
1730
+ useWords(id, [w]);
1731
+ return 15;
1732
+ }
1733
+ }
1734
+ return 0;
1735
+ };
1736
+ for (const [id, c] of cand) {
1737
+ const b = kindBonus(id);
1738
+ if (b) {
1739
+ c.score += b;
1740
+ c.direct += b;
1741
+ c.kinded = true;
1742
+ }
1743
+ }
1744
+ const hintFields = /* @__PURE__ */ new Set();
1745
+ for (const w of qSet) for (const f of FIELD_HINTS[w] || []) hintFields.add(f);
1746
+ const seedList = [...cand.entries()].filter(([, c]) => c.direct > 0).sort((a, b) => b[1].direct - a[1].direct).slice(0, seeds);
1747
+ for (const [sid, sc] of seedList) {
1748
+ const sname = index.byId.get(sid).name;
1749
+ for (const { field, id } of index.out.get(sid) || []) {
1750
+ const m = hintFields.has(field) ? 1.5 : 1;
1751
+ bump(id, sc.direct * 0.6 * m, { kind: "link", dir: "out", field, from: sname, fromId: sid }, false);
1752
+ }
1753
+ for (const { field, id } of index.inn.get(sid) || []) {
1754
+ const m = hintFields.has(field) ? 1.5 : 1;
1755
+ bump(id, sc.direct * 0.45 * m, { kind: "link", dir: "in", field, from: sname, fromId: sid }, false);
1756
+ }
1757
+ }
1758
+ for (const [id, c] of cand) {
1759
+ if (!c.kinded) {
1760
+ const b = kindBonus(id);
1761
+ if (b && c.direct === 0) c.score += b;
1762
+ }
1763
+ }
1764
+ const textHits = /* @__PURE__ */ new Map();
1765
+ for (const w of qSet) {
1766
+ if (!isContent(w) && !/^\d+$/.test(w)) continue;
1767
+ for (const t of similarWords(w, index.textIds)) {
1768
+ const f = index.textIdf.get(t);
1769
+ for (const id of index.textIds.get(t)) {
1770
+ if (!textHits.has(id)) textHits.set(id, /* @__PURE__ */ new Map());
1771
+ const m = textHits.get(id);
1772
+ if (!((m.get(w) ?? NaN) >= f)) m.set(w, f);
1773
+ }
1774
+ }
1775
+ }
1776
+ for (const [id, m] of textHits) {
1777
+ const used = qUsed.get(id);
1778
+ const ws = [...m.keys()].filter((w) => !used?.has(w));
1779
+ if (!ws.length) continue;
1780
+ const raw = 3 * ws.reduce((s, w) => s + m.get(w), 0);
1781
+ const reason = { kind: "text", text: ws.join(" ") };
1782
+ const c = cand.get(id);
1783
+ if (c) {
1784
+ c.score += raw;
1785
+ c.reasons.push(reason);
1786
+ } else cand.set(id, { score: 19 * (1 - Math.exp(-raw / 30)), direct: 0, reasons: [reason] });
1787
+ }
1788
+ for (const [id, c] of cand) if (SPATIAL.has(index.byId.get(id).type)) c.score *= 0.85;
1789
+ return [...cand.entries()].map(([id, c]) => ({ element: index.byId.get(id), score: Math.round(c.score * 10) / 10, reasons: c.reasons })).sort((a, b) => b.score - a.score || String(a.element.name).length - String(b.element.name).length || (a.element.id < b.element.id ? -1 : 1)).slice(0, limit);
1790
+ }
1791
+ function renderElement(element, index, { descMax = 500, linkMax = 12, incomingMax = 10 } = {}) {
1792
+ const el = element;
1793
+ const name = (id) => index.byId.get(id)?.name ?? "?";
1794
+ const kind = [el.supertype, el.subtype].filter((x) => x && x !== "undefined").join(" / ");
1795
+ const lines = [`${el.name} (${el.type}${kind ? `; ${kind}` : ""})`];
1796
+ if (el.description) {
1797
+ const d = String(el.description);
1798
+ lines.push(`description: ${d.length > descMax ? d.slice(0, descMax) + "..." : d}`);
1799
+ }
1800
+ const outBy = /* @__PURE__ */ new Map();
1801
+ for (const { field, id } of index.out.get(el.id) || []) {
1802
+ if (!outBy.has(field)) outBy.set(field, []);
1803
+ outBy.get(field).push(name(id));
1804
+ }
1805
+ const unres = /* @__PURE__ */ new Map();
1806
+ for (const { field } of index.unresolved.get(el.id) || []) unres.set(field, (unres.get(field) || 0) + 1);
1807
+ for (const [field, v] of Object.entries(el)) {
1808
+ if (RENDER_SKIP.has(field) || field.startsWith("x_") || field.startsWith("atlas_")) continue;
1809
+ if (outBy.has(field) || unres.has(field)) {
1810
+ const names = outBy.get(field) || [];
1811
+ let s = names.slice(0, linkMax).join(", ");
1812
+ if (names.length > linkMax) s += ` (+${names.length - linkMax} more)`;
1813
+ const u = unres.get(field);
1814
+ if (u) s += `${s ? "; " : ""}${u} linked element(s) not present in the world data`;
1815
+ lines.push(`${field}: ${s}`);
1816
+ continue;
1817
+ }
1818
+ if (v === null || v === void 0 || v === "" || Array.isArray(v) && !v.length || typeof v === "object" && !Array.isArray(v)) continue;
1819
+ lines.push(`${field}: ${Array.isArray(v) ? v.join(", ") : v}`);
1820
+ }
1821
+ const inBy = /* @__PURE__ */ new Map();
1822
+ for (const { field, id } of index.inn.get(el.id) || []) {
1823
+ const src = index.byId.get(id);
1824
+ if (SPATIAL.has(src.type)) continue;
1825
+ const k = `${src.type}.${field}`;
1826
+ if (!inBy.has(k)) inBy.set(k, []);
1827
+ inBy.get(k).push(src.name);
1828
+ }
1829
+ for (const [k, names] of inBy) {
1830
+ let s = names.slice(0, incomingMax).join(", ");
1831
+ if (names.length > incomingMax) s += ` (+${names.length - incomingMax} more)`;
1832
+ lines.push(`linked from (${k}): ${s}`);
1833
+ }
1834
+ return lines.join("\n");
1835
+ }
1836
+
1837
+ // src/photo.ts
1838
+ var PHOTO_TYPES = ["image/webp", "image/png", "image/jpeg", "image/avif"];
1839
+ var PHOTO_MAX_BYTES = 15 * 1024 * 1024;
1840
+ function planPhoto(source, opts = {}) {
1841
+ const maxSide = opts.maxSide ?? 2048;
1842
+ if (!(maxSide > 0)) throw new TypeError("preparePhoto: maxSide must be a positive number");
1843
+ if (!(source.width > 0 && source.height > 0)) throw new TypeError("preparePhoto: the picture has no size");
1844
+ const scale = Math.min(1, maxSide / Math.max(source.width, source.height));
1845
+ const width = Math.max(1, Math.round(source.width * scale));
1846
+ const height = Math.max(1, Math.round(source.height * scale));
1847
+ const fallback = source.type === "image/png" ? "image/png" : "image/jpeg";
1848
+ return { width, height, scale, targets: ["image/webp", fallback] };
1849
+ }
1850
+ function makeCanvas(width, height) {
1851
+ const g = globalThis;
1852
+ if (g.OffscreenCanvas) {
1853
+ const canvas = new g.OffscreenCanvas(width, height);
1854
+ return { canvas, encode: (type, quality) => canvas.convertToBlob({ type, quality }).catch(() => null) };
1855
+ }
1856
+ if (g.document) {
1857
+ const canvas = g.document.createElement("canvas");
1858
+ canvas.width = width;
1859
+ canvas.height = height;
1860
+ return { canvas, encode: (type, quality) => new Promise((res) => canvas.toBlob(res, type, quality)) };
1861
+ }
1862
+ throw new TypeError("preparePhoto needs a browser: no canvas here");
1863
+ }
1864
+ async function preparePhoto(input, opts = {}) {
1865
+ const quality = opts.quality ?? 0.82;
1866
+ const maxBytes = opts.maxBytes ?? PHOTO_MAX_BYTES;
1867
+ const decode = globalThis.createImageBitmap;
1868
+ if (!decode) throw new TypeError("preparePhoto needs a browser: no createImageBitmap here");
1869
+ let bitmap;
1870
+ try {
1871
+ bitmap = await decode(input, { imageOrientation: "from-image" });
1872
+ } catch {
1873
+ throw new TypeError("preparePhoto: this browser cannot read that picture");
1874
+ }
1875
+ const original = { width: bitmap.width, height: bitmap.height, type: input.type, size: input.size };
1876
+ try {
1877
+ const plan = planPhoto(original, opts);
1878
+ const { canvas, encode } = makeCanvas(plan.width, plan.height);
1879
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, plan.width, plan.height);
1880
+ let made = null;
1881
+ for (const type of plan.targets) {
1882
+ const b = await encode(type, quality);
1883
+ if (b && b.type === type) {
1884
+ made = b;
1885
+ break;
1886
+ }
1887
+ }
1888
+ if (!made) throw new TypeError("preparePhoto: this browser could not encode the picture");
1889
+ const keepOriginal = plan.scale === 1 && made.size >= input.size && PHOTO_TYPES.includes(input.type);
1890
+ const out = keepOriginal ? { blob: input, type: input.type, width: original.width, height: original.height, kept: true } : { blob: made, type: made.type, width: plan.width, height: plan.height, kept: false };
1891
+ if (out.blob.size > maxBytes) {
1892
+ throw new TypeError(`preparePhoto: still ${Math.ceil(out.blob.size / 1048576)} MB after shrinking; try a smaller maxSide`);
1893
+ }
1894
+ return { ...out, original };
1895
+ } finally {
1896
+ bitmap.close?.();
1897
+ }
1898
+ }
1899
+
1470
1900
  // src/token-resource.ts
1471
1901
  var TokenResource = class {
1472
1902
  constructor(client) {
@@ -1653,10 +2083,13 @@ export {
1653
2083
  OwPreconditionFailedError,
1654
2084
  OwV2Client,
1655
2085
  OwWorldFileError,
2086
+ PHOTO_MAX_BYTES,
2087
+ PHOTO_TYPES,
1656
2088
  SPATIAL_TYPES,
1657
2089
  TokenResource,
1658
2090
  WORLD_FILE_MAX_BYTES,
1659
2091
  WORLD_FILE_MAX_ENTRIES,
2092
+ buildWorldIndex,
1660
2093
  detectKeyKind,
1661
2094
  elementColor,
1662
2095
  errorFromResponse,
@@ -1665,8 +2098,12 @@ export {
1665
2098
  getElementLabel,
1666
2099
  isDemoKey,
1667
2100
  kindCanWrite,
2101
+ lookupWorld,
1668
2102
  parseErrorEnvelope,
1669
2103
  pinExpectation,
2104
+ planPhoto,
2105
+ preparePhoto,
1670
2106
  readWorldFile,
2107
+ renderElement,
1671
2108
  writeWorldFile
1672
2109
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlyworlds/sdk",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
4
4
  "description": "TypeScript SDK for the OnlyWorlds API - build world-building applications with type safety",
5
5
  "type": "module",
6
6
  "exports": {