AntimatterDimensionsSourceCode/javascripts/core/black_hole.js

541 lines
21 KiB
JavaScript

import { DC } from "./constants";
import { SpeedrunMilestones } from "./speedrun";
class BlackHoleUpgradeState {
constructor(config) {
const { getAmount, setAmount, calculateValue, initialCost, costMult } = config;
this.incrementAmount = () => setAmount(getAmount() + 1);
this._lazyValue = new Lazy(() => calculateValue(getAmount()));
this._lazyCost = new Lazy(() => getHybridCostScaling(getAmount(),
1e30,
initialCost,
costMult,
0.2,
DC.E310,
1e5,
10));
this.id = config.id;
this.hasAutobuyer = config.hasAutobuyer;
this.onPurchase = config.onPurchase;
}
get value() {
return this._lazyValue.value;
}
get cost() {
return this._lazyCost.value;
}
get isAffordable() {
return Currency.realityMachines.gte(this.cost);
}
purchase() {
if (!this.isAffordable || this.value === 0) return;
Currency.realityMachines.purchase(this.cost);
this.incrementAmount();
this._lazyValue.invalidate();
this._lazyCost.invalidate();
if (this.onPurchase) {
this.onPurchase();
}
EventHub.dispatch(GAME_EVENT.BLACK_HOLE_UPGRADE_BOUGHT);
}
}
class BlackHoleState {
constructor(id) {
this.id = id + 1;
const blackHoleCostMultipliers = [1, 1000];
// Interval: starts at 3600, x0.8 per upgrade, upgrade cost goes x3.5, starts at 15
this.intervalUpgrade = new BlackHoleUpgradeState({
id: this.id,
getAmount: () => this._data.intervalUpgrades,
setAmount: amount => this._data.intervalUpgrades = amount,
calculateValue: amount => (3600 / (Math.pow(10, id))) * Math.pow(0.8, amount),
initialCost: 15 * blackHoleCostMultipliers[id],
costMult: 3.5,
hasAutobuyer: false,
onPurchase: () => {
if (!this.isCharged) {
this._data.phase = Math.clampMax(this.interval, this._data.phase);
}
}
});
// Power: starts at 5, x1.35 per upgrade, cost goes x2, starts at 20
this.powerUpgrade = new BlackHoleUpgradeState({
id: this.id,
getAmount: () => this._data.powerUpgrades,
setAmount: amount => this._data.powerUpgrades = amount,
calculateValue: amount => (180 / Math.pow(2, id)) * Math.pow(1.35, amount),
initialCost: 20 * blackHoleCostMultipliers[id],
costMult: 2,
hasAutobuyer: true
});
// Duration: starts at 10, x1.5 per upgrade, cost goes x4, starts at 10
this.durationUpgrade = new BlackHoleUpgradeState({
id: this.id,
getAmount: () => this._data.durationUpgrades,
setAmount: amount => this._data.durationUpgrades = amount,
calculateValue: amount => (10 - (id) * 3) * Math.pow(1.3, amount),
initialCost: 10 * blackHoleCostMultipliers[id],
costMult: 4,
hasAutobuyer: false
});
}
/**
* @private
*/
get _data() {
return player.blackHole[this.id - 1];
}
/**
* Exists to avoid recursion in calculation of whether the black hole is permanent.
*/
get rawInterval() {
return this.intervalUpgrade.value * Achievement(145).effectOrDefault(1);
}
/**
* Amount of time the black hole is inactive for between activations.
*/
get interval() {
return this.isPermanent ? 0 : this.rawInterval;
}
/**
* Multiplier to time the black hole gives when active.
*/
get power() {
return this.powerUpgrade.value * Achievement(158).effectOrDefault(1);
}
/**
* Amount of time the black hole is active for.
*/
get duration() {
return this.durationUpgrade.value * Achievement(155).effectOrDefault(1);
}
get isUnlocked() {
return this._data.unlocked && !Enslaved.isRunning && !Pelle.isDisabled("blackhole");
}
get isCharged() {
return this._data.active;
}
get timeWithPreviousActiveToNextStateChange() {
return this.isCharged ? this.duration - this.phase : this.interval - this.phase;
}
// When inactive, returns time until active; when active, returns time until inactive (or paused for hole 2)
get timeToNextStateChange() {
let remainingTime = this.timeWithPreviousActiveToNextStateChange;
if (this.id === 1) return remainingTime;
// 2nd hole activation logic (not bothering generalizing since we're not adding that 3rd hole again)
if (this.isCharged) {
if (BlackHole(1).isCharged) return Math.min(remainingTime, BlackHole(1).timeToNextStateChange);
return BlackHole(1).timeToNextStateChange;
}
if (BlackHole(1).isCharged) {
if (remainingTime < BlackHole(1).timeToNextStateChange) return remainingTime;
remainingTime -= BlackHole(1).timeToNextStateChange;
}
let totalTime = BlackHole(1).isCharged
? BlackHole(1).timeToNextStateChange + BlackHole(1).interval
: BlackHole(1).timeToNextStateChange;
totalTime += Math.floor(remainingTime / BlackHole(1).duration) * BlackHole(1).cycleLength;
totalTime += remainingTime % BlackHole(1).duration;
return totalTime;
}
// This is a value which counts up from 0 to 1 when inactive, and 1 to 0 when active
get stateProgress() {
if (this.isCharged) {
return 1 - this.phase / this.duration;
}
return this.phase / this.interval;
}
// The logic to determine what state the black hole is in for displaying is nontrivial and used in multiple places
get displayState() {
if (Pelle.isDisabled("blackhole")) return `<i class="fas fa-ban"></i> Disabled`;
if (Enslaved.isAutoReleasing) {
if (Enslaved.autoReleaseTick < 3) return `<i class="fas fa-compress-arrows-alt u-fa-padding"></i> Pulsing`;
return `<i class="fas fa-expand-arrows-alt u-fa-padding"></i> Pulsing`;
}
if (Enslaved.isStoringGameTime) {
if (Ra.unlocks.adjustableStoredTime.canBeApplied) {
const storedTimeWeight = player.celestials.enslaved.storedFraction;
if (storedTimeWeight !== 0) {
return `<i class="fas fa-compress-arrows-alt"></i> Charging (${formatPercents(storedTimeWeight, 1)})`;
}
} else {
return `<i class="fas fa-compress-arrows-alt"></i> Charging`;
}
}
if (BlackHoles.areNegative) return `<i class="fas fa-caret-left"></i> Inverted`;
if (BlackHoles.arePaused) return `<i class="fas fa-pause"></i> Paused`;
if (this.isPermanent) return `<i class="fas fa-infinity"></i> Permanent`;
const timeString = TimeSpan.fromSeconds(this.timeToNextStateChange).toStringShort(true);
if (this.isActive) return `<i class="fas fa-play"></i> Active (${timeString})`;
return `<i class="fas fa-redo"></i> Inactive (${timeString})`;
}
get isActive() {
return this.isCharged && (this.id === 1 || BlackHole(this.id - 1).isActive) && !Pelle.isDisabled("blackhole");
}
get isPermanent() {
// If the black hole is active 99.99% of the time, the duration is exactly
// 9999 times longer than the interval.
return this.duration / this.rawInterval >= 9999;
}
/**
* Amount of time the black hole has spent since last state transition,
* so if it's active, it's the amount of time it's been active for, and if it's inactive,
* it's the amount of time it's been inactive for.
*/
get phase() {
return this._data.phase;
}
get cycleLength() {
return this.interval + this.duration;
}
updatePhase(activePeriod) {
if (this.isPermanent) return;
// Prevents a flickering black hole if phase gets set too high
// (shouldn't ever happen in practice). Also, more importantly,
// should work even if activePeriods[i] is very large. To check:
// This used to always use the period of blackHole[0], now it doesn't,
// will this cause other bugs?
this._data.phase += activePeriod;
// This conditional is a bit convoluted because the more straightforward check of just pausing if it activates
// soon will result in it pausing every tick, including the tick it gets manually unpaused. This is unintuitive
// because it forces the player to change auto-pause modes every time it reaches activation again. Instead, we
// check if before the conditional is false before this tick and true afterwards; this ensures it only ever pauses
// once per cycle, right at the activation threshold. We give it a buffer equal to the acceleration time so that
// it's at full speed once by the time it actually activates.
const beforeTick = this.phase - activePeriod, afterTick = this.phase;
const threhold = this.interval - BlackHoles.ACCELERATION_TIME;
const willActivateOnUnpause = !this.isActive && beforeTick < threhold && afterTick >= threhold;
switch (player.blackHoleAutoPauseMode) {
case BLACK_HOLE_PAUSE_MODE.NO_PAUSE:
break;
case BLACK_HOLE_PAUSE_MODE.PAUSE_BEFORE_BH1:
if (this.id === 1 && willActivateOnUnpause) {
BlackHoles.togglePause();
GameUI.notify.blackHole(`${RealityUpgrade(20).isBought ? "Black Holes" : "Black Hole"}
automatically paused.`);
return;
}
break;
case BLACK_HOLE_PAUSE_MODE.PAUSE_BEFORE_BH2:
if (willActivateOnUnpause && (this.id === 2 || (this.id === 1 && BlackHole(2).isCharged))) {
BlackHoles.togglePause();
GameUI.notify.blackHole(`Black Holes automatically paused.`);
return;
}
break;
default:
throw new Error("Unrecognized BH offline pausing mode");
}
if (this.phase >= this.cycleLength) {
// One activation for each full cycle.
this._data.activations += Math.floor(this.phase / this.cycleLength);
this._data.phase %= this.cycleLength;
}
if (this.isCharged) {
if (this.phase >= this.duration) {
this._data.phase -= this.duration;
this._data.active = false;
if (GameUI.notify.showBlackHoles) {
GameUI.notify.blackHole(`${this.description(true)} duration ended.`);
}
}
} else if (this.phase >= this.interval) {
this._data.phase -= this.interval;
this._data.activations++;
this._data.active = true;
if (GameUI.notify.showBlackHoles) {
GameUI.notify.blackHole(`${this.description(true)} has activated!`);
}
}
}
/**
* Given the time for which the previous black hole is active,
* this function returns the time for which current black hole is active.
* For example, for BlackHole(2), this function, given
* the time for which for BlackHole(1) is active, will return the time for which
* BlackHole(2) is active during that time.
*/
realTimeWhileActive(time) {
const nextDeactivation = this.timeUntilNextDeactivation;
const cooldown = this.interval;
const duration = this.duration;
const fullCycle = this.cycleLength;
const currentActivationDuration = Math.min(nextDeactivation, duration);
const activeCyclesUntilLastDeactivation = Math.floor((time - nextDeactivation) / fullCycle);
const activeTimeUntilLastDeactivation = duration * activeCyclesUntilLastDeactivation;
const timeLeftAfterLastDeactivation = (time - nextDeactivation + fullCycle) % fullCycle;
const lastActivationDuration = Math.max(timeLeftAfterLastDeactivation - cooldown, 0);
return currentActivationDuration + activeTimeUntilLastDeactivation + lastActivationDuration;
}
/**
* Returns the time that the previous black hole must be active until the next change
* from the active state to the inactive state. For example, for BlackHole(2),
* this function will return the time BlackHole(1) must be active for BlackHole(2)
* to transition to the inactive state. This is useful since BlackHole(2)'s phase
* only increases (that is, its state only changes) while BlackHole(1) is active.
* In general, a black hole only changes state while the previous black hole is active.
* So figuring out how long a black hole would be active after some amount of real time
* (as we do) is best done iteratively via figuring out how long a black hole would be active
* after a given amount of time of the previous black hole being active.
*/
get timeUntilNextDeactivation() {
if (this.isCharged) {
return this.duration - this.phase;
}
return this.cycleLength - this.phase;
}
description(capitalized) {
if (RealityUpgrade(20).isBought) {
return `Black Hole ${this.id}`;
}
return capitalized ? "The Black Hole" : "the Black Hole";
}
}
BlackHoleState.list = Array.range(0, 2).map(id => new BlackHoleState(id));
/**
* @param {number} id
* @return {BlackHoleState}
*/
export function BlackHole(id) {
return BlackHoleState.list[id - 1];
}
export const BlackHoles = {
// In seconds
ACCELERATION_TIME: 5,
/**
* @return {BlackHoleState[]}
*/
get list() {
return BlackHoleState.list;
},
get canBeUnlocked() {
return Currency.realityMachines.gte(100) && !this.areUnlocked;
},
get areUnlocked() {
return BlackHole(1).isUnlocked;
},
unlock() {
if (!this.canBeUnlocked) return;
player.blackHole[0].unlocked = true;
Currency.realityMachines.purchase(100);
SpeedrunMilestones(17).tryComplete();
Achievement(144).unlock();
EventHub.dispatch(GAME_EVENT.BLACK_HOLE_UNLOCKED);
},
togglePause: () => {
if (!BlackHoles.areUnlocked) return;
if (player.blackHolePause) player.requirementChecks.reality.slowestBH = 1;
player.blackHolePause = !player.blackHolePause;
player.blackHolePauseTime = player.records.realTimePlayed;
const pauseType = BlackHoles.areNegative ? "inverted" : "paused";
GameUI.notify.blackHole(player.blackHolePause ? `Black Hole ${pauseType}` : "Black Hole unpaused");
},
get unpauseAccelerationFactor() {
if (this.arePermanent) return 1;
return Math.clamp((player.records.realTimePlayed - player.blackHolePauseTime) /
(1000 * this.ACCELERATION_TIME), 0, 1);
},
get arePaused() {
return player.blackHolePause;
},
get areNegative() {
return this.arePaused && player.blackHoleNegative < 1;
},
get arePermanent() {
return BlackHoles.list.every(bh => bh.isPermanent);
},
updatePhases(blackHoleDiff) {
if (!this.areUnlocked || this.arePaused) return;
// This code is intended to successfully update the black hole phases
// even for very large values of blackHoleDiff.
const seconds = blackHoleDiff / 1000;
const activePeriods = this.realTimePeriodsWithBlackHoleActive(seconds);
for (const blackHole of this.list) {
if (!blackHole.isUnlocked) break;
blackHole.updatePhase(activePeriods[blackHole.id - 1]);
}
},
/**
* This function takes the total real time spent offline,
* a number of ticks to simulate, a tolerance for how far ticks can be
* from average (explained later), and returns a single realTickTime and
* blackHoleSpeed representing the real time taken up by the first simulated tick
* and the game speed due to black holess during it.
*
* This code makes sure that the following conditions are satisfied:
* 1: realTickTime * blackHoleSpeed is exactly (up to some small
* multiple of floating-point precision) the game time which would be spent
* after realTickTime real time, accounting for black holess
* (but not for anything else).
* 2: No tick contains too much (more than a constant multiple of
* the mean game time per tick) of the game time.
* 3: No tick has negative or zero real time or (equivalently)
* negative or zero game time.
* Note that Patashu has convinced me that we do not want the property
* "No tick contains too much (more than a constant multiple of the
* mean real time per tick) of the real time." There's no reason to have it
* aside from the edge cases of EC12 (and if you're going offline during EC12
* then you should expect technically correct but somewhat annoying behavior)
* and auto EC completion (but auto EC completion shouldn't be that much
* of an issue).
*/
calculateOfflineTick(totalRealTime, numberOfTicks, tolerance) {
// Cache speedups, so calculateGameTimeFromRealTime doesn't recalculate them every time.
const speedups = this.calculateSpeedups();
const totalGameTime = this.calculateGameTimeFromRealTime(totalRealTime, speedups);
// We have this special case just in case some floating-point mess prevents
// binarySearch from working in the numberOfTicks = 1 case.
// I doubt that's possible but it seems worth handling just in case.
if (numberOfTicks === 1) {
return [totalRealTime, totalGameTime / totalRealTime];
}
// We want calculateGameTimeFromRealTime(realTickTime, speedups) * numberOfTicks / totalGameTime to be roughly 1
// (that is, the tick taking realTickTime real time has roughly average length in terms of game time).
// We use binary search because it has somewhat better worst-case behavior than linear interpolation search here.
// Suppose you have 3000 seconds without a black hole and then 100 seconds of a black hole with 3000x power,
// and you want to find when 4000 seconds of game time have elapsed. With binary search it will take only
// 20 steps or so to get reasonable accuracy, but with linear interpolation it will take about 100 steps.
// These extra steps might always average out with cases where linear interpolation is quicker though.
const realTickTime = this.binarySearch(
0,
totalRealTime,
x => this.calculateGameTimeFromRealTime(x, speedups) * numberOfTicks / totalGameTime,
1,
tolerance
);
const blackHoleSpeedup = this.calculateGameTimeFromRealTime(realTickTime, speedups) / realTickTime;
return [realTickTime, blackHoleSpeedup];
},
/**
* Standard implementation of binary search for a monotone increasing function.
* The only unusual thing is tolerance, which is a bound on
* Math.abs(evaluationFunction(result) - target).
*/
// eslint-disable-next-line max-params
binarySearch(start, end, evaluationFunction, target, tolerance) {
let middle;
for (let iter = 0; iter < 100; ++iter) {
middle = (start + end) / 2;
const error = evaluationFunction(middle) - target;
if (Math.abs(error) < tolerance) break;
if (error < 0) {
// eslint-disable-next-line no-param-reassign
start = middle;
} else {
// eslint-disable-next-line no-param-reassign
end = middle;
}
}
return middle;
},
/**
* Returns a list of length (number of unlocked black holes + 1),
* where each element is the *total* speedup while that black hole
* is the highest-numbered black hole active, the black holes being numbered
* starting from black hole 1 and black hole 0 being normal game.
*/
calculateSpeedups() {
const effectsToConsider = [GAME_SPEED_EFFECT.FIXED_SPEED, GAME_SPEED_EFFECT.TIME_GLYPH,
GAME_SPEED_EFFECT.SINGULARITY_MILESTONE, GAME_SPEED_EFFECT.NERFS];
const speedupWithoutBlackHole = getGameSpeedupFactor(effectsToConsider);
const speedups = [1];
effectsToConsider.push(GAME_SPEED_EFFECT.BLACK_HOLE);
for (const blackHole of this.list) {
if (!blackHole.isUnlocked) break;
speedups.push(getGameSpeedupFactor(effectsToConsider, blackHole.id) / speedupWithoutBlackHole);
}
return speedups;
},
calculateGameTimeFromRealTime(realTime, speedups) {
const effectivePeriods = this.realTimePeriodsWithBlackHoleEffective(realTime, speedups);
return effectivePeriods
.map((period, i) => period * speedups[i])
.sum();
},
/**
* Returns the amount of real time spent with each unlocked black hole
* being the current "effective" black hole, that is, the active black hole
* with the highest index.
* For example:
* active periods = [100, 20, 5] (100ms of real time, 20ms of black hole 1, 5ms of black hole 2)
* effective periods = [80, 15, 5]
* 80ms of effective real time, because black hole 1 will be running in total 20ms => 100 - 20
* 15ms of effective black hole 1 time, because black hole 2 will be running in total 5ms => 20 - 5
* 5ms of effective black hole 2 time, because no higher black hole overlaps it,
* so it is effective for the whole active period
* Note: even though more than one black hole can be active
* (and thus effective) at once, the calling function first calculates the total speedups
* while each black hole is the highest-index black hole that's active and then acts
* as if only the highest-index black hole that's active is effective.
*/
realTimePeriodsWithBlackHoleEffective(realTime) {
const activePeriods = this.realTimePeriodsWithBlackHoleActive(realTime);
const effectivePeriods = [];
for (let i = 0; i < activePeriods.length - 1; i++) {
effectivePeriods.push(activePeriods[i] - activePeriods[i + 1]);
}
effectivePeriods.push(activePeriods.last());
return effectivePeriods;
},
/**
* Returns an array of real time periods spent in each black hole
* with first element being the "no black hole" state that is normal game.
*/
realTimePeriodsWithBlackHoleActive(realTime) {
const activePeriods = [realTime];
for (const blackHole of this.list) {
if (!blackHole.isUnlocked) break;
const activeTime = blackHole.realTimeWhileActive(activePeriods.last());
activePeriods.push(activeTime);
}
return activePeriods;
}
};