@onlyworlds/sdk 4.9.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,15 @@ 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`.
48
+ - World files (no account needed; since 4.10.0): `readWorldFile(bytes)` opens a `.zip` holding one
49
+ world folder and returns `{ worldId, name, world, files: [{ path, data }] }`;
50
+ `writeWorldFile(files, topDir)` makes one. Refusals throw `OwWorldFileError` with a `reason`.
51
+ `world.json`'s `api` block (link and key) is removed both ways.
43
52
  - Ids: the client mints **UUIDv7** on an id-less `create` (since 4.2.0; keel mints v7 too).
44
53
  A v4 or v7 id you supply is accepted. **Never sort elements by id**: worlds mix v7, v4
45
54
  and legacy `06x…` ids (nibble 7 too, but seconds-first). For creation order use
package/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ 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
+
33
+ ## [4.10.0] — 2026-10-11
34
+
35
+ ### Added
36
+ - **World files.** `readWorldFile(bytes, { maxBytes, maxEntries, skip })` opens a `.zip` that
37
+ holds one world folder (Folder Format §4.1, a portable copy) and returns
38
+ `{ worldId, name, world, files }`; `writeWorldFile(files, topDir)` makes one. Bytes in,
39
+ bytes out: no DOM or file system, so it runs in the browser and in Node. Absorbed from
40
+ Atlas's reader (Temper's), so Atlas, Lantern and scripts open one file the same way.
41
+ The reader refuses before returning anything, with **`OwWorldFileError`** and a `reason`:
42
+ `unsafe_path` (climbs out, absolute, a drive, a backslash, an empty part, a path that is
43
+ both a file and a folder), `too_big` / `too_many` (1 GiB and 200,000 files uncompressed,
44
+ `WORLD_FILE_MAX_BYTES` / `WORLD_FILE_MAX_ENTRIES`; the inflated bytes are counted again,
45
+ since a declared size can lie), `not_a_world` (no `world.json`, unreadable, no string
46
+ `id`), `more_than_one` (two worlds, loose files beside the folder, a nested world.json),
47
+ `not_openable` (not a zip). Archive junk (`__MACOSX`, `.DS_Store`, `Thumbs.db`) is
48
+ dropped. `world.json`'s `api` (the link and its key) is removed on read and on write; a
49
+ `world.json` without one keeps its exact bytes. The writer refuses a `world.json` that will
50
+ not parse rather than zip it raw. Every world in a real Atlas folder (15, up to 3,832
51
+ files) round-trips byte for byte.
52
+ - **First runtime dependency**: `fflate` (zip, no dependencies of its own, the one Atlas
53
+ uses). Tree-shaken away for code that never calls the world-file functions.
54
+
8
55
  ## [4.9.0] — 2026-10-11
9
56
 
10
57
  ### 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,37 @@ 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
+
155
+ ## World files
156
+
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.
158
+
159
+ ```typescript
160
+ import { readWorldFile, writeWorldFile, OwWorldFileError } from '@onlyworlds/sdk';
161
+
162
+ const world = readWorldFile(bytes); // a Uint8Array
163
+ world.worldId; world.name; world.world; // world.json, parsed
164
+ world.files; // [{ path, data }], relative to the world's folder, sorted
165
+
166
+ const zip = writeWorldFile(world.files, 'hollow-reach-0192a000'); // one top-level folder
167
+ ```
168
+
169
+ The reader refuses a file before returning anything: a path that could land outside the folder, more than 1 GiB or 200,000 files uncompressed (`maxBytes`, `maxEntries` to change them), no `world.json` with an `id`, or more than one world. Check `err.reason` on the `OwWorldFileError` it throws. The world's OnlyWorlds link and key (`world.json`'s `api`) never travel: both functions remove them. A tool's own folder (`.atlas/` and the like) comes back like any other file; pass `skip: path => …` to leave files out.
170
+
138
171
  ## Errors
139
172
 
140
173
  Every non-2xx response throws `OwApiError` with the API's envelope: `.status`, `.type`, `.code`, `.param` (the field that failed) and `.docUrl`, which is worth showing to your users. A transport failure throws `OwNetworkError`. `err.isValidationError` covers the common case. A write sent with `ifMatch` that found the element moved throws `OwPreconditionFailedError` (412, `err.isPreconditionFailed`), an `OwApiError` that also carries `current`.
package/dist/index.d.ts CHANGED
@@ -2459,6 +2459,272 @@ declare function getElementIcon(type: string): string;
2459
2459
  /** Plural display label for an element type (e.g. phenomenon → "Phenomena"). */
2460
2460
  declare function getElementLabel(elementType: ElementType): string;
2461
2461
 
2462
+ /**
2463
+ * The world file: one `.zip` holding one whole world as a portable copy
2464
+ * (Folder Format spec §4.1, "a portable copy"), and the reader that opens one
2465
+ * back. Absorbed from Atlas's `src/store/world-file.ts` (Temper's, Atlas
2466
+ * 1.17), so Atlas, Lantern and any script read one file the same way.
2467
+ *
2468
+ * THE FILE: one top-level folder holding every file of the world as it sits
2469
+ * on disk, except the OnlyWorlds link and its key (`world.json` without `api`).
2470
+ * JSON is deflated, everything else stored. A world.json at the root, with no
2471
+ * folder, reads the same.
2472
+ *
2473
+ * THE READER takes bytes and returns bytes: no DOM, `File` or file system, so
2474
+ * it runs in a browser and in Node alike. It refuses, before returning
2475
+ * anything: a path that climbs out, is absolute, names a drive, holds a
2476
+ * backslash or an empty part, or is both a file and a folder; a declared size
2477
+ * or a file count past the budget (and the inflated bytes are counted again,
2478
+ * since a declared size can lie); no world, more than one world, files
2479
+ * outside the world's folder, a world.json nested inside the world; and a
2480
+ * world.json that will not parse or has no string `id`. Archive junk
2481
+ * (`__MACOSX`, `.DS_Store`, `Thumbs.db`) is dropped.
2482
+ *
2483
+ * A tool's own `.<tool>/` folder is the tool's (spec §4): this module knows
2484
+ * none of them. A caller that leaves some of its files out passes `skip`.
2485
+ */
2486
+ /** What one world file may hold, uncompressed: Atlas's budget, the writer's and the reader's alike. */
2487
+ declare const WORLD_FILE_MAX_BYTES: number;
2488
+ declare const WORLD_FILE_MAX_ENTRIES = 200000;
2489
+ type OwWorldFileReason = 'unsafe_path' | 'too_big' | 'too_many' | 'not_a_world' | 'more_than_one' | 'not_openable';
2490
+ /** A world file was refused; nothing was returned. `reason` says why, for a tool to word its own message. */
2491
+ declare class OwWorldFileError extends Error {
2492
+ readonly reason: OwWorldFileReason;
2493
+ constructor(reason: OwWorldFileReason, message?: string);
2494
+ }
2495
+ interface OwWorldFileEntry {
2496
+ /** Relative to the world's folder, `/`-separated. */
2497
+ path: string;
2498
+ data: Uint8Array;
2499
+ }
2500
+ interface OwReadWorldFile {
2501
+ worldId: string;
2502
+ /** `world.json`'s `name` as the file has it; `''` when it has none. */
2503
+ name: string;
2504
+ /** `world.json`, parsed, without `api`. */
2505
+ world: Record<string, unknown>;
2506
+ /** The world's files, relative to its folder, `world.json` included (without `api`), sorted by path. */
2507
+ files: OwWorldFileEntry[];
2508
+ }
2509
+ interface OwReadWorldFileOptions {
2510
+ maxBytes?: number;
2511
+ maxEntries?: number;
2512
+ /** Leave a file out of `files` (a path relative to the world's folder). It still counts against the budget. */
2513
+ skip?: (path: string) => boolean;
2514
+ }
2515
+ /**
2516
+ * Read a world file. Throws `OwWorldFileError` saying why, having returned
2517
+ * nothing; the caller writes only what this returns.
2518
+ */
2519
+ declare function readWorldFile(bytes: Uint8Array, opts?: OwReadWorldFileOptions): OwReadWorldFile;
2520
+ /**
2521
+ * Write a world file: every file under one top-level folder `topDir`, JSON
2522
+ * deflated, everything else stored, `world.json` without `api`. Refuses
2523
+ * (`OwWorldFileError`) an unsafe path or `topDir`, a duplicate path, and a
2524
+ * world.json that is missing, will not parse (it may hold a key, so it is
2525
+ * never zipped raw) or has no `id`.
2526
+ */
2527
+ declare function writeWorldFile(files: OwWorldFileEntry[], topDir: string): Uint8Array;
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
+
2462
2728
  /**
2463
2729
  * OnlyWorlds Token Management API Types
2464
2730
  *
@@ -2743,4 +3009,4 @@ declare class TokenResource {
2743
3009
  getEncryptionInfo(): Promise<EncryptionInfo>;
2744
3010
  }
2745
3011
 
2746
- 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 OwRemovalTicket, type OwRemovedImage, type OwUploadedImage, OwV2Client, type OwWorldMeta, type RevokeAllSessionsResponse, type RevokeSessionResponse, SPATIAL_TYPES, type SectionInfo, type TokenConsumeParams, type TokenConsumeResponse, TokenResource, type TokenStatus, type TokenTransport, detectKeyKind, elementColor, errorFromResponse, familyOf, getElementIcon, getElementLabel, isDemoKey, kindCanWrite, parseErrorEnvelope, pinExpectation };
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
@@ -1333,6 +1333,570 @@ function getElementLabel(elementType) {
1333
1333
  return ELEMENT_LABELS[elementType];
1334
1334
  }
1335
1335
 
1336
+ // src/world-file.ts
1337
+ import { unzipSync, zipSync } from "fflate";
1338
+ var WORLD_FILE = "world.json";
1339
+ var WORLD_FILE_MAX_BYTES = 1024 ** 3;
1340
+ var WORLD_FILE_MAX_ENTRIES = 2e5;
1341
+ var MESSAGES = {
1342
+ unsafe_path: "The world file holds a path that cannot be written safely.",
1343
+ too_big: "The world file is bigger than the budget allows.",
1344
+ too_many: "The world file holds more files than the budget allows.",
1345
+ not_a_world: "The file is not a world: it has no readable world.json with an id.",
1346
+ more_than_one: "The file holds more than one world, or files outside the world's folder.",
1347
+ not_openable: "The file is not a zip that can be read."
1348
+ };
1349
+ var OwWorldFileError = class extends Error {
1350
+ constructor(reason, message = MESSAGES[reason]) {
1351
+ super(message);
1352
+ this.name = "OwWorldFileError";
1353
+ this.reason = reason;
1354
+ }
1355
+ };
1356
+ function isSafePath(path) {
1357
+ if (!path || path.includes("\\") || path.startsWith("/") || /^[a-zA-Z]:/.test(path)) return false;
1358
+ return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
1359
+ }
1360
+ function isJunk(path) {
1361
+ return path.split("/").some((seg) => seg === "__MACOSX" || seg === ".DS_Store" || seg === "Thumbs.db");
1362
+ }
1363
+ function parseWorld(data) {
1364
+ let meta;
1365
+ try {
1366
+ meta = JSON.parse(new TextDecoder().decode(data));
1367
+ } catch {
1368
+ throw new OwWorldFileError("not_a_world");
1369
+ }
1370
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) throw new OwWorldFileError("not_a_world");
1371
+ const id = meta.id;
1372
+ if (typeof id !== "string" || !id.trim()) throw new OwWorldFileError("not_a_world");
1373
+ return meta;
1374
+ }
1375
+ function withoutLink(data, meta) {
1376
+ if (!("api" in meta)) return { data, world: meta };
1377
+ const { api: _link, ...rest } = meta;
1378
+ return { data: new TextEncoder().encode(JSON.stringify(rest, null, 2) + "\n"), world: rest };
1379
+ }
1380
+ function readWorldFile(bytes, opts = {}) {
1381
+ const maxBytes = opts.maxBytes ?? WORLD_FILE_MAX_BYTES;
1382
+ const maxEntries = opts.maxEntries ?? WORLD_FILE_MAX_ENTRIES;
1383
+ let declared = 0;
1384
+ let count = 0;
1385
+ let refusal = null;
1386
+ let raw;
1387
+ try {
1388
+ raw = unzipSync(bytes, {
1389
+ filter: (file) => {
1390
+ if (refusal) return false;
1391
+ if (file.name.endsWith("/")) return false;
1392
+ if (!isSafePath(file.name)) {
1393
+ refusal = "unsafe_path";
1394
+ return false;
1395
+ }
1396
+ if (isJunk(file.name)) return false;
1397
+ count++;
1398
+ declared += file.originalSize;
1399
+ if (count > maxEntries) {
1400
+ refusal = "too_many";
1401
+ return false;
1402
+ }
1403
+ if (declared > maxBytes) {
1404
+ refusal = "too_big";
1405
+ return false;
1406
+ }
1407
+ return true;
1408
+ }
1409
+ });
1410
+ } catch {
1411
+ throw new OwWorldFileError(refusal ?? "not_openable");
1412
+ }
1413
+ if (refusal) throw new OwWorldFileError(refusal);
1414
+ const names = Object.keys(raw);
1415
+ if (names.reduce((n, k) => n + raw[k].length, 0) > maxBytes) throw new OwWorldFileError("too_big");
1416
+ const nameSet = new Set(names);
1417
+ for (const n of names) {
1418
+ const segs = n.split("/");
1419
+ for (let i = 1; i < segs.length; i++) {
1420
+ if (nameSet.has(segs.slice(0, i).join("/"))) throw new OwWorldFileError("unsafe_path");
1421
+ }
1422
+ }
1423
+ let prefix;
1424
+ if (nameSet.has(WORLD_FILE)) {
1425
+ prefix = "";
1426
+ } else {
1427
+ const tops = new Set(names.map((n) => n.includes("/") ? n.split("/")[0] + "/" : n));
1428
+ const worldTops = [...tops].filter((t) => t.endsWith("/") && nameSet.has(`${t}${WORLD_FILE}`));
1429
+ if (worldTops.length === 0) throw new OwWorldFileError("not_a_world");
1430
+ if (worldTops.length > 1 || tops.size > 1) throw new OwWorldFileError("more_than_one");
1431
+ prefix = worldTops[0];
1432
+ }
1433
+ const worldName = `${prefix}${WORLD_FILE}`;
1434
+ if (names.some((n) => n !== worldName && n.endsWith(`/${WORLD_FILE}`))) throw new OwWorldFileError("more_than_one");
1435
+ const parsed = withoutLink(raw[worldName], parseWorld(raw[worldName]));
1436
+ const files = [];
1437
+ for (const n of names) {
1438
+ const path = n.slice(prefix.length);
1439
+ if (opts.skip?.(path)) continue;
1440
+ files.push({ path, data: n === worldName ? parsed.data : raw[n] });
1441
+ }
1442
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1443
+ const name = parsed.world.name;
1444
+ return {
1445
+ worldId: parsed.world.id,
1446
+ name: typeof name === "string" ? name : "",
1447
+ world: parsed.world,
1448
+ files
1449
+ };
1450
+ }
1451
+ function writeWorldFile(files, topDir) {
1452
+ if (!isSafePath(topDir) || topDir.includes("/")) throw new OwWorldFileError("unsafe_path");
1453
+ const input = {};
1454
+ let sawWorld = false;
1455
+ for (const f of files) {
1456
+ if (!isSafePath(f.path)) throw new OwWorldFileError("unsafe_path");
1457
+ const key = `${topDir}/${f.path}`;
1458
+ if (key in input) throw new OwWorldFileError("unsafe_path", `The path ${f.path} is listed twice.`);
1459
+ let data = f.data;
1460
+ if (f.path === WORLD_FILE) {
1461
+ data = withoutLink(data, parseWorld(data)).data;
1462
+ sawWorld = true;
1463
+ }
1464
+ input[key] = [data, { level: /\.json$/i.test(f.path) ? 6 : 0 }];
1465
+ }
1466
+ if (!sawWorld) throw new OwWorldFileError("not_a_world");
1467
+ return zipSync(input);
1468
+ }
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
+
1336
1900
  // src/token-resource.ts
1337
1901
  var TokenResource = class {
1338
1902
  constructor(client) {
@@ -1518,8 +2082,14 @@ export {
1518
2082
  OwNetworkError,
1519
2083
  OwPreconditionFailedError,
1520
2084
  OwV2Client,
2085
+ OwWorldFileError,
2086
+ PHOTO_MAX_BYTES,
2087
+ PHOTO_TYPES,
1521
2088
  SPATIAL_TYPES,
1522
2089
  TokenResource,
2090
+ WORLD_FILE_MAX_BYTES,
2091
+ WORLD_FILE_MAX_ENTRIES,
2092
+ buildWorldIndex,
1523
2093
  detectKeyKind,
1524
2094
  elementColor,
1525
2095
  errorFromResponse,
@@ -1528,6 +2098,12 @@ export {
1528
2098
  getElementLabel,
1529
2099
  isDemoKey,
1530
2100
  kindCanWrite,
2101
+ lookupWorld,
1531
2102
  parseErrorEnvelope,
1532
- pinExpectation
2103
+ pinExpectation,
2104
+ planPhoto,
2105
+ preparePhoto,
2106
+ readWorldFile,
2107
+ renderElement,
2108
+ writeWorldFile
1533
2109
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlyworlds/sdk",
3
- "version": "4.9.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": {
@@ -60,5 +60,8 @@
60
60
  },
61
61
  "engines": {
62
62
  "node": ">=18.0.0"
63
+ },
64
+ "dependencies": {
65
+ "fflate": "^0.8.3"
63
66
  }
64
67
  }