references/resource/avatar.md
# Avatar Lookup
Search avatar costume items, browse the full avatar catalog, look up
default parts, and inspect item details.
> **All examples in this file go through the Node.js wrapper
> `scripts/msw_resource_api.cjs`.** Use it either as a CLI from a
> shell or via `require(...)` inside Node.js — never assemble curl commands
> by hand.
---
## Searching Costume Items — `POST /v3/search/resources`
**Costume items (hats, coats, shoes, weapons, …) are searched through the
general resource-search endpoint** with `resourceTypeFilter: ["avataritem"]`.
The wrapper exposes this as `searchAvatarItems` (CLI: `search-avatar`)
and hard-codes that filter, so you only need to pass the query (and
optionally a slot category).
```bash
# CLI — narrow to a specific slot with --category
node scripts/msw_resource_api.cjs \
search-avatar "early dismissal" --topK 3 --category shoes
```
```js
// Node.js
const { searchAvatarItems } = require('./scripts/msw_resource_api.cjs');
const result = await searchAvatarItems("early dismissal", {
topK: 3,
categoryFilter: ["shoes"],
});
```
Each result has `type: "avataritem"` and a `category` matching the avatar slot
(`cap`, `coat`, `pants`, `shoes`, `weapon`, …). Use the `id` (RUID) to assign to
the corresponding `Custom*Equip` property — see the slot mapping table in the
`msw-avatar` skill.
> **For full request/response details (fields like `dname`, `score`, `hasEmbedding`,
> `payload.color_hex`, `categoryFilter` slot list, pagination via `nextOffset`/`offset`),
> see the "Avatar Item Search" section in [`references/resource/search.md`](search.md).**
---
## GET /v3/avatars
List **all** avatar items (server-cached). Best for browsing tabs without
a query; for keyword search use `searchAvatarItems`.
### Usage
```bash
# CLI — canonical-only (default; deduped by color/shape variant)
node scripts/msw_resource_api.cjs avatars
# Include all variants
node scripts/msw_resource_api.cjs avatars --no-canonical-only
```
```js
// Node.js
const { listAvatars } = require('./scripts/msw_resource_api.cjs');
const avatars = await listAvatars({ canonicalOnly: true });
```
| Query param (server) | Wrapper arg / CLI flag | Type | Description |
|----------------------|------------------------|------|-------------|
| `canonicalOnly` | `canonicalOnly` / `--no-canonical-only` | bool | Server default `true` (variant groups deduped to representative) |
### Response
```json
{
"items": [
{
"ruid": "d9e9948624a54255b079df8dba096f47",
"category": "coat",
"names": {"ko": ["Yellow Frill Sleeveless"]},
"dname": "coat-541",
"group_id": "coat:757587b9bf92",
"color_hex": "#eeac19",
"group_size": 2,
"group_canonical": false,
"group_members": [...]
}
],
"nextOffset": null,
"total": null
}
```
---
## GET /v3/avatars/defaults
Fetch the default avatar body / head RUIDs.
### Usage
```bash
# CLI
node scripts/msw_resource_api.cjs \
avatar-defaults
```
```js
// Node.js
const { getAvatarDefaults } = require('./scripts/msw_resource_api.cjs');
const defaults = await getAvatarDefaults();
```
### Response
```json
{
"body": "body_ruid_32hex",
"head": "head_ruid_32hex"
}
```
`body` and `head` are the base avatar parts used with costume item slots.
---
## Inspecting an avatar item — use `GET /v3/resources/{ruid}`
> **There is no `/v3/avatars/{ruid}` endpoint.** Avatar item details
> live behind the same `GET /v3/resources/{ruid}` endpoint that returns
> sprites, animationclips, and resource_packs.
### Usage
```bash
# CLI
node scripts/msw_resource_api.cjs \
get ITEM_RUID
```
```js
// Node.js
const { getResource } = require('./scripts/msw_resource_api.cjs');
const item = await getResource("ITEM_RUID");
```
### Response (avataritem branch)
```json
{
"id": "71ce85c4acf04770949b7a55488974c2",
"type": "avataritem",
"category": "cap",
"names": {"ko": ["Orange Mushroom Beanie"]},
"dname": "cap-895",
"payload": {
"color_hex": "#ed8316",
"group_size": 2,
"group_canonical": false,
"group_members": [...],
"group_id": "cap:..."
}
}
```
## Workflows
### Costume item search → application
1. `searchAvatarItems("...", { topK: N, categoryFilter: [slot] })` → obtain RUID
2. Use the slot mapping table in the `msw-avatar` skill to assign the RUID to the correct `Custom*Equip` property
3. Edit `./Global/DefaultPlayer.model` or the relevant `.map` file → `refresh`
### Inspecting avatar item details
1. `searchAvatarItems("...")` → search for a costume item and get its RUID
2. `getResource(ruid)` → inspect color_hex / group meta / variants
references/resource/browse.md
# Listing, Random Recommendations, and Pack Lookups
Browse resources by type/category, get random recommendations, and find
which resource packs include a given RUID.
> **All examples in this file go through the Node.js wrapper
> `scripts/msw_resource_api.cjs`.** Use it either as a CLI from a
> shell or via `require(...)` inside Node.js — never assemble curl commands
> by hand.
## GET /v3/resources
Qdrant Scroll over all resources with embeddings. Supports filters and
opaque-string cursor pagination.
### Usage
```bash
# CLI — list 3 monster sprites
node scripts/msw_resource_api.cjs \
list --resource-type sprite --category mob --limit 3
```
```js
// Node.js
const { listResources } = require('./scripts/msw_resource_api.cjs');
const result = await listResources({
resourceTypeFilter: ["sprite"],
categoryFilter: ["mob"],
limit: 3,
});
// Next page:
const nextPage = await listResources({
resourceTypeFilter: ["sprite"],
categoryFilter: ["mob"],
limit: 3,
offset: result.nextOffset,
});
```
| Query param (server) | Wrapper arg / CLI flag | Type | Description |
|----------------------|------------------------|------|-------------|
| `resourceTypeFilter` | `resourceTypeFilter` / `--resource-type` | string[] | `sprite`, `animationclip`, `resource_pack`, `bgm`, `voice`, `effect` (all three are audio), `avataritem` |
| `categoryFilter` | `categoryFilter` / `--category` | string[] | `mob`, `npc`, `item`, `skill`, `object`, `background`, `foothold`, `rope`, `ladder`, `etc` (or avatar slot). **`map` / `effect` / `ui` are NOT valid** — they return zero items. See [`SKILL.md`](../../SKILL.md) "Categories" |
| `limit` | `limit` / `--limit` | int (1–100) | Page size (server default 50 / **this skill's recommended default 3**) |
| `offset` | `offset` / `--offset` | **string** | Opaque cursor returned in `nextOffset`. **Omit on the first page** — sending `0` would be treated as a cursor and return empty. |
| `canonicalOnly` | `canonicalOnly` / `--canonical-only` | bool | Server default `true` |
| `widthMin` / `widthMax` / `heightMin` / `heightMax` | `widthMin/Max`, `heightMin/Max` | int | Sprite/animationclip size filter |
| `lengthMin` / `lengthMax` | `lengthMin/Max` | float | Sound length filter (seconds) |
> **Earlier wrapper used `type` / `category` (singular, string) and
> `offset=0` (int).** Both are wrong: the server expects array filters
> named `resourceTypeFilter` / `categoryFilter`, and `offset` is an
> opaque string cursor (`null` for the first page).
### Response
```json
{
"items": [
{
"id": "RUID",
"type": "sprite",
"category": "mob",
"names": {"ko": ["초록버섯"], "en": ["Green Mushroom"]},
"payload": {
"width": 64,
"height": 64,
"thumbnail": "https://..."
}
}
],
"nextOffset": "0008f952-65b8-56bc-9023-98f3eeb28730",
"total": null
}
```
`nextOffset` is the cursor for the next page (pass it back in `offset`).
Reaches `null` at the end.
---
## GET /v3/resources/random
Random resource recommendation — useful for inspiration or when you just
need a sample.
### Usage
```bash
# CLI — 3 random monster sprites
node scripts/msw_resource_api.cjs \
random --resource-type sprite --category mob --count 3
# 3 random voice clips
node scripts/msw_resource_api.cjs \
random --resource-type voice --count 3
# Fully random with no filters (always specify count explicitly — default 3)
node scripts/msw_resource_api.cjs \
random --count 3
```
```js
// Node.js
const { randomResources } = require('./scripts/msw_resource_api.cjs');
const result = await randomResources({
resourceTypeFilter: ["sprite"],
categoryFilter: ["mob"],
count: 3,
});
```
| Query param (server) | Wrapper arg / CLI flag | Type | Description |
|----------------------|------------------------|------|-------------|
| `resourceTypeFilter` | `resourceTypeFilter` / `--resource-type` | string[] | Resource type filter |
| `categoryFilter` | `categoryFilter` / `--category` | string[] | Category filter |
| `count` | `count` / `--count` | int (1–100) | Number of results (server default 20 / **this skill's recommended default 3**) |
| `canonicalOnly` | `canonicalOnly` / `--canonical-only` | bool | Server default `true` |
| `widthMin/Max`, `heightMin/Max`, `lengthMin/Max` | wrapper option keys | int / float | Same shape as `listResources` |
> **Earlier wrapper sent `limit` / `type` / `category`** — the server's
> parameters are `count` / `resourceTypeFilter` / `categoryFilter`.
### Response
Same shape as the listing endpoint (`items` + `nextOffset` + `total`).
---
## GET /v3/resources/packs/{ruid}
List resource packs **that contain the given RUID**. The path parameter
is a 32-char-hex RUID, **NOT a pack id**.
> **Looking up a pack's own contents?** Use `getResource(packId)`
> (i.e. `GET /v3/resources/{packId}`) instead — that endpoint returns
> the pack with each `payload.elements[*]` already populated.
### Usage
```bash
# CLI — find packs that include this animationclip RUID
node scripts/msw_resource_api.cjs \
packs e5fff311269b464984a9b7885a6401e7 --limit 3
```
```js
// Node.js
const { findPacksContaining } = require('./scripts/msw_resource_api.cjs');
const packs = await findPacksContaining("e5fff311269b464984a9b7885a6401e7", { limit: 3 });
```
| Param (server) | Wrapper arg / CLI flag | Type | Description |
|----------------|------------------------|------|-------------|
| `id` (path) | `ruid` (positional) | string | 32-char-hex RUID to search for |
| `limit` | `limit` / `--limit` | int (1–100) | Page size (server default 50 / **this skill's default 3**) |
| `offset` | `offset` / `--offset` | string | Opaque cursor (`nextOffset` from previous page) |
### Response
```json
{
"items": [
{
"id": "npc/9072309.img",
"type": "resource_pack",
"category": "npc",
"names": {"ko": ["주황버섯"]},
"assetGuid": "304aba4bea874f948a7548c0d8e393f5",
"payload": {
"elements": [
{
"resource_type": "animationclip",
"ruid": "0a4cee60c89a42bea0d52fd8df00f17c",
"rel_path": "move"
},
{
"resource_type": "animationclip",
"ruid": "e5fff311269b464984a9b7885a6401e7",
"rel_path": "stand"
}
]
}
}
]
}
```
### Pack contents (use `getResource` instead)
To fetch a pack's full contents — same `elements` array but with each
element's payload also filled in — call `getResource(packId)`:
```js
const { getResource } = require('./scripts/msw_resource_api.cjs');
const pack = await getResource("npc/1013617.img");
for (const element of pack.payload.elements) {
// element has resource_type, ruid, rel_path, and payload (width/height/thumbnail/...)
}
```
A resource pack usually consists of multiple animations (stand, walk,
attack, etc.), sounds, and per-frame sprites. The RUID of an individual
element can be used directly in fields like
`SpriteRendererComponent.SpriteRUID`.
references/resource/detail.md
# Resource Details & Tags
Fetch details for a single or multiple resources, and inspect AI-generated multilingual tags.
> **All examples in this file go through the Node.js wrapper
> `scripts/msw_resource_api.cjs`.** Use it either as a CLI from a
> shell or via `require(...)` inside Node.js — never assemble curl commands
> by hand.
## GET /v3/resources/{ruid}
Fetch detail for a single resource.
### Usage
```bash
# CLI
node scripts/msw_resource_api.cjs \
get 0017da7385e04bc4b2ddbe5949b4b462
```
```js
// Node.js
const { getResource } = require('./scripts/msw_resource_api.cjs');
const detail = await getResource("0017da7385e04bc4b2ddbe5949b4b462");
```
| Parameter | Location | Type | Required | Description |
|-----------|----------|------|----------|-------------|
| ruid | path | string | O | Resource RUID (32-char hex) |
### Response
```json
{
"id": "0017da7385e04bc4b2ddbe5949b4b462",
"type": "sprite",
"category": "mob",
"names": {
"ko": ["초록버섯"],
"en": ["Green Mushroom"]
},
"assetGuid": "304aba4bea874f948a7548c0d8e393f5",
"payload": {
"width": 64,
"height": 64,
"pivot": {"x": 32, "y": 50},
"thumbnail": "https://...",
"frames": [
{"filename": "frame0.png", "delay": 100}
],
"elements": [
{
"resource_type": "animationclip",
"rel_path": "mob/stand",
"ruid": "element_ruid"
}
]
}
}
```
### Key `payload` Fields
| Field | Type | Description |
|-------|------|-------------|
| `width`, `height` | int | Image size (pixels) |
| `pivot` | {x, y} | Sprite anchor coordinates |
| `thumbnail` | string | Thumbnail image URL |
| `frames` | array | Animation frame list (animationclip) |
| `elements` | array | Resource pack components (resource_pack) |
- `frames` exists only on `animationclip`
- `elements` exists only on `resource_pack`
- `pivot` exists on `sprite`
---
## POST /v3/resources/batch
Fetch multiple RUIDs in a single request.
### Usage
```bash
# CLI — pass RUIDs as space-separated positional arguments
node scripts/msw_resource_api.cjs \
batch 0017da7385e04bc4b2ddbe5949b4b462 abc123def456789012345678abcdef01
```
```js
// Node.js
const { getResourcesBatch } = require('./scripts/msw_resource_api.cjs');
const resources = await getResourcesBatch([
"0017da7385e04bc4b2ddbe5949b4b462",
"abc123def456789012345678abcdef01",
]);
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| ids | string[] | O | Array of RUIDs |
### Response
An array of resource objects. Each element has the same structure as the single-resource response.
---
## GET /v3/resources/tags/{ruid}
Fetch AI-generated multilingual tags for a resource — including description, keywords, and per-language tags.
### Usage
```bash
# CLI
node scripts/msw_resource_api.cjs \
tags 0017da7385e04bc4b2ddbe5949b4b462
```
```js
// Node.js
const { getResourceTags } = require('./scripts/msw_resource_api.cjs');
const tags = await getResourceTags("0017da7385e04bc4b2ddbe5949b4b462");
```
| Parameter | Location | Type | Required | Description |
|-----------|----------|------|----------|-------------|
| ruid | path | string | O | Resource RUID (32-char hex) |
### Response
```json
{
"tags": {
"ko": ["초록", "버섯", "몬스터", "슬라임"],
"en": ["green", "mushroom", "monster", "slime"],
"ja": ["緑", "キノコ", "モンスター"],
"zh": ["绿色", "蘑菇", "怪物"],
"description": "A green mushroom monster from MapleStory",
"keywords": ["green_mushroom", "mob", "monster"]
}
}
```
| Field | Description |
|-------|-------------|
| `tags.ko/en/ja/zh` | Tag array per language |
| `tags.description` | Resource description (English) |
| `tags.keywords` | Search keywords |
Tags are generated automatically by AI and describe the resource's visual traits and intended use.
They are useful for similar-resource search and for filtering.
references/resource/search.md
# Semantic Search & Similar Resources
Search MSW resources by natural language, and find resources similar to a given one.
> **All examples in this file go through the Node.js wrapper
> `scripts/msw_resource_api.cjs`.** Use it either as a CLI from a
> shell or via `require(...)` inside Node.js — never assemble curl commands
> by hand. The wrapper sends UTF-8 JSON bodies directly, so non-ASCII queries
> (Korean / Japanese / Chinese / emoji) are safe and the
> `{"detail":"There was an error parsing the body"}` failure mode is
> impossible to trigger.
## POST /v3/search/resources
Natural-language semantic search. Supports queries in Korean, English, Japanese, and Chinese.
### Usage
```bash
# CLI — defaults: topK=3, --resource-type / --category optional
node scripts/msw_resource_api.cjs \
search "orange mushroom" --resource-type resource_pack --category npc --topK 3
```
```js
// Node.js — require and call
const { searchResources } = require('./scripts/msw_resource_api.cjs');
const result = await searchResources("orange mushroom", {
resourceTypeFilter: ["resource_pack"],
categoryFilter: ["npc"],
topK: 3,
});
```
> Use `require(...)` with a relative path (e.g.
> `require('./scripts/msw_resource_api.cjs')`) when calling from a sibling file —
> the wrapper is a single-file CommonJS drop-in with zero dependencies.
| Body field (server) | Wrapper arg / CLI flag | Type | Required | Description |
|---------------------|------------------------|------|----------|-------------|
| `query` | `query` (positional) | string | O | Search term (natural language, RUID, or pack ID — exact-match patterns trigger ID lookup) |
| `resourceTypeFilter` | `resourceTypeFilter` / `--resource-type` | string[] | - | `sprite`, `animationclip`, `resource_pack`, `bgm`, `voice`, `effect` (all three are audio), `avataritem` |
| `categoryFilter` | `categoryFilter` / `--category` | string[] | - | `mob`, `npc`, `item`, `skill`, `object`, `background`, `foothold`, `rope`, `ladder`, `etc` (or avatar slot when `resourceTypeFilter=["avataritem"]`). **`map` / `effect` / `ui` are NOT valid** — see [`SKILL.md`](../../SKILL.md) "Categories" |
| `topK` | `topK` / `--topK` | int (1–100) | - | Number of results (server default 20 / **this skill's recommended default 3** — always send explicitly) |
| `offset` | `offset` / `--offset` | int (0–5000) | - | Result offset; default 0 |
| `canonicalOnly` | `canonicalOnly` / `--canonical-only` | bool | - | Server default `true` (sprite/animationclip/avataritem dedup) |
| `widthMin` / `widthMax` | `widthMin` / `widthMax` / `--width-min/max` | int | - | Sprite/animationclip width filter |
| `heightMin` / `heightMax` | `heightMin` / `heightMax` / `--height-min/max` | int | - | Sprite/animationclip height filter |
| `lengthMin` / `lengthMax` | `lengthMin` / `lengthMax` / `--length-min/max` | float | - | Sound length filter (seconds) |
> **The legacy field names `types` / `categories` / `limit` are silently
> ignored by the server.** Always use the canonical names above —
> the wrapper takes care of this for you.
> Avatar-item search uses the same endpoint with
> `resourceTypeFilter=["avataritem"]` already filled in. Use the
> `searchAvatarItems` wrapper (CLI: `search-avatar`) — see "Avatar
> Item Search" below.
### Response
```json
{
"results": [
{
"id": "0017da7385e04bc4b2ddbe5949b4b462",
"type": "resource_pack",
"category": "mob",
"names": {
"ko": ["초록버섯"],
"en": ["Green Mushroom"]
},
"dname": "Green Mushroom",
"score": 0.9234,
"assetGuid": "304aba4bea874f948a7548c0d8e393f5",
"payload": {
"width": 64,
"height": 64,
"thumbnail": "https://...",
"elements": [
{
"resource_type": "animationclip",
"rel_path": "mob/9833419.img/move",
"ruid": "abc123..."
}
]
}
}
],
"nextOffset": 5
}
```
### Key Response Fields
| Field | Description |
|-------|-------------|
| `id` | RUID — 32-char hex resource identifier |
| `type` | Resource type |
| `category` | Category |
| `names` | Multilingual names (prefer ko > en) |
| `score` | Semantic similarity score (0–1) |
| `assetGuid` | Unity asset GUID (used in `spawn_preset`, may be missing) |
| `payload.thumbnail` | Thumbnail image URL |
| `payload.width/height` | Image dimensions |
| `payload.elements` | Resource pack components (sprite, animation, sound) |
| `nextOffset` | Next page offset (integer; pass it back as `offset` to paginate) |
### Choosing `resourceTypeFilter`
- **Default:** `resource_pack` — a finished asset bundling sprites + animations + sounds; suitable for most searches
- **Sound / audio:** `bgm` (background music) / `voice` (NPC voice) / `effect` (sound effect — **not visual**)
- **Individual sprite:** `sprite`
- **Individual animation:** `animationclip`
- **Visual effect / particle / hit FX:** `sprite` or `animationclip` + `categoryFilter: ["skill","mob","etc"]` — there is no visual `effect` resource_type or category
- **Avatar costume item:** `avataritem` (or just call `searchAvatarItems`)
### Using `categoryFilter`
| User phrase | category |
|-------------|----------|
| Monster, mob | `mob` |
| NPC | `npc` |
| Item | `item` |
| Skill resource / skill effect | `skill` |
| Tree / rock / map object / decoration | `object` |
| Background / map tile / scenery / BGM | `background` |
| Walkable platform | `foothold` |
| Rope | `rope` |
| Ladder | `ladder` |
| Uncategorized / misc | `etc` |
| Visual effect / particle | use `categoryFilter: ["skill","mob","etc"]` with `resourceTypeFilter: ["sprite","animationclip"]` — no dedicated `effect` category |
| Sound effect (audio) | use `resourceTypeFilter: ["effect"]` — `effect` is an audio **resource_type**, not a category |
| UI element | not indexed under a `ui` category — search `sprite` + `category: "etc"` or by direct name |
### Search Tips
- If you miss on the first try, retry with synonyms / English / Korean:
- "tree" → "forest" → "forest background" → "plant"
- "running monster" → "moving monster" → "moving enemy"
- You can also pass a RUID directly as `query`.
---
## Avatar Item Search (`resourceTypeFilter: ["avataritem"]`)
The same `POST /v3/search/resources` endpoint also covers **avatar costume items**
(hats, coats, shoes, weapons, etc.). This is the **only supported way to obtain
avatar item RUIDs by natural-language search** — the legacy full-list endpoint is
no longer used.
The wrapper hard-codes `resourceTypeFilter=["avataritem"]`. You can
narrow the search to a specific avatar slot by passing
`categoryFilter: ["cap", ...]` (CLI: `--category cap`).
### Usage
```bash
# CLI
node scripts/msw_resource_api.cjs \
search-avatar "early dismissal" --topK 3 --category shoes
```
```js
// Node.js
const { searchAvatarItems } = require('./scripts/msw_resource_api.cjs');
const result = await searchAvatarItems("early dismissal", {
topK: 3,
categoryFilter: ["shoes"],
});
```
| Body field (server) | Wrapper arg / CLI flag | Type | Required | Description |
|---------------------|------------------------|------|----------|-------------|
| `query` | `query` (positional) | string | O | Search term (Korean / English / Japanese / Chinese) |
| `topK` | `topK` / `--topK` | int (1–100) | - | Page size (server default 20 / **this skill's recommended default 3**) |
| `offset` | `offset` / `--offset` | int (0–5000) | - | Pagination offset (default 0) |
| `categoryFilter` | `categoryFilter` / `--category` | string[] | - | Avatar slot: `cap`, `cape`, `coat`, `longcoat`, `pants`, `shoes`, `glove`, `hair`, `face`, `weapon`, `twohandweapon`, `subweapon`, `shield`, … |
| `canonicalOnly` | `canonicalOnly` / `--canonical-only` | bool | - | Server default `true` (color/shape variants deduped) |
> The wrapper hard-codes `resourceTypeFilter=["avataritem"]`, so you do not
> need to pass it explicitly.
### Response
```json
{
"query": "early dismissal",
"results": [
{
"id": "ac02d9eb84dc4c4197dfce6721c6543c",
"type": "avataritem",
"category": "shoes",
"names": { "ko": ["이른 하교"] },
"dname": "shoes-1141",
"assetGuid": null,
"score": 0.78686994,
"hasEmbedding": true,
"payload": {
"color_hex": "#981125",
"group_size": null,
"group_canonical": null,
"group_members": null,
"group_id": null
}
}
],
"nextOffset": 2,
"exactMatch": false
}
```
### Avatar-Specific Response Fields
| Field | Description |
|-------|-------------|
| `id` | RUID — assign to the matching `Custom*Equip` slot (see slot mapping in the `msw-avatar` skill) |
| `type` | Always `avataritem` |
| `category` | Avatar slot / part category (`shoes`, `cap`, `coat`, `weapon`, …) — drives the slot mapping |
| `dname` | Internal display name (e.g. `shoes-1141`); useful as a stable secondary key |
| `assetGuid` | Usually `null` for avatar items — **do not depend on it for avatar items** |
| `score` | Semantic similarity (0–1) |
| `hasEmbedding` | Whether the item has a vector embedding indexed (filter out `false` if you want only well-indexed results) |
| `payload.color_hex` | Dominant color in `#RRGGBB` — handy for color-based filtering |
| `payload.group_*` | Variant grouping (e.g. recolor families). May be `null` when the item is standalone. |
| `nextOffset` | Pass back as `offset` to fetch the next page |
| `exactMatch` | `true` when the query exactly matches an item name |
### Tips
- The default `topK` is **3** (this skill's convention). Increase to 50–100 only when explicitly browsing widely.
- `category` in avatar item results aligns with avatar slots (`cap`, `coat`, `pants`, `shoes`, `weapon`, …) — use the slot mapping table in the `msw-avatar` skill to assign to the correct `Custom*Equip` property.
- `payload.color_hex` lets you filter results client-side, e.g. "red shoes" → search "shoes", then keep entries whose `color_hex` is reddish.
---
## GET /v3/search/resources/similar/{id}
Find resources that are visually or semantically similar to a given resource.
### Usage
```bash
# CLI
node scripts/msw_resource_api.cjs \
similar 0017da7385e04bc4b2ddbe5949b4b462 --topK 3 --resource-type animationclip
```
```js
// Node.js
const { findSimilarResources } = require('./scripts/msw_resource_api.cjs');
const result = await findSimilarResources("0017da7385e04bc4b2ddbe5949b4b462", {
topK: 3,
resourceTypeFilter: ["animationclip"],
});
```
| Query param (server) | Wrapper arg / CLI flag | Type | Description |
|----------------------|------------------------|------|-------------|
| `id` (path) | `ruid` (positional) | string | Source resource RUID (32-char hex) |
| `topK` | `topK` / `--topK` | int (1–100) | Number of results (server default 20 / **this skill's recommended default 3**) |
| `resourceTypeFilter` | `resourceTypeFilter` / `--resource-type` | string[] | Optional type narrowing |
| `categoryFilter` | `categoryFilter` / `--category` | string[] | Optional category narrowing (incl. avatar slot for avataritem source) |
| `canonicalOnly` | `canonicalOnly` / `--canonical-only` | bool | Server default `true` |
| `widthMin` / `widthMax` / `heightMin` / `heightMax` | `widthMin/Max`, `heightMin/Max` | int | Sprite/animationclip size filter |
> Earlier wrapper sent `limit`; the server's parameter is **`topK`**.
### Response
An array with the same structure as search results. `score` represents similarity to the source resource.
scripts/msw_resource_api.cjs
#!/usr/bin/env node
/**
* MSW Resource Search API — Node.js (CommonJS) wrapper.
*
* Node.js implementation. Exposes every endpoint of the MSW resource search
* REST API as functions and CLI subcommands.
*
* const api = require('./msw_resource_api');
* const hits = await api.searchResources('orange mushroom', {
* resourceTypeFilter: ['resource_pack'],
* categoryFilter: ['npc'],
* topK: 3,
* });
* const detail = await api.getResource(hits.results[0].id);
*
* CLI:
* node msw_resource_api.cjs search "orange mushroom" \
* --resource-type resource_pack --category npc --topK 3
* node msw_resource_api.cjs get 0017da7385e04bc4b2ddbe5949b4b462
* Design notes:
* - No external dependencies. Uses only Node 18+ built-in `fetch`/`AbortController`.
* - All POST bodies are encoded as UTF-8 JSON and sent with
* `Content-Type: application/json; charset=utf-8`.
* Korean / Japanese / emoji payloads are safe.
* - Default page size for list-style endpoints is 3 (per the SKILL.md convention).
*/
'use strict';
const BASE_URL = 'https://maplestoryworlds-resourcesearch-new.nexon.com/api';
const DEFAULT_TIMEOUT_MS = 15_000; // SKILL.md recommends 15s
const DEFAULT_LIMIT = 3; // skill convention (server defaults are 5/10)
class MswApiError extends Error {
constructor(status, url, body) {
const snippet = typeof body === 'string' ? body.slice(0, 500) : String(body);
super(`MSW API ${status} on ${url}: ${snippet}`);
this.name = 'MswApiError';
this.status = status;
this.url = url;
this.body = body;
}
}
// ---------------------------------------------------------------------------
// Input validation helpers
// ---------------------------------------------------------------------------
const HEX_RUID_RE = /^[0-9a-f]{32}$/i;
const UUID_CURSOR_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Normalize the `offset` argument for list-style endpoints (`/v3/resources`,
* `/v3/resources/packs/{ruid}`) whose cursor is an opaque UUID string.
*
* - `undefined` / `null` → undefined (first page).
* - Valid UUID cursor → returned as-is.
* - `0` / `"0"` / `""` / `"null"` → undefined (silent: common "first page"
* confusion that would otherwise return zero items).
* - Anything else → undefined, with a stderr warning so the caller learns
* that the value was discarded.
*/
function _normalizeListOffset(offset) {
if (offset === undefined || offset === null) return undefined;
const s = String(offset);
if (UUID_CURSOR_RE.test(s)) return s;
if (s === '0' || s === '' || s === 'null' || s === 'undefined') return undefined;
if (typeof process !== 'undefined' && process.stderr && process.stderr.write) {
process.stderr.write(
`[msw-api warn] offset "${s}" is not a valid cursor `
+ `(expected the UUID string returned in the previous response's nextOffset); `
+ `ignoring and fetching the first page.\n`
);
}
return undefined;
}
// ---------------------------------------------------------------------------
// Low-level HTTP helper
// ---------------------------------------------------------------------------
/**
* Serialize a query object to a URLSearchParams string.
* - null / undefined values are dropped.
* - Arrays / tuples are repeated under the same key (`types=a&types=b`).
*/
function _buildQuery(query) {
if (!query) return '';
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item === null || item === undefined) continue;
params.append(key, String(item));
}
} else {
params.append(key, String(value));
}
}
const s = params.toString();
return s ? `?${s}` : '';
}
async function _request(method, path, { query, body, timeout = DEFAULT_TIMEOUT_MS } = {}) {
const url = BASE_URL + path + _buildQuery(query);
const headers = { Accept: 'application/json' };
let payload;
if (body !== undefined && body !== null) {
payload = Buffer.from(JSON.stringify(body), 'utf8');
headers['Content-Type'] = 'application/json; charset=utf-8';
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let resp;
try {
resp = await fetch(url, {
method,
headers,
body: payload,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
const reason = err && err.name === 'AbortError'
? `timeout after ${timeout}ms`
: (err && err.message) || String(err);
throw new MswApiError(0, url, reason);
}
clearTimeout(timer);
const text = await resp.text();
if (!resp.ok) {
throw new MswApiError(resp.status, url, text);
}
if (!text) return null;
try {
return JSON.parse(text);
} catch (_e) {
return text;
}
}
const _enc = (s) => encodeURIComponent(s);
// ---------------------------------------------------------------------------
// Section: Semantic Search — POST /v3/search/resources & similar
// ---------------------------------------------------------------------------
/**
* Natural-language semantic search.
* `POST /v3/search/resources` (SearchRequest schema).
* Used to search sprite / animationclip / resource_pack / sound / avataritem.
* For avatar costumes, prefer `searchAvatarItems` — it pins
* `resourceTypeFilter=["avataritem"]` automatically.
*
* Body keys follow the OpenAPI spec verbatim (`topK`, `resourceTypeFilter`,
* `categoryFilter`, …). The legacy wrapper's `limit` / `types` / `categories`
* were silently ignored by the server.
*
* @param {string} query
* @param {object} [opts]
* @returns {Promise<object>}
*/
async function searchResources(query, opts = {}) {
const {
resourceTypeFilter,
categoryFilter,
topK = DEFAULT_LIMIT,
offset = 0,
canonicalOnly,
widthMin, widthMax, heightMin, heightMax,
lengthMin, lengthMax,
compact = true,
} = opts;
const payload = { query, topK, offset };
if (resourceTypeFilter !== undefined) payload.resourceTypeFilter = [...resourceTypeFilter];
if (categoryFilter !== undefined) payload.categoryFilter = [...categoryFilter];
if (canonicalOnly !== undefined && canonicalOnly !== null) payload.canonicalOnly = canonicalOnly;
if (widthMin !== undefined && widthMin !== null) payload.widthMin = widthMin;
if (widthMax !== undefined && widthMax !== null) payload.widthMax = widthMax;
if (heightMin !== undefined && heightMin !== null) payload.heightMin = heightMin;
if (heightMax !== undefined && heightMax !== null) payload.heightMax = heightMax;
if (lengthMin !== undefined && lengthMin !== null) payload.lengthMin = lengthMin;
if (lengthMax !== undefined && lengthMax !== null) payload.lengthMax = lengthMax;
return _request('POST', '/v3/search/resources', {
query: compact ? { compact: 'true' } : undefined,
body: payload,
});
}
/**
* Search avatar costume items (cap, coat, pants, shoes, weapon, …).
* `POST /v3/search/resources` + `resourceTypeFilter=["avataritem"]`.
*/
async function searchAvatarItems(query, opts = {}) {
const {
topK = DEFAULT_LIMIT,
offset = 0,
categoryFilter,
canonicalOnly,
compact = true,
} = opts;
const payload = {
query, topK, offset,
resourceTypeFilter: ['avataritem'],
};
if (categoryFilter !== undefined) payload.categoryFilter = [...categoryFilter];
if (canonicalOnly !== undefined && canonicalOnly !== null) payload.canonicalOnly = canonicalOnly;
return _request('POST', '/v3/search/resources', {
query: compact ? { compact: 'true' } : undefined,
body: payload,
});
}
/**
* Find resources similar to a given RUID.
* `GET /v3/search/resources/similar/{id}`. The server uses `topK`
* (default 20, max 100). The legacy wrapper's `limit` was ignored.
*/
async function findSimilarResources(ruid, opts = {}) {
const {
topK = DEFAULT_LIMIT,
resourceTypeFilter,
categoryFilter,
canonicalOnly,
widthMin, widthMax, heightMin, heightMax,
compact = true,
} = opts;
const query = { topK };
if (resourceTypeFilter !== undefined) query.resourceTypeFilter = [...resourceTypeFilter];
if (categoryFilter !== undefined) query.categoryFilter = [...categoryFilter];
if (canonicalOnly !== undefined && canonicalOnly !== null) query.canonicalOnly = canonicalOnly ? 'true' : 'false';
if (widthMin !== undefined && widthMin !== null) query.widthMin = widthMin;
if (widthMax !== undefined && widthMax !== null) query.widthMax = widthMax;
if (heightMin !== undefined && heightMin !== null) query.heightMin = heightMin;
if (heightMax !== undefined && heightMax !== null) query.heightMax = heightMax;
if (compact) query.compact = 'true';
return _request('GET', `/v3/search/resources/similar/${_enc(ruid)}`, { query });
}
// ---------------------------------------------------------------------------
// Section: Resource Details & Tags
// ---------------------------------------------------------------------------
/** Fetch a single resource's details. `GET /v3/resources/{ruid}`. */
async function getResource(ruid) {
return _request('GET', `/v3/resources/${_enc(ruid)}`);
}
/** Batch-fetch multiple resources. `POST /v3/resources/batch`. */
async function getResourcesBatch(ids) {
return _request('POST', '/v3/resources/batch', { body: { ids: [...ids] } });
}
/** Fetch AI-generated multilingual tags. `GET /v3/resources/tags/{ruid}`. */
async function getResourceTags(ruid) {
return _request('GET', `/v3/resources/tags/${_enc(ruid)}`);
}
// ---------------------------------------------------------------------------
// Section: Browsing — listings, random, and pack details
// ---------------------------------------------------------------------------
/**
* Qdrant Scroll-based resource listing. `GET /v3/resources`.
* `offset` is the `nextOffset` string cursor from the previous response.
* Omit it for the first page.
* Fixes the legacy bug where the wrapper sent `offset=0` (int) and matched 0 items.
*
* Filters are sent under the canonical OpenAPI keys
* `resourceTypeFilter` / `categoryFilter`.
*/
async function listResources(opts = {}) {
const {
resourceTypeFilter,
categoryFilter,
limit = DEFAULT_LIMIT,
offset,
canonicalOnly,
widthMin, widthMax, heightMin, heightMax,
lengthMin, lengthMax,
compact = true,
} = opts;
const query = { limit };
if (resourceTypeFilter !== undefined) query.resourceTypeFilter = [...resourceTypeFilter];
if (categoryFilter !== undefined) query.categoryFilter = [...categoryFilter];
const cursor = _normalizeListOffset(offset);
if (cursor !== undefined) query.offset = cursor;
if (canonicalOnly !== undefined && canonicalOnly !== null) query.canonicalOnly = canonicalOnly ? 'true' : 'false';
if (widthMin !== undefined && widthMin !== null) query.widthMin = widthMin;
if (widthMax !== undefined && widthMax !== null) query.widthMax = widthMax;
if (heightMin !== undefined && heightMin !== null) query.heightMin = heightMin;
if (heightMax !== undefined && heightMax !== null) query.heightMax = heightMax;
if (lengthMin !== undefined && lengthMin !== null) query.lengthMin = lengthMin;
if (lengthMax !== undefined && lengthMax !== null) query.lengthMax = lengthMax;
if (compact) query.compact = 'true';
return _request('GET', '/v3/resources', { query });
}
/**
* Random resource recommendations. `GET /v3/resources/random`.
* The server uses `count` (NOT `limit`) along with
* `resourceTypeFilter` / `categoryFilter`.
*/
async function randomResources(opts = {}) {
const {
resourceTypeFilter,
categoryFilter,
count = DEFAULT_LIMIT,
canonicalOnly,
widthMin, widthMax, heightMin, heightMax,
lengthMin, lengthMax,
compact = true,
} = opts;
const query = { count };
if (resourceTypeFilter !== undefined) query.resourceTypeFilter = [...resourceTypeFilter];
if (categoryFilter !== undefined) query.categoryFilter = [...categoryFilter];
if (canonicalOnly !== undefined && canonicalOnly !== null) query.canonicalOnly = canonicalOnly ? 'true' : 'false';
if (widthMin !== undefined && widthMin !== null) query.widthMin = widthMin;
if (widthMax !== undefined && widthMax !== null) query.widthMax = widthMax;
if (heightMin !== undefined && heightMin !== null) query.heightMin = heightMin;
if (heightMax !== undefined && heightMax !== null) query.heightMax = heightMax;
if (lengthMin !== undefined && lengthMin !== null) query.lengthMin = lengthMin;
if (lengthMax !== undefined && lengthMax !== null) query.lengthMax = lengthMax;
if (compact) query.compact = 'true';
return _request('GET', '/v3/resources/random', { query });
}
/**
* List resource packs that contain a given RUID.
* `GET /v3/resources/packs/{id}` — the path parameter is a 32-hex RUID
* (NOT a pack id). The server returns packs whose `payload.elements` include that RUID.
*
* To fetch a pack's own metadata + populated elements, use `getResource(packId)`
* — that endpoint fills in the payload of each element and returns it.
*/
async function findPacksContaining(ruid, opts = {}) {
if (!HEX_RUID_RE.test(String(ruid || ''))) {
throw new Error(
`'packs' expects a 32-hex RUID, got "${ruid}". `
+ `If you want a pack's contents, use getResource(packId) (CLI: 'get <packId>') instead — `
+ `that endpoint returns the pack with each payload.elements[*] populated.`
);
}
const { limit = DEFAULT_LIMIT, offset, compact = true } = opts;
const query = { limit };
const cursor = _normalizeListOffset(offset);
if (cursor !== undefined) query.offset = cursor;
if (compact) query.compact = 'true';
return _request('GET', `/v3/resources/packs/${_enc(ruid)}`, { query });
}
// ---------------------------------------------------------------------------
// Section: Avatar — listings and defaults
// ---------------------------------------------------------------------------
/**
* List every avatar item (server-cached).
* `GET /v3/avatars`. For keyword search, use `searchAvatarItems`.
*/
async function listAvatars({ canonicalOnly = true } = {}) {
return _request('GET', '/v3/avatars', {
query: { canonicalOnly: canonicalOnly ? 'true' : 'false' },
});
}
/** Fetch the default body / head RUIDs. `GET /v3/avatars/defaults`. */
async function getAvatarDefaults() {
return _request('GET', '/v3/avatars/defaults');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
/** Tiny argv parser. Does not port argparse wholesale — only what we need. */
function _parseArgs(argv, spec) {
// spec: { positional: [{name, nargs?: '+'|undefined}], options: { flag: {dest, type, nargs?, const?, default?} } }
const result = {};
for (const [, opt] of Object.entries(spec.options || {})) {
if (opt.default !== undefined) result[opt.dest] = opt.default;
}
const positionals = [];
let i = 0;
while (i < argv.length) {
const tok = argv[i];
if (tok.startsWith('--')) {
const opt = spec.options && spec.options[tok];
if (!opt) throw new Error(`unknown option: ${tok}`);
if (opt.nargs === '+') {
const values = [];
i += 1;
while (i < argv.length && !argv[i].startsWith('--')) {
values.push(argv[i]);
i += 1;
}
if (values.length === 0) throw new Error(`option ${tok} requires at least one value`);
result[opt.dest] = opt.type === 'int' ? values.map((v) => parseInt(v, 10))
: opt.type === 'float' ? values.map((v) => parseFloat(v))
: values;
continue;
}
if (opt.const !== undefined) {
result[opt.dest] = opt.const;
i += 1;
continue;
}
const v = argv[i + 1];
if (v === undefined) throw new Error(`option ${tok} requires a value`);
result[opt.dest] = opt.type === 'int' ? parseInt(v, 10)
: opt.type === 'float' ? parseFloat(v)
: v;
i += 2;
continue;
}
positionals.push(tok);
i += 1;
}
let pi = 0;
for (const p of spec.positional || []) {
if (p.nargs === '+') {
if (pi >= positionals.length) throw new Error(`missing positional: ${p.name}`);
result[p.name] = positionals.slice(pi);
pi = positionals.length;
} else {
if (pi >= positionals.length) throw new Error(`missing positional: ${p.name}`);
result[p.name] = positionals[pi];
pi += 1;
}
}
return result;
}
function _printJson(value) {
if (value === null || value === undefined) return;
if (typeof value === 'object') {
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
} else {
process.stdout.write(String(value) + '\n');
}
}
const COMMON_FILTERS = {
'--resource-type': { dest: 'resourceTypeFilter', nargs: '+' },
'--category': { dest: 'categoryFilter', nargs: '+' },
'--canonical-only': { dest: 'canonicalOnly', const: true },
'--no-canonical-only': { dest: 'canonicalOnly', const: false },
'--no-compact': { dest: 'compact', const: false },
'--width-min': { dest: 'widthMin', type: 'int' },
'--width-max': { dest: 'widthMax', type: 'int' },
'--height-min': { dest: 'heightMin', type: 'int' },
'--height-max': { dest: 'heightMax', type: 'int' },
'--length-min': { dest: 'lengthMin', type: 'float' },
'--length-max': { dest: 'lengthMax', type: 'float' },
};
const CLI_USAGE = `\
MSW Resource Search API CLI
Usage:
node msw_resource_api.cjs <command> [args]
Commands:
search <query> [--resource-type ...] [--category ...] [--topK N] [--offset N]
[--canonical-only|--no-canonical-only] [--width-min N] ...
[--no-compact]
search-avatar <query> [--topK N] [--offset N] [--category ...] [--no-compact]
similar <ruid> [--topK N] [--resource-type ...] [--category ...] [--no-compact]
get <ruid>
batch <id1> <id2> ...
tags <ruid>
list [--resource-type ...] [--category ...] [--limit N] [--offset CURSOR] ...
random [--resource-type ...] [--category ...] [--count N] ...
packs <ruid> [--limit N] [--offset CURSOR] [--no-compact]
avatars [--no-canonical-only]
avatar-defaults
`;
const CLI_HANDLERS = {
search: async (argv) => {
const a = _parseArgs(argv, {
positional: [{ name: 'query' }],
options: {
...COMMON_FILTERS,
'--topK': { dest: 'topK', type: 'int', default: DEFAULT_LIMIT },
'--offset': { dest: 'offset', type: 'int', default: 0 },
},
});
return searchResources(a.query, {
resourceTypeFilter: a.resourceTypeFilter,
categoryFilter: a.categoryFilter,
topK: a.topK, offset: a.offset,
canonicalOnly: a.canonicalOnly,
widthMin: a.widthMin, widthMax: a.widthMax,
heightMin: a.heightMin, heightMax: a.heightMax,
lengthMin: a.lengthMin, lengthMax: a.lengthMax,
compact: a.compact !== false,
});
},
'search-avatar': async (argv) => {
const a = _parseArgs(argv, {
positional: [{ name: 'query' }],
options: {
'--category': { dest: 'categoryFilter', nargs: '+' },
'--canonical-only': { dest: 'canonicalOnly', const: true },
'--no-canonical-only': { dest: 'canonicalOnly', const: false },
'--no-compact': { dest: 'compact', const: false },
'--topK': { dest: 'topK', type: 'int', default: DEFAULT_LIMIT },
'--offset': { dest: 'offset', type: 'int', default: 0 },
},
});
return searchAvatarItems(a.query, {
topK: a.topK, offset: a.offset,
categoryFilter: a.categoryFilter,
canonicalOnly: a.canonicalOnly,
compact: a.compact !== false,
});
},
similar: async (argv) => {
const a = _parseArgs(argv, {
positional: [{ name: 'ruid' }],
options: {
'--resource-type': { dest: 'resourceTypeFilter', nargs: '+' },
'--category': { dest: 'categoryFilter', nargs: '+' },
'--canonical-only': { dest: 'canonicalOnly', const: true },
'--no-canonical-only': { dest: 'canonicalOnly', const: false },
'--no-compact': { dest: 'compact', const: false },
'--topK': { dest: 'topK', type: 'int', default: DEFAULT_LIMIT },
},
});
return findSimilarResources(a.ruid, {
topK: a.topK,
resourceTypeFilter: a.resourceTypeFilter,
categoryFilter: a.categoryFilter,
canonicalOnly: a.canonicalOnly,
compact: a.compact !== false,
});
},
get: async (argv) => {
const a = _parseArgs(argv, { positional: [{ name: 'ruid' }] });
return getResource(a.ruid);
},
batch: async (argv) => {
const a = _parseArgs(argv, { positional: [{ name: 'ids', nargs: '+' }] });
return getResourcesBatch(a.ids);
},
tags: async (argv) => {
const a = _parseArgs(argv, { positional: [{ name: 'ruid' }] });
return getResourceTags(a.ruid);
},
list: async (argv) => {
const a = _parseArgs(argv, {
options: {
...COMMON_FILTERS,
'--limit': { dest: 'limit', type: 'int', default: DEFAULT_LIMIT },
'--offset': { dest: 'offset' }, // string cursor
},
});
return listResources({
resourceTypeFilter: a.resourceTypeFilter,
categoryFilter: a.categoryFilter,
limit: a.limit, offset: a.offset,
canonicalOnly: a.canonicalOnly,
widthMin: a.widthMin, widthMax: a.widthMax,
heightMin: a.heightMin, heightMax: a.heightMax,
lengthMin: a.lengthMin, lengthMax: a.lengthMax,
compact: a.compact !== false,
});
},
random: async (argv) => {
const a = _parseArgs(argv, {
options: {
...COMMON_FILTERS,
'--count': { dest: 'count', type: 'int', default: DEFAULT_LIMIT },
},
});
return randomResources({
resourceTypeFilter: a.resourceTypeFilter,
categoryFilter: a.categoryFilter,
count: a.count,
canonicalOnly: a.canonicalOnly,
widthMin: a.widthMin, widthMax: a.widthMax,
heightMin: a.heightMin, heightMax: a.heightMax,
lengthMin: a.lengthMin, lengthMax: a.lengthMax,
compact: a.compact !== false,
});
},
packs: async (argv) => {
const a = _parseArgs(argv, {
positional: [{ name: 'ruid' }],
options: {
'--limit': { dest: 'limit', type: 'int', default: DEFAULT_LIMIT },
'--offset': { dest: 'offset' },
'--no-compact': { dest: 'compact', const: false },
},
});
return findPacksContaining(a.ruid, {
limit: a.limit, offset: a.offset,
compact: a.compact !== false,
});
},
avatars: async (argv) => {
const a = _parseArgs(argv, {
options: { '--no-canonical-only': { dest: 'canonicalOnly', const: false } },
});
return listAvatars({ canonicalOnly: a.canonicalOnly !== false });
},
'avatar-defaults': async () => getAvatarDefaults(),
};
async function main(argv = process.argv.slice(2)) {
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
process.stdout.write(CLI_USAGE);
return 0;
}
const [cmd, ...rest] = argv;
const handler = CLI_HANDLERS[cmd];
if (!handler) {
process.stderr.write(`unknown command: ${cmd}\n\n${CLI_USAGE}`);
return 2;
}
try {
const value = await handler(rest);
_printJson(value);
return 0;
} catch (err) {
if (err instanceof MswApiError) {
process.stderr.write(`[msw-api error] ${err.message}\n`);
return 2;
}
process.stderr.write(`[error] ${err && err.message ? err.message : String(err)}\n`);
return 2;
}
}
module.exports = {
BASE_URL,
DEFAULT_TIMEOUT_MS,
DEFAULT_LIMIT,
MswApiError,
searchResources,
searchAvatarItems,
findSimilarResources,
getResource,
getResourcesBatch,
getResourceTags,
listResources,
randomResources,
findPacksContaining,
listAvatars,
getAvatarDefaults,
main,
};
if (require.main === module) {
main().then((code) => process.exit(code));
}
SKILL.md
---
name: msw-search
description: "MSW search integration — (1) vector search for API docs and implementation guides via the msw-mcp MCP server (mlua_api_retriever / mlua_document_retriever), (2) REST API search for resources (sprite / animation / sound / resource pack / avatar). Use for 'find details, examples, or related APIs not in .d.mlua', 'need a SpriteRUID', 'monster sprite', 'background image', 'find a sound', 'avatar item lookup', etc. Keywords: document search, API details, examples, guide, retriever, resource, sprite, animation, sound, RUID, resource pack, avatar."
---
# MSW Search
MSW has **two distinct search targets**:
1. **API docs & implementation guides** — Vector search for descriptions, code examples, and related APIs missing from `.d.mlua`.
2. **Resources** — REST API for sprites, animations, sounds, resource packs, and avatars. The only path for obtaining RUIDs.
---
## Routing Table
| Request type | Go to section |
|--------------|---------------|
| "How do I implement this?", "Show me an example", "What related APIs exist?" | **Document search** |
| ".d.mlua only has the signature; the description is insufficient" | **Document search** |
| "I don't know the API name (semantic search)" | **Document search** |
| "Implementation guide / best practice / pattern" | **Document search** |
| "I need a SpriteRUID", "Find a sprite for monster / NPC / background" | **Resource search** → **start with `resource_pack`** |
| "Find an animation / sound / resource pack" | **Resource search** → **start with `resource_pack`** |
| "Details for this RUID", "Similar resources" | **Resource search** |
| "Avatar item / default avatar lookup" | **Resource search** |
| "Upload / list / update / delete my own assets" | Call `msw-mcp` `asset_*` tools directly |
| "Set sprite pivot", "set 9-slice border", "slice boundary for UI RUID", "asset properties" | Call `msw-mcp` `asset_update_resource_storage_info` directly (`properties: [{ key, value }]` — `pivot_x/y`, `border_left/right/top/bottom`, `filter_mode`, `wrap_mode`) |
> **★ Resource search default — always `resource_pack` first**
>
> Unless the user **explicitly** asks for an individual sprite / animationclip / sound / avatar item (or names a non-pack RUID directly), pass `resourceTypeFilter: ["resource_pack"]` to `searchResources`. A pack bundles every sprite + animation + sound for one asset, so picking a stray `sprite` or `animationclip` first usually leaves the entity with a single frame, no animation set, or the wrong asset family.
>
> Search the pack → drill into `payload.elements` → assign individual RUIDs.
> Switch types only on explicit intent: "BGM file", "individual sprite only", "avatar item", "animationclip similar to this RUID", etc.
---
# Section 1 — Document Search (APIs & Guides)
Vector search via the **`msw-mcp`** MCP server. Supplies the **detailed descriptions, code examples, related APIs, and implementation guides** missing from `.d.mlua`.
## Decision Flow
```
Need API-related information
│
├─ Checking signature / type / property / enum
│ → Read .d.mlua first (highest priority)
│ → If .d.mlua is insufficient, call msw-mcp
│ (code examples, parameter details, related APIs, etc.)
│
├─ Implementation guide / pattern / best practice
│ → mlua_document_retriever
│
└─ Don't know the API name (semantic search)
→ mlua_api_retriever (and/or mlua_document_retriever for broader scope)
```
---
## API Research Order
### Priority 1 — .d.mlua (always first)
If you know the API name, **always read `.d.mlua` first.** Signatures, types, properties, event parameters, and enum values can be confirmed here accurately.
**Path**: `Environment/NativeScripts/{Component,Service,Event,Enum,Logic,Misc}/Name.d.mlua`
| Situation | Example |
|-----------|---------|
| Confirm method signature | "Does TransformComponent have SetPosition?" |
| Property type / existence | "What is the type of SpriteRendererComponent.RUID?" |
| Event parameter structure | "What are the AttackEvent constructor parameters?" |
| List of enum values | "What are the BodyMoveType values?" |
| Method existence | "What methods does SpawnService have?" |
### Priority 2 — Vector search (when .d.mlua is not enough)
`.d.mlua` contains only signatures and **lacks detailed descriptions and examples.** Use vector search when you need any of the following.
| Situation | MCP tool | Example query |
|-----------|----------|---------------|
| Need a **code example** | `mlua_api_retriever` | `AIComponent example`, `BehaviorTree usage` |
| **Parameter details** | `mlua_api_retriever` | `BadgeService GetBadgeInfosAndWait parameters` |
| **Related API** cross-references | `mlua_api_retriever` | `AttackComponent related`, `HitComponent` |
| **ScriptOverridable** check | `mlua_api_retriever` | `AttackComponent CalcCritical override` |
| **Don't know** the API name | both retrievers | `damage calculation`, `inventory save` |
| **"How do I …?"** implementation guide | `mlua_document_retriever` | `how to make inventory system` |
| **Pattern / best practice** | `mlua_document_retriever` | `collision detection best practice` |
---
## MCP Tools (`msw-mcp`)
| Tool | Description |
|------|-------------|
| **`mlua_api_retriever`** | API details for Service / Component / Misc etc. (signatures, parameters, examples). Pass an API/class/function/component name. |
| **`mlua_document_retriever`** | Authoring manuals, guidelines, MLua usage, and other document-style material. Pass a natural-language sentence describing what to implement. |
**On failure**: If a `msw-mcp` tool call errors out, surface the failure to the user and fall back to `.d.mlua`. Do not guess — state what you couldn't verify.
**Default result count**: request `3` results unless wider exploration is explicitly required.
---
## .d.mlua vs Search — Information Comparison
`.d.mlua` is a type stub (~29 lines); Search returns the full document (254+ lines).
| Information | .d.mlua | Search |
|-------------|:-------:|:------:|
| Method signature / types | **O** | O |
| Property declarations | **O** | O |
| Detailed method description (DetailDesc) | X | **O** |
| Code examples (AdditionalPageContent) | X | **O** |
| Per-parameter descriptions | X | **O** |
| Related APIs (SeeAlsoAPIs) | X | **O** |
| Related guides (SeeAlsoGuides) | X | **O** |
| ScriptOverridable flag | X | **O** |
| SyncDirection | Partial | **O** |
| Localized descriptions (Ko/Ja/Es/Zh) | X | **O** |
---
## Maker Editor Syntax → .mlua Conversion Rules
Code examples in search results use **Maker Editor syntax**. They must be converted before being used in a local `.mlua` file.
| Item | Maker Editor | .mlua file | Note |
|------|--------------|------------|------|
| Override declaration | `override integer CalcDamage(...)` | `method integer CalcDamage(...)` | `override` → `method` |
| Block | `{ ... }` | `... end` | Braces → `end` |
| Exec space (own method) | `[server only]` | `@ExecSpace("ServerOnly")` | Self-defined methods: annotate explicitly |
| Exec space (override) | `[server only]` shown / omitted in editor | **Match the parent's `@ExecSpace` exactly** — see warning below | LEA-3014 if mismatched |
| Property | `Property: int32 Score = 0` | `@Sync property int32 Score = 0` | Add `@Sync` if synced |
| Type `int` | `int` | `integer` | C# int → mlua integer |
| Type `number` | `number` | `number` | Same (double) |
| Type `float` | `float` | `float` | Same (single) |
> `number` (64-bit double) and `float` (32-bit single) are assignable to each other but remain distinct types. Follow the `.d.mlua` declaration.
> ⚠ **Override ExecSpace caveat — LEA-3014 `SignatureMismatch`**
>
> The Maker Editor often **hides** the parent's exec space and lets you toggle `[server only]` freely on an `override` block. In `.mlua`, however, the override's `@ExecSpace` must be **byte-identical** to the parent declared in `.d.mlua`. If the parent has no `@ExecSpace` (engine default = `ExecSpace=All`), the override must also **omit** `@ExecSpace` entirely.
>
> Concretely, the AttackComponent / HitComponent damage hooks (`CalcDamage`, `CalcCritical`, `GetCriticalDamageRate`, `GetDisplayHitCount`, `IsAttackTarget`, `IsHitTarget`, `OnAttack`) are all `ExecSpace=All` upstream. Adding `@ExecSpace("ServerOnly")` produces:
>
> ```
> [LEA-3014] SignatureMismatch : The signature of <Child>.CalcDamage[... (ExecSpace=ServerOnly)]
> must match the overridden <Parent>.CalcDamage.[... (ExecSpace=All)].
> ```
>
> Always look up the parent in `.d.mlua` first and copy its annotation block verbatim. Detail: [`msw-scripting/SKILL.md` §9 "Method override → LEA-3014"](../msw-scripting/SKILL.md).
**Conversion example** — AttackComponent from search results:
```
-- Maker Editor syntax (search result)
override int CalcDamage(Entity attacker, Entity defender, string attackInfo) {
return 50
}
override boolean CalcCritical(Entity attacker, Entity defender, string attackInfo) {
return _UtilLogic:RandomDouble() < 0.3
}
```
```lua
-- Converted to .mlua
-- ⚠ Parent AttackComponent.CalcDamage / CalcCritical declare no @ExecSpace
-- (ExecSpace=All). Adding @ExecSpace here triggers LEA-3014 SignatureMismatch.
method integer CalcDamage(Entity attacker, Entity defender, string attackInfo)
return 50
end
method boolean CalcCritical(Entity attacker, Entity defender, string attackInfo)
return _UtilLogic:RandomDouble() < 0.3
end
```
---
# Section 2 — Resource Search (Sprite / Animation / Sound / Resource Pack / Avatar)
REST API for searching and browsing MSW resources.
Never guess or fabricate a RUID — **always obtain one through this API**.
> **Default search type = `resource_pack`** — see the pack-first rule under the Routing Table above.
## Access — always go through `msw_resource_api.cjs`
All resource-API calls in this skill are made through the Node.js wrapper
```
scripts/msw_resource_api.cjs
```
**Do not assemble curl commands by hand.** The wrapper:
- Sends UTF-8 JSON bodies directly, so non-ASCII queries (Korean / Japanese / Chinese / emoji) avoid the `{"detail":"There was an error parsing the body"}` failure mode that hits inline `curl -d '...'`.
- URL-encodes slash-containing path parameters (e.g. pack IDs like `npc/1013617.img`).
- Zero dependencies (Node 18+ built-in `fetch` / `AbortController`).
- Uses the **exact OpenAPI field names** (`topK`, `resourceTypeFilter`, `categoryFilter`, `count`, …). Legacy names like `limit` / `types` / `categories` are silently ignored by the server.
Two ways to use it:
```bash
# 1) CLI — fire one call from a shell. Output is pretty-printed JSON.
node scripts/msw_resource_api.cjs \
search "orange mushroom" --resource-type resource_pack --category npc --topK 3
# Discover available subcommands:
node scripts/msw_resource_api.cjs --help
```
```js
// 2) require — preferred when already in a Node.js context.
const {
searchResources, searchAvatarItems, findSimilarResources,
getResource, getResourcesBatch, getResourceTags,
listResources, randomResources, findPacksContaining,
listAvatars, getAvatarDefaults,
} = require('./scripts/msw_resource_api.cjs');
const result = await searchResources("orange mushroom", {
resourceTypeFilter: ["resource_pack"],
categoryFilter: ["npc"],
topK: 3,
});
```
## Wrapper function ↔ endpoint map
| Wrapper function | CLI subcommand | Endpoint |
|------------------|----------------|----------|
| `searchResources` | `search` | `POST /v3/search/resources` |
| `searchAvatarItems` | `search-avatar` | `POST /v3/search/resources` (avatar mode) |
| `findSimilarResources` | `similar` | `GET /v3/search/resources/similar/{ruid}` |
| `getResource` | `get` | `GET /v3/resources/{ruid}` (works for sprite / animationclip / resource_pack / avataritem) |
| `getResourcesBatch` | `batch` | `POST /v3/resources/batch` |
| `getResourceTags` | `tags` | `GET /v3/resources/tags/{ruid}` |
| `listResources` | `list` | `GET /v3/resources` (Qdrant Scroll, opaque-string `offset` cursor) |
| `randomResources` | `random` | `GET /v3/resources/random` |
| `findPacksContaining` | `packs` | `GET /v3/resources/packs/{ruid}` (lists packs **containing** a RUID — pack id is NOT accepted here) |
| `listAvatars` | `avatars` | `GET /v3/avatars` |
| `getAvatarDefaults` | `avatar-defaults` | `GET /v3/avatars/defaults` |
> **No `/v3/avatars/{ruid}` endpoint exists.** To inspect an avataritem
> (color_hex, group members, …), call `getResource(ruid)` — the
> `/v3/resources/{ruid}` endpoint returns avataritem detail just like
> any other resource.
## Base URL & transport (informational)
The wrapper handles all of this — you do not need to set it manually.
- Base URL: `https://maplestoryworlds-resourcesearch-new.nexon.com/api`
- No auth (public), `/v3/` prefix, POST bodies are `application/json; charset=utf-8`
- Default timeout: 15s (override via the wrapper's `_request(method, path, { timeout })`)
### Result count — this skill's default is **3**
Unless explicitly told otherwise, **always send `3`** for the result-count parameter
on every search call. The wrapper defaults to 3 as well, and parameter names follow
the OpenAPI spec exactly — note that `limit` / `count` / `topK` differ per endpoint.
| Endpoint | Server parameter | Wrapper default |
|----------|------------------|:---------------:|
| `POST /v3/search/resources` (resources + avatar) | `topK` | **3** |
| `GET /v3/search/resources/similar/{ruid}` | `topK` | **3** |
| `GET /v3/resources` (browsing) | `limit` | **3** |
| `GET /v3/resources/random` | `count` | **3** |
| `GET /v3/resources/packs/{ruid}` (packs containing a RUID) | `limit` | **3** |
> The server-side default is 20 or 50, so **always pass these parameters explicitly**.
> Increase to 10+ (or 50–100 for avatar broad-browse) only when wider exploration is explicitly required.
> **`offset` parameter caveat** — for `GET /v3/resources` and `GET /v3/resources/packs/{ruid}`,
> `offset` is **not an integer** but the **opaque string cursor `nextOffset` returned by the previous response**.
> Do not send it on the first page (sending integer `0` is interpreted as a cursor and returns empty results).
### POST body rule — let `msw_resource_api.cjs` handle it
If you must POST without the wrapper (no HTTP client in your language), reproduce its behaviour:
1. Serialize the body as **UTF-8 JSON bytes** (not a re-encoded shell string).
2. Send `Content-Type: application/json; charset=utf-8`.
3. POST raw bytes (e.g. curl's `--data-binary "@file"` reading a UTF-8 temp file).
Otherwise, just call the wrapper.
## Resource Types
`type` values (the `type` field on server responses, and the values you put
into the `resourceTypeFilter` array when searching):
| type | Description |
|------|-------------|
| `sprite` | Static image (PNG) |
| `animationclip` | Frame-based animation |
| `resource_pack` | Finished asset bundling sprites + animations + sounds |
| `bgm` | Background music (audio) |
| `voice` | Voice clip — NPC dialogue, etc. (audio) |
| `effect` | **Sound effect (audio).** Not a visual effect. For visual particles / hit / skill FX, search `sprite` or `animationclip` (categories `skill` / `mob` / `etc`). |
| `avataritem` | Avatar costume item (cap, coat, pants, shoes, weapon, …) — same `POST /v3/search/resources` endpoint with `resourceTypeFilter: ["avataritem"]`. See [`references/resource/search.md`](references/resource/search.md) ("Avatar Item Search") and [`references/resource/avatar.md`](references/resource/avatar.md). |
> All search and listing endpoints use the same type-filter field name: **`resourceTypeFilter`**
> (an array). Other names like `types` are silently ignored by the server.
> The wrapper's `resource_type_filter` argument (or CLI `--resource-type`) maps to this field.
> ⚠ **`SpriteRendererComponent.SpriteRUID` accepts both `sprite` and `animationclip`, but renders them differently:**
> - `animationclip` → all frame layers play (shadow + body + foreground)
> - `sprite` → that single Sprite renders only
>
> Symptom of mistake: feeding an `animationclip` RUID where you intended a `sprite` (or vice-versa) leaves only the shadow layer visible — the body silently vanishes. Always check `payload.type` of the response before assigning to `SpriteRUID`. Use `sprite` for the static idle/default frame; use `animationclip` only for fields like `StateAnimationComponent.ActionSheet` values.
>
> **`skeleton` and `avataritem` RUIDs fail silently (no error, nothing renders) when assigned to `SpriteRUID` / `ImageRUID` without the `thumbnail://` prefix.** Conversely, `CostumeManagerComponent.Custom*Equip` / `SkeletonRendererComponent.SkeletonRUID` / `StateAnimationComponent.ActionSheet` do **not** accept the `thumbnail://` prefix — pass a plain RUID there. If the search query targeted an icon / thumbnail image and returned a `sprite` RUID, that RUID is already renderable directly — adding `thumbnail://` is redundant. Full assignment rules — accepted types, slot-by-slot prefix matrix, RUID-vs-prefix usage — live in [`msw-sprite-ruid/SKILL.md`](../msw-sprite-ruid/SKILL.md).
## Categories
`category` values that actually appear on responses. Use these with `categoryFilter`.
### General resources (`sprite` / `animationclip` / `resource_pack` / `bgm` / `voice` / `effect`)
| category | Description |
|----------|-------------|
| `mob` | Monster |
| `npc` | NPC |
| `item` | Item |
| `skill` | Skill effect / skill resources |
| `object` | Map object (tree, rock, decoration) |
| `background` | Background / map tile / BGM |
| `foothold` | Walkable platform |
| `rope` | Rope |
| `ladder` | Ladder |
| `etc` | Uncategorized |
### Avatar (`avataritem` only)
| category | Slot |
|----------|------|
| `cap`, `hair`, `face`, `faceaccessory`, `eyeaccessory`, `earaccessory` | Head / face |
| `coat`, `longcoat`, `pants`, `shoes`, `glove`, `cape` | Body |
| `weapon`, `twohandweapon`, `subweapon`, `shield` | Weapon |
> `map`, `effect`, `ui` are **not** valid category values — they return zero results.
> - Looking for maps / backgrounds → `category: "background"` or `"object"`.
> - Looking for **visual effects** → search `sprite` / `animationclip` with `category: "skill"` (or `mob`/`etc`); `effect` is the **audio** resource_type, not a category.
> - There is no `ui` resource family in this index — UI sprites usually live as `sprite` + `category: "etc"`.
## RUID
A 32-character hex string that uniquely identifies every resource. Example: `"0017da7385e04bc4b2ddbe5949b4b462"`
- The `id` field in search results is the RUID
- `assetGuid` is a separate Unity asset GUID (used in `spawn_preset`)
- Never guess or fabricate a RUID — always obtain it from an API response
## Common Response Fields
```json
{
"id": "32-char hex RUID",
"type": "sprite|animationclip|resource_pack|bgm|voice|effect|avataritem",
"category": "mob|npc|item|skill|object|background|foothold|rope|ladder|etc | <avatar slot>",
"names": {
"ko": ["Korean name"],
"en": ["English name"]
},
"assetGuid": "Unity asset GUID (may or may not exist)",
"payload": {
"width": 64,
"height": 64,
"thumbnail": "https://...",
"pivot": {"x": 32, "y": 32},
"frames": [],
"elements": []
}
}
```
## Pagination — same name, two flavors
`nextOffset` appears in every list-style response but means **different things** depending on the endpoint. Round-tripping a value into the wrong endpoint silently misbehaves.
| Endpoint | `nextOffset` type | Meaning | How to paginate |
|---|---|---|---|
| `POST /v3/search/resources` (search) | **integer** | Item offset (0-based) | Pass it back as `offset` (number) |
| `GET /v3/search/resources/similar/{id}` (similar) | **integer** | Item offset | Same |
| `GET /v3/resources` (list) | **opaque UUID string** | Qdrant Scroll cursor | Pass the string back as `offset`. **End-of-stream = `null`** |
| `GET /v3/resources/packs/{ruid}` (packs) | **opaque UUID string** | Same cursor | Same |
| `GET /v3/resources/random` | n/a | No pagination | — |
**Rules:**
1. Never feed a `list` cursor into a `search` call (or vice versa) — the server ignores the wrong-shape value and returns the first page.
2. On the **first page**, omit `offset` entirely. Sending integer `0` to `list` / `packs` is interpreted as a cursor and yields **zero items** (silent failure).
3. Stop paginating when the response returns `nextOffset: null` (list / packs) or returns fewer items than `topK` (search / similar).
## Endpoint Summary
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/v3/search/resources` | Natural-language semantic search (incl. avatar items via `resourceTypeFilter: ["avataritem"]`) |
| GET | `/v3/search/resources/similar/{ruid}` | Find similar resources |
| GET | `/v3/resources/{ruid}` | Single resource details (sprite / animationclip / **resource_pack with populated elements** / **avataritem**) |
| POST | `/v3/resources/batch` | Batch fetch multiple resources |
| GET | `/v3/resources/tags/{ruid}` | AI-generated multilingual tags |
| GET | `/v3/resources` | List resources (Qdrant Scroll, opaque-string `offset` cursor) |
| GET | `/v3/resources/random` | Random resource recommendation |
| GET | `/v3/resources/packs/{ruid}` | List resource packs **containing** the given RUID — the path parameter is a 32-char-hex RUID, not a pack id |
| GET | `/v3/avatars` | List all avatar items (cached) |
| GET | `/v3/avatars/defaults` | Default avatar body / head RUIDs |
> Single avataritem detail uses `/v3/resources/{ruid}` (no
> `/v3/avatars/{ruid}` endpoint exists).
---
## Resource Routing Guide
> **★ When in doubt, search `resource_pack` first.** Only the rows marked with an
> explicit non-pack intent below should bypass the pack-first default.
| Situation | Wrapper call (CLI subcommand) | Reference file |
|-----------|-------------------------------|----------------|
| "Find a slime / orange mushroom / monster / NPC / item / background / map asset" (default — no type specified) | `searchResources(query, { resourceTypeFilter: ["resource_pack"], ... })` (`search ... --resource-type resource_pack`) | [`references/resource/search.md`](references/resource/search.md) |
| "Find an **individual sprite** / single image" (user explicitly asked for a sprite) | `searchResources(query, { resourceTypeFilter: ["sprite"], ... })` | [`references/resource/search.md`](references/resource/search.md) |
| "Find an **individual animationclip**" (user explicitly asked for an animation) | `searchResources(query, { resourceTypeFilter: ["animationclip"], ... })` | [`references/resource/search.md`](references/resource/search.md) |
| "Find a **visual effect / particle / hit FX**" | `searchResources(query, { resourceTypeFilter: ["animationclip","sprite"], categoryFilter: ["skill","mob","etc"] })` — note: `effect` here would mean **audio**, not visual | [`references/resource/search.md`](references/resource/search.md) |
| "Find a **sound / BGM / voice / sound-effect**" (audio) | `searchResources(query, { resourceTypeFilter: ["bgm"\|"voice"\|"effect"], ... })` — `effect` resource_type = sound-effect (audio) | [`references/resource/search.md`](references/resource/search.md) |
| "Find a **background / map tile / scenery**" | `searchResources(query, { resourceTypeFilter: ["sprite","animationclip"], categoryFilter: ["background","object"] })` — there is no `map` category in the index | [`references/resource/search.md`](references/resource/search.md) |
| "Find a costume / hat / shoes / weapon (avatar item)" | `searchAvatarItems(...)` (`search-avatar`) | [`references/resource/search.md`](references/resource/search.md) (Avatar Item Search section) + [`references/resource/avatar.md`](references/resource/avatar.md) |
| "Any more monsters like this one?" | `findSimilarResources(ruid, ...)` (`similar`) | [`references/resource/search.md`](references/resource/search.md) |
| "Details for RUID abc123" (any type incl. avataritem and resource_pack) | `getResource(ruid)` (`get`) | [`references/resource/detail.md`](references/resource/detail.md) |
| "Show me a list of monster sprites" | `listResources(...)` (`list`) | [`references/resource/browse.md`](references/resource/browse.md) |
| "Which resource packs include this RUID?" | `findPacksContaining(ruid, ...)` (`packs`) | [`references/resource/browse.md`](references/resource/browse.md) |
| "Browse all avatar items" | `listAvatars(...)` (`avatars`) | [`references/resource/avatar.md`](references/resource/avatar.md) |
### Typical Workflow (pack-first)
```
1. searchResources(query, { resourceTypeFilter: ["resource_pack"], topK: 3 })
→ obtain a resource_pack RUID (or pack id like "npc/9072309.img")
→ switch types only on explicit user intent, or fall back when 0 packs match
2. getResource(id)
→ resource_pack: payload.elements is pre-populated with element payloads
(sprite / animationclip / sound RUIDs live here)
→ avataritem: payload has color_hex / group meta
3. Pick the element from payload.elements and assign its RUID to
SpriteRendererComponent.SpriteRUID / StateAnimationComponent.ActionSheet
(or assign avataritem RUIDs through the slot mapping in `msw-avatar`)
```
> **Don't** call `findPacksContaining(packId)` to "open" a pack — that endpoint takes a 32-hex RUID and returns the **packs that include that RUID**, not the contents of a pack. Use `getResource(packId)` for pack contents.
> **For detailed Request/Response of each endpoint, refer to the files under `references/resource/`.**
---
## Sprite Orientation — Most Resources Face Left
Most MSW sprite / animationclip / resource_pack assets — especially `mob`, `npc`, and player-character — are authored **facing left**, so a freshly spawned `SpriteRendererComponent` renders left unless you flip it.
| Situation | What to do |
|-----------|-----------|
| Spawn an entity that should face **right** | Set `FlipX = true` on `SpriteRendererComponent` (default is `false` = left-facing as authored) |
| Custom AI / chase using `MovementComponent:MoveToDirection` | Update `FlipX` on direction change: `sprite.FlipX = velocity.x > 0` (right ⇒ flip) |
| Monster model / monster collider alignment | Invert `TransformComponent.Scale.x` instead of `FlipX` so the sprite and collider stay aligned; see [`msw-general/references/monster.md`](../msw-general/references/monster.md) |
| Native `AIChaseComponent` / `AIWanderComponent` | Engine flips automatically based on movement — do nothing |
| Top-down (`RectTile`) movement | Decide per-axis: usually flip when `dx > 0`; sprites with up/down frames need the StateAnimationComponent action set instead |
| `_EffectService:PlayEffect(...)` should face right | Pass `FlipX = true` in the `options` table |
| Player-attached effect must follow the player's facing | Use `SyncFlip = true` in `PlayEffect` options, or read `PlayerControllerComponent.LookDirectionX` |
| Resource is authored facing right (rare) | Inspect `payload.thumbnail` via `GET /v3/resources/{ruid}` and invert the rule for that asset |
```lua
-- Custom side-view chase: flip sprite to match movement direction
local sprite = self.Entity.SpriteRendererComponent
local selfX = self.Entity.TransformComponent.WorldPosition.x
local dx = targetPos.x - selfX
if dx ~= 0 then
sprite.FlipX = dx > 0 -- target on the right → flip
end
```
> **Sanity check** — the left-facing convention is not contractual. Open `payload.thumbnail` from `GET /v3/resources/{ruid}` to confirm.
>
> **Do not use `TransformComponent.Scale.x` as a general renderer flip** — for players / effects / non-monster renderers, use `SpriteRendererComponent.FlipX`. **Monster exception**: monster models should invert `TransformComponent.Scale.x` so the sprite and collider stay aligned. Related: [`msw-combat-system/SKILL.md` "Direction check ★"](../msw-combat-system/SKILL.md), [`msw-general/references/monster.md`](../msw-general/references/monster.md).
---
# Shared Tips
1. **Keyword choice** — Use the exact name if you know it; natural-language Korean/English also works.
2. **Adjust the page-size parameter** — the name differs per endpoint (`topK` for search/similar, `limit` for list/packs, `count` for random). **This skill's default is 3** (see the "Result count" table above). Keep it at 3 for precise lookups; increase to 10+ (or 50–100 for avatar broad-browse) only when wider exploration is required.
3. **When search fails**:
- Document search fails → read `.d.mlua` directly.
- Resource search fails → retry with synonyms or a different category; browse with `listResources(...)` (CLI: `list`) by type/category.
- `POST` returns `{"detail":"There was an error parsing the body"}` → you bypassed
the wrapper and sent JSON inline via `curl -d '{...}'`. Switch to
`msw_resource_api.cjs` (or replicate its UTF-8 raw-body POST pattern) as
described in Section 2.
4. **Composite queries are allowed** — e.g. `AttackComponent CalcCritical` for docs, `red slime jump` for resources.
5. **No guessing** — Never guess API names, RUIDs, or enum values; always confirm via search or references.