/**
* @fileoverview Wall Street Raider 10 - Frontend API Layer
*
* This module provides the complete API surface for the Wall Street Raider 10
* game frontend. It communicates with the C++ backend (wsr10.exe) via two
* transports:
*
* 1. **REST API** (default): HTTP POST/GET requests to dynamically-assigned ports.
* 2. **IPC bridge** (addon mode, `WSR_NO_REST=1`): Electron IPC calls to main process.
*
* All API calls await `bridgeReady` before executing.
*
* @author Wall Street Raider 10 Team
*/
import { insertCurrencySymbols } from './components/helpers.js';
import { html, render, useRef, useState, useEffect, useMemo, useCallback, useContext, createContext } from './lib/preact.standalone.module.js';
import zustand from './lib/zustand.module.js';
import { debugLog } from './debug-log.js';
const { createStore } = zustand;
// In a plain browser (phone access), Electron APIs are unavailable — stub them out.
const ipcRenderer = (typeof require !== 'undefined')
? require('electron').ipcRenderer
: { invoke: () => Promise.resolve(null), send: () => {}, on: () => {} };
// Bridge handshake: REST + WebSocket ports are OS-assigned at wsr.exe startup
// and delivered here via the 'bridge-ports' IPC message from electron/main.js,
// which reads them from %LOCALAPPDATA%\Wall Street Raider\runtime.json.
// No REST or WebSocket call may fire until bridgeReady resolves — fetchWithRetry
// awaits it at the top, so all POST/GET helpers below are port-safe.
let apiBase = null;
let wsUrl = null;
let _resolveBridge;
/**
* Promise that resolves when the backend bridge ports are discovered.
* All API calls internally await this before making network requests.
* @type {Promise<void>}
*/
export const bridgeReady = new Promise(resolve => { _resolveBridge = resolve; });
/**
* Returns the currently configured REST API base URL.
* Returns `null` until the bridge handshake completes.
* @returns {string|null} e.g. "http://127.0.0.1:54321"
*/
export const getApiBase = () => apiBase;
/**
* Returns the currently configured WebSocket URL.
* Returns `null` until the bridge handshake completes.
* @returns {string|null} e.g. "ws://127.0.0.1:54322"
*/
export const getWsUrl = () => wsUrl;
// Phone/remote-access override: browsers loading index.html from a non-loopback
// host can't read the local handshake file. They pass the backend ports via URL
// params (e.g. ?restPort=54321&wsPort=54322) from a manual discovery step.
(function applyUrlOverride() {
try {
if (typeof location === 'undefined') return;
const h = location.hostname;
if (!h || h === '127.0.0.1' || h === 'localhost') return;
const params = new URLSearchParams(location.search || '');
const rest = Number(params.get('restPort'));
const ws = Number(params.get('wsPort'));
if (Number.isInteger(rest) && rest > 0) {
apiBase = `http://${h}:${rest}`;
if (Number.isInteger(ws) && ws > 0) wsUrl = `ws://${h}:${ws}`;
_resolveBridge();
}
} catch (e) { /* best effort */ }
})();
function _applyPorts(ports) {
if (!ports) return false;
apiBase = `http://127.0.0.1:${ports.restPort}`;
wsUrl = `ws://127.0.0.1:${ports.wsPort}`;
console.log('[api] bridge-ports:', ports);
_resolveBridge();
return true;
}
if (ipcRenderer && typeof ipcRenderer.on === 'function') {
// Catches send('bridge-ports') from main on wsr.exe restarts. Note this
// would miss the initial handshake if the IPC fires before this listener
// registers — the invoke() call below is the race-free primary path.
ipcRenderer.on('bridge-ports', (_evt, ports) => _applyPorts(ports));
}
// Race-free initial pull: renderer asks main for the ports. If they're
// already known, main returns them synchronously. If not (handshake still
// in flight), main returns a promise it resolves when the handshake arrives.
// On wsr-restart, main rejects outstanding invokes with null so we retry.
if (ipcRenderer && typeof ipcRenderer.invoke === 'function') {
const pullPorts = () => {
ipcRenderer.invoke('get-bridge-ports').then(ports => {
if (_applyPorts(ports)) return;
// null response = stale request on restart; retry once the new
// child has started the next handshake cycle.
setTimeout(pullPorts, 200);
}).catch(err => {
console.warn('[api] get-bridge-ports failed, retrying:', err?.message || err);
setTimeout(pullPorts, 500);
});
};
pullPorts();
}
// Detect addon mode: WSR_NO_REST=1 means no REST server, use IPC for everything
const useIPC = typeof process !== 'undefined' && process.env && process.env.WSR_NO_REST === '1';
console.log('[api] useIPC =', useIPC, 'WSR_NO_REST =', typeof process !== 'undefined' && process.env ? process.env.WSR_NO_REST : 'N/A');
// REST path → GameEventType mapping (from GameEvent.h)
// Used to route postNoArg/postIdArg through game:dispatch IPC in addon mode
const REST_TO_EVENT = {
'/clear_event_string': 5,
'/start_ticker': 10,
'/run_ticker': 13,
'/stop_ticker': 15,
'/set_ticker_speed': 1160,
'/loadgame': 20,
'/newgame': 30,
'/savegame': 40,
'/exit_game': 45,
'/check_scoreboard': 50,
'/buy_stock': 60,
'/sell_stock': 70,
'/short_stock': 80,
'/cover_short_stock': 90,
'/buy_corporate_bond': 100,
'/sell_corporate_bond': 110,
'/buy_long_govt_bonds': 120,
'/sell_long_govt_bonds': 125,
'/buy_short_govt_bonds': 130,
'/sell_short_govt_bonds': 135,
'/buy_commodity_futures': 140,
'/sell_commodity_futures': 150,
'/short_commodity_futures': 160,
'/cover_short_commodity_futures': 170,
'/buy_physical_commodity': 180,
'/sell_physical_commodity': 190,
'/buy_physical_crypto': 200,
'/sell_physical_crypto': 210,
'/buy_crypto_futures': 220,
'/sell_crypto_futures': 230,
'/buy_calls': 280,
'/sell_calls': 290,
'/buy_puts': 300,
'/sell_puts': 310,
'/advanced_options_trading': 330,
'/exercise_call_options_early': 340,
'/exercise_put_options_early': 350,
'/prepay_taxes': 360,
'/elect_ceo': 370,
'/resign_as_ceo': 380,
'/change_managers': 390,
'/set_dividend': 400,
'/set_productivity': 410,
'/set_growth_rate': 420,
'/restructure': 430,
'/buy_corporate_assets': 440,
'/sell_corporate_assets': 450,
'/view_for_sale_items': 456,
'/sell_subsidiary_stock': 460,
'/rebrand': 470,
'/toggle_company_autopilot': 480,
'/toggle_global_autopilot': 490,
'/become_etf_advisor': 500,
'/set_advisory_fee': 510,
'/merger': 530,
'/greenmail': 540,
'/lbo': 550,
'/startup': 560,
'/capital_contribution': 570,
'/public_stock_offering': 580,
'/private_stock_offering': 590,
'/issue_new_corp_bonds': 600,
'/redeem_corp_bonds': 610,
'/extraordinary_dividend': 620,
'/tax_free_liquidation': 630,
'/taxable_liquidation': 640,
'/spin_off': 650,
'/split_stock': 660,
'/reverse_split_stock': 670,
'/borrow_money': 680,
'/repay_loan': 690,
'/advance_funds': 700,
'/call_in_advance': 710,
'/interest_rate_swaps': 720,
'/view_swap_details': 724,
'/terminate_swap': 725,
'/set_bank_allocation': 730,
'/trade_tbills': 740,
'/list_bank_loans': 750,
'/change_bank': 770,
'/call_in_loan': 780,
'/buy_business_loans': 810,
'/sell_business_loan': 820,
'/buy_consumer_loans': 830,
'/sell_consumer_loans': 840,
'/buy_prime_mortgages': 850,
'/sell_prime_mortgages': 860,
'/buy_subprime_mortgages': 870,
'/sell_subprime_mortgages': 880,
'/list_etfs': 890,
'/freeze_all_loans': 900,
'/freeze_loan': 905,
'/decrease_earnings': 910,
'/increase_earnings': 920,
'/change_law_firm': 930,
'/antitrust_lawsuit': 940,
'/harrassing_lawsuit': 950,
'/spread_rumors': 960,
'/credit_info': 1080,
'/clear_chart': 1090,
'/growth_throttle': 1100,
'/clear_stream_list': 1110,
'/fill_stream_list': 1120,
'/database_search': 1150,
'/set_ticker_speed': 1160,
'/set_view_asset': 2000,
'/set_view_industry': 2003,
'/change_acting_as': 2001,
'/toggle_streaming_quote': 2002,
'/close_modal': 3000,
// '/set_active_ui_report' — handled as special case in postIdArg (direct pointer write, not an event)
'/set_tutorial_step': 4100,
'/set_tutorial_enabled': 4101,
'/set_chart_type': 4200,
'/set_locale': 4201,
'/supp_earn_select': 2100,
'/currency_select': 2101,
'/supp_warn_select': 2102,
'/suppress_select': 2103,
'/autosave_select': 2104,
'/exercise_select': 2105,
'/sweep_select': 2106,
'/makedelivery_select': 2107,
'/takedelivery_select': 2108,
'/tooltips_select': 2109,
'/shareholdergraph_select': 2110,
'/unethical_select': 2111,
'/disablehotkeys_select': 2112,
'/autoadd_select': 2113,
'/cheat_disable_lawsuits': 2120,
'/cheat_merger_info': 2121,
'/cheat_earnings_info': 2122,
'/cheat_add_cash': 2123,
'/create_price_alert': 4102,
'/delete_price_alert': 4103,
'/show_price_alerts': 4104,
};
// Market report REST paths that use trigger_event with specific GameEventTypes
const REPORT_PATH_TO_EVENT = {
'/view_current_interest_rates': 970,
'/whos_ahead': 980,
'/db_research_tool': 990,
'/economic_stats': 1000,
'/most_cash_report': 1010,
'/largest_market_cap': 1020,
'/largest_tax_losses': 1030,
'/industry_summary': 1040,
'/industry_projections': 1050,
'/view_corp_assets_for_sale': 1060,
};
// Network error handling - refresh browser only after sustained failures (e.g., sleep/wake).
// Transient failures (server busy during game load) must NOT trigger reload or it causes
// an infinite refresh loop: load game -> server busy -> fetch fails -> reload -> repeat.
let _networkFailCount = 0;
const NETWORK_FAIL_THRESHOLD = 5;
async function fetchWithRetry(pathOrUrl, options = {}) {
// Must await bridgeReady BEFORE building the URL — apiBase is null until
// the handshake arrives, so constructing "null/foo" synchronously at the
// caller would produce a broken URL.
await bridgeReady;
const url = (typeof pathOrUrl === 'string' && pathOrUrl.startsWith('/'))
? apiBase + pathOrUrl
: pathOrUrl;
const method = options.method || 'GET';
const pathOnly = (typeof pathOrUrl === 'string' && pathOrUrl.startsWith('/'))
? pathOrUrl
: String(pathOrUrl).replace(apiBase || '', '');
const t0 = performance.now();
try {
const response = await fetch(url, options);
_networkFailCount = 0; // Reset on any successful fetch
return response;
} catch (error) {
_networkFailCount++;
if (_networkFailCount <= 1 || _networkFailCount % 10 === 0) {
console.warn(`[FETCH #${_networkFailCount}] ${method} ${pathOnly} FAILED (${(performance.now() - t0).toFixed(1)}ms, streak=${_networkFailCount}): ${error.message}`);
}
throw error;
}
}
/**
* Industry IDs
*/
export const PLAYER_IND = 0;
/**
* b a n k_ i n d
*/
export const BANK_IND = 1;
/**
* i n s u r a n c e_ i n d
*/
export const INSURANCE_IND = 2;
/**
* s e c u r i t i e s_ b r o k e r_ i n d
*/
export const SECURITIES_BROKER_IND = 37;
/**
* e t f_ i n d
*/
export const ETF_IND = 71;
/**
* Entity IDs
*/
export const HUMAN1_ID = 2;
/**
* c o m p u t e r1_ i d
*/
export const COMPUTER1_ID = 1;
/**
* c o m p u t e r2_ i d
*/
export const COMPUTER2_ID = 3;
/**
* c o m p u t e r3_ i d
*/
export const COMPUTER3_ID = 4;
/**
* c o m p u t e r4_ i d
*/
export const COMPUTER4_ID = 5;
/**
* s t o c k_ i n d e x_ i d
*/
export const STOCK_INDEX_ID = 0;
/**
* c m o d_ i d
*/
export const CMOD_ID = 1600; // Basic Commodities Fund ETF
/**
* o i l_ i d
*/
export const OIL_ID = 6;
/**
* g o l d_ i d
*/
export const GOLD_ID = 7;
/**
* s i l v e r_ i d
*/
export const SILVER_ID = 8;
/**
* w h e a t_ i d
*/
export const WHEAT_ID = 9;
/**
* c o r n_ i d
*/
export const CORN_ID = 10;
/**
* p r i m e_ r a t e_ i d
*/
export const PRIME_RATE_ID = 1601;
/**
* t b o n d_ r a t e_ i d
*/
export const TBOND_RATE_ID = 1602;
/**
* s b o n d_ r a t e_ i d
*/
export const SBOND_RATE_ID = 1603;
/**
* g n p_ r a t e_ i d
*/
export const GNP_RATE_ID = 1604;
/**
* b i t c o i n_ i d
*/
export const BITCOIN_ID = 1605;
/**
* e t h e r e u m_ i d
*/
export const ETHEREUM_ID = 1606;
/**
* UI IDs
*/
export const UI_ADVISORY_REPORT = 1;
/**
* u i_ m a r k e t_ r e p o r t s_ c o m m o d_ f u t u r e s_ r e p o r t
*/
export const UI_MARKET_REPORTS_COMMOD_FUTURES_REPORT = 1;
/**
* u i_ m a r k e t_ r e p o r t s_ c o m m o d_ p h y s i c a l_ r e p o r t
*/
export const UI_MARKET_REPORTS_COMMOD_PHYSICAL_REPORT = 2;
/**
* u i_ m a r k e t_ r e p o r t s_ o p t i o n s_ r e p o r t
*/
export const UI_MARKET_REPORTS_OPTIONS_REPORT = 3;
/**
* u i_ m a r k e t_ r e p o r t s_ i n t e r e s t_ r a t e_ s w a p s_ r e p o r t
*/
export const UI_MARKET_REPORTS_INTEREST_RATE_SWAPS_REPORT = 4;
/**
* u i_ m a r k e t_ r e p o r t s_ s t o c k s_ r e p o r t
*/
export const UI_MARKET_REPORTS_STOCKS_REPORT = 5;
/**
* u i_ m a r k e t_ r e p o r t s_ i n v e s t m e n t_ c o n t r a c t s_ r e p o r t
*/
export const UI_MARKET_REPORTS_INVESTMENT_CONTRACTS_REPORT = 6;
/**
* u i_ m a r k e t_ r e p o r t s_ i n d u s t r y_ g r o w t h_ r a t e s_ r e p o r t
*/
export const UI_MARKET_REPORTS_INDUSTRY_GROWTH_RATES_REPORT = 7;
/**
* u i_ m a r k e t_ r e p o r t s_ l a r g e s t_ m a r k e t_ s h a r e_ r e p o r t
*/
export const UI_MARKET_REPORTS_LARGEST_MARKET_SHARE_REPORT = 8;
/**
* u i_ m a r k e t_ r e p o r t s_ l a r g e s t_ t a x_ l o s s e s_ r e p o r t
*/
export const UI_MARKET_REPORTS_LARGEST_TAX_LOSSES_REPORT = 9;
/**
* u i_ m a r k e t_ r e p o r t s_ m o s t_ c a s h_ r e p o r t
*/
export const UI_MARKET_REPORTS_MOST_CASH_REPORT = 10;
/**
* u i_ m a r k e t_ r e p o r t s_ m o s t_ m a r k e t_ c a p_ r e p o r t
*/
export const UI_MARKET_REPORTS_MOST_MARKET_CAP_REPORT = 11;
/**
* u i_ m a r k e t_ r e p o r t s_ e c o n_ s t a t s_ r e p o r t
*/
export const UI_MARKET_REPORTS_ECON_STATS_REPORT = 12;
/**
* u i_ m a r k e t_ r e p o r t s_ i n t e r e s t_ r a t e s_ r e p o r t
*/
export const UI_MARKET_REPORTS_INTEREST_RATES_REPORT = 13;
/**
* u i_ m a r k e t_ r e p o r t s_ w h o_ a h e a d_ r e p o r t
*/
export const UI_MARKET_REPORTS_WHO_AHEAD_REPORT = 14;
/**
* u i_ i n d u s t r y_ s u m m a r y_ r e p o r t
*/
export const UI_INDUSTRY_SUMMARY_REPORT = 15;
/**
* u i_ i n d u s t r y_ p r o j e c t i o n s_ r e p o r t
*/
export const UI_INDUSTRY_PROJECTIONS_REPORT = 16;
/**
* u i_ c o r p_ f i n a n c i a l_ p r o f i l e
*/
export const UI_CORP_FINANCIAL_PROFILE = 17;
/**
* u i_ b a n k_ l o a n s_ l i s t
*/
export const UI_BANK_LOANS_LIST = 18;
/**
* u i_ c o r p_ c a s h_ f l o w_ p r o j e c t i o n
*/
export const UI_CORP_CASH_FLOW_PROJECTION = 19;
/**
* u i_ c o r p_ r e s e a r c h_ r e p o r t
*/
export const UI_CORP_RESEARCH_REPORT = 20;
/**
* u i_ c o r p_ s t o c k s_ b o n d s_ p o r t f o l i o
*/
export const UI_CORP_STOCKS_BONDS_PORTFOLIO = 21;
/**
* u i_ c o r p_ o p t i o n s_ p o r t f o l i o
*/
export const UI_CORP_OPTIONS_PORTFOLIO = 22;
/**
* u i_ c o r p_ s h a r e h o l d e r s_ l i s t
*/
export const UI_CORP_SHAREHOLDERS_LIST = 23;
/**
* u i_ c o r p_ c o m m o d i t y_ c o n t r a c t s_ l i s t
*/
export const UI_CORP_COMMODITY_CONTRACTS_LIST = 24;
/**
* u i_ c o r p_ e a r n i n g s_ r e p o r t
*/
export const UI_CORP_EARNINGS_REPORT = 25;
/**
* u i_ p l a y e r_ f i n a n c i a l_ p r o f i l e
*/
export const UI_PLAYER_FINANCIAL_PROFILE = 26;
/**
* u i_ p l a y e r_ c a s h_ f l o w_ p r o j e c t i o n
*/
export const UI_PLAYER_CASH_FLOW_PROJECTION = 27;
/**
* u i_ p l a y e r_ s t o c k s_ b o n d s_ p o r t f o l i o
*/
export const UI_PLAYER_STOCKS_BONDS_PORTFOLIO = 28;
/**
* u i_ p l a y e r_ o p t i o n s_ p o r t f o l i o
*/
export const UI_PLAYER_OPTIONS_PORTFOLIO = 29;
/**
* u i_ p l a y e r_ c o r p o r a t i o n s_ l i s t
*/
export const UI_PLAYER_CORPORATIONS_LIST = 30;
/**
* u i_ p l a y e r_ c o m m o d i t y_ c o n t r a c t s_ l i s t
*/
export const UI_PLAYER_COMMODITY_CONTRACTS_LIST = 31;
/**
* u i_ p l a y e r_ a d v a n c e s_ l i s t
*/
export const UI_PLAYER_ADVANCES_LIST = 32;
/**
* u i_ m a r k e t_ h e a t m a p
*/
export const UI_MARKET_HEATMAP = 33;
/**
* u i_ i n d u s t r y_ h e a t m a p
*/
export const UI_INDUSTRY_HEATMAP = 34;
/**
* u i_ i n d u s t r i e s_ h e a t m a p
*/
export const UI_INDUSTRIES_HEATMAP = 35;
/**
* u i_ c o m p a n i e s_ h e a t m a p
*/
export const UI_COMPANIES_HEATMAP = 36;
/**
* u i_ c o r p_ s w a p s_ p o r t f o l i o
*/
export const UI_CORP_SWAPS_PORTFOLIO = 37;
/**
* u i_ p l a y e r_ s w a p s_ p o r t f o l i o
*/
export const UI_PLAYER_SWAPS_PORTFOLIO = 38;
/**
* u i_ d b_ s e a r c h
*/
export const UI_DB_SEARCH = 39;
/**
* u i_ c o r p_ h o l d i n g s
*/
export const UI_CORP_HOLDINGS = 40;
/**
* u i_ p l a y e r_ h o l d i n g s
*/
export const UI_PLAYER_HOLDINGS = 41;
/**
* u i_ c o r p_ o v e r v i e w
*/
export const UI_CORP_OVERVIEW = 42;
/**
* Dispatches a POST request with no arguments (empty JSON body).
* Automatically routes to REST or IPC based on current mode.
* @param {string} path - REST endpoint path (e.g. "/start_ticker")
* @returns {Promise<string>} Response text from backend
*/
export async function postNoArg(path) {
if (useIPC) {
// Special-case endpoints that need dedicated IPC handlers
if (path === '/close_modal') { return ipcRenderer.invoke('game:closeModal'); }
if (path === '/splash_screen_played') { return ipcRenderer.invoke('game:splashScreenPlayed'); }
// Map REST path to event type and dispatch via IPC
const eventType = REST_TO_EVENT[path] ?? REPORT_PATH_TO_EVENT[path];
if (eventType !== undefined) {
return ipcRenderer.invoke('game:dispatch', eventType, 0, '');
}
console.warn('[api] No IPC mapping for POST', path);
}
const response = await fetchWithRetry(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.text();
}
/**
* Dispatches a POST request with a single "id" integer argument.
* @param {string} path - REST endpoint path
* @param {number} id - Entity ID or numeric parameter
* @returns {Promise<any>} Parsed JSON response from backend
*/
export async function postIdArg(path, id) {
if (useIPC) {
// /asset_chart needs its own IPC handler (returns data, not just ack)
if (path === '/asset_chart') {
return ipcRenderer.invoke('game:getAssetChart', typeof id === 'string' ? Number(id) : id);
}
// /set_who_owns_filter uses a different param name
if (path === '/set_who_owns_filter') {
return ipcRenderer.invoke('game:setWhoOwnsFilter', id);
}
// /set_active_ui_report is a direct pointer write, not an event
if (path === '/set_active_ui_report') {
return ipcRenderer.invoke('game:setActiveUIReport', id);
}
const eventType = REST_TO_EVENT[path];
if (eventType !== undefined) {
return ipcRenderer.invoke('game:dispatch', eventType, id || 0, '');
}
console.warn('[api] No IPC mapping for POST', path, 'id=', id);
}
const response = await fetchWithRetry(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}
/**
* Variant of postIdArg that optionally includes an acting-as entity override.
* If actingAsId > 0, the backend switches context before executing the command.
* @param {string} path - REST endpoint path
* @param {number} id - Entity ID
* @param {number} [actingAsId=0] - Entity to act as (0 for current view entity)
* @returns {Promise<any>} Parsed JSON response
*/
export async function postIdArgWithActingAs(path, id, actingAsId = 0) {
if (useIPC) {
const eventType = REST_TO_EVENT[path];
if (eventType !== undefined) {
return ipcRenderer.invoke('game:dispatch', eventType, id || 0, '', actingAsId || 0);
}
console.warn('[api] No IPC mapping for POST', path, 'id=', id);
}
const body = (actingAsId > 0) ? { id, intParam2: actingAsId } : { id };
const response = await fetchWithRetry(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}
/**
* Variant for options-trade endpoints (buy/sell calls/puts). Same shape as postIdArgWithActingAs, plus an optional `underlyingId`: when id=0 and underlyingId>0, the C++ bridge stashes it in strParam1 and PB uses it as TGT — skipping SelectCompanyModal$. Lets CLI "CALL ABC" resolve the symbol up front without colliding with IntParam1's ContractNum& semantics.
* @param {any} path
* @param {number} id
* @param {number} [actingAsId=0]
* @param {number} [underlyingId=0]
* @returns {Promise<any>}
*/
export async function postOptionsTradeWithActingAs(path, id, actingAsId = 0, underlyingId = 0) {
if (useIPC) {
const eventType = REST_TO_EVENT[path];
if (eventType !== undefined) {
const strParam1 = underlyingId > 0 ? String(underlyingId) : '';
return ipcRenderer.invoke('game:dispatch', eventType, id || 0, strParam1, actingAsId || 0);
}
console.warn('[api] No IPC mapping for POST', path, 'id=', id);
}
const body = { id };
if (actingAsId > 0) body.intParam2 = actingAsId;
if (underlyingId > 0) body.underlyingId = underlyingId;
const response = await fetchWithRetry(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}
/**
* Post string arg
* @param {any} path
* @param {string} str
* @returns {Promise<any>}
*/
export async function postStringArg(path, str) {
if (useIPC) {
// String-arg endpoints: map to event + strParam1
if (path === '/load_specific_save') {
// LOAD_SPECIFIC_GAME = 21, str goes in strParam1 as "filename"
return ipcRenderer.invoke('game:dispatch', 21, 0, str || '');
}
if (path === '/savegameas') {
// SAVE_GAME_AS = 41, filename in strParam1
return ipcRenderer.invoke('game:dispatch', 41, 0, str || '');
}
console.warn('[api] No IPC mapping for POST string', path);
}
const response = await fetchWithRetry(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ str })
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.text();
}
/**
* Dispatches a GET request and returns the parsed JSON response.
* @param {string} path - REST endpoint path
* @returns {Promise<any>}
*/
export async function getJSON(path) {
if (useIPC) {
if (path === '/gamestate') return ipcRenderer.invoke('game:getGameState');
if (path === '/database_data') return ipcRenderer.invoke('game:getDatabaseData');
if (path === '/ownership_tree') return ipcRenderer.invoke('game:getOwnershipTree');
if (path === '/subsidiaries_tree') return ipcRenderer.invoke('game:getSubsidiariesTree');
if (path === '/quote') return ipcRenderer.invoke('game:getQuote');
console.warn('[api] No IPC mapping for GET', path);
}
const response = await fetchWithRetry(path);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
return data;
}
/**
* Fetch database search data.
* @returns {Promise<any>}
*/
export async function getDatabaseData() {
return getJSON('/database_data');
}
/**
* Fetch ownership tree (who owns the active entity).
* @returns {Promise<any>}
*/
export async function getOwnershipTree() {
return getJSON('/ownership_tree');
}
/**
* Fetch subsidiaries tree (what the active entity owns).
* @returns {Promise<any>}
*/
export async function getSubsidiariesTree() {
return getJSON('/subsidiaries_tree');
}
/**
* Checks if an entity is controlled by the player.
* @param {Array<object>} controlledCompanies - List of companies from gameState
* @param {number} entityId - ID to check
* @returns {boolean}
*/
export function isPlayerControlled(controlledCompanies, entityId) {
return (controlledCompanies || []).some(c => c.id === entityId);
}
/**
* Checks if the player is the CEO of a company.
* @param {number} chairedCompanyId - player.chairedCompanyId from gameState
* @param {number} entityId - ID to check
* @returns {boolean}
*/
export function isPlayerCEO(chairedCompanyId, entityId) {
return chairedCompanyId === entityId;
}
/**
* Returns the company object for a given ticker symbol.
* @param {Array<object>} allCompanies - List of companies from gameState
* @param {string} symbol - Ticker symbol
* @returns {object|undefined}
*/
export function getCompanyBySymbol(allCompanies, symbol) {
return (allCompanies || []).find(c => c.symbol === symbol);
}
/**
* Returns the industry object for a given industry ID.
* @param {Array<object>} allIndustries - List of industries from gameState
* @param {number} industryNum - Industry ID
* @returns {object|undefined}
*/
export function getIndustry(allIndustries, industryNum) {
return (allIndustries || []).find(ind => ind.id === industryNum);
}
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
let lookup = {};
// Memoization cache for buildDictRegex - avoids rebuilding on every 50ms poll
let _cachedRegex = null;
let _cachedFingerprint = '';
function _dictFingerprint(allCompanies, allIndustries) {
// Fast fingerprint: count + first/last IDs. Lists only change on mergers/new companies.
const cl = (allCompanies || []).length;
const il = (allIndustries || []).length;
const c0 = cl > 0 ? allCompanies[0].id : 0;
const cN = cl > 0 ? allCompanies[cl - 1].id : 0;
const i0 = il > 0 ? allIndustries[0].id : 0;
const iN = il > 0 ? allIndustries[il - 1].id : 0;
return `${cl}:${c0}:${cN}:${il}:${i0}:${iN}`;
}
/**
* Build dict regex
* @param {any} allCompanies
* @param {string} allIndustries
*/
export function buildDictRegex(allCompanies, allIndustries) {
// Return cached regex if the company/industry lists haven't changed
const fp = _dictFingerprint(allCompanies, allIndustries);
if (_cachedRegex && fp === _cachedFingerprint) {
return _cachedRegex;
}
_cachedFingerprint = fp;
lookup = Object.create(null); // key: lowercased token -> {id, type}
// Companies: match by symbol (2+ chars only to avoid false hyperlinks) and name
// BUG-027/082/103: Single-letter symbols (N, X, T, E, M, H, L) trigger on common words
for (const c of allCompanies || []) {
if (c.symbol && c.symbol.length >= 2) lookup[c.symbol] = { id: c.id, type: 'C' };
if (c.name) lookup[c.name] = { id: c.id, type: 'C' };
}
// Industries: names can be multi-word, add common informal aliases (BUG-088)
const INDUSTRY_ALIASES = {
'BIOTECHNOLOGY': ['BioTech', 'Biotech', 'BIOTECH'],
'MEDICAL EQUIP. / SUPPLIES': ['Medical Supplies', 'Medical Equipment'],
'ENTERTAIN. & BROADCAST': ['Entertainment', 'Broadcasting'],
'HOUSEHOLD & PERS. PRODS.': ['Household Products'],
'INTERNET SERV. / CONTENT': ['Internet Services', 'Internet'],
'NETWORK & TELECOM EQUIP.': ['Telecom Equipment'],
};
for (const ind of allIndustries || []) {
if (ind.name) {
lookup[ind.name] = { id: ind.id, type: 'I' };
lookup[toTitleCase(ind.name)] = { id: ind.id, type: 'I' };
lookup[toTitleCase(ind.name).toUpperCase()] = { id: ind.id, type: 'I' };
// Add aliases for industry names that appear informally in game text
const aliases = INDUSTRY_ALIASES[ind.name];
if (aliases) {
for (const alias of aliases) {
lookup[alias] = { id: ind.id, type: 'I' };
}
}
}
}
const keys = Object.keys(lookup);
const esc = keys.map(escapeRe).sort((a, b) => b.length - a.length);
const singles = esc.filter(k => /^[A-Za-z]$/.test(k));
const others = esc.filter(k => !singles.includes(k));
const parts = [];
if (others.length) parts.push(`(?:${others.join("|")})`);
if (singles.length) parts.push(`(?:${singles.join("|")})(?!\\.(?=\\w))`);
const core = `(?:${parts.join("|")})`;
const parenWrapped = `(?<=\\()${core}(?=\\))`;
const dblQuoted = `(?<=")${core}(?=")`;
const sglQuotedBoth = `(?<=')${core}(?=')`;
const bareEndSglOnly = `(?<!')(?<![A-Za-z0-9("'(])${core}(?=')`;
const bareNoWrap = `(?<![A-Za-z0-9("'(])${core}(?![A-Za-z0-9)"'])`;
const allowed = `(?:${parenWrapped}|${dblQuoted}|${sglQuotedBoth}|${bareEndSglOnly}|${bareNoWrap})`;
const pattern =
`(?<![./_])${allowed}(?![/$_])(?!-(?=[A-Za-z0-9]))(?!/(?=[A-Za-z0-9]))`;
_cachedRegex = new RegExp(pattern, "g");
return _cachedRegex;
}
function toTitleCase(str) {
const exceptions = ['and', 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'a', 'an', 'the'];
return str
.toLowerCase()
.replaceAll("&", "and")
.replace(/\b\w+/g, (w, i) =>
exceptions.includes(w) && i !== 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)
);
}
/**
* Render hyperlinks
* @param {any} headline
* @param {any} onClick
* @param {any} regex
*/
export function renderHyperlinks(headline, onClick, regex) {
if ([
"Operating Earnings",
"LONG AND SHORT",
"LONG POSITION",
"SHORT POSITION",
"Credit Rating",
"SUBPRIME MORT.",
"LONG BOND",
"SHORT BOND",
'AS "ACTIVE ENTITY"',
"LONG PARTY",
"NAME OF ENTITY WITH LONG",
"SAVED AS"
].some(s => headline.includes(s))) return headline;
headline = insertCurrencySymbols(headline);
const parts = [];
let lastIndex = 0;
let m;
regex.lastIndex = 0;
while ((m = regex.exec(headline)) !== null) {
const before = headline.slice(lastIndex, m.index);
if (before) parts.push(before);
const raw = m[0];
const info = lookup[raw]; // {id, type}
parts.push(html`
<span
class="text-blue-400 cursor-pointer hover:underline"
onClick=${() => onClick({ id: info.id, type: info.type })}
>${raw}</span>
`);
lastIndex = regex.lastIndex;
}
const after = headline.slice(lastIndex);
if (after) parts.push(after);
return html`${parts}`;
}
/* General */
/**
* Get game state
*/
export function getGameState() { return getJSON('/gamestate'); }
/**
* Clear event string
* @returns {Promise<any>}
*/
export async function clearEventString() { await postNoArg('/clear_event_string'); }
/**
* Start ticker
* @returns {Promise<any>}
*/
export async function startTicker() {
// Optimistically update UI immediately so ticker starts without waiting for backend round-trip
localTickerRunning = true;
gameStore.setState(state => ({
gameState: { ...state.gameState, isTickerRunning: true }
}));
await postNoArg('/start_ticker');
}
/**
* Run ticker
* @returns {Promise<any>}
*/
export async function runTicker() { await postNoArg('/run_ticker'); }
/**
* Stop ticker
* @returns {Promise<any>}
*/
export async function stopTicker() {
// Optimistically update UI immediately so ticker stops without waiting for backend round-trip
localTickerRunning = false;
gameStore.setState(state => ({
gameState: { ...state.gameState, isTickerRunning: false }
}));
await postNoArg('/stop_ticker');
}
/**
* Set tick speed
* @param {any} speed
* @returns {Promise<any>}
*/
export async function setTickSpeed(speed) { await postIdArg('/set_ticker_speed', speed); }
/**
* Advance ticker
* @returns {Promise<any>}
*/
export async function advanceTicker() {
if (useIPC) return ipcRenderer.invoke('game:advanceTicker');
return postNoArg('/ticker_advance');
}
/**
* Load game
* @returns {Promise<any>}
*/
export async function loadGame() {
await postNoArg('/loadgame');
}
/**
* New game
* @returns {Promise<any>}
*/
export async function newGame() {
await postNoArg('/newgame');
}
/**
* Save game
* @returns {Promise<any>}
*/
export async function saveGame() { await postNoArg('/savegame'); }
/**
* Save game as
* @param {string} filename
* @returns {Promise<any>}
*/
export async function saveGameAs(filename) {
if (useIPC) {
// SAVE_GAME_AS = 41, filename in strParam1
return ipcRenderer.invoke('game:dispatch', 41, 0, filename || '');
}
await fetchWithRetry('/savegameas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
}
/**
* List available save files (via IPC to main process)
* @returns {Promise<any>}
*/
export async function listSaves() {
return ipcRenderer.invoke('list-saves');
}
/**
* Delete a save file (via IPC to main process)
* @param {string} filename
* @returns {Promise<any>}
*/
export async function deleteSave(filename) {
return ipcRenderer.invoke('delete-save', filename);
}
/**
* Load a specific save file by triggering the backend load event
* @param {string} filename
* @returns {Promise<any>}
*/
export async function loadSpecificSave(filename) {
if (useIPC) {
// LOAD_SPECIFIC_GAME = 21, filename in strParam1
return ipcRenderer.invoke('game:dispatch', 21, 0, filename || '');
}
// First set the filename, then trigger load
await fetchWithRetry('/load_specific_save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
}
/**
* Validate Windows filename (no invalid characters)
* @param {string} filename
*/
export function isValidWindowsFilename(filename) {
if (!filename || filename.length === 0) return false;
// Invalid Windows filename characters: \ / : * ? " < > |
const invalidChars = /[\\/:*?"<>|]/;
return !invalidChars.test(filename);
}
/**
* Exit game
* @returns {Promise<any>}
*/
export async function exitGame() {
// Just send the exit_game request - WSR will show save dialog,
// and if user confirms, WSR will exit and Electron will restart it automatically
await postNoArg('/exit_game');
}
/**
* Exit to desktop
* @returns {Promise<any>}
*/
export async function exitToDesktop() {
gameStore.setState(state => ({
gameState: { ...state.gameState, isLoading: true }
}));
ipcRenderer.send('exit-to-desktop');
}
// Listen for wsr.exe restarting - show loading state
ipcRenderer.on('wsr-restarting', () => {
console.log('wsr.exe exited, restarting...');
gameStore.setState(state => ({
gameState: { ...state.gameState, isLoading: true }
}));
});
// Listen for wsr.exe restart completion - reset game state to show main menu.
// Preserve splashScreenPlayed across restart so the splash overlay doesn't briefly
// flash when the previous value (true) is wiped to undefined and the selector
// evaluates !undefined === true before the new gameState broadcast arrives.
ipcRenderer.on('wsr-restarted', () => {
console.log('wsr.exe restarted, returning to main menu...');
gameStore.setState(state => ({
gameState: {
gameLoaded: false,
isLoading: false,
splashScreenPlayed: state.gameState?.splashScreenPlayed,
},
}));
});
/**
* Check scoreboard
* @returns {Promise<any>}
*/
export async function checkScoreboard() { await postNoArg('/check_scoreboard'); }
/**
* Get quote of the day
* @returns {Promise<any>}
*/
export async function getQuoteOfTheDay() { return getJSON('/quote'); }
/**
* Set active u i report
* @param {number} uiId
* @returns {Promise<any>}
*/
export async function setActiveUIReport(uiId) { await postIdArg('/set_active_ui_report', uiId); }
/**
* Get asset chart
* @param {number} id
* @returns {Promise<any>}
*/
export async function getAssetChart(id) {
// Some Jenkins builds don't return chart history until later (e.g., after Q2 starts).
// The UI should still render charts early in the game, so we provide a bounded
// fallback: a flat series based on the latest known value.
const buildFallback = () => {
const gs = gameStore.getState?.().gameState || {};
const month = Number.isFinite(gs.currentMonth) ? gs.currentMonth : 0;
const year = Number.isFinite(gs.currentYear) ? gs.currentYear : 0;
const idNum = (typeof id === 'string') ? Number(id) : id;
let v = null;
// Player / human net worth chart.
if (idNum === HUMAN1_ID) v = gs.netWorth;
// Macro charts (best-effort).
else if (idNum === GNP_RATE_ID) v = gs.gnpRate ?? gs.gdpRate ?? gs.gdp;
else if (idNum === PRIME_RATE_ID) v = gs.primeRate;
// Stocks shown in Streaming Quotes.
if (v == null) {
const sq = Array.isArray(gs.streamingQuotesList) ? gs.streamingQuotesList : [];
const hit = sq.find(q => Number(q?.id) === idNum);
if (hit && Number.isFinite(hit.price)) v = hit.price;
}
// Last resort: 0 so chart can still paint.
if (!Number.isFinite(v)) v = 0;
return {
prices: [v, v, v, v, v, v],
highs: [],
lows: [],
baseMonth: month,
baseYear: year,
};
};
try {
const data = await postIdArg('/asset_chart', id);
if (data && Array.isArray(data.prices) && data.prices.length >= 3) {
// Filter out leading zeros — matching PB's charTEST.inc behavior.
// Zero values compress the chart scale, making it look like only
// the current price is shown.
const firstNonZero = data.prices.findIndex(p => p !== 0);
if (firstNonZero > 0) {
const fillValue = data.prices[firstNonZero];
for (let i = 0; i < firstNonZero; i++) {
data.prices[i] = fillValue;
}
}
if (!Array.isArray(data.highs)) data.highs = [];
if (!Array.isArray(data.lows)) data.lows = [];
return data;
}
return buildFallback();
} catch (e) {
console.error(e);
return buildFallback();
}
}
/* Trading Center - Stocks */
/**
* Buy stock
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyStock(id, actingAsId = 0) {
console.log('[Tutorial] buyStock called with id:', id);
await postIdArgWithActingAs('/buy_stock', id, actingAsId);
}
/**
* Sell stock
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellStock(id, actingAsId = 0) { await postIdArgWithActingAs('/sell_stock', id, actingAsId); }
/**
* Short stock
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function shortStock(id, actingAsId = 0) { await postIdArgWithActingAs('/short_stock', id, actingAsId); }
/**
* Cover short stock
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function coverShortStock(id, actingAsId = 0) { await postIdArgWithActingAs('/cover_short_stock', id, actingAsId); }
/* Bonds */
/**
* Buy corporate bond
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyCorporateBond(id, actingAsId = 0) { await postIdArgWithActingAs('/buy_corporate_bond', id, actingAsId); }
/**
* Sell corporate bond
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellCorporateBond(id, actingAsId = 0) { await postIdArgWithActingAs('/sell_corporate_bond', id, actingAsId); }
/**
* Buy long govt bonds
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyLongGovtBonds(actingAsId = 0) { await postIdArgWithActingAs('/buy_long_govt_bonds', 0, actingAsId); }
/**
* Sell long govt bonds
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellLongGovtBonds(actingAsId = 0) { await postIdArgWithActingAs('/sell_long_govt_bonds', 0, actingAsId); }
/**
* Buy short govt bonds
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyShortGovtBonds(actingAsId = 0) { await postIdArgWithActingAs('/buy_short_govt_bonds', 0, actingAsId); }
/**
* Sell short govt bonds
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellShortGovtBonds(actingAsId = 0) { await postIdArgWithActingAs('/sell_short_govt_bonds', 0, actingAsId); }
/* Commodity Futures */
/**
* Buy commodity futures
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyCommodityFutures(id, actingAsId = 0) { await postIdArgWithActingAs('/buy_commodity_futures', id, actingAsId); }
/**
* Sell commodity futures
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellCommodityFutures(id, actingAsId = 0) { await postIdArgWithActingAs('/sell_commodity_futures', id, actingAsId); }
/**
* Close long commodity futures by slot
* @param {any} slot
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function closeLongCommodityFuturesBySlot(slot, actingAsId = 0) { await postIdArgWithActingAs('/close_long_commodity_futures_by_slot', slot, actingAsId); }
/**
* Short commodity futures
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function shortCommodityFutures(id, actingAsId = 0) { await postIdArgWithActingAs('/short_commodity_futures', id, actingAsId); }
/**
* Cover short commodity futures
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function coverShortCommodityFutures(id, actingAsId = 0) { await postIdArgWithActingAs('/cover_short_commodity_futures', id, actingAsId); }
/**
* Cover short commodity futures by slot
* @param {any} slot
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function coverShortCommodityFuturesBySlot(slot, actingAsId = 0) { await postIdArgWithActingAs('/cover_short_commodity_futures_by_slot', slot, actingAsId); }
/* Physical Commodities */
/**
* Buy physical commodity
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyPhysicalCommodity(id, actingAsId = 0) { await postIdArgWithActingAs('/buy_physical_commodity', id, actingAsId); }
/**
* Sell physical commodity
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellPhysicalCommodity(id, actingAsId = 0) { await postIdArgWithActingAs('/sell_physical_commodity', id, actingAsId); }
/* Crypto */
/**
* Buy physical crypto
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyPhysicalCrypto(id, actingAsId = 0) { await postIdArgWithActingAs('/buy_physical_crypto', id, actingAsId); }
/**
* Sell physical crypto
* @param {number} id
* @returns {Promise<any>}
*/
export async function sellPhysicalCrypto(id) { await postIdArg('/sell_physical_crypto', id); }
/**
* Buy crypto futures
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyCryptoFutures(id, actingAsId = 0) { await postIdArgWithActingAs('/buy_crypto_futures', id, actingAsId); }
/**
* Sell crypto futures
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellCryptoFutures(id, actingAsId = 0) { await postIdArgWithActingAs('/sell_crypto_futures', id, actingAsId); }
/* Options */
/**
* Buy calls
* @param {number} id
* @param {number} [actingAsId=0]
* @param {number} [underlyingId=0]
* @returns {Promise<any>}
*/
export async function buyCalls(id, actingAsId = 0, underlyingId = 0) { await postOptionsTradeWithActingAs('/buy_calls', id, actingAsId, underlyingId); }
/**
* Sell calls
* @param {number} id
* @param {number} [actingAsId=0]
* @param {number} [underlyingId=0]
* @returns {Promise<any>}
*/
export async function sellCalls(id, actingAsId = 0, underlyingId = 0) { await postOptionsTradeWithActingAs('/sell_calls', id, actingAsId, underlyingId); }
/**
* Buy puts
* @param {number} id
* @param {number} [actingAsId=0]
* @param {number} [underlyingId=0]
* @returns {Promise<any>}
*/
export async function buyPuts(id, actingAsId = 0, underlyingId = 0) { await postOptionsTradeWithActingAs('/buy_puts', id, actingAsId, underlyingId); }
/**
* Sell puts
* @param {number} id
* @param {number} [actingAsId=0]
* @param {number} [underlyingId=0]
* @returns {Promise<any>}
*/
export async function sellPuts(id, actingAsId = 0, underlyingId = 0) { await postOptionsTradeWithActingAs('/sell_puts', id, actingAsId, underlyingId); }
/**
* Advanced options trading
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function advancedOptionsTrading(actingAsId = 0) { await postIdArgWithActingAs('/advanced_options_trading', 0, actingAsId); }
/**
* Exercise call options early
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function exerciseCallOptionsEarly(id, actingAsId = 0) { await postIdArgWithActingAs('/exercise_call_options_early', id, actingAsId); }
/**
* Exercise put options early
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function exercisePutOptionsEarly(id, actingAsId = 0) { await postIdArgWithActingAs('/exercise_put_options_early', id, actingAsId); }
/* Management */
/**
* Prepay taxes
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function prepayTaxes(actingAsId = 0) { await postIdArgWithActingAs('/prepay_taxes', 0, actingAsId); }
/**
* Elect ceo
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function electCeo(actingAsId = 0) { await postIdArgWithActingAs('/elect_ceo', 0, actingAsId); }
/**
* Resign as ceo
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function resignAsCeo(actingAsId = 0) { await postIdArgWithActingAs('/resign_as_ceo', 0, actingAsId); }
/**
* Change managers
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function changeManagers(actingAsId = 0) { await postIdArgWithActingAs('/change_managers', 0, actingAsId); }
/**
* Set dividend
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function setDividend(actingAsId = 0) { await postIdArgWithActingAs('/set_dividend', 0, actingAsId); }
/**
* Set productivity
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function setProductivity(actingAsId = 0) { await postIdArgWithActingAs('/set_productivity', 0, actingAsId); }
/**
* Set growth rate
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function setGrowthRate(actingAsId = 0) { await postIdArgWithActingAs('/set_growth_rate', 0, actingAsId); }
/**
* Restructure
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function restructure(actingAsId = 0) { await postIdArgWithActingAs('/restructure', 0, actingAsId); }
/**
* Buy corporate assets
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyCorporateAssets(actingAsId = 0) { await postIdArgWithActingAs('/buy_corporate_assets', 0, actingAsId); }
/**
* Sell corporate assets
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellCorporateAssets(actingAsId = 0) { await postIdArgWithActingAs('/sell_corporate_assets', 0, actingAsId); }
/**
* View for sale items
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function viewForSaleItems(actingAsId = 0) { await postIdArgWithActingAs('/view_for_sale_items', 0, actingAsId); }
/**
* Sell subsidiary stock
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellSubsidiaryStock(id, actingAsId = 0) { await postIdArgWithActingAs('/sell_subsidiary_stock', id, actingAsId); }
/**
* Rebrand
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function rebrand(actingAsId = 0) { await postIdArgWithActingAs('/rebrand', 0, actingAsId); }
/**
* Toggle company autopilot
* @param {number} id
* @returns {Promise<any>}
*/
export async function toggleCompanyAutopilot(id) { await postIdArg('/toggle_company_autopilot', id); }
/**
* Toggle global autopilot
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function toggleGlobalAutopilot(actingAsId = 0) { await postIdArgWithActingAs('/toggle_global_autopilot', 0, actingAsId); }
/**
* Become etf advisor
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function becomeEtfAdvisor(actingAsId = 0) { await postIdArgWithActingAs('/become_etf_advisor', 0, actingAsId); }
/**
* Set advisory fee
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function setAdvisoryFee(actingAsId = 0) { await postIdArgWithActingAs('/set_advisory_fee', 0, actingAsId); }
/* Deals & Funding */
/**
* Merger
* @param {number} [targetId=0]
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function merger(targetId = 0, actingAsId = 0) { await postIdArgWithActingAs('/merger', targetId, actingAsId); }
/**
* Greenmail
* @param {number} [targetId=0]
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function greenmail(targetId = 0, actingAsId = 0) { await postIdArgWithActingAs('/greenmail', targetId, actingAsId); }
/**
* Lbo
* @param {number} [targetId=0]
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function lbo(targetId = 0, actingAsId = 0) { await postIdArgWithActingAs('/lbo', targetId, actingAsId); }
/**
* Startup
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function startup(actingAsId = 0) { await postIdArgWithActingAs('/startup', 0, actingAsId); }
/**
* Capital contribution
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function capitalContribution(actingAsId = 0) { await postIdArgWithActingAs('/capital_contribution', 0, actingAsId); }
/**
* Public stock offering
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function publicStockOffering(actingAsId = 0) { await postIdArgWithActingAs('/public_stock_offering', 0, actingAsId); }
/**
* Private stock offering
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function privateStockOffering(actingAsId = 0) { await postIdArgWithActingAs('/private_stock_offering', 0, actingAsId); }
/**
* Issue new corp bonds
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function issueNewCorpBonds(actingAsId = 0) { await postIdArgWithActingAs('/issue_new_corp_bonds', 0, actingAsId); }
/**
* Redeem corp bonds
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function redeemCorpBonds(actingAsId = 0) { await postIdArgWithActingAs('/redeem_corp_bonds', 0, actingAsId); }
/**
* Extraordinary dividend
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function extraordinaryDividend(actingAsId = 0) { await postIdArgWithActingAs('/extraordinary_dividend', 0, actingAsId); }
/**
* Tax free liquidation
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function taxFreeLiquidation(actingAsId = 0) { await postIdArgWithActingAs('/tax_free_liquidation', 0, actingAsId); }
/**
* Taxable liquidation
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function taxableLiquidation(actingAsId = 0) { await postIdArgWithActingAs('/taxable_liquidation', 0, actingAsId); }
/**
* Spin off
* @param {number} id
* @returns {Promise<any>}
*/
export async function spinOff(id) { await postIdArg('/spin_off', id); }
/**
* Split stock
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function splitStock(actingAsId = 0) { await postIdArgWithActingAs('/split_stock', 0, actingAsId); }
/**
* Reverse split stock
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function reverseSplitStock(actingAsId = 0) { await postIdArgWithActingAs('/reverse_split_stock', 0, actingAsId); }
/* Lender Services */
/**
* Borrow money
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function borrowMoney(actingAsId = 0) { await postIdArgWithActingAs('/borrow_money', 0, actingAsId); }
/**
* Repay loan
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function repayLoan(actingAsId = 0) { await postIdArgWithActingAs('/repay_loan', 0, actingAsId); }
/**
* Advance funds
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function advanceFunds(actingAsId = 0) { await postIdArgWithActingAs('/advance_funds', 0, actingAsId); }
/**
* Call in advance
* @param {number} id
* @returns {Promise<any>}
*/
export async function callInAdvance(id) { await postIdArg('/call_in_advance', id); }
/**
* Interest rate swaps
* @param {number} [assetId=0]
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function interestRateSwaps(assetId = 0, actingAsId = 0) { await postIdArgWithActingAs('/interest_rate_swaps', assetId, actingAsId); }
/**
* View swap details
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function viewSwapDetails(id, actingAsId = 0) { await postIdArgWithActingAs('/view_swap_details', id, actingAsId); }
/**
* Terminate swap
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function terminateSwap(id, actingAsId = 0) { await postIdArgWithActingAs('/terminate_swap', id, actingAsId); }
/* Bank/Insurance Specific */
/**
* Set bank allocation
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function setBankAllocation(actingAsId = 0) { await postIdArgWithActingAs('/set_bank_allocation', 0, actingAsId); }
/**
* Trade tbills
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function tradeTbills(actingAsId = 0) { await postIdArgWithActingAs('/trade_tbills', 0, actingAsId); }
/**
* List bank loans
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function listBankLoans(actingAsId = 0) { await postIdArgWithActingAs('/list_bank_loans', 0, actingAsId); }
/**
* Change bank
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function changeBank(actingAsId = 0) { await postIdArgWithActingAs('/change_bank', 0, actingAsId); }
/**
* Call in loan
* @param {number} id
* @returns {Promise<any>}
*/
export async function callInLoan(id) { await postIdArg('/call_in_loan', id); }
/**
* Buy bank loans
* @returns {Promise<any>}
*/
export async function buyBankLoans() { await postNoArg('/buy_bank_loans'); }
/**
* Buy business loans
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyBusinessLoans(actingAsId = 0) { await postIdArgWithActingAs('/buy_business_loans', 0, actingAsId); }
/**
* Sell business loan
* @param {number} id
* @returns {Promise<any>}
*/
export async function sellBusinessLoan(id) { await postIdArg('/sell_business_loan', id); }
/**
* Buy consumer loans
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyConsumerLoans(actingAsId = 0) { await postIdArgWithActingAs('/buy_consumer_loans', 0, actingAsId); }
/**
* Sell consumer loans
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellConsumerLoans(actingAsId = 0) { await postIdArgWithActingAs('/sell_consumer_loans', 0, actingAsId); }
/**
* Buy prime mortgages
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buyPrimeMortgages(actingAsId = 0) { await postIdArgWithActingAs('/buy_prime_mortgages', 0, actingAsId); }
/**
* Sell prime mortgages
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellPrimeMortgages(actingAsId = 0) { await postIdArgWithActingAs('/sell_prime_mortgages', 0, actingAsId); }
/**
* Buy subprime mortgages
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function buySubprimeMortgages(actingAsId = 0) { await postIdArgWithActingAs('/buy_subprime_mortgages', 0, actingAsId); }
/**
* Sell subprime mortgages
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function sellSubprimeMortgages(actingAsId = 0) { await postIdArgWithActingAs('/sell_subprime_mortgages', 0, actingAsId); }
/**
* List etfs
* @returns {Promise<any>}
*/
export async function listEtfs() { await postNoArg('/list_etfs'); }
/**
* Freeze all loans
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function freezeAllLoans(actingAsId = 0) { await postIdArgWithActingAs('/freeze_all_loans', 0, actingAsId); }
/**
* Freeze loan
* @param {number} id
* @returns {Promise<any>}
*/
export async function freezeLoan(id) { await postIdArg('/freeze_loan', id); }
/* Accounting */
/**
* Decrease earnings
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function decreaseEarnings(actingAsId = 0) { await postIdArgWithActingAs('/decrease_earnings', 0, actingAsId); }
/**
* Increase earnings
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function increaseEarnings(actingAsId = 0) { await postIdArgWithActingAs('/increase_earnings', 0, actingAsId); }
/* Legal */
/**
* Change law firm
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function changeLawFirm(actingAsId = 0) { await postIdArgWithActingAs('/change_law_firm', 0, actingAsId); }
/**
* Credit info
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function creditInfo(actingAsId = 0) { await postIdArgWithActingAs('/credit_info', 0, actingAsId); }
/**
* Clear chart
* @returns {Promise<any>}
*/
export async function clearChart() { await postNoArg('/clear_chart'); }
/**
* Growth throttle
* @returns {Promise<any>}
*/
export async function growthThrottle() { await postNoArg('/growth_throttle'); }
/**
* Clear stream list
* @returns {Promise<any>}
*/
export async function clearStreamList() { await postNoArg('/clear_stream_list'); }
/**
* Fill stream list
* @returns {Promise<any>}
*/
export async function fillStreamList() { await postNoArg('/fill_stream_list'); }
/**
* Set who owns filter
* @param {any} value
* @returns {Promise<any>}
*/
export async function setWhoOwnsFilter(value) {
await fetchWithRetry('/set_who_owns_filter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value })
});
}
/**
* Antitrust lawsuit
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function antitrustLawsuit(id, actingAsId = 0) { await postIdArgWithActingAs('/antitrust_lawsuit', id, actingAsId); }
/**
* Harrassing lawsuit
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function harrassingLawsuit(id, actingAsId = 0) { await postIdArgWithActingAs('/harrassing_lawsuit', id, actingAsId); }
/**
* Spread rumors
* @param {number} id
* @param {number} [actingAsId=0]
* @returns {Promise<any>}
*/
export async function spreadRumors(id, actingAsId = 0) { await postIdArgWithActingAs('/spread_rumors', id, actingAsId); }
/**
* Menu/Settings
* @returns {Promise<any>}
*/
export async function suppEarnSelect() { await postNoArg('/supp_earn_select'); }
/**
* Currency select
* @returns {Promise<any>}
*/
export async function currencySelect() { await postNoArg('/currency_select'); }
/**
* Supp warn select
* @returns {Promise<any>}
*/
export async function suppWarnSelect() { await postNoArg('/supp_warn_select'); }
/**
* Suppress select
* @returns {Promise<any>}
*/
export async function suppressSelect() { await postNoArg('/suppress_select'); }
/**
* Autosave select
* @returns {Promise<any>}
*/
export async function autosaveSelect() { await postNoArg('/autosave_select'); }
/**
* Exercise select
* @returns {Promise<any>}
*/
export async function exerciseSelect() { await postNoArg('/exercise_select'); }
/**
* Sweep select
* @returns {Promise<any>}
*/
export async function sweepSelect() { await postNoArg('/sweep_select'); }
/**
* Makedelivery select
* @returns {Promise<any>}
*/
export async function makedeliverySelect() { await postNoArg('/makedelivery_select'); }
/**
* Takedelivery select
* @returns {Promise<any>}
*/
export async function takedeliverySelect() { await postNoArg('/takedelivery_select'); }
/**
* Tooltips select
* @returns {Promise<any>}
*/
export async function tooltipsSelect() { await postNoArg('/tooltips_select'); }
/**
* Shareholder graph select
* @returns {Promise<any>}
*/
export async function shareholderGraphSelect() { await postNoArg('/shareholdergraph_select'); }
/**
* Unethical select
* @returns {Promise<any>}
*/
export async function unethicalSelect() { await postNoArg('/unethical_select'); }
/**
* Disable hotkeys select
* @returns {Promise<any>}
*/
export async function disableHotkeysSelect() { await postNoArg('/disablehotkeys_select'); }
/**
* Auto add select
* @returns {Promise<any>}
*/
export async function autoAddSelect() { await postNoArg('/autoadd_select'); }
/**
* Per-machine UI prefs (PB persists to CFIG.WSR via WriteConfig)
* @param {any} typeInt
* @returns {Promise<any>}
*/
export async function setChartType(typeInt) { await postIdArg('/set_chart_type', typeInt); }
/**
* Set locale
* @param {any} localeCode
* @returns {Promise<any>}
*/
export async function setLocale(localeCode) {
if (useIPC) {
return ipcRenderer.invoke('game:dispatch', REST_TO_EVENT['/set_locale'], 0, localeCode);
}
const response = await fetchWithRetry('/set_locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ str: localeCode })
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
return response.text();
}
/**
* Cheat Menu
* @returns {Promise<any>}
*/
export async function cheatDisableLawsuits() { await postNoArg('/cheat_disable_lawsuits'); }
/**
* Cheat merger info
* @returns {Promise<any>}
*/
export async function cheatMergerInfo() { await postNoArg('/cheat_merger_info'); }
/**
* Cheat earnings info
* @returns {Promise<any>}
*/
export async function cheatEarningsInfo() { await postNoArg('/cheat_earnings_info'); }
/**
* Cheat add cash
* @returns {Promise<any>}
*/
export async function cheatAddCash() { await postNoArg('/cheat_add_cash'); }
/**
* Fullscreen toggle (Electron if available, otherwise browser fullscreen API)
*/
export function toggleFullscreen() {
try {
// Electron preload bridge (if the host provides it)
if (window?.electron?.toggleFullscreen) {
return window.electron.toggleFullscreen();
}
if (window?.electron?.setFullscreen) {
// Some builds expose a setter that toggles internally
return window.electron.setFullscreen();
}
// Browser fallback
if (!document.fullscreenElement) {
return document.documentElement.requestFullscreen?.();
}
return document.exitFullscreen?.();
} catch (e) {
console.error('toggleFullscreen failed', e);
// Re-throw so Toolbar.safe() can show a user message
throw e;
}
}
/* Market Reports */
/**
* View current interest rates
* @returns {Promise<any>}
*/
export async function viewCurrentInterestRates() { await postNoArg('/view_current_interest_rates'); }
/**
* Whos ahead
* @returns {Promise<any>}
*/
export async function whosAhead() { await postNoArg('/whos_ahead'); }
/**
* Db research tool
* @returns {Promise<any>}
*/
export async function dbResearchTool() { await postNoArg('/db_research_tool'); }
/**
* Economic stats
* @returns {Promise<any>}
*/
export async function economicStats() { await postNoArg('/economic_stats'); }
/**
* Most cash report
* @returns {Promise<any>}
*/
export async function mostCashReport() { await postNoArg('/most_cash_report'); }
/**
* Largest market cap
* @returns {Promise<any>}
*/
export async function largestMarketCap() { await postNoArg('/largest_market_cap'); }
/**
* Largest tax losses
* @returns {Promise<any>}
*/
export async function largestTaxLosses() { await postNoArg('/largest_tax_losses'); }
/**
* Industry summary
* @returns {Promise<any>}
*/
export async function industrySummary() { await postNoArg('/industry_summary'); }
/**
* Industry projections
* @returns {Promise<any>}
*/
export async function industryProjections() { await postNoArg('/industry_projections'); }
/**
* View corp assets for sale
* @returns {Promise<any>}
*/
export async function viewCorpAssetsForSale() { await postNoArg('/view_corp_assets_for_sale'); }
/*
* Navigation is now managed in C++ (GameState.h).
* navHistory + navPointerIndex come from gameState polling.
* Back/forward/goto are POST calls to C++ endpoints.
*/
/**
* Splash screen played
* @returns {Promise<any>}
*/
export async function splashScreenPlayed() { await postNoArg('/splash_screen_played'); }
/* Modal */
/**
* Close modal
* @param {any} result
* @returns {Promise<any>}
*/
export async function closeModal(result) { await postNoArg('/close_modal', result); }
/**
* Modal result
* @param {any} result
* @returns {Promise<any>}
*/
export async function modalResult(result) {
if (useIPC) {
if (typeof result === 'number') {
return ipcRenderer.invoke('game:modalResult', result, '');
} else {
return ipcRenderer.invoke('game:modalResult', 0, result || '');
}
}
const response = await fetchWithRetry('/modal_result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof result === 'number' ? JSON.stringify({ answer: result }) : JSON.stringify({ str: result })
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.text();
}
/* CustomData API */
/**
* Set custom data
* @param {any} blob
* @returns {Promise<any>}
*/
export async function setCustomData(blob) {
if (useIPC) {
return ipcRenderer.invoke('game:setCustomData', JSON.stringify(blob));
}
const response = await fetchWithRetry('/set_custom_data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(blob)
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.text();
}
/* Price Alerts API — bridge to PB alerts.inc via events 4102/4103/4104 */
/**
* Show price alerts
* @returns {Promise<any>}
*/
export async function showPriceAlerts() { await postNoArg('/show_price_alerts'); }
/**
* Create price alert
* @param {number} entityId
* @param {any} direction
* @param {any} targetPrice
* @returns {Promise<any>}
*/
export async function createPriceAlert(entityId, direction, targetPrice) {
const str = `${entityId}|${direction}|${targetPrice}`;
const response = await fetchWithRetry('/create_price_alert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ str })
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.text();
}
/**
* Delete price alert
* @param {any} slot
* @returns {Promise<any>}
*/
export async function deletePriceAlert(slot) {
const response = await fetchWithRetry('/delete_price_alert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: slot })
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.text();
}
/* Ticker optimistic state */
// Track locally set ticker running state - preserve until backend catches up
let localTickerRunning = null; // null means use backend value
/* Tutorial API */
// Track locally set tutorial values - preserve until backend catches up
let localTutorialStep = null; // null means use backend value
let localTutorialEnabled = null; // null means use backend value
/**
* Set tutorial step
* @param {any} step
* @returns {Promise<any>}
*/
export async function setTutorialStep(step) {
// Optimistically update the local state immediately
const state = gameStore.getState();
state.setGameState({ ...state.gameState, tutorialStep: step });
localTutorialStep = step; // Track expected value
return postIdArg('/set_tutorial_step', step);
}
/**
* Set tutorial enabled
* @param {any} enabled
* @returns {Promise<any>}
*/
export async function setTutorialEnabled(enabled) {
// Optimistically update the local state immediately
const state = gameStore.getState();
const value = enabled ? 1 : 0;
state.setGameState({ ...state.gameState, tutorialEnabled: value });
localTutorialEnabled = value; // Track expected value
return postIdArg('/set_tutorial_enabled', value);
}
/**
* Merge new game state from polling, preserving local tutorial changes until backend catches up
* @param {any} newState
*/
export function mergeGameState(newState) {
const state = gameStore.getState();
const currentState = state.gameState || {};
// For tutorialStep: keep local value until backend matches or exceeds it
if (localTutorialStep !== null) {
if (newState.tutorialStep >= localTutorialStep) {
// Backend caught up, clear local override
localTutorialStep = null;
} else {
// Backend hasn't caught up, use local value
newState.tutorialStep = currentState.tutorialStep;
}
}
// For tutorialEnabled: keep local value until backend matches
if (localTutorialEnabled !== null) {
if (newState.tutorialEnabled === localTutorialEnabled) {
// Backend caught up, clear local override
localTutorialEnabled = null;
} else {
// Backend hasn't caught up, use local value
newState.tutorialEnabled = currentState.tutorialEnabled;
}
}
// For isTickerRunning: keep local value until backend matches
if (localTickerRunning !== null) {
if (newState.isTickerRunning === localTickerRunning) {
// Backend caught up, clear local override
localTickerRunning = null;
} else {
// Backend hasn't caught up, use local value
newState.isTickerRunning = currentState.isTickerRunning;
}
}
// Preserve UI-only preferred tab overrides — the backend doesn't know about these
// fields, so polling would otherwise overwrite them with undefined before the view
// components can consume them. Under Phase 2 these fields continuously reflect the
// active tab in each top-level view (PlayerView, IndustrialView, IndustryView
// drilldown, market overview), so dropping them would also break hint matching.
if (currentState.uiPreferredCompanyTab && !newState.uiPreferredCompanyTab) {
newState.uiPreferredCompanyTab = currentState.uiPreferredCompanyTab;
}
if (currentState.uiPreferredPlayerTab && !newState.uiPreferredPlayerTab) {
newState.uiPreferredPlayerTab = currentState.uiPreferredPlayerTab;
}
if (currentState.uiPreferredIndustryTab && !newState.uiPreferredIndustryTab) {
newState.uiPreferredIndustryTab = currentState.uiPreferredIndustryTab;
}
if (currentState.uiPreferredMarketTab && !newState.uiPreferredMarketTab) {
newState.uiPreferredMarketTab = currentState.uiPreferredMarketTab;
}
// BUG-105 FIX: Reuse old references when contents haven't changed.
// This prevents unnecessary re-renders of report/list components every poll cycle.
for (const key of Object.keys(newState)) {
const prev = currentState[key];
const next = newState[key];
if (Array.isArray(prev) && Array.isArray(next)) {
if (prev.length === next.length) {
let same = true;
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== next[i]) { same = false; break; }
}
if (same) newState[key] = prev;
}
} else if (typeof prev === 'string' && typeof next === 'string' && prev === next) {
// String dedup: reuse old reference when value hasn't changed
// Prevents flicker in AdvisorySummary and other text components
newState[key] = prev;
}
}
return newState;
}
/**
* Serialize
* @param {any} obj
*/
export function serialize(obj) {
return Object.entries(obj).map(([key, value]) => `${key}=${value}`).join('|');
}
// Navigation — C++ handles history tracking in set_view_asset/set_view_industry/change_acting_as
/**
* View industry
* @param {number} id
* @returns {Promise<any>}
*/
export async function viewIndustry(id) {
await postIdArg('/set_view_industry', id);
}
/**
* View db search
* @returns {Promise<any>}
*/
export async function viewDbSearch() {
await postIdArg('/set_view_industry', -2);
await postIdArg('/set_active_ui_report', UI_DB_SEARCH);
}
/**
* Open market heat map
* @returns {Promise<any>}
*/
export async function openMarketHeatMap() {
const state = gameStore.getState();
const gs = state.gameState || {};
const industryId = (typeof gs.activeIndustryNum === 'number' && gs.activeIndustryNum >= 0)
? gs.activeIndustryNum
: 0;
state.setGameState({ ...gs, uiPreferredIndustryTab: 'Heat Maps' });
await viewIndustry(industryId);
await setActiveUIReport(UI_MARKET_HEATMAP);
}
/**
* Set view asset
* @param {number} id
* @returns {Promise<any>}
*/
export async function setViewAsset(id) {
// Optimistically update activeEntityNum so components reading from the store
// during the 50ms polling gap don't compute actAs against the previous entity.
gameStore.setState(state => ({
gameState: { ...state.gameState, activeEntityNum: id }
}));
await postIdArg('/set_view_asset', id);
}
/**
* Like setViewAsset but also sets a preferred tab so the destination view opens on the correct tab (e.g. "Stocks & Bonds" when drilling from Holdings). preferredTab is a string matching the tab label in IndustrialView / PlayerView. NOTE: the preferred tab is set AFTER the navigation REST call completes so that it lands in a separate render cycle and doesn't cause a simultaneous activeEntityNum + preferredTab state change, which previously caused rapid mount/unmount of heavy components (Holdings ↔ target tab) and an Oilpan OOM crash.
* @param {number} id
* @param {any} preferredTab
* @returns {Promise<any>}
*/
export async function setViewAssetWithTab(id, preferredTab) {
const isPlayer = id <= 10; // players are entity 1-10
// Navigate first (same as setViewAsset — single render cycle)
gameStore.setState(state => ({
gameState: { ...state.gameState, activeEntityNum: id }
}));
await postIdArg('/set_view_asset', id);
// Set preferred tab AFTER navigation, in its own render cycle
const gs = gameStore.getState().gameState || {};
const patchKey = isPlayer ? 'uiPreferredPlayerTab' : 'uiPreferredCompanyTab';
gameStore.getState().setGameState({ ...gs, [patchKey]: preferredTab });
}
/**
* Goto page
* @param {any} p
* @returns {Promise<any>}
*/
export async function gotoPage(p) {
const endpoint = p.type === 'industry' ? '/set_view_industry' : '/set_view_asset';
await postIdArg(endpoint, p.id);
}
/**
* Go back
* @returns {Promise<any>}
*/
export async function goBack() {
await postNoArg('/nav_back');
}
/**
* Go forward
* @returns {Promise<any>}
*/
export async function goForward() {
await postNoArg('/nav_forward');
}
/**
* Nav goto index
* @param {any} index
* @returns {Promise<any>}
*/
export async function navGotoIndex(index) {
await postIdArg('/nav_goto', index);
}
/**
* Restore nav history from localStorage — called on game load. entries: [{id, type}] ordered most-recent-first.
* @param {any} entries
* @returns {Promise<any>}
*/
export async function navSetHistory(entries) {
await fetchWithRetry('/nav_set_history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entries)
});
}
/**
* Change acting as
* @param {number} id
* @returns {Promise<any>}
*/
export async function changeActingAs(id) {
await postIdArg('/change_acting_as', id);
}
/**
* Cycle acting-as to next/previous controlled company
* @param {any} direction
*/
export function cycleActingAs(direction) {
const state = gameStore.getState();
const gs = state.gameState || {};
const playerId = gs.playerId;
const controlledCompanies = gs.controlledCompanies || [];
const actingAsId = gs.actingAsId;
// Build the full options list: player + controlled companies
const options = [playerId, ...controlledCompanies.map(c => c.id)];
if (options.length <= 1) return; // Nothing to cycle
const currentIndex = options.indexOf(actingAsId);
let nextIndex;
if (direction > 0) {
nextIndex = (currentIndex + 1) % options.length;
} else {
nextIndex = (currentIndex - 1 + options.length) % options.length;
}
changeActingAs(options[nextIndex]);
}
/**
* Toggle streaming quote
* @param {number} id
* @returns {Promise<any>}
*/
export async function toggleStreamingQuote(id) { await postIdArg('/toggle_streaming_quote', id); }
// Wrap a command-palette fn so the viewed entity is appended as the final
// arg (acting-as). Most api trade/finance fns take actingAsId as their last
// param, so this lets command-line invocations (e.g. "BUY GAC" on the ETF
// page) correctly route through the active entity instead of defaulting to
// PlayCo. Functions that don't take acting-as will simply ignore the extra arg.
const _withActiveEntityActingAs = (fn) => (...args) =>
fn(...args, gameStore.getState().gameState?.activeEntityNum || 0);
/**
* Command map
*/
export const commandMap = {
// === Navigation / Acting As ===
// === Trading - Stocks ===
'BUY': { description: 'Buy stock', fn: _withActiveEntityActingAs(buyStock), takesId: true },
'SELL': { description: 'Sell stock', fn: _withActiveEntityActingAs(sellStock), takesId: true },
'SHORT': { description: 'Short stock', fn: _withActiveEntityActingAs(shortStock), takesId: true },
'COVER': { description: 'Cover short', fn: _withActiveEntityActingAs(coverShortStock), takesId: true },
// === Trading - Corporate Bonds ===
'BUYBOND': { description: 'Buy corporate bond', fn: _withActiveEntityActingAs(buyCorporateBond), takesId: true },
'SELLBOND': { description: 'Sell corporate bond', fn: _withActiveEntityActingAs(sellCorporateBond), takesId: true },
// === Trading - Government Bonds ===
'BUYLGOV': { description: 'Buy long govt bonds', fn: _withActiveEntityActingAs(buyLongGovtBonds), takesId: false },
'SELLLGOV': { description: 'Sell long govt bonds', fn: _withActiveEntityActingAs(sellLongGovtBonds), takesId: false },
'BUYSGOV': { description: 'Buy short govt bonds', fn: _withActiveEntityActingAs(buyShortGovtBonds), takesId: false },
'SELLSGOV': { description: 'Sell short govt bonds', fn: _withActiveEntityActingAs(sellShortGovtBonds), takesId: false },
// === Trading - Options ===
// CLI passes a resolved underlying company id (e.g. "CALL AAPL" → AAPL's id).
// Route through the underlying-aware path: IntParam1=0, actingAs=viewed
// entity, picked id travels in strParam1 so PB uses it as TGT without a
// prompt. Typing the command alone (no operand) sends underlying=0 and
// PB's SelectCompanyModal$ fires normally.
'CALL': { description: 'Buy calls', fn: (underlying) => buyCalls(0, gameStore.getState().gameState?.activeEntityNum || 0, underlying), takesId: true },
'SELLCALL': { description: 'Sell calls', fn: (underlying) => sellCalls(0, gameStore.getState().gameState?.activeEntityNum || 0, underlying), takesId: true },
'PUT': { description: 'Buy puts', fn: (underlying) => buyPuts(0, gameStore.getState().gameState?.activeEntityNum || 0, underlying), takesId: true },
'SELLPUT': { description: 'Sell puts', fn: (underlying) => sellPuts(0, gameStore.getState().gameState?.activeEntityNum || 0, underlying), takesId: true },
'ADVOPTS': { description: 'Advanced options trading', fn: _withActiveEntityActingAs(advancedOptionsTrading), takesId: false },
// === Trading - Commodity Futures ===
'BUYFUT': { description: 'Buy commodity futures', fn: _withActiveEntityActingAs(buyCommodityFutures), takesId: true },
'SELLFUT': { description: 'Sell commodity futures', fn: _withActiveEntityActingAs(sellCommodityFutures), takesId: true },
'SHORTFUT': { description: 'Short commodity futures', fn: _withActiveEntityActingAs(shortCommodityFutures), takesId: true },
'COVERFUT': { description: 'Cover short commodity futures', fn: _withActiveEntityActingAs(coverShortCommodityFutures), takesId: true },
// === Trading - Physical Commodities ===
'BUYCOMM': { description: 'Buy physical commodity', fn: _withActiveEntityActingAs(buyPhysicalCommodity), takesId: true },
'SELLCOMM': { description: 'Sell physical commodity', fn: _withActiveEntityActingAs(sellPhysicalCommodity), takesId: true },
// === Trading - Crypto ===
'BUYCRYPTO': { description: 'Buy physical crypto', fn: _withActiveEntityActingAs(buyPhysicalCrypto), takesId: true },
'SELLCRYPTO': { description: 'Sell physical crypto', fn: _withActiveEntityActingAs(sellPhysicalCrypto), takesId: true },
'BUYCFUT': { description: 'Buy crypto futures', fn: _withActiveEntityActingAs(buyCryptoFutures), takesId: true },
'SELLCFUT': { description: 'Sell crypto futures', fn: _withActiveEntityActingAs(sellCryptoFutures), takesId: true },
// === Finance ===
'BORROW': { description: 'Borrow money', fn: _withActiveEntityActingAs(borrowMoney), takesId: false },
'REPAY': { description: 'Repay loan', fn: _withActiveEntityActingAs(repayLoan), takesId: false },
'PREPAY': { description: 'Prepay taxes', fn: _withActiveEntityActingAs(prepayTaxes), takesId: false },
'ADVANCE': { description: 'Advance funds', fn: _withActiveEntityActingAs(advanceFunds), takesId: false },
'SWAPS': { description: 'Interest rate swaps', fn: _withActiveEntityActingAs(interestRateSwaps), takesId: false },
'TBILLS': { description: 'Trade T-Bills', fn: _withActiveEntityActingAs(tradeTbills), takesId: false },
'CHGBANK': { description: 'Change bank', fn: _withActiveEntityActingAs(changeBank), takesId: false },
// === Corporate - Leadership ===
'ELECT': { description: 'Elect yourself as CEO', fn: _withActiveEntityActingAs(electCeo), takesId: false },
'RESIGN': { description: 'Resign as CEO', fn: _withActiveEntityActingAs(resignAsCeo), takesId: false },
'MANAGERS': { description: 'Change managers', fn: _withActiveEntityActingAs(changeManagers), takesId: false },
// === Corporate - Strategy ===
'DIVIDEND': { description: 'Set dividend', fn: _withActiveEntityActingAs(setDividend), takesId: false },
'PRODUCTIVITY': { description: 'Set productivity', fn: _withActiveEntityActingAs(setProductivity), takesId: false },
'GROWTH': { description: 'Set growth rate', fn: _withActiveEntityActingAs(setGrowthRate), takesId: false },
'REBRAND': { description: 'Rebrand company', fn: _withActiveEntityActingAs(rebrand), takesId: false },
'RESTRUCTURE': { description: 'Restructure company', fn: _withActiveEntityActingAs(restructure), takesId: false },
// === Corporate - Equity ===
'PSO': { description: 'Public stock offering', fn: _withActiveEntityActingAs(publicStockOffering), takesId: false },
'PPO': { description: 'Private stock offering', fn: _withActiveEntityActingAs(privateStockOffering), takesId: false },
'SPLIT': { description: 'Split stock', fn: _withActiveEntityActingAs(splitStock), takesId: false },
'RSPLIT': { description: 'Reverse split stock', fn: _withActiveEntityActingAs(reverseSplitStock), takesId: false },
// === Corporate - Debt ===
'ISSUEBOND': { description: 'Issue new corp bonds', fn: _withActiveEntityActingAs(issueNewCorpBonds), takesId: false },
'REDEEMBOND': { description: 'Redeem corp bonds', fn: _withActiveEntityActingAs(redeemCorpBonds), takesId: false },
// === Corporate - Returns / Liquidation ===
'EXTDIV': { description: 'Extraordinary dividend', fn: _withActiveEntityActingAs(extraordinaryDividend), takesId: false },
'TAXFREE': { description: 'Tax-free liquidation', fn: _withActiveEntityActingAs(taxFreeLiquidation), takesId: false },
'TAXLIQ': { description: 'Taxable liquidation', fn: _withActiveEntityActingAs(taxableLiquidation), takesId: false },
// === Corporate - Assets ===
'BUYASSET': { description: 'Buy corporate assets', fn: _withActiveEntityActingAs(buyCorporateAssets), takesId: false },
'SELLASSET': { description: 'Sell corporate assets', fn: _withActiveEntityActingAs(sellCorporateAssets), takesId: false },
'SELLSUB': { description: 'Sell subsidiary stock', fn: _withActiveEntityActingAs(sellSubsidiaryStock), takesId: false },
'SPINOFF': { description: 'Spin off a subsidiary', fn: spinOff, takesId: true },
'BROWSE': { description: 'Browse for-sale items', fn: _withActiveEntityActingAs(viewForSaleItems), takesId: false },
// === Corporate - M&A ===
'MERGER': { description: 'Merge with viewed company', fn: _withActiveEntityActingAs(merger), takesId: false },
'GREENMAIL': { description: 'Greenmail', fn: _withActiveEntityActingAs(greenmail), takesId: false },
'LBO': { description: 'Leveraged buyout', fn: _withActiveEntityActingAs(lbo), takesId: false },
'STARTUP': { description: 'Start a new company', fn: _withActiveEntityActingAs(startup), takesId: false },
'CONTRIB': { description: 'Capital contribution', fn: _withActiveEntityActingAs(capitalContribution), takesId: false },
// === Hostile Actions ===
'HARASS': { description: 'Harassing lawsuit', fn: _withActiveEntityActingAs(harrassingLawsuit), takesId: true },
'RUMORS': { description: 'Spread rumors', fn: _withActiveEntityActingAs(spreadRumors), takesId: true },
'ANTITRUST': { description: 'Antitrust lawsuit', fn: _withActiveEntityActingAs(antitrustLawsuit), takesId: true },
'LAWFIRM': { description: 'Change law firm', fn: _withActiveEntityActingAs(changeLawFirm), takesId: false },
// === Accounting ===
'INCREARN': { description: 'Increase earnings', fn: _withActiveEntityActingAs(increaseEarnings), takesId: false },
'DECREARN': { description: 'Decrease earnings', fn: _withActiveEntityActingAs(decreaseEarnings), takesId: false },
// === ETF / Advisory ===
'FEE': { description: 'Set advisory fee', fn: _withActiveEntityActingAs(setAdvisoryFee), takesId: false },
'AUTOPILOT': { description: 'Toggle global autopilot', fn: _withActiveEntityActingAs(toggleGlobalAutopilot), takesId: false },
// === Bank Operations ===
'ALLOC': { description: 'Set bank allocation', fn: _withActiveEntityActingAs(setBankAllocation), takesId: false },
'LISTLOANS': { description: 'List bank loans', fn: _withActiveEntityActingAs(listBankLoans), takesId: false },
'FREEZE': { description: 'Freeze all loans', fn: _withActiveEntityActingAs(freezeAllLoans), takesId: false },
'BUYBIZ': { description: 'Buy business loans', fn: _withActiveEntityActingAs(buyBusinessLoans), takesId: false },
'BUYCONS': { description: 'Buy consumer loans', fn: _withActiveEntityActingAs(buyConsumerLoans), takesId: false },
'SELLCONS': { description: 'Sell consumer loans', fn: _withActiveEntityActingAs(sellConsumerLoans), takesId: false },
'BUYMORT': { description: 'Buy prime mortgages', fn: _withActiveEntityActingAs(buyPrimeMortgages), takesId: false },
'SELLMORT': { description: 'Sell prime mortgages', fn: _withActiveEntityActingAs(sellPrimeMortgages), takesId: false },
'BUYSUBMORT': { description: 'Buy subprime mortgages', fn: _withActiveEntityActingAs(buySubprimeMortgages), takesId: false },
'SELLSUBMORT': { description: 'Sell subprime mortgages', fn: _withActiveEntityActingAs(sellSubprimeMortgages), takesId: false },
}
/**
* w s r context
*/
export const WSRContext = createContext();
/**
* Game store
*/
export const gameStore = createStore((set, get) => ({
gameState: {},
setGameState: (next) => set({ gameState: next }),
uiHoldingsTabActive: false,
setUiHoldingsTabActive: (v) => set({ uiHoldingsTabActive: v }),
}));
/**
* Use game store
* @param {any} [selector=s]
* @param {any} [isEqual=Object.is]
*/
export function useGameStore(selector = s => s, isEqual = Object.is) {
const selRef = useRef(selector);
selRef.current = selector;
const isEqualRef = useRef(isEqual);
isEqualRef.current = isEqual;
const [val, setVal] = useState(() => selRef.current(gameStore.getState()));
const valRef = useRef(val);
valRef.current = val;
useEffect(() => {
const unsub = gameStore.subscribe((state) => {
const next = selRef.current(state);
if (!isEqualRef.current(next, valRef.current)) setVal(next);
});
return unsub;
}, []); // stable subscription — selRef/valRef always point to latest values
return val;
}
/**
* w s r provider
* @param {any} { children }
*/
export function WSRProvider({ children }) {
const [helpShown, setHelpShown] = useState(false);
const [helpSectionId, setHelpSectionId] = useState(null);
const showHelp = useCallback((sectionId = null) => {
setHelpSectionId(sectionId);
setHelpShown(true);
}, []);
const hideHelp = useCallback(() => {
setHelpShown(false);
setHelpSectionId(null);
}, []);
// Database Search modal state
const [dbSearchShown, setDbSearchShown] = useState(false);
const showDbSearch = useCallback(() => setDbSearchShown(true), []);
const hideDbSearch = useCallback(() => setDbSearchShown(false), []);
// Tutorial modal state
const showTutorial = useCallback(() => setTutorialEnabled(true), []);
const hideTutorial = useCallback(() => setTutorialEnabled(false), []);
// gameState/setGameState are intentionally NOT in context: gameState polls at
// 200ms (50ms in-game) and would force every consumer to re-render. Consumers
// that need gameState use useGameStore(selector) directly so they only
// re-render on the specific fields they read.
const value = useMemo(() => ({
helpShown,
helpSectionId,
showHelp,
hideHelp,
dbSearchShown,
showDbSearch,
hideDbSearch,
showTutorial,
hideTutorial,
}), [helpShown, helpSectionId, showHelp, hideHelp, dbSearchShown, showDbSearch, hideDbSearch, showTutorial, hideTutorial]);
return html`<${WSRContext.Provider} value=${value}>
${children}
<//>`;
}
/**
* Use w s r context
*/
export const useWSRContext = () => useContext(WSRContext);
/**
* Debounce
* @param {any} fn
* @param {any} delay
*/
export function debounce(fn, delay) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
/**
* Diff objects
* @param {any} oldObj
* @param {any} newObj
* @param {any} [path=[]]
*/
export function diffObjects(oldObj, newObj, path = []) {
const result = {
added: {},
removed: {},
changed: {}
};
const oldKeys = Object.keys(oldObj || {});
const newKeys = Object.keys(newObj || {});
const allKeys = new Set([...oldKeys, ...newKeys]);
for (const key of allKeys) {
const oldVal = oldObj?.[key];
const newVal = newObj?.[key];
const currentPath = path.concat(key).join('.');
// Added
if (!(key in (oldObj || {}))) {
result.added[currentPath] = newVal;
continue;
}
// Removed
if (!(key in (newObj || {}))) {
result.removed[currentPath] = oldVal;
continue;
}
// Both exist
if (isObject(oldVal) && isObject(newVal)) {
const child = diffObjects(oldVal, newVal, path.concat(key));
Object.assign(result.added, child.added);
Object.assign(result.removed, child.removed);
Object.assign(result.changed, child.changed);
continue;
}
// Changed
if (!isEqual(oldVal, newVal)) {
result.changed[currentPath] = { from: oldVal, to: newVal };
}
}
return result;
}
function isObject(val) {
return val !== null && typeof val === 'object' && !Array.isArray(val);
}
function isEqual(a, b) {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every(k => isEqual(a[k], b[k]));
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, i) => isEqual(v, b[i]));
}
return false;
}
/**
* Is different
* @param {any} oldObj
* @param {any} newObj
*/
export function isDifferent(oldObj, newObj) {
const diff = diffObjects(oldObj, newObj);
return Object.keys(diff.added).length > 0 ||
Object.keys(diff.removed).length > 0 ||
Object.keys(diff.changed).length > 0;
}
/**
* Format thousands preserve caret
* @param {any} e
* @param {any} formatFn
*/
export function formatThousandsPreserveCaret(e, formatFn) {
function formatFn(value) {
// remove commas and spaces
const raw = value.replace(/,/g, '').trim();
// allow empty
if (raw === '') return '';
// reject non-numeric input
if (!/^\d+(\.\d*)?$/.test(raw)) return null;
// Add thousands commas
const num = Number(raw);
if (isNaN(num)) return null;
// Preserve decimals if present
const [intPart, decPart] = raw.split('.');
let formatted = Number(intPart).toLocaleString();
if (decPart !== undefined) {
formatted += '.' + decPart;
}
return formatted;
}
const input = e.target;
const prev = input.value;
const start = input.selectionStart ?? prev.length;
// How many digits were to the left of the caret
const digitsLeft = (prev.slice(0, start).match(/\d/g) || []).length;
const formatted = formatFn(prev);
if (formatted == null) return;
// Apply formatted value
input.value = formatted;
// Restore caret after same number of digits
let pos = 0;
let seenDigits = 0;
while (pos < formatted.length && seenDigits < digitsLeft) {
if (/\d/.test(formatted[pos])) seenDigits++;
pos++;
}
input.setSelectionRange(pos, pos);
}