Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/foundry.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Foundry module

on:
push:
paths: ["foundry/**", "web/jackforge.template.html", ".github/workflows/foundry.yml"]
pull_request:
paths: ["foundry/**", "web/jackforge.template.html", ".github/workflows/foundry.yml"]
release:
types: [published]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Engine tests
run: node --test foundry/tests/*.test.mjs
- name: Validate manifest and language file
run: |
node -e 'JSON.parse(require("fs").readFileSync("foundry/module.json"))'
node -e 'JSON.parse(require("fs").readFileSync("foundry/lang/en.json"))'

package:
needs: test
if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'foundry-v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Stamp version and download URL from the tag
run: |
VERSION="${GITHUB_REF_NAME#foundry-v}"
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("foundry/module.json"));
m.version = process.argv[1];
m.download = `https://github.com/${process.env.GITHUB_REPOSITORY}/releases/download/${process.env.GITHUB_REF_NAME}/jackforge.zip`;
fs.writeFileSync("foundry/module.json", JSON.stringify(m, null, 2) + "\n");
' "$VERSION"
- name: Build jackforge.zip
run: |
cp LICENSE foundry/LICENSE
cd foundry
zip -r ../jackforge.zip module.json LICENSE README.md scripts styles templates lang assets
- name: Attach to release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" jackforge.zip foundry/module.json --clobber
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Field of temporary hit points, and unique character 'jacks get a manual challeng
- **Web version:** open [`web/jackforge.html`](web/jackforge.html) in any browser, or embed it on a
web page (see [Embedding](#embedding-on-a-web-page)).
- **From source:** see [Building](#building-from-source).
- **Foundry VTT module (Pathfinder 1e):** install from the manifest URL
`https://github.com/DatJavaClass/JackForge/releases/latest/download/module.json`.
Needs Foundry V12 build 343 or newer and the `pf1` system v11 or newer. See
[`foundry/README.md`](foundry/README.md).

---

Expand Down Expand Up @@ -145,6 +149,7 @@ dotnet publish src/JackForge -c Release -r linux-x64 --self-contained -p:Publish

```
src/JackForge/ the Avalonia desktop application (C#)
foundry/ the Foundry VTT module (Pathfinder 1e)
web/ the embeddable single-file web version + instructions
docs/ logo and screenshots
DESIGN.md design notes and the locked ruleset decisions
Expand Down
64 changes: 64 additions & 0 deletions foundry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# JackForge for Foundry VTT

Forge WarMachine warjacks, myrmidons, vectors, and colossals into **Pathfinder 1e** construct
actors, right inside Foundry.

- **Foundry:** V12 build 343 or newer.
- **System:** Pathfinder 1e (`pf1`) v11 or newer.

## Install

In Foundry's **Add-on Modules** tab, choose **Install Module** and paste this manifest URL:

```
https://github.com/DatJavaClass/JackForge/releases/latest/download/module.json
```

Enable **JackForge** in your PF1 world.

## Use

1. Open the **Actors** sidebar and press the brass **JackForge** button.
2. Fill in the **Chassis Plate** with the 'jack's WarMachine stat line, then pick its faction and
chassis, weight class, and arc node. The blueprint on the right updates as you type, and the
furnace gauge runs hotter as the CR climbs.
3. Press **Forge Actor**. A PF1 NPC lands in the **JackForge Foundry** actor folder and its sheet opens.

**Copy SBC Text** puts the same stat block on the clipboard in the format the SBC importer reads,
if you would rather build the actor that way.

## What the forged actor contains

| Piece | How it is built |
|---|---|
| Construct racial HD | Hit dice, total hit points, BAB, and saves set to the JackForge calibration |
| Abilities | Str and Dex from the stat line, no Con, Int/Wis from the chassis (none for Cyriss vectors) |
| Armor | Natural armor sized so AC matches the calibrated value |
| DR | `DR #/adamantine` (Serric Steel) |
| Attacks | Left Slam and Right Slam (natural), Cannon (ranged, fire) |
| Myrmidons | Energy Field ability, with its value loaded as temporary HP |
| Vectors | Fly speed with clumsy or average maneuverability when set to hover |
| Token | Sized 2, 3, or 6 squares for Large, Huge, or Colossal |

Senses are left off on purpose: no darkvision is attached to forged actors or to the SBC text.

## Macro API

```js
const jf = game.modules.get("jackforge").api;
jf.open(); // open the forge window
jf.convert({ name: "Juggernaut", spd: 4, str: 12, mat: 6, rat: 4, def: 10, arm: 20, hb: 34,
type: "Heavy", chassisKey: "Khador Juggernaut Chasis" });
jf.forge({ ...same input }); // create the actor directly
jf.chassis; // the chassis table, for valid chassisKey values
```

## Development

```bash
node --test foundry/tests/*.test.mjs
```

The tests check the module engine against the original web engine for every chassis.
Publishing a GitHub release tagged `foundry-vX.Y.Z` builds `jackforge.zip` and attaches it,
along with `module.json`, to the release.
Binary file added foundry/assets/ironkingdoms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added foundry/assets/pf1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions foundry/lang/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"JACKFORGE": {
"Title": "JackForge",
"Heading": "JACKFORGE",
"Subheading": "Mechanikal Foundry · WarMachine to Pathfinder 1e",
"OpenForge": "JackForge",
"PlateHeading": "Chassis Plate",
"Designation": "Designation",
"HitBoxes": "Hit Boxes",
"SpecialAtk": "Special Atk",
"CasterLevel": "CaL",
"CasterHD": "CHD",
"Chassis": "Faction / Chassis",
"ChassisType": "Weight Class",
"ArcNode": "Arc Node",
"Type": { "Light": "Light", "Heavy": "Heavy", "Colossal": "Colossal" },
"HoverLabel": "Hover (vector)",
"Hover": { "None": "None (walks)", "Clumsy": "Clumsy hover (+1 CR)", "Average": "Average hover (+1 CR)" },
"HoverHint": "Light trimodal vectors: Clumsy. Purpose-built hovering vectors: Average. Most heavy vectors: None.",
"FieldPower": "Field Power (active while the Energy Field holds)",
"FieldPowerPlaceholder": "e.g. boostable attacks, shield guard...",
"Unique": "Unique 'Jack?",
"CrAdjust": "CR Adjustment (+/-)",
"CrAdjustHint": "For character 'jacks with special rules (twin cortex, double field). Nudges the CR.",
"Pf1Readout": "Pathfinder 1e Construct",
"HeatLabel": "Furnace heat: CR {cr}",
"FieldBar": "Energy Field: {field} temporary HP, spent before the chassis takes damage",
"FieldPowerLine": "Active while up: {power}",
"SbcHeading": "SBC stat block text",
"Forge": "Forge Actor",
"CopySbc": "Copy SBC Text",
"Reset": "Reset",
"Copied": "SBC stat block copied to the clipboard.",
"Forged": "{name} (CR {cr}) rolls off the line.",
"ForgeFailed": "The forge misfired. See the console (F12) for details.",
"NoPermission": "You do not have permission to create actors.",
"FolderName": "JackForge Foundry",
"WrongSystem": "JackForge needs the Pathfinder 1e (pf1) game system."
}
}
33 changes: 33 additions & 0 deletions foundry/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"id": "jackforge",
"title": "JackForge: Iron Kingdoms 'Jack Converter",
"description": "Forge WarMachine warjacks, myrmidons, vectors, and colossals into Pathfinder 1e construct actors, calibrated to the Monster Statistics by CR table.",
"version": "2.1.0",
"authors": [
{ "name": "Victor S. (DatJavaClass)", "url": "/DatJavaClass" }
],
"compatibility": {
"minimum": "12.343",
"verified": "12.343"
},
"relationships": {
"systems": [
{
"id": "pf1",
"type": "system",
"compatibility": { "minimum": "11.0" }
}
]
},
"esmodules": ["scripts/jackforge.mjs"],
"styles": ["styles/jackforge.css"],
"languages": [
{ "lang": "en", "name": "English", "path": "lang/en.json" }
],
"url": "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/DatJavaClass/JackForge",
"manifest": "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/DatJavaClass/JackForge/releases/latest/download/module.json",
"download": "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/DatJavaClass/JackForge/releases/download/foundry-v2.1.0/jackforge.zip",
"bugs": "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/DatJavaClass/JackForge/issues",
"license": "LICENSE",
"readme": "README.md"
}
166 changes: 166 additions & 0 deletions foundry/scripts/actor-builder.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Builds PF1 (system "pf1", v11+) NPC actor data from a JackForge conversion.
//
// The PF1 system derives most numbers itself, so the calibrated stat block is reproduced by
// feeding it the right inputs:
// - a racial "Construct" class item carries HD, total HP, and custom BAB / save formulas;
// - natural armor is set directly so AC lands on the calibrated value;
// - each attack item's attackBonus covers the gap between PF1's own math and the calibrated
// attack bonus from the Monster-Statistics-by-CR table.
// No senses are attached (darkvision in particular is deliberately left off).

import { exportSbc, readoutRows, crNote } from "./engine.mjs";

export const MODULE_ID = "jackforge";

const SIZE_KEY = { Large: "lg", Huge: "huge", Colossal: "col" };
const TOKEN_SIZE = { Large: 2, Huge: 3, Colossal: 6 };
const SIZE_ATTACK = { Large: -1, Huge: -2, Colossal: -8 };
const CONSTRUCT_IMG = "systems/pf1/icons/skills/mech_7.jpg";

// Construct condition immunities, using PF1 condition-type ids.
const CONSTRUCT_CI = ["deathEffects", "disease", "energyDrain", "exhausted", "fatigue", "mindAffecting",
"paralyze", "poison", "sleep", "stun"];

const escapeHtml = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const id = () => foundry.utils.randomID(16);

function attackItem({ name, subType, actionType, ability, bonus, formula, types, range, img }) {
return {
name, type: "attack", img,
system: {
subType,
proficient: true,
held: null,
weaponGroups: subType === "natural" ? ["natural"] : [],
actions: [{
_id: id(),
name: "Attack",
img,
actionType,
activation: { type: "attack", unchained: { type: "attack" } },
ability: { attack: ability, damage: actionType === "mwak" ? "str" : "", damageMult: 1 },
attackBonus: bonus ? String(bonus) : "",
damage: { parts: [{ formula, types }] },
range,
}],
},
};
}

/** Actor creation data for a converted warjack. `w` is the normalized input, `r` the PF1 result. */
export function buildActorData(w, r) {
const size = SIZE_KEY[r.size] ?? "huge";
const sizeAtk = SIZE_ATTACK[r.size] ?? -2;
const wisMod = r.wisScore === null ? 0 : Math.floor((r.wisScore - 10) / 2);

const construct = {
name: "Construct",
type: "class",
img: CONSTRUCT_IMG,
system: {
subType: "racial",
tag: "construct",
hd: 10,
level: r.hitDice,
hp: r.hp,
bab: "custom",
babFormula: String(r.bab),
skillsPerLevel: 0,
// PF1 adds the ability modifier on top of the class base, so strip it back out here.
savingThrows: {
fort: { value: "custom", custom: String(r.fort) },
ref: { value: "custom", custom: String(r.ref - r.dexMod) },
will: { value: "custom", custom: String(r.will - wisMod) },
},
description: { value: "<p>Warjack chassis calibrated by JackForge. HD, hit points, BAB and saves are set to the JackForge calibration.</p>" },
},
};

const slam = (side) => attackItem({
name: `${side} Slam`, subType: "natural", actionType: "mwak", ability: "str",
bonus: r.meleeTop - (r.bab + r.strMod + sizeAtk),
formula: r.slamDice, types: ["bludgeoning"], range: { units: "melee" }, img: CONSTRUCT_IMG,
});
const cannon = attackItem({
name: "Cannon", subType: "weapon", actionType: "rwak", ability: "dex",
bonus: r.rangedTop - (r.bab + r.dexMod + sizeAtk),
formula: r.cannonDice, types: ["fire"], range: { value: "60", units: "ft", maxIncrements: 10 }, img: CONSTRUCT_IMG,
});

const items = [construct, slam("Left"), slam("Right"), cannon];

if (r.field !== null) {
const power = r.fieldPower ? `<p><strong>Field Power:</strong> ${escapeHtml(r.fieldPower)}</p>` : "";
items.push({
name: "Energy Field", type: "feat", img: CONSTRUCT_IMG,
system: {
subType: "racial", abilityType: "su",
description: { value: `<p>The myrmidon starts each encounter with ${r.field} temporary hit points from its arcane energy field. All damage depletes the field first.</p>${power}` },
},
});
}

const speed = { land: { base: r.speed } };
if (r.flySpeed > 0) speed.fly = { base: r.flySpeed, maneuverability: r.hoverManeuver || "clumsy" };

const readout = readoutRows(r).map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`).join("");
const biography = `<h2>Forged by JackForge</h2>
<p>WarMachine plate: SPD ${w.spd}, STR ${w.str}, MAT ${w.mat}, RAT ${w.rat}, DEF ${w.def}, ARM ${w.arm}, ${w.hb} hit boxes.</p>
<table>${readout}<tr><th>CR</th><td>${r.cr} (${escapeHtml(crNote(r))})</td></tr></table>
<p>Damage reduction bypasses adamantine, which in the Iron Kingdoms is Serric Steel.</p>
<h3>SBC stat block</h3><pre>${escapeHtml(exportSbc(r))}</pre>`;

return {
name: r.name,
type: "npc",
img: CONSTRUCT_IMG,
system: {
abilities: {
str: { value: r.strScore },
dex: { value: r.dexScore },
con: { value: null },
int: { value: r.intScore },
wis: { value: r.wisScore },
cha: { value: 1 },
},
attributes: {
naturalAC: r.natural,
speed,
hp: { temp: r.field ?? 0 },
},
details: {
cr: { base: r.cr },
alignment: "tn",
biography: { value: biography },
},
traits: {
size,
dr: r.dr > 0 ? { value: [{ amount: r.dr, operator: true, types: ["adamantine", ""] }], custom: "" } : { value: [], custom: "" },
ci: CONSTRUCT_CI,
},
},
prototypeToken: {
name: r.name,
width: TOKEN_SIZE[r.size] ?? 3,
height: TOKEN_SIZE[r.size] ?? 3,
actorLink: false,
},
items,
flags: { [MODULE_ID]: { input: w, cr: r.cr } },
};
}

/** Find or create the Actor folder JackForge files its constructs into. */
async function forgeFolder() {
const name = game.i18n.localize("JACKFORGE.FolderName");
const existing = game.folders.find((f) => f.type === "Actor" && f.getFlag(MODULE_ID, "forge"));
if (existing) return existing;
return Folder.create({ name, type: "Actor", color: "#8e2a1c", flags: { [MODULE_ID]: { forge: true } } });
}

export async function forgeActor(w, r) {
const data = buildActorData(w, r);
const folder = await forgeFolder();
data.folder = folder?.id ?? null;
return Actor.create(data);
}
Loading
Loading