Museum Day, Flushies, and Xanax travel optimizer for Torn
// ==UserScript==
// @name LostMaster Travel
// @namespace lostmaster.travel
// @version 2.0.8
// @description Museum Day, Flushies, and Xanax travel optimizer for Torn
// @author LostMaster
// @license MIT
// @match https://www.torn.com/*
// @grant GM_xmlhttpRequest
// @connect yata.yt
// ==/UserScript==
(function () {
'use strict';
const APP = 'LostMaster Travel';
const SHORT_APP = 'LMT';
const VERSION = '2.0.8';
const API_BASE = 'https://api.torn.com/v2';
const YATA_URL =
'https://yata.yt/api/v1/travel/export/';
const ROOT_ID = 'lmt-root';
const STYLE_ID = 'lmt-style';
const STORAGE = {
key: 'lmt_api_key',
inventory: 'lmt_inventory_cache',
mode: 'lmt_mode',
profile: 'lmt_travel_profile',
minimized: 'lmt_minimized',
position: 'lmt_panel_position',
country: 'lmt_last_foreign_location',
yata: 'lmt_yata_cache',
prices: 'lmt_trusted_price_cache',
sizeDesktop: 'lmt_panel_size_desktop',
sizeMobile: 'lmt_panel_size_mobile'
};
const YATA_TTL =
60 * 1000;
const YATA_FAILURE_RETRY_MS =
60 * 1000;
const PRICE_TTL =
30 * 60 * 1000;
const INVENTORY_TTL =
60 * 60 * 1000;
const STOCK_STALE_MS =
10 * 60 * 1000;
const WATCH_MS =
500;
const BUY_FREEZE_MS =
15000;
/*
* Torn asks API clients to stop using disabled / invalid keys
* instead of repeatedly retrying them.
*
* 1 = key empty
* 2 = incorrect key
* 10 = key owner in federal jail
* 13 = key disabled due to owner inactivity
* 16 = access level too low
* 18 = key paused by owner
*/
const KEY_STOP_ERROR_CODES =
new Set([
1,
2,
10,
13,
16,
18
]);
/*
* Trusted market pricing:
*
* - inspect up to the cheapest 1,000 actual units
* - use the price at the 25% depth point
*
* Example:
* unit 250 of the cheapest 1,000.
*/
const MARKET_TARGET_UNITS =
1000;
const MARKET_DEPTH_PERCENT =
0.25;
const MARKET_MAX_PAGES =
20;
/*
* Xanax prices abroad fluctuate.
*
* The buffer only affects the amount of cash LMT recommends
* carrying. It does NOT change estimated profit.
*/
const XANAX_CASH_BUFFER_PERCENT =
0.02;
const XANAX_CASH_BUFFER_MIN =
250000;
/*
* Quick / Extended are strategic destination groups.
*
* Travel-speed perks change displayed times and profit/hour,
* but they do NOT move countries between groups.
*/
const QUICK_COUNTRIES =
new Set([
'Mexico',
'Cayman Islands',
'Canada'
]);
// ============================================================
// TRAVEL DATA
// ============================================================
const TRAVEL = {
'Mexico': {
minutes: 24,
city: 'Ciudad Juarez',
cost: 6500,
yata: 'mex'
},
'Cayman Islands': {
minutes: 33,
city: 'George Town',
cost: 10000,
yata: 'cay'
},
'Canada': {
minutes: 39,
city: 'Toronto',
cost: 9000,
yata: 'can'
},
'Hawaii': {
minutes: 127,
city: 'Honolulu',
cost: 11000,
yata: 'haw'
},
'United Kingdom': {
minutes: 151,
city: 'London',
cost: 18000,
yata: 'uni'
},
'Argentina': {
minutes: 158,
city: 'Buenos Aires',
cost: 21000,
yata: 'arg'
},
'Switzerland': {
minutes: 166,
city: 'Zurich',
cost: 27000,
yata: 'swi'
},
'Japan': {
minutes: 213,
city: 'Tokyo',
cost: 32000,
yata: 'jap'
},
'China': {
minutes: 229,
city: 'Beijing',
cost: 35000,
yata: 'chi'
},
'United Arab Emirates': {
minutes: 257,
city: 'Dubai',
cost: 32000,
yata: 'uae'
},
'South Africa': {
minutes: 282,
city: 'Johannesburg',
cost: 40000,
yata: 'sou'
}
};
const YATA_COUNTRY =
Object.fromEntries(
Object.entries(TRAVEL)
.map(
([country, data]) => [
data.yata,
country
]
)
);
// ============================================================
// MUSEUM DATA
// ============================================================
/*
* Museum mode deliberately excludes:
*
* Hawaii:
* Orchid
*
* Switzerland:
* Chamois Plushie
* Edelweiss
*
* Japan:
* Cherry Blossom
*
* Torn-native plushies are also not tracked.
*/
const MUSEUM_ITEMS = [
{
name: 'Jaguar Plushie',
type: 'plushie',
country: 'Mexico'
},
{
name: 'Stingray Plushie',
type: 'plushie',
country: 'Cayman Islands'
},
{
name: 'Wolverine Plushie',
type: 'plushie',
country: 'Canada'
},
{
name: 'Nessie Plushie',
type: 'plushie',
country: 'United Kingdom'
},
{
name: 'Red Fox Plushie',
type: 'plushie',
country: 'United Kingdom'
},
{
name: 'Monkey Plushie',
type: 'plushie',
country: 'Argentina'
},
{
name: 'Panda Plushie',
type: 'plushie',
country: 'China'
},
{
name: 'Camel Plushie',
type: 'plushie',
country: 'United Arab Emirates'
},
{
name: 'Lion Plushie',
type: 'plushie',
country: 'South Africa'
},
{
name: 'Dahlia',
type: 'flower',
country: 'Mexico'
},
{
name: 'Banana Orchid',
type: 'flower',
country: 'Cayman Islands'
},
{
name: 'Crocus',
type: 'flower',
country: 'Canada'
},
{
name: 'Heather',
type: 'flower',
country: 'United Kingdom'
},
{
name: 'Ceibo Flower',
type: 'flower',
country: 'Argentina'
},
{
name: 'Peony',
type: 'flower',
country: 'China'
},
{
name: 'Tribulus Omanense',
type: 'flower',
country: 'United Arab Emirates'
},
{
name: 'African Violet',
type: 'flower',
country: 'South Africa'
}
];
const MUSEUM_COUNTRIES =
Object.fromEntries(
Object.keys(TRAVEL)
.map(
country => [
country,
MUSEUM_ITEMS
.filter(
item =>
item.country ===
country
)
.map(
item =>
item.name
)
]
)
.filter(
([, items]) =>
items.length > 0
)
);
// ============================================================
// FLUSHIES DATA
// ============================================================
/*
* Flushies includes EVERY foreign flower / plushie worth
* comparing for resale, including locations deliberately
* excluded from Museum mode.
*/
const FLUSHIE_NAMES =
new Set([
...MUSEUM_ITEMS.map(
item =>
item.name
),
'Orchid',
'Chamois Plushie',
'Edelweiss',
'Cherry Blossom'
]);
// ============================================================
// MODES / PROFILE
// ============================================================
const MODES = {
museum: 'Museum Day',
flushies: 'Flushies',
xanax: 'Xanax'
};
const METHOD_MULTIPLIER = {
standard: 1,
airstrip: 0.70,
wlt: 0.50,
business: 0.30
};
const DEFAULT_PROFILE = {
method: 'standard',
mailingBook: false,
capacity: 28,
businessTicketsFree: false
};
/*
* Business Class Ticket item ID.
*
* Only used when Business Class is selected and tickets
* are not configured as free.
*/
const BCT_ITEM_ID =
396;
// ============================================================
// RUNTIME STATE
// ============================================================
let settingsOpen =
false;
let changeKeyOpen =
false;
let inventoryOpen =
false;
let inventoryBusy =
false;
let dataBusy =
false;
let dataError =
'';
let yataWarning =
'';
let yataRetryAfter =
0;
let lastSignature =
'';
let buyFrozen =
false;
let freezeStarted =
0;
let confirmationSeen =
false;
let drag =
null;
let dragInstalled =
false;
let resizeState =
null;
let resizeInstalled =
false;
let profileSavedUntil =
0;
/*
* Persisted only in runtime so a bad stored key can be removed
* while still telling the user why LMT returned to Setup.
*/
let apiKeyNotice =
'';
// ============================================================
// GENERAL HELPERS
// ============================================================
function norm(value) {
return String(
value || ''
)
.trim()
.toLowerCase();
}
function esc(value) {
const div =
document.createElement(
'div'
);
div.textContent =
String(
value ?? ''
);
return div.innerHTML;
}
function num(value) {
const match =
String(
value || ''
)
.replace(
/,/g,
''
)
.match(
/\d+/
);
return match
? Number(
match[0]
)
: null;
}
function money(value) {
if (
!Number.isFinite(
Number(value)
)
) {
return '—';
}
return '$' +
Math.round(
Number(value)
)
.toLocaleString();
}
function duration(minutes) {
const m =
Math.max(
0,
Math.round(
Number(minutes) ||
0
)
);
if (
m < 60
) {
return m +
'm';
}
return (
Math.floor(
m / 60
) +
'h ' +
(
m %
60
) +
'm'
);
}
function sleep(ms) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
function mobilePanelProfile() {
return (
window.matchMedia(
'(pointer: coarse)'
).matches ||
window.innerWidth <= 700
);
}
function panelSizeStorageKey() {
return mobilePanelProfile()
? STORAGE.sizeMobile
: STORAGE.sizeDesktop;
}
function savedPanelSize() {
return readJson(
panelSizeStorageKey()
);
}
function panelSizeLimits() {
return {
minWidth:
Math.min(
260,
Math.max(
180,
window.innerWidth - 20
)
),
minHeight:
Math.min(
220,
Math.max(
160,
window.innerHeight - 20
)
),
maxWidth:
Math.max(
180,
window.innerWidth - 20
),
maxHeight:
Math.max(
160,
window.innerHeight - 20
)
};
}
function clampPanelSize(
width,
height
) {
const limits =
panelSizeLimits();
return {
width:
Math.min(
limits.maxWidth,
Math.max(
limits.minWidth,
Number(width) ||
limits.minWidth
)
),
height:
Math.min(
limits.maxHeight,
Math.max(
limits.minHeight,
Number(height) ||
limits.minHeight
)
)
};
}
function applyPanelSize(
panel
) {
const saved =
savedPanelSize();
if (
saved &&
Number.isFinite(
saved.width
) &&
Number.isFinite(
saved.height
)
) {
const size =
clampPanelSize(
saved.width,
saved.height
);
panel.style.width =
size.width +
'px';
panel.style.height =
'';
panel.style.maxHeight =
size.height +
'px';
panel.classList.add(
'sized'
);
}
else {
panel.style.width =
'';
panel.style.height =
'';
panel.style.maxHeight =
'';
panel.classList.remove(
'sized'
);
}
}
function savePanelSize(
width,
height
) {
const size =
clampPanelSize(
width,
height
);
writeJson(
panelSizeStorageKey(),
size
);
return size;
}
// ============================================================
// STORAGE
// ============================================================
function readJson(
key,
fallback = null
) {
try {
const value =
JSON.parse(
localStorage.getItem(
key
) ||
'null'
);
return value ??
fallback;
}
catch (_) {
return fallback;
}
}
function writeJson(
key,
value
) {
localStorage.setItem(
key,
JSON.stringify(
value
)
);
}
function cacheGet(
key,
ttl =
Number.MAX_SAFE_INTEGER
) {
const cache =
readJson(
key
);
if (
!cache ||
!Number.isFinite(
cache.updated
)
) {
return null;
}
if (
Date.now() -
cache.updated >
ttl
) {
return null;
}
return cache.data ??
null;
}
function cacheSet(
key,
data
) {
writeJson(
key,
{
updated:
Date.now(),
data
}
);
}
function getKey() {
return (
localStorage.getItem(
STORAGE.key
) ||
''
);
}
function setKey(key) {
localStorage.setItem(
STORAGE.key,
String(
key ||
''
).trim()
);
apiKeyNotice =
'';
}
function removeStoredKey() {
localStorage.removeItem(
STORAGE.key
);
}
function keyStopMessage(
code,
fallback
) {
if (
code ===
16
) {
return (
'Saved API key does not have enough access. ' +
'LMT requires a Minimal Access key. ' +
'Please enter a new key.'
);
}
if (
code ===
13
) {
return (
'Saved API key is disabled because the key owner ' +
'has been inactive. Please enter an active key.'
);
}
if (
code ===
18
) {
return (
'Saved API key has been paused by its owner. ' +
'Please enter an active key.'
);
}
if (
code ===
10
) {
return (
'Saved API key cannot currently be used. ' +
'Please enter another active key.'
);
}
return (
fallback ||
'Saved API key is invalid or unavailable. ' +
'Please enter a new key.'
);
}
function getMode() {
let mode =
localStorage.getItem(
STORAGE.mode
) ||
'museum';
/*
* 2.0.1 compatibility.
*/
if (
mode ===
'profit'
) {
mode =
'flushies';
localStorage.setItem(
STORAGE.mode,
mode
);
}
return MODES[
mode
]
? mode
: 'museum';
}
function setMode(mode) {
if (
MODES[
mode
]
) {
localStorage.setItem(
STORAGE.mode,
mode
);
}
}
function getProfile() {
const raw =
readJson(
STORAGE.profile,
{}
);
return {
method:
METHOD_MULTIPLIER[
raw.method
] !==
undefined
? raw.method
: DEFAULT_PROFILE
.method,
mailingBook:
Boolean(
raw.mailingBook
),
capacity:
Math.max(
1,
Math.min(
100,
Number(
raw.capacity
) ||
DEFAULT_PROFILE
.capacity
)
),
businessTicketsFree:
Boolean(
raw.businessTicketsFree
)
};
}
function saveProfile(profile) {
writeJson(
STORAGE.profile,
profile
);
}
function effectiveMinutes(country) {
const destination =
TRAVEL[
country
];
if (
!destination
) {
return Infinity;
}
const profile =
getProfile();
const bookMultiplier =
profile.mailingBook
? 0.75
: 1;
return Math.max(
1,
Math.round(
destination.minutes *
METHOD_MULTIPLIER[
profile.method
] *
bookMultiplier
)
);
}
function bucket(country) {
return QUICK_COUNTRIES
.has(
country
)
? 'quick'
: 'extended';
}
function rememberCountry(country) {
if (
TRAVEL[
country
]
) {
localStorage.setItem(
STORAGE.country,
country
);
}
}
function rememberedCountry() {
const country =
localStorage.getItem(
STORAGE.country
);
return TRAVEL[
country
]
? country
: null;
}
function clearCountry() {
localStorage.removeItem(
STORAGE.country
);
}
// ============================================================
// INVENTORY CACHE
// ============================================================
function emptyInventory() {
return Object.fromEntries(
MUSEUM_ITEMS.map(
item => [
item.name,
0
]
)
);
}
function getInventoryCache() {
const cache =
readJson(
STORAGE.inventory
);
if (
!cache?.items
) {
return {
items:
emptyInventory(),
updated:
0
};
}
return cache;
}
function saveInventory(items) {
writeJson(
STORAGE.inventory,
{
items,
updated:
Date.now()
}
);
}
// ============================================================
// TRUSTED PRICE CACHE
// ============================================================
function getPriceCache() {
const cache =
readJson(
STORAGE.prices,
{
items: {}
}
);
if (
!cache ||
typeof cache !==
'object'
) {
return {
items: {}
};
}
if (
!cache.items ||
typeof cache.items !==
'object'
) {
cache.items =
{};
}
return cache;
}
function savePriceCache(cache) {
writeJson(
STORAGE.prices,
cache
);
}
function cachedTrustedPrice(
itemId,
allowStale = false
) {
const cache =
getPriceCache();
const entry =
cache.items[
String(
itemId
)
];
if (
!entry ||
!Number.isFinite(
entry.updated
)
) {
return null;
}
if (
!allowStale &&
Date.now() -
entry.updated >
PRICE_TTL
) {
return null;
}
return entry;
}
function storeTrustedPrice(
itemId,
entry
) {
const cache =
getPriceCache();
cache.items[
String(
itemId
)
] = {
...entry,
updated:
Date.now()
};
savePriceCache(
cache
);
}
// ============================================================
// PAGE TEXT
// ============================================================
function pageText() {
if (
!document.body
) {
return '';
}
const clone =
document.body
.cloneNode(
true
);
clone
.querySelector(
'#' +
ROOT_ID
)
?.remove();
return String(
clone.textContent ||
''
)
.replace(
/\s+/g,
' '
)
.trim();
}
function pageLower() {
return norm(
pageText()
);
}
// ============================================================
// TORN API
// ============================================================
async function rawApi(
path,
params = {},
keyOverride = null
) {
const key =
keyOverride !==
null
? keyOverride
: getKey();
if (
!key
) {
throw new Error(
'No API key configured.'
);
}
const url =
new URL(
API_BASE +
path
);
for (
const [
name,
value
]
of Object.entries(
params
)
) {
url.searchParams.set(
name,
String(
value
)
);
}
url.searchParams.set(
'key',
key
);
const response =
await fetch(
url.toString()
);
if (
!response.ok
) {
throw new Error(
'Torn API HTTP ' +
response.status +
'.'
);
}
const data =
await response.json();
if (
data?.error
) {
const code =
Number(
data.error.code
);
const message =
data.error.error ||
'Torn API error.';
/*
* Only remove the stored key when this request used the
* saved key. A bad candidate entered in Change Key must
* not destroy the currently working saved key.
*/
if (
keyOverride ===
null &&
KEY_STOP_ERROR_CODES.has(
code
)
) {
removeStoredKey();
apiKeyNotice =
keyStopMessage(
code,
message
);
settingsOpen =
false;
changeKeyOpen =
false;
inventoryOpen =
false;
}
const error =
new Error(
message
);
error.tornCode =
code;
throw error;
}
return data;
}
// ============================================================
// YATA REQUEST
// ============================================================
function gmJson(url) {
return new Promise(
(
resolve,
reject
) => {
if (
typeof GM_xmlhttpRequest ===
'function'
) {
GM_xmlhttpRequest({
method:
'GET',
url,
headers: {
Accept:
'application/json'
},
timeout:
20000,
onload(
response
) {
if (
response.status <
200 ||
response.status >=
300
) {
reject(
new Error(
'YATA HTTP ' +
response.status
)
);
return;
}
try {
resolve(
JSON.parse(
response.responseText ||
'{}'
)
);
}
catch (
error
) {
reject(
error
);
}
},
onerror() {
reject(
new Error(
'Could not reach YATA.'
)
);
},
ontimeout() {
reject(
new Error(
'YATA request timed out.'
)
);
}
});
return;
}
fetch(
url
)
.then(
response => {
if (
!response.ok
) {
throw new Error(
'YATA HTTP ' +
response.status
);
}
return response.json();
}
)
.then(
resolve
)
.catch(
reject
);
}
);
}
// ============================================================
// INVENTORY API
// ============================================================
async function fetchInventoryForKey(
key
) {
const result =
emptyInventory();
for (
const category
of [
'Flower',
'Plushie'
]
) {
const data =
await rawApi(
'/user/inventory',
{
cat:
category,
limit:
250
},
key
);
const items =
data
?.inventory
?.items;
if (
!Array.isArray(
items
)
) {
throw new Error(
'Unexpected inventory response.'
);
}
for (
const entry
of items
) {
const known =
MUSEUM_ITEMS.find(
item =>
norm(
item.name
) ===
norm(
entry.name
)
);
if (
known
) {
result[
known.name
] =
Number(
entry.amount
) ||
0;
}
}
}
return result;
}
async function refreshInventory(
forceRender = true
) {
if (
!getKey() ||
inventoryBusy
) {
return;
}
inventoryBusy =
true;
try {
saveInventory(
await fetchInventoryForKey(
getKey()
)
);
}
catch (
error
) {
console.error(
APP,
error
);
}
inventoryBusy =
false;
if (
forceRender
) {
render();
}
}
// ============================================================
// YATA FOREIGN STOCK
// ============================================================
function normalizeYata(raw) {
const result = {};
for (
const [
code,
countryData
]
of Object.entries(
raw?.stocks ||
{}
)
) {
const country =
YATA_COUNTRY[
code
];
if (
!country
) {
continue;
}
result[
country
] = {
update:
Number(
countryData
?.update ||
0
),
items:
(
countryData
?.stocks ||
[]
)
.map(
item => ({
id:
Number(
item.id
),
name:
String(
item.name ||
''
),
quantity:
Math.max(
0,
Number(
item.quantity
) ||
0
),
cost:
Math.max(
0,
Number(
item.cost
) ||
0
),
nextRestock:
item.nextRestock ||
null
})
)
};
}
return result;
}
function yataCacheAgeText() {
const cache =
readJson(
STORAGE.yata
);
if (
!cache ||
!Number.isFinite(
cache.updated
)
) {
return 'unknown age';
}
const ageMinutes =
Math.max(
0,
Math.floor(
(
Date.now() -
cache.updated
) /
60000
)
);
if (
ageMinutes < 1
) {
return 'less than 1m old';
}
return ageMinutes +
'm old';
}
async function getYata(
force = false
) {
if (
!force
) {
const fresh =
cacheGet(
STORAGE.yata,
YATA_TTL
);
if (
fresh
) {
yataWarning =
'';
return fresh;
}
if (
Date.now() <
yataRetryAfter
) {
const stale =
cacheGet(
STORAGE.yata
);
if (
stale
) {
return stale;
}
}
}
try {
const data =
normalizeYata(
await gmJson(
YATA_URL
)
);
cacheSet(
STORAGE.yata,
data
);
yataWarning =
'';
yataRetryAfter =
0;
return data;
}
catch (
error
) {
const stale =
cacheGet(
STORAGE.yata
);
if (
stale
) {
yataRetryAfter =
Date.now() +
YATA_FAILURE_RETRY_MS;
yataWarning =
'YATA temporarily unavailable (' +
String(
error?.message ||
error ||
'request failed'
) +
'). Using cached data — ' +
yataCacheAgeText() +
'.';
console.warn(
APP,
yataWarning
);
return stale;
}
yataRetryAfter =
Date.now() +
YATA_FAILURE_RETRY_MS;
throw error;
}
}
function yataData() {
return (
cacheGet(
STORAGE.yata
) ||
{}
);
}
function yataItem(
country,
name
) {
const target =
norm(
name
);
return (
yataData()
?.[country]
?.items
?.find(
item =>
norm(
item.name
) ===
target
) ||
null
);
}
// ============================================================
// TRUSTED ITEM MARKET PRICING
// ============================================================
async function fetchTrustedSalePrice(
itemId,
itemName
) {
if (
!Number.isFinite(
Number(
itemId
)
)
) {
throw new Error(
'Missing market item ID for ' +
itemName +
'.'
);
}
const listings = [];
let offset =
0;
let accumulatedUnits =
0;
for (
let page = 0;
page <
MARKET_MAX_PAGES;
page++
) {
const data =
await rawApi(
'/market/' +
itemId +
'/itemmarket',
{
limit:
100,
offset
}
);
const pageListings =
data
?.itemmarket
?.listings;
if (
!Array.isArray(
pageListings
) ||
pageListings.length ===
0
) {
break;
}
for (
const listing
of pageListings
) {
const price =
Number(
listing?.price
);
const amount =
Math.max(
0,
Number(
listing?.amount
) ||
0
);
if (
Number.isFinite(
price
) &&
price > 0 &&
amount > 0
) {
listings.push({
price,
amount
});
accumulatedUnits +=
amount;
}
}
if (
accumulatedUnits >=
MARKET_TARGET_UNITS
) {
break;
}
const next =
data
?._metadata
?.next;
if (
!next
) {
break;
}
offset +=
pageListings.length;
/*
* Small spacing between pages keeps requests civilized
* when a thin market requires several pages.
*/
await sleep(
80
);
}
if (
listings.length ===
0
) {
throw new Error(
'No Item Market listings found for ' +
itemName +
'.'
);
}
listings.sort(
(a, b) =>
a.price -
b.price
);
const totalAvailable =
listings.reduce(
(
total,
listing
) =>
total +
listing.amount,
0
);
const sampledUnits =
Math.min(
MARKET_TARGET_UNITS,
totalAvailable
);
if (
sampledUnits <= 0
) {
throw new Error(
'No usable market units found for ' +
itemName +
'.'
);
}
const targetUnit =
Math.max(
1,
Math.ceil(
sampledUnits *
MARKET_DEPTH_PERCENT
)
);
let running =
0;
let estimatedSaleValue =
listings[
listings.length -
1
].price;
for (
const listing
of listings
) {
running +=
listing.amount;
if (
running >=
targetUnit
) {
estimatedSaleValue =
listing.price;
break;
}
}
const result = {
id:
Number(
itemId
),
name:
itemName,
estimatedSaleValue,
sampledUnits,
targetUnit
};
storeTrustedPrice(
itemId,
result
);
return {
...result,
updated:
Date.now()
};
}
async function getTrustedSalePrice(
itemId,
itemName,
force = false
) {
if (
!force
) {
const cached =
cachedTrustedPrice(
itemId
);
if (
cached
) {
return cached;
}
}
return fetchTrustedSalePrice(
itemId,
itemName
);
}
function trustedPriceForId(
itemId
) {
return cachedTrustedPrice(
itemId,
true
);
}
function relevantFinancialItems(
mode
) {
const unique =
new Map();
for (
const countryData
of Object.values(
yataData()
)
) {
for (
const item
of countryData.items ||
[]
) {
if (
!Number.isFinite(
item.id
)
) {
continue;
}
if (
mode ===
'flushies' &&
FLUSHIE_NAMES.has(
item.name
)
) {
unique.set(
item.id,
{
id:
item.id,
name:
item.name
}
);
}
if (
mode ===
'xanax' &&
norm(
item.name
) ===
'xanax'
) {
unique.set(
item.id,
{
id:
item.id,
name:
item.name
}
);
}
}
}
const profile =
getProfile();
if (
profile.method ===
'business' &&
!profile
.businessTicketsFree
) {
unique.set(
BCT_ITEM_ID,
{
id:
BCT_ITEM_ID,
name:
'Business Class Ticket'
}
);
}
return [
...unique.values()
];
}
async function ensureTrustedPrices(
mode,
force = false
) {
const items =
relevantFinancialItems(
mode
);
for (
const item
of items
) {
const existing =
force
? null
: cachedTrustedPrice(
item.id
);
if (
existing
) {
continue;
}
await getTrustedSalePrice(
item.id,
item.name,
force
);
/*
* Do not burst dozens of requests at once.
*/
await sleep(
110
);
}
}
// ============================================================
// DATA REFRESH
// ============================================================
async function ensureData(
force = false
) {
if (
dataBusy ||
!getKey()
) {
return;
}
dataBusy =
true;
dataError =
'';
try {
await getYata(
force
);
const mode =
getMode();
if (
mode ===
'flushies' ||
mode ===
'xanax'
) {
await ensureTrustedPrices(
mode,
force
);
}
if (
mode ===
'museum'
) {
const inventory =
getInventoryCache();
if (
force ||
!inventory.updated ||
Date.now() -
inventory.updated >
INVENTORY_TTL
) {
await refreshInventory(
false
);
}
}
}
catch (
error
) {
dataError =
String(
error?.message ||
error ||
'Could not load optimizer data.'
);
console.error(
APP,
error
);
}
dataBusy =
false;
lastSignature =
'';
render();
}
// ============================================================
// MUSEUM OPTIMIZER
// ============================================================
function itemType(name) {
const item =
MUSEUM_ITEMS.find(
entry =>
entry.name ===
name
);
return item?.type ||
null;
}
function typeItems(type) {
return MUSEUM_ITEMS
.filter(
item =>
item.type ===
type
);
}
function quantity(name) {
return Number(
getInventoryCache()
.items[
name
] ||
0
);
}
function typeMinimum(type) {
const values =
typeItems(
type
)
.map(
item =>
quantity(
item.name
)
);
return values.length
? Math.min(
...values
)
: 0;
}
function typeProgress(type) {
return (
typeMinimum(
type
) *
typeItems(
type
).length
);
}
function efficiency(type) {
const count =
typeItems(
type
).length;
return count
? 10 /
count
: 0;
}
function museumPriority(name) {
const type =
itemType(
name
);
if (
!type
) {
return null;
}
const amount =
quantity(
name
);
const minimum =
typeMinimum(
type
);
return {
amount,
minimum,
bottleneck:
amount ===
minimum,
progress:
typeProgress(
type
),
efficiency:
efficiency(
type
)
};
}
function compareMuseumNames(
aName,
bName
) {
const a =
museumPriority(
aName
);
const b =
museumPriority(
bName
);
if (
!a &&
!b
) {
return 0;
}
if (
!a
) {
return 1;
}
if (
!b
) {
return -1;
}
if (
a.bottleneck !==
b.bottleneck
) {
return a.bottleneck
? -1
: 1;
}
if (
a.progress !==
b.progress
) {
return (
a.progress -
b.progress
);
}
if (
a.efficiency !==
b.efficiency
) {
return (
b.efficiency -
a.efficiency
);
}
if (
a.amount !==
b.amount
) {
return (
a.amount -
b.amount
);
}
return aName
.localeCompare(
bName
);
}
function museumItemForCountry(
country
) {
const items =
MUSEUM_COUNTRIES[
country
] ||
[];
return (
items
.slice()
.sort(
compareMuseumNames
)[0] ||
null
);
}
// ============================================================
// STOCK PREDICTION
// ============================================================
function stockVerdict(
country,
itemName
) {
const countryData =
yataData()
?.[country];
const item =
yataItem(
country,
itemName
);
const profile =
getProfile();
const arrival =
effectiveMinutes(
country
);
const now =
Date.now();
if (
!countryData ||
!item
) {
return {
safe:
false,
state:
'unknown',
label:
'STOCK UNKNOWN',
detail:
'No current YATA report.'
};
}
const ageMs =
countryData.update
? Math.max(
0,
now -
countryData.update *
1000
)
: Infinity;
const ageMinutes =
Math.floor(
ageMs /
60000
);
const stale =
ageMs >
STOCK_STALE_MS;
let restock =
null;
if (
item.nextRestock
) {
const time =
Date.parse(
item.nextRestock
);
if (
Number.isFinite(
time
)
) {
restock =
Math.max(
0,
(
time -
now
) /
60000
);
}
}
if (
item.quantity <=
0
) {
if (
restock !==
null &&
restock <=
arrival &&
!stale
) {
return {
safe:
true,
state:
'expected_in_stock',
label:
'EXPECTED IN STOCK',
detail:
'Restock expected ~' +
duration(
arrival -
restock
) +
' before arrival.'
};
}
const wait =
restock !==
null
? Math.max(
0,
restock -
arrival
)
: null;
return {
safe:
false,
state:
'expected_out_of_stock',
label:
'EXPECTED OUT OF STOCK',
detail:
wait !==
null
? (
'Estimated wait after arrival: ~' +
duration(
wait
) +
'.'
)
: (
'Currently sold out; restock time unknown.'
)
};
}
if (
stale
) {
return {
safe:
false,
state:
'unknown',
label:
'STOCK UNKNOWN',
detail:
'YATA report is ' +
ageMinutes +
'm old.'
};
}
if (
item.quantity <
profile.capacity
) {
return {
safe:
false,
state:
'expected_out_of_stock',
label:
'EXPECTED OUT OF STOCK',
detail:
item.quantity
.toLocaleString() +
' reported, below your ' +
profile.capacity +
'-item capacity.'
};
}
return {
safe:
true,
state:
'expected_in_stock',
label:
'EXPECTED IN STOCK',
detail:
item.quantity
.toLocaleString() +
' reported · ' +
ageMinutes +
'm old.'
};
}
// ============================================================
// FINANCIAL CALCULATIONS
// ============================================================
function bctPrice() {
return (
trustedPriceForId(
BCT_ITEM_ID
)
?.estimatedSaleValue ||
0
);
}
function travelExpense(country) {
const destination =
TRAVEL[
country
];
const profile =
getProfile();
if (
!destination
) {
return 0;
}
/*
* Standard airfare is charged for each leg.
*/
if (
profile.method ===
'standard'
) {
return (
destination.cost *
2
);
}
/*
* One Business Class Ticket covers the return trip.
*/
if (
profile.method ===
'business' &&
!profile
.businessTicketsFree
) {
return bctPrice();
}
/*
* Airstrip and WLT flights are treated as free.
*/
return 0;
}
function purchaseCash(
foreignCost
) {
return (
foreignCost *
getProfile()
.capacity
);
}
function xanaxRecommendedCash(
foreignCost
) {
const required =
purchaseCash(
foreignCost
);
const cushion =
Math.max(
required *
XANAX_CASH_BUFFER_PERCENT,
XANAX_CASH_BUFFER_MIN
);
return Math.ceil(
required +
cushion
);
}
function profitFor(
country,
foreignCost,
estimatedSaleValue
) {
return (
(
estimatedSaleValue -
foreignCost
) *
getProfile()
.capacity -
travelExpense(
country
)
);
}
function profitPerHour(
country,
foreignCost,
estimatedSaleValue
) {
const hours =
(
effectiveMinutes(
country
) *
2
) /
60;
return hours >
0
? (
profitFor(
country,
foreignCost,
estimatedSaleValue
) /
hours
)
: -Infinity;
}
// ============================================================
// CANDIDATES
// ============================================================
function museumCandidates() {
return Object.keys(
MUSEUM_COUNTRIES
)
.map(
country => {
const item =
museumItemForCountry(
country
);
if (
!item
) {
return null;
}
return {
country,
item,
minutes:
effectiveMinutes(
country
),
bucket:
bucket(
country
),
verdict:
stockVerdict(
country,
item
)
};
}
)
.filter(
Boolean
);
}
function compareMuseumCandidates(
a,
b
) {
const difference =
compareMuseumNames(
a.item,
b.item
);
return difference !==
0
? difference
: (
a.minutes -
b.minutes
);
}
function financialCandidates(
mode
) {
const result = [];
for (
const [
country,
countryData
]
of Object.entries(
yataData()
)
) {
if (
!TRAVEL[
country
]
) {
continue;
}
for (
const foreign
of countryData.items ||
[]
) {
if (
mode ===
'flushies' &&
!FLUSHIE_NAMES.has(
foreign.name
)
) {
continue;
}
if (
mode ===
'xanax' &&
norm(
foreign.name
) !==
'xanax'
) {
continue;
}
const pricing =
trustedPriceForId(
foreign.id
);
if (
!pricing ||
!Number.isFinite(
pricing
.estimatedSaleValue
) ||
pricing
.estimatedSaleValue <=
0
) {
continue;
}
const estimatedSaleValue =
pricing
.estimatedSaleValue;
result.push({
id:
foreign.id,
country,
item:
foreign.name,
foreignCost:
foreign.cost,
estimatedSaleValue,
sampledUnits:
pricing
.sampledUnits,
marketTargetUnit:
pricing
.targetUnit,
requiredCash:
purchaseCash(
foreign.cost
),
recommendedCash:
mode ===
'xanax'
? xanaxRecommendedCash(
foreign.cost
)
: null,
profit:
profitFor(
country,
foreign.cost,
estimatedSaleValue
),
profitPerHour:
profitPerHour(
country,
foreign.cost,
estimatedSaleValue
),
minutes:
effectiveMinutes(
country
),
bucket:
bucket(
country
),
verdict:
stockVerdict(
country,
foreign.name
)
});
}
}
return result.sort(
(
a,
b
) => {
if (
b.profitPerHour !==
a.profitPerHour
) {
return (
b.profitPerHour -
a.profitPerHour
);
}
if (
b.profit !==
a.profit
) {
return (
b.profit -
a.profit
);
}
return (
a.minutes -
b.minutes
);
}
);
}
function candidatesForMode(
mode
) {
if (
mode ===
'museum'
) {
return museumCandidates()
.sort(
compareMuseumCandidates
);
}
return financialCandidates(
mode
);
}
function pairFor(
mode,
desiredBucket
) {
const candidates =
candidatesForMode(
mode
)
.filter(
candidate =>
candidate.bucket ===
desiredBucket
);
const best =
candidates[
0
] ||
null;
const recommended =
best &&
!best
.verdict
.safe
? (
candidates.find(
candidate =>
candidate
.verdict
.safe
) ||
null
)
: null;
return {
best,
recommended
};
}
function countryCandidate(
mode,
country
) {
return (
candidatesForMode(
mode
)
.find(
candidate =>
candidate.country ===
country
) ||
null
);
}
// ============================================================
// PAGE DETECTION
// ============================================================
function isTravelAgencyPage() {
const href =
location.href
.toLowerCase();
const title =
document.title
.toLowerCase();
return (
href.includes(
'travelagency.php'
) ||
title.includes(
'travel agency'
)
);
}
function flightRouteText() {
const section =
document.querySelector(
'section[class*="flightProgressSection"]'
);
return String(
section?.innerText ||
section?.textContent ||
''
)
.replace(
/\s+/g,
' '
)
.trim();
}
function isTravelPage() {
const route =
flightRouteText();
return Boolean(
route &&
/remaining flight time/i
.test(
route
)
);
}
function travelState() {
const route =
norm(
flightRouteText()
);
for (
const [
country,
destination
]
of Object.entries(
TRAVEL
)
) {
const city =
norm(
destination.city
);
if (
route.includes(
'torn to ' +
city
)
) {
rememberCountry(
country
);
return {
direction:
'outbound',
country
};
}
if (
route.includes(
city +
' to torn'
)
) {
return {
direction:
'return',
country
};
}
}
if (
route.startsWith(
'torn to '
)
) {
return {
direction:
'outbound',
country:
null
};
}
if (
route.includes(
' to torn'
)
) {
return {
direction:
'return',
country:
null
};
}
return {
direction:
'unknown',
country:
null
};
}
function isForeignShopPage() {
const text =
pageLower();
const store =
text.includes(
'general store'
) ||
text.includes(
'black market'
);
return (
store &&
text.includes(
'stock'
) &&
text.includes(
'amount'
) &&
text.includes(
'buy'
)
);
}
function shoppingCountry() {
const text =
pageText();
const direct =
text.match(
/you\s+are\s+in\s+([a-z ]+?)\s+and\s+have/i
);
if (
direct?.[
1
]
) {
const found =
Object.keys(
TRAVEL
)
.find(
country =>
norm(
country
) ===
norm(
direct[
1
]
)
);
if (
found
) {
rememberCountry(
found
);
return found;
}
}
return rememberedCountry();
}
function pageMode() {
if (
isForeignShopPage()
) {
const country =
shoppingCountry();
return country
? {
mode:
'shop',
country
}
: {
mode:
'hidden'
};
}
if (
isTravelPage()
) {
return {
mode:
'travel',
...travelState()
};
}
if (
isTravelAgencyPage()
) {
return {
mode:
'agency'
};
}
return {
mode:
'hidden'
};
}
// ============================================================
// FOREIGN STORE LIVE DATA
// ============================================================
function carryStatus() {
const match =
pageText()
.match(
/purchased\s+([\d,]+)\s*\/\s*([\d,]+)\s+items/i
);
if (
!match
) {
return null;
}
const purchased =
num(
match[
1
]
);
const capacity =
num(
match[
2
]
);
if (
purchased ===
null ||
capacity ===
null
) {
return null;
}
return {
purchased,
capacity,
remaining:
Math.max(
0,
capacity -
purchased
)
};
}
function liveStock(name) {
const target =
norm(
name
);
const rows =
[
...document.querySelectorAll(
'div[class*="row"]'
)
]
.filter(
row =>
!row.closest(
'#' +
ROOT_ID
)
);
let best =
null;
for (
const row
of rows
) {
const text =
String(
row.innerText ||
row.textContent ||
''
).trim();
if (
!text ||
!norm(
text
).includes(
target
)
) {
continue;
}
if (
!best ||
text.length <
best.text.length
) {
best = {
row,
text
};
}
}
if (
!best
) {
return null;
}
if (
norm(
best.row
.className
).includes(
'soldout'
)
) {
return 0;
}
const match =
best.text
.match(
/stock\s+([\d,]+)/i
);
return match
? num(
match[
1
]
)
: null;
}
// ============================================================
// BUY CONFIRMATION FREEZE
// ============================================================
function purchaseConfirmationVisible() {
const text =
pageLower();
if (
!/buy\s+[\d,]+\s*x\s+.+?\s+for\s+\$[\d,]+\s*\?/i
.test(
text
)
) {
return false;
}
const controls =
[
...document.querySelectorAll(
'button, a, span, div'
)
]
.filter(
element =>
!element.closest(
'#' +
ROOT_ID
)
);
return (
controls.some(
element =>
norm(
element.innerText ||
element.textContent
) ===
'yes'
) &&
controls.some(
element =>
norm(
element.innerText ||
element.textContent
) ===
'no'
)
);
}
function startBuyFreeze() {
if (
buyFrozen
) {
return;
}
buyFrozen =
true;
freezeStarted =
Date.now();
confirmationSeen =
false;
}
function updateBuyFreeze() {
if (
!buyFrozen
) {
return;
}
if (
Date.now() -
freezeStarted >
BUY_FREEZE_MS
) {
buyFrozen =
false;
lastSignature =
'';
render();
return;
}
if (
purchaseConfirmationVisible()
) {
confirmationSeen =
true;
return;
}
if (
confirmationSeen
) {
buyFrozen =
false;
lastSignature =
'';
render();
}
}
function handleBuyCapture(
event
) {
if (
buyFrozen ||
pageMode().mode !==
'shop'
) {
return;
}
let node =
event.target instanceof
Element
? event.target
: event.target
?.parentElement;
for (
let depth = 0;
node &&
depth < 6;
depth++,
node =
node.parentElement
) {
if (
node.closest(
'#' +
ROOT_ID
)
) {
return;
}
if (
norm(
node.innerText ||
node.textContent
) ===
'buy'
) {
startBuyFreeze();
return;
}
}
}
// ============================================================
// STYLE
// ============================================================
function injectStyle() {
if (
document.getElementById(
STYLE_ID
)
) {
return;
}
const style =
document.createElement(
'style'
);
style.id =
STYLE_ID;
style.textContent = `
#${ROOT_ID} {
position: fixed;
right: 18px;
bottom: 18px;
width: 300px;
max-width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
display: flex;
flex-direction: column;
z-index: 2147483647;
background: #1b1b1d;
color: #eee;
border: 1px solid #555;
border-radius: 9px;
box-shadow: 0 5px 20px rgba(0,0,0,.55);
font-family: Arial, sans-serif;
overflow: hidden;
}
#${ROOT_ID}.hidden {
display: none !important;
}
#${ROOT_ID}.mini {
width: 46px !important;
height: 34px !important;
right: 0 !important;
border-radius: 6px 0 0 6px;
}
#${ROOT_ID} * {
box-sizing: border-box;
}
#${ROOT_ID} .head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: #29292c;
border-bottom: 1px solid #3b3b3d;
cursor: move;
user-select: none;
touch-action: none;
}
#${ROOT_ID} .title {
font-size: 13px;
font-weight: bold;
color: #fff;
}
#${ROOT_ID} .actions {
display: flex;
gap: 5px;
}
#${ROOT_ID} button {
font-family: inherit;
}
#${ROOT_ID} .icon {
width: 29px;
height: 29px;
border: 0;
border-radius: 5px;
background: #3d3d40;
color: #ddd;
cursor: pointer;
}
#${ROOT_ID} .miniButton {
width: 46px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
background: #29292c;
color: #fff;
font-size: 11px;
font-weight: bold;
cursor: pointer;
}
#${ROOT_ID} .modes {
display: flex;
gap: 4px;
padding: 7px;
background: #202023;
border-bottom: 1px solid #3b3b3d;
}
#${ROOT_ID} .mode {
flex: 1;
padding: 7px 3px;
border: 1px solid #4a4a4d;
border-radius: 5px;
background: #303034;
color: #bbb;
font-size: 11px;
font-weight: bold;
cursor: pointer;
}
#${ROOT_ID} .mode.active {
background: #505055;
color: #fff;
border-color: #777;
}
#${ROOT_ID} .body,
#${ROOT_ID} .settings {
padding: 11px;
}
#${ROOT_ID} .scroll {
max-height: 350px;
overflow-y: auto;
}
#${ROOT_ID}.sized .scroll {
flex: 1 1 auto;
min-height: 0;
max-height: none;
}
#${ROOT_ID}.sized .settings {
flex: 0 1 auto;
max-height: 45%;
}
#${ROOT_ID} .settings {
border-top: 1px solid #4b4b50;
background: #29292d;
max-height: 360px;
overflow-y: auto;
}
#${ROOT_ID} .label {
color: #999;
font-size: 10px;
font-weight: bold;
letter-spacing: .8px;
margin-bottom: 3px;
}
#${ROOT_ID} .main {
color: #fff;
font-size: 17px;
font-weight: bold;
line-height: 1.3;
}
#${ROOT_ID} .sub {
color: #bbb;
font-size: 12px;
line-height: 1.4;
margin-top: 3px;
}
#${ROOT_ID} .buy {
color: #a5e7a8;
}
#${ROOT_ID} .warn {
color: #efc36d;
}
#${ROOT_ID} .danger {
color: #ef9b9b;
}
#${ROOT_ID} .divider {
height: 1px;
background: #3b3b3b;
margin: 10px 0;
}
#${ROOT_ID} .stock {
margin-top: 7px;
padding: 6px 7px;
border-radius: 5px;
background: #27272a;
font-size: 10px;
line-height: 1.35;
}
#${ROOT_ID} .stock.safe {
color: #a5e7a8;
}
#${ROOT_ID} .stock.bad {
color: #ef9b9b;
}
#${ROOT_ID} .stock.unknown {
color: #efc36d;
}
#${ROOT_ID} .moneybox {
margin-top: 7px;
padding: 7px;
background: #242427;
border-radius: 5px;
font-size: 11px;
line-height: 1.45;
color: #ccc;
}
#${ROOT_ID} .buttons {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
#${ROOT_ID} .button {
flex: 1;
min-width: 95px;
padding: 8px;
border: 0;
border-radius: 5px;
background: #444;
color: #fff;
cursor: pointer;
}
#${ROOT_ID} .button:disabled {
cursor: default;
opacity: 1;
}
#${ROOT_ID} .input,
#${ROOT_ID} select,
#${ROOT_ID} input[type=number] {
width: 100%;
padding: 8px;
margin-top: 5px;
background: #111;
color: #fff;
border: 1px solid #555;
border-radius: 5px;
}
#${ROOT_ID} .field {
display: block;
margin-top: 9px;
color: #aaa;
font-size: 11px;
}
#${ROOT_ID} .check {
display: flex;
align-items: center;
gap: 7px;
margin-top: 9px;
color: #bbb;
font-size: 11px;
}
#${ROOT_ID} .check input {
width: auto;
}
#${ROOT_ID} .note {
margin-top: 7px;
color: #888;
font-size: 10px;
}
#${ROOT_ID} .apiDisclosure {
margin-top: 10px;
padding: 8px;
border: 1px solid #4a4a4f;
border-radius: 5px;
background: #222225;
color: #aaa;
font-size: 10px;
line-height: 1.45;
}
#${ROOT_ID} .apiDisclosure strong {
color: #ddd;
}
#${ROOT_ID} .apiNotice {
margin-top: 8px;
padding: 7px;
border-radius: 5px;
background: #34282a;
color: #efb0b0;
font-size: 11px;
line-height: 1.4;
}
#${ROOT_ID} .inventory {
margin-top: 10px;
padding: 8px;
background: #111;
border-radius: 6px;
max-height: 260px;
overflow-y: auto;
}
#${ROOT_ID} .invrow {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 3px 0;
color: #bbb;
font-size: 11px;
border-bottom: 1px solid #292929;
}
#${ROOT_ID} .resizeHandle {
position: absolute;
right: 0;
bottom: 0;
width: 22px;
height: 22px;
cursor: nwse-resize;
touch-action: none;
user-select: none;
z-index: 5;
}
#${ROOT_ID} .resizeHandle::after {
content: '';
position: absolute;
right: 4px;
bottom: 4px;
width: 10px;
height: 10px;
border-right: 2px solid #777;
border-bottom: 2px solid #777;
}
#${ROOT_ID} .resizeHandle:hover::after {
border-color: #bbb;
}
`;
(
document.head ||
document.documentElement
)
.appendChild(
style
);
}
// ============================================================
// PANEL
// ============================================================
function root() {
let panel =
document.getElementById(
ROOT_ID
);
if (
!panel
) {
panel =
document.createElement(
'div'
);
panel.id =
ROOT_ID;
document.body
.appendChild(
panel
);
}
return panel;
}
function positionPanel(
panel,
left,
top
) {
const maxLeft =
Math.max(
0,
innerWidth -
panel.offsetWidth
);
const maxTop =
Math.max(
0,
innerHeight -
panel.offsetHeight
);
const x =
Math.min(
Math.max(
0,
left
),
maxLeft
);
const y =
Math.min(
Math.max(
0,
top
),
maxTop
);
panel.style.left =
x +
'px';
panel.style.top =
y +
'px';
panel.style.right =
'auto';
panel.style.bottom =
'auto';
return {
left:
x,
top:
y
};
}
function savedPosition() {
return readJson(
STORAGE.position
);
}
function applyPosition(
panel
) {
const saved =
savedPosition();
if (
saved &&
Number.isFinite(
saved.left
) &&
Number.isFinite(
saved.top
)
) {
positionPanel(
panel,
saved.left,
saved.top
);
}
}
// ============================================================
// DRAGGING
// ============================================================
function installDrag() {
if (
dragInstalled
) {
return;
}
dragInstalled =
true;
document.addEventListener(
'pointermove',
event => {
if (
!drag ||
drag.type !==
'pointer'
) {
return;
}
positionPanel(
drag.panel,
event.clientX -
drag.dx,
event.clientY -
drag.dy
);
event.preventDefault();
}
);
document.addEventListener(
'pointerup',
finishDrag
);
document.addEventListener(
'pointercancel',
finishDrag
);
document.addEventListener(
'touchmove',
event => {
if (
!drag ||
drag.type !==
'touch' ||
event.touches.length !==
1
) {
return;
}
const touch =
event.touches[
0
];
positionPanel(
drag.panel,
touch.clientX -
drag.dx,
touch.clientY -
drag.dy
);
event.preventDefault();
},
{
passive:
false
}
);
document.addEventListener(
'touchend',
finishDrag,
{
passive:
false
}
);
document.addEventListener(
'touchcancel',
finishDrag,
{
passive:
false
}
);
}
function finishDrag() {
if (
!drag
) {
return;
}
const rect =
drag.panel
.getBoundingClientRect();
writeJson(
STORAGE.position,
{
left:
rect.left,
top:
rect.top
}
);
drag =
null;
}
function bindDrag(
panel
) {
const head =
panel.querySelector(
'.head'
);
if (
!head
) {
return;
}
head.onpointerdown =
event => {
if (
event.pointerType ===
'touch' ||
event.target.closest(
'button,input,a,select'
)
) {
return;
}
const rect =
panel
.getBoundingClientRect();
drag = {
type:
'pointer',
panel,
dx:
event.clientX -
rect.left,
dy:
event.clientY -
rect.top
};
event.preventDefault();
};
head.ontouchstart =
event => {
if (
event.target.closest(
'button,input,a,select'
) ||
event.touches.length !==
1
) {
return;
}
const touch =
event.touches[
0
];
const rect =
panel
.getBoundingClientRect();
drag = {
type:
'touch',
panel,
dx:
touch.clientX -
rect.left,
dy:
touch.clientY -
rect.top
};
event.preventDefault();
};
}
// ============================================================
// RESIZING
// ============================================================
function installResize() {
if (
resizeInstalled
) {
return;
}
resizeInstalled =
true;
document.addEventListener(
'pointermove',
event => {
if (
!resizeState ||
resizeState.type !==
'pointer'
) {
return;
}
resizePanelToPointer(
event.clientX,
event.clientY
);
event.preventDefault();
}
);
document.addEventListener(
'pointerup',
finishResize
);
document.addEventListener(
'pointercancel',
finishResize
);
document.addEventListener(
'touchmove',
event => {
if (
!resizeState ||
resizeState.type !==
'touch' ||
event.touches.length !==
1
) {
return;
}
const touch =
event.touches[
0
];
resizePanelToPointer(
touch.clientX,
touch.clientY
);
event.preventDefault();
},
{
passive:
false
}
);
document.addEventListener(
'touchend',
finishResize,
{
passive:
false
}
);
document.addEventListener(
'touchcancel',
finishResize,
{
passive:
false
}
);
}
function resizePanelToPointer(
clientX,
clientY
) {
if (
!resizeState
) {
return;
}
const size =
clampPanelSize(
resizeState.startWidth +
(
clientX -
resizeState.startX
),
resizeState.startHeight +
(
clientY -
resizeState.startY
)
);
resizeState.panel.style.width =
size.width +
'px';
resizeState.panel.style.height =
'';
resizeState.panel.style.maxHeight =
size.height +
'px';
resizeState.requestedHeight =
size.height;
resizeState.panel
.classList.add(
'sized'
);
positionPanel(
resizeState.panel,
resizeState.left,
resizeState.top
);
}
function finishResize() {
if (
!resizeState
) {
return;
}
const rect =
resizeState.panel
.getBoundingClientRect();
savePanelSize(
rect.width,
resizeState.requestedHeight ||
rect.height
);
writeJson(
STORAGE.position,
{
left:
rect.left,
top:
rect.top
}
);
resizeState =
null;
}
function beginResize(
panel,
clientX,
clientY,
type
) {
const rect =
panel
.getBoundingClientRect();
resizeState = {
type,
panel,
startX:
clientX,
startY:
clientY,
startWidth:
rect.width,
startHeight:
Math.max(
rect.height,
parseFloat(
panel.style.maxHeight
) ||
rect.height
),
requestedHeight:
Math.max(
rect.height,
parseFloat(
panel.style.maxHeight
) ||
rect.height
),
left:
rect.left,
top:
rect.top
};
panel.classList.add(
'sized'
);
}
function bindResize(
panel
) {
const handle =
panel.querySelector(
'#lmt-resize-handle'
);
if (
!handle
) {
return;
}
handle.onpointerdown =
event => {
if (
event.pointerType ===
'touch'
) {
return;
}
beginResize(
panel,
event.clientX,
event.clientY,
'pointer'
);
event.preventDefault();
event.stopPropagation();
};
handle.ontouchstart =
event => {
if (
event.touches.length !==
1
) {
return;
}
const touch =
event.touches[
0
];
beginResize(
panel,
touch.clientX,
touch.clientY,
'touch'
);
event.preventDefault();
event.stopPropagation();
};
}
// ============================================================
// UI
// ============================================================
function resizeHandleUI() {
return `
<div
id="lmt-resize-handle"
class="resizeHandle"
title="Resize LostMaster Travel"
aria-label="Resize LostMaster Travel"
></div>
`;
}
function headerUI() {
return `
<div class="head">
<div class="title">
${APP}
</div>
<div class="actions">
<button
id="lmt-refresh"
class="icon"
title="Refresh"
>
↻
</button>
<button
id="lmt-settings"
class="icon"
title="Settings"
>
⚙
</button>
<button
id="lmt-minimize"
class="icon"
title="Minimize"
>
_
</button>
</div>
</div>
`;
}
function modesUI() {
const mode =
getMode();
return `
<div class="modes">
<button
class="mode ${
mode ===
'museum'
? 'active'
: ''
}"
data-mode="museum"
>
Museum
</button>
<button
class="mode ${
mode ===
'flushies'
? 'active'
: ''
}"
data-mode="flushies"
>
Flushies
</button>
<button
class="mode ${
mode ===
'xanax'
? 'active'
: ''
}"
data-mode="xanax"
>
Xanax
</button>
</div>
`;
}
function apiDisclosureUI() {
return `
<div class="apiDisclosure">
<strong>
API USE
</strong>
<div>
<strong>Storage:</strong>
Your Torn API key and LMT cache data are stored
locally in this browser only.
</div>
<div>
<strong>Sharing:</strong>
Your Torn API key is sent only to the official
Torn API. It is never sent to YATA or another
third party.
</div>
<div>
<strong>Purpose:</strong>
Museum inventory balancing, Flushies profit
estimates, Xanax travel estimates, and travel
recommendations.
</div>
<div>
<strong>Required access:</strong>
Minimal Access.
</div>
<div>
<strong>Torn API selections:</strong>
user → inventory; market → itemmarket.
</div>
<div>
LMT separately retrieves public foreign-stock
information from YATA without sending your Torn
API key.
</div>
</div>
`;
}
function setupUI() {
return `
<div class="body">
<div class="main">
Setup
</div>
<div class="sub">
Enter your Torn API key.
</div>
<input
id="lmt-key"
class="input"
type="text"
placeholder="API key"
autocomplete="off"
>
${
apiKeyNotice
? `
<div class="apiNotice">
${esc(
apiKeyNotice
)}
</div>
`
: ''
}
<div
id="lmt-key-status"
class="sub"
></div>
${apiDisclosureUI()}
<div class="buttons">
<button
id="lmt-save-key-first"
class="button"
>
Save
</button>
</div>
</div>
`;
}
function inventoryUI() {
if (
!inventoryOpen
) {
return '';
}
if (
inventoryBusy
) {
return `
<div class="inventory">
Checking Torn API inventory...
</div>
`;
}
const inventory =
getInventoryCache();
return (
'<div class="inventory">' +
MUSEUM_ITEMS
.map(
item => `
<div class="invrow">
<span>
${esc(
item.name
)}
</span>
<span>
${
Number(
inventory
.items[
item.name
] ||
0
)
}
</span>
</div>
`
)
.join(
''
) +
'</div>'
);
}
function settingsUI() {
if (
!settingsOpen
) {
return '';
}
if (
changeKeyOpen
) {
return `
<div class="settings">
<div class="label">
CHANGE API KEY
</div>
<input
id="lmt-new-key"
class="input"
type="text"
placeholder="New API key"
autocomplete="off"
>
<div
id="lmt-key-status"
class="sub"
></div>
${apiDisclosureUI()}
<div class="buttons">
<button
id="lmt-save-new-key"
class="button"
>
Save
</button>
<button
id="lmt-cancel-key"
class="button"
>
Cancel
</button>
</div>
</div>
`;
}
const profile =
getProfile();
const saved =
Date.now() <
profileSavedUntil;
return `
<div class="settings">
<div class="label">
TRAVEL PROFILE
</div>
<label class="field">
Travel type
</label>
<select
id="lmt-method"
>
<option
value="standard"
${
profile.method ===
'standard'
? 'selected'
: ''
}
>
Standard
</option>
<option
value="airstrip"
${
profile.method ===
'airstrip'
? 'selected'
: ''
}
>
Airstrip
</option>
<option
value="wlt"
${
profile.method ===
'wlt'
? 'selected'
: ''
}
>
WLT
</option>
<option
value="business"
${
profile.method ===
'business'
? 'selected'
: ''
}
>
Business Class
</option>
</select>
<label class="check">
<input
id="lmt-book"
type="checkbox"
${
profile.mailingBook
? 'checked'
: ''
}
>
Mailing Yourself Abroad active
</label>
<label class="field">
Travel capacity
</label>
<input
id="lmt-capacity"
type="number"
min="1"
max="100"
value="${
profile.capacity
}"
>
<label class="check">
<input
id="lmt-bct-free"
type="checkbox"
${
profile
.businessTicketsFree
? 'checked'
: ''
}
>
Business Class tickets are free
</label>
<div class="buttons">
<button
id="lmt-save-profile"
class="button"
${
saved
? 'disabled'
: ''
}
>
${
saved
? 'Saved ✓'
: 'Save Profile'
}
</button>
</div>
<div class="divider"></div>
<div class="buttons">
<button
id="lmt-change-key"
class="button"
>
Change Key
</button>
<button
id="lmt-inventory"
class="button"
>
API Inventory Check
</button>
</div>
${inventoryUI()}
<div class="note">
${APP} ${VERSION}
· YATA 60s cache
· trusted prices 30m cache
</div>
</div>
`;
}
function stockUI(
verdict
) {
if (
!verdict
) {
return '';
}
const cls =
verdict.safe
? 'safe'
: (
verdict.state ===
'expected_out_of_stock'
? 'bad'
: 'unknown'
);
return `
<div class="stock ${cls}">
<strong>
${esc(
verdict.label
)}
</strong>
<div>
${esc(
verdict.detail
)}
</div>
</div>
`;
}
function financialUI(
candidate,
mode
) {
if (
mode ===
'museum'
) {
return '';
}
const xanaxCash =
mode ===
'xanax'
? `
<div>
Recommended cash:
<strong>
${money(
candidate.recommendedCash
)}
</strong>
</div>
<div class="warn">
Includes
${money(
candidate.recommendedCash -
candidate.requiredCash
)}
cash buffer.
</div>
`
: '';
return `
<div class="moneybox">
<div>
Purchase cash:
<strong>
${money(
candidate.requiredCash
)}
</strong>
</div>
${xanaxCash}
<div>
Est. profit:
<strong>
${money(
candidate.profit
)}
</strong>
</div>
<div>
Profit/hour:
<strong>
${money(
candidate.profitPerHour
)}/hr
</strong>
</div>
<div>
Est. sale:
<strong>
${money(
candidate.estimatedSaleValue
)}
</strong>
each
</div>
</div>
`;
}
function candidateUI(
label,
candidate,
mode,
options = {}
) {
if (
!candidate
) {
return `
<div class="label">
${esc(
label
)}
</div>
<div class="main warn">
No candidate available
</div>
`;
}
return `
<div class="label">
${esc(
label
)}
</div>
<div class="main">
${esc(
candidate.country
)}
</div>
<div class="sub buy">
Buy
${esc(
candidate.item
)}
</div>
<div class="sub">
${duration(
candidate.minutes
)}
one way
</div>
${financialUI(
candidate,
mode
)}
${stockUI(
candidate.verdict
)}
${
options.notRecommended
? `
<div class="sub danger">
Not recommended
</div>
`
: ''
}
`;
}
function bucketUI(
mode,
desiredBucket
) {
const pair =
pairFor(
mode,
desiredBucket
);
const label =
desiredBucket ===
'quick'
? 'QUICK'
: 'EXTENDED';
let html =
candidateUI(
'BEST ' +
label,
pair.best,
mode,
{
notRecommended:
Boolean(
pair.best &&
!pair.best
.verdict
.safe
)
}
);
if (
pair.best &&
!pair.best
.verdict
.safe
) {
html +=
'<div class="divider"></div>';
if (
pair.recommended
) {
html +=
candidateUI(
'RECOMMENDED ' +
label,
pair.recommended,
mode
);
}
else {
html += `
<div class="sub warn">
No stock-safe
${desiredBucket}
recommendation
currently available.
</div>
`;
}
}
return html;
}
function agencyUI() {
const mode =
getMode();
if (
dataBusy
) {
return `
<div class="body">
<div class="main">
Loading
${esc(
MODES[
mode
]
)}
data...
</div>
</div>
`;
}
if (
dataError
) {
return `
<div class="body">
<div class="main warn">
Optimizer data unavailable
</div>
<div class="sub">
${esc(
dataError
)}
</div>
</div>
`;
}
const yataWarningUI =
yataWarning
? `
<div class="stock unknown">
<strong>
YATA DATA WARNING
</strong>
<div>
${esc(
yataWarning
)}
</div>
</div>
<div class="divider"></div>
`
: '';
return `
<div class="body scroll">
${yataWarningUI}
<div class="label">
${esc(
MODES[
mode
]
.toUpperCase()
)}
</div>
${bucketUI(
mode,
'quick'
)}
<div class="divider"></div>
${bucketUI(
mode,
'extended'
)}
</div>
`;
}
function travelUI(
state
) {
if (
state.direction ===
'return'
) {
return `
<div class="body">
<div class="label">
TRAVELING
</div>
<div class="main">
Returning to Torn
</div>
</div>
`;
}
if (
state.direction ===
'outbound' &&
state.country
) {
const candidate =
countryCandidate(
getMode(),
state.country
);
if (
!candidate
) {
return `
<div class="body">
<div class="label">
FLYING TO
</div>
<div class="main">
${esc(
state.country
)}
</div>
<div class="sub">
No
${esc(
MODES[
getMode()
]
)}
target for this destination.
</div>
</div>
`;
}
return `
<div class="body scroll">
<div class="label">
FLYING TO
</div>
<div class="main">
${esc(
state.country
)}
</div>
<div class="divider"></div>
<div class="label">
WHEN YOU LAND
</div>
<div class="main buy">
Buy
${esc(
candidate.item
)}
</div>
${
getMode() !==
'museum'
? financialUI(
candidate,
getMode()
)
: ''
}
${stockUI(
candidate.verdict
)}
</div>
`;
}
return `
<div class="body">
<div class="label">
TRAVELING
</div>
<div class="main">
Flight in progress
</div>
</div>
`;
}
// ============================================================
// FOREIGN STORE PLANS
// ============================================================
function museumLivePlan(
country
) {
const items =
(
MUSEUM_COUNTRIES[
country
] ||
[]
)
.slice()
.sort(
compareMuseumNames
);
const carry =
carryStatus();
const remaining =
carry?.remaining ??
getProfile()
.capacity;
const purchases = [];
let left =
remaining;
for (
const item
of items
) {
if (
left <=
0
) {
break;
}
const stock =
liveStock(
item
);
if (
stock ===
null ||
stock <=
0
) {
continue;
}
const amount =
Math.min(
left,
stock
);
purchases.push({
item,
amount
});
left -=
amount;
}
return {
purchases,
left
};
}
function shopUI(
country
) {
const carry =
carryStatus();
if (
carry &&
carry.remaining ===
0
) {
return `
<div class="body">
<div class="label">
CAPACITY FULL
</div>
<div class="main buy">
Travel Home
</div>
</div>
`;
}
const mode =
getMode();
if (
mode ===
'museum'
) {
const plan =
museumLivePlan(
country
);
if (
!plan
.purchases
.length
) {
return `
<div class="body">
<div class="label">
${esc(
country
.toUpperCase()
)}
</div>
<div class="main warn">
WAIT FOR RESTOCK
</div>
</div>
`;
}
return (
'<div class="body scroll">' +
plan.purchases
.map(
purchase => `
<div class="main buy">
Buy
${purchase.amount}
${esc(
purchase.item
)}
</div>
<div class="divider"></div>
`
)
.join(
''
) +
(
plan.left >
0
? `
<div class="main warn">
WAIT FOR RESTOCK
</div>
`
: ''
) +
'</div>'
);
}
const candidate =
countryCandidate(
mode,
country
);
if (
!candidate
) {
return `
<div class="body">
<div class="label">
${esc(
country
.toUpperCase()
)}
</div>
<div class="main warn">
No
${esc(
MODES[
mode
]
)}
target here
</div>
</div>
`;
}
const remaining =
carry?.remaining ??
getProfile()
.capacity;
const stock =
liveStock(
candidate.item
);
if (
stock ===
0
) {
return `
<div class="body">
<div class="label">
${esc(
country
.toUpperCase()
)}
</div>
<div class="main warn">
WAIT FOR RESTOCK
</div>
<div class="sub">
${esc(
candidate.item
)}
is sold out.
</div>
</div>
`;
}
const amount =
stock ===
null
? remaining
: Math.min(
remaining,
stock
);
const purchaseCost =
amount *
candidate.foreignCost;
return `
<div class="body">
<div class="label">
${esc(
country
.toUpperCase()
)}
</div>
<div class="label">
${esc(
MODES[
mode
]
.toUpperCase()
)}
</div>
<div class="main buy">
Buy
${amount}
${esc(
candidate.item
)}
</div>
<div class="sub">
Purchase cost:
${money(
purchaseCost
)}
</div>
${
stock !==
null &&
stock <
remaining
? `
<div class="sub warn">
Buy available stock,
then wait or return home.
</div>
`
: ''
}
</div>
`;
}
// ============================================================
// API KEY
// ============================================================
async function saveNewKey(
value,
statusId
) {
const status =
document.getElementById(
statusId
);
const candidate =
String(
value ||
''
).trim();
if (
!candidate
) {
if (
status
) {
status.textContent =
'Enter an API key.';
}
return;
}
if (
status
) {
status.textContent =
'Checking key...';
}
try {
const inventory =
await fetchInventoryForKey(
candidate
);
setKey(
candidate
);
saveInventory(
inventory
);
changeKeyOpen =
false;
settingsOpen =
false;
if (
status
) {
status.textContent =
'';
}
await ensureData(
true
);
}
catch (
error
) {
if (
status
) {
status.textContent =
String(
error?.message ||
error
);
}
}
}
// ============================================================
// RENDER
// ============================================================
function render() {
if (
!document.body ||
buyFrozen
) {
return;
}
injectStyle();
const panel =
root();
const context =
pageMode();
if (
context.mode ===
'hidden'
) {
panel.className =
'hidden';
return;
}
if (
localStorage.getItem(
STORAGE.minimized
) ===
'1'
) {
panel.className =
'mini';
panel.innerHTML = `
<div
id="lmt-mini"
class="miniButton"
title="${APP}"
>
${SHORT_APP}
</div>
`;
panel.style.left =
'auto';
panel.style.right =
'0';
panel.style.top =
'20%';
panel.style.bottom =
'auto';
panel
.querySelector(
'#lmt-mini'
)
.onclick =
() => {
localStorage.setItem(
STORAGE.minimized,
'0'
);
render();
};
return;
}
panel.className =
'';
let html =
headerUI();
if (
!getKey()
) {
html +=
setupUI();
}
else {
html +=
modesUI();
if (
context.mode ===
'agency'
) {
clearCountry();
html +=
agencyUI();
}
else if (
context.mode ===
'travel'
) {
html +=
travelUI(
context
);
}
else if (
context.mode ===
'shop'
) {
html +=
shopUI(
context.country
);
}
html +=
settingsUI();
}
html +=
resizeHandleUI();
panel.innerHTML =
html;
applyPanelSize(
panel
);
applyPosition(
panel
);
bind(
panel
);
bindDrag(
panel
);
bindResize(
panel
);
}
// ============================================================
// BUTTON BINDINGS
// ============================================================
function bind(
panel
) {
panel
.querySelector(
'#lmt-minimize'
)
?.addEventListener(
'click',
() => {
localStorage.setItem(
STORAGE.minimized,
'1'
);
settingsOpen =
false;
changeKeyOpen =
false;
inventoryOpen =
false;
render();
}
);
panel
.querySelector(
'#lmt-settings'
)
?.addEventListener(
'click',
() => {
settingsOpen =
!settingsOpen;
if (
!settingsOpen
) {
changeKeyOpen =
false;
inventoryOpen =
false;
}
render();
}
);
panel
.querySelector(
'#lmt-refresh'
)
?.addEventListener(
'click',
async () => {
if (
getMode() ===
'museum'
) {
await refreshInventory(
false
);
}
await ensureData(
true
);
}
);
panel
.querySelector(
'#lmt-save-key-first'
)
?.addEventListener(
'click',
() => {
saveNewKey(
panel
.querySelector(
'#lmt-key'
)
?.value,
'lmt-key-status'
);
}
);
for (
const button
of panel.querySelectorAll(
'[data-mode]'
)
) {
button.addEventListener(
'click',
async () => {
const mode =
button
.getAttribute(
'data-mode'
);
if (
!mode ||
mode ===
getMode()
) {
return;
}
setMode(
mode
);
dataError =
'';
render();
await ensureData(
false
);
}
);
}
panel
.querySelector(
'#lmt-change-key'
)
?.addEventListener(
'click',
() => {
changeKeyOpen =
true;
inventoryOpen =
false;
render();
}
);
panel
.querySelector(
'#lmt-cancel-key'
)
?.addEventListener(
'click',
() => {
changeKeyOpen =
false;
render();
}
);
panel
.querySelector(
'#lmt-save-new-key'
)
?.addEventListener(
'click',
() => {
saveNewKey(
panel
.querySelector(
'#lmt-new-key'
)
?.value,
'lmt-key-status'
);
}
);
panel
.querySelector(
'#lmt-save-profile'
)
?.addEventListener(
'click',
async () => {
saveProfile({
method:
panel
.querySelector(
'#lmt-method'
)
?.value ||
'standard',
mailingBook:
Boolean(
panel
.querySelector(
'#lmt-book'
)
?.checked
),
capacity:
Math.max(
1,
Math.min(
100,
Number(
panel
.querySelector(
'#lmt-capacity'
)
?.value
) ||
28
)
),
businessTicketsFree:
Boolean(
panel
.querySelector(
'#lmt-bct-free'
)
?.checked
)
});
profileSavedUntil =
Date.now() +
2000;
lastSignature =
'';
render();
await ensureData(
false
);
setTimeout(
() => {
if (
Date.now() >=
profileSavedUntil
) {
profileSavedUntil =
0;
lastSignature =
'';
render();
}
},
2100
);
}
);
panel
.querySelector(
'#lmt-inventory'
)
?.addEventListener(
'click',
async () => {
if (
inventoryOpen
) {
inventoryOpen =
false;
inventoryBusy =
false;
render();
return;
}
inventoryOpen =
true;
inventoryBusy =
true;
render();
await refreshInventory(
false
);
inventoryBusy =
false;
render();
}
);
}
// ============================================================
// WATCHER
// ============================================================
function signature() {
const context =
pageMode();
const yataCache =
readJson(
STORAGE.yata
);
const priceCache =
getPriceCache();
const priceSignature =
Object.values(
priceCache.items ||
{}
)
.reduce(
(
latest,
entry
) =>
Math.max(
latest,
Number(
entry.updated
) ||
0
),
0
);
return JSON.stringify({
href:
location.href,
context,
mode:
getMode(),
profile:
getProfile(),
minimized:
localStorage.getItem(
STORAGE.minimized
),
inventory:
getInventoryCache()
.updated,
yata:
yataCache
?.updated ||
0,
prices:
priceSignature
});
}
function pricesNeedRefresh(
mode
) {
if (
mode !==
'flushies' &&
mode !==
'xanax'
) {
return false;
}
const relevant =
relevantFinancialItems(
mode
);
return relevant.some(
item =>
!cachedTrustedPrice(
item.id
)
);
}
function tick() {
if (
buyFrozen
) {
updateBuyFreeze();
return;
}
const current =
signature();
if (
current !==
lastSignature
) {
lastSignature =
current;
const panel =
document.getElementById(
ROOT_ID
);
if (
!panel?.contains(
document.activeElement
)
) {
render();
}
}
if (
getKey() &&
pageMode().mode ===
'agency'
) {
const yataStale =
!cacheGet(
STORAGE.yata,
YATA_TTL
) &&
Date.now() >=
yataRetryAfter;
const mode =
getMode();
const priceStale =
pricesNeedRefresh(
mode
);
const inventory =
getInventoryCache();
const inventoryStale =
mode ===
'museum' &&
(
!inventory.updated ||
Date.now() -
inventory.updated >
INVENTORY_TTL
);
if (
(
yataStale ||
priceStale ||
inventoryStale
) &&
!dataBusy
) {
ensureData(
false
);
}
}
}
// ============================================================
// START
// ============================================================
function start() {
if (
!document.body
) {
setTimeout(
start,
100
);
return;
}
injectStyle();
installDrag();
installResize();
document.addEventListener(
'click',
handleBuyCapture,
true
);
render();
if (
getKey()
) {
ensureData(
false
);
}
lastSignature =
signature();
setInterval(
tick,
WATCH_MS
);
}
start();
})();