first ai implementation
This commit is contained in:
5
src/lib/config.ts
Normal file
5
src/lib/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const config = {
|
||||
wrongWeightNumerator: 1,
|
||||
wrongWeightDenominator: 1,
|
||||
trivialMultiplier: 0.5,
|
||||
};
|
||||
187
src/lib/game.ts
Normal file
187
src/lib/game.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { getTypeInfo, getEffectiveness } from './types';
|
||||
import { Question, Sampler } from './sampler';
|
||||
|
||||
const STORAGE_KEY = 'pokemon-type-quiz-data';
|
||||
|
||||
export interface GameState {
|
||||
currentQuestion: Question | null;
|
||||
selectedAnswer: number | null;
|
||||
selectedIndex: number;
|
||||
showResult: boolean;
|
||||
isCorrect: boolean;
|
||||
streak: number;
|
||||
settings: {
|
||||
allowDualTypes: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function effectivenessToLabel(value: number): { label: string; short: string } {
|
||||
if (value === 0) return { label: 'No Effect (0×)', short: '0×' };
|
||||
if (value === 0.25) return { label: 'Not Very Effective (¼×)', short: '¼×' };
|
||||
if (value === 0.5) return { label: 'Not Very Effective (½×)', short: '½×' };
|
||||
if (value === 1) return { label: 'Neutral (1×)', short: '1×' };
|
||||
if (value === 2) return { label: 'Super Effective (2×)', short: '2×' };
|
||||
if (value === 4) return { label: 'Super Effective (4×)', short: '4×' };
|
||||
return { label: `${value}×`, short: `${value}×` };
|
||||
}
|
||||
|
||||
export class Game {
|
||||
private sampler: Sampler;
|
||||
private state: GameState;
|
||||
private onUpdate: () => void;
|
||||
|
||||
constructor(onUpdate: () => void) {
|
||||
this.onUpdate = onUpdate;
|
||||
this.sampler = new Sampler();
|
||||
this.state = {
|
||||
currentQuestion: null,
|
||||
selectedAnswer: null,
|
||||
selectedIndex: -1,
|
||||
showResult: false,
|
||||
isCorrect: false,
|
||||
streak: 0,
|
||||
settings: {
|
||||
allowDualTypes: false,
|
||||
},
|
||||
};
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
this.state.streak = data.streak || 0;
|
||||
this.state.settings.allowDualTypes = data.settings?.allowDualTypes || false;
|
||||
this.sampler.setAllowDualTypes(this.state.settings.allowDualTypes);
|
||||
if (data.history) {
|
||||
this.sampler.loadHistory(data.history);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
try {
|
||||
const data = {
|
||||
streak: this.state.streak,
|
||||
settings: this.state.settings,
|
||||
history: this.sampler.getHistory(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
getState(): GameState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getOptions(): { options: { value: number; label: string; short: string }[]; startKey: number } {
|
||||
if (!this.state.currentQuestion) return { options: [], startKey: 1 };
|
||||
|
||||
const options = new Set<number>();
|
||||
options.add(this.state.currentQuestion.correctAnswer);
|
||||
|
||||
const allValues = [0, 0.25, 0.5, 1, 2, 4];
|
||||
for (const v of allValues) {
|
||||
if (v !== this.state.currentQuestion.correctAnswer) {
|
||||
options.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
const isDual = this.state.currentQuestion.defenderTypes.length > 1;
|
||||
const monoValues = [2, 1, 0.5, 0];
|
||||
const dualValues = [4, 2, 1, 0.5, 0.25, 0];
|
||||
const filtered = isDual
|
||||
? dualValues.filter(v => options.has(v))
|
||||
: monoValues.filter(v => options.has(v));
|
||||
|
||||
const optionsResult = filtered.map(v => ({ value: v, ...effectivenessToLabel(v) }));
|
||||
|
||||
return { options: optionsResult, startKey: isDual ? 1 : 2 };
|
||||
}
|
||||
|
||||
setAllowDualTypes(allow: boolean): void {
|
||||
this.state.settings.allowDualTypes = allow;
|
||||
this.sampler.setAllowDualTypes(allow);
|
||||
this.nextQuestion();
|
||||
this.save();
|
||||
}
|
||||
|
||||
nextQuestion(): void {
|
||||
this.state.currentQuestion = this.sampler.generateQuestion();
|
||||
this.state.selectedAnswer = null;
|
||||
this.state.selectedIndex = -1;
|
||||
this.state.showResult = false;
|
||||
this.onUpdate();
|
||||
}
|
||||
|
||||
selectAnswer(index: number): void {
|
||||
if (this.state.showResult) return;
|
||||
|
||||
const { options } = this.getOptions();
|
||||
if (index < 0 || index >= options.length) return;
|
||||
|
||||
this.state.selectedIndex = index;
|
||||
this.state.selectedAnswer = options[index].value;
|
||||
this.state.showResult = true;
|
||||
this.state.isCorrect = this.state.selectedAnswer === this.state.currentQuestion?.correctAnswer;
|
||||
|
||||
if (this.state.isCorrect) {
|
||||
this.state.streak++;
|
||||
} else {
|
||||
this.state.streak = 0;
|
||||
}
|
||||
|
||||
this.sampler.recordGuess(
|
||||
this.state.currentQuestion!.attackType,
|
||||
this.state.currentQuestion!.defenderTypes,
|
||||
this.state.isCorrect
|
||||
);
|
||||
|
||||
this.save();
|
||||
this.onUpdate();
|
||||
}
|
||||
|
||||
getStats(): { total: number; correct: number; accuracy: number } {
|
||||
const history = this.sampler.getHistory();
|
||||
const total = history.length;
|
||||
const correct = history.filter(h => h.correct).length;
|
||||
return { total, correct, accuracy: total > 0 ? (correct / total) * 100 : 0 };
|
||||
}
|
||||
|
||||
getExplanation(): string {
|
||||
if (!this.state.currentQuestion) return '';
|
||||
|
||||
const { attackType, defenderTypes } = this.state.currentQuestion;
|
||||
const attackInfo = getTypeInfo(attackType);
|
||||
|
||||
let explanation = `<strong>${attackInfo.name}</strong> against `;
|
||||
|
||||
if (defenderTypes.length === 1) {
|
||||
const defInfo = getTypeInfo(defenderTypes[0]);
|
||||
explanation += `<strong>${defInfo.name}</strong>`;
|
||||
} else {
|
||||
const defInfos = defenderTypes.map(t => getTypeInfo(t));
|
||||
explanation += `<strong>${defInfos[0].name} / ${defInfos[1].name}</strong>`;
|
||||
}
|
||||
|
||||
const effective = getEffectiveness(attackType, defenderTypes);
|
||||
const effectLabel = effectivenessToLabel(effective);
|
||||
|
||||
explanation += `<br><br>Effectiveness: <strong>${effectLabel.label}</strong>`;
|
||||
|
||||
if (defenderTypes.length > 1) {
|
||||
const first = getEffectiveness(attackType, [defenderTypes[0]]);
|
||||
const second = getEffectiveness(attackType, [defenderTypes[1]]);
|
||||
explanation += `<br><small>(${effectivenessToLabel(first).short} × ${effectivenessToLabel(second).short} = ${effectLabel.short})</small>`;
|
||||
}
|
||||
|
||||
return explanation;
|
||||
}
|
||||
}
|
||||
107
src/lib/sampler.ts
Normal file
107
src/lib/sampler.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { TypeName, TYPES, getEffectiveness, TYPE_COUNT } from './types';
|
||||
import { config } from './config';
|
||||
|
||||
export interface Question {
|
||||
attackType: TypeName;
|
||||
defenderTypes: TypeName[];
|
||||
correctAnswer: number;
|
||||
}
|
||||
|
||||
export interface GuessRecord {
|
||||
key: string;
|
||||
correct: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function makeKey(attackType: TypeName, defenderTypes: TypeName[]): string {
|
||||
return `${attackType}:${defenderTypes.sort().join('/')}`;
|
||||
}
|
||||
|
||||
export class Sampler {
|
||||
private history: GuessRecord[] = [];
|
||||
private allowDualTypes: boolean = false;
|
||||
|
||||
constructor(allowDualTypes: boolean = false) {
|
||||
this.allowDualTypes = allowDualTypes;
|
||||
}
|
||||
|
||||
setAllowDualTypes(allow: boolean): void {
|
||||
this.allowDualTypes = allow;
|
||||
}
|
||||
|
||||
recordGuess(attackType: TypeName, defenderTypes: TypeName[], correct: boolean): void {
|
||||
const key = makeKey(attackType, defenderTypes);
|
||||
this.history.push({ key, correct, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
getHistory(): GuessRecord[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
loadHistory(history: GuessRecord[]): void {
|
||||
this.history = history;
|
||||
}
|
||||
|
||||
private calculateWeight(attackType: TypeName, defenderTypes: TypeName[]): number {
|
||||
const key = makeKey(attackType, defenderTypes);
|
||||
const records = this.history.filter(r => r.key === key);
|
||||
|
||||
const wrong = records.filter(r => !r.correct).length;
|
||||
const right = records.filter(r => r.correct).length;
|
||||
|
||||
let weight = (wrong + config.wrongWeightNumerator) / (right + config.wrongWeightDenominator);
|
||||
|
||||
const isTrivial = defenderTypes.every(defType =>
|
||||
getEffectiveness(attackType, [defType]) === 1
|
||||
);
|
||||
if (isTrivial) {
|
||||
weight *= config.trivialMultiplier;
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
generateQuestion(): Question {
|
||||
const candidates: { attackType: TypeName; defenderTypes: TypeName[]; weight: number }[] = [];
|
||||
|
||||
for (let i = 0; i < TYPE_COUNT; i++) {
|
||||
const attackType = TYPES[i].name;
|
||||
|
||||
// Single type defenders - all types
|
||||
for (let j = 0; j < TYPE_COUNT; j++) {
|
||||
const defenderTypes = [TYPES[j].name];
|
||||
candidates.push({ attackType, defenderTypes, weight: this.calculateWeight(attackType, defenderTypes) });
|
||||
}
|
||||
|
||||
if (this.allowDualTypes) {
|
||||
// Dual type defenders
|
||||
for (let j = 0; j < TYPE_COUNT; j++) {
|
||||
for (let k = j + 1; k < TYPE_COUNT; k++) {
|
||||
const defenderTypes = [TYPES[j].name, TYPES[k].name];
|
||||
candidates.push({ attackType, defenderTypes, weight: this.calculateWeight(attackType, defenderTypes) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalWeight = candidates.reduce((sum, c) => sum + c.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
random -= candidate.weight;
|
||||
if (random <= 0) {
|
||||
return {
|
||||
attackType: candidate.attackType,
|
||||
defenderTypes: candidate.defenderTypes,
|
||||
correctAnswer: getEffectiveness(candidate.attackType, candidate.defenderTypes),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0] ? {
|
||||
attackType: candidates[0].attackType,
|
||||
defenderTypes: candidates[0].defenderTypes,
|
||||
correctAnswer: getEffectiveness(candidates[0].attackType, candidates[0].defenderTypes),
|
||||
} : this.generateQuestion();
|
||||
}
|
||||
}
|
||||
84
src/lib/types.ts
Normal file
84
src/lib/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type TypeName =
|
||||
| 'Normal' | 'Fire' | 'Water' | 'Electric' | 'Grass' | 'Ice'
|
||||
| 'Fighting' | 'Poison' | 'Ground' | 'Flying' | 'Psychic' | 'Bug'
|
||||
| 'Rock' | 'Ghost' | 'Dragon' | 'Dark' | 'Steel' | 'Fairy';
|
||||
|
||||
export interface TypeInfo {
|
||||
name: TypeName;
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const TYPES: TypeInfo[] = [
|
||||
{ name: 'Normal', backgroundColor: '#a4acaf', color: '#212124' },
|
||||
{ name: 'Fire', backgroundColor: '#fd7d24', color: '#ffffff' },
|
||||
{ name: 'Water', backgroundColor: '#4792C4', color: '#ffffff' },
|
||||
{ name: 'Electric', backgroundColor: '#eed535', color: '#212121' },
|
||||
{ name: 'Grass', backgroundColor: '#9bcc50', color: '#212121' },
|
||||
{ name: 'Ice', backgroundColor: '#90d5d5', color: '#212121' },
|
||||
{ name: 'Fighting', backgroundColor: '#d56723', color: '#ffffff' },
|
||||
{ name: 'Poison', backgroundColor: '#b97fc9', color: '#ffffff' },
|
||||
{ name: 'Ground', backgroundColor: '#debb5c', color: '#212121' },
|
||||
{ name: 'Flying', backgroundColor: '#3dc7ef', color: '#212121' },
|
||||
{ name: 'Psychic', backgroundColor: '#f366b9', color: '#ffffff' },
|
||||
{ name: 'Bug', backgroundColor: '#7e9f56', color: '#ffffff' },
|
||||
{ name: 'Rock', backgroundColor: '#a38c21', color: '#ffffff' },
|
||||
{ name: 'Ghost', backgroundColor: '#7b62a3', color: '#ffffff' },
|
||||
{ name: 'Dragon', backgroundColor: '#53a4cf', color: '#ffffff' },
|
||||
{ name: 'Dark', backgroundColor: '#707070', color: '#ffffff' },
|
||||
{ name: 'Steel', backgroundColor: '#9eb7b8', color: '#212121' },
|
||||
{ name: 'Fairy', backgroundColor: '#fdb9e9', color: '#212121' },
|
||||
];
|
||||
|
||||
const TYPE_NAMES = TYPES.map(t => t.name);
|
||||
|
||||
const EFFECTIVENESS_FLAT: number[] = [
|
||||
// NOR FIR WAT ELE GRA ICE FIG POI GRO FLY PSY BUG ROC GHO DRA DAR STE FAI
|
||||
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 0.50, 0.00, 1.00, 1.00, 0.50, 1.00, // NOR
|
||||
1.00, 0.50, 0.50, 1.00, 2.00, 2.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 0.50, 1.00, 2.00, 1.00, // FIR
|
||||
1.00, 2.00, 0.50, 1.00, 0.50, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 1.00, 1.00, // WAT
|
||||
1.00, 1.00, 2.00, 0.50, 0.50, 1.00, 1.00, 1.00, 0.00, 2.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, // ELE
|
||||
1.00, 0.50, 2.00, 1.00, 0.50, 1.00, 1.00, 0.50, 2.00, 0.50, 1.00, 0.50, 2.00, 1.00, 0.50, 1.00, 0.50, 1.00, // GRA
|
||||
1.00, 0.50, 0.50, 1.00, 2.00, 0.50, 1.00, 1.00, 2.00, 2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, // ICE
|
||||
2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 0.50, 0.50, 0.50, 2.00, 0.00, 1.00, 2.00, 2.00, 0.50, // FIG
|
||||
1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 0.50, 0.50, 1.00, 1.00, 1.00, 0.50, 0.50, 1.00, 1.00, 0.00, 2.00, // POI
|
||||
1.00, 2.00, 1.00, 2.00, 0.50, 1.00, 1.00, 2.00, 1.00, 0.00, 1.00, 0.50, 2.00, 1.00, 1.00, 1.00, 2.00, 1.00, // GRO
|
||||
1.00, 1.00, 1.00, 0.50, 2.00, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 1.00, 1.00, 0.50, 1.00, // FLY
|
||||
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 2.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, 1.00, 0.00, 0.50, 1.00, // PSY
|
||||
1.00, 0.50, 1.00, 1.00, 2.00, 1.00, 0.50, 0.50, 1.00, 0.50, 2.00, 1.00, 1.00, 0.50, 1.00, 2.00, 0.50, 0.50, // BUG
|
||||
1.00, 2.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 0.50, 2.00, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, // ROC
|
||||
0.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 1.00, // GHO
|
||||
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 0.50, 0.00, // DRA
|
||||
1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 0.50, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 2.00, 1.00, 0.50, 1.00, 0.50, // DAR
|
||||
1.00, 0.50, 0.50, 0.50, 1.00, 2.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 1.00, 1.00, 1.00, 0.50, 2.00, // STE
|
||||
1.00, 0.50, 1.00, 1.00, 1.00, 1.00, 2.00, 0.50, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 2.00, 2.00, 0.50, 1.00 // FAI
|
||||
];
|
||||
|
||||
const TYPE_INDEX: Record<TypeName, number> = Object.fromEntries(
|
||||
TYPE_NAMES.map((name, i) => [name, i])
|
||||
) as Record<TypeName, number>;
|
||||
|
||||
export const EFFECTIVENESS_MATRIX: number[][] = [];
|
||||
for (let i = 0; i < TYPE_NAMES.length; i++) {
|
||||
EFFECTIVENESS_MATRIX[i] = EFFECTIVENESS_FLAT.slice(i * 18, (i + 1) * 18);
|
||||
}
|
||||
|
||||
export function getEffectiveness(attackType: TypeName, defenderTypes: TypeName[]): number {
|
||||
let result = 1;
|
||||
for (const defType of defenderTypes) {
|
||||
const attackIdx = TYPE_INDEX[attackType];
|
||||
const defIdx = TYPE_INDEX[defType];
|
||||
result *= EFFECTIVENESS_MATRIX[attackIdx][defIdx];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getTypeInfo(name: TypeName): TypeInfo {
|
||||
return TYPES[findTypeIndex(name)];
|
||||
}
|
||||
|
||||
export function findTypeIndex(name: TypeName): number {
|
||||
return TYPE_INDEX[name];
|
||||
}
|
||||
|
||||
export const TYPE_COUNT = TYPES.length;
|
||||
Reference in New Issue
Block a user