aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion/Mood.js
blob: c4df50bde6112a617628b0d5e8367cbe690d5c3e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// 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,
    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,
    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");
    // 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 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 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;
}