// Copyright (C) 2026 Danilo M. // // 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, shoveHold: 500, // knocked off balance, before she turns mad at the balloon 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, admireLook: 1500, // turned to the wallpaper, before the blush admireBlush: 3000, admireCooldown: 180000, }; 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"); // shovedAt 0 is the instant before Companion.qml stamps it. if (s.shoved) return st("shoved", s.shovedAt && s.now - s.shovedAt >= T.shoveHold ? "mad" : "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.admireAt < T.admireLook + T.admireBlush) return st("admire", s.now - s.admireAt < T.admireLook ? "watch" : "blush3"); 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 skirtless ms after it (Prefs.companionSkirtless), // or with 0 until she is shown again, which sets destructAt back to 0 and // also restores it early. function outfit(destructAt, now, skirtless) { if (!destructAt) return "dress"; const since = now - destructAt; if (since < T.destruct) return "dress"; return skirtless === 0 || since < T.destruct + T.scorched + skirtless ? "leotard" : "dress"; } function mockAllowed(lastMockAt, now) { return now - lastMockAt >= T.mockCooldown; } function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; } 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; }