Code viewer for World: New World
// by Niall Bastible Díaz and Patrick Dinu

/**
 * @typedef {import('three/examples/jsm/loaders/GLTFLoader').GLTF} GLTF
 * @typedef {import('three/examples/jsm/loaders/GLTFLoader').GLTFLoader} GLTFLoader
 */

const UPLOAD_PATH = "uploads/nbd/";
const MOUSE_SPEED = 0.001;
const MAX_CHARS = 500; // the most characters we're willing to put on screen from LLM output
let LIGHT_INTENSITY = 0.9; // how bright the lightbulb is

/** @type {globject} */
const GLOBAL = {
    scene: createScene(),
    camera: createCamera(),
    loop: new Map(), // contains the callbacks that are run every frame
    renderer: createRenderer(),
    loaders: setupLoaders(),
    hud: createHud(),
    keys: new Map(), // holds the API keys for each model
    operations: 0, // keeps track of how many inflight API requests there are
    config: {}, // contains user config
    cops: [], // list of interrogators
}; // global state object

// represents an interactive instance of a conversational LLM
class TextModel {
    messages = [];
    apiKey;
    url;
    goodcop; // boolean
    temperature;
    static MAIN_PROMPT = `You are a police interrogator. You are in a small, dark room with your partner and a suspect in a murder. That suspect is the user, who you will be conversing with, though you may acknowledge your partner's presence if needed. Your goal is to extract intel from the suspect, and hopefully a confession. You respond and act entirely in character as an interrogator, never adding in details of physical gestures or prepending an indicator like 'Officer: ' to your dialogue, and your responses should be no more than a paragraph. ${MAX_CHARS} characters MAXIMUM, and respond ENTIRELY IN CHARACTER. Your evidence is as follows, and you may fill in the blanks if any of these facts are vague.`
    
    // HTTP Request params
    apiHeaders;
    model;

    constructor(url, model) {
        this.url = url;
        this.model = model;
    }
    
    setApiKey(string) {
        this.apiKey = string;
        this.apiHeaders = new Headers();
        this.apiHeaders.append("Content-Type", "application/json");
        this.apiHeaders.append("Authorization", "Bearer " + this.apiKey);
    }

    setTemperament(good, temperature = 0.7) {
        this.goodcop = good;
        if (temperature <= 1 && 0 < temperature) {
            this.temperature = temperature;
        }
    }

    /** @return {Promise<string>} */
    async startChat(evidence) {
        return this.message(`${TextModel.MAIN_PROMPT}\n${evidence}\nOne more thing - you are using the ${this.goodcop ? 'good' : 'bad'} cop approach for this investigation. Act accordingly`, "system");
    }

    // send a message to the model and receive its response - it remembers what you say for further interactions
    /** @return {Promise<string>} */
    async message(text, role = "user") {
        GLOBAL.operations++;
        this.messages.push({"role" : role, "content" : text});

        const requestBody = {
            model : this.model,
            messages : this.messages,
            temperature : this.temperature,
        }
        
        const requestInit = {
            method: "POST",
            headers : this.apiHeaders,
            body : JSON.stringify(requestBody),
        }

        const apiRequest = new Request(this.url, requestInit);
        const response = await fetch(apiRequest);
        const result = await response.json();

        const message = result.choices[0].message;
        this.messages.push(message)
        GLOBAL.operations--;
        return message.content;
    }
}

// represents a text-to-speech model on standby for queries
class VoiceModel {
    apiKey;
    url;
    headerGen;
    baseBody;

    /**
     * @param {string} url 
     * @param {(ths: VoiceModel) => Object} headerGen 
     * @param {Object} baseBody 
     */
    constructor(url, headerGen, baseBody) {
        this.url = url;
        this.baseBody = baseBody;
        this.headerGen = headerGen;
    }

    setApiKey(key) {
        this.apiKey = key;
    }

    /** @return {Promise<HTMLAudioElement>} */
    async record(message) {
        GLOBAL.operations++;
        const reqBody = this.baseBody;
        reqBody.text = message;
        const reqHeaders = this.headerGen(this);
        reqHeaders['Content-Type'] = 'application/json';

        const options = {
            method: 'POST',
            headers: reqHeaders,
            body: JSON.stringify(reqBody),
          };
          
        const response = await fetch(this.url, options);
        const blob = await response.blob();
        const url = URL.createObjectURL(blob);
        const audio = new Audio(url);
        
        GLOBAL.operations--;
        return audio;
    }
}

// encapsulates all the AI into its game representation
class Cop {
    /** @type {TextModel} */
    brain;
    hud; // the cop dumps its lines into the .text prop of this object, because strings are immutable
    /** @type {VoiceModel} */
    voice; // this prop is optional and may be undefined

    constructor(brain, hud, voice) {
        this.brain = brain;
        this.hud = hud;
        this.voice = voice;
    }

    /** @return {Promise<string>} */
    async message(text) {
        return this.brain.message(text);
    }

    async speak(dialogue) {
        const duration = dialogue.length * 25;
        if (this.voice) {
            const audio = await this.voice.record(dialogue);
            const typing = iteratePeriod(dialogue, char => this.hud.text += char, duration);
            const playback = new Promise((resolve) => {
                audio.addEventListener('ended', resolve);
                audio.play();
            });
            return Promise.allSettled([typing, playback]);
        } else {
            const typing = iteratePeriod(dialogue, char => this.hud.text += char, duration);
            return typing;
        }
    }
}

const elevenLabs = new VoiceModel('https://api.elevenlabs.io/v1/text-to-speech/N2lVS1w4EtoT3dr4eOWO?output_format=mp3_22050_32', ths => ({'xi-api-key': ths.apiKey}), {'model_id': 'eleven_turbo_v2'});
const deepgram = new VoiceModel("https://api.deepgram.com/v1/speak?model=aura-angus-en", ths => ({'Authorization': `Token ${ths.apiKey}`}), {});


const openAi = new TextModel("https://api.openai.com/v1/chat/completions", "gpt-4o-mini");
const mistral = new TextModel("https://api.mistral.ai/v1/chat/completions", "open-mistral-7b");

AB.world.newRun = function()
{
    AB.runReady = false; // Ancient Brain's step system isn't particularly well suited for us
    AB.newSplash();
    AB.splashHtml(`
<h1>No Contest</h1>
<p>
    You're being interrogated for a murder. Buy yourself some time before your lawyer arrives, and be careful not to incriminate yourself.
    <br><br>
    Look around with the mouse, type your words with the keyboard, and hit ENTER to speak.<br>
    When the game ends, you'll get to pick the model that performed best, and compare it to other runs.
</p>
<div style="display: flex; flex-direction: column; align-items: center; gap: 20px;">
    <div style="display: flex; justify-content: center; gap: 20px;">
        <div>
            <p>Enter OpenAI API key:</p>
            <input type="text" id="openai-key">
            <p>Temperature:</p>
            <div style="display: flex;">
                <input type="range" id="openai-temp" value="0.7" min="0" max="1" step="0.1" onchange="document.getElementById('openai-temp-text').innerText = this.value">
                <span id="openai-temp-text">0.7</span>
            </div>
            <div style="display: flex;">
                <p>Good cop?</p>
                <input type="checkbox" id="openai-good" checked />
            </div>
        </div>
        <div>
            <p>Enter Mistral key:</p>
            <input type="text" id="mistral-key">
            <p>Temperature:</p>
            <div style="display: flex;">
                <input type="range" id="mistral-temp" value="0.7" min="0" max="1" step="0.1" onchange="document.getElementById('mistral-temp-text').innerText = this.value">
                <span id="mistral-temp-text">0.7</span>
            </div>
            <div style="display: flex;">
                <p>Good cop?</p>
                <input type="checkbox" id="mistral-good">
            </div>
        </div>
    </div>

    <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
        <p>Enable voices (just for fun) <input type="checkbox" id="enable-voice" onchange="document.getElementById('voice-keys').style.display = this.checked ? 'flex' : 'none'"></p>
        <div id="voice-keys" style="display: none; justify-content: center; gap: 20px;">
            <div>
                <p>Enter ElevenLabs API key:</p>
                <input type="text" id="elevenlabs-key">
            </div>
            <div>
                <p>Enter Deepgram API key:</p>
                <input type="text" id="deepgram-key">
            </div>
        </div>
    </div>
    <button style="flex: 0;" onclick="start();">Start</button>
</div>
<div id="error"></div>
    `);
};

// pull config from splash screen and start the game
function start() {
    // @ts-expect-error
    const openAiKey = document.getElementById("openai-key").value; // @ts-expect-error
    const mistralKey = document.getElementById("mistral-key").value; // @ts-expect-error
    const voiceEnabled = document.getElementById("enable-voice").checked; // @ts-expect-error
    const elevenLabsKey = document.getElementById("elevenlabs-key").value; // @ts-expect-error
    const deepgramKey = document.getElementById("deepgram-key").value;

    // reject input if missing text model keys, or missing voice keys if they're enabled
    if (!(openAiKey && mistralKey) || (voiceEnabled && !(elevenLabsKey && deepgramKey))) {
        document.getElementById("error").innerText = "Error! Check that all API keys are supplied correctly"
        return;
    }

    GLOBAL.keys.set("openai", openAiKey);
    GLOBAL.keys.set("mistral", mistralKey);
    if (voiceEnabled) {
        GLOBAL.config.voices = true;
        GLOBAL.keys.set("elevenlabs", elevenLabsKey);
        GLOBAL.keys.set("deepgram", deepgramKey);
    }

    // @ts-expect-error
    const openAiGood = document.getElementById('openai-good').checked; // @ts-expect-error
    const mistralGood = document.getElementById('mistral-good').checked;

    // @ts-expect-error
    const openAiTemp = parseFloat(document.getElementById('openai-temp').value); // @ts-expect-error
    const mistralTemp = parseFloat(document.getElementById('mistral-temp').value);

    GLOBAL.config.openai = { temperature: openAiTemp, good: openAiGood};
    GLOBAL.config.mistral = { temperature: mistralTemp, good: mistralGood};

    setupGame()
    .then(async () => {
        // seed the cops with their initial prompts
        const evidence = generateEvidence().map(s => s + '.').join(' ');
        await turnDialogue(cop => cop.brain.startChat(evidence));
    })
    .then(gameLoop)
    .then(async () => {
        // fade the screen out to black all smooth and cool like
        await sleep(500);
        GLOBAL.hud.open.element.style.opacity = "0%";
        GLOBAL.hud.mistral.element.style.opacity = "0%";
        while (LIGHT_INTENSITY > 0) {
            LIGHT_INTENSITY -= 0.001
            await sleep(10);
        }
        await sleep(500);
    })
    .then(AB.world.endRun);
}

async function setupGame() {
    AB.removeSplash();

    const p = setupAis();
    setupScene();
    setupMouse();
    setupHud();
    setupInput();
    setupRendering();
    setupCops();

    await p;
}

async function gameLoop() {
    for (let i = 0; i < 5; i++) {
        // wait for the user to type something
        const input = await consumeInput();
        GLOBAL.hud.open.text = ""; // reset all dialogue boxes
        GLOBAL.hud.mistral.text = "";
        
        await turnDialogue(cop => cop.message(input));
    }
}


AB.world.nextStep = function()		 
{
};


AB.world.endRun = async function()
{
    // disable the renderer and controls
    const canvas = GLOBAL.renderer.domElement;
    canvas.parentNode.removeChild(canvas);
    canvas.removeEventListener('click', canvas.requestPointerLock);
    document.getElementById("ab-wrapper").style.pointerEvents = 'auto';

    // ask for response if logged in
    if (AB.runloggedin) {
        const selection = await choiceScreen();
        const runData = {
            winner: selection,
            info: {
                openai: {
                    temperature: openAi.temperature,
                    good: openAi.goodcop,
                },
                mistral: {
                    temperature: mistral.temperature,
                    good: mistral.goodcop,
                }
            }
        };
        // append this run's stats to the user's history if it exists, or start one afresh
        const hasData = await new Promise((resolve) => AB.queryDataExists(resolve));
        const userData = hasData ? await new Promise((resolve) => AB.restoreData(resolve)) : {stats: []};
        userData.stats.push(runData);
        AB.saveData(userData);
    }

    endScreen();
};

function createScene() {
    const scene = new THREE.Scene();
    scene.background = new THREE.Color('skyblue');
    return scene;
}

function createCamera() {
    const camera = new THREE.PerspectiveCamera();
    camera.rotation.order = 'YXZ'; // this angle ordering is necessary for first-person
    return camera;
}

function resizeScreen() {
    // take up the entire screen
    GLOBAL.renderer.setSize(window.innerWidth, window.innerHeight);
    GLOBAL.camera.aspect = window.innerWidth / window.innerHeight;
    GLOBAL.camera.updateProjectionMatrix(); // must be called to get the camera to respond to these changes
}

function createRenderer() {
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.shadowMap.enabled = true;
    return renderer;
}

// create the HTML attributes that make up the game's HUD
function createHud() {
    const hud = {
        input: {
            element: document.createElement('p'),
            text: "",
            on: false,
        },
        open: {
            element: document.createElement("p"),
            text: "",
        },
        mistral: {
            element: document.createElement("p"),
            text: "",
        },
        loading: {
            element: document.createElement("p"),
        }
    };
    const style = {
        position: 'fixed',
        left: '50%',
        transform: 'translateX(-50%)', // this centres it back to the left without messing up the alignment
        bottom: "20px",
        color: 'white',
        fontSize: 'xx-large',
        transition: 'opacity 5s',
        opacity: '100%',
        // overflow: 'hidden', // don't think it's needed
    };
    hud.input.element.id = "input";
    Object.assign(hud.input.element.style, style);

    hud.open.element.id = "open";
    style.color = 'lime';
    style.left = '2%';

    style.top = '2%';
    style.fontSize = 'x-large'
    style.maxWidth = '45%';
    delete style.bottom;
    delete style.transform;
    Object.assign(hud.open.element.style, style);

    hud.mistral.element.id = "mistral";
    style.color = 'orange';
    style.left = '50%';
    Object.assign(hud.mistral.element.style, style);

    hud.loading.element.id = "loading";
    hud.loading.element.innerText = "Loading responses, hang tight...";
    style.color = 'white';
    style.fontSize = 'small';
    style.left = '50%';
    style.transform = 'translateX(-50%)';
    style.top = '50%';
    Object.assign(hud.loading.element.style, style);

    // top-level container for all Ancient Brain stuff
    const abWrapper = document.getElementById("ab-wrapper");
    Object.entries(hud).forEach(([key, value]) => abWrapper.appendChild(value.element));
    return hud;
}

// iterate over every element of a sequence over a given duration (in ms)
/** @returns {Promise<void>} */
function iteratePeriod(sequence, callback, duration) {
    return new Promise((resolve, reject) => {
        const delay = duration / sequence.length;
        let index = 0;
        function step() {
            if (index >= sequence.length) {
                resolve();
                return;
            }
            callback(sequence[index]);
            index++;
            setTimeout(step, delay);
        }
        step();
    });
}

// helper function to sleep d ms
/** @return {Promise<void>} */
function sleep(duration) {
    return new Promise((resolve, reject) => setTimeout(resolve, duration));
}

function setupLoaders() {
    const output = {
        /** @type {GLTFLoader} */
        // @ts-expect-error // couldn't augment the namespace but this does exist on Ancient Brain
        gltf: new THREE.GLTFLoader(),
    };
    Object.values(output).forEach(loader => loader.setPath(UPLOAD_PATH));
    return output;
}

/**
 * @param {string} filename 
 * @return {Promise<GLTF>}
 */
function loadGltf(filename) {
    return new Promise((resolve, reject) => GLOBAL.loaders.gltf.load(filename, resolve, undefined, reject));
}

/**
 * @param {string} filename 
 * @param {string} name 
 * @return {Promise<THREE.Object3D>}
 */
function loadObject(filename, name) {
    return new Promise((resolve, reject) => {
        loadGltf(filename).then(gltf => {
            const obj = gltf.scene.children[0];
            obj.name = name;
            resolve(obj);
        }).catch(reject);
    });
}

/** @return {THREE.Group} */
function createLightbulb() {
    const color = 0xffcccc;
    // bit of a reddish tint, decays quite quickly
    const light = new THREE.PointLight(color, 0.75, 15, 4);
    light.name = "light";
    light.castShadow = true;

    const sphere = new THREE.SphereGeometry(0.05);
    const bulbMaterial = new THREE.MeshStandardMaterial({ color: color, emissive: color, transparent: true, opacity: 0.7 });
    const bulb = new THREE.Mesh(sphere, bulbMaterial);
    bulb.name = "bulb";
    GLOBAL.loop.set("light", () => {
        const intensity = Math.random() * 0.2 + LIGHT_INTENSITY;
        light.intensity = intensity;
        bulbMaterial.emissiveIntensity = intensity;
    });

    const cylinder = new THREE.CylinderGeometry(0.005, 0.002, 1);
    const stringColor = 0x222222;
    // the light directly below won't illuminate it so we make it slightly emissive
    const stringMaterial = new THREE.MeshStandardMaterial({ color: stringColor, emissive: stringColor, emissiveIntensity: 0.3 });
    const string = new THREE.Mesh(cylinder, stringMaterial);
    string.castShadow = true;
    string.receiveShadow = true;
    string.name = "string";
    string.position.y = 0.5;

    const group = new THREE.Group();
    group.name = "lightbulb";
    group.add(light);
    group.add(bulb);
    group.add(string);
    return group;
}

async function setupScene() {
    const room = await loadObject("empty_room.glb", "empty_room");
    room.traverseVisible(obj => obj.receiveShadow = true);
    room.position.set(0, 0, 0);
    GLOBAL.scene.add(room);

    const table = await loadObject("wooden_table.glb", "wooden_table");
    table.position.set(0, -0.3, 1);
    table.castShadow = true;
    GLOBAL.scene.add(table);

    const chair = await loadObject("wooden_chair.glb", "chair");
    chair.position.set(0, -1, 0);
    chair.scale.multiplyScalar(0.75);
    GLOBAL.scene.add(chair);

    const otherChairA = chair.clone(true);
    otherChairA.name = "other_chairA";
    otherChairA.rotateY(THREE.MathUtils.degToRad(180));
    otherChairA.position.set(0.6, -1, 2);
    GLOBAL.scene.add(otherChairA);

    const otherChairB = otherChairA.clone(true);
    otherChairB.name = "other_chairB";
    otherChairB.position.x *= -1;
    GLOBAL.scene.add(otherChairB);

    const lightbulb = createLightbulb();
    lightbulb.position.set(0, 1, 1.5);
    GLOBAL.scene.add(lightbulb);

    // helper function to load the humanoid models
    const loadHuman = async (filename, name) => {
        return loadGltf(filename).then(gltf => {
            const model = gltf.scene;
            model.name = name;

            const mixer = new THREE.AnimationMixer(model);
            mixer.timeScale -= Math.random() * 0.3; // add a random offset to their breathing speeds
            const clock = new THREE.Clock(true);
            GLOBAL.loop.set(name, () => mixer.update(clock.getDelta() * mixer.timeScale));

            const idle = gltf.animations[4]; // its the same index on both models
            const action = mixer.clipAction(idle);
            action.play();
            return model;
        });
    };

    const suitMan = await loadHuman("suit_man.glb", "suit_man");
    suitMan.position.copy(otherChairA.position);
    suitMan.position.y -= 0.3; // make it look like he's sitting
    suitMan.rotation.set(0, 3.5, 0); // look at the player, thereabouts
    GLOBAL.scene.add(suitMan);

    const teeMan = await loadHuman("tee_man.glb", "tee_man");
    teeMan.position.copy(otherChairB.position);
    teeMan.position.y -= 0.3;
    teeMan.rotation.set(0, -3.5, 0);
    GLOBAL.scene.add(teeMan);

    GLOBAL.camera.position.set(0, 0.28, 0.2);
    GLOBAL.camera.lookAt(table.position);
}

function setupRendering() {
    document.body.appendChild(GLOBAL.renderer.domElement);
    resizeScreen();
    window.addEventListener('resize', resizeScreen);
    // this uses internal browser animation API so it's more efficient
    GLOBAL.renderer.setAnimationLoop(() => {
        GLOBAL.loop.forEach(fn => fn()); // execute each callback
        GLOBAL.renderer.render(GLOBAL.scene, GLOBAL.camera);
    });
}

function setupMouse() {
    // need to disable clicking on the div
    document.getElementById("ab-wrapper").style.pointerEvents = 'none';
    // lock the mouse when the user click's the renderer
    const canvas = GLOBAL.renderer.domElement;
    canvas.addEventListener('click', canvas.requestPointerLock);

    const MAX_PITCH = Math.PI / 6; // +/- 30 degrees i think? we just wanna stop you getting sick

    document.addEventListener('mousemove', event => {
        if (document.pointerLockElement === canvas) {
            const deltaX = event.movementX;
            const deltaY = event.movementY;
        
            GLOBAL.camera.rotation.x -= deltaY * MOUSE_SPEED;
            GLOBAL.camera.rotation.y -= deltaX * MOUSE_SPEED;
            // prevent the player from inverting their pitch
            GLOBAL.camera.rotation.x = THREE.MathUtils.clamp(GLOBAL.camera.rotation.x, -MAX_PITCH, MAX_PITCH);
        }
    });
}

function setupHud() {
    const hud = GLOBAL.hud;
    // show the input prompt
    const INTERVAL = 10;
    let barTimer = 0;
    GLOBAL.loop.set("input", () => {
        const input = hud.input;
        input.element.style.visibility = input.on  ? 'visible' : 'hidden';
        if (!input.on) return;
        input.element.innerText = "> " + input.text;
        if (document.pointerLockElement === null) return;
        input.element.innerText += (barTimer++ < INTERVAL / 2 ? "█" : "▒");
        barTimer %= INTERVAL;
    });

    // show the dialogue from the cops, and loading too
    GLOBAL.loop.set("hud", () => {
        hud.open.element.innerText = hud.open.text;
        hud.mistral.element.innerText = hud.mistral.text;
        hud.loading.element.style.visibility = GLOBAL.operations ? 'visible' : 'hidden';
    });
}

// show the input textbox and resolve the promise once they hit enter
/** @return {Promise<string>} */
function consumeInput() {
    const input = GLOBAL.hud.input;
    input.on = true;
    return new Promise((resolve, reject) => {
        /** @param {KeyboardEvent} event */
        function handleInput(event) {
            if (document.pointerLockElement === null) return; // don't take input when unfocused
            if (event.key === "Enter") {
                const output = input.text.trim();
                if (!output) return; // do not accept empty input
                input.on = false;
                resolve(input.text);
                input.text = "";
                document.removeEventListener('keydown', handleInput); // remove this listener
            }
        }
        document.addEventListener('keydown', handleInput);
    });
}

// we're too hipster for <input/>
function setupInput() {
    const input = GLOBAL.hud.input;
    // listen for keystrokes and add them to the buffer
    document.addEventListener('keydown', event => {
        if (!input.on || document.pointerLockElement === null) return; // don't take input when unfocused or disabled
        if (event.key.length === 1) {
            input.text += event.key;
        } else if (event.key === "Backspace") {
            input.text = input.text.slice(0, -1);
        }
    });
}

// test function, probably not going to be part of the game
function setupMovement() {
    // cameras look down their Z axis (as a negative for some reason)
    const controls = new Map([
        ['w', { delta: new THREE.Vector3(0, 0, -1), on: false }],
        ['s', { delta: new THREE.Vector3(0, 0, 1), on: false }],
        ['a', { delta: new THREE.Vector3(-1, 0, 0), on: false }],
        ['d', { delta: new THREE.Vector3(1, 0, 0), on: false }],
        [' ', { delta: new THREE.Vector3(0, 1, 0), on: false }],
        ['Shift', { delta: new THREE.Vector3(0, -1, 0), on: false }],
    ]);

    document.addEventListener('keydown', event => {
        const entry = controls.get(event.key);
        if (!entry) return;
        entry.on = true;
    });

    document.addEventListener('keyup', event => {
        const entry = controls.get(event.key);
        if (!entry) return;
        entry.on = false;
    });

    GLOBAL.loop.set("movement", () => {
        if (!document.pointerLockElement) return; // don't move if the game's not focused

        const delta = new THREE.Vector3();
        controls.forEach(value => {
            if (value.on) delta.add(value.delta);
        });

        delta.applyEuler(GLOBAL.camera.rotation);
        GLOBAL.camera.position.addScaledVector(delta, 0.1);
    });
}

// set up the LLMs and TTS models with their API keys and parameters
async function setupAis() {
    openAi.setApiKey(GLOBAL.keys.get("openai"));
    openAi.setTemperament(GLOBAL.config.openai.good, GLOBAL.config.openai.temperature);

    mistral.setApiKey(GLOBAL.keys.get("mistral"));
    mistral.setTemperament(GLOBAL.config.mistral.good, GLOBAL.config.mistral.temperature);

    if (GLOBAL.config.voices) {
        elevenLabs.setApiKey(GLOBAL.keys.get("elevenlabs"));
        deepgram.setApiKey(GLOBAL.keys.get("deepgram"));
    }
}

function setupCops() {
    const voices = GLOBAL.config.voices;
    GLOBAL.cops.push(
        new Cop(openAi, GLOBAL.hud.open, voices && elevenLabs),
        new Cop(mistral, GLOBAL.hud.mistral, voices && deepgram),
    );
}

function generateEvidence() {
    // facts to flesh out the details of the crime
    const relationship = [
        "The suspect is the victim's fiancé",
        "The suspect is the victim's business partner",
        "The suspect and victim were recently involved in a legal dispute",
        "The suspect had been dating the victim but they recently broke up",
        "The suspect is the victim's estranged sibling"
    ];
    const location = [
        "The murder occurred in the victim's home",
        "The murder took place at a remote cabin the suspect rented a week prior",
        "The murder scene was in an alley behind the victim's workplace",
        "The body was discovered in a park where the suspect often jogs",
        "The crime occurred in the suspect's car, as evidenced by blood stains found inside"
    ];
    const recency = [
        "The murder happened within the last 24 hours, according to the coroner's report",
        "The murder occurred three days ago, as indicated by the level of decomposition",
        "The murder is estimated to have happened a week ago based on weathering of the evidence",
        "The crime is believed to have taken place over a month ago, given forensic analysis of the remains",
        "The murder is fresh, estimated to have occurred just hours before the body was found"
    ];
    const time = [
        "The murder occurred late at night, between midnight and 2 AM",
        "The crime happened in the early morning, shortly after sunrise",
        "The victim was killed in the evening, around dinnertime",
        "The murder took place in the afternoon, during a time when the area was usually quiet",
        "The exact time of the murder cannot be pinpointed but is believed to be during the night"
    ];
    const weapon = [
        "The murder weapon was a knife found at the scene, with the suspect's fingerprints on it",
        "The victim was shot with a firearm registered to the suspect",
        "The murder weapon was a blunt object, such as a baseball bat found in the suspect's car",
        "Poison was detected in the victim's system, and the suspect recently purchased a similar toxin",
        "The victim was strangled, and fibers matching the suspect's scarf were found under the nails"
    ];
    const motive = [
        "The suspect stood to gain a large inheritance from the victim's death",
        "The victim had threatened to expose the suspect's illegal activities",
        "The suspect was jealous of the victim's recent promotion",
        "The victim was blackmailing the suspect over a personal secret",
        "The suspect was involved in a romantic affair that the victim had discovered"
    ];
    // random miscellany about the suspect and victim
    const suspect = [
        "The suspect has no prior criminal record but was arrested for disorderly conduct last year",
        "The suspect recently quit their job without explanation",
        "The suspect's car was reported stolen a day after the murder",
        "The suspect is known to collect rare knives, one of which matches the murder weapon",
        "The suspect has a tattoo with a date matching the victim's birthday",
        "The suspect was seen making a large cash withdrawal two days before the murder",
        "Neighbors reported hearing loud arguments from the suspect's house in the week leading up to the murder",
        "The suspect booked a one-way flight to another country the morning after the crime",
        "The suspect recently changed their appearance, dyeing their hair and shaving their beard",
        "The suspect has a documented history of gambling and is known to owe money to dangerous individuals"
    ];
    const victim = [
        "The victim was a well-known philanthropist with no known enemies",
        "The victim had filed for divorce three weeks before the murder",
        "The victim owned a rare painting that was reported missing from their home",
        "The victim had recently been diagnosed with a terminal illness",
        "The victim left behind a diary that mentioned feeling unsafe around someone close",
        "The victim's phone records show they had been receiving anonymous threatening messages",
        "The victim had purchased a life insurance policy worth $2 million, naming the suspect as the beneficiary",
        "The victim was part of a controversial lawsuit involving a powerful corporation",
        "The victim's recent social media posts hinted at plans to start a new life abroad",
        "The victim had booked an appointment with a private investigator the day before their death"
    ];

    // return a random fact from every list
    return [relationship, location, recency, time, weapon, motive, suspect, victim].map(list => list[Math.floor(Math.random() * list.length)]);
}

// sometimes despite our best efforts the LLMs spit out too much dialogue
// this cuts it to the first complete sentence under the specified character limit
/** @param {string} s  */
function trimOutput(s) {
    const blank = s.indexOf('\n\n');
    const max = blank !== -1 ? Math.min(blank, MAX_CHARS) : MAX_CHARS;
    let i = max;
    while (i > 0 && !".!?".includes(s[i])) i--;
    return s.slice(0, i + 1);
}

// convenience function to prod the LLMs with something and show their output
/** @param {(cop: Cop) => Promise<string>} fn  */
async function turnDialogue(fn) {
    const p1 = fn(GLOBAL.cops[0]), p2 = fn(GLOBAL.cops[1]);
    console.log("triggering #1")
    await GLOBAL.cops[0].speak(trimOutput(await p1));
    await sleep(500);
    console.log("triggering #2")
    await GLOBAL.cops[1].speak(trimOutput(await p2));
}

// show a splash giving the user two options and resolve once the user's made their choice
async function choiceScreen() {
    AB.newSplash();
    AB.splashHtml(`
<h1>Whodunnit (better)?</h1>
<p>
    Select the model that you believe sleuthed best tonight.<br>
    How you define it is up to you - you are a human with free will, right?<br>
    Maybe one followed the instructions better, or was able to discern inconsistencies in your story.
</p>
<div style="display: flex; justify-content: center; gap: 20px;">
    <button id="select-openai" style="color: lime; font-size: xx-large;">OpenAI</button>
    <button id="select-mistral" style="color: orange; font-size: xx-large;">Mistral</button>
</div>
    `);

    const selectOpenAi = document.getElementById('select-openai');
    const selectMistral = document.getElementById('select-mistral');
    const result = await new Promise((resolve) => {
        selectOpenAi.addEventListener('click', () => resolve("openai"));
        selectMistral.addEventListener('click', () => resolve("mistral"));
    });
    AB.removeSplash();
    return result;
}

// get the results across every game
/** @return {Promise<Stat[]>} */
async function getStats() {
    /** @type {{stats: Stat[]}[]} */
    const allData = await new Promise((resolve) => AB.getAllData(resolve)).then(a => a.map(e => e[2])); // the doc says this is a tuple of (id, name, object)
    const allStats = allData.map(o => o.stats || []).flat();
    return allStats;
}

async function endScreen() {
    const allStats = await getStats();
    console.log("Stats for the games are here:");
    console.log(allStats);

    const games = allStats.length;
    const openAiWins = allStats.filter(s => s.winner === 'openai').length;
    const mistralWins = allStats.filter(s => s.winner === 'mistral').length;

    const openAiPercent = Math.floor((openAiWins / games) * 100);
    const mistralPercent = Math.floor((mistralWins / games) * 100);
    AB.newSplash();
    AB.splashHtml(`
<h1>Results</h1>
<p>Across all ${games} finished games, here's the stats</p>
<div style="display: flex; justify-content: center; gap: 20px;">
    <div>
        <h2 style="color: lime;">OpenAi</h2>
        <p>${openAiWins} wins</p>
        <p>${openAiPercent}%</p>
    </div>
    <div>
        <h2 style="color: orange;">Mistral</h2>
        <p>${mistralWins} wins</p>
        <p>${mistralPercent}%</p>
    </div>
</div>
<p>If you're curious to see the exact details of each game, the raw data has been logged to the console.<br>Alternatively, you can run getStats() any time.</p>
    `);
}