Skip to main content
Glama

wound_calculator

Calculate expected wounds and damage for a Warhammer 40,000 attack sequence by entering attack profile and target stats to get hit, wound, save, and damage probabilities.

Instructions

Calculate expected wounds and damage for a Warhammer 40,000 attack sequence. Pure math — input an attack profile and target stats, get probabilities and expected results for hits, wounds, saves, and damage.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
attacksYesNumber of attacks
hit_skillYesBallistic Skill or Weapon Skill needed (e.g., 3 for 3+)
strengthYesWeapon strength
toughnessYesTarget toughness
armour_saveYesTarget's armour save (e.g., 3 for 3+, 7 for no save)
damageYesDamage value (e.g., '1', '2', 'D3', 'D6', 'D6+1', '2D6')
armour_penetrationNoAP value as a positive number (e.g., 2 for AP-2)
invulnerable_saveNoInvulnerable save (e.g., 4 for 4++)
feel_no_painNoFeel No Pain value (e.g., 5 for 5+++)
reroll_hitsNoRe-roll hit rolls: 'ones' = re-roll 1s, 'all' = re-roll all misses
reroll_woundsNoRe-roll wound rolls: 'ones' = re-roll 1s, 'all' = re-roll all misses
weapon_keywordsNoWeapon keywords that affect the calculation (e.g., ['Lethal Hits', 'Sustained Hits 1', 'Devastating Wounds', 'Torrent', 'Twin-linked'])
wounds_per_modelNoWounds characteristic of each target model (for models killed estimate)
game_modeNoGame mode (currently only 40k wound math is supported)40k

Implementation Reference

  • Registration of the wound_calculator tool via server.tool('wound_calculator', ...) with full Zod schema and handler callback.
    export function registerWoundCalculator(server: McpServer): void {
      server.tool(
        "wound_calculator",
        "Calculate expected wounds and damage for a Warhammer 40,000 attack sequence. Pure math — input an attack profile and target stats, get probabilities and expected results for hits, wounds, saves, and damage.",
        {
          attacks: z.number().min(1).describe("Number of attacks"),
          hit_skill: z
            .number()
            .min(2)
            .max(6)
            .describe(
              "Ballistic Skill or Weapon Skill needed (e.g., 3 for 3+)",
            ),
          strength: z.number().min(1).describe("Weapon strength"),
          toughness: z.number().min(1).describe("Target toughness"),
          armour_save: z
            .number()
            .min(2)
            .max(7)
            .describe("Target's armour save (e.g., 3 for 3+, 7 for no save)"),
          damage: z
            .string()
            .describe("Damage value (e.g., '1', '2', 'D3', 'D6', 'D6+1', '2D6')"),
          armour_penetration: z
            .number()
            .min(0)
            .max(6)
            .optional()
            .default(0)
            .describe("AP value as a positive number (e.g., 2 for AP-2)"),
          invulnerable_save: z
            .number()
            .min(2)
            .max(6)
            .optional()
            .describe("Invulnerable save (e.g., 4 for 4++)"),
          feel_no_pain: z
            .number()
            .min(2)
            .max(6)
            .optional()
            .describe("Feel No Pain value (e.g., 5 for 5+++)"),
          reroll_hits: z
            .enum(["ones", "all"])
            .optional()
            .describe("Re-roll hit rolls: 'ones' = re-roll 1s, 'all' = re-roll all misses"),
          reroll_wounds: z
            .enum(["ones", "all"])
            .optional()
            .describe("Re-roll wound rolls: 'ones' = re-roll 1s, 'all' = re-roll all misses"),
          weapon_keywords: z
            .array(z.string())
            .optional()
            .describe(
              "Weapon keywords that affect the calculation (e.g., ['Lethal Hits', 'Sustained Hits 1', 'Devastating Wounds', 'Torrent', 'Twin-linked'])",
            ),
          wounds_per_model: z
            .number()
            .min(1)
            .optional()
            .describe("Wounds characteristic of each target model (for models killed estimate)"),
          game_mode: z
            .enum(["40k", "combat_patrol", "kill_team"])
            .optional()
            .default("40k")
            .describe("Game mode (currently only 40k wound math is supported)"),
        },
        async (args) => {
          if (args.game_mode === "kill_team") {
            return {
              content: [
                {
                  type: "text" as const,
                  text: "Kill Team uses a different wound/defence system (attack dice vs defence dice). This calculator currently supports Warhammer 40,000 only.",
                },
              ],
            };
          }
    
          const result = calculateWounds({
            attacks: args.attacks,
            hitSkill: args.hit_skill,
            strength: args.strength,
            toughness: args.toughness,
            armourSave: args.armour_save,
            armourPenetration: args.armour_penetration,
            damage: args.damage,
            invulnerableSave: args.invulnerable_save,
            feelNoPain: args.feel_no_pain,
            rerollHits: args.reroll_hits,
            rerollWounds: args.reroll_wounds,
            weaponKeywords: args.weapon_keywords,
            woundsPerModel: args.wounds_per_model,
          });
    
          const text = formatResult(args, result);
          return {
            content: [{ type: "text" as const, text }],
          };
        },
      );
    }
  • Zod input schema for wound_calculator: defines all parameters (attacks, hit_skill, strength, toughness, armour_save, damage, armour_penetration, invulnerable_save, feel_no_pain, reroll_hits, reroll_wounds, weapon_keywords, wounds_per_model, game_mode).
    {
      attacks: z.number().min(1).describe("Number of attacks"),
      hit_skill: z
        .number()
        .min(2)
        .max(6)
        .describe(
          "Ballistic Skill or Weapon Skill needed (e.g., 3 for 3+)",
        ),
      strength: z.number().min(1).describe("Weapon strength"),
      toughness: z.number().min(1).describe("Target toughness"),
      armour_save: z
        .number()
        .min(2)
        .max(7)
        .describe("Target's armour save (e.g., 3 for 3+, 7 for no save)"),
      damage: z
        .string()
        .describe("Damage value (e.g., '1', '2', 'D3', 'D6', 'D6+1', '2D6')"),
      armour_penetration: z
        .number()
        .min(0)
        .max(6)
        .optional()
        .default(0)
        .describe("AP value as a positive number (e.g., 2 for AP-2)"),
      invulnerable_save: z
        .number()
        .min(2)
        .max(6)
        .optional()
        .describe("Invulnerable save (e.g., 4 for 4++)"),
      feel_no_pain: z
        .number()
        .min(2)
        .max(6)
        .optional()
        .describe("Feel No Pain value (e.g., 5 for 5+++)"),
      reroll_hits: z
        .enum(["ones", "all"])
        .optional()
        .describe("Re-roll hit rolls: 'ones' = re-roll 1s, 'all' = re-roll all misses"),
      reroll_wounds: z
        .enum(["ones", "all"])
        .optional()
        .describe("Re-roll wound rolls: 'ones' = re-roll 1s, 'all' = re-roll all misses"),
      weapon_keywords: z
        .array(z.string())
        .optional()
        .describe(
          "Weapon keywords that affect the calculation (e.g., ['Lethal Hits', 'Sustained Hits 1', 'Devastating Wounds', 'Torrent', 'Twin-linked'])",
        ),
      wounds_per_model: z
        .number()
        .min(1)
        .optional()
        .describe("Wounds characteristic of each target model (for models killed estimate)"),
      game_mode: z
        .enum(["40k", "combat_patrol", "kill_team"])
        .optional()
        .default("40k")
        .describe("Game mode (currently only 40k wound math is supported)"),
    },
  • Handler function that validates game_mode, calls calculateWounds() from the math engine, then formats and returns the result as text.
      async (args) => {
        if (args.game_mode === "kill_team") {
          return {
            content: [
              {
                type: "text" as const,
                text: "Kill Team uses a different wound/defence system (attack dice vs defence dice). This calculator currently supports Warhammer 40,000 only.",
              },
            ],
          };
        }
    
        const result = calculateWounds({
          attacks: args.attacks,
          hitSkill: args.hit_skill,
          strength: args.strength,
          toughness: args.toughness,
          armourSave: args.armour_save,
          armourPenetration: args.armour_penetration,
          damage: args.damage,
          invulnerableSave: args.invulnerable_save,
          feelNoPain: args.feel_no_pain,
          rerollHits: args.reroll_hits,
          rerollWounds: args.reroll_wounds,
          weaponKeywords: args.weapon_keywords,
          woundsPerModel: args.wounds_per_model,
        });
    
        const text = formatResult(args, result);
        return {
          content: [{ type: "text" as const, text }],
        };
      },
    );
  • formatResult() helper that builds the human-readable markdown output from the calculation results (attack profile, target line, results table, key interactions).
    function formatResult(
      args: {
        attacks: number;
        hit_skill: number;
        strength: number;
        toughness: number;
        armour_save: number;
        armour_penetration?: number;
        damage: string;
        invulnerable_save?: number;
        feel_no_pain?: number;
        reroll_hits?: "ones" | "all";
        reroll_wounds?: "ones" | "all";
        weapon_keywords?: string[];
        wounds_per_model?: number;
      },
      r: ReturnType<typeof calculateWounds>,
    ): string {
      const ap = args.armour_penetration ?? 0;
      const modifiedSave = args.armour_save + ap;
      const effectiveSave =
        args.invulnerable_save !== undefined
          ? Math.min(modifiedSave, args.invulnerable_save)
          : modifiedSave;
      const effectiveSaveStr =
        effectiveSave > 6 ? "none (auto-fail)" : `${effectiveSave}+`;
    
      const isTorrent = (args.weapon_keywords ?? []).some(
        (kw) => kw.toLowerCase() === "torrent",
      );
    
      // Attack profile line
      const hitStr = isTorrent ? "auto-hit" : `BS/WS ${args.hit_skill}+`;
      const apStr = ap > 0 ? ` AP-${ap}` : " AP0";
      const profileLine = `${args.attacks} attacks | ${hitStr} | S${args.strength}${apStr} D${args.damage}`;
    
      // Target line
      let targetLine = `vs T${args.toughness} Sv${args.armour_save}+`;
      if (ap > 0 && modifiedSave <= 6) {
        targetLine += ` (modified to ${modifiedSave}+)`;
      } else if (ap > 0) {
        targetLine += ` (modified to auto-fail)`;
      }
      if (args.invulnerable_save !== undefined) {
        targetLine += ` | ${args.invulnerable_save}++ invuln`;
      }
      if (args.feel_no_pain !== undefined) {
        targetLine += ` | ${args.feel_no_pain}+++ FNP`;
      }
      if (args.wounds_per_model !== undefined) {
        targetLine += ` | ${args.wounds_per_model}W per model`;
      }
    
      // Results table
      const pctHit = (r.hitProbability * 100).toFixed(1);
      const pctWound = (r.woundProbability * 100).toFixed(1);
      const pctSaveFail = (r.saveProbability * 100).toFixed(1);
    
      const rows = [
        `| Hits | ${r.expectedHits.toFixed(2)} | ${pctHit}% |`,
        `| Wounds | ${r.expectedWounds.toFixed(2)} | ${pctWound}% |`,
        `| Unsaved wounds | ${r.expectedUnsaved.toFixed(2)} | ${pctSaveFail}% |`,
        `| Damage dealt | ${r.expectedDamage.toFixed(2)} | — |`,
      ];
    
      if (r.expectedModelsKilled !== null) {
        rows.push(`| Models killed | ~${r.expectedModelsKilled} | — |`);
      }
    
      // Key Interactions
      const interactions: string[] = [];
    
      if (ap > 0) {
        if (
          args.invulnerable_save !== undefined &&
          args.invulnerable_save < modifiedSave
        ) {
          interactions.push(
            `AP-${ap} modifies ${args.armour_save}+ save to ${modifiedSave > 6 ? "auto-fail" : `${modifiedSave}+`}, but ${args.invulnerable_save}++ invulnerable save is better (effective save: ${effectiveSaveStr})`,
          );
        } else {
          interactions.push(
            `AP-${ap} modifies ${args.armour_save}+ save to ${modifiedSave > 6 ? "auto-fail" : `${modifiedSave}+`}`,
          );
        }
      } else if (args.invulnerable_save !== undefined) {
        if (args.invulnerable_save < args.armour_save) {
          interactions.push(
            `${args.invulnerable_save}++ invulnerable save is better than ${args.armour_save}+ armour save`,
          );
        } else {
          interactions.push(
            `Armour save (${args.armour_save}+) is better than or equal to invulnerable save (${args.invulnerable_save}++)`,
          );
        }
      } else {
        interactions.push("No invulnerable save applies");
      }
    
      if (isTorrent) {
        interactions.push("Torrent: all attacks auto-hit (no hit roll)");
      }
    
      const keywords = args.weapon_keywords ?? [];
      for (const kw of keywords) {
        const sustained = kw.match(/sustained\s+hits?\s+(\d+)/i);
        if (sustained) {
          interactions.push(
            `Sustained Hits ${sustained[1]}: unmodified 6s to hit generate ${sustained[1]} extra hit(s)`,
          );
        }
        if (kw.toLowerCase() === "lethal hits") {
          interactions.push(
            "Lethal Hits: unmodified 6s to hit auto-wound (skip wound roll)",
          );
        }
        if (kw.toLowerCase() === "devastating wounds") {
          interactions.push(
            `Devastating Wounds: unmodified 6s to wound become mortal wounds (~${r.mortalWoundDamage.toFixed(2)} mortal wound damage)`,
          );
        }
        if (kw.toLowerCase() === "twin-linked") {
          interactions.push("Twin-linked: re-roll all failed wound rolls");
        }
      }
    
      if (args.reroll_hits) {
        interactions.push(
          `Re-roll ${args.reroll_hits === "ones" ? "hit rolls of 1" : "all failed hit rolls"}`,
        );
      }
      if (args.reroll_wounds) {
        interactions.push(
          `Re-roll ${args.reroll_wounds === "ones" ? "wound rolls of 1" : "all failed wound rolls"}`,
        );
      }
      if (args.feel_no_pain !== undefined) {
        const fnpPassPct = (((7 - args.feel_no_pain) / 6) * 100).toFixed(1);
        interactions.push(
          `Feel No Pain ${args.feel_no_pain}+++ ignores ${fnpPassPct}% of damage`,
        );
      }
    
      const avgDmg = parseDamage(args.damage);
      if (avgDmg !== parseInt(args.damage, 10)) {
        interactions.push(
          `Variable damage ${args.damage} averages ${avgDmg.toFixed(1)} per wound`,
        );
      }
    
      const lines = [
        `# Wound Calculator — Warhammer 40,000`,
        ``,
        `## Attack Profile`,
        profileLine,
        targetLine,
        ``,
        `## Results`,
        `| Step | Expected | Per-attack % |`,
        `|------|----------|-------------|`,
        ...rows,
        ``,
        `## Key Interactions`,
        ...interactions.map((i) => `- ${i}`),
      ];
    
      return lines.join("\n");
    }
  • Core math engine calculateWounds() implementing the full 40K wound sequence: hit rolls (with Torrent, Sustained Hits, Lethal Hits, re-rolls), wound rolls (with Devastating Wounds, Twin-linked), save rolls (with AP, invulnerable saves), damage, Feel No Pain, and models killed estimation.
    export function calculateWounds(input: WoundCalcInput): WoundCalcResult {
      const {
        attacks,
        hitSkill,
        strength,
        toughness,
        armourSave,
        damage,
        invulnerableSave,
        feelNoPain,
        woundsPerModel,
      } = input;
      const ap = input.armourPenetration ?? 0;
      const rerollHits = input.rerollHits;
      const keywords = input.weaponKeywords ?? [];
    
      const isTorrent = hasKeyword(keywords, "Torrent");
      const isLethalHits = hasKeyword(keywords, "Lethal Hits");
      const isDevastatingWounds = hasKeyword(keywords, "Devastating Wounds");
      const sustainedN = parseSustainedHits(keywords);
      const isTwinLinked = hasKeyword(keywords, "Twin-linked");
    
      // Determine re-roll policies
      const effectiveRerollWounds =
        isTwinLinked ? "all" : (input.rerollWounds ?? undefined);
    
      // === Step 1: Hits ===
      let hitProb: number;
      let expectedHits: number;
      let lethalHitCount = 0;
      let sustainedExtraHits = 0;
    
      if (isTorrent) {
        // Auto-hits — no hit roll
        hitProb = 1;
        expectedHits = attacks;
        // No natural 6s to hit since there's no hit roll — Lethal Hits and Sustained Hits don't trigger
      } else {
        const baseHitProb = probOfTarget(hitSkill);
        hitProb = probWithReroll(baseHitProb, rerollHits);
    
        // Natural 6s to hit (before re-rolls affect 6-counting)
        // P(rolling a natural 6) from original rolls = 1/6
        // From re-rolled dice (1s or misses), the chance of getting a 6 is also 1/6
        // Total expected natural 6s:
        let expected6sToHit: number;
        if (!rerollHits) {
          expected6sToHit = attacks * (1 / 6);
        } else if (rerollHits === "ones") {
          // Original 6s (1/6 of attacks) + rerolled 1s that become 6s (1/6 * 1/6 of attacks)
          expected6sToHit = attacks * (1 / 6 + (1 / 6) * (1 / 6));
        } else {
          // "all" — Original 6s + rerolled misses that become 6s
          // Misses on first roll = (hitSkill - 1) / 6
          const missProb = (hitSkill - 1) / 6;
          expected6sToHit = attacks * (1 / 6 + missProb * (1 / 6));
        }
    
        // Sustained Hits: each natural 6 generates N extra hits (auto-hit, go to wound step)
        if (sustainedN > 0) {
          sustainedExtraHits = expected6sToHit * sustainedN;
        }
    
        // Lethal Hits: natural 6s to hit auto-wound (skip wound roll)
        if (isLethalHits) {
          lethalHitCount = expected6sToHit;
        }
    
        // Normal hits (not counting lethal hits which bypass wound roll)
        expectedHits = attacks * hitProb + sustainedExtraHits;
      }
    
      // === Step 2: Wounds ===
      const wTarget = woundTarget(strength, toughness);
      const baseWoundProb = probOfTarget(wTarget);
      const woundProb = probWithReroll(baseWoundProb, effectiveRerollWounds);
    
      // Hits that go through wound roll = total hits minus lethal hits (which skip wound roll)
      let hitsToWoundRoll: number;
      if (isLethalHits && !isTorrent) {
        // Lethal hits skip wound roll entirely; the rest go through normally
        hitsToWoundRoll = expectedHits - lethalHitCount;
      } else {
        hitsToWoundRoll = expectedHits;
      }
    
      const woundsFromRolling = hitsToWoundRoll * woundProb;
      const woundsFromLethal = lethalHitCount; // auto-wound, no roll needed
    
      // Devastating Wounds: natural 6s to wound become mortal wounds (bypass saves)
      // Only applies to wounds that are actually rolled — not lethal hits
      let devastatingWoundCount = 0;
      if (isDevastatingWounds && hitsToWoundRoll > 0) {
        // Expected natural 6s among wound rolls
        // With re-rolls, we need: expected 6s from initial rolls + expected 6s from rerolled dice
        let expected6sToWound: number;
        if (!effectiveRerollWounds) {
          expected6sToWound = hitsToWoundRoll * (1 / 6);
        } else if (effectiveRerollWounds === "ones") {
          expected6sToWound =
            hitsToWoundRoll * (1 / 6 + (1 / 6) * (1 / 6));
        } else {
          const woundMissProb = (wTarget - 1) / 6;
          expected6sToWound =
            hitsToWoundRoll * (1 / 6 + woundMissProb * (1 / 6));
        }
        devastatingWoundCount = expected6sToWound;
      }
    
      // Total wounds = rolled wounds (minus devastating) + lethal hits + devastating
      const normalWounds =
        woundsFromRolling - devastatingWoundCount + woundsFromLethal;
      const totalWounds = normalWounds + devastatingWoundCount;
    
      // === Step 3: Saves ===
      const modifiedArmourSave = armourSave + ap;
      let effectiveSave: number;
      if (invulnerableSave !== undefined) {
        // Use the better (lower) of modified armour save or invuln
        effectiveSave = Math.min(modifiedArmourSave, invulnerableSave);
      } else {
        effectiveSave = modifiedArmourSave;
      }
    
      // Clamp: if effective save > 6, it auto-fails
      const savePassProb = effectiveSave <= 6 ? probOfTarget(effectiveSave) : 0;
      const saveFailProb = 1 - savePassProb;
    
      // Normal wounds go through saves; devastating wounds bypass saves
      const unsavedFromNormal = normalWounds * saveFailProb;
      const unsavedFromDevastating = devastatingWoundCount; // bypass saves entirely
      const expectedUnsaved = unsavedFromNormal + unsavedFromDevastating;
    
      // === Step 4: Damage ===
      const avgDamage = parseDamage(damage);
      const rawDamage = expectedUnsaved * avgDamage;
    
      // Devastating wound damage calculated separately (but already included in unsaved)
      const mortalWoundDamage = unsavedFromDevastating * avgDamage;
    
      // === Step 5: Feel No Pain ===
      let fnpFailProb = 1;
      if (feelNoPain !== undefined) {
        const fnpPassProb = probOfTarget(feelNoPain);
        fnpFailProb = 1 - fnpPassProb;
      }
      const expectedDamage = rawDamage * fnpFailProb;
    
      // === Step 6: Models killed ===
      let expectedModelsKilled: number | null = null;
      if (woundsPerModel !== undefined) {
        expectedModelsKilled = Math.floor(expectedDamage / woundsPerModel);
      }
    
      return {
        expectedHits: roundTo(expectedHits, 2),
        expectedWounds: roundTo(totalWounds, 2),
        expectedUnsaved: roundTo(expectedUnsaved, 2),
        expectedDamage: roundTo(expectedDamage, 2),
        expectedModelsKilled,
        mortalWoundDamage: roundTo(mortalWoundDamage, 2),
        hitProbability: roundTo(hitProb, 4),
        woundProbability: roundTo(woundProb, 4),
        saveProbability: roundTo(saveFailProb, 4),
        fnpFailProbability: roundTo(fnpFailProb, 4),
      };
    }
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations provided, so description carries full burden. Only states 'Pure math' and lists output topics. No disclosure of calculation method, accuracy, performance, or side effects. Minimal behavioral insight beyond function.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Description is two sentences, front-loads the main purpose, and every word adds value. No filler or repetition.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

No output schema exists, so description should clarify return format. Merely says 'get probabilities and expected results' without structure details. Also omits that game_mode currently only supports '40k'. Incomplete given 14 parameters and no annotations.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100% with clear per-parameter descriptions. The tool description does not add additional meaning beyond summarizing input as 'attack profile and target stats'. Baseline of 3 applies.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states 'Calculate expected wounds and damage for a Warhammer 40,000 attack sequence' with specific verb 'calculate', resource 'attack sequence', and output scope. It clearly differentiates from sibling lookup/comparison tools.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies use for probabilistic damage calculations via 'Pure math' but does not explicitly state when to use versus alternatives or when not to use. No exclusion criteria or prerequisites mentioned.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gregario/warhammer-oracle'

If you have feedback or need assistance with the MCP directory API, please join our Discord server