aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion/Mood.js
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-23 15:01:28 +0200
committerDanilo M. <danix@danix.xyz>2026-10-23 15:01:28 +0200
commit524362dcf762713f15907668f990504fdc9b9929 (patch)
treed77806f7706235a0081c3ec7fdfae5fc7cddb3b0 /desktop/companion/Mood.js
parent101c3e68aa18814dcc18a813958e1aa0f76bd7a7 (diff)
downloadquickshell-524362dcf762713f15907668f990504fdc9b9929.tar.gz
quickshell-524362dcf762713f15907668f990504fdc9b9929.zip
companion: Mood.js, which state wins
The spec's priority table as a pure function over a snapshot of her inputs, with the click burst, the outfit after the self-destruct, the mock and line cooldowns and the monologue's odds beside it. Pure so the node check can drive it with a fake clock; Companion.qml will own the inputs and timers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/companion/Mood.js')
-rw-r--r--desktop/companion/Mood.js93
1 files changed, 93 insertions, 0 deletions
diff --git a/desktop/companion/Mood.js b/desktop/companion/Mood.js
new file mode 100644
index 0000000..f3a4133
--- /dev/null
+++ b/desktop/companion/Mood.js
@@ -0,0 +1,93 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// Which state the companion is in, from a snapshot of her inputs. Pure, so
+// test_mood.js drives it with plain objects and a fake clock. Companion.qml
+// owns the inputs and the timers; this only decides. Times in milliseconds.
+
+var T = {
+ burstGap: 1500, // a click burst ends after this long without a click
+ clickHold: 2000, // a click reaction lasts this long after the last click
+ mockHold: 2500,
+ mockCooldown: 20000,
+ typingHold: 2000,
+ waveHold: 3000,
+ wakeHold: 1500, // the stretch after idling
+ lineCooldown: 30000,
+ monologueCooldown: 3600000,
+ destruct: 1000, // the glow and the flash; the sprite is hidden under it
+ scorched: 3000,
+ skirtless: 60000,
+};
+
+function st(state, frame) { return { state: state, frame: frame }; }
+
+// The burst after a click at now.
+function click(prev, now) {
+ if (prev.count > 0 && now - prev.at <= T.burstGap) return { count: prev.count + 1, at: now };
+ return { count: 1, at: now };
+}
+
+// Click 1 smiles; 2 to 6 blush harder, in three steps. The 7th is the
+// self-destruct, which Companion.qml starts rather than shows.
+function clickFrame(count) {
+ if (count <= 1) return "smile";
+ if (count <= 3) return "blush1";
+ if (count <= 5) return "blush2";
+ return "blush3";
+}
+
+// The spec's priority table, top to bottom: the first that applies wins.
+// idle is 0 awake, 1 bored, 2 yawning, 3 lying down.
+function pick(s) {
+ const since = s.destructAt ? s.now - s.destructAt : Infinity;
+ if (since < T.destruct) return st("destruct", "base");
+ if (since < T.destruct + T.scorched) return st("scorched", "scorched");
+ if (s.dodging) return st("dodge", "base");
+ if (s.shoved) return st("shoved", "shoved");
+ if (s.recording) return st("listen", "listen");
+ if (s.busy) return st("think", "think");
+ if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave");
+ if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold)
+ return st("clicks", clickFrame(s.clicks.count));
+ if (s.now - s.mockAt < T.mockHold)
+ return st("mock", s.now - s.mockAt < T.mockHold / 2 ? "point" : "smirk");
+ if (s.now - s.typingAt < T.typingHold) return st("watch", "watch");
+ if (s.now - s.wakeAt < T.wakeHold) return st("stretch", "stretch");
+ if (s.monologue) return st("muse", "muse");
+ if (s.idle >= 3) return st("lying", "lying");
+ if (s.idle === 2) return st("yawn", "yawn");
+ if (s.idle === 1) return st("bored", "sleepy");
+ return st("base", "base");
+}
+
+// The self-destruct takes the skirt off once the flash is over, for the
+// scorched moment and the skirtless minute after it. Showing her again
+// sets destructAt back to 0, which restores it early.
+function outfit(destructAt, now) {
+ if (!destructAt) return "dress";
+ const since = now - destructAt;
+ return since >= T.destruct && since < T.destruct + T.scorched + T.skirtless ? "leotard" : "dress";
+}
+
+function mockAllowed(lastMockAt, now) { return now - lastMockAt >= T.mockCooldown; }
+
+function canSpeak(lastLineAt, now) { return now - lastLineAt >= T.lineCooldown; }
+
+// Whether the opening monologue plays: a chance per trigger, and never
+// twice within the hour. roll is Math.random(), passed in for the test.
+function monologue(trigger, lastAt, now, roll) {
+ if (now - lastAt < T.monologueCooldown) return false;
+ const chance = trigger === "bored" ? 1 / 4 : trigger === "shown" ? 1 / 3 : 0;
+ return roll < chance;
+}