diff options
Diffstat (limited to 'desktop/companion')
| -rw-r--r-- | desktop/companion/Mood.js | 93 | ||||
| -rw-r--r-- | desktop/companion/test_mood.js | 119 |
2 files changed, 212 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; +} diff --git a/desktop/companion/test_mood.js b/desktop/companion/test_mood.js new file mode 100644 index 0000000..abdb348 --- /dev/null +++ b/desktop/companion/test_mood.js @@ -0,0 +1,119 @@ +// 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. + +// Run: node desktop/companion/test_mood.js +// Mood.js is a QML library, so its .pragma line is stripped before node +// evaluates it. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/Mood.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const M = new Function(src + + "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue };")(); + +const NOW = 10000000; +// A snapshot with nothing going on, overridden per case. +const calm = over => Object.assign({ + now: NOW, destructAt: 0, dodging: false, shoved: false, recording: false, busy: false, + repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, + monologue: false, idle: 0, +}, over); +const state = over => M.pick(calm(over)).state; +const frame = over => M.pick(calm(over)).frame; + +// Nothing going on: the rest pose. +assert.deepStrictEqual(M.pick(calm({})), { state: "base", frame: "base" }); + +// The priority table, top to bottom: with every trigger from a row down +// active at once, that row wins. Merged so a higher row's values win. +const ladder = [ + ["destruct", { destructAt: NOW - 10 }], + ["scorched", { destructAt: NOW - M.T.destruct - 10 }], + ["dodge", { dodging: true }], + ["shoved", { shoved: true }], + ["listen", { recording: true }], + ["think", { busy: true }], + ["wave", { repliedAt: NOW - 10 }], + ["clicks", { clicks: { count: 2, at: NOW - 10 } }], + ["mock", { mockAt: NOW - 10 }], + ["watch", { typingAt: NOW - 10 }], + ["stretch", { wakeAt: NOW - 10 }], + ["muse", { monologue: true }], + ["lying", { idle: 3 }], +]; +for (let i = 0; i < ladder.length; i++) { + const all = Object.assign({}, ...ladder.slice(i).map(l => l[1]).reverse()); + assert.strictEqual(state(all), ladder[i][0], "everything from " + ladder[i][0] + " down"); +} + +// Shoved has a frame of its own: knocked off balance, glaring at the balloon. +assert.deepStrictEqual(M.pick(calm({ shoved: true })), { state: "shoved", frame: "shoved" }); + +// Idle levels and their frames. +assert.deepStrictEqual(M.pick(calm({ idle: 1 })), { state: "bored", frame: "sleepy" }); +assert.deepStrictEqual(M.pick(calm({ idle: 2 })), { state: "yawn", frame: "yawn" }); +assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lying" }); + +// Holds end on time. +assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base"); +assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base"); +assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base"); +assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base"); +assert.strictEqual(state({ destructAt: NOW - M.T.destruct - M.T.scorched }), "base"); +// The mock points first, then smirks. +assert.strictEqual(frame({ mockAt: NOW - 10 }), "point"); +assert.strictEqual(frame({ mockAt: NOW - M.T.mockHold / 2 - 10 }), "smirk"); +assert.strictEqual(state({ mockAt: NOW - M.T.mockHold }), "base"); + +// A click burst: within burstGap it grows, past it it starts over. +let c = { count: 0, at: 0 }; +c = M.click(c, 1000); +assert.deepStrictEqual(c, { count: 1, at: 1000 }); +c = M.click(c, 1000 + M.T.burstGap); +assert.strictEqual(c.count, 2); +c = M.click(c, c.at + M.T.burstGap + 1); +assert.strictEqual(c.count, 1); +c = { count: 0, at: 0 }; +for (let i = 0; i < 7; i++) c = M.click(c, 5000 + i * 300); +assert.strictEqual(c.count, 7); +// 1 smiles, 2-6 blush harder. +assert.deepStrictEqual([1, 2, 3, 4, 5, 6].map(M.clickFrame), + ["smile", "blush1", "blush1", "blush2", "blush2", "blush3"]); + +// Outfit: the dress until the flash is over, then the leotard for +// scorched + skirtless, then the dress again. +assert.strictEqual(M.outfit(0, NOW), "dress"); +assert.strictEqual(M.outfit(NOW - 10, NOW), "dress"); +assert.strictEqual(M.outfit(NOW - M.T.destruct, NOW), "leotard"); +const bare = M.T.destruct + M.T.scorched + M.T.skirtless; +assert.strictEqual(M.outfit(NOW - bare + 10, NOW), "leotard"); +assert.strictEqual(M.outfit(NOW - bare, NOW), "dress"); + +// Cooldowns. +assert.strictEqual(M.mockAllowed(0, NOW), true); +assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown + 1, NOW), false); +assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown, NOW), true); +assert.strictEqual(M.canSpeak(0, NOW), true); +assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown + 1, NOW), false); +assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown, NOW), true); + +// The monologue: 1 in 4 on boredom, 1 in 3 on being shown, once an hour. +assert.strictEqual(M.monologue("bored", 0, NOW, 0.24), true); +assert.strictEqual(M.monologue("bored", 0, NOW, 0.25), false); +assert.strictEqual(M.monologue("shown", 0, NOW, 0.33), true); +assert.strictEqual(M.monologue("shown", 0, NOW, 0.34), false); +assert.strictEqual(M.monologue("other", 0, NOW, 0), false); +assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown + 1, NOW, 0), false); +assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown, NOW, 0), true); + +console.log("ok"); |
