@zerospacegg/vynthra
Version:
Discord bot for ZeroSpace.gg data
216 lines (189 loc) • 6.27 kB
text/typescript
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}"`;
}
}