Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/tray-ui/src/components/kb/TrayEntityLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
ReferenceCategory,
ReferenceEntry,
} from '../../lib/reference';
import { webKbUrl } from '../../lib/reference';
import { resolveReferenceEntry, webKbUrl } from '../../lib/reference';
import { TierChip } from './TierChip';

interface Props {
Expand Down Expand Up @@ -69,8 +69,10 @@ export function TrayEntityLink({
return <span>{label ?? ''}</span>;
}

const entry: ReferenceEntry | undefined = catalog?.get(
classKey.toLowerCase(),
const entry: ReferenceEntry | undefined = resolveReferenceEntry(
category,
classKey,
catalog,
);
const text = label ?? entry?.display_name ?? classKey;

Expand Down
101 changes: 101 additions & 0 deletions apps/tray-ui/src/lib/reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';

import {
findEntityInBundles,
isCosmeticItemPort,
isNonLinkableItemClass,
resolveReferenceEntry,
type AllReferenceBundles,
type CategoryBundle,
type ReferenceCatalog,
type ReferenceCategory,
type ReferenceEntry,
} from './reference';

function refEntry(
category: ReferenceCategory,
class_name: string,
display_name: string,
slug: string | null = null,
): ReferenceEntry {
return { category, class_name, display_name, slug, summary: { category } };
}

function makeCatalog(entries: ReferenceEntry[]): ReferenceCatalog {
const m = new Map<string, ReferenceEntry>();
for (const e of entries) m.set(e.class_name.toLowerCase(), e);
return m;
}

function bundle(entries: ReferenceEntry[]): CategoryBundle {
return { map: new Map(), catalog: makeCatalog(entries), list: entries };
}

describe('resolveReferenceEntry — tray mirror', () => {
const vehicles = makeCatalog([
refEntry('vehicle', 'ARGO_MOLE', 'ARGO MOLE', 'argo-mole'),
]);

it('strips _Teach loaner suffix', () => {
expect(
resolveReferenceEntry('vehicle', 'ARGO_MOLE_Teach', vehicles)?.slug,
).toBe('argo-mole');
});

it('resolves exact + case-insensitive', () => {
expect(resolveReferenceEntry('vehicle', 'argo_mole', vehicles)?.slug).toBe(
'argo-mole',
);
});

it('filters avatar/structural item noise', () => {
const items = makeCatalog([
refEntry('item', 'grin_multitool_01', 'Greycat Multi-Tool', 'multitool'),
]);
expect(
resolveReferenceEntry('item', 'Head_Eyelashes', items),
).toBeUndefined();
expect(
resolveReferenceEntry('item', 'grin_multitool_01', items)?.slug,
).toBe('multitool');
});
});

describe('isNonLinkableItemClass / isCosmeticItemPort — tray mirror', () => {
it('flags noise classes but not equipment', () => {
expect(isNonLinkableItemClass('Default')).toBe(true);
expect(isNonLinkableItemClass('Shared_Scalp_Unified')).toBe(true);
expect(isNonLinkableItemClass('grin_multitool_01')).toBe(false);
});

it('flags cosmetic ports but not equipment ports', () => {
expect(isCosmeticItemPort('Hair_ItemPort')).toBe(true);
expect(isCosmeticItemPort('weapon_attach_hand_right')).toBe(false);
expect(isCosmeticItemPort(null)).toBe(false);
});
});

describe('findEntityInBundles — applies noise + suffix logic', () => {
const bundles: AllReferenceBundles = {
vehicle: bundle([refEntry('vehicle', 'ARGO_MOLE', 'ARGO MOLE', 'argo-mole')]),
weapon: bundle([]),
item: bundle([
refEntry('item', 'grin_multitool_01', 'Greycat Multi-Tool', 'multitool'),
]),
location: bundle([]),
};

it('finds a loaner variant via suffix strip', () => {
const hit = findEntityInBundles('ARGO_MOLE_Teach', bundles);
expect(hit?.category).toBe('vehicle');
expect(hit?.entry.slug).toBe('argo-mole');
});

it('does not bind avatar noise even though it probes the item catalog', () => {
expect(findEntityInBundles('Head_Eyelashes', bundles)).toBeNull();
});

it('returns null for a genuinely unknown identifier', () => {
expect(findEntityInBundles('NOPE_Unknown_Thing', bundles)).toBeNull();
});
});
79 changes: 77 additions & 2 deletions apps/tray-ui/src/lib/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,74 @@ export function webKbUrl(
return `${base}/kb/${category}`;
}

/**
* Variant / loaner suffixes appended to a base class name in some
* event payloads but absent from the wiki catalogue. Mirrors the web
* `apps/web/src/lib/reference-types.ts`. Stripped as a second lookup
* attempt so `ARGO_MOLE_Teach` resolves to `ARGO_MOLE`. Lowercased.
*/
const VARIANT_SUFFIXES: readonly string[] = ['_teach', '_loaner'];

/**
* Item class identifiers that are character-avatar parts, structural
* placeholders, or engine defaults — never catalogued equipment. Keep
* in sync with the web mirror. Match is case-insensitive.
*/
const NON_LINKABLE_ITEM_PATTERNS: readonly RegExp[] = [
/^default(_|$)/i,
/^head_/i,
/^body_/i,
/^shared_scalp/i,
/^pu_protos/i,
/^fp_visor$/i,
/^fps_default/i,
/lensdisplay/i,
];

/** True when an item class is avatar/structural noise. Pure. */
export function isNonLinkableItemClass(classKey: string): boolean {
return NON_LINKABLE_ITEM_PATTERNS.some((re) => re.test(classKey));
}

const COSMETIC_ITEM_PORTS: readonly RegExp[] = [
/^(eyes|hair|eyelashes|eyebrow|beard|teeth|head|face)_itemport$/i,
/^body_itemport$/i,
/_scalp/i,
];

/** True when an item PORT is avatar customisation / structural. Pure. */
export function isCosmeticItemPort(port: string | null | undefined): boolean {
if (!port) return false;
return COSMETIC_ITEM_PORTS.some((re) => re.test(port));
}

/**
* Resolve a raw class identifier within a single category's catalog,
* applying the item-noise filter and variant-suffix strip. Mirror of
* the web `resolveReferenceEntry`. Pure; returns undefined on miss.
*/
export function resolveReferenceEntry(
category: ReferenceCategory,
classKey: string | null | undefined,
catalog: ReferenceCatalog | undefined,
): ReferenceEntry | undefined {
if (!classKey || !catalog) return undefined;
if (category === 'item' && isNonLinkableItemClass(classKey)) {
return undefined;
}
const key = classKey.toLowerCase();
const direct = catalog.get(key);
if (direct) return direct;
if (category === 'location') return undefined;
for (const suffix of VARIANT_SUFFIXES) {
if (key.endsWith(suffix) && key.length > suffix.length) {
const stripped = catalog.get(key.slice(0, -suffix.length));
if (stripped) return stripped;
}
}
return undefined;
}

/** Locate a class identifier across all four catalogues. Used by
* the ReactNode prettifier (`prettifySummaryReact`) — the regex
* picks tokens out of a server-rendered summary string without
Expand All @@ -337,16 +405,23 @@ export function webKbUrl(
* practice because the wiki sync namespaces by category, but the
* iteration order is deterministic if they ever did.
*
* Applies the item-noise filter + variant-suffix strip per category
* via `resolveReferenceEntry`, so loaner variants resolve and avatar
* noise doesn't bind.
*
* Returns `null` when no catalogue claims the identifier — the
* caller falls back to the raw string in that case (same
* behaviour as the legacy `prettifySummary`). */
export function findEntityInBundles(
classKey: string,
bundles: AllReferenceBundles,
): { category: ReferenceCategory; entry: ReferenceEntry } | null {
const key = classKey.toLowerCase();
for (const category of REFERENCE_CATEGORIES) {
const entry = bundles[category].catalog.get(key);
const entry = resolveReferenceEntry(
category,
classKey,
bundles[category].catalog,
);
if (entry) return { category, entry };
}
return null;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/kb/EntityLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ReferenceCatalog,
ReferenceCategory,
} from '@/lib/reference-types';
import { resolveReferenceEntry } from '@/lib/reference-types';
import { toFriendlyName } from '@/lib/heuristic-name';
import { EntityHoverCard } from './EntityHoverCard';
import { TierChip } from './TierChip';
Expand Down Expand Up @@ -71,7 +72,7 @@ export function EntityLink({
return <span>{label ?? ''}</span>;
}

const entry = catalog?.get(classKey.toLowerCase());
const entry = resolveReferenceEntry(category, classKey, catalog);
const text = label ?? entry?.display_name ?? toFriendlyName(classKey);

// Tier chip is opt-in via `showTier` and only meaningful for
Expand Down
127 changes: 127 additions & 0 deletions apps/web/src/lib/reference-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest';

import {
isCosmeticItemPort,
isNonLinkableItemClass,
placementLabel,
resolveReferenceEntry,
subtypeLabel,
tierLabel,
type LocationSummary,
type Placement,
type ReferenceCatalog,
type ReferenceEntry,
} from './reference-types';

describe('tierLabel', () => {
Expand Down Expand Up @@ -90,3 +95,125 @@ describe('LocationSummary backward compat', () => {
}
});
});

function refEntry(
category: ReferenceEntry['category'],
class_name: string,
display_name: string,
slug: string | null = null,
): ReferenceEntry {
return { category, class_name, display_name, slug, summary: { category } };
}

/** Catalog keyed by lowercased class_name, mirroring getCategoryBundle. */
function makeCatalog(entries: ReferenceEntry[]): ReferenceCatalog {
const m = new Map<string, ReferenceEntry>();
for (const e of entries) m.set(e.class_name.toLowerCase(), e);
return m;
}

describe('resolveReferenceEntry — variant-suffix strip (workstream A)', () => {
const vehicles = makeCatalog([
refEntry('vehicle', 'ARGO_MOLE', 'ARGO MOLE', 'argo-mole'),
refEntry('vehicle', 'DRAK_Vulture', 'Drake Vulture', 'drake-vulture'),
]);

it('resolves an exact (and case-insensitive) class name', () => {
expect(resolveReferenceEntry('vehicle', 'ARGO_MOLE', vehicles)?.slug).toBe(
'argo-mole',
);
expect(resolveReferenceEntry('vehicle', 'argo_mole', vehicles)?.slug).toBe(
'argo-mole',
);
});

it('strips the _Teach loaner suffix to the base class', () => {
// The two real misses found in the live tray DB (93 + 13 events).
expect(
resolveReferenceEntry('vehicle', 'ARGO_MOLE_Teach', vehicles)?.slug,
).toBe('argo-mole');
expect(
resolveReferenceEntry('vehicle', 'DRAK_Vulture_Teach', vehicles)?.slug,
).toBe('drake-vulture');
});

it('does not over-strip when there is no catalogued base', () => {
expect(
resolveReferenceEntry('vehicle', 'SOME_Unknown_Teach', vehicles),
).toBeUndefined();
});

it('returns undefined when no catalog is supplied', () => {
expect(
resolveReferenceEntry('vehicle', 'ARGO_MOLE', undefined),
).toBeUndefined();
});
});

describe('isNonLinkableItemClass — item noise filter (workstream D)', () => {
it('flags avatar / structural / default classes', () => {
for (const c of [
'Default',
'Default_LensDisplay_PU',
'Head_Eyelashes',
'Head_Teeth',
'body_01_noMagicPocket',
'Shared_Scalp_Unified',
'PU_Protos_Head',
'FP_Visor',
'FPS_DefaultRadar_Lens',
]) {
expect(isNonLinkableItemClass(c), c).toBe(true);
}
});

it('does NOT flag genuine equipment', () => {
for (const c of [
'grin_multitool_01',
'klwe_pistol_energy_01_mag',
'crlf_consumable_healing_01',
'behr_gren_frag_01',
]) {
expect(isNonLinkableItemClass(c), c).toBe(false);
}
});

it('keeps noise item classes from resolving (renders plain text)', () => {
const items = makeCatalog([
refEntry('item', 'Head_Eyelashes', 'Eyelashes', 'eyelashes'),
refEntry('item', 'grin_multitool_01', 'Greycat Multi-Tool', 'multitool'),
]);
expect(
resolveReferenceEntry('item', 'Head_Eyelashes', items),
).toBeUndefined();
expect(
resolveReferenceEntry('item', 'grin_multitool_01', items)?.slug,
).toBe('multitool');
});
});

describe('isCosmeticItemPort', () => {
it('flags avatar / structural ports, not equipment ports', () => {
for (const p of [
'Eyes_ItemPort',
'Hair_ItemPort',
'Eyelashes_ItemPort',
'Body_ItemPort',
]) {
expect(isCosmeticItemPort(p), p).toBe(true);
}
for (const p of [
'weapon_attach_hand_right',
'magazine_attach',
'Armor_Helmet',
'utility_attach_1',
]) {
expect(isCosmeticItemPort(p), p).toBe(false);
}
});

it('handles null / undefined', () => {
expect(isCosmeticItemPort(null)).toBe(false);
expect(isCosmeticItemPort(undefined)).toBe(false);
});
});
Loading
Loading