From 19e9a48ecaed011636ebad844a79b1ed4924513d Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 5 Jul 2026 14:13:02 +0800 Subject: [PATCH 1/2] feat(rules): Hand to Hand Expert, Martial Arts, Assassin & Commando Adds the four remaining RUE Hand-to-Hand progressions (printed pp.347-349) to combat content. The engine is data-driven, so no engine changes: attacksPerMelee/hthBonuses read the new types as-is. New accumulative bonus keys carried by the tables (initiative, entangle, autoDodge, bodyFlipThrow, saveVsHorrorFactor, strikeThrown, strikeGuns) validate through the open bonus record and await the attack resolver (#16) to be consumed; special moves without flat modifiers are notes. Builder fallout: the LLW wizard upgrades (Expert/Martial Arts/Assassin) light up automatically via hthIdFromName resolution. Tests updated to pin the flipped availability, upgrade costs against the real LLW, the Assassin evil-alignment gate through a modeled target, and the still-unavailable path via a synthetic unmodeled style. --- .../src/content/combat/hand-to-hand.json | 212 +++++++++++++++++- packages/rules/src/engine/builder.ts | 2 +- packages/rules/tests/builder.test.ts | 70 ++++-- packages/rules/tests/combat.test.ts | 105 ++++++++- 4 files changed, 371 insertions(+), 18 deletions(-) diff --git a/packages/rules/src/content/combat/hand-to-hand.json b/packages/rules/src/content/combat/hand-to-hand.json index 9719299..005ebda 100644 --- a/packages/rules/src/content/combat/hand-to-hand.json +++ b/packages/rules/src/content/combat/hand-to-hand.json @@ -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", @@ -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." } + ] } ] } diff --git a/packages/rules/src/engine/builder.ts b/packages/rules/src/engine/builder.ts index 94a31cf..516824a 100644 --- a/packages/rules/src/engine/builder.ts +++ b/packages/rules/src/engine/builder.ts @@ -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; } diff --git a/packages/rules/tests/builder.test.ts b/packages/rules/tests/builder.test.ts index 15c9c0b..75eecf6 100644 --- a/packages/rules/tests/builder.test.ts +++ b/packages/rules/tests/builder.test.ts @@ -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 }, ]); }); }); @@ -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", @@ -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: {}, @@ -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, diff --git a/packages/rules/tests/combat.test.ts b/packages/rules/tests/combat.test.ts index c4ab6b1..01d13db 100644 --- a/packages/rules/tests/combat.test.ts +++ b/packages/rules/tests/combat.test.ts @@ -34,7 +34,7 @@ describe("Hit Points & S.D.C. (RUE p.287)", () => { }); }); -describe("Hand to Hand progression (RUE p.347)", () => { +describe("Hand to Hand progression (RUE pp.347-349)", () => { test("Basic attacks per melee: 4, +1 at 4/9/15", () => { expect(attacksPerMelee("basic", 1)).toBe(4); expect(attacksPerMelee("basic", 3)).toBe(4); @@ -60,6 +60,92 @@ describe("Hand to Hand progression (RUE p.347)", () => { damage: 2, }); }); + + test("Expert attacks per melee: 4, +1 at 4/9/14", () => { + expect(attacksPerMelee("expert", 1)).toBe(4); + expect(attacksPerMelee("expert", 4)).toBe(5); + expect(attacksPerMelee("expert", 9)).toBe(6); + expect(attacksPerMelee("expert", 15)).toBe(7); + }); + + test("Expert accumulative bonuses at level 15", () => { + expect(hthBonuses("expert", 15)).toEqual({ + pullPunch: 3, + rollWithImpact: 2, + parry: 5, + dodge: 5, + strike: 2, + disarm: 3, + damage: 3, + }); + }); + + test("Martial Arts attacks per melee: 4, +1 at 4/9/14", () => { + expect(attacksPerMelee("martial-arts", 1)).toBe(4); + expect(attacksPerMelee("martial-arts", 4)).toBe(5); + expect(attacksPerMelee("martial-arts", 9)).toBe(6); + expect(attacksPerMelee("martial-arts", 15)).toBe(7); + }); + + test("Martial Arts accumulative bonuses at level 15", () => { + expect(hthBonuses("martial-arts", 15)).toEqual({ + pullPunch: 3, + rollWithImpact: 3, + parry: 5, + dodge: 5, + strike: 2, + initiative: 2, + entangle: 2, + disarm: 4, + damage: 4, + }); + }); + + test("Assassin attacks per melee: 3, +2 at 2, +1 at 5/8/13", () => { + expect(attacksPerMelee("assassin", 1)).toBe(3); + expect(attacksPerMelee("assassin", 2)).toBe(5); + expect(attacksPerMelee("assassin", 5)).toBe(6); + expect(attacksPerMelee("assassin", 8)).toBe(7); + expect(attacksPerMelee("assassin", 15)).toBe(8); + }); + + test("Assassin accumulative bonuses at level 15 (incl. thrown/gun strikes)", () => { + expect(hthBonuses("assassin", 15)).toEqual({ + strike: 6, + initiative: 4, + pullPunch: 5, + rollWithImpact: 2, + damage: 6, + strikeThrown: 2, + strikeGuns: 3, + parry: 3, + dodge: 3, + entangle: 2, + }); + }); + + test("Commando attacks per melee: 4, +1 at 4/8/13", () => { + expect(attacksPerMelee("commando", 1)).toBe(4); + expect(attacksPerMelee("commando", 4)).toBe(5); + expect(attacksPerMelee("commando", 8)).toBe(6); + expect(attacksPerMelee("commando", 15)).toBe(7); + }); + + test("Commando accumulative bonuses at level 15 (incl. auto-dodge & Horror Factor)", () => { + expect(hthBonuses("commando", 15)).toEqual({ + saveVsHorrorFactor: 5, + initiative: 6, + strike: 3, + parry: 4, + dodge: 4, + rollWithImpact: 4, + pullPunch: 8, + disarm: 3, + autoDodge: 5, + bodyFlipThrow: 5, + damage: 4, + }); + }); }); describe("Saving throws (RUE p.346)", () => { @@ -137,7 +223,20 @@ describe("combatProfile integrates attributes + Hand to Hand", () => { }); test("an unknown Hand-to-Hand id throws instead of silently defaulting", () => { - expect(() => attacksPerMelee("expert", 1)).toThrow(/Unknown Hand-to-Hand/); - expect(() => hthBonuses("expert", 1)).toThrow(/Unknown Hand-to-Hand/); + expect(() => attacksPerMelee("ninjutsu", 1)).toThrow(/Unknown Hand-to-Hand/); + expect(() => hthBonuses("ninjutsu", 1)).toThrow(/Unknown Hand-to-Hand/); + }); + + test("P.P. 20 / P.S. 20, Expert H2H at level 3", () => { + const p = combatProfile({ + attributes: { PP: 20, PS: 20 }, + hthType: "expert", + level: 3, + }); + expect(p.attacksPerMelee).toBe(4); + expect(p.strike).toBe(5); // P.P. +3, Expert +2 at level 3 + expect(p.parry).toBe(6); // P.P. +3, Expert +3 at level 2 + expect(p.dodge).toBe(6); + expect(p.damageBonus).toBe(5); // P.S. +5, no Expert damage bonus until level 10 }); }); From 3994c2acf23a96afa076017b2cc5718121fc7d13 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 5 Jul 2026 15:07:39 +0800 Subject: [PATCH 2/2] fix(test): root vp test now runs per-package Vitest projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-level `vp test` loaded ONLY the root vite.config.ts as a single Vitest project, so per-package configs never applied: apps/web lost its solid plugin and `resolve.conditions` fix, solid-js resolved to the non-reactive server build, and the Convex bindings resubscription test failed (createRenderEffect never re-ran on signal change). Per-package `vp run web#test` was unaffected, which is why CI stayed green. Declaring `test.projects: ["apps/*", "packages/*"]` makes root runs load each package's own config — same plugins, resolve conditions and test environments as the per-package commands. 170/170 from root and per-package. Pre-existing on main; surfaced while validating #15. --- vite.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index 568bf8b..4e30f89 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,4 +12,12 @@ export default defineConfig({ run: { cache: true, }, + test: { + // Run each package as its own Vitest project so root-level `vp test` + // applies per-package configs (plugins, resolve conditions, environments) + // exactly like `vp run #test` does. Without this the root config is + // the only one loaded — apps/web then resolves solid-js to its + // non-reactive server build and the Convex bindings tests fail. + projects: ["apps/*", "packages/*"], + }, });