UNPKG

@zerospacegg/vynthra

Version:
216 lines (189 loc) 6.27 kB
import type { Entity } from "@zerospacegg/iolin"; import { allEntities } from "@zerospacegg/iolin/meta/all"; import type { Summary } from "@zerospacegg/iolin/meta/search-index"; import { getIndexedEntities, getSearchIndex } from "@zerospacegg/iolin/meta/search-index"; import { createFuzzyMatcher, exactMatch, fuzzySearch } from "./fuzzy.js"; import type { ScoredMatch, SearchOptions, SearchResult } from "./types.js"; // Create fuzzy matchers for different search fields const allEntitySummaries: Summary[] = getIndexedEntities(); const idMatcher = createFuzzyMatcher<Summary>(allEntitySummaries, (entity) => entity.id); const slugMatcher = createFuzzyMatcher<Summary>(allEntitySummaries, (entity) => entity.slug); const nameMatcher = createFuzzyMatcher<Summary>(allEntitySummaries, (entity) => entity.name); /** * Load the full entity data for a given entity summary */ function loadFullEntity(entity: Summary): Entity { const { id } = entity; // Get all full entities and find the matching one const fullEntities = allEntities(); const fullEntity = fullEntities.find(e => e.id === id); if (!fullEntity) { throw new Error(`Entity not found: ${id}`); } return fullEntity; } /** * Search for entities by query string * * Priority order: * 1. Exact ID match * 2. Exact slug match * 3. Exact name match * 4. Slug ends with query (e.g., "vynthra" matches entities ending in "vynthra") * 5. ID contains query as segment (e.g., "stinger" matches "faction/grell/unit/stinger") * 6. Fuzzy matches on ID, slug, and name */ export function searchEntities( query: string, options: SearchOptions = {}, ): SearchResult { const { fuzzyThreshold = 0.3, maxResults = 10 } = options; if (!query.trim()) { return { type: "none", query }; } const normalizedQuery = query.trim().toLowerCase(); const scoredMatches: ScoredMatch[] = []; // 1. Check for exact ID match const searchIndex = getSearchIndex(); const exactIdMatch = searchIndex.all[normalizedQuery]; if (exactIdMatch) { return { type: "single", entity: exactIdMatch, fullEntity: loadFullEntity(exactIdMatch), }; } // 2. Check for exact slug match const exactSlugId = searchIndex.ids[normalizedQuery]; if (exactSlugId) { const entity = searchIndex.all[exactSlugId]; if (entity) { return { type: "single", entity, fullEntity: loadFullEntity(entity), }; } } // 3. Look for exact matches in names (case insensitive) for (const entity of allEntitySummaries) { if (exactMatch(normalizedQuery, entity.name)) { return { type: "single", entity, fullEntity: loadFullEntity(entity), }; } } // 4. High-priority slug matches: slug ends with query verbatim const slugEndMatches: ScoredMatch[] = []; for (const entity of allEntitySummaries) { if (entity.slug.toLowerCase().endsWith(normalizedQuery)) { // Prioritize faction entities over coop entities const score = entity.id.startsWith("faction/") ? 1.0 : 0.98; slugEndMatches.push({ entity, score, // Perfect slug match, higher for faction entities matchType: "slug-end", }); } } // 5. High-priority ID segment matches: query appears as complete ID segment const idSegmentMatches: ScoredMatch[] = []; for (const entity of allEntitySummaries) { const idSegments = entity.id.toLowerCase().split('/'); if (idSegments.includes(normalizedQuery)) { // Prioritize faction entities over coop entities const score = entity.id.startsWith("faction/") ? 0.95 : 0.93; idSegmentMatches.push({ entity, score, // Very high score for ID segment match, higher for faction entities matchType: "id-segment", }); } } // 6. Fuzzy search across ID, slug, and name const fuzzyIdResults = fuzzySearch<Summary>( idMatcher, normalizedQuery, maxResults * 2, ); const fuzzySlugResults = fuzzySearch<Summary>( slugMatcher, normalizedQuery, maxResults * 2, ); const fuzzyNameResults = fuzzySearch<Summary>( nameMatcher, normalizedQuery, maxResults * 2, ); // Add slug end matches (highest priority) for (const match of slugEndMatches) { scoredMatches.push(match); } // Add ID segment matches (second highest priority) for (const match of idSegmentMatches) { scoredMatches.push(match); } // Add fuzzy slug results for (const result of fuzzySlugResults) { if (result.score >= fuzzyThreshold) { scoredMatches.push({ entity: result.item, score: result.score * 0.8, // Lower than exact matches matchType: "fuzzy-slug", }); } } // Add fuzzy name results for (const result of fuzzyNameResults) { if (result.score >= fuzzyThreshold) { scoredMatches.push({ entity: result.item, score: result.score * 0.7, // Lowest priority for fuzzy name matchType: "fuzzy-name", }); } } // Remove duplicates and sort by score const uniqueMatches = new Map<string, ScoredMatch>(); for (const match of scoredMatches) { const existing = uniqueMatches.get(match.entity.id); if (!existing || match.score > existing.score) { uniqueMatches.set(match.entity.id, match); } } const sortedMatches = Array.from(uniqueMatches.values()) .sort((a, b) => b.score - a.score) .slice(0, maxResults); // Return results based on match count if (sortedMatches.length === 0) { return { type: "none", query: normalizedQuery }; } if (sortedMatches.length === 1) { const match = sortedMatches[0]; return { type: "single", entity: match.entity, fullEntity: loadFullEntity(match.entity), }; } return { type: "multi", matches: sortedMatches.map((m) => m.entity), }; } /** * Get a human-readable description of search results */ export function describeSearchResult(result: SearchResult): string { switch (result.type) { case "single": return `Found: ${result.entity.name} (${result.entity.type})`; case "multi": return `Found ${result.matches.length} matches`; case "none": return `No matches found for "${result.query}"`; } }