Repository: loathers/libram
Pull request #779: Offer shield mood skills/effects
Author: login: Ignose
Created: 2025-04-04T14:48:31Z | Merged: 2025-04-07T20:09:16Z



Diff:
diff --git a/package.json b/package.json
index 07f2d89900..c1a2b0bf37 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
     "eslint-config-prettier": "^9.1.0",
     "eslint-import-resolver-typescript": "^3.6.3",
     "eslint-plugin-jsdoc": "^50.2.3",
-    "eslint-plugin-libram": "^0.4.24",
+    "eslint-plugin-libram": "^0.4.26",
     "husky": "^9.1.6",
     "java-parser": "^2.3.2",
     "kolmafia": "^5.28047.0",
diff --git a/src/mood.ts b/src/mood.ts
index a224930f25..4104f47384 100644
--- a/src/mood.ts
+++ b/src/mood.ts
@@ -5,6 +5,8 @@ import {
   eat,
   Effect,
   effectModifier,
+  equip,
+  equippedItem,
   haveEffect,
   haveSkill,
   hpCost,
@@ -18,18 +20,29 @@ import {
   restoreMp,
   retrieveItem,
   Skill,
+  Slot,
   toEffect,
   toSkill,
   turnsPerCast,
   use,
   useSkill,
 } from "kolmafia";
-import { getActiveSongs, have, isSong } from "./lib.js";
+import { getActiveSongs, have, isSong, unequip } from "./lib.js";
 import { get } from "./property.js";
 import { AsdonMartin } from "./resources/index.js";
-import { $item, $skill } from "./template-string.js";
+import { $effect, $item, $skill, $slot } from "./template-string.js";
 import { clamp, sum } from "./utils.js";
 
+const aprilShieldEffects = new Map([
+  [$skill`Empathy of the Newt`, $effect`Thoughtful Empathy`],
+  [$skill`Sauce Contemplation`, $effect`Lubricating Sauce`],
+  [$skill`Manicotti Meditation`, $effect`Tubes of Universal Meat`],
+  [$skill`Seal Clubbing Frenzy`, $effect`Slippery as a Seal`],
+  [$skill`Patience of the Tortoise`, $effect`Strength of the Tortoise`],
+  [$skill`Disco Aerobics`, $effect`Disco over Matter`],
+  [$skill`Moxie of the Mariachi`, $effect`Mariachi Moisture`],
+]);
+
 export abstract class MpSource {
   usesRemaining(): number {
     return 0;
@@ -116,12 +129,29 @@ abstract class MoodElement {
   abstract execute(mood: Mood, ensureTurns: number): boolean;
 }
 
+interface SkillEffectOptions {
+  requireAprilShield?: boolean;
+}
+
 class SkillMoodElement extends MoodElement {
   skill: Skill;
+  effect: Effect;
+  options: SkillEffectOptions;
 
-  constructor(skill: Skill) {
+  constructor(skill: Skill, options: SkillEffectOptions) {
     super();
     this.skill = skill;
+    this.effect = options.requireAprilShield
+      ? (aprilShieldEffects.get(skill) ?? $effect.none)
+      : toEffect(skill);
+    this.options = options;
+  }
+
+  get aprilShieldRestricted(): boolean {
+    return (
+      !this.options.requireAprilShield &&
+      this.skill === $skill`Empathy of the Newt`
+    );
   }
 
   mpCostPerTurn(): number {
@@ -134,22 +164,26 @@ class SkillMoodElement extends MoodElement {
   }
 
   execute(mood: Mood, ensureTurns: number): boolean {
-    const effect = toEffect(this.skill);
-    const initialTurns = haveEffect(effect);
+    if (this.effect === $effect.none) return false;
 
-    if (!haveSkill(this.skill)) return false;
+    const initialTurns = haveEffect(this.effect);
+    // Track these separately because of LHM
+    const shieldSlot = Slot.all().find(
+      (slot) => equippedItem(slot) === $item`April Shower Thoughts shield`,
+    );
+    const initialOffhand = equippedItem($slot`off-hand`);
     if (initialTurns >= ensureTurns) return true;
+    if (!haveSkill(this.skill)) return false;
 
-    // Deal with song slots.
-    if (
-      mood.options.songSlots.length > 0 &&
-      isSong(this.skill) &&
-      !have(effect)
-    ) {
+    if (this.aprilShieldRestricted && shieldSlot) {
+      unequip($item`April Shower Thoughts shield`);
+    }
+
+    if (mood.options.songSlots && isSong(this.skill) && !have(this.effect)) {
       const activeSongs = getActiveSongs();
       for (const song of activeSongs) {
         const slot = mood.options.songSlots.find((slot) => slot.includes(song));
-        if (!slot || slot.includes(effect)) {
+        if (!slot || slot.includes(this.effect)) {
           cliExecute(`shrug ${song}`);
           break;
         }
@@ -158,33 +192,44 @@ class SkillMoodElement extends MoodElement {
 
     let oldRemainingCasts = -1;
     let remainingCasts = Math.ceil(
-      (ensureTurns - haveEffect(effect)) / turnsPerCast(this.skill),
+      (ensureTurns - haveEffect(this.effect)) / turnsPerCast(this.skill),
     );
-    while (remainingCasts > 0 && oldRemainingCasts !== remainingCasts) {
-      let maxCasts;
-      if (hpCost(this.skill) > 0) {
-        // FIXME: restore HP
-        maxCasts = Math.max(0, Math.floor((myHp() - 1) / hpCost(this.skill))); // Do not allow ourselves to hit 0 hp
-      } else {
-        const cost = mpCost(this.skill);
-        maxCasts = Math.floor(Math.min(mood.availableMp(), myMp()) / cost);
-        if (maxCasts < remainingCasts) {
-          const bestMp = Math.min(
-            remainingCasts * mpCost(this.skill),
-            myMaxmp(),
-          );
-          mood.moreMp(bestMp);
+
+    try {
+      while (remainingCasts > 0 && oldRemainingCasts !== remainingCasts) {
+        if (this.options.requireAprilShield && !shieldSlot) {
+          if (!equip($item`April Shower Thoughts shield`)) return false;
+        }
+
+        let maxCasts = 0;
+
+        if (hpCost(this.skill) > 0) {
+          maxCasts = Math.max(0, Math.floor((myHp() - 1) / hpCost(this.skill)));
+        } else {
+          const cost = mpCost(this.skill);
           maxCasts = Math.floor(Math.min(mood.availableMp(), myMp()) / cost);
+          if (maxCasts < remainingCasts) {
+            const bestMp = Math.min(remainingCasts * cost, myMaxmp());
+            mood.moreMp(bestMp);
+            maxCasts = Math.floor(Math.min(mood.availableMp(), myMp()) / cost);
+          }
         }
+
+        const casts = clamp(remainingCasts, 0, Math.min(100, maxCasts));
+        useSkill(casts, this.skill);
+
+        oldRemainingCasts = remainingCasts;
+        remainingCasts = Math.ceil(
+          (ensureTurns - haveEffect(this.effect)) / turnsPerCast(this.skill),
+        );
       }
-      const casts = clamp(remainingCasts, 0, Math.min(100, maxCasts));
-      useSkill(casts, this.skill);
-      oldRemainingCasts = remainingCasts;
-      remainingCasts = Math.ceil(
-        (ensureTurns - haveEffect(effect)) / turnsPerCast(this.skill),
-      );
+
+      return haveEffect(this.effect) >= ensureTurns;
+    } finally {
+      if (shieldSlot) equip($item`April Shower Thoughts shield`, shieldSlot);
+      if (initialOffhand !== equippedItem($slot`off-hand`))
+        equip(initialOffhand, $slot`off-hand`);
     }
-    return haveEffect(effect) > ensureTurns;
   }
 }
 
@@ -366,10 +411,11 @@ export class Mood {
    * Add a skill to the mood.
    *
    * @param skill Skill to add.
+   * @param options Additional `SkillEffectOptions` to pass to new `SkillMoodElement`
    * @returns This mood to enable chaining
    */
-  skill(skill: Skill): Mood {
-    this.elements.push(new SkillMoodElement(skill));
+  skill(skill: Skill, options: SkillEffectOptions = {}): Mood {
+    this.elements.push(new SkillMoodElement(skill, options));
     return this;
   }
 
@@ -383,7 +429,10 @@ export class Mood {
   effect(effect: Effect, gainEffect?: () => void): Mood {
     const skill = toSkill(effect);
     if (!gainEffect && skill !== $skill.none) {
-      this.skill(skill);
+      const requireAprilShield = aprilShieldEffects
+        .values()
+        .some((ef) => ef === effect);
+      this.skill(skill, { requireAprilShield });
     } else {
       this.elements.push(new CustomMoodElement(effect, gainEffect));
     }
diff --git a/yarn.lock b/yarn.lock
index 9c9c96e538..8677eb86cb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3510,13 +3510,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"eslint-plugin-libram@npm:^0.4.24":
-  version: 0.4.24
-  resolution: "eslint-plugin-libram@npm:0.4.24"
+"eslint-plugin-libram@npm:^0.4.26":
+  version: 0.4.26
+  resolution: "eslint-plugin-libram@npm:0.4.26"
   dependencies:
     html-entities: "npm:^2.5.2"
     requireindex: "npm:~1.2.0"
-  checksum: 10c0/6e3e918102ead7b3185de26b19b0cca89a5c8fdd47e9d7c9f3bb885a76f4a0f3a538dab3bad3d45a852795befbe5ee47010edc8c6e8736cb07ed176dc1ac693a
+  checksum: 10c0/140b9095387244555ffcb2f0ad8f78879ee34a541b04ecd91a78bc7b08366817ca32e189db634634772b2ed3ddf0259f888c38a0170eea92d08266f8b2d99f3d
   languageName: node
   linkType: hard
 
@@ -4365,7 +4365,7 @@ __metadata:
     eslint-config-prettier: "npm:^9.1.0"
     eslint-import-resolver-typescript: "npm:^3.6.3"
     eslint-plugin-jsdoc: "npm:^50.2.3"
-    eslint-plugin-libram: "npm:^0.4.24"
+    eslint-plugin-libram: "npm:^0.4.26"
     html-entities: "npm:^2.5.2"
     husky: "npm:^9.1.6"
     java-parser: "npm:^2.3.2"


Reviews:
user: login: spaghetti-squash
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T16:13:34Z
user: login: spaghetti-squash
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T16:16:56Z
user: login: spaghetti-squash
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T16:58:05Z

Review comments:
diff_hunk: @@ -303,6 +310,70 @@ class AsdonMoodElement extends MoodElement {
   }
 }
 
+class ShieldMoodElement extends MoodElement {
+  effect: Effect;
+  skill: Skill;
+
+  constructor(effect: Effect) {
+    super();
+    this.effect = effect;
+    const skill = shieldEffects.get(effect);
+    if (!skill) throw `Invalid shield effect: ${effect}`;
+    this.skill = skill;
+  }
+
+  mpCostPerTurn(): number {
+    const turns = turnsPerCast(this.skill);
+    return turns > 0 ? mpCost(this.skill) / turns : 0;
+  }
+
+  turnIncrement(): number {
+    return turnsPerCast(this.skill);
path: src/mood.ts
user: login: spaghetti-squash
body: I don't think we can rely on turnsPerCast here, we might need to add it to the map
created_at: 2025-04-04T16:13:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,6 +310,70 @@ class AsdonMoodElement extends MoodElement {
   }
 }
 
+class ShieldMoodElement extends MoodElement {
+  effect: Effect;
+  skill: Skill;
+
+  constructor(effect: Effect) {
+    super();
+    this.effect = effect;
+    const skill = shieldEffects.get(effect);
+    if (!skill) throw `Invalid shield effect: ${effect}`;
+    this.skill = skill;
+  }
+
+  mpCostPerTurn(): number {
+    const turns = turnsPerCast(this.skill);
+    return turns > 0 ? mpCost(this.skill) / turns : 0;
+  }
+
+  turnIncrement(): number {
+    return turnsPerCast(this.skill);
+  }
+
+  execute(mood: Mood, ensureTurns: number): boolean {
+    const initialTurns = haveEffect(this.effect);
+
+    if (!haveSkill(this.skill)) return false;
+    if (initialTurns >= ensureTurns) return true;
+
+    let oldRemainingCasts = -1;
+    let remainingCasts = Math.ceil(
+      (ensureTurns - initialTurns) / turnsPerCast(this.skill),
+    );
+
+    while (remainingCasts > 0 && oldRemainingCasts !== remainingCasts) {
+      equip($item`April Shower Thoughts shield`);
+
+      let maxCasts;
+      if (hpCost(this.skill) > 0) {
path: src/mood.ts
user: login: spaghetti-squash
body: Not relevant for shield 
created_at: 2025-04-04T16:14:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,6 +310,70 @@ class AsdonMoodElement extends MoodElement {
   }
 }
 
+class ShieldMoodElement extends MoodElement {
+  effect: Effect;
+  skill: Skill;
+
+  constructor(effect: Effect) {
+    super();
+    this.effect = effect;
+    const skill = shieldEffects.get(effect);
+    if (!skill) throw `Invalid shield effect: ${effect}`;
+    this.skill = skill;
+  }
+
+  mpCostPerTurn(): number {
+    const turns = turnsPerCast(this.skill);
+    return turns > 0 ? mpCost(this.skill) / turns : 0;
+  }
+
+  turnIncrement(): number {
+    return turnsPerCast(this.skill);
+  }
+
+  execute(mood: Mood, ensureTurns: number): boolean {
+    const initialTurns = haveEffect(this.effect);
+
+    if (!haveSkill(this.skill)) return false;
+    if (initialTurns >= ensureTurns) return true;
+
+    let oldRemainingCasts = -1;
+    let remainingCasts = Math.ceil(
+      (ensureTurns - initialTurns) / turnsPerCast(this.skill),
+    );
+
+    while (remainingCasts > 0 && oldRemainingCasts !== remainingCasts) {
+      equip($item`April Shower Thoughts shield`);
+
+      let maxCasts;
+      if (hpCost(this.skill) > 0) {
+        // FIXME: restore HP
+        maxCasts = Math.max(0, Math.floor((myHp() - 1) / hpCost(this.skill)));
+      } else {
+        const cost = mpCost(this.skill);
+        maxCasts = Math.floor(Math.min(mood.availableMp(), myMp()) / cost);
+        if (maxCasts < remainingCasts) {
+          const bestMp = Math.min(remainingCasts * cost, myMaxmp());
+          mood.moreMp(bestMp);
+          maxCasts = Math.floor(Math.min(mood.availableMp(), myMp()) / cost);
+        }
+      }
+
+      const casts = clamp(remainingCasts, 0, Math.min(100, maxCasts));
+      useSkill(casts, this.skill);
+
+      unequip($item`April Shower Thoughts shield`);
path: src/mood.ts
user: login: spaghetti-squash
body: Rather than using unequip we should restore your previous offhand
created_at: 2025-04-04T16:15:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,6 +310,70 @@ class AsdonMoodElement extends MoodElement {
   }
 }
 
+class ShieldMoodElement extends MoodElement {
+  effect: Effect;
+  skill: Skill;
+
+  constructor(effect: Effect) {
+    super();
+    this.effect = effect;
+    const skill = shieldEffects.get(effect);
+    if (!skill) throw `Invalid shield effect: ${effect}`;
+    this.skill = skill;
+  }
+
+  mpCostPerTurn(): number {
+    const turns = turnsPerCast(this.skill);
+    return turns > 0 ? mpCost(this.skill) / turns : 0;
+  }
+
+  turnIncrement(): number {
+    return turnsPerCast(this.skill);
+  }
+
+  execute(mood: Mood, ensureTurns: number): boolean {
+    const initialTurns = haveEffect(this.effect);
+
+    if (!haveSkill(this.skill)) return false;
+    if (initialTurns >= ensureTurns) return true;
+
+    let oldRemainingCasts = -1;
+    let remainingCasts = Math.ceil(
+      (ensureTurns - initialTurns) / turnsPerCast(this.skill),
+    );
+
+    while (remainingCasts > 0 && oldRemainingCasts !== remainingCasts) {
+      equip($item`April Shower Thoughts shield`);
+
+      let maxCasts;
+      if (hpCost(this.skill) > 0) {
+        // FIXME: restore HP
+        maxCasts = Math.max(0, Math.floor((myHp() - 1) / hpCost(this.skill)));
+      } else {
+        const cost = mpCost(this.skill);
+        maxCasts = Math.floor(Math.min(mood.availableMp(), myMp()) / cost);
+        if (maxCasts < remainingCasts) {
+          const bestMp = Math.min(remainingCasts * cost, myMaxmp());
+          mood.moreMp(bestMp);
+          maxCasts = Math.floor(Math.min(mood.availableMp(), myMp()) / cost);
+        }
+      }
+
+      const casts = clamp(remainingCasts, 0, Math.min(100, maxCasts));
+      useSkill(casts, this.skill);
+
+      unequip($item`April Shower Thoughts shield`);
path: src/mood.ts
user: login: spaghetti-squash
body: Also for regular skills we need to remove shield for empathy
created_at: 2025-04-04T16:16:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,12 +27,27 @@ import {
   use,
   useSkill,
 } from "kolmafia";
-import { getActiveSongs, have, isSong } from "./lib.js";
+import { getActiveSongs, have, isSong, unequip } from "./lib.js";
 import { get } from "./property.js";
 import { AsdonMartin } from "./resources/index.js";
-import { $item, $skill } from "./template-string.js";
+import { $effect, $item, $skill, $slot } from "./template-string.js";
 import { clamp, sum } from "./utils.js";
 
+const shieldEffects = new Map<Effect, { skill: Skill; turnsPerCast: number }>([
+  [
+    $effect`Thoughtful Empathy`,
+    { skill: $skill`Empathy of the Newt`, turnsPerCast: 5 },
path: src/mood.ts
user: login: spaghetti-squash
body: This one does actually obey turns per cast, because it's functionally treated as a turtle totem spell
created_at: 2025-04-04T16:53:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,12 +27,27 @@ import {
   use,
   useSkill,
 } from "kolmafia";
-import { getActiveSongs, have, isSong } from "./lib.js";
+import { getActiveSongs, have, isSong, unequip } from "./lib.js";
 import { get } from "./property.js";
 import { AsdonMartin } from "./resources/index.js";
-import { $item, $skill } from "./template-string.js";
+import { $effect, $item, $skill, $slot } from "./template-string.js";
 import { clamp, sum } from "./utils.js";
 
+const shieldEffects = new Map<Effect, { skill: Skill; turnsPerCast: number }>([
+  [
+    $effect`Thoughtful Empathy`,
+    { skill: $skill`Empathy of the Newt`, turnsPerCast: 5 },
+  ],
+  [
+    $effect`Lubricating Sauce`,
+    { skill: $skill`Sauce Contemplation`, turnsPerCast: 5 },
path: src/mood.ts
user: login: spaghetti-squash
body: I misremembered these as giving fewer adventures than the regular spell does; looks like we _can_ rely on turnsPerCast after all, just as sort-of-a-coincidence. Sorry king
created_at: 2025-04-04T16:54:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,9 +154,17 @@ class SkillMoodElement extends MoodElement {
   execute(mood: Mood, ensureTurns: number): boolean {
     const effect = toEffect(this.skill);
     const initialTurns = haveEffect(effect);
+    let showerShieldUnequipped = false;
path: src/mood.ts
user: login: spaghetti-squash
body: instead of storing this in a `let` variable, use a `const` to store the slot it's equipped in:
```typescript
  const aprilShowerShieldSlot = Slot.all().find((s) => equippedItem(s) === $item`April Shower Thoughts shield`);
```
```
if (this.skill === $skill`Empathy of the Newt` && aprilShowerShieldSlot) { 
  unequip(aprilShowerShieldSlot);
}
```
```
if (aprilShowerShieldSlot) equip(aprilShowerShieldSlot, $item`April Shower Thoughts shield`);
```
created_at: 2025-04-04T16:57:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: spaghetti-squash
created_at: 2025-04-04T16:19:08Z
body: It'd be nice if there was a way to force all april skills to be done in a row with the shield up, so we aren't taking it on and off for each skill (this is still WAY better than for each cast). @phulin is the resident expert on mood
author_association: CONTRIBUTOR
user: login: Ignose
created_at: 2025-04-04T20:59:22Z
body: A @phulin review would be lit; no rush though.
author_association: CONTRIBUTOR