first ai implementation

This commit is contained in:
Tobias Christian Nauen
2026-03-05 17:36:08 +01:00
commit 89c3cd91c2
14 changed files with 2040 additions and 0 deletions

5
src/lib/config.ts Normal file
View File

@@ -0,0 +1,5 @@
export const config = {
wrongWeightNumerator: 1,
wrongWeightDenominator: 1,
trivialMultiplier: 0.5,
};

187
src/lib/game.ts Normal file
View 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
View 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
View 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;