@kitten-science/kitten-analysts
Version:
420 lines • 17.6 kB
JavaScript
import { SavegameLoader } from "@kitten-science/kitten-scientists/tools/SavegameLoader.js";
import { TechnologiesIgnored, } from "@kitten-science/kitten-scientists/types/index.js";
import { isNil } from "@oliversalzburg/js-utils/data/nil.js";
import { redirectErrorsToConsole } from "@oliversalzburg/js-utils/errors/console.js";
import { cl } from "./tools/Log.js";
import { identifyExchange } from "./tools/MessageFormat.js";
export class KittenAnalysts {
/**
* A reference to the Kittens Game.
*/
game;
/**
* The websocket we're using to talk to the backend.
*/
ws = null;
/**
* A function in the game that allows to retrieve translated messages.
*
* Ideally, you should never access this directly and instead use the
* i18n interface provided by `Engine`.
*/
i18nEngine;
location = window.location.toString().replace(/#$/, "");
#interval = -1;
#timeoutReconnect = -1;
#connectTry = 0;
#withAnalyticsBackend = false;
constructor(game, i18nEngine) {
console.warn(...cl("Kitten Analysts constructed."));
this.game = game;
this.i18nEngine = i18nEngine;
}
/**
* Start the user script after loading and configuring it.
*/
run() {
this.connect(true);
}
/**
* Connect the Kitten Analysts to all systems.
* @param withAnalyticsBackend Should the Kitten Analysts report information to the
* Kitten DnA backend? Because this only makes sense in a strict development environment,
* this should usually be kept disabled for most users.
* @returns Nothing
*/
connect(withAnalyticsBackend) {
if (this.ws !== null) {
return;
}
if (-1 < this.#timeoutReconnect) {
window.clearTimeout(this.#timeoutReconnect);
this.#timeoutReconnect = -1;
}
document.removeEventListener("ks.reportFrame", this.reportFrameListener);
document.addEventListener("ks.reportFrame", this.reportFrameListener);
document.removeEventListener("ks.reportSavegame", this.reportSavegameListener);
document.addEventListener("ks.reportSavegame", this.reportSavegameListener);
if (!withAnalyticsBackend) {
return;
}
this.#withAnalyticsBackend = true;
// Manipulate game to use internal URL for KGNet.
// KG would always return this exact URL itself, if it was running on localhost.
// Because we might not be accessing the current instance of the game through localhost,
// we need to override the entire method to _always_ return this URL.
//
// Temporarily disabled to further integration with the official game website.
//this.game.server.getServerUrl = () => `http://${location.hostname}:7780`;
const wsTarget = "ws://localhost:9093/";
console.info(...cl(`Connecting ${wsTarget} (try ${this.#connectTry})...`));
this.ws = new WebSocket(wsTarget);
++this.#connectTry;
this.ws.onerror = error => {
console.warn(...cl("Error on WS connection! Closing and reconnecting...", error.type));
// This should also trigger the `onclose` handler below and, thus, the reconnect.
this.ws?.close();
this.ws = null;
};
this.ws.onclose = () => {
console.warn(...cl("WS connection closed! Reconnecting..."));
this.ws?.close();
this.ws = null;
this.reconnect();
};
this.ws.onopen = () => {
console.info(...cl("WS connection established."));
this.#connectTry = 0;
this.postMessage({
type: "connected",
client_type: this.location.includes("headless.html") ? "headless" : "browser",
location: this.location,
guid: game.telemetry.guid,
});
};
this.ws.onmessage = event => {
const message = JSON.parse(event.data);
const response = this.processMessage(message);
if (!response) {
return;
}
this.postMessage(response);
};
}
processMessage(message) {
console.debug(...cl(`=> ${identifyExchange(message)} received.`));
switch (message.type) {
case "connected":
break;
case "getBuildings": {
const bonfire = game.bld.meta[0].meta.flatMap(building => {
if (building.stages) {
return building.stages.map((stage, index) => ({
group: "upgrade",
label: stage.label,
name: building.name,
on: index === building.stage ? building.on : 0,
tab: "Bonfire",
value: index === building.stage ? building.val : 0,
}));
}
return {
group: "base",
label: building.label ?? building.name,
name: building.name,
on: building.on,
tab: "Bonfire",
value: building.val,
};
});
const religionGroups = ["ziggurats", "orderOfTheSun", "cryptotheology", "pacts"];
const religion = game.religion.meta.flatMap((meta, index) => meta.meta.map(building => ({
group: religionGroups[index],
label: building.label,
name: building.name,
on: building.on ?? 0,
tab: "Religion",
value: building.val ?? 0,
})));
const spaceGroups = [
"groundControl",
"cath",
"moon",
"dune",
"piscine",
"helios",
"terminus",
"kairo",
"yarn",
"umbra",
"charon",
"centaurusSystem",
"furthestRing",
];
const space = game.space.meta.flatMap((meta, index) =>
// index 0 is moon missions
index === 0
? []
: meta.meta.map(building => ({
group: spaceGroups[index],
label: building.label,
name: building.name,
on: building.on ?? 0,
tab: "Space",
value: building.val ?? 0,
})));
const timeGroups = ["chronoForge", "voidSpace"];
const time = game.time.meta.flatMap((meta, index) => meta.meta.map(item => ({
group: timeGroups[index],
label: item.label,
name: item.name,
on: item.on ?? 0,
tab: "Time",
value: item.val ?? 0,
})));
return {
client_type: this.location.includes("headless.html") ? "headless" : "browser",
data: [...bonfire, ...religion, ...space, ...time],
guid: game.telemetry.guid,
location: this.location,
responseId: message.responseId,
type: message.type,
};
}
case "getCalendar": {
const data = [
{
cryptoPrice: game.calendar.cryptoPrice,
cycle: game.calendar.cycle,
cycleYear: game.calendar.cycleYear,
day: game.calendar.day,
eventChance: game.calendar.eventChance,
festivalDays: game.calendar.festivalDays,
futureSeasonTemporalParadox: game.calendar.futureSeasonTemporalParadox,
season: game.calendar.season,
seasonsPerYear: game.calendar.seasonsPerYear,
year: game.calendar.year,
yearsPerCycle: game.calendar.yearsPerCycle,
},
];
return {
client_type: this.location.includes("headless.html") ? "headless" : "browser",
data,
guid: game.telemetry.guid,
location: this.location,
responseId: message.responseId,
type: message.type,
};
}
case "getPollution": {
const producers = game.bld.meta[0].meta.filter(_ => !isNil(_.effects?.cathPollutionPerTickProd));
const consumers = game.bld.meta[0].meta.filter(_ => !isNil(_.effects?.cathPollutionPerTickCon));
const data = [
{
label: "Total",
name: "cathPollution",
// Could be simplified, but this syntax matches the function getPollutionMod of the game.
pollution: (game.bld.cathPollution / 10000000) * 100,
},
...producers.map(_ => ({
label: _.label ?? "",
name: _.name,
pollution: (_.effects?.cathPollutionPerTickProd ?? 0) * _.on,
})),
...consumers.map(_ => ({
label: _.label ?? "",
name: _.name,
pollution: (_.effects?.cathPollutionPerTickCon ?? 0) * _.on,
})),
];
return {
client_type: this.location.includes("headless.html") ? "headless" : "browser",
data,
guid: game.telemetry.guid,
location: this.location,
responseId: message.responseId,
type: message.type,
};
}
case "getRaces": {
const data = game.diplomacy.races.map(race => ({
embassyLevel: race.embassyLevel,
energy: race.energy,
name: race.name,
standing: race.standing,
title: race.title,
unlocked: race.unlocked,
}));
return {
client_type: this.location.includes("headless.html") ? "headless" : "browser",
data,
guid: game.telemetry.guid,
location: this.location,
responseId: message.responseId,
type: message.type,
};
}
case "getResourcePool": {
const isTimeParadox = this.game.calendar.day < 0;
const resources = this.game.resPool.resources.map(resource => {
let rate = (isTimeParadox ? 0 : this.game.getResourcePerTick(resource.name, true)) *
this.game.getTicksPerSecondUI();
if (resource.calculatePerDay === true) {
rate =
this.game.getResourcePerDay(resource.name) *
(resource.name === "necrocorn" ? 1 + this.game.timeAccelerationRatio() : 1);
}
else if (resource.calculateOnYear) {
rate = this.game.getResourceOnYearProduction(resource.name);
}
return {
name: resource.name,
value: resource.value ?? 0,
maxValue: resource.maxValue ?? 0,
label: resource.title,
craftable: resource.craftable ?? false,
rate: rate,
};
});
const pseudoResources = [
{
craftable: false,
label: "Worship",
maxValue: Number.POSITIVE_INFINITY,
name: "worship",
rate: 0,
value: game.religion.faith,
},
{
craftable: false,
label: "Epiphany",
maxValue: Number.POSITIVE_INFINITY,
name: "epiphany",
rate: 0,
value: game.religion.faithRatio,
},
{
craftable: false,
label: "Necrocorn deficit",
maxValue: Number.POSITIVE_INFINITY,
name: "necrocornDeficit",
rate: 0,
value: game.religion.pactsManager.necrocornDeficit,
},
];
return {
client_type: this.location.includes("headless.html") ? "headless" : "browser",
data: [...resources, ...pseudoResources],
guid: game.telemetry.guid,
location: this.location,
responseId: message.responseId,
type: message.type,
};
}
case "getStatistics": {
const data = game.stats.statGroups.flatMap((group, index) => group.group.map(member => ({
name: member.name,
label: member.title,
type: index === 0 ? "all_time" : "current",
value: member.val,
})));
return {
client_type: this.location.includes("headless.html") ? "headless" : "browser",
data,
guid: game.telemetry.guid,
location: this.location,
responseId: message.responseId,
type: message.type,
};
}
case "getTechnologies": {
const data = game.science.techs
.filter(tech => !TechnologiesIgnored.includes(tech.name))
.map(tech => ({
name: tech.name,
label: tech.label,
researched: tech.researched,
unlocked: tech.unlocked,
tab: "Science",
}));
return {
client_type: this.location.includes("headless.html") ? "headless" : "browser",
data,
guid: game.telemetry.guid,
location: this.location,
responseId: message.responseId,
type: message.type,
};
}
case "injectSavegame": {
console.warn(...cl("=> Injecting savegame..."));
const data = message.data;
new SavegameLoader(this.game).load(data.saveData).catch(redirectErrorsToConsole(console));
break;
}
}
return undefined;
}
reportFrameListener = (event) => {
const location = window.location.toString().replace(/#$/, "");
this.postMessage({
client_type: location.includes("headless.html") ? "headless" : "browser",
data: event.detail,
guid: game.telemetry.guid,
location,
type: "reportFrame",
});
};
reportSavegameListener = (event) => {
const location = window.location.toString().replace(/#$/, "");
this.postMessage({
client_type: location.includes("headless.html") ? "headless" : "browser",
data: event.detail,
guid: game.telemetry.guid,
location,
type: "reportSavegame",
});
};
heartbeat() {
console.debug(...cl("Heartbeat"));
window.clearTimeout(this.#timeoutReconnect);
this.#timeoutReconnect = window.setTimeout(() => this.ws?.close(), 30000);
}
reconnect() {
if (-1 < this.#timeoutReconnect) {
return;
}
console.info(...cl("Reconnecting..."));
this.#timeoutReconnect = window.setTimeout(() => {
this.connect(this.#withAnalyticsBackend);
}, 5000);
}
postMessage(message) {
if (this.ws === null) {
return;
}
try {
this.ws.send(JSON.stringify(message));
if ("responseId" in message) {
console.debug(...cl(`<= ${identifyExchange(message)} fulfilled.`));
}
else {
console.debug(...cl(`<= ${identifyExchange(message)} dispatched.`));
}
}
catch (error) {
console.warn(...cl("Error while sending message. Closing socket.", error));
this.ws.onclose?.(new CloseEvent("close"));
}
}
start() {
if (this.#interval !== -1) {
return;
}
}
stop() {
window.clearInterval(this.#interval);
this.#interval = -1;
}
}
//# sourceMappingURL=KittenAnalysts.js.map