Miskatonic-Investigative-Society / CoC7-FoundryVTT

An unofficial implementation of the Call of Cthulhu 7th Edition game system for Foundry Virtual Tabletop
https://discord.gg/foundryvtt
GNU General Public License v3.0
119 stars 97 forks source link

[Feature Request] Implement optional rule to roll for initiative #96

Closed Vass1979 closed 4 years ago

Vass1979 commented 4 years ago

This is an optional rule that is found in the keeper's handbook page 123.

Basically you roll your dexterity and get ranked in order of initiative as follows. Critical Success -> Extreme Success -> Hard Success -> Regular Success ->Fail ->Critical Fail If you have a readied firearm you get to roll with a bonus die. If two people tie, highest Dexterity goes first. If they have the same Dexterity, randomly decide. I had someone write me a custom API script on Roll20 for this, that I've included here in case it is helpful.

on("ready",() => {

    /* eslint-disable no-unused-vars */
    const getTurnArray = () => ( '' === Campaign().get('turnorder') ? [] : JSON.parse(Campaign().get('turnorder')));
    const setTurnArray = (ta) => Campaign().set({turnorder: JSON.stringify(ta)});
    const addTokenTurn = (id, pr) => Campaign().set({ turnorder: JSON.stringify( [...getTurnArray(), {id,pr}]) });
    const addCustomTurn = (custom, pr) => Campaign().set({ turnorder: JSON.stringify( [...getTurnArray(), {id:-1,custom,pr}]) });
    const removeTokenTurn = (tid) => Campaign().set({ turnorder: JSON.stringify( getTurnArray().filter( (to) => to.id !== tid)) });
    const clearTurnOrder = () => Campaign().set({turnorder:'[]'});
    const sorter_asc = (a, b) => b.pr - a.pr;
    const sorter_desc = (a, b) => a.pr - b.pr;
    const sortTurnOrder = (sortBy = sorter_desc) => Campaign().set({turnorder: JSON.stringify(getTurnArray().sort(sortBy))});
    /* eslint-enable no-unused-vars */

    const sorter_cthulhu = (a,b) => {
        if(a.pr !== b.pr){
            return degreeToOrder[a.pr] - degreeToOrder[b.pr];
        }
        let aTok = getObj('graphic',a.id);
        let bTok = getObj('graphic',b.id);
        if(aTok && bTok) {
            let aDex = getAttrByName(aTok.get('represents'),'dex');
            let bDex = getAttrByName(bTok.get('represents'),'dex');
            if(aDex && bDex){
                return (parseFloat(bDex)||0) - (parseFloat(aDex)||0);
            }
            return 0;
        }
        return 0;
    };

    const degreeToOrder = {
        Critical        : 1,
        Extreme         : 2,
        Hard            : 3,
        Success         : 4,
        "Critical Fail" : 5,
        Fail            : 6
    };

    const degreeFromRollAndDex = (r,d) => {
        if ( r === 1    )  { return "Critical";      } 
        if ( r <= d / 5 )  { return "Extreme";       } 
        if ( r <= d / 2 )  { return "Hard";          } 
        if ( r <= d     )  { return "Success";       } 
        if ( r === 100  )  { return "Critical Fail"; } 
        if ( r > d      )  { return "Fail";          } 

        return "Critical Fail";
    };

    on("chat:message",(msg) => {
        if('api' === msg.type) {
            let args = msg.content.split(/\s+/);
            let cmd = (args.shift()||"").toLowerCase();
            let who = (getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname');
            let isBonus = true;

            switch(cmd) {
                // CLEAR TURN TRACKER
            case '!creset':
            case '!cthulhureset':
                if(playerIsGM(msg.playerid)){
                    clearTurnOrder();
                } else {
                    sendChat("",`/w "${who}" Only the GM may clear the turn order.`);
                }
                break;

            case '!ci':
            case '!cthulhuinit':
                isBonus = false;
                /* break; // intentional dropthrough */ /* falls through */
            case '!cib':
            case '!cthulhuinitbonus': {

                let tokens = (msg.selected || [])
                .map(o=>getObj('graphic',o._id))
                .filter(g=>undefined !== g)
                .map(t => ({
                    token: t,
                    character: getObj('character', t.get('represents')),
                    dex: getAttrByName(t.get('represents'),'dex')
                }))
                .filter( o=> (undefined !== o.character && undefined !== o.dex))
                .map(o=>Object.assign({}, o, { roll1: randomInteger(100), roll2: (isBonus ? randomInteger(100) : 101)}))
                .map(o=>Object.assign({}, o, { roll: Math.min(o.roll1, o.roll2)}))
                .map(o=>Object.assign({}, o, { degree: degreeFromRollAndDex(o.roll,parseFloat(o.dex)||0)}))
                ;

                if( ! tokens.length) {
                    sendChat("",`/w "${who}" Please select one or more tokens that represent characters with a dex attribute.`);
                    return;
                }

                tokens.forEach(o=>{
                    sendChat(o.token.get('name'),`&{template:coc${isBonus?'-bonus':'-1'}}`
                        +`{{name=Initiative${isBonus?' (Bonus Die)':''}}}`
                        +`{{success=[[${Math.floor(parseFloat(o.dex)||0)}]]}}`
                        +`{{hard=[[${Math.floor((parseFloat(o.dex)||0)/2)}]]}}`
                        +`{{extreme=[[${Math.floor((parseFloat(o.dex)||0)/5)}]]}}`
                        +`{{roll1=[[${o.roll}]]}}`
                        + (isBonus ? `{{PlayerRoll1=[[${o.roll1}]]}} {{PlayerRoll2=[[${o.roll2}]]}}` : '')
                    );
                });

                tokens.forEach(o => addTokenTurn(o.token.id,o.degree));

                sortTurnOrder(sorter_cthulhu);
            }

            }
        }
    });
});

image

HavlockV commented 4 years ago

Thanks for the feedback and suggestion. I'll have a look on how to implement that asap

Vass1979 commented 4 years ago

Have you had a chance to look into this at all? It's the one thing that is stopping me from moving my games over from Roll20 currently :(

Can it even be done in Foundry? I was browsing the API and saw that the initiative field is a number, can it accept text strings?

HavlockV commented 4 years ago

I had, it seems reasonably feasible. I'll try to do it in the next few days.

HavlockV commented 4 years ago

Option has been added. You can select it from the system's options. initiative will be roll with DEX only or as successLevel.DEX. Icon added to the combat tracker to draw a gun. On normal rules this will just add 50 to the initiative score. On optional rules this will re-trigger a roll with a bonus die and keep higher of both.