Refactor: Remove audio processing and game state management modules
This commit is contained in:
317
src/server-deno/application/AnswerCheckService.ts
Normal file
317
src/server-deno/application/AnswerCheckService.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Answer checking service for fuzzy matching of title, artist, and year guesses
|
||||
* Based on the original answerCheck.js logic
|
||||
*/
|
||||
|
||||
export interface ScoreResult {
|
||||
score: number;
|
||||
match: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer checking service
|
||||
*/
|
||||
export class AnswerCheckService {
|
||||
/**
|
||||
* Strip diacritics from string
|
||||
*/
|
||||
private stripDiacritics(str: string): string {
|
||||
return str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize common terms
|
||||
*/
|
||||
private normalizeCommon(str: string): string {
|
||||
let normalized = this.stripDiacritics(str)
|
||||
.toLowerCase()
|
||||
// Normalize common contractions before removing punctuation
|
||||
.replace(/\bcan't\b/g, 'cant')
|
||||
.replace(/\bwon't\b/g, 'wont')
|
||||
.replace(/\bdon't\b/g, 'dont')
|
||||
.replace(/\bdidn't\b/g, 'didnt')
|
||||
.replace(/\bisn't\b/g, 'isnt')
|
||||
.replace(/\baren't\b/g, 'arent')
|
||||
.replace(/\bwasn't\b/g, 'wasnt')
|
||||
.replace(/\bweren't\b/g, 'werent')
|
||||
.replace(/\bhasn't\b/g, 'hasnt')
|
||||
.replace(/\bhaven't\b/g, 'havent')
|
||||
.replace(/\bhadn't\b/g, 'hadnt')
|
||||
.replace(/\bshouldn't\b/g, 'shouldnt')
|
||||
.replace(/\bwouldn't\b/g, 'wouldnt')
|
||||
.replace(/\bcouldn't\b/g, 'couldnt')
|
||||
.replace(/\bmustn't\b/g, 'mustnt')
|
||||
.replace(/\bi'm\b/g, 'im')
|
||||
.replace(/\byou're\b/g, 'youre')
|
||||
.replace(/\bhe's\b/g, 'hes')
|
||||
.replace(/\bshe's\b/g, 'shes')
|
||||
.replace(/\bit's\b/g, 'its')
|
||||
.replace(/\bwe're\b/g, 'were')
|
||||
.replace(/\bthey're\b/g, 'theyre')
|
||||
.replace(/\bi've\b/g, 'ive')
|
||||
.replace(/\byou've\b/g, 'youve')
|
||||
.replace(/\bwe've\b/g, 'weve')
|
||||
.replace(/\bthey've\b/g, 'theyve')
|
||||
.replace(/\bi'll\b/g, 'ill')
|
||||
.replace(/\byou'll\b/g, 'youll')
|
||||
.replace(/\bhe'll\b/g, 'hell')
|
||||
.replace(/\bshe'll\b/g, 'shell')
|
||||
.replace(/\bwe'll\b/g, 'well')
|
||||
.replace(/\bthey'll\b/g, 'theyll')
|
||||
.replace(/\bthat's\b/g, 'thats')
|
||||
.replace(/\bwho's\b/g, 'whos')
|
||||
.replace(/\bwhat's\b/g, 'whats')
|
||||
.replace(/\bwhere's\b/g, 'wheres')
|
||||
.replace(/\bwhen's\b/g, 'whens')
|
||||
.replace(/\bwhy's\b/g, 'whys')
|
||||
.replace(/\bhow's\b/g, 'hows')
|
||||
.replace(/\s*(?:&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/g, ' ')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean title-specific noise (remasters, edits, etc.)
|
||||
*/
|
||||
private cleanTitleNoise(raw: string): string {
|
||||
let s = raw;
|
||||
|
||||
// Remove common parenthetical annotations
|
||||
s = s.replace(/\(([^)]*remaster[^)]*)\)/gi, '');
|
||||
s = s.replace(/\(([^)]*radio edit[^)]*)\)/gi, '');
|
||||
s = s.replace(/\(([^)]*edit[^)]*)\)/gi, '');
|
||||
s = s.replace(/\(([^)]*version[^)]*)\)/gi, '');
|
||||
s = s.replace(/\(([^)]*live[^)]*)\)/gi, '');
|
||||
s = s.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
|
||||
|
||||
// Remove standalone noise words
|
||||
s = s.replace(/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi, '');
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip optional segments (parentheses, quotes, brackets)
|
||||
*/
|
||||
private stripOptionalSegments(raw: string): string {
|
||||
let s = raw;
|
||||
s = s.replace(/"[^"]*"/g, ' '); // Remove quoted segments
|
||||
s = s.replace(/\([^)]*\)/g, ' '); // Remove parenthetical
|
||||
s = s.replace(/\[[^\]]*\]/g, ' '); // Remove brackets
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize title for comparison
|
||||
*/
|
||||
private normalizeTitle(str: string): string {
|
||||
return this.normalizeCommon(this.cleanTitleNoise(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize title with optional segments removed
|
||||
*/
|
||||
private normalizeTitleBaseOptional(str: string): string {
|
||||
return this.normalizeCommon(this.stripOptionalSegments(this.cleanTitleNoise(str)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize artist for comparison
|
||||
*/
|
||||
private normalizeArtist(str: string): string {
|
||||
return this.normalizeCommon(str)
|
||||
.replace(/\bthe\b/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize string
|
||||
*/
|
||||
private tokenize(str: string): string[] {
|
||||
return str ? str.split(' ').filter(Boolean) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create token set
|
||||
*/
|
||||
private tokenSet(str: string): Set<string> {
|
||||
return new Set(this.tokenize(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Jaccard similarity
|
||||
*/
|
||||
private jaccard(a: string, b: string): number {
|
||||
const setA = this.tokenSet(a);
|
||||
const setB = this.tokenSet(b);
|
||||
|
||||
if (setA.size === 0 && setB.size === 0) return 1;
|
||||
|
||||
let intersection = 0;
|
||||
for (const token of setA) {
|
||||
if (setB.has(token)) intersection++;
|
||||
}
|
||||
|
||||
const union = setA.size + setB.size - intersection;
|
||||
return union ? intersection / union : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance
|
||||
*/
|
||||
private levenshtein(a: string, b: string): number {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
|
||||
if (!m) return n;
|
||||
if (!n) return m;
|
||||
|
||||
const dp = new Array(n + 1).fill(0);
|
||||
|
||||
for (let j = 0; j <= n; j++) {
|
||||
dp[j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
let prev = dp[0];
|
||||
dp[0] = i;
|
||||
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const temp = dp[j];
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
|
||||
prev = temp;
|
||||
}
|
||||
}
|
||||
|
||||
return dp[n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity ratio based on Levenshtein distance
|
||||
*/
|
||||
private simRatio(a: string, b: string): number {
|
||||
if (!a && !b) return 1;
|
||||
if (!a || !b) return 0;
|
||||
|
||||
const dist = this.levenshtein(a, b);
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
return maxLen ? 1 - dist / maxLen : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split artists string
|
||||
*/
|
||||
splitArtists(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[,&+]|(?:\s+(?:feat\.?|featuring|with|vs\.?|and|x)\s+)/i)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score title guess
|
||||
*/
|
||||
scoreTitle(guess: string, correct: string): ScoreResult {
|
||||
if (!guess || !correct) {
|
||||
return { score: 0, match: false };
|
||||
}
|
||||
|
||||
const g = this.normalizeTitle(guess);
|
||||
const c = this.normalizeTitle(correct);
|
||||
|
||||
// Exact match
|
||||
if (g === c) {
|
||||
return { score: 1.0, match: true };
|
||||
}
|
||||
|
||||
// Try without optional segments
|
||||
const gOpt = this.normalizeTitleBaseOptional(guess);
|
||||
const cOpt = this.normalizeTitleBaseOptional(correct);
|
||||
|
||||
if (gOpt === cOpt && gOpt.length > 0) {
|
||||
return { score: 0.98, match: true };
|
||||
}
|
||||
|
||||
// Fuzzy matching
|
||||
const jac = this.jaccard(g, c);
|
||||
const sim = this.simRatio(g, c);
|
||||
const score = 0.6 * jac + 0.4 * sim;
|
||||
|
||||
// Accept if score >= 0.6 (softened threshold)
|
||||
return { score, match: score >= 0.6 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Score artist guess
|
||||
*/
|
||||
scoreArtist(guess: string, correct: string): ScoreResult {
|
||||
if (!guess || !correct) {
|
||||
return { score: 0, match: false };
|
||||
}
|
||||
|
||||
const guessParts = this.splitArtists(guess);
|
||||
const correctParts = this.splitArtists(correct);
|
||||
|
||||
const gNorm = guessParts.map((p) => this.normalizeArtist(p));
|
||||
const cNorm = correctParts.map((p) => this.normalizeArtist(p));
|
||||
|
||||
// Check if any guess part matches any correct part
|
||||
let bestScore = 0;
|
||||
|
||||
for (const gPart of gNorm) {
|
||||
for (const cPart of cNorm) {
|
||||
if (gPart === cPart) {
|
||||
return { score: 1.0, match: true };
|
||||
}
|
||||
|
||||
const jac = this.jaccard(gPart, cPart);
|
||||
const sim = this.simRatio(gPart, cPart);
|
||||
const score = 0.6 * jac + 0.4 * sim;
|
||||
bestScore = Math.max(bestScore, score);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept if score >= 0.6 (softened threshold)
|
||||
return { score: bestScore, match: bestScore >= 0.6 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Score year guess
|
||||
*/
|
||||
scoreYear(guess: number | string, correct: number | null): ScoreResult {
|
||||
if (correct === null) {
|
||||
return { score: 0, match: false };
|
||||
}
|
||||
|
||||
const guessNum = typeof guess === 'string' ? parseInt(guess, 10) : guess;
|
||||
|
||||
if (isNaN(guessNum)) {
|
||||
return { score: 0, match: false };
|
||||
}
|
||||
|
||||
if (guessNum === correct) {
|
||||
return { score: 1.0, match: true };
|
||||
}
|
||||
|
||||
const diff = Math.abs(guessNum - correct);
|
||||
|
||||
// Accept within 1 year
|
||||
if (diff <= 1) {
|
||||
return { score: 0.9, match: true };
|
||||
}
|
||||
|
||||
// Within 2 years - partial credit but no match
|
||||
if (diff <= 2) {
|
||||
return { score: 0.7, match: false };
|
||||
}
|
||||
|
||||
return { score: 0, match: false };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user