Untitled

 avatar
unknown
typescript
4 months ago
5.4 kB
3
Indexable
import type { Player } from "./player";
import { chance } from "../helpers";
import type { RootState } from "../state";

export interface Consideration<TAction> {
    name: string;
    weight?: number;
    evaluate(player: Player, state: RootState, action: TAction): number;
}

type ScoredAction<TAction> = {
    action: TAction;
    score: number;
}

export abstract class Decision<TAction> {
    protected abstract considerations: Consideration<TAction>[];

    protected abstract getActions(player: Player, state: RootState): TAction[];

    // During evaluation, any option scored as this will be removed from consideration.
    public static IMPOSSIBLE_ACTION_SIGNIFYING_SCORE = -Infinity;

    public static NoDecisionAvailableError = class extends Error {
        constructor(name: string) {
            super(`No decisions remained after ${name} evaluation.`);
        }
    }

    public make(player: Player, state: RootState): TAction {
        const [best, runnerUp] = this.consider(player, state);

        return this.willMakeSuboptimalDecision(player) ? runnerUp : best;
    }

    private consider(player: Player, state: RootState): [TAction, TAction] {
        const [best, runnerUp] = this.evaluateActions(player, state);;
        return [best.action, runnerUp.action];
    }

    private evaluateActions(player: Player, state: RootState): { action: TAction; score: number }[] {
        const actions = this.getActions(player, state);

        if (actions.length === 1) {
            const action = actions[0];
            const scoredAction = {
                action,
                score: Infinity
            };

            return [scoredAction, scoredAction];
        }

        const rawEvaluations = actions
            .map(action => {
                let isPossible = true;

                const score = this.considerations.reduce((prev, next) => {
                    const evaluation = next.evaluate(player, state, action);

                    if (evaluation === Decision.IMPOSSIBLE_ACTION_SIGNIFYING_SCORE) {
                        isPossible = false;
                    }

                    const weightedScore = evaluation * (next.weight ?? 1);

                    return prev + weightedScore;
                }, 0);

                return isPossible
                    ? { action, score }
                    : null;
            })
            .filter(scoredAction => scoredAction && scoredAction.score > 0) as ScoredAction<TAction>[];

        if (rawEvaluations.length === 0) {
            throw new Decision.NoDecisionAvailableError(this.constructor.name);
        } else if (rawEvaluations.length === 1) {
            const [onlyAction] = rawEvaluations;
            return [onlyAction, onlyAction];
        }

        // Extract scores for normalization
        const scores = rawEvaluations.map(({ score }) => score);
        const minScore = Math.min(...scores);
        const maxScore = Math.max(...scores);

        // Normalize evaluations
        const normalized = rawEvaluations.map(({ action, score }) => ({
            action,
            score: this.normalize(score, minScore, maxScore),
        }));

        const { bestChoice, remaining } = this.sortBySuboptimalCoefficient(player, normalized);
        const { bestChoice: nextBestChoice } = this.sortBySuboptimalCoefficient(player, remaining);

        if (!bestChoice) {
            throw new Decision.NoDecisionAvailableError(this.constructor.name);
        }

        return [bestChoice, nextBestChoice ?? bestChoice];
    }

    private normalize(value: number, min: number, max: number): number {
        return min === max ? 0.5 : (value - min) / (max - min);
    }

    private willMakeSuboptimalDecision(player: Player): boolean {
        return chance.bool({ likelihood: player.suboptimalDecisionCoefficient * 100 });
    }

    private sortBySuboptimalCoefficient(
        player: Player,
        actions: ScoredAction<TAction>[]
    ): {
        bestChoice: ScoredAction<TAction>;
        remaining: ScoredAction<TAction>[];
    } {
        const coefficient = player.suboptimalDecisionCoefficient;

        // First, sort by score.
        actions.sort((a, b) => b.score - a.score);

        if (coefficient >= 0.5) {
            // Fully randomized if suboptimalCoefficient >= 0.5
            actions.sort(() => Math.random() - 0.5);
        } else {
            actions.sort((a, b) => {
                if (coefficient <= 0) {
                    // If suboptimalCoefficient is 0, always select the higher score
                    return b.score - a.score;
                } else if (a.score === b.score) {
                    // Randomize ties
                    return Math.random() - 0.5;
                }

                const distance = Math.abs(a.score - b.score);
                const distanceModifier = this.calculateDistanceModifier(distance)
                const likelihood = coefficient * distanceModifier * 100;

                return chance.bool({ likelihood }) ? -1 : 1;
            });
        }

        return {
            bestChoice: actions.shift()!,
            remaining: actions
        };
    }

    private calculateDistanceModifier(distance: number): number {
        return 1 - Math.pow(1 - distance, 2);
    }
}
Editor is loading...
Leave a Comment