aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion/Lines.js
blob: 77e7a838e158d52d388f68ae005e85dde1cfe40e (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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// 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

// What she says, in English, grouped by the reaction that says it.
// Companion.qml picks one at random per reaction and keeps to one line in
// 30 s, except for clicks and the self-destruct (Mood.canSpeak).

var lines = {
    shown: ["Glory to mankind.", "2B, reporting.", "...Back on duty."],
    bye: ["2B, signing off.", "Rest while you can.", "Duty complete."],
    bored: ["...Operator, I'm bored.", "Nothing to report.", "Awaiting orders."],
    wake: ["I'm awake.", "Systems online."],
    mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"],
    shoved: ["Hey.", "Rude.", "I was standing here."],
    ready: ["Answer's ready.", "Your reply came in."],
    headphones: ["...Good song.", "Music. Finally.", "I'll allow this one.", "Louder."],
    groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."],
    click: ["...Yes?", "Can I help you?"],
    blush: ["S-stop that.", "...Operator."],
    wallpaper: ["...!", "W-what is this?", "I wasn't staring."],
    dodge: ["...Fine.", "I'll move."],
    destruct: ["Self-destruct sequence initiated."],
    aftermath: ["...That was unpleasant."],
};

// The lines for a program she recognizes, by lowercased window class: one is
// said at random when focus moves to it. The settings window's Companion
// phrases page edits them into Prefs.companionApps, which then replaces this
// whole set; a class with no entry says nothing.
var apps = {
    firefox: ["Researching again?", "Don't open too many tabs.", "The Bunker's network is faster."],
    kitty: ["A terminal. Serious work.", "Careful with that rm.", "I'll watch your commands."],
    discord: ["Talking to the Resistance?", "Don't tell them where we are."],
    signal: ["Encrypted. Good."],
    steam: ["Training simulation?", "Don't play all night."],
    gimp: ["Making art?"],
    "com.obsproject.studio": ["Are we recording?", "I'll stay out of frame."],
    "md.obsidian.obsidian": ["Writing a report?", "Taking notes. Good habit."],
    sublime_text: ["Writing code?", "Mind the typos."],
    qtmaildir: ["Any orders from Command?", "Mail. Anything urgent?"],
    "virt-manager": ["More machines?", "Androids running androids."],
    audacious: ["Music. I like this."],
    llamachat: ["Talking to Pod?"],
    qtpass: ["I'm not looking."],
};

// The lines for an artist she knows, keyed by a lowercased name matched as
// a substring of the reported artist, so "daft punk" also catches
// collaborations. The Companion artists page edits them into
// Prefs.companionArtists, which then replaces this whole set.
var artists = {
    "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."],
    "evanescence": ["Wake me up inside. ...Operator, don't laugh."],
    "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."],
    "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."],
};

// The game's opening, one sentence per bubble.
var monologue = [
    "Everything that lives is designed to end.",
    "We are perpetually trapped in a never-ending spiral of life and death.",
    "Is this a curse?",
    "Or some kind of punishment?",
    "I often think about the god who blessed us with this cryptic puzzle...and wonder if we'll ever get the chance to kill him.",
];

// A set in effect: saved when it is a plain object, the defaults (apps
// unless given) otherwise. A malformed saved value must not leave her mute.
function appMap(saved, defaults) {
    return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : (defaults || apps);
}

// A set as an ordered list of {cls, lines}, for the settings pages; cls is
// the key, a window class or an artist.
function appList(saved, defaults) {
    const m = appMap(saved, defaults);
    const keys = Object.keys(m);
    const out = [];
    for (let i = 0; i < keys.length; i++)
        out.push({ cls: keys[i], lines: (m[keys[i]] || []).slice() });
    return out;
}

function nonEmpty(all) {
    const l = [];
    for (let i = 0; i < (all || []).length; i++)
        if (typeof all[i] === "string" && all[i].trim() !== "") l.push(all[i]);
    return l;
}

function at(l, roll) { return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; }

// A line for an app class, empty strings skipped; "" when the class has none.
function pickApp(cls, saved, roll) { return at(nonEmpty(appMap(saved)[cls]), roll); }

// A line for the playing artist: the first key that is a case-insensitive
// substring of it and has a non-empty line. ponytail: substring, so a
// short key like "air" also matches "Airbourne"; whole words if that bites.
function pickArtist(artist, saved, roll) {
    const a = String(artist || "").toLowerCase();
    if (a === "") return "";
    const m = appMap(saved, artists);
    const keys = Object.keys(m);
    for (let i = 0; i < keys.length; i++) {
        const l = nonEmpty(m[keys[i]]);
        if (keys[i] !== "" && l.length && a.indexOf(keys[i].toLowerCase()) !== -1) return at(l, roll);
    }
    return "";
}

// A line for a reaction kind, or, with an "app:" or "artist:" prefix, for
// that class or artist.
function pick(kind, roll, saved, savedArtists) {
    if (kind.slice(0, 4) === "app:") return pickApp(kind.slice(4), saved, roll);
    if (kind.slice(0, 7) === "artist:") return pickArtist(kind.slice(7), savedArtists, roll);
    return at(lines[kind] || [], roll);
}

// The page's list back to a map for Prefs: empty classes and phrases dropped,
// the class lowercased (Hyprland reports it that way), a repeated class
// keeping its last entry.
function toMap(list) {
    const m = {};
    for (let i = 0; i < list.length; i++) {
        const cls = String(list[i].cls || "").trim().toLowerCase();
        if (cls === "") continue;
        const src = list[i].lines || [];
        const ls = [];
        for (let j = 0; j < src.length; j++) {
            const s = String(src[j] || "").trim();
            if (s !== "") ls.push(s);
        }
        if (ls.length) m[cls] = ls;
    }
    return m;
}