Skip to content
Merged
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
212 changes: 211 additions & 1 deletion packages/rules/src/content/combat/hand-to-hand.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"page": 347,
"table": "Hand to Hand Combat Skills"
},
"note": "Accumulative level-by-level bonuses. `bonuses` values are added to the like-named combat action; `addAttacks` increments attacks per melee. Only 'none' and 'basic' are modeled so far (Expert/Martial Arts/Assassin/Commando to follow).",
"note": "Accumulative level-by-level bonuses, printed pp.347-349. `bonuses` values are added to the like-named combat action; `addAttacks` increments attacks per melee. Keys beyond strike/parry/dodge/damage (initiative, entangle, autoDodge, bodyFlipThrow, saveVsHorrorFactor, strikeThrown, strikeGuns) accumulate the same way and await the attack resolver (#16) to be consumed. Special moves without a flat modifier are captured as notes.",
"types": [
{
"id": "none",
Expand Down Expand Up @@ -46,6 +46,216 @@
{ "level": 14, "bonuses": { "damage": 2 } },
{ "level": 15, "addAttacks": 1 }
]
},
{
"id": "expert",
"name": "Hand to Hand: Expert",
"baseAttacks": 4,
"levels": [
{
"level": 1,
"attacks": 4,
"bonuses": { "pullPunch": 2, "rollWithImpact": 2 },
"note": "Kick attack does 1D8 damage."
},
{ "level": 2, "bonuses": { "parry": 3, "dodge": 3, "pullPunch": 1 } },
{
"level": 3,
"bonuses": { "strike": 2, "disarm": 2 },
"note": "Can perform a Karate Punch."
},
{ "level": 4, "addAttacks": 1 },
{ "level": 5, "note": "Can perform a Karate Kick, does 2D6 damage." },
{ "level": 6, "note": "Critical Strike on an unmodified roll of 18, 19 or 20." },
{
"level": 7,
"note": "W.P. Paired Weapons and backhand strike (average, does 1D4 damage)."
},
{
"level": 8,
"note": "Body flip/throw: 1D6 damage; victim loses initiative and one attack."
},
{ "level": 9, "addAttacks": 1, "bonuses": { "disarm": 1 } },
{ "level": 10, "bonuses": { "damage": 3 } },
{ "level": 11, "note": "Knockout/stun on an unmodified roll of 18, 19 or 20." },
{ "level": 12, "bonuses": { "parry": 2, "dodge": 2 } },
{ "level": 13, "note": "Critical Strike or knockout from behind (triple damage)." },
{ "level": 14, "addAttacks": 1 },
{ "level": 15, "note": "Death blow on a roll of Natural 20." }
]
},
{
"id": "martial-arts",
"name": "Hand to Hand: Martial Arts",
"baseAttacks": 4,
"levels": [
{
"level": 1,
"attacks": 4,
"bonuses": { "pullPunch": 3, "rollWithImpact": 3 },
"note": "Body flip/throw: 1D6 damage; victim loses initiative and one attack."
},
{
"level": 2,
"bonuses": { "parry": 3, "dodge": 3, "strike": 2 },
"note": "May perform Karate and any hand strike/punch."
},
{
"level": 3,
"bonuses": { "initiative": 1 },
"note": "May perform a Karate-style kick (does 2D6 damage) and any foot strike except Leap Kick."
},
{ "level": 4, "addAttacks": 1 },
{
"level": 5,
"bonuses": { "entangle": 2 },
"note": "Leap Kick (3D8 damage, but counts as two melee attacks)."
},
{ "level": 6, "note": "Critical Strike on an unmodified roll of 18, 19 or 20." },
{
"level": 7,
"bonuses": { "disarm": 2 },
"note": "W.P. Paired Weapons and can perform Holds."
},
{ "level": 8, "note": "Back flip and back flip escape." },
{ "level": 9, "addAttacks": 1 },
{ "level": 10, "bonuses": { "disarm": 2 }, "note": "Back flip attack." },
{ "level": 11, "bonuses": { "damage": 4, "initiative": 1 } },
{ "level": 12, "bonuses": { "parry": 2, "dodge": 2 } },
{ "level": 13, "note": "Knockout/stun on an unmodified roll of 18, 19 or 20." },
{ "level": 14, "addAttacks": 1 },
{ "level": 15, "note": "Death blow on a roll of a Natural 20." }
]
},
{
"id": "assassin",
"name": "Hand to Hand: Assassin",
"baseAttacks": 3,
"levels": [
{
"level": 1,
"attacks": 3,
"bonuses": { "strike": 2 },
"note": "W.P. Paired Weapons."
},
{ "level": 2, "addAttacks": 2, "bonuses": { "initiative": 1 } },
{
"level": 3,
"bonuses": { "pullPunch": 3, "rollWithImpact": 2 },
"note": "Karate Punch (2D4 damage)."
},
{
"level": 4,
"bonuses": { "damage": 4, "initiative": 1 },
"note": "Karate Kick (2D6 damage); the damage bonus applies to all physical attacks."
},
{ "level": 5, "addAttacks": 1, "bonuses": { "strikeThrown": 1 } },
{
"level": 6,
"bonuses": { "parry": 3, "dodge": 3, "entangle": 2 },
"note": "Backhand strike (martial arts, 1D6 damage)."
},
{
"level": 7,
"note": "Knockout/stun on an unmodified roll of 17-20 and leap kick (3D8 damage, but counts as two melee attacks)."
},
{ "level": 8, "addAttacks": 1, "bonuses": { "strikeGuns": 1, "initiative": 1 } },
{ "level": 9, "bonuses": { "initiative": 1 }, "note": "Can perform back flip." },
{ "level": 10, "note": "Critical Strike on an unmodified roll of 19 or 20." },
{
"level": 11,
"bonuses": { "strike": 2, "strikeThrown": 1, "strikeGuns": 1 },
"note": "Strike bonus is in hand to hand. Can perform back flip attack."
},
{
"level": 12,
"bonuses": { "pullPunch": 2 },
"note": "Death blow on a roll of a Natural 19 or 20."
},
{ "level": 13, "addAttacks": 1 },
{ "level": 14, "bonuses": { "damage": 2 }, "note": "Can perform Holds." },
{
"level": 15,
"bonuses": { "strike": 2, "strikeGuns": 1 },
"note": "Strike bonus is in hand to hand."
}
]
},
{
"id": "commando",
"name": "Hand to Hand: Commando",
"baseAttacks": 4,
"levels": [
{
"level": 1,
"attacks": 4,
"bonuses": { "saveVsHorrorFactor": 2 },
"note": "W.P. Paired Weapons, body flip/throw and body block/tackle."
},
{
"level": 2,
"bonuses": {
"initiative": 1,
"strike": 1,
"parry": 2,
"dodge": 2,
"rollWithImpact": 3,
"pullPunch": 3
},
"note": "Backward sweep kick (opponents behind only): no damage, pure knockdown (same penalties as body flip); cannot be parried, dodge at -2."
},
{
"level": 3,
"bonuses": { "initiative": 1, "disarm": 1 },
"note": "Karate punch/strike (does 2D4 damage)."
},
{ "level": 4, "addAttacks": 1, "note": "Karate kick (does 2D6 damage)." },
{
"level": 5,
"bonuses": { "autoDodge": 2 },
"note": "The +2 also applies to all foot strikes."
},
{
"level": 6,
"bonuses": {
"initiative": 2,
"strike": 1,
"parry": 1,
"dodge": 1,
"bodyFlipThrow": 1
}
},
{
"level": 7,
"bonuses": {
"damage": 2,
"saveVsHorrorFactor": 1,
"disarm": 1,
"autoDodge": 1,
"pullPunch": 2
}
},
{
"level": 8,
"addAttacks": 1,
"bonuses": { "bodyFlipThrow": 2, "rollWithImpact": 1 },
"note": "Jump kick."
},
{
"level": 9,
"bonuses": { "pullPunch": 2 },
"note": "Death blow on a Natural 18-20."
},
{ "level": 10, "bonuses": { "saveVsHorrorFactor": 2, "initiative": 1, "strike": 1 } },
{ "level": 11, "bonuses": { "disarm": 1, "pullPunch": 1, "bodyFlipThrow": 2 } },
{
"level": 12,
"bonuses": { "damage": 2, "parry": 1, "dodge": 1, "autoDodge": 2 }
},
{ "level": 13, "addAttacks": 1 },
{ "level": 14, "bonuses": { "initiative": 1 }, "note": "Can perform Holds." },
{ "level": 15, "note": "Critical Strike on a Natural 17-20." }
]
}
]
}
2 changes: 1 addition & 1 deletion packages/rules/src/engine/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export interface HthUpgrade {
cost: number;
/** Alignment category the upgrade demands (e.g. Assassin requires evil). */
requiresAlignmentCategory?: string;
/** False until the target H2H type exists in combat content (#15). */
/** False when the target H2H type is not modeled in combat content. */
available: boolean;
}

Expand Down
70 changes: 57 additions & 13 deletions packages/rules/tests/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,19 @@ describe("occSkillPlan — the LLW's printed grants", () => {
]);
});

test("hand to hand: basic, with printed upgrades unavailable until #15", () => {
test("hand to hand: basic, with all printed upgrades available (#15)", () => {
expect(plan.hth?.hthId).toBe("basic");
expect(
plan.hth?.upgrades.map((u) => ({ to: u.to, cost: u.cost, available: u.available })),
plan.hth?.upgrades.map((u) => ({
to: u.to,
hthId: u.hthId,
cost: u.cost,
available: u.available,
})),
).toEqual([
{ to: "Hand to Hand: Expert", cost: 1, available: false },
{ to: "Martial Arts", cost: 2, available: false },
{ to: "Assassin", cost: 2, available: false },
{ to: "Hand to Hand: Expert", hthId: "expert", cost: 1, available: true },
{ to: "Martial Arts", hthId: "martial-arts", cost: 2, available: true },
{ to: "Assassin", hthId: "assassin", cost: 2, available: true },
]);
});
});
Expand Down Expand Up @@ -286,18 +291,41 @@ describe("assembleSkills — rule violations", () => {
expect(errors).toContainEqual(expect.stringContaining("same label"));
});

test("unavailable hand-to-hand upgrades are rejected (until #15)", () => {
const { errors } = assembleSkills(leyLineWalker, {
test("upgrading to Expert spends one related pick (#15)", () => {
const result = assembleSkills(leyLineWalker, {
...legalSelections,
hthId: "expert",
related: legalSelections.related.slice(0, 6),
});
expect(result.errors).toEqual([]);
expect(result.hthType).toBe("expert");
});

test("upgrading to Martial Arts spends two related picks (#15)", () => {
const result = assembleSkills(leyLineWalker, {
...legalSelections,
hthId: "martial-arts",
related: legalSelections.related.slice(0, 5),
});
expect(errors).toContainEqual(expect.stringContaining('"expert" is not available'));
expect(result.errors).toEqual([]);
expect(result.hthType).toBe("martial-arts");
});

test("Assassin demands an evil alignment even now that it is modeled", () => {
const { errors } = assembleSkills(leyLineWalker, {
...legalSelections,
hthId: "assassin",
related: legalSelections.related.slice(0, 5),
// legalSelections is scrupulous (good)
});
expect(errors).toContainEqual(expect.stringContaining("requires an evil alignment"));
});
});

describe("assembleSkills — hand-to-hand upgrades (synthetic O.C.C.)", () => {
// A minimal O.C.C. whose grant is "none" upgradable to Basic — the only
// modeled H2H pair today — so the upgrade mechanics are exercisable.
// A minimal O.C.C. whose grant is "none" upgradable to Basic, so the upgrade
// mechanics are exercisable — plus an unmodeled style to pin the
// unavailability path now that all printed RUE types exist (#15).
const brawler = occSchema.parse({
source: { book: "Test Fixture", page: 1 },
id: "test-brawler",
Expand All @@ -313,12 +341,28 @@ describe("assembleSkills — hand-to-hand upgrades (synthetic O.C.C.)", () => {
upgrades: [
{ to: "Basic", cost: { occRelatedSkills: 1 } },
{ to: "Hand to Hand: Expert", cost: { occRelatedSkills: 2 }, requiresAlignment: "evil" },
{ to: "Ninjutsu", cost: { occRelatedSkills: 1 } },
],
},
],
occRelatedSkills: { count: 2, categoryRules: [{ category: "Science", allowed: "any" }] },
});

test("an upgrade to an unmodeled style stays unavailable", () => {
const plan = occSkillPlan(brawler);
expect(plan.hth?.upgrades.find((u) => u.to === "Ninjutsu")).toMatchObject({
hthId: undefined,
available: false,
});
const { errors } = assembleSkills(brawler, {
occChoices: {},
related: [{ skillId: "biology" }],
secondary: [],
hthId: "ninjutsu",
});
expect(errors).toContainEqual(expect.stringContaining('"ninjutsu" is not available'));
});

test("an available upgrade spends related picks", () => {
const result = assembleSkills(brawler, {
occChoices: {},
Expand Down Expand Up @@ -348,9 +392,9 @@ describe("assembleSkills — hand-to-hand upgrades (synthetic O.C.C.)", () => {
hthId: "expert",
alignmentId: "miscreant",
});
// "expert" isn't modeled, so it stays unavailable — but the alignment
// check is reachable through a modeled target.
expect(evil.errors).toContainEqual(expect.stringContaining("not available"));
// Expert costs both related picks; an evil alignment satisfies the gate.
expect(evil.errors).toEqual([]);
expect(evil.hthType).toBe("expert");

const gated = occSchema.parse({
...brawler,
Expand Down
Loading
Loading