diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fadf5ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + # Guard: the pushed tag must match package.json version, because + # build-release.mjs derives the zip name and module.json download URL + # from package.json. A mismatch would publish a broken download link. + - name: Verify tag matches package.json version + env: + REF_NAME: ${{ github.ref_name }} + run: | + TAG="${REF_NAME#v}" + PKG="$(node -p "require('./package.json').version")" + echo "Tag version: $TAG" + echo "package.json: $PKG" + if [ "$TAG" != "$PKG" ]; then + echo "::error::Tag $REF_NAME does not match package.json version $PKG" + exit 1 + fi + + - run: npm ci + + # check ends with `vite build`, producing dist/; package zips it. + - run: npm run check + - run: npm run package + + - name: Determine prerelease flag + id: prerelease + env: + REF_NAME: ${{ github.ref_name }} + run: | + case "$REF_NAME" in + *-rc*|*-beta*|*-alpha*) echo "value=true" >> "$GITHUB_OUTPUT" ;; + *) echo "value=false" >> "$GITHUB_OUTPUT" ;; + esac + + # Pull just this version's section out of CHANGELOG.md for the release body. + - name: Extract changelog section + env: + REF_NAME: ${{ github.ref_name }} + run: | + VERSION="${REF_NAME#v}" + awk -v header="## v${VERSION}" ' + $0 == header { found = 1; next } + found && /^## v/ { exit } + found { print } + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "No changelog section for v${VERSION}; falling back to full changelog." + cp CHANGELOG.md release-notes.md + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: Grim Arithmetic ${{ github.ref_name }} + body_path: release-notes.md + prerelease: ${{ steps.prerelease.outputs.value == 'true' }} + files: | + releases/grim-arithmetic-${{ github.ref_name }}.zip + releases/module.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1587066 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - run: npm ci + + # Chains eslint + vitest + vite build (see package.json "check"). + - run: npm run check diff --git a/.gitignore b/.gitignore index aae3867..9d29f17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ node_modules/ coverage/ dist/ -docs/ -plans/ +docs/plans/ releases/ screenshots/ .DS_Store diff --git a/README.md b/README.md index f0a997a..f4a9a64 100644 --- a/README.md +++ b/README.md @@ -92,3 +92,7 @@ npm install npm run check # eslint + vitest + vite build npm run package # build the release zip ``` + +## Author + +Kyle Travis — [kyletravis.com](https://kyletravis.com) · [@kyletravis](https://x.com/kyletravis) diff --git a/dist/assets/simulation.worker-ChHnUOCM.js b/dist/assets/simulation.worker-ChHnUOCM.js deleted file mode 100644 index 1e86760..0000000 --- a/dist/assets/simulation.worker-ChHnUOCM.js +++ /dev/null @@ -1 +0,0 @@ -function e(e){let t=typeof e==`string`?n(e):r(e),i=()=>{t=t+1831565813>>>0;let e=t;return e=Math.imul(e^e>>>15,e|1),e^=e+Math.imul(e^e>>>7,e|61),((e^e>>>14)>>>0)/4294967296};return{next:i,nextInt:(e,t)=>{if(!Number.isInteger(e)||!Number.isInteger(t))throw Error(`nextInt bounds must be integers: got ${e}, ${t}`);if(e>t)throw Error(`nextInt: min (${e}) must be <= max (${t})`);let n=t-e+1;return e+Math.floor(i()*n)}}}function t(e,t){return i(typeof e==`string`?n(e):r(e),typeof t==`string`?n(t):r(t))}function n(e){let t=1779033703^e.length;for(let n=0;n>>19;return t=Math.imul(t^t>>>16,2246822507),t=Math.imul(t^t>>>13,3266489909),t^=t>>>16,t>>>0}function r(e){if(!Number.isFinite(e))throw Error(`Numeric seed must be finite: got ${e}`);return Math.floor(Math.abs(e))>>>0}function i(e,t){let n=(e^Math.imul(t,2654435761))>>>0;return n=Math.imul(n^n>>>16,2246822507),n=Math.imul(n^n>>>13,3266489909),n^=n>>>16,n>>>0}const a=[`criticalFailure`,`failure`,`success`,`criticalSuccess`];function o(e){let{die:t,total:n,dc:r}=e,i;return i=n>=r+10?`criticalSuccess`:n>=r?`success`:n<=r-10?`criticalFailure`:`failure`,t===20?s(i,1):t===1?s(i,-1):i}function s(e,t){let n=a.indexOf(e);return a[Math.max(0,Math.min(a.length-1,n+t))]}const c={maxFormulaLength:256,maxTerms:20,maxDicePerTerm:100,maxDieFaces:1e3,maxTotalDice:500,maxOutcomes:5e4};function l(e){return Error(`Dice formula rejected: ${e}`)}function u(e){let t=d(e).match(/[+-]?[^+-]+/g)??[];if(t.length>c.maxTerms)throw l(`too many terms (${t.length} exceeds ${c.maxTerms})`);let n=0;for(let e of t){let t=e.replace(/^[+-]/,``).match(/^(\d+)d(\d+)$/);if(t){let r=Number(t[1]);if(r>c.maxDicePerTerm)throw l(`term ${e} has ${r} dice (max ${c.maxDicePerTerm})`);n+=r}}if(n>c.maxTotalDice)throw l(`too many total dice (${n} exceeds ${c.maxTotalDice})`);let r=new Map([[0,1]]);for(let e of t){let t=f(e);if(t.size>c.maxOutcomes)throw l(`term ${e} produced ${t.size} outcomes (max ${c.maxOutcomes})`);if(r=p(r,t),r.size>c.maxOutcomes)throw l(`convolution produced ${r.size} outcomes (max ${c.maxOutcomes})`)}return m(r,e)}function d(e){if(e.length>c.maxFormulaLength)throw l(`formula length ${e.length} exceeds ${c.maxFormulaLength}`);let t=e.replace(/\s+/g,``);if(!/^[+-]?(\d+d\d+|\d+)([+-](\d+d\d+|\d+))*$/.test(t))throw Error(`Unsupported damage formula: ${e}`);return t}function f(e){let t=e.startsWith(`-`)?-1:1,n=e.replace(/^[+-]/,``),r=n.match(/^(\d+)d(\d+)$/);if(!r)return new Map([[t*Number(n),1]]);let i=Number(r[1]),a=Number(r[2]);if(!Number.isInteger(i)||!Number.isInteger(a)||i<1||a<1)throw Error(`Unsupported damage term: ${e}`);if(a>c.maxDieFaces)throw l(`term ${e} has ${a} faces (max ${c.maxDieFaces})`);let o=new Map([[0,1]]),s=new Map;for(let e=1;e<=a;e+=1)s.set(t*e,1/a);for(let e=0;e({damage:e,probability:t})).sort((e,t)=>e.damage-t.damage);if(n.length===0||n.reduce((e,t)=>e+t.probability,0)<=0)throw Error(`Unsupported damage formula: ${t}`);let r=n.reduce((e,t)=>e+t.damage*t.probability,0);return{min:n[0].damage,max:n[n.length-1].damage,mean:r,outcomes:n}}function h(e,t){switch(e.kind){case`battle-medicine`:{let n=e.medicineDC??15,r=e.medicineModifier??0,i=t.nextInt(1,20),a=o({die:i,total:i+r,dc:n});switch(a){case`criticalSuccess`:return{healedAmount:_(`4d8+8`,t),degree:a};case`success`:return{healedAmount:_(`2d8+4`,t),degree:a};case`failure`:return{healedAmount:0,degree:a};case`criticalFailure`:return{healedAmount:0,degree:a,collateralDamage:_(`1d8`,t)}}return{healedAmount:0,degree:a}}case`heal-spell-1action`:return{healedAmount:_(`1d10`,t)};case`heal-spell-2action`:{let n=Math.max(1,e.spellRank??1);return{healedAmount:_(`${n}d8+${n*8}`,t)}}case`heal-spell-3action`:{let n=Math.max(1,e.spellRank??1);return{healedAmount:_(`${n}d8+${n*8}`,t)}}case`heal-cantrip-1action`:return{healedAmount:_(`1d10`,t)};case`heal-cantrip-2action`:{let n=Math.max(1,e.healerLevel??1);return{healedAmount:_(`${1+Math.ceil(n/2)}d8`,t)}}}}function g(e,t,n={}){if(t<=0&&!n.clearsDying)return e;let r=Math.min(e.hp.max,e.hp.current+Math.max(0,t)),i={...e,hp:{...e.hp,current:r}};return n.clearsDying&&i.dying>0&&(i.dying=0,r>0&&(i.downed=!1)),i}function _(e,t){let n=u(e),r=t.next(),i=0;for(let e of n.outcomes)if(i+=e.probability,r({combatantId:e.id,side:e.side,dieRoll:0,bonus:e.initiativeBonus,total:e.initiativeBonus}));let r=n.pcsWinTies??!0,i=e.map(e=>{let n=t.nextInt(1,20);return{combatantId:e.id,side:e.side,dieRoll:n,bonus:e.initiativeBonus,total:n+e.initiativeBonus}});return i.sort((e,t)=>t.total===e.total?e.side===t.side?e.combatantIdw(e,n)))return{damageType:n,resistance:0,weakness:0,immune:!0,note:`Applied ${n} immunity; modeled damage is 0.`};let o=[];return i>0&&o.push(`${n} resistance ${i}`),a>0&&o.push(`${n} weakness ${a}`),{damageType:n,resistance:i,weakness:a,immune:!1,note:o.length>0?`Applied ${b(o)}.`:`No ${n} resistance, weakness, or immunity matched.`}}function b(e){return e.length<=1?e[0]??``:`${e.slice(0,-1).join(`, `)} and ${e.at(-1)}`}function x(e,t){let n=new Map;for(let r of e.outcomes){let e=S(r.damage,t);n.set(e,(n.get(e)??0)+r.probability)}let r=Array.from(n.entries()).sort(([e],[t])=>e-t).map(([e,t])=>({damage:e,probability:t})),i=r.reduce((e,t)=>e+t.damage*t.probability,0);return{min:r[0]?.damage??0,max:r.at(-1)?.damage??0,mean:i,outcomes:r}}function S(e,t){return t.immune?0:Math.max(0,e-t.resistance)+t.weakness}function C(e,t){return e.reduce((e,n)=>w(n.type,t)?Math.max(e,n.value):e,0)}function w(e,t){let n=T(e),r=T(t);return!n||!r?!1:n===r||n===`all`?!0:n===`physical`?r===`bludgeoning`||r===`piercing`||r===`slashing`:!1}function T(e){if(e)return e.trim().toLowerCase().replace(/\s+/g,`-`)}function E(e){return e===`agile`?[0,-4,-8]:e===`none`?[0,0,0]:[0,-5,-10]}function ee(e){return{min:e.min*2,max:e.max*2,mean:e.mean*2,outcomes:e.outcomes.map(e=>({damage:e.damage*2,probability:e.probability}))}}function te(e,t){if(e.dying<=0)return{roll:0,degree:`success`,newDying:0,stabilized:!0};let n=10+e.dying,r=t.nextInt(1,20),i=o({die:r,total:r,dc:n}),a=0;switch(i){case`criticalSuccess`:a=-2;break;case`success`:a=-1;break;case`failure`:a=0;break;case`criticalFailure`:a=1;break}let s=Math.max(0,e.dying+a);return{roll:r,degree:i,newDying:s,stabilized:s===0}}function ne(e,t){let n=t.nextInt(1,20),r=o({die:n,total:n+e.attackBonus+e.mapPenalty,dc:e.defenderAc}),i=0;if(r===`success`||r===`criticalSuccess`){let n=u(e.damageFormula),a=y(e.damageType,e.defenderAdjustments);i=D(x(r===`criticalSuccess`?ee(n):n,a),t)}return{attackerId:e.attackerId,defenderId:e.defenderId,attackId:e.attackId,attackName:e.attackName,degree:r,dieRoll:n,damage:i}}function D(e,t){let n=t.next(),r=0;for(let t of e.outcomes)if(r+=t.probability,n0||e.downed,r=e.downed,i=t.damage,a=e.hp.temp,o=e.hp.current+e.hp.temp;if(a>0){let e=Math.min(a,i);a-=e,i-=e}let s=Math.max(0,e.hp.current-i),c={...e,hp:{...e.hp,current:s,temp:a}},l=Math.max(0,o-(s+a));if(s>0)return{combatant:c,damageAbsorbed:l,becameDowned:!1,becameDead:!1};if(e.side===`enemy`)return{combatant:{...c,downed:!0,dead:!0},damageAbsorbed:l,becameDowned:!r,becameDead:!0};let u=t.degree===`criticalSuccess`?2:1,d=n?e.dying+u:u+e.wounded,f=d>=Math.max(1,4-e.doomed);return f&&e.heroPoints>0&&!e.heroPointSurvivalUsed?{combatant:{...c,dying:0,downed:!0,dead:!1,heroPoints:e.heroPoints-1,heroPointSurvivalUsed:!0},damageAbsorbed:l,becameDowned:!r,becameDead:!1,heroPointSurvivalFired:!0}:{combatant:{...c,dying:d,downed:!0,dead:f},damageAbsorbed:l,becameDowned:!r,becameDead:f}}function k(e){return Math.max(1,4-e.doomed)}function A(e){return!e.downed&&!e.dead}function j(e){return e.filter(A)}function re(e){return e.filter(e=>!e.dead)}function ie(e){if(e.attacks.length===0)return;let t=e.attacks[0],n=N(t.damageFormula);for(let r=1;rn&&(n=a,t=i)}return t}function M(e){return e.attacks[0]}function N(e){try{return u(e).mean}catch{return-1/0}}const ae={id:`boss-cinematic`,description:`Use the highest-damage attack on the toughest standing PC, all strikes on the same target.`,chooseTurn(e){let t=j(e.pcs);if(t.length===0)return{strikes:[]};let n=[...t].sort((e,t)=>t.hp.current===e.hp.current?e.ide.hp.current===t.hp.current?e.id low-HP > full-HP PCs; attack downed only if no standing PCs remain.`,chooseTurn(e){let t=j(e.pcs),n;if(n=t.length>0?[...t].sort(ce):re(e.pcs),n.length===0)return{strikes:[]};let r=M(e.attacker);if(!r)return{strikes:[]};let i=n[0],a=[];for(let e=0;e<2;e+=1)a.push({attackId:r.id,targetId:i.id,mapIndex:e});return{strikes:a}}};function ce(e,t){return t.wounded===e.wounded?e.hp.current===t.hp.current?e.idt.hp.current===e.hp.current?e.id!e.downed&&!e.dead),n=e.attacker,r=I(e.pcs);if(r&&F(n.healing)){let e=R(n,r,`emergency`);if(e)return{strikes:[],heal:e}}let i=L(e.pcs,n);if(i&&F(n.healing)){let e=R(n,i,`topup`);if(e){let r=M(n);if(r&&t.length>0){let n=[...t].sort(B)[0];return{strikes:[{attackId:r.id,targetId:n.id,mapIndex:0}],heal:e}}return{strikes:[],heal:e}}}if(t.length===0)return{strikes:[]};let a=M(n);if(!a)return{strikes:[]};let o=[...t].sort(B)[0],s=[];for(let e=0;e<2;e+=1)s.push({attackId:a.id,targetId:o.id,mapIndex:e});return{strikes:s}}};function F(e){return e?Object.values(e.healSpellSlotsRemaining).some(e=>e>0)||e.healCantripLevel!==null||e.hasBattleMedicine:!1}function I(e){let t=e.filter(e=>e.dying>0&&!e.dead);if(t.length!==0)return[...t].sort((e,t)=>t.dying===e.dying?e.id!e.downed&&!e.dead&&e.dying===0&&e.hp.current{let r=+(e.id===t.id),i=+(n.id===t.id);if(r!==i)return r-i;let a=e.hp.current/e.hp.max,o=n.hp.current/n.hp.max;return a===o?e.idNumber(e)).filter(t=>e[t]>0).sort((e,t)=>e-t)[0]}function B(e,t){let n=V(e),r=V(t);return n===r?e.hp.current===t.hp.current?e.idt&&(t=e)}catch{}return t}function H(e,t,n,r=0,i={}){let a=new Map,o=new Map;for(let t of e.pcs)a.set(t.id,W(t)),o.set(t.id,t.hp.current+t.hp.temp);for(let t of e.enemies)a.set(t.id,W(t)),o.set(t.id,t.hp.current+t.hp.temp);let s=v(Array.from(a.values()),n,{useFixedOrder:i.useFixedInitiative}),c=le[t.tacticsProfile],l=P,u={},d=[],f=null,p=0,m=0,_=0,y=0;for(let e=1;e<=t.maxRounds;e+=1){p=e;for(let r of s){let i=a.get(r.combatantId);if(!i||i.dead)continue;if(i.side===`pc`&&i.dying>0){let e=te(i,n);m+=1;let t=k(i),r={...i,dying:e.newDying,downed:e.newDying>0||i.hp.current===0,dead:e.newDying>=t};a.set(i.id,r);continue}if(i.downed)continue;let o=G(a,`pc`),s=G(a,`enemy`),p=(i.side===`pc`?l:c).chooseTurn({attacker:i,pcs:o,enemies:s,round:e},n);if(p.heal){let e=a.get(p.heal.targetId);if(e&&!e.dead){let t=h({kind:p.heal.kind,healerLevel:i.healing?.healCantripLevel??void 0,spellRank:p.heal.spellRank,medicineModifier:i.healing?.medicineModifier,medicineDC:i.healing?.medicineDC},n),r=p.heal.kind.startsWith(`heal-spell`)&&e.dying>0,o=g(e,t.healedAmount,{clearsDying:r});if(t.collateralDamage&&t.collateralDamage>0&&(o=O(o,{damage:t.collateralDamage,degree:`success`}).combatant),a.set(e.id,o),y+=1,p.heal.kind.startsWith(`heal-spell`)&&p.heal.spellRank!==void 0){let e=U(i,p.heal.spellRank);a.set(i.id,e)}else if(p.heal.kind===`battle-medicine`){let t=ue(i,e.id);a.set(i.id,t)}}}if(p.strikes.length!==0)for(let r of p.strikes){let o=a.get(r.targetId),s=i.attacks.find(e=>e.id===r.attackId);if(!o||o.dead||!s)continue;let c=E(s.mapType===`unknown`?`normal`:s.mapType)[r.mapIndex]??0,l=ne({attackerId:i.id,defenderId:o.id,attackId:s.id,attackName:s.name,attackBonus:s.attackBonus,mapPenalty:c,defenderAc:o.defenses.ac,damageFormula:s.damageFormula,damageType:s.damageType,defenderAdjustments:o.damageAdjustments},n),p=O(o,{damage:l.damage,degree:l.degree});if(a.set(o.id,p.combatant),p.damageAbsorbed>0){let e=`${i.id}->${o.id}`;u[e]=(u[e]??0)+p.damageAbsorbed}p.becameDowned&&o.side===`pc`&&f===null&&(f=e),p.heroPointSurvivalFired&&(_+=1),t.captureEvents&&d.push({round:e,attackerId:i.id,defenderId:o.id,attackId:s.id,attackName:s.name,degree:l.degree,damage:p.damageAbsorbed,causedDown:p.becameDowned})}}if(K(a)||de(a))break}let b=Array.from(a.values()).map(e=>({id:e.id,side:e.side,endingHp:e.hp.current,dying:e.dying,wounded:e.wounded,doomed:e.doomed,downed:e.downed,dead:e.dead,damageTaken:Math.max(0,(o.get(e.id)??0)-(e.hp.current+e.hp.temp))})),x=fe(a);return{iterationIndex:r,roundsElapsed:p,firstDownRound:f,tpk:x,perCombatant:b,damageByPair:u,events:t.captureEvents?d:void 0,healsFired:y,recoveryChecksFired:m,heroPointSurvivalsFired:_}}function U(e,t){if(!e.healing)return e;let n=e.healing.healSpellSlotsRemaining[t]??0;if(n<=0)return e;let r={...e.healing.healSpellSlotsRemaining};return r[t]=n-1,{...e,healing:{...e.healing,healSpellSlotsRemaining:r}}}function ue(e,t){if(!e.healing)return e;let n=new Set(e.healing.battleMedicineUsedTargets);return n.add(t),{...e,healing:{...e.healing,battleMedicineUsedTargets:n}}}function W(e){return{...e,hp:{...e.hp},defenses:{...e.defenses}}}function G(e,t){let n=[];for(let r of e.values())r.side===t&&n.push(r);return n}function K(e){let t=0;for(let n of e.values())if(n.side===`pc`&&(t+=1,!n.downed&&!n.dead))return!1;return t>0}function de(e){let t=0;for(let n of e.values())if(n.side===`enemy`&&(t+=1,!n.dead))return!1;return t>0}function fe(e){return K(e)}const q=1e4;var pe=class extends Error{requested;cap;constructor(e){super(`Iterations ${e} exceeds engine cap ${q}`),this.name=`MaxIterationsExceededError`,this.requested=e,this.cap=q}};function me(e){if(!Number.isInteger(e.iterations)||e.iterations<1)throw Error(`iterations must be a positive integer, got ${e.iterations}`);if(e.iterations>1e4)throw new pe(e.iterations);if(!Number.isInteger(e.maxRounds)||e.maxRounds<1)throw Error(`maxRounds must be a positive integer, got ${e.maxRounds}`);if(e.wallClockBudgetMs!==void 0&&(!Number.isFinite(e.wallClockBudgetMs)||e.wallClockBudgetMs<0))throw Error(`wallClockBudgetMs must be a non-negative finite number, got ${e.wallClockBudgetMs}`)}function he(e,t,n=ge){let r=-1/0,i=null,a=(...a)=>{let o=n();o-r>=t?(r=o,i=null,e(...a)):i=a};return a.flush=()=>{i&&(e(...i),i=null,r=n())},a}function ge(){return Date.now()}function _e(n,r,i={}){me(r);let a=r.seed??Date.now(),o=r.wallClockBudgetMs??0,s=o>0?Date.now():0,c=[],l=!1;for(let u=0;u0&&Date.now()-s>o){l=!0;break}let d=t(a,u);c.push(H(n,r,e(d),u)),i.onProgress?.(u+1,r.iterations)}return ve(n,r,a,c,l)}function ve(e,t,n,r,i){let a=r.length;if(a===0)return{iterationsRequested:t.iterations,iterationsCompleted:0,seed:n,tacticsProfile:t.tacticsProfile,aborted:i,anyPcDownProbability:0,tpkProbability:0,meanFirstDownRound:null,medianFirstDownRound:null,perPc:e.pcs.map(e=>({id:e.id,name:e.name,downProbability:0,deathProbability:0,meanEndingHp:e.hp.current,topContributingEnemyId:null})),perEnemy:e.enemies.map(e=>({id:e.id,name:e.name,damageShare:0,topTargetId:null})),safetyNet:{meanHealsPerIteration:0,meanRecoveryChecksPerIteration:0,heroPointSurvivalRate:0},caveats:[...e.caveats,`No iterations completed.`]};let o=0,s=0,c=[],l=new Map,u=new Map,d=new Map,f=new Map;for(let e of r){let t=!1,n=!0,r=0;for(let i of e.perCombatant){if(i.side!==`pc`)continue;r+=1,i.downed||i.dead?(t=!0,l.set(i.id,(l.get(i.id)??0)+1)):n=!1,i.dead&&u.set(i.id,(u.get(i.id)??0)+1);let e=d.get(i.id)??[];e.push(i.endingHp),d.set(i.id,e)}r>0&&t&&(o+=1),r>0&&n&&(s+=1),e.firstDownRound!==null&&c.push(e.firstDownRound);for(let[t,n]of Object.entries(e.damageByPair))f.set(t,(f.get(t)??0)+n)}let p=e.pcs.map(t=>{let n=l.get(t.id)??0,r=u.get(t.id)??0,i=d.get(t.id)??[],o=i.length>0?i.reduce((e,t)=>e+t,0)/i.length:t.hp.current,s=null,c=0;for(let n of e.enemies){let e=f.get(`${n.id}->${t.id}`)??0;e>c&&(c=e,s=n.id)}return{id:t.id,name:t.name,downProbability:n/a,deathProbability:r/a,meanEndingHp:o,topContributingEnemyId:s}}),m=0;for(let e of f.values())m+=e;let h=e.enemies.map(t=>{let n=0,r=null,i=0;for(let a of e.pcs){let e=f.get(`${t.id}->${a.id}`)??0;n+=e,e>i&&(i=e,r=a.id)}return{id:t.id,name:t.name,damageShare:m>0?n/m:0,topTargetId:r}}),g=0,_=0,v=0;for(let e of r)g+=e.healsFired,_+=e.recoveryChecksFired,e.heroPointSurvivalsFired>0&&(v+=1);let y=o/a,b=s/a,x=c.length>0?c.reduce((e,t)=>e+t,0)/c.length:null,S=ye(o,a,s,c);return{iterationsRequested:t.iterations,iterationsCompleted:a,seed:n,tacticsProfile:t.tacticsProfile,aborted:i,anyPcDownProbability:y,tpkProbability:b,meanFirstDownRound:x,medianFirstDownRound:c.length>0?J(c):null,perPc:p,perEnemy:h,safetyNet:{meanHealsPerIteration:g/a,meanRecoveryChecksPerIteration:_/a,heroPointSurvivalRate:v/a},confidenceIntervals:S,caveats:[...e.caveats]}}function J(e){let t=[...e].sort((e,t)=>e-t),n=Math.floor(t.length/2);return t.length%2==0?(t[n-1]+t[n])/2:t[n]}const Y=1.959963984540054;function ye(e,t,n,r){let i=X(e,t),a=X(n,t),o=null;return r.length>0&&(o=be(r)),{anyPcDown:i,tpk:a,meanFirstDownRound:o}}function X(e,t){if(t===0)return{lower:0,upper:0};let n=e/t,r=Y*Math.sqrt(n*(1-n)/t);return{lower:Z(n-r),upper:Z(n+r)}}function be(e){let t=e.length;if(t===0)return{lower:0,upper:0};let n=e.reduce((e,t)=>e+t,0)/t,r=e.reduce((e,t)=>e+(t-n)**2,0)/t,i=Y*Math.sqrt(r/t);return{lower:n-i,upper:n+i}}function Z(e){return Math.max(0,Math.min(1,e))}const Q=typeof self<`u`?self:globalThis;let $=!1;const xe={get aborted(){return $}};Q.addEventListener(`message`,e=>{let t=e.data;if(!(!t||typeof t!=`object`)){if(t.type===`abort`){$=!0;return}if(t.type===`run`){$=!1;let e=he((e,t)=>Q.postMessage({type:`progress`,completed:e,total:t}),100);try{let n=_e(t.setup,t.config,{onProgress:e,abortSignal:xe});e.flush(),Q.postMessage({type:`result`,result:n})}catch(e){Q.postMessage({type:`error`,message:e instanceof Error?e.message:String(e)})}}}}); \ No newline at end of file diff --git a/dist/grim-arithmetic.js b/dist/grim-arithmetic.js deleted file mode 100644 index 2225523..0000000 --- a/dist/grim-arithmetic.js +++ /dev/null @@ -1,2584 +0,0 @@ -var e = { - id: "grim-arithmetic", - title: "Grim Arithmetic", - description: "GM-facing PF2e mortality and encounter-risk analysis for Foundry VTT.", - version: "0.7.1-rc3", - authors: [{ name: "Kyle Travis" }], - compatibility: { - minimum: "13", - verified: "14.363" - }, - relationships: { systems: [{ - id: "pf2e", - type: "system" - }] }, - esmodules: ["dist/grim-arithmetic.js"], - styles: ["styles/grim-arithmetic.css"], - url: "https://github.com/kyletravis/grim-arithmetic", - manifest: "https://github.com/kyletravis/grim-arithmetic/releases/latest/download/module.json", - download: "https://github.com/kyletravis/grim-arithmetic/releases/download/v0.7.1-rc3/grim-arithmetic-v0.7.1-rc3.zip" -}, t = "grim-arithmetic", n = "Grim Arithmetic", r = e.version; -//#endregion -//#region src/debug-capture.ts -function i(e) { - let t = e.actor, n = d(t?.system); - return { - token: { - id: e.id, - name: e.name, - disposition: e.document?.disposition - }, - actor: t ? { - id: t.id, - name: t.name, - type: t.type, - system: o(n), - itemTypes: l(t.itemTypes), - meleeItems: u(t.items).filter((e) => e.type === "melee").map((e) => ({ - id: e.id, - name: e.name, - type: e.type, - system: { - bonus: d(e.system).bonus, - attack: d(e.system).attack, - damageRolls: d(e.system).damageRolls, - traits: d(e.system).traits - } - })) - } : null - }; -} -function a(e) { - if (!e || !(typeof game < "u" && game.user?.isGM === !0) || !(typeof game < "u" && (game.settings?.get?.("grim-arithmetic", "debugLogging") ?? !1))) return null; - let t = i(e); - return console.log("Grim Arithmetic | Debug capture", t), t; -} -function o(e) { - let t = c({ - immunities: e.immunities, - weaknesses: e.weaknesses, - resistances: e.resistances - }); - return c({ - attributes: s(e), - saves: e.saves, - traits: e.traits, - legacyDamageAdjustments: Object.keys(t).length > 0 ? t : void 0 - }); -} -function s(e) { - let t = d(e.attributes); - return c({ - hp: t.hp, - ac: t.ac, - immunities: t.immunities, - weaknesses: t.weaknesses, - resistances: t.resistances - }); -} -function c(e) { - return Object.fromEntries(Object.entries(e).filter(([, e]) => e !== void 0)); -} -function l(e) { - return { condition: d(e).condition }; -} -function u(e) { - if (Array.isArray(e)) return e.filter(f); - let t = d(e).contents; - if (Array.isArray(t)) return t.filter(f); - if (f(e) && typeof e.filter == "function") { - let t = e.filter(f); - return Array.isArray(t) ? t.filter(f) : []; - } - return []; -} -function d(e) { - return f(e) ? e : {}; -} -function f(e) { - return typeof e == "object" && !!e; -} -//#endregion -//#region src/settings.ts -var p = "enableMonteCarlo"; -function m() { - game.settings.register(t, "defaultStrikes", { - name: "Default enemy Strike count", - hint: "Default number of Strikes used for immediate-threat estimates.", - scope: "world", - config: !0, - type: Number, - default: 2, - choices: { - 1: "1 Strike", - 2: "2 Strikes", - 3: "3 Strikes" - } - }), game.settings.register(t, "debugLogging", { - name: "Debug logging", - hint: "Log Grim Arithmetic debug information to the browser console.", - scope: "client", - config: !0, - type: Boolean, - default: !1 - }), game.settings.register(t, p, { - name: "Enable Monte Carlo encounter simulation", - hint: "Disable on low-end machines if simulation runs are too slow. The Encounter Danger Board still works either way.", - scope: "client", - config: !0, - type: Boolean, - default: !0 - }); -} -function h() { - if (typeof game > "u") return !0; - try { - return !!(game.settings?.get?.("grim-arithmetic", "enableMonteCarlo") ?? !0); - } catch { - return !0; - } -} -//#endregion -//#region src/engine/degree-of-success.ts -var g = [ - "criticalFailure", - "failure", - "success", - "criticalSuccess" -]; -function _(e) { - let { die: t, total: n, dc: r } = e, i; - return i = n >= r + 10 ? "criticalSuccess" : n >= r ? "success" : n <= r - 10 ? "criticalFailure" : "failure", t === 20 ? v(i, 1) : t === 1 ? v(i, -1) : i; -} -function v(e, t) { - let n = g.indexOf(e); - return g[Math.max(0, Math.min(g.length - 1, n + t))]; -} -//#endregion -//#region src/engine/attack-probability.ts -function ee(e) { - let t = { - criticalSuccess: 0, - success: 0, - failure: 0, - criticalFailure: 0 - }; - for (let n = 1; n <= 20; n += 1) { - let r = _({ - die: n, - total: n + e.attackBonus, - dc: e.ac - }); - t[r] += 1; - } - return { - criticalSuccess: t.criticalSuccess / 20, - success: t.success / 20, - failure: t.failure / 20, - criticalFailure: t.criticalFailure / 20 - }; -} -//#endregion -//#region src/engine/dice.ts -var y = { - maxFormulaLength: 256, - maxTerms: 20, - maxDicePerTerm: 100, - maxDieFaces: 1e3, - maxTotalDice: 500, - maxOutcomes: 5e4 -}; -function b(e) { - return /* @__PURE__ */ Error(`Dice formula rejected: ${e}`); -} -function x(e) { - let t = te(e).match(/[+-]?[^+-]+/g) ?? []; - if (t.length > y.maxTerms) throw b(`too many terms (${t.length} exceeds ${y.maxTerms})`); - let n = 0; - for (let e of t) { - let t = e.replace(/^[+-]/, "").match(/^(\d+)d(\d+)$/); - if (t) { - let r = Number(t[1]); - if (r > y.maxDicePerTerm) throw b(`term ${e} has ${r} dice (max ${y.maxDicePerTerm})`); - n += r; - } - } - if (n > y.maxTotalDice) throw b(`too many total dice (${n} exceeds ${y.maxTotalDice})`); - let r = new Map([[0, 1]]); - for (let e of t) { - let t = ne(e); - if (t.size > y.maxOutcomes) throw b(`term ${e} produced ${t.size} outcomes (max ${y.maxOutcomes})`); - if (r = S(r, t), r.size > y.maxOutcomes) throw b(`convolution produced ${r.size} outcomes (max ${y.maxOutcomes})`); - } - return re(r, e); -} -function te(e) { - if (e.length > y.maxFormulaLength) throw b(`formula length ${e.length} exceeds ${y.maxFormulaLength}`); - let t = e.replace(/\s+/g, ""); - if (!/^[+-]?(\d+d\d+|\d+)([+-](\d+d\d+|\d+))*$/.test(t)) throw Error(`Unsupported damage formula: ${e}`); - return t; -} -function ne(e) { - let t = e.startsWith("-") ? -1 : 1, n = e.replace(/^[+-]/, ""), r = n.match(/^(\d+)d(\d+)$/); - if (!r) return new Map([[t * Number(n), 1]]); - let i = Number(r[1]), a = Number(r[2]); - if (!Number.isInteger(i) || !Number.isInteger(a) || i < 1 || a < 1) throw Error(`Unsupported damage term: ${e}`); - if (a > y.maxDieFaces) throw b(`term ${e} has ${a} faces (max ${y.maxDieFaces})`); - let o = new Map([[0, 1]]), s = /* @__PURE__ */ new Map(); - for (let e = 1; e <= a; e += 1) s.set(t * e, 1 / a); - for (let e = 0; e < i; e += 1) o = S(o, s); - return o; -} -function S(e, t) { - let n = /* @__PURE__ */ new Map(); - for (let [r, i] of e) for (let [e, a] of t) { - let t = r + e; - n.set(t, (n.get(t) ?? 0) + i * a); - } - return n; -} -function re(e, t) { - let n = Array.from(e.entries()).map(([e, t]) => ({ - damage: e, - probability: t - })).sort((e, t) => e.damage - t.damage); - if (n.length === 0 || n.reduce((e, t) => e + t.probability, 0) <= 0) throw Error(`Unsupported damage formula: ${t}`); - let r = n.reduce((e, t) => e + t.damage * t.probability, 0); - return { - min: n[0].damage, - max: n[n.length - 1].damage, - mean: r, - outcomes: n - }; -} -//#endregion -//#region src/engine/mortality.ts -function C(e) { - let t = x(e.damageFormula), n = pe(t), r = w(e.damageType, e.targetAdjustments), i = T(t, r), a = T(n, r), o = ue(e.mapType).slice(0, e.strikes), s = [], c = [], l = 0, u = 0, d = 0, f = new Map([[0, 1]]); - for (let t of o) { - let n = ee({ - attackBonus: e.attackBonus + t, - ac: e.ac - }); - s.push(n.success), c.push(n.criticalSuccess); - let r = n.failure + n.criticalFailure; - l += n.success * i.mean + n.criticalSuccess * a.mean, u += n.success * me(i.outcomes, e.hp), d += n.criticalSuccess * me(a.outcomes, e.hp), f = de(f, [ - { - damage: 0, - probability: r - }, - ...fe(i.outcomes, n.success), - ...fe(a.outcomes, n.criticalSuccess) - ]); - } - let p = ge(he(f, e.hp)), m = Math.max(0, e.hp - l), h = ce({ - wounded: e.wounded ?? 0, - doomed: e.doomed ?? 0, - assumeHeroPointAvailable: e.assumeHeroPointAvailable ?? !1 - }); - return { - downProbability: p, - expectedHpAfterTurn: m, - hitChanceByStrike: s, - critChanceByStrike: c, - riskLabel: _e(p), - topRiskDrivers: ve({ - downProbability: p, - hitDownProbability: u, - critDownProbability: d, - highestCritChance: Math.max(...c) - }), - assumptions: [ - "Uses exact damage distributions for supported formulas.", - r.note, - "Critical damage is modeled as simple double damage of the supported formula total.", - `Enemy turn model: ${e.strikes} Strike${e.strikes === 1 ? "" : "s"}.`, - `MAP model: ${e.mapType}.` - ], - notModeled: [ - "Deadly, fatal, precision, splash, and persistent damage.", - "Reactions such as Shield Block or Champion reactions.", - "Healing before or during the enemy turn.", - "Permanent death probability." - ], - damage: ye(i, a), - dyingSeverity: h, - damageAdjustment: r - }; -} -function w(e, t) { - let n = E(e), r = { - damageType: n ?? "unknown", - resistance: 0, - weakness: 0, - immune: !1, - note: "Damage type unknown; no resistance, weakness, or immunity applied." - }; - if (!n) return r; - let i = oe(t?.resistances ?? [], n), a = oe(t?.weaknesses ?? [], n); - if ((t?.immunities ?? []).some((e) => se(e, n))) return { - damageType: n, - resistance: 0, - weakness: 0, - immune: !0, - note: `Applied ${n} immunity; modeled damage is 0.` - }; - let o = []; - return i > 0 && o.push(`${n} resistance ${i}`), a > 0 && o.push(`${n} weakness ${a}`), { - damageType: n, - resistance: i, - weakness: a, - immune: !1, - note: o.length > 0 ? `Applied ${ie(o)}.` : `No ${n} resistance, weakness, or immunity matched.` - }; -} -function ie(e) { - return e.length <= 1 ? e[0] ?? "" : `${e.slice(0, -1).join(", ")} and ${e.at(-1)}`; -} -function T(e, t) { - let n = /* @__PURE__ */ new Map(); - for (let r of e.outcomes) { - let e = ae(r.damage, t); - n.set(e, (n.get(e) ?? 0) + r.probability); - } - let r = Array.from(n.entries()).sort(([e], [t]) => e - t).map(([e, t]) => ({ - damage: e, - probability: t - })), i = r.reduce((e, t) => e + t.damage * t.probability, 0); - return { - min: r[0]?.damage ?? 0, - max: r.at(-1)?.damage ?? 0, - mean: i, - outcomes: r - }; -} -function ae(e, t) { - return t.immune ? 0 : Math.max(0, e - t.resistance) + t.weakness; -} -function oe(e, t) { - return e.reduce((e, n) => se(n.type, t) ? Math.max(e, n.value) : e, 0); -} -function se(e, t) { - let n = E(e), r = E(t); - return !n || !r ? !1 : n === r || n === "all" ? !0 : n === "physical" ? r === "bludgeoning" || r === "piercing" || r === "slashing" : !1; -} -function E(e) { - if (e) return e.trim().toLowerCase().replace(/\s+/g, "-"); -} -function ce({ wounded: e, doomed: t, assumeHeroPointAvailable: n }) { - let r = Math.max(0, Math.floor(e)), i = Math.max(0, Math.floor(t)), a = Math.max(1, 4 - i), o = 1 + r, s = 2 + r; - return { - wounded: r, - doomed: i, - deathThreshold: a, - normalDownDying: o, - critDownDying: s, - immediateDeathFlag: le({ - normalDownDying: o, - critDownDying: s, - deathThreshold: a - }), - heroPointNote: n ? "Hero Point prevention is assumed available; this can prevent death but is not modeled as a survival probability." : "No Hero Point death-prevention assumption is applied." - }; -} -function le({ normalDownDying: e, critDownDying: t, deathThreshold: n }) { - return e >= n ? `Normal down would reach Dying ${e}, meeting or exceeding the doomed-adjusted death threshold (Dying ${n}).` : t >= n ? `Crit-down would reach Dying ${t}, meeting or exceeding the doomed-adjusted death threshold (Dying ${n}).` : t === n - 1 ? `Crit-down would put this PC at Dying ${t}, one step below the doomed-adjusted death threshold (Dying ${n}).` : `If downed, severity would be Dying ${e} on a normal hit or Dying ${t} on a critical hit.`; -} -function ue(e) { - return e === "agile" ? [ - 0, - -4, - -8 - ] : e === "none" ? [ - 0, - 0, - 0 - ] : [ - 0, - -5, - -10 - ]; -} -function de(e, t) { - let n = /* @__PURE__ */ new Map(); - for (let [r, i] of e) for (let e of t) { - if (e.probability === 0) continue; - let t = r + e.damage, a = i * e.probability; - n.set(t, (n.get(t) ?? 0) + a); - } - return n; -} -function fe(e, t) { - return t === 0 ? [] : e.map((e) => ({ - damage: e.damage, - probability: e.probability * t - })); -} -function pe(e) { - return { - min: e.min * 2, - max: e.max * 2, - mean: e.mean * 2, - outcomes: e.outcomes.map((e) => ({ - damage: e.damage * 2, - probability: e.probability - })) - }; -} -function me(e, t) { - return e.reduce((e, n) => e + (n.damage >= t ? n.probability : 0), 0); -} -function he(e, t) { - let n = 0; - for (let [r, i] of e) r >= t && (n += i); - return n; -} -function ge(e) { - return Math.max(0, Math.min(1, e)); -} -function _e(e) { - return e < .05 ? "Low" : e < .15 ? "Guarded" : e < .35 ? "Dangerous" : e < .6 ? "Severe" : "Grim"; -} -function ve({ downProbability: e, hitDownProbability: t, critDownProbability: n, highestCritChance: r }) { - return e === 0 ? ["No exact supported hit or crit damage roll in the selected sequence downs the PC."] : t === 0 && n > 0 && n < r ? ["Only some crit damage rolls can down the PC; exact distribution reduces false precision from average damage."] : t === 0 && n > 0 ? [`Down risk is crit-driven; highest strike crit chance is ${Math.round(r * 100)}%.`] : ["Cumulative exact hit and crit damage rolls can down the PC in the modeled sequence."]; -} -function ye(e, t) { - let n = e.mean.toFixed(1); - return { - min: e.min, - max: e.max, - average: n, - critMin: t.min, - critMax: t.max, - swinginess: be(e, n) - }; -} -function be(e, t) { - let n = e.max - e.min + 1; - return n >= e.mean ? `High swing: damage range is ${n} around an average of ${t}.` : `Moderate swing: damage range is ${n} around an average of ${t}.`; -} -//#endregion -//#region src/engine/encounter-risk.ts -var xe = (e) => `${e} has no supported melee Strike with numeric attack bonus and damage formula.`, Se = "Encounter too large to compute pairwise risk safely. Reduce combatants or use the single-pair detail view."; -function Ce(e, t) { - let { adapter: n, controls: r, pairLimit: i } = t, a = e.hostiles.map((e) => ({ - hostile: e, - attacks: Te(n, e.token) - })), o = e.pcs.length * a.reduce((e, t) => e + t.attacks.length, 0); - if (i !== void 0 && o > i) return { - pairs: [], - skipped: !0, - caveats: [Se] - }; - let s = [], c = []; - for (let { hostile: t, attacks: n } of a) { - if (n.length === 0) { - c.push(xe(t.snapshot.name)); - continue; - } - for (let i of e.pcs) for (let e of n) s.push(we(i.snapshot, t.snapshot, e, r)); - } - return { - pairs: s, - skipped: !1, - caveats: c - }; -} -function we(e, t, n, r) { - let i = []; - try { - let a = C({ - hp: e.hp.current + (e.hp.temp ?? 0), - ac: e.defenses.ac + r.shieldBonus, - attackBonus: n.attackBonus, - damageFormula: n.damageFormula, - strikes: r.strikes, - mapType: Ee(r.mapMode, n.mapType), - wounded: De(e, r.woundedOverride), - doomed: e.deathState?.doomed ?? 0, - assumeHeroPointAvailable: Oe(e, r.heroPointMode), - damageType: n.damageType, - targetAdjustments: e.damageAdjustments - }); - return { - pcId: e.id, - pcName: e.name, - enemyId: t.id, - enemyName: t.name, - attackId: n.id, - attackName: n.name, - downProbability: a.downProbability, - riskLabel: a.riskLabel, - caveats: i - }; - } catch (r) { - return i.push(`Risk could not be computed for this pair: ${r instanceof Error ? r.message : "unknown error"}.`), { - pcId: e.id, - pcName: e.name, - enemyId: t.id, - enemyName: t.name, - attackId: n.id, - attackName: n.name, - downProbability: 0, - riskLabel: "Low", - caveats: i - }; - } -} -function Te(e, t) { - try { - return e.getAttacksFromToken(t); - } catch { - return []; - } -} -function Ee(e, t) { - return e === "auto" ? t === "unknown" ? "normal" : t : e; -} -function De(e, t) { - return t === "current" ? e.deathState?.wounded ?? 0 : Number(t); -} -function Oe(e, t) { - return t === "available" ? !0 : t === "unavailable" ? !1 : (e.deathState?.heroPoints ?? 0) > 0; -} -//#endregion -//#region src/foundry/encounter-participants.ts -var D = "Unknown actor", ke = "No active combat encounter — using scene tokens as a best-effort fallback.", Ae = "No active combat encounter."; -function je(e, t, n = {}) { - let r = e.combatants ? Array.from(e.combatants) : []; - return r.length > 0 ? O(r.map((e) => e.token), t, []) : n.allowSceneFallback && e.sceneTokens ? O(Array.from(e.sceneTokens), t, [ke]) : { - pcs: [], - hostiles: [], - unsupported: [], - caveats: [Ae] - }; -} -function O(e, t, n) { - let r = [], i = [], a = [], o = [...n]; - for (let n of e) { - if (!n) { - a.push(`${D} (no token)`); - continue; - } - let e; - try { - e = t.getCombatantFromToken(n); - } catch { - a.push(n.name ?? D); - continue; - } - if (!e) { - a.push(n.name ?? D); - continue; - } - e.disposition === "pc" ? r.push({ - token: n, - snapshot: e - }) : e.disposition === "enemy" ? i.push({ - token: n, - snapshot: e - }) : o.push(`${e.name} is ${e.disposition} and was excluded from the danger board.`); - } - return { - pcs: r, - hostiles: i, - unsupported: a, - caveats: o - }; -} -function k(e, t = {}) { - let n = game.combat, r = n?.combatants ? Array.from(n.combatants).map((e) => ({ token: Me(e) })) : void 0, i = canvas.tokens?.placeables; - return je({ - combatants: r, - sceneTokens: i - }, e, t); -} -function Me(e) { - let t = e.token; - if (t) return t.object ?? t; -} -//#endregion -//#region src/systems/pf2e-adapter.ts -var Ne = -1, A = class { - id = "pf2e"; - label = "Pathfinder Second Edition"; - getCombatantFromToken(e) { - let t = e.actor; - if (!t) return null; - let n = R(t.system), r = R(n.attributes), i = R(r.hp), a = R(r.ac), o = i.value, s = i.max, c = a.value; - if (!B(o) || !B(s) || !B(c)) return null; - let l = R(n.saves), u = R(R(n.resources).heroPoints), d = R(n.traits), f = V(R(n.perception).value) ?? V(R(r.perception).value); - return { - id: e.id ?? t.id ?? "", - name: e.name ?? t.name ?? "Unknown Combatant", - disposition: Ye(e, t), - hp: { - current: o, - max: s, - temp: V(i.temp) - }, - defenses: { - ac: c, - fort: V(R(l.fortitude).value), - reflex: V(R(l.reflex).value), - will: V(R(l.will).value) - }, - deathState: { - dying: F(t, "dying"), - wounded: F(t, "wounded"), - doomed: F(t, "doomed"), - heroPoints: V(u.value) - }, - damageAdjustments: { - resistances: et(r.resistances ?? n.resistances), - weaknesses: et(r.weaknesses ?? n.weaknesses), - immunities: tt(r.immunities ?? n.immunities) - }, - initiativeBonus: f, - pcCapabilities: t.type === "character" ? Be(t) : void 0, - traits: U(d.value), - assumptions: [] - }; - } - getAttacksFromToken(e) { - let t = e.actor; - return t ? t.type === "character" ? Pe(t) : I(t).filter((e) => e.type === "melee").map((e) => { - let t = R(e.system), n = Xe(t), r = Ze(t), i = Qe(r); - if (!B(n) || typeof i != "string") return null; - let a = U(R(t.traits).value); - return { - id: e.id ?? "", - name: e.name ?? "Unknown Strike", - attackBonus: n, - damageFormula: i, - damageType: $e(r), - traits: a, - mapType: a.includes("agile") ? "agile" : "normal", - assumptions: ["PF2e Strike extraction is first-pass and may miss conditional modifiers."] - }; - }).filter((e) => e !== null) : []; - } -}; -function Pe(e) { - let t = R(e.system).actions, n = (Array.isArray(t) ? t : []).map((e) => Fe(e)).filter((e) => e !== null); - return n.length > 0 ? n : I(e).filter((e) => e.type === "weapon").filter(Re).map((e) => ze(e)).filter((e) => e !== null); -} -function Fe(e) { - if (!z(e)) return null; - let t = H(e.totalModifier) ?? H(e.attackBonus) ?? H(e.mod) ?? H(R(e.attack).totalModifier); - if (t === void 0) return null; - let n = Ie(e); - if (!n) return null; - let r = Le(e), i = U(e.traits), a = R(e.item); - return { - id: typeof e.slug == "string" && e.slug || typeof e.id == "string" && e.id || typeof a.id == "string" && a.id || "", - name: typeof e.label == "string" && e.label || typeof e.name == "string" && e.name || typeof a.name == "string" && a.name || "Unknown Strike", - attackBonus: t, - damageFormula: n, - damageType: r, - traits: i, - mapType: i.includes("agile") ? "agile" : "normal", - assumptions: ["PC Strike extracted from actor.system.actions; conditional modifiers (status, MAP-adjacent feats) may be missing."] - }; -} -function Ie(e) { - if (typeof e.damageFormula == "string") return e.damageFormula; - if (typeof e.damage == "string") return e.damage; - if (z(e.damage)) { - let t = e.damage; - if (typeof t.formula == "string") return t.formula; - if (typeof t.damage == "string") return t.damage; - } -} -function Le(e) { - if (typeof e.damageType == "string") return e.damageType; - if (z(e.damage)) { - let t = e.damage; - if (typeof t.damageType == "string") return t.damageType; - if (typeof t.type == "string") return t.type; - } -} -function Re(e) { - let t = R(R(e.system).equipped); - if (t.carryType === "held") return !0; - let n = H(t.handsHeld); - return n !== void 0 && n > 0; -} -function ze(e) { - let t = R(e.system), n = R(t.damage), r = H(n.dice) ?? 1, i = H(n.die) ?? 6, a = H(n.modifier) ?? 0; - if (r < 1 || i < 2) return null; - let o = a > 0 ? `${r}d${i}+${a}` : a < 0 ? `${r}d${i}${a}` : `${r}d${i}`, s = typeof n.damageType == "string" ? n.damageType : typeof n.type == "string" ? n.type : void 0, c = H(R(t.bonus).value) ?? 0, l = U(R(t.traits).value); - return { - id: e.id ?? "", - name: e.name ?? "Unknown Weapon", - attackBonus: c, - damageFormula: o, - damageType: s, - traits: l, - mapType: l.includes("agile") ? "agile" : "normal", - assumptions: ["PC Strike fallback from weapon item: attack bonus excludes character proficiency and ability modifiers."] - }; -} -function Be(e) { - let t = R(R(R(e.system).skills).medicine), n = H(t.totalModifier) ?? H(t.value), r = Ge(H(t.rank)), i = Ke(e), { healSpellSlots: a, healCantripLevel: o } = Ve(e); - return qe() && Je(e, a, o), n !== void 0 || i || Object.keys(a).length > 0 || o !== null ? { - medicineModifier: n, - hasBattleMedicine: i, - medicineDC: r, - healSpellSlots: Object.keys(a).length > 0 ? a : void 0, - healCantripLevel: o - } : { - medicineDC: r, - healCantripLevel: null - }; -} -function Ve(e) { - let t = N(e, "spell"), n = N(e, "spellcastingEntry"), r = {}, i = null, a = /* @__PURE__ */ new Set(), o = !1; - for (let n of t) { - let t = R(n.system); - if (P(n, t) !== "heal") continue; - o = !0; - let r = M(n); - r && a.add(r), Ue(t) && (i = H(t.casterLevel) ?? We(e) ?? i ?? 1); - } - let s = !1; - for (let t of n) { - let n = R(t.system), c = R(n.prepared), l = typeof c.value == "string" ? c.value : void 0, u = R(n.slots); - for (let [t, n] of Object.entries(u)) { - let c = He(t); - if (c === void 0) continue; - let u = R(n), d = j(u.prepared), f = 0; - for (let e of d) { - let t = R(e), n = t.id; - typeof n == "string" && a.has(n) && t.expended !== !0 && (f += 1); - } - if (f > 0) { - s = !0, c === 0 ? i === null && (i = We(e) ?? 1) : r[c] = (r[c] ?? 0) + f; - continue; - } - if (l === "spontaneous" && o && c >= 1) { - let e = H(u.value); - e !== void 0 && e > 0 && (r[c] = (r[c] ?? 0) + e, s = !0); - } - } - } - if (!s) for (let e of t) { - let t = R(e.system); - if (P(e, t) !== "heal" || Ue(t)) continue; - let n = H(t.level) ?? H(R(t.location).heightenedLevel), i = H(t.slotsRemaining) ?? H(t.uses) ?? 1; - typeof n == "number" && n >= 1 && i > 0 && (r[n] = (r[n] ?? 0) + i); - } - return { - healSpellSlots: r, - healCantripLevel: i - }; -} -function j(e) { - return Array.isArray(e) ? e : typeof e != "object" || !e ? [] : Object.values(e); -} -function M(e) { - if (typeof e.id == "string" && e.id.length > 0) return e.id; - let t = e; - if (typeof t._id == "string" && t._id.length > 0) return t._id; -} -function N(e, t) { - if (!e) return []; - let n = R(e.itemTypes)[t]; - return Array.isArray(n) && n.length > 0 ? n.filter(L) : I(e).filter((e) => e.type === t); -} -function P(e, t) { - return typeof t.slug == "string" ? t.slug : e.name?.toLowerCase().replace(/\s+/g, "-"); -} -function He(e) { - let t = e.match(/^slot(\d+)$/); - if (t) return Number(t[1]); -} -function Ue(e) { - return U(R(e.traits).value).includes("cantrip") || e.isCantrip === !0 ? !0 : H(e.level) === 0; -} -function We(e) { - let t = R(R(e.system).details); - return H(R(t.level).value) ?? H(t.level); -} -function Ge(e) { - switch (e) { - case 4: return 40; - case 3: return 30; - case 2: return 20; - case 1: return 15; - default: return 15; - } -} -function Ke(e) { - let t = N(e, "feat"); - for (let e of t) { - let t = R(e.system); - if ((typeof t.slug == "string" ? t.slug : e.name?.toLowerCase().replace(/\s+/g, "-")) === "battle-medicine") return !0; - } - return !1; -} -function qe() { - if (typeof game > "u") return !1; - try { - return !!(game.settings?.get?.("grim-arithmetic", "debugLogging") ?? !1); - } catch { - return !1; - } -} -function Je(e, t, n) { - let r = I(e), i = R(e.itemTypes), a = {}; - for (let e of r) { - let t = typeof e.type == "string" ? e.type : "(no-type)"; - a[t] = (a[t] ?? 0) + 1; - } - let o = Object.keys(i), s = {}; - for (let e of o) { - let t = i[e]; - Array.isArray(t) && (s[e] = t.length); - } - let c = N(e, "spell"), l = N(e, "spellcastingEntry"), u = c.filter((e) => P(e, R(e.system)) === "heal").map((e) => ({ - id: M(e), - name: e.name, - slug: R(e.system).slug, - level: R(e.system).level, - isCantrip: R(e.system).isCantrip, - traits: R(R(e.system).traits).value, - slotsRemaining: R(e.system).slotsRemaining, - location: R(e.system).location - })), d = l.map((e) => { - let t = R(e.system), n = R(t.slots), r = {}; - for (let [e, t] of Object.entries(n)) { - let n = R(t), i = j(n.prepared); - r[e] = { - max: n.max, - value: n.value, - preparedCount: i.length, - preparedSample: i.slice(0, 5).map((e) => R(e)) - }; - } - return { - id: M(e), - name: e.name, - preparedKind: R(t.prepared).value, - tradition: R(t.tradition).value, - slotKeys: Object.keys(n), - slotSummaries: r - }; - }); - console.log("Grim Arithmetic | extraction probe", { - actor: e.name, - actorType: e.type, - totalItems: r.length, - itemTypeCounts: a, - itemTypesArrayLengths: s, - healSpells: u, - spellcastingEntries: d, - extractedSlots: t, - extractedCantripLevel: n - }); -} -function Ye(e, t) { - return t.type === "character" ? "pc" : e.document?.disposition === Ne ? "enemy" : "neutral"; -} -function F(e, t) { - let n = e.itemTypes?.condition?.find((e) => e.slug === t); - return n ? V(n.value) ?? V(R(R(n.system).value).value) ?? 0 : 0; -} -function I(e) { - let t = e?.items; - if (Array.isArray(t)) return t.filter(L); - let n = R(t).contents; - if (Array.isArray(n)) return n.filter(L); - if (z(t) && typeof t.filter == "function") { - let e = t.filter(L); - return Array.isArray(e) ? e : []; - } - return []; -} -function L(e) { - return z(e); -} -function Xe(e) { - return H(R(e.bonus).value) ?? H(R(e.attack).value); -} -function Ze(e) { - let t = R(e.damageRolls); - return Object.values(t).find(z); -} -function Qe(e) { - if (!e) return; - let t = e.damage, n = e.formula; - if (typeof t == "string") return t; - if (typeof n == "string") return n; -} -function $e(e) { - if (!e) return; - let t = e.damageType ?? e.type ?? e.category; - return typeof t == "string" ? t : void 0; -} -function et(e) { - return (Array.isArray(e) ? e : Object.values(R(e))).filter(z).map((e) => { - let t = e.type ?? e.slug ?? e.label, n = H(e.value) ?? H(e.amount); - return typeof t != "string" || n === void 0 ? null : { - type: t, - value: n - }; - }).filter((e) => e !== null); -} -function tt(e) { - return (Array.isArray(e) ? e : Object.values(R(e))).map((e) => { - if (typeof e == "string") return e; - let t = R(e), n = t.type ?? t.slug ?? t.label; - return typeof n == "string" ? n : null; - }).filter((e) => typeof e == "string"); -} -function R(e) { - return z(e) ? e : {}; -} -function z(e) { - return typeof e == "object" && !!e; -} -function B(e) { - return typeof e == "number" && Number.isFinite(e); -} -function V(e) { - return B(e) ? e : void 0; -} -function H(e) { - if (B(e)) return e; - if (typeof e != "string") return; - let t = Number(e.trim().replace(/^\+/, "")); - return B(t) ? t : void 0; -} -function U(e) { - return Array.isArray(e) ? e.map((e) => { - if (typeof e == "string") return e; - let t = R(e).slug; - return typeof t == "string" ? t : null; - }).filter((e) => typeof e == "string") : []; -} -//#endregion -//#region src/ui/danger-board.ts -var nt = 5; -function rt(e, t = {}) { - let n = t.topN ?? nt; - return { - topEndangeredPcs: it(e.pairs, (e) => e.pcId).sort((e, t) => t.downProbability - e.downProbability).slice(0, n).map(at), - topDangerousEnemies: it(e.pairs, (e) => e.enemyId).sort((e, t) => t.downProbability - e.downProbability).slice(0, n).map(at), - caveats: e.caveats, - empty: e.pairs.length === 0, - skipped: e.skipped - }; -} -function it(e, t) { - let n = /* @__PURE__ */ new Map(); - for (let r of e) { - let e = t(r), i = n.get(e); - (!i || r.downProbability > i.downProbability) && n.set(e, r); - } - return Array.from(n.values()); -} -function at(e) { - let t = Math.round(e.downProbability * 100); - return { - pcId: e.pcId, - enemyId: e.enemyId, - attackId: e.attackId, - pcName: e.pcName, - enemyName: e.enemyName, - attackName: e.attackName, - downPercent: t, - riskLabel: e.riskLabel, - riskClass: e.riskLabel.toLowerCase(), - label: `${e.pcName} vs ${e.enemyName} ${e.attackName} — ${t}% ${e.riskLabel}` - }; -} -//#endregion -//#region src/engine/prng.ts -function ot(e) { - let t = typeof e == "string" ? W(e) : G(e), n = () => { - t = t + 1831565813 >>> 0; - let e = t; - return e = Math.imul(e ^ e >>> 15, e | 1), e ^= e + Math.imul(e ^ e >>> 7, e | 61), ((e ^ e >>> 14) >>> 0) / 4294967296; - }; - return { - next: n, - nextInt: (e, t) => { - if (!Number.isInteger(e) || !Number.isInteger(t)) throw Error(`nextInt bounds must be integers: got ${e}, ${t}`); - if (e > t) throw Error(`nextInt: min (${e}) must be <= max (${t})`); - let r = t - e + 1; - return e + Math.floor(n() * r); - } - }; -} -function st(e, t) { - return ct(typeof e == "string" ? W(e) : G(e), typeof t == "string" ? W(t) : G(t)); -} -function W(e) { - let t = 1779033703 ^ e.length; - for (let n = 0; n < e.length; n += 1) t = Math.imul(t ^ e.charCodeAt(n), 3432918353), t = t << 13 | t >>> 19; - return t = Math.imul(t ^ t >>> 16, 2246822507), t = Math.imul(t ^ t >>> 13, 3266489909), t ^= t >>> 16, t >>> 0; -} -function G(e) { - if (!Number.isFinite(e)) throw Error(`Numeric seed must be finite: got ${e}`); - return Math.floor(Math.abs(e)) >>> 0; -} -function ct(e, t) { - let n = (e ^ Math.imul(t, 2654435761)) >>> 0; - return n = Math.imul(n ^ n >>> 16, 2246822507), n = Math.imul(n ^ n >>> 13, 3266489909), n ^= n >>> 16, n >>> 0; -} -//#endregion -//#region src/engine/heal-actions.ts -function lt(e, t) { - switch (e.kind) { - case "battle-medicine": { - let n = e.medicineDC ?? 15, r = e.medicineModifier ?? 0, i = t.nextInt(1, 20), a = _({ - die: i, - total: i + r, - dc: n - }); - switch (a) { - case "criticalSuccess": return { - healedAmount: K("4d8+8", t), - degree: a - }; - case "success": return { - healedAmount: K("2d8+4", t), - degree: a - }; - case "failure": return { - healedAmount: 0, - degree: a - }; - case "criticalFailure": return { - healedAmount: 0, - degree: a, - collateralDamage: K("1d8", t) - }; - } - return { - healedAmount: 0, - degree: a - }; - } - case "heal-spell-1action": return { healedAmount: K("1d10", t) }; - case "heal-spell-2action": { - let n = Math.max(1, e.spellRank ?? 1); - return { healedAmount: K(`${n}d8+${n * 8}`, t) }; - } - case "heal-spell-3action": { - let n = Math.max(1, e.spellRank ?? 1); - return { healedAmount: K(`${n}d8+${n * 8}`, t) }; - } - case "heal-cantrip-1action": return { healedAmount: K("1d10", t) }; - case "heal-cantrip-2action": { - let n = Math.max(1, e.healerLevel ?? 1); - return { healedAmount: K(`${1 + Math.ceil(n / 2)}d8`, t) }; - } - } -} -function ut(e, t, n = {}) { - if (t <= 0 && !n.clearsDying) return e; - let r = Math.min(e.hp.max, e.hp.current + Math.max(0, t)), i = { - ...e, - hp: { - ...e.hp, - current: r - } - }; - return n.clearsDying && i.dying > 0 && (i.dying = 0, r > 0 && (i.downed = !1)), i; -} -function K(e, t) { - let n = x(e), r = t.next(), i = 0; - for (let e of n.outcomes) if (i += e.probability, r < i) return e.damage; - return n.outcomes[n.outcomes.length - 1].damage; -} -//#endregion -//#region src/engine/initiative.ts -function dt(e, t, n = {}) { - if (n.useFixedOrder) return e.map((e) => ({ - combatantId: e.id, - side: e.side, - dieRoll: 0, - bonus: e.initiativeBonus, - total: e.initiativeBonus - })); - let r = n.pcsWinTies ?? !0, i = e.map((e) => { - let n = t.nextInt(1, 20); - return { - combatantId: e.id, - side: e.side, - dieRoll: n, - bonus: e.initiativeBonus, - total: n + e.initiativeBonus - }; - }); - return i.sort((e, t) => t.total === e.total ? e.side === t.side ? e.combatantId < t.combatantId ? -1 : 1 : e.side === "pc" ? r ? -1 : 1 : r ? 1 : -1 : t.total - e.total), i; -} -//#endregion -//#region src/engine/sample-recovery.ts -function ft(e, t) { - if (e.dying <= 0) return { - roll: 0, - degree: "success", - newDying: 0, - stabilized: !0 - }; - let n = 10 + e.dying, r = t.nextInt(1, 20), i = _({ - die: r, - total: r, - dc: n - }), a = 0; - switch (i) { - case "criticalSuccess": - a = -2; - break; - case "success": - a = -1; - break; - case "failure": - a = 0; - break; - case "criticalFailure": - a = 1; - break; - } - let o = Math.max(0, e.dying + a); - return { - roll: r, - degree: i, - newDying: o, - stabilized: o === 0 - }; -} -//#endregion -//#region src/engine/sample-strike.ts -function pt(e, t) { - let n = t.nextInt(1, 20), r = _({ - die: n, - total: n + e.attackBonus + e.mapPenalty, - dc: e.defenderAc - }), i = 0; - if (r === "success" || r === "criticalSuccess") { - let n = x(e.damageFormula), a = w(e.damageType, e.defenderAdjustments); - i = mt(T(r === "criticalSuccess" ? pe(n) : n, a), t); - } - return { - attackerId: e.attackerId, - defenderId: e.defenderId, - attackId: e.attackId, - attackName: e.attackName, - degree: r, - dieRoll: n, - damage: i - }; -} -function mt(e, t) { - let n = t.next(), r = 0; - for (let t of e.outcomes) if (r += t.probability, n < r) return t.damage; - return e.outcomes[e.outcomes.length - 1].damage; -} -//#endregion -//#region src/engine/sim-state.ts -function ht(e, t) { - if (e.dead || t.damage <= 0) return { - combatant: e, - damageAbsorbed: 0, - becameDowned: !1, - becameDead: !1 - }; - let n = e.dying > 0 || e.downed, r = e.downed, i = t.damage, a = e.hp.temp, o = e.hp.current + e.hp.temp; - if (a > 0) { - let e = Math.min(a, i); - a -= e, i -= e; - } - let s = Math.max(0, e.hp.current - i), c = { - ...e, - hp: { - ...e.hp, - current: s, - temp: a - } - }, l = Math.max(0, o - (s + a)); - if (s > 0) return { - combatant: c, - damageAbsorbed: l, - becameDowned: !1, - becameDead: !1 - }; - if (e.side === "enemy") return { - combatant: { - ...c, - downed: !0, - dead: !0 - }, - damageAbsorbed: l, - becameDowned: !r, - becameDead: !0 - }; - let u = t.degree === "criticalSuccess" ? 2 : 1, d = n ? e.dying + u : u + e.wounded, f = d >= Math.max(1, 4 - e.doomed); - return f && e.heroPoints > 0 && !e.heroPointSurvivalUsed ? { - combatant: { - ...c, - dying: 0, - downed: !0, - dead: !1, - heroPoints: e.heroPoints - 1, - heroPointSurvivalUsed: !0 - }, - damageAbsorbed: l, - becameDowned: !r, - becameDead: !1, - heroPointSurvivalFired: !0 - } : { - combatant: { - ...c, - dying: d, - downed: !0, - dead: f - }, - damageAbsorbed: l, - becameDowned: !r, - becameDead: f - }; -} -function gt(e) { - return Math.max(1, 4 - e.doomed); -} -//#endregion -//#region src/engine/tactics/shared.ts -function _t(e) { - return !e.downed && !e.dead; -} -function q(e) { - return e.filter(_t); -} -function vt(e) { - return e.filter((e) => !e.dead); -} -function yt(e) { - if (e.attacks.length === 0) return; - let t = e.attacks[0], n = bt(t.damageFormula); - for (let r = 1; r < e.attacks.length; r += 1) { - let i = e.attacks[r], a = bt(i.damageFormula); - a > n && (n = a, t = i); - } - return t; -} -function J(e) { - return e.attacks[0]; -} -function bt(e) { - try { - return x(e).mean; - } catch { - return -Infinity; - } -} -//#endregion -//#region src/engine/tactics/boss-cinematic.ts -var xt = { - id: "boss-cinematic", - description: "Use the highest-damage attack on the toughest standing PC, all strikes on the same target.", - chooseTurn(e) { - let t = q(e.pcs); - if (t.length === 0) return { strikes: [] }; - let n = [...t].sort((e, t) => t.hp.current === e.hp.current ? e.id < t.id ? -1 : 1 : t.hp.current - e.hp.current)[0], r = yt(e.attacker) ?? J(e.attacker); - if (!r) return { strikes: [] }; - let i = []; - for (let e = 0; e < 2; e += 1) i.push({ - attackId: r.id, - targetId: n.id, - mapIndex: e - }); - return { strikes: i }; - } -}, St = { - id: "focus-fire", - description: "Concentrate every strike on the standing PC with the lowest current HP.", - chooseTurn(e) { - let t = q(e.pcs); - if (t.length === 0) return { strikes: [] }; - let n = J(e.attacker); - if (!n) return { strikes: [] }; - let r = [...t].sort((e, t) => e.hp.current === t.hp.current ? e.id < t.id ? -1 : 1 : e.hp.current - t.hp.current)[0], i = []; - for (let e = 0; e < 2; e += 1) i.push({ - attackId: n.id, - targetId: r.id, - mapIndex: e - }); - return { strikes: i }; - } -}, Ct = { - id: "predator", - description: "Prioritize wounded > low-HP > full-HP PCs; attack downed only if no standing PCs remain.", - chooseTurn(e) { - let t = q(e.pcs), n; - if (n = t.length > 0 ? [...t].sort(wt) : vt(e.pcs), n.length === 0) return { strikes: [] }; - let r = J(e.attacker); - if (!r) return { strikes: [] }; - let i = n[0], a = []; - for (let e = 0; e < 2; e += 1) a.push({ - attackId: r.id, - targetId: i.id, - mapIndex: e - }); - return { strikes: a }; - } -}; -function wt(e, t) { - return t.wounded === e.wounded ? e.hp.current === t.hp.current ? e.id < t.id ? -1 : 1 : e.hp.current - t.hp.current : t.wounded - e.wounded; -} -//#endregion -//#region src/engine/tactics/index.ts -var Tt = { - "random-legal": { - id: "random-legal", - description: "Pick any legal PC target and any attack, independently per strike.", - chooseTurn(e, t) { - let n = q(e.pcs); - if (n.length === 0 || e.attacker.attacks.length === 0) return { strikes: [] }; - let r = []; - for (let i = 0; i < 2; i += 1) { - let a = n[t.nextInt(0, n.length - 1)], o = e.attacker.attacks[t.nextInt(0, e.attacker.attacks.length - 1)]; - r.push({ - attackId: o.id, - targetId: a.id, - mapIndex: i - }); - } - return { strikes: r }; - } - }, - "spread-damage": { - id: "spread-damage", - description: "Distribute strikes across higher-HP standing PCs; never target downed.", - chooseTurn(e) { - let t = q(e.pcs); - if (t.length === 0) return { strikes: [] }; - let n = J(e.attacker); - if (!n) return { strikes: [] }; - let r = [...t].sort((e, t) => t.hp.current === e.hp.current ? e.id < t.id ? -1 : 1 : t.hp.current - e.hp.current), i = []; - for (let e = 0; e < 2; e += 1) { - let t = r[e % r.length]; - i.push({ - attackId: n.id, - targetId: t.id, - mapIndex: e - }); - } - return { strikes: i }; - } - }, - "focus-fire": St, - predator: Ct, - "boss-cinematic": xt -}, Et = { - id: "random-legal", - description: "PCs heal dying or low-HP allies when capable; otherwise 2 Strikes against the most-dangerous standing enemy.", - chooseTurn(e) { - let t = e.enemies.filter((e) => !e.downed && !e.dead), n = e.attacker, r = Ot(e.pcs); - if (r && Dt(n.healing)) { - let e = At(n, r, "emergency"); - if (e) return { - strikes: [], - heal: e - }; - } - let i = kt(e.pcs, n); - if (i && Dt(n.healing)) { - let e = At(n, i, "topup"); - if (e) { - let r = J(n); - if (r && t.length > 0) { - let n = [...t].sort(Mt)[0]; - return { - strikes: [{ - attackId: r.id, - targetId: n.id, - mapIndex: 0 - }], - heal: e - }; - } - return { - strikes: [], - heal: e - }; - } - } - if (t.length === 0) return { strikes: [] }; - let a = J(n); - if (!a) return { strikes: [] }; - let o = [...t].sort(Mt)[0], s = []; - for (let e = 0; e < 2; e += 1) s.push({ - attackId: a.id, - targetId: o.id, - mapIndex: e - }); - return { strikes: s }; - } -}; -function Dt(e) { - return e ? Object.values(e.healSpellSlotsRemaining).some((e) => e > 0) || e.healCantripLevel !== null || e.hasBattleMedicine : !1; -} -function Ot(e) { - let t = e.filter((e) => e.dying > 0 && !e.dead); - if (t.length !== 0) return [...t].sort((e, t) => t.dying === e.dying ? e.id < t.id ? -1 : 1 : t.dying - e.dying)[0]; -} -function kt(e, t) { - let n = e.filter((e) => !e.downed && !e.dead && e.dying === 0 && e.hp.current < e.hp.max * .4); - if (n.length !== 0) return [...n].sort((e, n) => { - let r = +(e.id === t.id), i = +(n.id === t.id); - if (r !== i) return r - i; - let a = e.hp.current / e.hp.max, o = n.hp.current / n.hp.max; - return a === o ? e.id < n.id ? -1 : 1 : a - o; - })[0]; -} -function At(e, t, n) { - let r = e.healing; - if (!r) return; - let i = n === "emergency" ? [ - "heal-spell-2action", - "heal-cantrip-2action", - "heal-spell-1action", - "heal-cantrip-1action", - "battle-medicine" - ] : [ - "heal-spell-1action", - "heal-cantrip-1action", - "heal-spell-2action", - "heal-cantrip-2action", - "battle-medicine" - ]; - for (let n of i) { - if (n === "heal-spell-2action" || n === "heal-spell-1action" || n === "heal-spell-3action") { - let i = jt(r.healSpellSlotsRemaining); - if (i !== void 0) return { - kind: n, - healerId: e.id, - targetId: t.id, - spellRank: i - }; - continue; - } - if (n === "heal-cantrip-2action" || n === "heal-cantrip-1action") { - if (r.healCantripLevel !== null) return { - kind: n, - healerId: e.id, - targetId: t.id - }; - continue; - } - if (n === "battle-medicine") { - if (r.hasBattleMedicine && !r.battleMedicineUsedTargets.has(t.id)) return { - kind: n, - healerId: e.id, - targetId: t.id - }; - continue; - } - } -} -function jt(e) { - return Object.keys(e).map((e) => Number(e)).filter((t) => e[t] > 0).sort((e, t) => e - t)[0]; -} -function Mt(e, t) { - let n = Nt(e), r = Nt(t); - return n === r ? e.hp.current === t.hp.current ? e.id < t.id ? -1 : 1 : e.hp.current - t.hp.current : r - n; -} -function Nt(e) { - let t = 0; - for (let n of e.attacks) try { - let e = x(n.damageFormula).mean; - e > t && (t = e); - } catch {} - return t; -} -//#endregion -//#region src/engine/run-iteration.ts -function Pt(e, t, n, r = 0, i = {}) { - let a = /* @__PURE__ */ new Map(), o = /* @__PURE__ */ new Map(); - for (let t of e.pcs) a.set(t.id, Lt(t)), o.set(t.id, t.hp.current + t.hp.temp); - for (let t of e.enemies) a.set(t.id, Lt(t)), o.set(t.id, t.hp.current + t.hp.temp); - let s = dt(Array.from(a.values()), n, { useFixedOrder: i.useFixedInitiative }), c = Tt[t.tacticsProfile], l = Et, u = {}, d = [], f = null, p = 0, m = 0, h = 0, g = 0; - for (let e = 1; e <= t.maxRounds; e += 1) { - p = e; - for (let r of s) { - let i = a.get(r.combatantId); - if (!i || i.dead) continue; - if (i.side === "pc" && i.dying > 0) { - let e = ft(i, n); - m += 1; - let t = gt(i), r = { - ...i, - dying: e.newDying, - downed: e.newDying > 0 || i.hp.current === 0, - dead: e.newDying >= t - }; - a.set(i.id, r); - continue; - } - if (i.downed) continue; - let o = Rt(a, "pc"), s = Rt(a, "enemy"), p = (i.side === "pc" ? l : c).chooseTurn({ - attacker: i, - pcs: o, - enemies: s, - round: e - }, n); - if (p.heal) { - let e = a.get(p.heal.targetId); - if (e && !e.dead) { - let t = lt({ - kind: p.heal.kind, - healerLevel: i.healing?.healCantripLevel ?? void 0, - spellRank: p.heal.spellRank, - medicineModifier: i.healing?.medicineModifier, - medicineDC: i.healing?.medicineDC - }, n), r = p.heal.kind.startsWith("heal-spell") && e.dying > 0, o = ut(e, t.healedAmount, { clearsDying: r }); - if (t.collateralDamage && t.collateralDamage > 0 && (o = ht(o, { - damage: t.collateralDamage, - degree: "success" - }).combatant), a.set(e.id, o), g += 1, p.heal.kind.startsWith("heal-spell") && p.heal.spellRank !== void 0) { - let e = Ft(i, p.heal.spellRank); - a.set(i.id, e); - } else if (p.heal.kind === "battle-medicine") { - let t = It(i, e.id); - a.set(i.id, t); - } - } - } - if (p.strikes.length !== 0) for (let r of p.strikes) { - let o = a.get(r.targetId), s = i.attacks.find((e) => e.id === r.attackId); - if (!o || o.dead || !s) continue; - let c = ue(s.mapType === "unknown" ? "normal" : s.mapType)[r.mapIndex] ?? 0, l = pt({ - attackerId: i.id, - defenderId: o.id, - attackId: s.id, - attackName: s.name, - attackBonus: s.attackBonus, - mapPenalty: c, - defenderAc: o.defenses.ac, - damageFormula: s.damageFormula, - damageType: s.damageType, - defenderAdjustments: o.damageAdjustments - }, n), p = ht(o, { - damage: l.damage, - degree: l.degree - }); - if (a.set(o.id, p.combatant), p.damageAbsorbed > 0) { - let e = `${i.id}->${o.id}`; - u[e] = (u[e] ?? 0) + p.damageAbsorbed; - } - p.becameDowned && o.side === "pc" && f === null && (f = e), p.heroPointSurvivalFired && (h += 1), t.captureEvents && d.push({ - round: e, - attackerId: i.id, - defenderId: o.id, - attackId: s.id, - attackName: s.name, - degree: l.degree, - damage: p.damageAbsorbed, - causedDown: p.becameDowned - }); - } - } - if (zt(a) || Bt(a)) break; - } - let _ = Array.from(a.values()).map((e) => ({ - id: e.id, - side: e.side, - endingHp: e.hp.current, - dying: e.dying, - wounded: e.wounded, - doomed: e.doomed, - downed: e.downed, - dead: e.dead, - damageTaken: Math.max(0, (o.get(e.id) ?? 0) - (e.hp.current + e.hp.temp)) - })), v = Vt(a); - return { - iterationIndex: r, - roundsElapsed: p, - firstDownRound: f, - tpk: v, - perCombatant: _, - damageByPair: u, - events: t.captureEvents ? d : void 0, - healsFired: g, - recoveryChecksFired: m, - heroPointSurvivalsFired: h - }; -} -function Ft(e, t) { - if (!e.healing) return e; - let n = e.healing.healSpellSlotsRemaining[t] ?? 0; - if (n <= 0) return e; - let r = { ...e.healing.healSpellSlotsRemaining }; - return r[t] = n - 1, { - ...e, - healing: { - ...e.healing, - healSpellSlotsRemaining: r - } - }; -} -function It(e, t) { - if (!e.healing) return e; - let n = new Set(e.healing.battleMedicineUsedTargets); - return n.add(t), { - ...e, - healing: { - ...e.healing, - battleMedicineUsedTargets: n - } - }; -} -function Lt(e) { - return { - ...e, - hp: { ...e.hp }, - defenses: { ...e.defenses } - }; -} -function Rt(e, t) { - let n = []; - for (let r of e.values()) r.side === t && n.push(r); - return n; -} -function zt(e) { - let t = 0; - for (let n of e.values()) if (n.side === "pc" && (t += 1, !n.downed && !n.dead)) return !1; - return t > 0; -} -function Bt(e) { - let t = 0; - for (let n of e.values()) if (n.side === "enemy" && (t += 1, !n.dead)) return !1; - return t > 0; -} -function Vt(e) { - return zt(e); -} -//#endregion -//#region src/engine/simulation-types.ts -var Ht = 1e4, Ut = class extends Error { - requested; - cap; - constructor(e) { - super(`Iterations ${e} exceeds engine cap ${Ht}`), this.name = "MaxIterationsExceededError", this.requested = e, this.cap = Ht; - } -}; -function Wt(e) { - if (!Number.isInteger(e.iterations) || e.iterations < 1) throw Error(`iterations must be a positive integer, got ${e.iterations}`); - if (e.iterations > 1e4) throw new Ut(e.iterations); - if (!Number.isInteger(e.maxRounds) || e.maxRounds < 1) throw Error(`maxRounds must be a positive integer, got ${e.maxRounds}`); - if (e.wallClockBudgetMs !== void 0 && (!Number.isFinite(e.wallClockBudgetMs) || e.wallClockBudgetMs < 0)) throw Error(`wallClockBudgetMs must be a non-negative finite number, got ${e.wallClockBudgetMs}`); -} -//#endregion -//#region src/engine/run-simulation.ts -function Gt(e, t, n = {}) { - Wt(t); - let r = t.seed ?? Date.now(), i = t.wallClockBudgetMs ?? 0, a = i > 0 ? Date.now() : 0, o = [], s = !1; - for (let c = 0; c < t.iterations; c += 1) { - if (n.abortSignal?.aborted) { - s = !0; - break; - } - if (i > 0 && Date.now() - a > i) { - s = !0; - break; - } - let l = st(r, c); - o.push(Pt(e, t, ot(l), c)), n.onProgress?.(c + 1, t.iterations); - } - return Kt(e, t, r, o, s); -} -function Kt(e, t, n, r, i) { - let a = r.length; - if (a === 0) return { - iterationsRequested: t.iterations, - iterationsCompleted: 0, - seed: n, - tacticsProfile: t.tacticsProfile, - aborted: i, - anyPcDownProbability: 0, - tpkProbability: 0, - meanFirstDownRound: null, - medianFirstDownRound: null, - perPc: e.pcs.map((e) => ({ - id: e.id, - name: e.name, - downProbability: 0, - deathProbability: 0, - meanEndingHp: e.hp.current, - topContributingEnemyId: null - })), - perEnemy: e.enemies.map((e) => ({ - id: e.id, - name: e.name, - damageShare: 0, - topTargetId: null - })), - safetyNet: { - meanHealsPerIteration: 0, - meanRecoveryChecksPerIteration: 0, - heroPointSurvivalRate: 0 - }, - caveats: [...e.caveats, "No iterations completed."] - }; - let o = 0, s = 0, c = [], l = /* @__PURE__ */ new Map(), u = /* @__PURE__ */ new Map(), d = /* @__PURE__ */ new Map(), f = /* @__PURE__ */ new Map(); - for (let e of r) { - let t = !1, n = !0, r = 0; - for (let i of e.perCombatant) { - if (i.side !== "pc") continue; - r += 1, i.downed || i.dead ? (t = !0, l.set(i.id, (l.get(i.id) ?? 0) + 1)) : n = !1, i.dead && u.set(i.id, (u.get(i.id) ?? 0) + 1); - let e = d.get(i.id) ?? []; - e.push(i.endingHp), d.set(i.id, e); - } - r > 0 && t && (o += 1), r > 0 && n && (s += 1), e.firstDownRound !== null && c.push(e.firstDownRound); - for (let [t, n] of Object.entries(e.damageByPair)) f.set(t, (f.get(t) ?? 0) + n); - } - let p = e.pcs.map((t) => { - let n = l.get(t.id) ?? 0, r = u.get(t.id) ?? 0, i = d.get(t.id) ?? [], o = i.length > 0 ? i.reduce((e, t) => e + t, 0) / i.length : t.hp.current, s = null, c = 0; - for (let n of e.enemies) { - let e = f.get(`${n.id}->${t.id}`) ?? 0; - e > c && (c = e, s = n.id); - } - return { - id: t.id, - name: t.name, - downProbability: n / a, - deathProbability: r / a, - meanEndingHp: o, - topContributingEnemyId: s - }; - }), m = 0; - for (let e of f.values()) m += e; - let h = e.enemies.map((t) => { - let n = 0, r = null, i = 0; - for (let a of e.pcs) { - let e = f.get(`${t.id}->${a.id}`) ?? 0; - n += e, e > i && (i = e, r = a.id); - } - return { - id: t.id, - name: t.name, - damageShare: m > 0 ? n / m : 0, - topTargetId: r - }; - }), g = 0, _ = 0, v = 0; - for (let e of r) g += e.healsFired, _ += e.recoveryChecksFired, e.heroPointSurvivalsFired > 0 && (v += 1); - let ee = o / a, y = s / a, b = c.length > 0 ? c.reduce((e, t) => e + t, 0) / c.length : null, x = Yt(o, a, s, c); - return { - iterationsRequested: t.iterations, - iterationsCompleted: a, - seed: n, - tacticsProfile: t.tacticsProfile, - aborted: i, - anyPcDownProbability: ee, - tpkProbability: y, - meanFirstDownRound: b, - medianFirstDownRound: c.length > 0 ? qt(c) : null, - perPc: p, - perEnemy: h, - safetyNet: { - meanHealsPerIteration: g / a, - meanRecoveryChecksPerIteration: _ / a, - heroPointSurvivalRate: v / a - }, - confidenceIntervals: x, - caveats: [...e.caveats] - }; -} -function qt(e) { - let t = [...e].sort((e, t) => e - t), n = Math.floor(t.length / 2); - return t.length % 2 == 0 ? (t[n - 1] + t[n]) / 2 : t[n]; -} -var Jt = 1.959963984540054; -function Yt(e, t, n, r) { - let i = Xt(e, t), a = Xt(n, t), o = null; - return r.length > 0 && (o = Zt(r)), { - anyPcDown: i, - tpk: a, - meanFirstDownRound: o - }; -} -function Xt(e, t) { - if (t === 0) return { - lower: 0, - upper: 0 - }; - let n = e / t, r = Jt * Math.sqrt(n * (1 - n) / t); - return { - lower: Qt(n - r), - upper: Qt(n + r) - }; -} -function Zt(e) { - let t = e.length; - if (t === 0) return { - lower: 0, - upper: 0 - }; - let n = e.reduce((e, t) => e + t, 0) / t, r = e.reduce((e, t) => e + (t - n) ** 2, 0) / t, i = Jt * Math.sqrt(r / t); - return { - lower: n - i, - upper: n + i - }; -} -function Qt(e) { - return Math.max(0, Math.min(1, e)); -} -//#endregion -//#region src/engine/run-simulation-in-worker.ts -var $t = class extends Error { - constructor() { - super("Monte Carlo encounter simulation is disabled in module settings."), this.name = "MonteCarloDisabledError"; - } -}; -function en(e, t, n = {}) { - return h() ? typeof Worker > "u" ? tn(e, t, n) : nn(e, t, n) : { - promise: Promise.reject(new $t()), - cancel: () => {} - }; -} -function tn(e, t, n) { - let r = { aborted: !1 }; - return { - promise: Promise.resolve().then(() => Gt(e, t, { - onProgress: n.onProgress, - abortSignal: r - })), - cancel: () => { - r.aborted = !0; - } - }; -} -function nn(e, t, n) { - let r = new Worker(new URL( - /* @vite-ignore */ - "" + new URL("assets/simulation.worker-ChHnUOCM.js", import.meta.url).href, - "" + import.meta.url - ), { type: "module" }), i = !1, a = !1; - return { - promise: new Promise((i, o) => { - let s = (e) => { - let t = e.data; - if (!(!t || typeof t != "object")) switch (t.type) { - case "progress": - n.onProgress?.(t.completed, t.total); - break; - case "result": - l(() => i(t.result)); - break; - case "error": - l(() => o(Error(t.message))); - break; - } - }, c = (e) => { - l(() => o(Error(e.message || "Worker error"))); - }, l = (e) => { - if (!a) { - a = !0, r.removeEventListener("message", s), r.removeEventListener("error", c); - try { - e(); - } finally { - r.terminate(); - } - } - }; - r.addEventListener("message", s), r.addEventListener("error", c); - let u = { - type: "run", - setup: e, - config: t - }; - r.postMessage(u); - }), - cancel: () => { - i || a || (i = !0, r.postMessage({ type: "abort" })); - } - }; -} -//#endregion -//#region src/foundry/encounter-setup.ts -function rn(e, t = {}) { - return an(k(e, { allowSceneFallback: t.allowSceneFallback }), e); -} -function an(e, t) { - let n = [...e.caveats]; - for (let t of e.unsupported) n.push(`Unsupported actor skipped: ${t}`); - let r = sn(); - return { - pcs: e.pcs.map((e) => { - let i = []; - try { - i = t.getAttacksFromToken(e.token); - } catch { - n.push(`${e.snapshot.name}: PC attack extraction failed; treated as no supported Strike.`); - } - i.length === 0 && n.push(`${e.snapshot.name} has no supported Strike; will skip its turns in the simulation.`); - let a = ln(e.snapshot, "pc", i, n); - return a.healing = on(e.snapshot, n), r && cn(e.snapshot, a.healing), a; - }), - enemies: e.hostiles.map((e) => { - let r = []; - try { - r = t.getAttacksFromToken(e.token); - } catch { - n.push(`${e.snapshot.name}: attack extraction failed; treated as no supported attacks.`); - } - return r.length === 0 && n.push(`${e.snapshot.name} has no supported attacks; will skip its turns.`), ln(e.snapshot, "enemy", r, n); - }), - caveats: n - }; -} -function on(e, t) { - let n = e.pcCapabilities; - if (!n) return { - medicineDC: 15, - hasBattleMedicine: !1, - battleMedicineUsedTargets: /* @__PURE__ */ new Set(), - healSpellSlotsRemaining: {}, - healCantripLevel: null - }; - let r = n.hasBattleMedicine === !0, i = n.healSpellSlots ?? {}, a = n.healCantripLevel ?? null, o = Object.values(i).reduce((e, t) => e + t, 0); - return !r && o === 0 && a === null ? t.push(`${e.name} has no healing options; will not heal in this simulation.`) : r && o === 0 && a === null && n.medicineModifier === void 0 && t.push(`${e.name}: Battle Medicine detected but no Medicine modifier; using default DC 15 with +0 modifier.`), { - medicineModifier: n.medicineModifier, - medicineDC: n.medicineDC ?? 15, - hasBattleMedicine: r, - battleMedicineUsedTargets: /* @__PURE__ */ new Set(), - healSpellSlotsRemaining: { ...i }, - healCantripLevel: a - }; -} -function sn() { - if (typeof game > "u") return !1; - try { - return !!(game.settings?.get?.("grim-arithmetic", "debugLogging") ?? !1); - } catch { - return !1; - } -} -function cn(e, t) { - let n = Object.entries(t.healSpellSlotsRemaining).map(([e, t]) => `rank ${e}: ${t}`).join(", "); - console.log("Grim Arithmetic | PC healing capability", { - name: e.name, - hasBattleMedicine: t.hasBattleMedicine, - medicineModifier: t.medicineModifier, - medicineDC: t.medicineDC, - healCantripLevel: t.healCantripLevel, - healSpellSlots: n || "(none)" - }); -} -function ln(e, t, n, r) { - return e.initiativeBonus === void 0 && r.push(`${e.name}: initiative bonus unknown; defaulting to 0.`), { - id: e.id, - name: e.name, - side: t, - hp: { - current: e.hp.current, - max: e.hp.max, - temp: e.hp.temp ?? 0 - }, - defenses: { - ac: e.defenses.ac, - fort: e.defenses.fort, - reflex: e.defenses.reflex, - will: e.defenses.will - }, - heroPointSurvivalUsed: !1, - dying: e.deathState?.dying ?? 0, - wounded: e.deathState?.wounded ?? 0, - doomed: e.deathState?.doomed ?? 0, - heroPoints: e.deathState?.heroPoints ?? 0, - downed: (e.deathState?.dying ?? 0) > 0, - dead: !1, - initiativeBonus: e.initiativeBonus ?? 0, - damageAdjustments: e.damageAdjustments, - traits: [...e.traits], - attacks: [...n] - }; -} -//#endregion -//#region src/ui/panel-data.ts -var un = { - strikes: 2, - mapMode: "auto", - shieldBonus: 0, - woundedOverride: "current", - heroPointMode: "actor", - attackId: "" -}, Y = "Permanent death probability is planned for a future milestone and is not modeled in MVP."; -function dn({ selection: e, adapter: t, controls: n, moduleVersion: r }) { - if (e.errors.length > 0 || !e.subjectToken || !e.enemyToken) return { - moduleVersion: r, - message: "Select one PC token and target one enemy token to estimate immediate down risk.", - permanentDeath: Y, - errors: e.errors, - controls: fn(n, []) - }; - let i = t.getCombatantFromToken(e.subjectToken), a = t.getCombatantFromToken(e.enemyToken), o = t.getAttacksFromToken(e.enemyToken), s = mn(o, n.attackId), c = pn(i, a, s), l = fn(n, o, s?.id); - if (c.length > 0 || !i || !a || !s) return { - moduleVersion: r, - message: "Grim Arithmetic could not extract enough PF2e data for this token pair yet.", - permanentDeath: Y, - errors: c, - controls: l - }; - let u = hn(n.mapMode, s.mapType), d = i.defenses.ac + n.shieldBonus, f = i.hp.current + (i.hp.temp ?? 0), p = _n(i, n.woundedOverride), m = i.deathState?.doomed ?? 0, h = vn(i, n.heroPointMode), g = C({ - hp: f, - ac: d, - attackBonus: s.attackBonus, - damageFormula: s.damageFormula, - strikes: n.strikes, - mapType: u, - wounded: p, - doomed: m, - assumeHeroPointAvailable: h, - damageType: s.damageType, - targetAdjustments: i.damageAdjustments - }), _ = [...s.assumptions, ...g.assumptions]; - return n.shieldBonus > 0 && _.push(`Applies a +${n.shieldBonus} shield/status AC adjustment.`), n.woundedOverride !== "current" && _.push(`Uses wounded override ${n.woundedOverride} for dying severity if the PC is downed.`), n.heroPointMode !== "actor" && _.push(`Uses Hero Point override: ${n.heroPointMode}.`), { - moduleVersion: r, - message: "Immediate down-risk estimate based on the selected PC and targeted enemy.", - permanentDeath: Y, - errors: [], - controls: l, - subject: i, - enemy: a, - attack: s, - risk: { - downPercent: X(g.downProbability), - expectedHpAfterTurn: g.expectedHpAfterTurn.toFixed(1), - riskLabel: g.riskLabel, - effectiveAc: d, - modeledHp: f, - woundedNote: gn(i, n.woundedOverride), - damage: g.damage, - damageAdjustment: g.damageAdjustment, - dyingSeverity: g.dyingSeverity, - strikeChances: g.hitChanceByStrike.map((e, t) => ({ - index: t + 1, - hitPercent: X(e), - critPercent: X(g.critChanceByStrike[t] ?? 0) - })), - assumptions: _, - notModeled: g.notModeled - } - }; -} -function fn(e, t, n = e.attackId) { - let r = t.some((e) => e.id === n) ? n : t[0]?.id ?? ""; - return { - strikes: [ - "1", - "2", - "3" - ].map((t) => ({ - value: t, - label: `${t} Strike${t === "1" ? "" : "s"}`, - selected: String(e.strikes) === t - })), - attacks: t.map((e) => ({ - value: e.id, - label: `${e.name} — +${e.attackBonus}, ${e.damageFormula}`, - selected: e.id === r - })), - mapMode: [ - ["auto", "Auto"], - ["normal", "Normal"], - ["agile", "Agile"], - ["none", "None"] - ].map(([t, n]) => ({ - value: t, - label: n, - selected: e.mapMode === t - })), - shieldBonus: [ - "0", - "1", - "2" - ].map((t) => ({ - value: t, - label: t === "0" ? "No shield bonus" : `+${t} AC`, - selected: String(e.shieldBonus) === t - })), - woundedOverride: [ - "current", - "0", - "1", - "2", - "3" - ].map((t) => ({ - value: t, - label: t === "current" ? "Current actor value" : `Wounded ${t}`, - selected: e.woundedOverride === t - })), - heroPointMode: [ - ["actor", "Use actor Hero Points"], - ["available", "Assume Hero Point available"], - ["unavailable", "Assume no Hero Point"] - ].map(([t, n]) => ({ - value: t, - label: n, - selected: e.heroPointMode === t - })) - }; -} -function pn(e, t, n) { - let r = []; - return e || r.push("Could not read selected PC HP/AC from PF2e actor data."), t || r.push("Could not read targeted enemy HP/AC from PF2e actor data."), e && e.disposition !== "pc" && r.push("Selected token is not recognized as a PC/character by the PF2e adapter."), t && t.disposition !== "enemy" && r.push("Targeted token is not recognized as an enemy/NPC by the PF2e adapter."), n || r.push("Targeted enemy has no supported melee Strike with a numeric attack bonus and supported damage formula."), r; -} -function mn(e, t) { - return e.find((e) => e.id === t) ?? e[0]; -} -function hn(e, t) { - return e === "auto" ? t === "unknown" ? "normal" : t : e; -} -function gn(e, t) { - return t === "current" ? `Current actor wounded value used for dying severity: ${e.deathState?.wounded ?? 0}` : `Override used for dying severity: Wounded ${t}`; -} -function _n(e, t) { - return t === "current" ? e.deathState?.wounded ?? 0 : Number(t); -} -function vn(e, t) { - return t === "available" ? !0 : t === "unavailable" ? !1 : (e.deathState?.heroPoints ?? 0) > 0; -} -function X(e) { - return Math.round(e * 100); -} -var yn = { tacticsProfile: "spread-damage" }, Z = { - "random-legal": "Random Legal", - "spread-damage": "Spread Damage", - "focus-fire": "Focus Fire", - predator: "Predator", - "boss-cinematic": "Boss Cinematic" -}, bn = { - "random-legal": "Enemies pick any legal PC target and any attack independently per strike.", - "spread-damage": "Enemies spread strikes across higher-HP standing PCs; never target downed.", - "focus-fire": "Enemies concentrate every strike on the lowest-HP standing PC.", - predator: "Enemies prioritize wounded > low-HP > full-HP PCs; attack downed only as a last resort.", - "boss-cinematic": "Enemy uses the highest-damage attack on the toughest standing PC, all strikes on the same target." -}; -function xn({ moduleVersion: e, enabled: t, controls: n, state: r }) { - let i = [ - "PCs Strike the most-dangerous standing enemy (2 strikes per turn by default).", - "PCs with healing capability substitute Strikes for Heal spells / Battle Medicine when allies are dying or below 40% HP.", - "Dying PCs roll PF2e recovery checks each turn (DC 10+dying); crit-success / success / crit-failure step dying.", - "Hero Points are spent to prevent death (once per iteration per PC).", - "Not modeled: reactions (Shield Block, Champion), spells beyond Heal, persistent damage, attacks of opportunity, movement / reach / line of sight." - ]; - if (!t) return { - moduleVersion: e, - enabled: !1, - disabledMessage: "Monte Carlo simulation is disabled in Grim Arithmetic module settings. Enable it in Configure Settings to run forecasts on this client.", - message: "", - state: "idle", - controls: Q(n), - assumptions: i - }; - let a = Q(n); - if (r.kind === "idle") return { - moduleVersion: e, - enabled: !0, - disabledMessage: "", - message: "Select a tactics profile and click Forecast to simulate the active encounter.", - state: "idle", - controls: a, - assumptions: i - }; - if (r.kind === "running") return { - moduleVersion: e, - enabled: !0, - disabledMessage: "", - message: "Simulation in progress…", - state: "running", - controls: a, - progress: { - completed: r.completed, - total: r.total, - percent: r.total > 0 ? Math.round(r.completed / r.total * 100) : 0 - }, - assumptions: i - }; - if (r.kind === "error") return { - moduleVersion: e, - enabled: !0, - disabledMessage: "", - message: "Forecast failed.", - state: "error", - controls: a, - errorMessage: r.message, - assumptions: i - }; - let o = r.result; - return { - moduleVersion: e, - enabled: !0, - disabledMessage: "", - message: o.aborted ? "Forecast aborted." : "Forecast complete.", - state: "done", - controls: a, - result: Cn(o), - pessimismWarning: Sn(o), - assumptions: [...i, ...o.caveats.map((e) => `Setup: ${e}`)] - }; -} -function Sn(e) { - if (!(e.anyPcDownProbability < .8)) return "High-risk encounter. Even with PCs healing, recovering, and spending Hero Points, the modeled outcome ends badly in most iterations. Reactions (Shield Block, Champion) and tactical positioning are still not modeled, so real-table risk may be a bit lower — but this encounter has structural lethality worth examining."; -} -function Q(e) { - return { tacticsProfile: Object.keys(Z).sort().map((t) => ({ - value: t, - label: Z[t], - description: bn[t], - selected: e.tacticsProfile === t - })) }; -} -function Cn(e) { - let t = new Map(e.perPc.map((e) => [e.id, e.name])), n = new Map(e.perEnemy.map((e) => [e.id, e.name])), r = e.confidenceIntervals, i = Math.round(e.anyPcDownProbability * 100), a = Math.round(e.tpkProbability * 100), o = r?.anyPcDown ? `${Math.round(r.anyPcDown.lower * 100)}%–${Math.round(r.anyPcDown.upper * 100)}%` : null, s = r?.tpk ? `${Math.round(r.tpk.lower * 100)}%–${Math.round(r.tpk.upper * 100)}%` : null, c = r?.meanFirstDownRound ? `${r.meanFirstDownRound.lower.toFixed(1)}–${r.meanFirstDownRound.upper.toFixed(1)}` : null; - return { - iterationsCompleted: e.iterationsCompleted, - iterationsRequested: e.iterationsRequested, - tacticsProfileLabel: Z[e.tacticsProfile], - tacticsProfileDescription: bn[e.tacticsProfile], - aborted: e.aborted, - anyPcDownPercent: i, - anyPcDownCi: o, - tpkPercent: a, - tpkCi: s, - meanFirstDownRound: e.meanFirstDownRound === null ? "n/a" : e.meanFirstDownRound.toFixed(1), - meanFirstDownCi: c, - medianFirstDownRound: e.medianFirstDownRound === null ? "n/a" : String(e.medianFirstDownRound), - meanHealsPerIteration: e.safetyNet.meanHealsPerIteration.toFixed(1), - meanRecoveryChecksPerIteration: e.safetyNet.meanRecoveryChecksPerIteration.toFixed(1), - heroPointSurvivalPercent: Math.round(e.safetyNet.heroPointSurvivalRate * 100), - perPc: e.perPc.map((t) => { - let r = wn(t.downProbability, e.iterationsCompleted), i = wn(t.deathProbability, e.iterationsCompleted); - return { - id: t.id, - name: t.name, - downPercent: Math.round(t.downProbability * 100), - downCi: r ? `${Math.round(r.lower * 100)}%–${Math.round(r.upper * 100)}%` : null, - deathPercent: Math.round(t.deathProbability * 100), - deathCi: i ? `${Math.round(i.lower * 100)}%–${Math.round(i.upper * 100)}%` : null, - meanEndingHp: t.meanEndingHp.toFixed(1), - topContributingEnemyName: t.topContributingEnemyId ? n.get(t.topContributingEnemyId) ?? t.topContributingEnemyId : "—", - riskClass: En(t.downProbability), - riskLabel: Tn(t.downProbability) - }; - }), - perEnemy: e.perEnemy.map((e) => ({ - id: e.id, - name: e.name, - damageSharePercent: Math.round(e.damageShare * 100), - topTargetName: e.topTargetId ? t.get(e.topTargetId) ?? e.topTargetId : "—" - })), - caveats: e.caveats - }; -} -function wn(e, t) { - if (t === 0) return null; - let n = e, r = 1.959963984540054 * Math.sqrt(n * (1 - n) / t); - return { - lower: Math.max(0, n - r), - upper: Math.min(1, n + r) - }; -} -function Tn(e) { - return e < .05 ? "Low" : e < .15 ? "Guarded" : e < .35 ? "Dangerous" : e < .6 ? "Severe" : "Grim"; -} -function En(e) { - return Tn(e).toLowerCase(); -} -//#endregion -//#region src/ui/forecast-panel.ts -var Dn = foundry.applications.api, On = Dn.HandlebarsApplicationMixin(Dn.ApplicationV2), kn = `${t}-forecast`, An = class e extends On { - static singleton; - controls = { ...yn }; - runState = { kind: "idle" }; - currentHandle; - static DEFAULT_OPTIONS = { - id: kn, - classes: ["grim-arithmetic-window"], - tag: "section", - window: { - title: `${n} — Encounter Forecast`, - resizable: !0 - }, - position: { - width: 800, - height: 720 - }, - actions: { - run: function() { - this.startRun(); - }, - cancel: function() { - this.currentHandle?.cancel(); - } - } - }; - static PARTS = { main: { template: `modules/${t}/templates/forecast-panel.hbs` } }; - static getInstance() { - return e.singleton ||= new e(), e.singleton; - } - static open() { - e.getInstance().render({ force: !0 }); - } - async _prepareContext() { - return xn({ - moduleVersion: r, - enabled: h(), - controls: this.controls, - state: this.runState - }); - } - async _onRender() { - let e = this.element; - e && e.querySelectorAll("[data-grim-forecast-control]").forEach((e) => { - e.addEventListener("change", () => { - if (e.dataset.grimForecastControl === "tacticsProfile") { - let t = e.value; - (t === "random-legal" || t === "spread-damage" || t === "focus-fire" || t === "predator" || t === "boss-cinematic") && (this.controls.tacticsProfile = t); - } - this.render(); - }); - }); - } - async _preClose() { - this.currentHandle?.cancel(); - } - startRun() { - let e = new A(), t; - try { - t = rn(e); - } catch (e) { - this.runState = { - kind: "error", - message: e instanceof Error ? e.message : String(e) - }, this.render(); - return; - } - if (t.pcs.length === 0 || t.enemies.length === 0) { - this.runState = { - kind: "error", - message: "No active combat with both PCs and enemies. Start a combat encounter, then run the forecast." - }, this.render(); - return; - } - let n = { - iterations: 5e3, - tacticsProfile: this.controls.tacticsProfile, - maxRounds: 5 - }; - this.runState = { - kind: "running", - completed: 0, - total: 5e3 - }, this.render(); - let r = en(t, n, { onProgress: (e, t) => { - this.runState.kind === "running" && (this.runState = { - kind: "running", - completed: e, - total: t - }, this.render()); - } }); - this.currentHandle = r, r.promise.then((e) => { - this.runState = { - kind: "done", - result: e - }, this.currentHandle = void 0, this.render(); - }, (e) => { - this.runState = { - kind: "error", - message: e instanceof Error ? e.message : String(e) - }, this.currentHandle = void 0, this.render(); - }); - } -}; -//#endregion -//#region src/foundry/selection.ts -function jn() { - return Mn({ - controlled: canvas.tokens?.controlled, - targets: game.user?.targets - }); -} -function Mn(e) { - let t = e.controlled ?? [], n = Array.from(e.targets ?? []), r = [], i = t.length === 1 ? t[0] : null, a = n.length === 1 ? n[0] : null; - return t.length === 0 && r.push("No PC token selected. Select one PC token."), t.length > 1 && r.push("Multiple tokens selected. Select only one PC token."), n.length === 0 && r.push("No target selected. Target one enemy token."), n.length > 1 && r.push("Multiple targets selected. Target only one enemy token."), { - subjectToken: i, - enemyToken: a, - errors: r - }; -} -//#endregion -//#region src/ui/pair-detail-resolver.ts -function Nn(e, t) { - let n = []; - return e || n.push("PC token is no longer on the canvas. The encounter may have changed since the danger board was rendered."), t || n.push("Enemy token is no longer on the canvas. The encounter may have changed since the danger board was rendered."), { - subjectToken: e, - enemyToken: t, - errors: n - }; -} -//#endregion -//#region src/ui/pair-detail-panel.ts -var Pn = foundry.applications.api, Fn = Pn.HandlebarsApplicationMixin(Pn.ApplicationV2), In = `${t}-pair-detail`, $ = class e extends Fn { - static singleton; - controls = { ...un }; - explicitSelection; - static DEFAULT_OPTIONS = { - id: In, - classes: ["grim-arithmetic-window"], - tag: "section", - window: { - title: `${n} - Pair Detail`, - resizable: !0 - }, - position: { - width: 500, - height: 640 - }, - actions: { refresh: function() { - this.render(); - } } - }; - static PARTS = { main: { template: `modules/${t}/templates/pair-detail-panel.hbs` } }; - static getInstance() { - return e.singleton ||= new e(), e.singleton; - } - static openForPair(t, n, r) { - let i = canvas.tokens?.get(t) ?? null, a = canvas.tokens?.get(n) ?? null, o = e.getInstance(); - o.explicitSelection = Nn(i, a), r !== void 0 && (o.controls.attackId = r), o.render({ force: !0 }); - } - static openForSelection() { - let t = e.getInstance(); - t.explicitSelection = void 0, t.render({ force: !0 }); - } - async _prepareContext() { - return dn({ - selection: this.explicitSelection ?? jn(), - adapter: new A(), - controls: this.controls, - moduleVersion: r - }); - } - async _onRender() { - let e = this.element; - e && e.querySelectorAll("[data-grim-control]").forEach((e) => { - e.addEventListener("change", () => { - let t = Hn(e); - t && (this.updateControl(t, e.value), this.render()); - }); - }); - } - updateControl(e, t) { - e === "strikes" && (this.controls.strikes = Ln(Number(t))), e === "mapMode" && (this.controls.mapMode = Rn(t)), e === "shieldBonus" && (this.controls.shieldBonus = zn(t)), e === "woundedOverride" && (this.controls.woundedOverride = Bn(t)), e === "heroPointMode" && (this.controls.heroPointMode = Vn(t)), e === "attackId" && (this.controls.attackId = t); - } -}; -function Ln(e) { - return e === 1 || e === 2 || e === 3 ? e : 2; -} -function Rn(e) { - return e === "normal" || e === "agile" || e === "none" ? e : "auto"; -} -function zn(e) { - return e === "1" ? 1 : e === "2" ? 2 : 0; -} -function Bn(e) { - return e === "0" || e === "1" || e === "2" || e === "3" ? e : "current"; -} -function Vn(e) { - return e === "available" || e === "unavailable" ? e : "actor"; -} -function Hn(e) { - let t = e.dataset.grimControl; - return t === "strikes" || t === "mapMode" || t === "shieldBonus" || t === "woundedOverride" || t === "heroPointMode" || t === "attackId" ? t : null; -} -//#endregion -//#region src/ui/danger-board-panel.ts -var Un = "Encounter-wide immediate risk. Click a row to see the detail math, or use the selection-target button to model an arbitrary pair.", Wn = foundry.applications.api, Gn = Wn.HandlebarsApplicationMixin(Wn.ApplicationV2), Kn = class extends Gn { - static DEFAULT_OPTIONS = { - id: `${t}-danger-board`, - classes: ["grim-arithmetic-window"], - tag: "section", - window: { - title: `${n} — Encounter Danger Board`, - resizable: !0 - }, - position: { - width: 640, - height: "auto" - }, - actions: { - openDetailPair: function(e, t) { - let n = t.dataset, r = n.grimPcId, i = n.grimEnemyId, a = n.grimAttackId; - !r || !i || $.openForPair(r, i, a); - }, - openDetailSelection: function() { - $.openForSelection(); - }, - openForecast: function() { - An.open(); - }, - refresh: function() { - this.render(); - } - } - }; - static PARTS = { main: { template: `modules/${t}/templates/danger-board-panel.hbs` } }; - async _prepareContext() { - let e = new A(); - return { - moduleVersion: r, - message: Un, - dangerBoard: rt(Ce(k(e), { - adapter: e, - controls: un, - pairLimit: 200 - })), - forecastEnabled: h() - }; - } -}, qn = `${t}-open-panel`; -function Jn() { - new Kn().render(!0); -} -function Yn() { - Hooks.on("getSceneControlButtons", (e) => { - let t = e.tokens; - t && (t.tools[qn] = { - name: qn, - title: "Grim Arithmetic", - icon: "fa-solid fa-skull", - order: Object.keys(t.tools).length, - button: !0, - visible: !!game.user?.isGM, - onChange: Jn - }); - }); -} -//#endregion -//#region src/main.ts -Hooks.once("init", () => { - console.log(`${n} | Initializing`), m(), Yn(), Xn(); -}); -function Xn() { - let e = globalThis.Handlebars; - e && e.registerHelper("eq", function(e, t) { - return e === t; - }); -} -Hooks.once("ready", () => { - if (!game.user?.isGM) return; - let e = game.modules.get(t); - e && (e.api = { - openPanel: () => new Kn().render(!0), - openPairDetail: (e, t, n) => $.openForPair(e, t, n), - openPairDetailFromSelection: () => $.openForSelection(), - openForecast: () => An.open(), - captureTokenDebug: (e = canvas.tokens?.controlled?.[0]) => a(e) - }); -}); -//#endregion diff --git a/docs/ARITHMETIC.md b/docs/ARITHMETIC.md new file mode 100644 index 0000000..5eb784c --- /dev/null +++ b/docs/ARITHMETIC.md @@ -0,0 +1,835 @@ +# Grim Arithmetic Calculation Guide + +> **Purpose:** Explain, in plain English and implementation-level detail, what goes into Grim Arithmetic's risk calculation. +> **Current baseline:** v0.5.0 encounter-wide immediate risk view +> **Audience:** GMs, testers, contributors, and anyone asking “where did that percentage come from?” + +Grim Arithmetic is a decision-support tool, not a combat oracle. Its current MVP answers one narrow question: + +> **If the targeted enemy spends its next modeled turn making Strikes against the selected PC, what is the chance the PC is reduced to 0 HP or below?** + +The headline output is therefore **immediate down-risk**, not full death probability and not encounter outcome probability. + +--- + +## Current MVP summary + +The current calculation uses: + +- selected PC's current HP +- selected PC's temporary HP, if available +- selected PC's AC +- optional manual AC adjustment, such as Raise Shield +- targeted enemy's Strike attack bonus +- targeted enemy's Strike damage formula +- selected number of enemy Strikes: 1, 2, or 3 +- Multiple Attack Penalty model: normal, agile, or none +- PF2e degree-of-success rules for d20 attacks +- exact probability mass functions from supported dice formulas +- simple doubled total damage distribution on critical hits +- cumulative exact damage distribution across the modeled Strike sequence +- wounded and doomed values for dying severity if the PC is downed +- optional Hero Point death-prevention assumption messaging +- simple resistance, weakness, and immunity adjustments when Strike damage type is confidently known + +The current calculation does **not** model: + +- permanent death probability +- recovery checks +- healing before or during the enemy turn +- Hero Point survival probability +- Shield Block damage prevention +- Champion reactions or other defensive reactions +- resistance, weakness, or immunity edge cases for ambiguous/mixed damage +- deadly, fatal, precision, splash, or persistent damage +- enemy tactics beyond “make this many Strikes against this PC” +- terrain, reach, movement, action availability, or line of effect + +Some of these are planned backlog items. When they are implemented, this document should be updated in the same PR/commit. + +--- + +## Input flow + +In Foundry, the MVP expects this workflow: + +1. The GM selects exactly one PC token. +2. The GM targets exactly one enemy token. +3. The GM opens Grim Arithmetic from the skull button in Token Controls. +4. Grim Arithmetic extracts data from the selected and targeted tokens. +5. The panel calculates immediate down-risk. + +Internally, the calculation is split into layers: + +- **Foundry selection layer** — figures out selected PC and targeted enemy. +- **PF2e adapter layer** — extracts HP, AC, Strike bonus, damage, and MAP hints from Foundry/PF2e actor data. +- **Engine layer** — performs pure probability/math calculations without Foundry dependencies. +- **UI layer** — formats the result as percentages, labels, assumptions, and caveats. + +--- + +## Combatant data used + +### Selected PC + +The MVP uses: + +- **Current HP** +- **Temporary HP**, if available +- **AC** +- **Wounded value**, used to report dying severity if downed +- **Doomed value**, used to report the adjusted death threshold +- **Hero Points**, used only for the Hero Point availability note + +The modeled HP is: + +```text +modeled HP = current HP + temporary HP +``` + +If the GM applies a shield/AC adjustment in the panel, the effective AC is: + +```text +effective AC = actor AC + selected AC adjustment +``` + +For example: + +```text +Actor AC: 21 +Shield adjustment: +2 +Effective AC used by Grim Arithmetic: 23 +``` + +### Targeted enemy + +The module lets the GM choose among the enemy's supported melee Strikes. For the selected Strike, it extracts: + +- Strike name +- attack bonus +- damage formula +- primary damage type, when available +- MAP type if detectable: + - `normal` + - `agile` + - `unknown`, which defaults to normal MAP in Auto mode + +If the previously selected Strike is no longer available, the panel falls back to the first supported Strike. + +--- + +## Attack outcome math + +For each modeled Strike, Grim Arithmetic evaluates all 20 possible d20 rolls. + +For each die result from 1 through 20: + +```text +attack total = d20 result + attack bonus + MAP penalty +``` + +That total is compared to the PC's effective AC using PF2e degree-of-success rules. + +### Base degree of success + +```text +if attack total >= AC + 10: critical success +else if attack total >= AC: success +else if attack total <= AC - 10: critical failure +else: failure +``` + +### Natural 20 and natural 1 + +PF2e upgrades or downgrades the degree of success: + +```text +natural 20: improve the result by one step +natural 1: worsen the result by one step +``` + +The possible degrees, from worst to best, are: + +```text +critical failure → failure → success → critical success +``` + +So a natural 20 that would otherwise be a failure becomes a success. A natural 1 that would otherwise be a success becomes a failure. + +### Probability output + +After checking all 20 die results, Grim Arithmetic counts how many rolls produce each outcome: + +```text +critical success probability = critical success count / 20 +success probability = success count / 20 +failure probability = failure count / 20 +critical failure probability = critical failure count / 20 +``` + +Because this is a d20, each individual die result is worth 5 percentage points. + +--- + +## Multiple Attack Penalty + +The GM chooses, or Auto-detects, a MAP model. + +### Normal MAP + +```text +Strike 1: 0 +Strike 2: -5 +Strike 3: -10 +``` + +### Agile MAP + +```text +Strike 1: 0 +Strike 2: -4 +Strike 3: -8 +``` + +### No MAP + +```text +Strike 1: 0 +Strike 2: 0 +Strike 3: 0 +``` + +No MAP is mainly a what-if/debug option. It can also approximate special cases where the GM wants to ignore MAP, but the MVP does not yet validate whether that is rules-legal. + +--- + +## Damage math in v0.2.0 + +The current calculation uses **exact dice distributions** for supported formulas. Instead of replacing `1d8+4` with only its average, Grim Arithmetic builds a probability mass function: every possible damage total and the probability of rolling it. + +Supported formulas are simple additive/subtractive dice expressions, such as: + +```text +1d8 +2d6+4 +2d8 + 6 +1d12+1d6+3 +4 +``` + +Unsupported formulas include typed/tagged or conditional PF2e expressions such as: + +```text +2d8[persistent,fire]+4 +1d8+1d6 precision +1d10 plus Grab +``` + +### Probability mass functions + +A probability mass function, or PMF, records each total and its probability. + +For `1d4`, the PMF is: + +```text +1: 25% +2: 25% +3: 25% +4: 25% +``` + +For `2d6+4`, Grim Arithmetic convolves the two d6 rolls, then adds the flat modifier. The minimum is 6, the maximum is 16, and the average is 11. A total of 11 is the most likely result because it comes from rolling 7 on 2d6. + +### Formula summary + +For every supported formula, the engine produces: + +```text +minimum damage +maximum damage +mean / average damage +probability of each total +``` + +The mean is still displayed because it is useful for intuition and expected HP, but down chance is no longer based on mean-only thresholds. + +### Critical damage in v0.2.0 + +Critical damage is modeled as simple doubled total damage: + +```text +crit total = normal total × 2 +``` + +For example, `1d4+2` has normal totals 3, 4, 5, and 6. Its simple crit distribution is 6, 8, 10, and 12. + +This is still intentionally simplified. PF2e traits like deadly and fatal are not modeled yet. + +--- + +## Expected damage + +For each Strike, Grim Arithmetic computes expected damage from the mean of the exact distribution: + +```text +expected damage from Strike = + success probability × normal damage mean ++ critical success probability × critical damage mean +``` + +Failures and critical failures contribute zero damage in the current Strike model. + +For multiple Strikes, expected damage is the sum of each Strike's expected damage after applying MAP: + +```text +total expected damage = Strike 1 expected damage + Strike 2 expected damage + Strike 3 expected damage +``` + +The displayed expected HP after the modeled turn is: + +```text +expected HP after turn = max(0, modeled HP - total expected damage) +``` + +Important: expected HP is a mean-based summary. It is not the same thing as down probability. The down chance uses the full exact damage distribution. + +--- + +## Down probability + +The down probability answers: + +```text +What fraction of modeled attack + damage sequences deal damage >= modeled HP? +``` + +For each Strike, Grim Arithmetic now has three branch groups: + +```text +miss branch: 0 damage +hit branches: every normal damage total from the exact PMF +crit branches: every doubled damage total from the exact crit PMF +``` + +The probability of the miss branch is: + +```text +failure probability + critical failure probability +``` + +Each hit branch probability is: + +```text +success probability × probability of that normal damage total +``` + +Each crit branch probability is: + +```text +critical success probability × probability of that doubled crit damage total +``` + +For one Strike, the down probability is the sum of hit/crit branches where damage is at least the PC's modeled HP. + +For multiple Strikes, Grim Arithmetic convolves cumulative damage states across the selected Strike sequence. + +Example for two Strikes: + +```text +Strike 1 miss + Strike 2 miss +Strike 1 miss + Strike 2 each hit/crit damage total +Strike 1 each hit/crit damage total + Strike 2 miss +Strike 1 each hit/crit damage total + Strike 2 each hit/crit damage total +``` + +Each path has a probability: + +```text +path probability = Strike 1 branch probability × Strike 2 branch probability +``` + +The module adds together the probabilities of all paths where: + +```text +cumulative damage >= modeled HP +``` + +That final sum is the displayed **down chance**. + +--- + +## Why down chance and expected HP can feel different + +Expected HP and down chance are related but not interchangeable. + +Example: + +```text +Modeled HP: 20 +Expected damage: 10 +Expected HP after turn: 10 +``` + +This does not mean down chance is zero. If the enemy has a meaningful crit branch that deals 20+ damage, the PC may still have a non-zero chance of dropping. + +Likewise: + +```text +Modeled HP: 8 +Expected damage: 9 +Expected HP after turn: 0 +``` + +This does not necessarily mean the PC is guaranteed to drop. It means the average damage exceeds HP, but individual miss/hit/crit branches still determine the actual down probability. + +This is why v0.2.0 uses exact dice distributions for down chance: average damage is useful, but it can hide swinginess. + +--- + +## Risk labels + +The MVP maps down probability to labels: + +```text +0% to <5% = Low +5% to <15% = Guarded +15% to <35% = Dangerous +35% to <60% = Severe +60%+ = Grim +``` + +These labels are meant as quick GM-facing signals, not official PF2e difficulty categories. + +--- + +## Worked example + +Suppose: + +```text +PC modeled HP: 7 +PC effective AC: 20 +Enemy attack bonus: +10 +Enemy damage: 1d4+2 +Enemy turn model: 1 Strike +MAP: normal +``` + +### Step 1: damage distribution + +`1d4+2` has normal damage totals: + +```text +3: 25% +4: 25% +5: 25% +6: 25% +``` + +The simple crit distribution doubles the total: + +```text +6: 25% +8: 25% +10: 25% +12: 25% +``` + +### Step 2: Strike probabilities + +For attack bonus +10 against AC 20, the engine evaluates all d20 rolls and applies PF2e natural 20/natural 1 rules. The result is: + +```text +hit chance: 50% +crit chance: 5% +miss/critical failure chance: 45% +``` + +### Step 3: exact down check + +The PC has 7 modeled HP. + +Normal hits cannot down the PC because the maximum normal damage is 6. + +A crit does not always down the PC either: + +```text +crit 6: does not down +crit 8: downs +crit 10: downs +crit 12: downs +``` + +So only 75% of crit damage rolls down the PC. + +```text +down chance = crit chance × crit-roll down fraction + = 5% × 75% + = 3.75% +``` + +A mean-only model would have treated the average crit as 9 and reported the full 5% crit chance. The exact distribution avoids that false precision. + +--- + +## Dying severity and immediate death flags + +v0.3.0 still treats **down chance** as the probability that modeled damage reduces the selected PC to 0 HP or below. It now also reports what that down would mean for PF2e dying pressure. + +When a downing hit occurs, Grim Arithmetic reports two severity numbers: + +```text +normal hit down: Dying 1 + wounded +critical hit down: Dying 2 + wounded +``` + +Doomed lowers the dying value at which the PC dies: + +```text +death threshold = max(1, 4 - doomed) +``` + +Examples: + +```text +Wounded 0, Doomed 0: +normal down -> Dying 1 +crit down -> Dying 2 +death threshold -> Dying 4 + +Wounded 1, Doomed 1: +normal down -> Dying 2 +crit down -> Dying 3 +death threshold -> Dying 3 +``` + +The panel's **Immediate death flag** is threshold-oriented, not a probability: + +- If normal-down severity reaches the doomed-adjusted threshold, the flag says so. +- Otherwise, if crit-down severity reaches the threshold, the flag says so. +- Otherwise, if crit-down severity is one step below the threshold, the flag calls that out. +- Otherwise, it reports the normal/crit dying values for table awareness. + +This is deliberately **not** permanent death probability. It does not model recovery checks, initiative order, healing, follow-up attacks after the PC is down, Hero Point decisions, table-specific death-prevention rules, or enemy behavior after a target falls. + +### Wounded override + +The Wounded control now affects dying severity output. It does not change the down chance, because wounded does not change whether damage reduces HP to 0. + +Use this when the actor's wounded condition is missing, stale, or you want to test table hypotheticals such as “what if this PC were already Wounded 2?” + +### Hero Point assumption + +The Hero Point control has three modes: + +- **Use actor Hero Points** — assume death prevention is available if the adapter sees one or more Hero Points. +- **Assume Hero Point available** — force the Hero Point note to available. +- **Assume no Hero Point** — force the Hero Point note to unavailable. + +This only changes the explanatory note. Grim Arithmetic does not convert Hero Point use into a survival probability. + +--- + +## Damage adjustments in v0.4.0 + +When PF2e actor data exposes the selected PC's resistances, weaknesses, or immunities and the selected enemy Strike exposes a primary damage type, Grim Arithmetic applies a simple PF2e-style adjustment to each exact damage outcome: + +```text +adjusted damage = max(0, rolled damage - matching resistance) + matching weakness +``` + +If the PC is immune to the Strike's damage type, modeled damage is set to 0. + +The adjustment is applied separately to the normal-hit and crit-hit exact distributions. This means down chance, damage range, average damage, and expected HP all use the adjusted distribution. + +Matching is intentionally conservative: + +- exact damage-type matches apply, such as `fire` resistance against `fire` damage +- `physical` applies to `bludgeoning`, `piercing`, and `slashing` +- `all` applies to any known damage type +- unknown or ambiguous damage types do **not** silently apply adjustments + +The panel reports the applied adjustment note, for example: + +```text +Applied slashing resistance 5 and slashing weakness 2. +Damage type unknown; no resistance, weakness, or immunity applied. +``` + +Current limitations: + +- mixed damage types are not split into separate pools yet +- exceptions and bypass rules are not modeled +- precision, splash, persistent, deadly, and fatal remain deferred +- manual damage-type/value override controls are still planned + +--- + +## Current interpretation guidance + +Use the MVP result as: + +- “How risky is this enemy's immediate Strike sequence?” +- “Is this danger mostly crit-driven?” +- “How much does +1 or +2 AC change the risk?” +- “Would 1 Strike be safe, but 2 or 3 Strikes become scary?” + +Do **not** use the MVP result as: + +- “This PC has exactly X% chance to die.” +- “This encounter has X% chance of a TPK.” +- “This enemy will definitely make all these Strikes.” +- “This accounts for every PF2e defensive rule.” + +--- + +## Encounter-wide danger board in v0.5.0 + +v0.5.0 splits the UI into **two windows**: an Encounter Danger Board (main, opened by the skull button) and a Pair Detail popup (one reusable instance, opened from danger board rows or from the "selected PC + targeted enemy" button). The single-pair math is unchanged — the encounter view simply runs that same engine against every supported pair in the active combat, and the detail window renders one pair at a time using the same `buildMortalityPanelData` builder. + +### How pairs are generated + +When a combat encounter is active, Grim Arithmetic reads combatants from `game.combat`: + +- Tokens whose actor disposition resolves to `pc` go into the **PCs** list. +- Tokens whose token-document disposition resolves to `enemy` (PF2e hostile, Foundry disposition `-1`) go into the **hostiles** list. +- Allied or neutral combatants are excluded with a caveat naming each one. +- Combatants the system adapter cannot extract are surfaced as **unsupported actors** rather than throwing. + +For each PC and each supported melee Strike on each hostile, Grim Arithmetic builds an `ImmediateDownRiskInput` from the same panel controls used by the single-pair detail view, calls the existing `immediateDownRisk()` engine, and records the result as a `PairRisk`: + +```text +PairRisk = { pcId, pcName, enemyId, enemyName, attackId, attackName, downProbability, riskLabel, caveats[] } +``` + +If `immediateDownRisk()` throws for one pair (for example, a malformed damage formula), the error is caught and surfaced as a per-pair caveat. Other pairs continue computing. + +A hostile with no supported attacks does not contribute pairs; an encounter-level caveat names the hostile. + +### Ranking + +The danger board ranks two things: + +1. **Most endangered PCs** — each PC appears at most once. The shown entry is the PC's worst pair (highest `downProbability`) across every hostile and every Strike. The list is sorted descending and truncated to a top-N (default 5). +2. **Most dangerous enemies** — each hostile appears at most once. The shown entry is the hostile's worst pair (highest `downProbability`) against any PC. Same sort/truncation. + +Each entry is formatted as: + +```text +PC vs Enemy Attack — XX% Label +``` + +For example: `Mira vs Troll Claw — 38% Severe`. The percentage is rounded to the nearest whole; the label is the same Low / Guarded / Dangerous / Severe / Grim mapping as the detail view. + +### Performance guardrails + +To avoid freezing the Foundry UI on very large scenes, the matrix function refuses to compute when the projected pair count (PCs × hostile-attack permutations) exceeds `MAX_PAIRS` (default **200**). When the guardrail trips, the danger board renders as **skipped** with a single caveat instead of partial or unbounded results. The single-pair detail view is unaffected. + +### What the danger board does not do + +- It does not run a Monte Carlo simulation, model turn order, or simulate tactics. +- It does not account for healing, reactions, or follow-up rounds. +- It does not change any of the single-pair math; the same engine, assumptions, and caveats apply. + +These are addressed by the Monte Carlo simulation in v0.6.0; see the next section. + +--- + +## Monte Carlo encounter simulation in v0.6.0 + +v0.6.0 adds an entirely new feature alongside the v0.5.0 danger board: a Monte Carlo simulation of the active encounter. The danger board's single-pair down-risk math is unchanged. The simulation is **opt-in per run** and lives in its own window. + +### What the simulation answers + +The danger board answers: *"if the enemies all swung at the PCs right now, who would drop?"* — narrow, per-pair, no turn order, no tactics. + +The Monte Carlo simulation answers: *"if we played out this encounter many times under explicit assumptions, what tends to happen?"* — broader, full encounter, configurable tactics. Headline metrics: + +- **Any-PC-down probability** — fraction of iterations where at least one PC was downed. +- **TPK probability** — fraction of iterations where every PC was downed. +- **Expected first-down round** — mean / median round in which the first PC dropped. +- **Per-PC down and death rates**, mean ending HP, biggest contributing enemy. +- **Per-enemy damage share** and top target. + +### Where the UI lives + +Per the v0.6.0 UX plan, the Monte Carlo UI lives in its own singleton **Forecast window** (~800w), distinct from the Danger Board. The Danger Board gets one new header button ("Forecast encounter") that opens the singleton — one click between the two views, intentional. The danger board's "what's lethal right now" identity stays uncluttered, and the forecast has room for per-PC tables, per-enemy tables, and an always-visible assumptions block without compressing the existing endangered/dangerous lists. + +### The pipeline + +The simulation engine is pure TypeScript (no Foundry imports) and runs on every iteration in a strict order: + +```text +seeded RNG → initiative roll → per-turn tactics plan + → per-Strike sampler → state transition (damage, dying) + → termination check +``` + +For each Strike the sampler: + +1. Draws a d20 face via `rng.nextInt(1, 20)`. +2. Applies the existing PF2e nat-1 / nat-20 step-shift rules through the shared `degreeOfSuccess()` helper. +3. On `success` or `criticalSuccess`, samples a damage total from the exact analytic PMF (the v0.4.0 / v0.5.0 `damageDistribution()` + `doubleDistribution()` path) using inverse-CDF over `rng.next()`. +4. Applies defender resistance / weakness / immunity via the same v0.4.0 helper the panel uses. + +The state transition function (`applyDamage`) handles the v0.3.0 dying / wounded / doomed rules: temp HP drains first; PCs entering dying take `1 + wounded` on a normal hit, `2 + wounded` on a critical hit; death threshold is `max(1, 4 - doomed)`. Enemies at 0 HP are marked dead directly (no dying spiral). + +### Tactics profiles + +Five tactics profiles ship in v0.6.0. Each is a pure function of state plus the seeded RNG, so the same setup + seed + profile always produces identical results. + +- **Random legal** — pick any legal PC target and any attack independently per strike. Conservative baseline: if even random play produces a high down rate, the encounter is structurally dangerous, not just dangerous under optimal tactics. +- **Spread damage** — distribute strikes across higher-HP standing PCs; never target downed. Use this when modeling enemies that try to keep all PCs in the fight at once. +- **Focus fire** — concentrate every strike on the lowest-HP standing PC. Use this when modeling enemies that "kill the wounded one." Tends to drive higher per-PC death rates than spread. +- **Predator** — prioritize wounded > low-HP > full-HP standing PCs; attack downed only if no standing PCs remain. Models monsters with "hunt the weak" lore. +- **Boss cinematic** — use the highest-mean-damage attack on the toughest (highest-HP) standing PC, all strikes on the same target. Models the dramatic boss-vs-tank matchup; the MAP-penalized follow-ups hit hard because the chosen attack is high-damage. + +A profile that lands on a target dropped by an earlier strike in the same turn still resolves the remaining strikes — the plan is committed when the turn begins. This is intentional and slightly more lethal than a "smart" enemy that would retarget; it is the conservative-for-the-PCs assumption. + +### PC tactics (v0.6.0-rc.3) + +rc.1 and rc.2 shipped with PCs taking no actions in the simulation. That made the output disorienting against GM intuition — a PF2e "Low Threat" encounter would report a 99% TPK because two enemies grind a stationary party for 5 rounds. rc.3 pulls PC action modeling forward from v0.7.0+ into v0.6.0 so the forecast is GM-useful before v0.6.0 promotes. + +In rc.3, PCs use **one hardcoded tactics profile** (no UI dropdown). Future v0.6.1 may add variants: + +- **Target selection.** PCs target the standing enemy whose primary attack has the highest mean damage — the actual threat to the party, not the lowest-HP minion. Tiebreakers: lower current HP wins, then lower id ascending (determinism). +- **Strikes per turn.** Two, matching PF2e's standard two-action attack routine. MAP escalates from 0 to -5 (or 0 to -4 for agile weapons), identical to enemy strikes. +- **Attack selection.** PC uses their first available Strike (mirrors `pickFirstAttack` for enemies). A future enhancement could pick by best expected damage against target AC. +- **Skip cases.** PC skips its turn cleanly when no standing enemies remain or when the PC has no extractable Strike. The setup builder surfaces " has no supported Strike; will skip its turns in the simulation." as a caveat. + +PC Strikes resolve through the exact same `sampleStrike` + `applyDamage` pipeline as enemy strikes. Enemy resistance/weakness/immunity applies to PC damage symmetrically. An enemy at 0 HP is marked dead directly (no dying spiral); the encounter ends early once all enemies are dead. + +### Iterations and stability + +Three iteration counts are exposed in the UI: + +| Iterations | Approx. headline standard error | Approx. wall-clock on a typical GM machine | +|------------|---------------------------------|---------------------------------------------| +| 1,000 | ±3% on any-PC-down | Sub-second | +| 5,000 | ±1.4% on any-PC-down | A few seconds (default) | +| 10,000 | ±1% on any-PC-down | Several seconds | + +Use 1k for quick what-ifs; 5k for the typical case; 10k when comparing two close options (encounter A vs encounter B, or two tactics profiles) where the gap might be within 1k's noise band. The engine refuses to run more than 10,000 iterations in a single call. + +### Seeding + +Two forms accepted in the seed input: + +- Blank: a fresh random seed is picked each run; results vary run-to-run within the standard-error band. +- Filled (string or number): deterministic. Same seed + same setup + same config → byte-identical SimulationResult. + +The runner derives a per-iteration sub-seed from the master seed so iteration N is reproducible independently of total iteration count. Truncating from a 5k run to a 1k run yields the same first 1,000 per-iteration outcomes when the master seed matches. + +### PC survival mechanics (v0.6.0-rc.4) + +rc.4 ships Phase I-A of the PC survival work: healing, recovery checks, and Hero Point death prevention. Reactions (Shield Block, Champion) are reserved for rc.5 Phase I-B. + +- **Recovery checks.** At the start of each dying PC's turn, the engine rolls a PF2e flat check (1d20 vs DC 10+dying). Crit-success / success / failure / crit-failure step dying by −2 / −1 / 0 / +1. Most dying PCs recover within 1–2 rounds without intervention, which dramatically cuts the rc.3 "drop once, die" cascade. +- **Healing actions** (when extracted from the PC's actor data): + - **Battle Medicine** — Medicine check vs DC (proficiency-scaled). Crit-success heals 4d8+8; success heals 2d8+4; failure heals 0; crit-failure heals 0 and deals 1d8 to the target. 1/target/day per PF2e. + - **Heal spell** — 1-action heals 1d10 single target; 2-action heals `d8 + 8*`; 3-action heals same as 2-action (sim ignores AoE positioning). Consumes a prepared slot. Heal cast on a dying target also clears dying. + - **Heal cantrip** — 1-action 1d10; 2-action `(1 + ceil(level/2))d8`, scaling with caster level. At-will. +- **PC tactics decision tree** runs each PC turn: + 1. *Emergency heal* — any standing ally is dying. Full-turn 2-action heal on the most-dying ally. No Strikes that round. + 2. *Top-up heal* — any ally below 40% HP (and not dying). 1-action heal + 1-action Strike. + 3. *Default* — 2 Strikes at the most-dangerous standing enemy. + Healer preference: Heal spell 2-action > Heal cantrip 2-action > 1-action variants > Battle Medicine. Slot economy: spell slots decrement; Battle Medicine respects per-target restriction. +- **Hero Point death prevention** — when a PC would die (`dying >= deathThreshold`), spend a Hero Point to drop to dying 0 at 0 HP. Capped at one Hero Point survival per iteration per PC. Eliminates the one-bad-roll TPK pattern rc.3 still produced. + +The combination cuts rc.3's TPK rate for the canonical 3-PC vs 2-enemy "Low Threat" encounter from ~32% (focus-fire) to ~2%, matching GM intuition for a PF2e Low-Threat encounter with a healer in the party. + +### Explicitly not modeled in v0.6.0 + +PC actions, healing, recovery, and Hero Point survival are all modeled as of rc.4. + +- **Reactions** (rc.5). Shield Block, Champion reactions (Liberator/Redeemer/Paladin) — coming in rc.5. Attacks of opportunity defer to v0.7.0+. +- **Spells beyond Heal.** Enemies use Strikes only; PCs use Strikes + Heal. Save-based threats and offensive spells defer to v0.7.0+. +- **Persistent damage.** v0.8.0+. +- **Movement, reach, line of sight.** The simulation assumes everyone can reach everyone. +- **Initiative-altering abilities.** Delay, Ready, surprise rounds, and feats that move initiative are not modeled. +- **PC multi-attack action economy beyond 2 Strikes/turn.** Real PCs sometimes Strike 3 times (third Strike at -10 MAP); rc.4 caps at 2. +- **Multi-encounter resource regeneration.** Heal spell slots and Battle Medicine targets are tracked within a single iteration but do not refresh between iterations — that's correct for single-encounter forecasts. + +### How to interpret the numbers + +The forecast is decision support, not prophecy. + +- After rc.4, the simulation reflects realistic round counts (typically 2–4 rounds) AND a realistic safety net (healing, recovery, Hero Points). A "Low Threat" PF2e encounter with a healer in the party should now show low single-digit TPK risk; if it doesn't, the encounter likely has structural lethality worth examining. +- A 30% TPK probability is still a **model artifact**, not a 30% campaign-death chance. It's the probability under "no healing, no reactions, no Hero Points, fixed tactics for both sides." Real-table risk usually skews lower because PCs use Battle Medicine, Shield Block, Demoralize, and similar non-Strike actions the model does not yet simulate. +- A 1k run with TPK 5% and a second 1k run with TPK 8% are within the same noise band (±3%). If a close call matters, bump to 10k. +- Risk pills (Low / Guarded / Dangerous / Severe / Grim) use the same v0.5.0 thresholds; per-PC risk is mapped from each PC's own down probability. +- The biggest threat enemy is "who contributed the most absorbed damage across iterations," not necessarily "who's the dramatic villain." +- The Danger Board's per-pair view is still the right tool for "what's lethal *right now in a single turn*"; the Forecast answers the multi-round encounter question. + +### Performance and the kill switch + +The simulation runs in a Web Worker so the Foundry main thread stays responsive even at 10k iterations. Progress events are throttled to ~10/sec to avoid postMessage flooding. Aborting a run mid-flight returns a partial result flagged `aborted: true` rather than discarding everything. + +A per-client setting in **Configure Settings → Module Settings → Grim Arithmetic** — **Enable Monte Carlo encounter simulation** — disables the feature entirely on machines where it's too costly. When the kill switch is off, the "Forecast encounter" button is hidden on the Danger Board and no Worker is constructed. The v0.5.0 danger board behavior is unchanged either way. + +--- + +## Implementation references + +Current calculation code lives in: + +```text +src/engine/degree-of-success.ts +src/engine/attack-probability.ts +src/engine/dice.ts +src/engine/mortality.ts +src/engine/encounter-risk.ts +src/engine/prng.ts +src/engine/simulation-types.ts +src/engine/sample-strike.ts +src/engine/sim-state.ts +src/engine/initiative.ts +src/engine/tactics/*.ts +src/engine/run-iteration.ts +src/engine/run-simulation.ts +src/engine/simulation-guardrails.ts +src/engine/simulation.worker.ts +src/engine/run-simulation-in-worker.ts +src/foundry/selection.ts +src/foundry/encounter-participants.ts +src/foundry/encounter-setup.ts +src/ui/panel-data.ts +src/ui/danger-board.ts +src/ui/danger-board-panel.ts +src/ui/pair-detail-panel.ts +src/ui/pair-detail-resolver.ts +src/ui/forecast-panel.ts +src/systems/pf2e-adapter.ts +``` + +Current tests live in: + +```text +tests/degree-of-success.test.ts +tests/attack-probability.test.ts +tests/dice.test.ts +tests/mortality.test.ts +tests/panel-data.test.ts +tests/pf2e-adapter.test.ts +tests/pf2e-adapter-pc-strikes.test.ts +tests/encounter-participants.test.ts +tests/encounter-risk.test.ts +tests/danger-board.test.ts +tests/encounter-guardrail.test.ts +tests/pair-detail-panel.test.ts +tests/prng.test.ts +tests/simulation-types.test.ts +tests/sim-state.test.ts +tests/sample-strike.test.ts +tests/initiative.test.ts +tests/tactics/tactics.test.ts +tests/tactics/pc-default.test.ts +tests/run-iteration.test.ts +tests/run-simulation.test.ts +tests/simulation-guardrails.test.ts +tests/encounter-setup.test.ts +tests/run-simulation-in-worker.test.ts +tests/forecast-panel-data.test.ts +tests/simulation-fixtures.test.ts +tests/settings.test.ts +``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..e33dfcd --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,334 @@ +# Installing Grim Arithmetic on a Foundry VTT Server + +This guide covers installing the current development build of **Grim Arithmetic** on a Foundry VTT server for manual testing. + +Current target: + +- Foundry VTT: **v13 minimum** +- Verified smoke-test target: **Foundry VTT v14.361** +- Manifest compatibility: `minimum: "13"`, `verified: "14.361"` +- System: **Pathfinder 2e (`pf2e`)** +- Module ID/folder name: `grim-arithmetic` + +> Grim Arithmetic has public release packages for tester installs. For development/server smoke testing, you can still copy or sync this repository into Foundry’s `Data/modules/grim-arithmetic/` directory. + +--- + +## 1. Build the module locally + +From your Mac/local development checkout: + +```bash +cd /Users/kyle/git/grim-arithmetic +npm install +npm run check +``` + +Expected: + +- ESLint passes. +- Vitest passes. +- Vite build passes. +- `dist/grim-arithmetic.js` exists. + +Quick verification: + +```bash +test -f module.json +test -f dist/grim-arithmetic.js +test -f templates/mortality-panel.hbs +test -f styles/grim-arithmetic.css +``` + +--- + +## 2. Identify the Foundry Data path + +Common Foundry server layouts: + +```text +~/foundrydata/Data +~/FoundryVTT/Data +/opt/foundrydata/Data +/home//foundrydata/Data +``` + +The module must land at: + +```text +/Data/modules/grim-arithmetic/ +``` + +The folder name should match the manifest ID: + +```json +"id": "grim-arithmetic" +``` + +--- + +## 3. Install option A — rsync from local Mac to server + +Use this when testing on a remote Proxmox-hosted Foundry server. + +Set these variables in your terminal, replacing the host/path values: + +```bash +LOCAL_REPO="/Users/kyle/git/grim-arithmetic" +FOUNDRY_HOST="your-foundry-host-or-ip" +FOUNDRY_USER="your-ssh-user" +FOUNDRY_DATA="/home/your-ssh-user/foundrydata/Data" +REMOTE_MODULE_DIR="$FOUNDRY_DATA/modules/grim-arithmetic" +``` + +Build locally first: + +```bash +cd "$LOCAL_REPO" +npm run check +``` + +Create the remote module directory: + +```bash +ssh "$FOUNDRY_USER@$FOUNDRY_HOST" "mkdir -p '$REMOTE_MODULE_DIR'" +``` + +Sync only the files Foundry needs plus useful docs: + +```bash +rsync -av --delete \ + --exclude '.git/' \ + --exclude 'node_modules/' \ + --exclude 'coverage/' \ + --exclude '.env' \ + "$LOCAL_REPO/" \ + "$FOUNDRY_USER@$FOUNDRY_HOST:$REMOTE_MODULE_DIR/" +``` + +Verify remotely: + +```bash +ssh "$FOUNDRY_USER@$FOUNDRY_HOST" " + set -e + test -f '$REMOTE_MODULE_DIR/module.json' + test -f '$REMOTE_MODULE_DIR/dist/grim-arithmetic.js' + test -f '$REMOTE_MODULE_DIR/templates/mortality-panel.hbs' + test -f '$REMOTE_MODULE_DIR/styles/grim-arithmetic.css' + python3 -m json.tool '$REMOTE_MODULE_DIR/module.json' >/dev/null + echo 'Grim Arithmetic files look good.' +" +``` + +### SSH/fail2ban caution + +If the Foundry server uses fail2ban, avoid rapid-fire repeated SSH reconnects. Prefer batched commands like the examples above. If SSH suddenly times out while ping still works, wait a few minutes before retrying. + +--- + +## 4. Install option B — clone or copy directly on the server + +Use this if the server has Git and Node available. + +```bash +FOUNDRY_DATA="$HOME/foundrydata/Data" +mkdir -p "$FOUNDRY_DATA/modules" +cd "$FOUNDRY_DATA/modules" +git clone grim-arithmetic +cd grim-arithmetic +npm install +npm run check +``` + +This is convenient, but for early private testing rsync from the Mac is often simpler. + +--- + +## 5. Restart Foundry + +After adding a module to the filesystem, do a full Foundry server restart. A browser refresh is not always enough for new module discovery. + +Examples, depending on deployment: + +```bash +# systemd-style deployment +systemctl --user restart foundryvtt + +# or, if running as root/system service +sudo systemctl restart foundryvtt + +# Docker-style deployment +cd /path/to/docker-compose-dir +docker compose restart foundry +``` + +Use whatever matches your Proxmox Foundry deployment. + +--- + +## 6. Enable the module in a PF2e world + +1. Open Foundry in the browser. +2. Launch a **PF2e** world. +3. Go to **Game Settings → Manage Modules**. +4. Find **Grim Arithmetic**. +5. Enable it. +6. Save module settings / reload world if prompted. + +Expected: + +- The module appears as **Grim Arithmetic**. +- No manifest validation error appears. +- Browser console logs: + +```text +Grim Arithmetic | Initializing +``` + +--- + +## 7. Quick smoke test + +In a PF2e scene: + +1. Place one PC token. +2. Place one hostile NPC token with at least one melee Strike. +3. Select the PC token. +4. Target the NPC token. +5. Open the Token Controls toolbar. +6. Click the skull icon for **Grim Arithmetic**. + +Expected: + +- A Grim Arithmetic panel opens. +- The panel header shows the current module version, e.g. `Grim Arithmetic v0.3.0`. +- It shows the PC vs enemy name. +- It shows an enemy Strike selector plus the selected Strike, attack bonus, and damage formula. +- It shows damage range, average damage, and damage swinginess. +- It shows down chance and risk label. +- It shows a damage adjustment note for resistance/weakness/immunity handling. +- It shows dying severity, doomed-adjusted death threshold, immediate death flag, and Hero Point note. +- It shows assumptions and not-modeled caveats. +- It explicitly says permanent death probability is not modeled in MVP. + +If the panel says it cannot find a supported melee Strike, continue to the testing guide and use `game.modules.get('grim-arithmetic')?.api?.captureTokenDebug?.(...)` to capture the sanitized actor data shape. + +--- + +## 8. Troubleshooting + +### Module does not appear in Manage Modules + +If Grim Arithmetic does not appear anywhere after a restart, Foundry is not discovering a valid module manifest. This is almost always one of: wrong data path, nested folder, folder/id mismatch, permissions, or manifest validation failure. + +Run this on the Foundry server, adjusting `FOUNDRY_DATA` if needed: + +```bash +FOUNDRY_DATA="$HOME/foundrydata/Data" +MODULE_DIR="$FOUNDRY_DATA/modules/grim-arithmetic" + +printf 'Module dir: %s\n' "$MODULE_DIR" +ls -la "$MODULE_DIR" +printf '\nmodule.json:\n' +python3 -m json.tool "$MODULE_DIR/module.json" +printf '\nKey files:\n' +find "$MODULE_DIR" -maxdepth 2 -type f | sort | sed "s|$MODULE_DIR/||" +printf '\nPermissions:\n' +namei -l "$MODULE_DIR/module.json" 2>/dev/null || true +``` + +Expected key files: + +```text +module.json +dist/grim-arithmetic.js +styles/grim-arithmetic.css +templates/mortality-panel.hbs +``` + +Also verify there is **not** an extra nested repo folder: + +```bash +test ! -f "$MODULE_DIR/grim-arithmetic/module.json" || echo 'Nested folder problem detected' +``` + +Common causes: + +- Folder is not named `grim-arithmetic`. +- `module.json` is missing or invalid. +- Files were copied one level too deep, e.g. `modules/grim-arithmetic/grim-arithmetic/module.json`. +- Foundry is using a different user data path than the one you copied into. +- The Foundry service user cannot read the module folder/files. +- Foundry was not fully restarted after install. + +Check the active user data path in the Foundry UI if possible: **Configuration → User Data Path**. + +For systemd installs, also inspect the service command/environment: + +```bash +systemctl status foundryvtt --no-pager +systemctl cat foundryvtt +``` + +Look for `--dataPath`, `FOUNDRY_VTT_DATA_PATH`, or similar. + +### Browser console has runtime errors + +Open browser dev tools and look for errors beginning with: + +```text +Grim Arithmetic +``` + +Also check whether Foundry APIs differ between the tested v13 path and v14.361 around: + +- `Application` +- `Hooks.on('getSceneControlButtons', ...)` +- token controls format +- `game.modules.get('grim-arithmetic').api` + +### Server logs show manifest/module validation errors + +On Linux-style Foundry data paths, logs often live under: + +```text +~/foundrydata/Logs/debug.YYYY-MM-DD.log +``` + +Example grep: + +```bash +grep -i "grim-arithmetic\|validation\|module" ~/foundrydata/Logs/debug.$(date +%Y-%m-%d).log | tail -80 +``` + +### Panel opens but cannot extract PF2e data + +The current adapter expects first-pass PF2e data paths such as: + +- PC HP: `actor.system.attributes.hp.value/max/temp` +- PC AC: `actor.system.attributes.ac.value` +- NPC melee items: `actor.items` entries where `type === 'melee'` +- Strike attack bonus: `item.system.bonus.value` or `item.system.attack.value` +- Strike damage: first `item.system.damageRolls.*.damage` or `.formula` + +If the real PF2e system uses a different shape, capture the debug snippets in `docs/TESTING.md` and we will patch `src/systems/pf2e-adapter.ts`. + +--- + +## 9. Updating after code changes + +After making local code changes: + +```bash +cd /Users/kyle/git/grim-arithmetic +npm run check +rsync -av --delete \ + --exclude '.git/' \ + --exclude 'node_modules/' \ + --exclude 'coverage/' \ + --exclude '.env' \ + /Users/kyle/git/grim-arithmetic/ \ + "$FOUNDRY_USER@$FOUNDRY_HOST:$REMOTE_MODULE_DIR/" +``` + +Then restart Foundry or reload the world depending on whether the manifest changed. For JavaScript-only changes, a browser hard refresh may be enough, but a world reload is safer during early testing. diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..fccf3a7 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,138 @@ +# Grim Arithmetic Release Notes / Tag Workflow + +This repo uses GitHub as the canonical remote: + +```bash +git remote set-url origin git@github.com:kyletravis/grim-arithmetic.git +git remote -v +``` + +Expected: + +```text +origin git@github.com:kyletravis/grim-arithmetic.git (fetch) +origin git@github.com:kyletravis/grim-arithmetic.git (push) +``` + +## Version bump checklist + +1. Update `package.json`. +2. Update `package-lock.json` root package versions. +3. Update `module.json`. +4. Ensure `module.json` release URLs match the version: + + ```json + { + "url": "https://github.com/kyletravis/grim-arithmetic", + "manifest": "https://github.com/kyletravis/grim-arithmetic/releases/latest/download/module.json", + "download": "https://github.com/kyletravis/grim-arithmetic/releases/download/vX.Y.Z/grim-arithmetic-vX.Y.Z.zip" + } + ``` + +5. Update `CHANGELOG.md`. +6. Build and test: + + ```bash + npm run check + npm run package + ``` + +7. Confirm the panel header shows `Grim Arithmetic vX.Y.Z` after deployment. + +## Release package contents + +`npm run package` builds: + +```text +releases/module.json +releases/grim-arithmetic-vX.Y.Z.zip +``` + +The zip is a direct Foundry module install archive with files at the zip root: + +```text +module.json +dist/ +styles/ +templates/ +README.md +LICENSE +CHANGELOG.md +``` + +It intentionally excludes source, tests, `.git/`, `node_modules/`, `docs/`, and other development-only files. (Documentation lives in `docs/` on GitHub and is not bundled into the install archive.) + +## Release pipeline (CI) and the manual gate + +Releases are published by `.github/workflows/release.yml`, triggered **only** by pushing a +`v*` tag. Pushing the tag *is* the human gate — never push it until the locally built zip has +passed on a real Foundry server. The flow: + +```text +bump version + CHANGELOG, commit, push main -> test.yml runs lint + test + build +npm run package -> local releases/grim-arithmetic-vX.Y.Z.zip +INSTALL THAT ZIP ON THE FOUNDRY SERVER, TEST <-- the gate; nothing ships until this passes +git tag -a vX.Y.Z -m "Grim Arithmetic vX.Y.Z" +git push origin main && git push origin vX.Y.Z -> release.yml builds + publishes the Release +``` + +On the tag push, `release.yml`: + +1. Verifies the tag matches `package.json` version (fails fast on mismatch). +2. Runs `npm ci`, `npm run check`, `npm run package` on the tagged commit. +3. Marks the Release as a **prerelease** automatically if the tag contains `-rc`/`-beta`/`-alpha`. +4. Creates the GitHub Release with notes from this version's `CHANGELOG.md` section, attaching + `grim-arithmetic-vX.Y.Z.zip` and `module.json`. + +Because CI rebuilds from the exact tagged commit with the pinned lockfile, the published zip +matches what you tested. `dist/` is no longer committed — CI and `npm run package` build it. + +### Tagging notes + +If a tag was created on another clone, fetch tags instead of recreating it: + +```bash +git fetch --tags origin +git tag --list 'v*' +``` + +### Manual fallback (only if CI is unavailable) + +```bash +gh release create vX.Y.Z \ + releases/grim-arithmetic-vX.Y.Z.zip \ + releases/module.json \ + --title "Grim Arithmetic vX.Y.Z" \ + --notes-file CHANGELOG.md +``` + +## Manifest install URLs + +Latest release install URL: + +```text +https://github.com/kyletravis/grim-arithmetic/releases/latest/download/module.json +``` + +Version-pinned install URL: + +```text +https://github.com/kyletravis/grim-arithmetic/releases/download/vX.Y.Z/module.json +``` + +Foundry reads the release `module.json`, then downloads the zip from that manifest's `download` URL. + +> Note: for unauthenticated Foundry installs, the GitHub repository/release assets must be public or otherwise reachable by the Foundry server. + +## Foundry server clone + +If testing from a server-side clone instead of manifest install, normalize the remote there too: + +```bash +cd ~/foundrydata/Data/modules/grim-arithmetic +git remote set-url origin git@github.com:kyletravis/grim-arithmetic.git +git fetch origin --tags +git status --short +``` + +Use the same `npm run check` gate before treating a server checkout as release-ready. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..b79b292 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,503 @@ +# Testing Grim Arithmetic in Foundry VTT v13/v14 / PF2e + +This guide walks through a manual test pass for Grim Arithmetic on a real PF2e server. Grim Arithmetic targets Foundry VTT v13+ and has been initially smoke-tested on Foundry VTT v14.361. + +Use this after following [INSTALL.md](./INSTALL.md). + +--- + +## 1. Test environment record + +Before testing, record the environment. This makes bug reports much easier. + +```text +Date: +Tester: +Server/host: +Foundry version/build, e.g. v14.361: +PF2e system version: +Browser: +Grim Arithmetic commit: +Grim Arithmetic version shown in panel header: +World name: +Scene name: +PC token used: +NPC token used: +``` + +Get the current commit locally: + +```bash +cd /Users/kyle/git/grim-arithmetic +git rev-parse --short HEAD +``` + +--- + +## 2. Basic module load checklist + +In Foundry: + +- [ ] Foundry launches successfully. +- [ ] PF2e world launches successfully. +- [ ] **Grim Arithmetic** appears in **Manage Modules**. +- [ ] Module enables without warning. +- [ ] World reloads after enabling. +- [ ] Browser console shows: + +```text +Grim Arithmetic | Initializing +``` + +- [ ] No Grim Arithmetic errors appear in the browser console. +- [ ] Token controls include a skull icon for Grim Arithmetic when logged in as GM. + +If the module does not appear, go back to [INSTALL.md troubleshooting](./INSTALL.md#8-troubleshooting). + +--- + +## 3. Empty / invalid selection behavior + +Open a scene with tokens available. + +### Case A — no selected token, no target + +Steps: + +1. Clear selected tokens. +2. Clear targets. +3. Click the Grim Arithmetic skull button. + +Expected: + +- [ ] Panel opens. +- [ ] It says no PC token is selected and asks for one PC token. +- [ ] It says no target is selected and asks for one enemy token. +- [ ] It does not crash. + +### Case B — selected PC, no target + +Steps: + +1. Select one PC token. +2. Clear targets. +3. Click the skull button. + +Expected: + +- [ ] Panel opens. +- [ ] It says no target is selected and asks for one enemy token. +- [ ] It does not crash. + +### Case C — one PC selected, multiple targets + +Steps: + +1. Select one PC token. +2. Target two enemy tokens. +3. Click the skull button. + +Expected: + +- [ ] Panel opens. +- [ ] It says multiple targets are selected and asks for only one enemy token. +- [ ] It does not crash. + +--- + +## 4. Happy-path immediate threat test + +Setup: + +- One PC token selected. +- One hostile NPC token targeted. +- NPC has at least one melee Strike. + +Steps: + +1. Select the PC token. +2. Target the NPC token. +3. Click the Grim Arithmetic skull button. + +Expected: + +- [ ] Panel opens. +- [ ] Header shows `Grim Arithmetic v0.3.0` or the current `module.json` version. +- [ ] Header shows `PC name vs NPC name`. +- [ ] Enemy Strike selector lists supported melee Strikes. +- [ ] Enemy Strike line shows selected Strike name, attack bonus, and damage formula. +- [ ] Modeled HP appears. +- [ ] Effective AC appears. +- [ ] Wounded display appears. +- [ ] Damage range, average damage, and swinginess appear. +- [ ] Damage adjustment note appears and does not claim adjustments when damage type is unknown. +- [ ] Down chance appears as a percentage. +- [ ] Risk label appears. +- [ ] Expected HP after turn appears. +- [ ] Dying if downed appears with normal-hit and crit values. +- [ ] Death threshold appears with doomed value. +- [ ] Immediate death flag appears. +- [ ] Hero Point note appears. +- [ ] Strike hit/crit chances appear. +- [ ] Assumptions appear. +- [ ] Not-modeled caveats appear. +- [ ] Permanent death is clearly marked as not modeled in MVP. + +Record observed values: + +```text +PC: +NPC: +Strike: +Attack bonus: +Damage formula: +Modeled HP: +Effective AC: +Down chance: +Risk label: +Expected HP after turn: +Damage adjustment note: +Dying if downed: +Death threshold: +Immediate death flag: +Hero Point note: +Strike hit/crit lines: +Unexpected issues: +``` + +--- + +## 5. Control behavior tests + +With the same PC/NPC pair and the panel open: + +### Enemy Strike selector + +If the targeted NPC has multiple supported melee Strikes, change **Enemy Strike** between them. + +Expected: + +- [ ] The selected Strike line changes. +- [ ] MAP auto mode follows the selected Strike's agile/normal trait. +- [ ] Down chance recalculates. +- [ ] Strike selection is preserved while changing other controls. + +### Refresh / Recalculate + +With the panel open, change token HP, targeting, or selection in Foundry, then click **Refresh / Recalculate**. + +Expected: + +- [ ] The panel re-reads current selection/target state. +- [ ] Modeled HP and risk values update without closing/reopening the panel. + +### Enemy turn Strike count + +Change **Enemy turn** between: + +- 1 Strike +- 2 Strikes +- 3 Strikes + +Expected: + +- [ ] Strike chance list length changes accordingly. +- [ ] Down chance recalculates. +- [ ] Assumptions update to the selected Strike count. + +### MAP mode + +Change **MAP** between: + +- Auto +- Normal +- Agile +- None + +Expected: + +- [ ] Hit/crit chances change when MAP mode changes. +- [ ] Down chance recalculates. +- [ ] Assumptions update to show the modeled MAP. + +### Shield / AC adjustment + +Change **Shield / AC adjustment** between: + +- No shield bonus +- +1 AC +- +2 AC + +Expected: + +- [ ] Effective AC changes. +- [ ] Hit/crit chances usually decrease as AC increases. +- [ ] Down chance usually decreases or stays equal. +- [ ] Assumptions mention the shield/status AC adjustment. + +### Wounded display + +Change **Wounded display** between: + +- Current actor value +- Wounded 0 +- Wounded 1 +- Wounded 2 +- Wounded 3 + +Expected: + +- [ ] Wounded line changes. +- [ ] Dying if downed changes by the selected wounded value. +- [ ] Immediate death flag updates when wounded reaches dangerous thresholds. +- [ ] Assumptions mention the wounded override when not using current actor value. +- [ ] Down chance does not need to change; wounded affects dying severity, not HP damage. + +### Hero Point prevention + +Change **Hero Point prevention** between: + +- Use actor Hero Points +- Assume Hero Point available +- Assume no Hero Point + +Expected: + +- [ ] Hero Point note updates. +- [ ] Assumptions mention the Hero Point override when not using actor state. +- [ ] No permanent death percentage appears. + +--- + +## 6. Encounter danger board + Pair detail (v0.5.0) + +v0.5.0 splits the previous combined panel into **two windows**: + +- **Encounter Danger Board** — main window opened by the Token Controls skull button. +- **Pair Detail** — separate popup window for the single-PC vs single-enemy detail view. One reusable instance; clicking another Detail button re-renders the same window with the new pair. + +### Setup + +- A scene with **at least 2 PC tokens** and **at least 2 hostile NPC tokens**, each NPC having at least one supported melee Strike. + +### Case A — danger board, no token selection + +Steps: + +1. Start a combat encounter and add the PCs and NPCs to the tracker. +2. Clear all selected tokens. Clear all targets. +3. Click the Grim Arithmetic skull button. + +Expected: + +- [ ] Danger board window opens (title contains "Encounter Danger Board"). +- [ ] **No** single-pair detail content is shown in this window. +- [ ] **Most endangered PCs** lists each PC at most once, sorted by their highest immediate down-risk against any hostile. +- [ ] **Most dangerous enemies** lists each hostile at most once, sorted by their worst pair against any PC. +- [ ] Each entry is formatted `PC vs Enemy Attack — XX% Label` and has its own **Detail** button. +- [ ] An "Open detail for selected PC + targeted enemy" button is present near the top. + +### Case B — open detail from a danger board row + +Steps: + +1. With the danger board open from Case A, click the **Detail** button on the top "Most endangered PCs" row. + +Expected: + +- [ ] A new **Pair Detail** window opens (separate window). +- [ ] The detail window has the correct PC, enemy, and Strike preselected. +- [ ] The danger board window stays open and unchanged. +- [ ] Click the **Detail** button on a different row (different PC or different enemy). +- [ ] The **same** Pair Detail window re-renders with the new pair instead of opening a second window. + +### Case C — open detail from selection + target + +Steps: + +1. Close any open Pair Detail window. +2. With the danger board open, select one PC token in the canvas and target one hostile NPC. +3. Click "Open detail for selected PC + targeted enemy" on the danger board. + +Expected: + +- [ ] Pair Detail window opens, populated with the selected PC and targeted enemy (the v0.4.x workflow). +- [ ] Numbers in the detail view match what previous releases produced for this PC × NPC × Strike triple. + +### Case D — Pair Detail when canvas state changes + +Steps: + +1. With a Pair Detail window open from Case B, end the active combat or delete the PC token. +2. Click the danger board's **Refresh** button, then re-click a Detail row. + +Expected: + +- [ ] If the referenced token is gone, the Pair Detail window shows a friendly error ("PC token is no longer on the canvas…") instead of throwing. +- [ ] No console exceptions related to Grim Arithmetic. + +### Case E — no combat active + +Steps: + +1. End the active combat encounter. +2. Click the skull button. + +Expected: + +- [ ] Danger board opens with the empty-state caveat ("No encounter-wide risk to show…"). +- [ ] "Open detail for selected PC + targeted enemy" still works when a PC is selected and an enemy is targeted. + +### Case F — large encounter guardrail + +Steps: + +1. With a scene that has many PCs and many hostiles such that PCs × hostile-attack-permutations would exceed 200, start combat and open the danger board. + +Expected: + +- [ ] Danger board reports "Encounter-wide risk was not computed (performance guardrail)…". +- [ ] Foundry does not freeze. +- [ ] "Open detail for selected PC + targeted enemy" still works. + +Record observed values: + +```text +PCs in combat: +Hostiles in combat: +Most endangered PCs (top 3): +Most dangerous enemies (top 3): +Detail-from-row worked?: +Detail-from-selection worked?: +Same detail window reused for multiple rows?: +Guardrail triggered?: +Unexpected issues: +``` + +--- + +## 7. PF2e actor-data debug capture + +If Grim Arithmetic cannot extract HP, AC, or Strike data, capture sanitized actor shape information. + +Open browser dev tools on the Foundry world and run these snippets. + +### Selected PC token snapshot + +```js +const pc = canvas.tokens.controlled[0]; +game.modules.get('grim-arithmetic')?.api?.captureTokenDebug?.(pc); +``` + +### Targeted NPC token snapshot + +```js +const enemy = Array.from(game.user.targets)[0]; +game.modules.get('grim-arithmetic')?.api?.captureTokenDebug?.(enemy); +``` + +### Grim Arithmetic module API check + +```js +console.log(game.modules.get('grim-arithmetic')); +game.modules.get('grim-arithmetic')?.api?.openPanel?.(); +game.modules.get('grim-arithmetic')?.api?.captureTokenDebug?.(); // defaults to first selected token +``` + +When sharing debug output, avoid posting private campaign text if any item descriptions include spoilers. The most useful data is structure/keys, attack bonuses, and damage formula shapes. + +--- + +## 8. Server-side log capture + +On the Foundry server, inspect today’s logs. Adjust path if your deployment uses a different data directory. + +```bash +grep -i "grim-arithmetic\|validation\|module" ~/foundrydata/Logs/debug.$(date +%Y-%m-%d).log | tail -120 +``` + +Useful things to capture: + +- manifest validation errors +- module load errors +- template load errors +- JavaScript import errors + +--- + +## 9. Known MVP limitations + +These are expected right now: + +- Permanent death probability is not modeled. +- Wounded and doomed affect dying severity and immediate death flags, not down chance. +- Resistance, weakness, and immunity are applied only for confidently typed Strike damage. +- Ambiguous/mixed damage types, exceptions, and bypass rules are not modeled. +- Hero Point availability is displayed/caveated but not modeled as survival probability. +- Damage uses exact dice distributions for supported formulas. +- Critical damage is simple double damage of the supported formula total. +- Resistance, weakness, immunity, deadly, fatal, precision, splash, and persistent damage are not modeled. +- Reactions such as Shield Block or Champion reactions are not modeled. +- Healing before/during enemy turn is not modeled. +- Enemy Strike selection is supported for extracted melee Strikes, but complex conditional Strike modifiers are not modeled. +- PF2e Strike extraction is hardened for common table-test shapes but may still need patching after unusual actor data review. + +--- + +## 10. Bug report template + +```md +## Grim Arithmetic Test Report + +**Date:** +**Tester:** +**Foundry version/build, e.g. v14.361:** +**PF2e system version:** +**Browser:** +**Grim Arithmetic commit:** + +### Scenario + +- PC token: +- NPC token: +- Strike/action expected: + +### Expected + + +### Actual + + +### Browser console errors + +```text +paste here +``` + +### Server log excerpts + +```text +paste here +``` + +### Sanitized actor/item shape notes + +```text +paste here +``` + +--- + +## 11. Pass/fail criteria for current build + +A passing first external test means: + +- [ ] Module installs on the Foundry server. +- [ ] Module enables in a PF2e world. +- [ ] GM sees the skull button. +- [ ] Panel opens with no selection and reports useful errors. +- [ ] Panel opens for selected PC + targeted NPC. +- [ ] If actor extraction succeeds, panel displays a down-risk estimate. +- [ ] If actor extraction fails, captured debug data is enough to patch the adapter. + +Either outcome is useful. If extraction fails, the next task is simply to update `src/systems/pf2e-adapter.ts` against real PF2e actor data for the Foundry/PF2e version under test. diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..ea28ed3 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,151 @@ +{ + "GrimArithmetic": { + "Settings": { + "DefaultStrikes": { + "Name": "Default enemy Strike count", + "Hint": "Default number of Strikes used for immediate-threat estimates.", + "Choices": { + "1": "1 Strike", + "2": "2 Strikes", + "3": "3 Strikes" + } + }, + "DebugLogging": { + "Name": "Debug logging", + "Hint": "Log Grim Arithmetic debug information to the browser console." + }, + "EnableMonteCarlo": { + "Name": "Enable Monte Carlo encounter simulation", + "Hint": "Disable on low-end machines if simulation runs are too slow. The Encounter Danger Board still works either way." + } + }, + "Window": { + "DangerBoard": "Grim Arithmetic — Encounter Danger Board", + "Forecast": "Grim Arithmetic — Encounter Forecast", + "PairDetail": "Grim Arithmetic - Pair Detail" + }, + "Common": { + "Detail": "Detail", + "Vs": "vs", + "DownTooltip": "Chance this PC is knocked out in one round by this attack.", + "RiskPillTooltip": "Risk rating from chance of being downed: Low → Grim.", + "CiTooltip": "95% confidence interval — the range the true value likely falls in given sampling variance." + }, + "DangerBoard": { + "Heading": "Grim Arithmetic v{version}", + "Tagline": "Encounter danger board — immediate mortality pressure, not fate.", + "ForecastButton": "Encounter Forecast", + "ForecastTooltip": "Run a Monte Carlo simulation of the whole encounter to forecast downs, deaths, and TPK risk.", + "DetailSelectionButton": "Detail Selection + Target", + "DetailSelectionTooltip": "Open a detailed one-attacker-vs-one-PC mortality breakdown for the selected token and target.", + "RefreshButton": "Refresh", + "RefreshTooltip": "Recompute the danger board from the current combat state.", + "TooLarge": "Encounter too large to compute a danger board safely. Reduce combatants, or use the selection + target button above for a single-pair view.", + "Empty": "No encounter-wide risk to show. Start a combat with PCs and hostile NPCs, or use the selection + target button above.", + "EndangeredTitle": "Most endangered PCs", + "EndangeredTooltip": "PCs ranked by their single-round chance of being knocked out by the most dangerous incoming attack.", + "DangerousTitle": "Most dangerous enemies", + "DangerousTooltip": "Enemy attacks ranked by their single-round chance of knocking out a PC." + }, + "Forecast": { + "Heading": "Encounter Forecast", + "Tagline": "Monte Carlo simulation of the active encounter under explicit assumptions.", + "TacticsLabel": "Tactics Profile", + "TacticsTooltip": "How simulated enemies pick targets and actions each round — changes who gets attacked and how focused the damage is.", + "RunButton": "Forecast", + "CancelButton": "Cancel", + "Progress": "Running iteration {completed} of {total} ({percent}%)…", + "FailedHeading": "Forecast failed", + "PessimismLead": "High-risk encounter.", + "CiNote": "Bracketed values are 95% confidence intervals from sampling variance.", + "Aborted": "aborted", + "RoundPrefix": "round", + "AnyPcDownLabel": "Any PC down", + "AnyPcDownTooltip": "Chance at least one PC is knocked out (0 HP) at some point in the encounter.", + "TpkLabel": "TPK risk", + "TpkTooltip": "Total Party Kill — chance the entire party is downed during the encounter.", + "FirstDownLabel": "Expected first down", + "FirstDownTooltip": "Average combat round in which the first PC is knocked out, across all simulated runs.", + "HealsLabel": "Heals per run", + "HealsTooltip": "Average number of healing actions the party spends per simulated encounter.", + "RecoveryLabel": "Recovery checks per run", + "RecoveryTooltip": "Average number of PF2e recovery checks (rolled while dying) per simulated encounter.", + "HeroPointLabel": "Hero Point saves", + "HeroPointTooltip": "Share of runs where spending a Hero Point to avoid death changed a PC's outcome.", + "MostLikelyToDrop": "Most likely to drop", + "BiggestThreats": "Biggest threats", + "ModeledAssumptions": "Modeled assumptions", + "Table": { + "Pc": "PC", + "Down": "Down", + "DownTooltip": "Chance this PC is knocked out at least once during the encounter.", + "Death": "Death", + "DeathTooltip": "Chance this PC dies during the encounter.", + "ExpectedHp": "Expected HP", + "ExpectedHpTooltip": "This PC's average ending HP across all runs.", + "BiggestThreat": "Biggest threat", + "BiggestThreatTooltip": "The enemy that dealt this PC the most damage on average.", + "Risk": "Risk", + "RiskTooltip": "Overall danger rating for this PC, derived from down/death chance.", + "Enemy": "Enemy", + "DamageShare": "Damage share", + "DamageShareTooltip": "Percentage of the party's total damage taken that this enemy dealt.", + "TopTarget": "Top target", + "TopTargetTooltip": "The PC this enemy attacked most often." + } + }, + "PairDetail": { + "Heading": "Pair Detail", + "Tagline": "Immediate mortality pressure, not fate.", + "EnemyStrikeLabel": "Enemy Strike", + "EnemyStrikeTooltip": "Which of the enemy's attacks to model.", + "EnemyTurnLabel": "Enemy turn", + "EnemyTurnTooltip": "How many Strikes the enemy makes this turn (drives the multiple-attack penalty).", + "MapLabel": "MAP", + "MapTooltip": "Multiple Attack Penalty — how the −5/−10 penalty on later Strikes is applied.", + "ShieldLabel": "Shield / AC adjustment", + "ShieldTooltip": "Temporary AC change to model, e.g. Raise a Shield.", + "WoundedLabel": "Wounded display", + "WoundedTooltip": "Override the PC's PF2e Wounded value used in dying math.", + "HeroPointLabel": "Hero Point prevention", + "HeroPointTooltip": "Whether a Hero Point is assumed available to avoid death.", + "RefreshButton": "Refresh / Recalculate", + "NeedsAttention": "Needs attention", + "EnemyStrikeTermTooltip": "The enemy attack being modeled — its to-hit bonus and damage formula.", + "EnemyStrikeValue": "{name} — +{bonus}, {formula}", + "ModeledHp": "Modeled HP", + "ModeledHpTooltip": "The PC's HP used here, including temporary HP.", + "EffectiveAc": "Effective AC", + "EffectiveAcTooltip": "The PC's AC after the shield/AC adjustment above.", + "Wounded": "Wounded", + "WoundedTermTooltip": "The PF2e Wounded value applied — it raises the dying counter when knocked out.", + "DamageRange": "Damage range", + "DamageRangeTooltip": "Min–max damage on a normal hit and on a critical hit.", + "DamageRangeValue": "{min}–{max} normal, {critMin}–{critMax} crit", + "AverageDamage": "Average damage", + "AverageDamageTooltip": "Mean damage per hit.", + "DamageSwing": "Damage swing", + "DamageSwingTooltip": "How variable the damage is — higher means less predictable.", + "DamageAdjustment": "Damage adjustment", + "DamageAdjustmentTooltip": "Modifiers applied to raw damage (resistances, weaknesses, etc.).", + "DownChance": "Down chance", + "DownChanceTooltip": "Chance this attack sequence knocks the PC out this turn.", + "ExpectedHpAfterTurn": "Expected HP after turn", + "ExpectedHpAfterTurnTooltip": "The PC's average remaining HP after the modeled enemy turn.", + "DyingIfDowned": "Dying if downed", + "DyingIfDownedTooltip": "The PF2e Dying value the PC starts at if knocked out — normal hit vs crit.", + "DyingIfDownedValue": "Normal hit: Dying {normal}; crit: Dying {crit}", + "DeathThreshold": "Death threshold", + "DeathThresholdTooltip": "The Dying value at which the PC dies, accounting for Doomed.", + "DeathThresholdValue": "Dying {threshold} after Doomed {doomed}", + "ImmediateDeathFlag": "Immediate death flag", + "ImmediateDeathFlagTooltip": "Whether the attack can kill the PC outright (massive/instant death).", + "HeroPointNote": "Hero Point note", + "HeroPointNoteTooltip": "How a Hero Point would affect this outcome.", + "StrikeChances": "Strike chances", + "StrikeLine": "Strike {index}: hit {hit}%, crit {crit}%", + "Assumptions": "Assumptions", + "NotModeledYet": "Not modeled yet" + } + } +} diff --git a/module.json b/module.json index 449e2a5..aa1a627 100644 --- a/module.json +++ b/module.json @@ -5,7 +5,8 @@ "version": "0.7.1-rc3", "authors": [ { - "name": "Kyle Travis" + "name": "Kyle Travis", + "url": "https://kyletravis.com" } ], "compatibility": { @@ -26,6 +27,13 @@ "styles": [ "styles/grim-arithmetic.css" ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], "url": "https://github.com/kyletravis/grim-arithmetic", "manifest": "https://github.com/kyletravis/grim-arithmetic/releases/latest/download/module.json", "download": "https://github.com/kyletravis/grim-arithmetic/releases/download/v0.7.1-rc3/grim-arithmetic-v0.7.1-rc3.zip" diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs index 60689de..60f329e 100644 --- a/scripts/build-release.mjs +++ b/scripts/build-release.mjs @@ -19,6 +19,7 @@ const requiredPaths = [ 'templates/danger-board-panel.hbs', 'templates/pair-detail-panel.hbs', 'templates/forecast-panel.hbs', + 'lang/en.json', 'README.md', 'LICENSE', 'CHANGELOG.md' @@ -43,6 +44,7 @@ execFileSync('rsync', [ join(root, 'dist'), join(root, 'styles'), join(root, 'templates'), + join(root, 'lang'), stagingDir ]); diff --git a/src/settings.ts b/src/settings.ts index 286b3e1..5ab258e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,22 +4,22 @@ export const ENABLE_MONTE_CARLO_SETTING = 'enableMonteCarlo'; export function registerSettings(): void { game.settings.register(MODULE_ID, 'defaultStrikes', { - name: 'Default enemy Strike count', - hint: 'Default number of Strikes used for immediate-threat estimates.', + name: 'GrimArithmetic.Settings.DefaultStrikes.Name', + hint: 'GrimArithmetic.Settings.DefaultStrikes.Hint', scope: 'world', config: true, type: Number, default: 2, choices: { - 1: '1 Strike', - 2: '2 Strikes', - 3: '3 Strikes' + 1: 'GrimArithmetic.Settings.DefaultStrikes.Choices.1', + 2: 'GrimArithmetic.Settings.DefaultStrikes.Choices.2', + 3: 'GrimArithmetic.Settings.DefaultStrikes.Choices.3' } }); game.settings.register(MODULE_ID, 'debugLogging', { - name: 'Debug logging', - hint: 'Log Grim Arithmetic debug information to the browser console.', + name: 'GrimArithmetic.Settings.DebugLogging.Name', + hint: 'GrimArithmetic.Settings.DebugLogging.Hint', scope: 'client', config: true, type: Boolean, @@ -27,8 +27,8 @@ export function registerSettings(): void { }); game.settings.register(MODULE_ID, ENABLE_MONTE_CARLO_SETTING, { - name: 'Enable Monte Carlo encounter simulation', - hint: 'Disable on low-end machines if simulation runs are too slow. The Encounter Danger Board still works either way.', + name: 'GrimArithmetic.Settings.EnableMonteCarlo.Name', + hint: 'GrimArithmetic.Settings.EnableMonteCarlo.Hint', scope: 'client', config: true, type: Boolean, diff --git a/src/ui/danger-board-panel.ts b/src/ui/danger-board-panel.ts index 35bcad7..4096727 100644 --- a/src/ui/danger-board-panel.ts +++ b/src/ui/danger-board-panel.ts @@ -2,7 +2,7 @@ import { computeEncounterRiskMatrix, MAX_PAIRS } from '../engine/encounter-risk' import { getEncounterParticipants } from '../foundry/encounter-participants'; import { isMonteCarloEnabled } from '../settings'; import { Pf2eAdapter } from '../systems/pf2e-adapter'; -import { MODULE_ID, MODULE_TITLE, MODULE_VERSION } from '../constants'; +import { MODULE_ID, MODULE_VERSION } from '../constants'; import { buildDangerBoardData, DangerBoardData } from './danger-board'; import { ForecastPanel } from './forecast-panel'; import { DEFAULT_PANEL_CONTROLS } from './panel-data'; @@ -33,7 +33,7 @@ export class DangerBoardPanel extends Base { classes: ['grim-arithmetic-window'], tag: 'section', window: { - title: `${MODULE_TITLE} — Encounter Danger Board`, + title: 'GrimArithmetic.Window.DangerBoard', resizable: true }, position: { diff --git a/src/ui/forecast-panel.ts b/src/ui/forecast-panel.ts index 7d50ffd..e30f7a7 100644 --- a/src/ui/forecast-panel.ts +++ b/src/ui/forecast-panel.ts @@ -4,7 +4,7 @@ import { DEFAULT_MAX_ROUNDS } from '../engine/simulation-types'; import { buildEncounterSetup } from '../foundry/encounter-setup'; import { isMonteCarloEnabled } from '../settings'; import { Pf2eAdapter } from '../systems/pf2e-adapter'; -import { MODULE_ID, MODULE_TITLE, MODULE_VERSION } from '../constants'; +import { MODULE_ID, MODULE_VERSION } from '../constants'; import { buildForecastPanelData, DEFAULT_SIMULATION_CONTROLS, @@ -37,7 +37,7 @@ export class ForecastPanel extends Base { classes: ['grim-arithmetic-window'], tag: 'section', window: { - title: `${MODULE_TITLE} — Encounter Forecast`, + title: 'GrimArithmetic.Window.Forecast', resizable: true }, position: { diff --git a/src/ui/pair-detail-panel.ts b/src/ui/pair-detail-panel.ts index d00ebb4..84b7286 100644 --- a/src/ui/pair-detail-panel.ts +++ b/src/ui/pair-detail-panel.ts @@ -1,6 +1,6 @@ import { getCurrentTokenSelection, TokenSelectionResult } from '../foundry/selection'; import { Pf2eAdapter } from '../systems/pf2e-adapter'; -import { MODULE_ID, MODULE_TITLE, MODULE_VERSION } from '../constants'; +import { MODULE_ID, MODULE_VERSION } from '../constants'; import { buildMortalityPanelData, DEFAULT_PANEL_CONTROLS, @@ -34,7 +34,7 @@ export class PairDetailPanel extends Base { classes: ['grim-arithmetic-window'], tag: 'section', window: { - title: `${MODULE_TITLE} - Pair Detail`, + title: 'GrimArithmetic.Window.PairDetail', resizable: true }, position: { diff --git a/templates/danger-board-panel.hbs b/templates/danger-board-panel.hbs index 1645533..319f66e 100644 --- a/templates/danger-board-panel.hbs +++ b/templates/danger-board-panel.hbs @@ -1,69 +1,69 @@
-

Grim Arithmetic v{{moduleVersion}}

-

Encounter danger board — immediate mortality pressure, not fate.

+

{{localize "GrimArithmetic.DangerBoard.Heading" version=moduleVersion}}

+

{{localize "GrimArithmetic.DangerBoard.Tagline"}}

{{message}}

{{#if forecastEnabled}} - + {{/if}} - - + +
{{#if dangerBoard.skipped}}

- Encounter too large to compute a danger board safely. Reduce combatants, or use the selection + target button above for a single-pair view. + {{localize "GrimArithmetic.DangerBoard.TooLarge"}}

{{else if dangerBoard.empty}}

- No encounter-wide risk to show. Start a combat with PCs and hostile NPCs, or use the selection + target button above. + {{localize "GrimArithmetic.DangerBoard.Empty"}}

{{else}} -

Most endangered PCs

+

{{localize "GrimArithmetic.DangerBoard.EndangeredTitle"}}

    {{#each dangerBoard.topEndangeredPcs}}
  1. {{pcName}} - vs + {{localize "GrimArithmetic.Common.Vs"}} {{enemyName}} {{attackName}} - {{downPercent}}% - {{riskLabel}} + {{downPercent}}% + {{riskLabel}} + data-grim-attack-id="{{attackId}}">{{localize "GrimArithmetic.Common.Detail"}}
  2. {{/each}}
-

Most dangerous enemies

+

{{localize "GrimArithmetic.DangerBoard.DangerousTitle"}}

    {{#each dangerBoard.topDangerousEnemies}}
  1. {{enemyName}} {{attackName}} - vs + {{localize "GrimArithmetic.Common.Vs"}} {{pcName}} - {{downPercent}}% - {{riskLabel}} + {{downPercent}}% + {{riskLabel}} + data-grim-attack-id="{{attackId}}">{{localize "GrimArithmetic.Common.Detail"}}
  2. {{/each}}
diff --git a/templates/forecast-panel.hbs b/templates/forecast-panel.hbs index 61b5c8e..fa25e13 100644 --- a/templates/forecast-panel.hbs +++ b/templates/forecast-panel.hbs @@ -1,7 +1,7 @@
-

Encounter Forecast

-

Monte Carlo simulation of the active encounter under explicit assumptions.

+

{{localize "GrimArithmetic.Forecast.Heading"}}

+

{{localize "GrimArithmetic.Forecast.Tagline"}}

{{#unless enabled}} @@ -11,7 +11,7 @@