From d888ab7056957d22c30a50a55b09e396e2cb62df Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Wed, 13 May 2026 10:44:02 -0400
Subject: [PATCH 01/25] reset beta version.
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 617f2128e9..e437e55e4f 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "stt-data-core",
"description": "STT DataCore",
"author": "ussjohnjay/ironyWrit",
- "version": "3.3.1",
+ "version": "3.4.0",
"license": "MIT",
"scripts": {
"check": "tsc --noEmit",
From 7b5e0fa2aebae80713ec31e799649f1d2108c186 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 17 May 2026 18:01:40 -0400
Subject: [PATCH 02/25] Fix dilemma math.
---
src/components/voyagecalculator/dilemmas/dilemmatable.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/voyagecalculator/dilemmas/dilemmatable.tsx b/src/components/voyagecalculator/dilemmas/dilemmatable.tsx
index 7168c6a171..f62cbd53c6 100644
--- a/src/components/voyagecalculator/dilemmas/dilemmatable.tsx
+++ b/src/components/voyagecalculator/dilemmas/dilemmatable.tsx
@@ -84,7 +84,8 @@ export const DilemmaTable = (props: DilemmaTableProps) => {
setEligble(eligible);
setInverse(inverse);
if (dilemmas.some(d => d.narrative) && playerData) {
- setMaxRun(2 * ((dilemmas.length - inverse.length) + 1));
+ let invact = inverse.filter(i => !i.dilemma.narrative);
+ setMaxRun(2 * ((dilemmas.length - invact.length)));
}
else {
setMaxRun(0);
From dc527556bd9e3685628852693d173861a84cbd14 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Tue, 9 Jun 2026 16:32:27 -0400
Subject: [PATCH 03/25] Use moving average difference for spot estimate
---
src/components/tracker/restracker.tsx | 17 ++++++++++++++++-
src/components/tracker/utils.tsx | 1 +
static/structured/locales/de/translation.json | 1 +
static/structured/locales/en/translation.json | 1 +
static/structured/locales/fr/translation.json | 1 +
static/structured/locales/sp/translation.json | 1 +
6 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/src/components/tracker/restracker.tsx b/src/components/tracker/restracker.tsx
index 2e830927f9..9ef6a47414 100644
--- a/src/components/tracker/restracker.tsx
+++ b/src/components/tracker/restracker.tsx
@@ -148,6 +148,11 @@ export const ResourceTracker = () => {
width: 1,
title: t('resource_tracker.columns.average_difference')
},
+ {
+ column: 'moving_average_difference',
+ width: 1,
+ title: t('resource_tracker.columns.moving_average_difference')
+ },
{
column: 'total_difference',
width: 1,
@@ -173,7 +178,7 @@ export const ResourceTracker = () => {
if (!!saleData?.honor_sale || !enabled || (!finalDaily && !finalWeekly) || (!resourceFilter.includes('honor') && resourceFilter.length) || !stats?.length) return <>>;
let i = stats.findLastIndex(f => f.resource === 'honor');
if (i === -1) return <>>;
- let h = Math.round(stats[i].average_difference);
+ let h = Math.round(stats[i].moving_average_difference);
if (h <= 0) {
return (
@@ -390,6 +395,9 @@ export const ResourceTracker = () => {
{Math.round(row.average_difference).toLocaleString()}
+
+ {Math.round(row.moving_average_difference).toLocaleString()}
+
{Math.round(row.total_difference).toLocaleString()}
{(100 * row.total_change_pct).toFixed(1)}%
@@ -468,6 +476,7 @@ export const ResourceTracker = () => {
average_difference: 0,
total_difference: 0,
moving_average: 0,
+ moving_average_difference: 0,
amount_pct: 0,
change_pct: 0,
total_change_pct: 0
@@ -539,6 +548,12 @@ export const ResourceTracker = () => {
const stat = rstats[i];
let diffs = rstats.map(stat => stat.difference);
stat.average_difference = diffs.slice(0, i + 1).reduce((p, n) => p + n, 0) / (i + 1);
+ if (i === 0) {
+ stat.moving_average_difference = stat.average_difference;
+ }
+ else {
+ stat.moving_average_difference = (rstats[i].difference + rstats[i - 1].difference) / 2;
+ }
if (!high[stat.resource]) continue;
stat.amount_pct = stat.amount / (low[stat.resource] || 1);
if (!stat.amount || stat.amount === low[stat.resource] || i == 0) continue;
diff --git a/src/components/tracker/utils.tsx b/src/components/tracker/utils.tsx
index 5b7fb40392..4726ce02a7 100644
--- a/src/components/tracker/utils.tsx
+++ b/src/components/tracker/utils.tsx
@@ -7,6 +7,7 @@ export interface ResourceData {
resource: string,
amount: number,
moving_average: number,
+ moving_average_difference: number,
difference: number,
timestamp: Date,
average_difference: number,
diff --git a/static/structured/locales/de/translation.json b/static/structured/locales/de/translation.json
index 1d7a8a8fa7..0944782bc6 100644
--- a/static/structured/locales/de/translation.json
+++ b/static/structured/locales/de/translation.json
@@ -1905,6 +1905,7 @@
"average_difference": "Durchschnittlicher Unterschied",
"difference": "Differenz",
"moving_average": "Gleitender Durchschnitt",
+ "moving_average_difference": "Differenz der gleitenden Durchschnitte",
"resource": "Ressource",
"timestamp": "Zeitstempel",
"total_difference": "Gesamtdifferenz"
diff --git a/static/structured/locales/en/translation.json b/static/structured/locales/en/translation.json
index 1114bccd8a..1b42bf6fad 100644
--- a/static/structured/locales/en/translation.json
+++ b/static/structured/locales/en/translation.json
@@ -1906,6 +1906,7 @@
"average_difference": "Average difference",
"difference": "Difference",
"moving_average": "Moving average",
+ "moving_average_difference": "Moving average difference",
"resource": "Resource",
"timestamp": "Timestamp",
"total_difference": "Total difference"
diff --git a/static/structured/locales/fr/translation.json b/static/structured/locales/fr/translation.json
index 086f8360a3..92b0a7c65e 100644
--- a/static/structured/locales/fr/translation.json
+++ b/static/structured/locales/fr/translation.json
@@ -1917,6 +1917,7 @@
"average_difference": "Différence moyenne",
"difference": "Différence",
"moving_average": "Moyenne mobile",
+ "moving_average_difference": "Différence de moyennes mobiles",
"resource": "Ressource",
"timestamp": "Horodatage",
"total_difference": "Différence totale"
diff --git a/static/structured/locales/sp/translation.json b/static/structured/locales/sp/translation.json
index fda6b3d97c..f2b117993f 100644
--- a/static/structured/locales/sp/translation.json
+++ b/static/structured/locales/sp/translation.json
@@ -1915,6 +1915,7 @@
"average_difference": "Diferencia promedio",
"difference": "Diferencia",
"moving_average": "Media móvil",
+ "moving_average_difference": "Diferencia de medias móviles",
"resource": "Recurso",
"timestamp": "Marca de tiempo",
"total_difference": "Diferencia total"
From 782404d3dca7c0fed9a27cbd2b0f3241ecb296a6 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Wed, 10 Jun 2026 12:39:41 -0400
Subject: [PATCH 04/25] Fix divisions for crew/ship.
---
src/components/ship/rostercalc.tsx | 22 ++++++++++------------
1 file changed, 10 insertions(+), 12 deletions(-)
diff --git a/src/components/ship/rostercalc.tsx b/src/components/ship/rostercalc.tsx
index 51aaf72ec6..79d3e1ac2f 100644
--- a/src/components/ship/rostercalc.tsx
+++ b/src/components/ship/rostercalc.tsx
@@ -1260,9 +1260,6 @@ export const ShipRosterCalc = (props: RosterCalcProps) => {
const pref_order = fbb_mode ? [1, 2, 5, 0, 3, 4, 6, 7, 8, 9, 10] : [1, 5, 0, 2, 3, 4, 6, 7, 8, 9, 10];
const bonus_pref = [0, 2, 1, 3];
- max_rarity ??= ship.rarity ?? 5;
- if (max_rarity > 5) max_rarity = 5;
- const min_rarity = minRarity ?? 1;
const shipDiv = getShipDivision(ship.rarity);
const maxvalues = [0, 0, 0, 0, 0].map(o => [0, 0, 0, 0]);
const maxabilityvalues = [0, 0, 0, 0, 0].map(o => Object.keys(CONFIG.CREW_SHIP_BATTLE_ABILITY_TYPE).slice(0, 9).map(m => 0));
@@ -1276,7 +1273,6 @@ export const ShipRosterCalc = (props: RosterCalcProps) => {
});
const results = crew.filter((crew) => {
- if (min_rarity && crew.max_rarity < min_rarity) return false;
if (onlyImmortal && ("immortal" in crew && !crew.immortal)) return false;
if (!fbb_mode && maxInitTime !== undefined) {
if (crew.action.initial_cooldown > maxInitTime) return false;
@@ -1294,15 +1290,17 @@ export const ShipRosterCalc = (props: RosterCalcProps) => {
// if (!ability_types.some(at => crew.action.ability?.type === at)) return false;
// }
+ let pass = true;
+ if (battleMode.startsWith("fbb_")) {
+ let n = Number(battleMode.slice(4));
+ if (!getBosses(ship, crew).some(boss => boss.id === n)) pass = false;
+ }
+ else if (battleMode === 'pvp') {
+ if (!getCrewDivisions(crew.max_rarity).includes(shipDiv)) pass = false;
+ }
+ if (!pass) return false;
+
if (crew.action.ability) {
- let pass = true;
- if (battleMode.startsWith("fbb_")) {
- let n = Number(battleMode.slice(4));
- if (!getBosses(ship, crew).some(boss => boss.id === n)) pass = false;
- }
- else if (battleMode === 'pvp') {
- if (!getCrewDivisions(crew.max_rarity).includes(shipDiv)) pass = false;
- }
if (pass) {
if (maxvalues[crew.max_rarity - 1][crew.action.bonus_type] < crew.action.bonus_amount) {
maxvalues[crew.max_rarity - 1][crew.action.bonus_type] = crew.action.bonus_amount;
From b4341fc839436c8d27072d73a38d7771ba8b34cf Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Fri, 12 Jun 2026 14:03:28 -0400
Subject: [PATCH 05/25] make crew merging more durable.
---
src/utils/crewutils.ts | 17 ++++-------------
1 file changed, 4 insertions(+), 13 deletions(-)
diff --git a/src/utils/crewutils.ts b/src/utils/crewutils.ts
index 9079c96d97..2e18d0322c 100644
--- a/src/utils/crewutils.ts
+++ b/src/utils/crewutils.ts
@@ -445,12 +445,6 @@ export function mergeListsOneToMany(onelist: T[], manylist: U[],
}
results.push(res);
}
- // results.sort((a, b) => {
- // let ax = orgList.findIndex(fi => fi === a.token);
- // let bx = orgList.findIndex(fi => fi === b.token);
- // return ax - bx;
- // });
-
return results;
}
@@ -537,7 +531,7 @@ export function prepareOne(origCrew: CrewMember | PlayerCrew, playerData?: Playe
}
//let ccc = null as any;
if (!crew.preview) {
- inroster = (knownPlayerCrew?.concat(inroster) || inroster.concat(playerData?.player?.character?.crew?.filter(c => (c.immortal === undefined || c.immortal <= 0) && c.archetype_id === crew.archetype_id) ?? []));
+ inroster = (knownPlayerCrew?.concat(inroster) || inroster.concat(playerData?.player?.character?.crew?.filter(c => (c.immortal === undefined || c.immortal <= 0) && c.archetype_id === crew.archetype_id && c.symbol === crew.symbol) ?? []));
//ccc = playerData?.player?.character?.crew?.filter(fc => fc.symbol === 'troi_lwaxana_fascination_crew');
}
else {
@@ -714,14 +708,11 @@ export function prepareProfileData(caller: string, allcrew: CrewMember[], player
let ownedCrew = [] as PlayerCrew[];
let unOwnedCrew = [] as PlayerCrew[];
let cidx = -1;
- playerData.player.character.crew.sort((a, b) => a.archetype_id - b.archetype_id || a.id - b.id);
- allcrew.sort((a, b) => a.archetype_id - b.archetype_id);
- let twolists = mergeListsOneToMany(allcrew, playerData.player.character.crew, (a, b) => a.archetype_id - b.archetype_id);
+ playerData.player.character.crew.sort((a, b) => a.symbol.localeCompare(b.symbol) || a.archetype_id - b.archetype_id || a.id - b.id);
+ allcrew.sort((a, b) => a.symbol.localeCompare(b.symbol) || a.archetype_id - b.archetype_id);
+ let twolists = mergeListsOneToMany(allcrew, playerData.player.character.crew, (a, b) => a.symbol.localeCompare(b.symbol) || a.archetype_id - b.archetype_id);
for (let tl of twolists) {
let c = tl.token;
- if (c.symbol === 'troi_lwaxana_fascination_crew') {
- console.log('break');
- }
for (let crew of prepareOne(c, playerData, buffConfig)) {
if (crew.have) {
if (!crew.id) {
From e3b356fceb4ec31ce72d06d39bdd09ed37e81531 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 14:38:30 -0400
Subject: [PATCH 06/25] Spore Drive default estimator
---
.../stats/stats_accordion.tsx | 58 ++--
src/model/worker.ts | 11 +-
src/workers/sporedrive.js | 286 ------------------
src/workers/unified-worker.js | 13 +-
4 files changed, 45 insertions(+), 323 deletions(-)
delete mode 100644 src/workers/sporedrive.js
diff --git a/src/components/voyagecalculator/stats/stats_accordion.tsx b/src/components/voyagecalculator/stats/stats_accordion.tsx
index 06da18828c..5c026c9858 100644
--- a/src/components/voyagecalculator/stats/stats_accordion.tsx
+++ b/src/components/voyagecalculator/stats/stats_accordion.tsx
@@ -3,9 +3,9 @@ import { Accordion, Message, Dimmer, Loader, Icon, Segment, SemanticICONS } from
import { UnifiedWorker as Worker } from '../../../typings/worker';
import { PlayerCrew, PlayerData, Voyage } from '../../../model/player';
-import { ExtendedVoyageStatsConfig, VoyageStatsConfig } from '../../../model/worker';
+import { SporeDriveConfig, VoyageStatsConfig } from '../../../model/worker';
import { Estimate } from "../../../model/voyage";
-import { CrewMember } from '../../../model/crew';
+import { CrewMember, Skill } from '../../../model/crew';
import { GlobalContext } from '../../../context/globalcontext';
import { VoyageStatsEstimate, VoyageStatsEstimateTitle } from './statsestimate';
@@ -29,12 +29,18 @@ type VoyageStatsState = {
hoverCrew?: CrewMember | PlayerCrew | undefined;
};
+type EstimateResponse = {
+ data: {
+ result: Estimate
+ }
+}
+
export class VoyageStatsAccordion extends Component {
static contextType = GlobalContext;
declare context: React.ContextType;
private worker: Worker | undefined = undefined;
- private config: ExtendedVoyageStatsConfig = {} as VoyageStatsConfig;
+ private config: SporeDriveConfig = {} as SporeDriveConfig;
constructor(props: VoyageStatsProps | Readonly) {
super(props);
@@ -66,8 +72,10 @@ export class VoyageStatsAccordion extends Component {
+ private readonly _eventListener = (message: EstimateResponse) => {
let maxTime = (message.data.result.refills.reduce((p, n) => n.safeResult && n.safeResult > p ? n.safeResult : p, 0));
- if (maxTime > (this.config.selectedTime ?? 20)) {
- this.config.selectedTime = Math.floor(maxTime + 2);
- this.worker?.terminate();
- this.worker?.removeEventListener('message', this._eventListener);
- this.worker = new Worker();
- this.worker.addEventListener('message', this._eventListener);
- this.beginCalc();
- }
- else {
- this.setState({ estimate: message.data.result });
+ this.config.selectedTime = Math.floor(maxTime);
+ this.setState({ estimate: message.data.result });
+ }
+
+ private initWorker() {
+ if (this.worker) {
+ this.worker.terminate();
+ this.worker.removeEventListener('message', this._eventListener);
+ this.worker = undefined;
}
+
+ this.worker = new Worker();
+ this.worker.addEventListener('message', this._eventListener);
+
}
private beginCalc() {
+ this.initWorker();
if (this.config.elapsedSeconds) {
let nextHour = Math.ceil(this.config.elapsedSeconds / 3600);
if (nextHour % 2) nextHour++;
@@ -124,17 +127,16 @@ export class VoyageStatsAccordion extends Component true) {
- /**
- * required input (starting numbers)
- * @type {number}
- */
- var ps = config.ps;
- /**
- * required input (starting numbers)
- * @type {number}
- */
- var ss = config.ss;
-
- if (!config.others) config.others = [0,0,0,0];
-
- var o1 = config.others[0];
- var o2 = config.others[1];
- var o3 = config.others[2];
- var o4 = config.others[3];
- var startAm = config.startAm;
-
- // optional input (proficiency ratio)
- var prof = config.prof ?? 20;
-
- // optional input (the time to compute)
- var selectedTime = config.selectedTime ?? 20;
-
- // optional input (ongoing voyage)
- var elapsedSeconds = config.elapsedSeconds ? config.elapsedSeconds : 0;
-
- if (elapsedSeconds) {
- let nextHour = Math.ceil(elapsedSeconds / 3600);
- if (nextHour % 2) nextHour++;
- if (selectedTime < nextHour) {
- selectedTime = nextHour + 2;
- }
- }
-
- var currentAm = config.currentAm ?? config.startAm;
-
- // optional input (simulations)
- var numSims = config.numSims ?? 20000;
-
- /**
- * returned estimate
- * @type {import("../model/worker").Estimate}
- */
- var estimate = {};
-
- // output
- var numExtends = config.noExtends ? 0 : 2;
- var maxExtends = 100;
- var maxNumSelectedTimeSims = 100;
-
- // variables
- var allSkills = [ps, ss, o1, o2, o3, o4];
- const ticksPerCycle = 28;
- const secondsPerTick = 20;
- const secondsInMinute = 60;
- const minutesInHour = 60;
- //const hazardTick = 4;
- //const rewardTick = 7;
- //const hazardAsRewardTick = 28;
- const ticksPerMinute = secondsInMinute/secondsPerTick;
- const ticksPerHour = ticksPerMinute*minutesInHour;
- const cycleSeconds = ticksPerCycle*secondsPerTick;
- const cyclesPerHour = minutesInHour*secondsInMinute/cycleSeconds;
- const hazPerCycle = 6;
- //const amPerActivity = 1;
- const hoursBetweenDilemmas = 2;
- const dilemmasPerHour = 1/hoursBetweenDilemmas;
- //const ticksBetweenDilemmas = hoursBetweenDilemmas*minutesInHour*ticksPerMinute;
- const skillIncPerHaz = 32;
- const hazPerHour = hazPerCycle*cyclesPerHour-dilemmasPerHour;
- const ticksPerHazard = 4;
- const hazAmPass = 5;
- const hazAmFail = 30;
- //const minPerHour = 60;
- const psChance = 0.35;
- const ssChance = 0.25;
- const osChance = 0.1;
- //const skillChances = [psChance,ssChance,osChance,osChance,osChance,osChance];
- const dilPerMin = 5;
- const numSelectedTimeSims = Math.min(maxNumSelectedTimeSims, numSims);
- const maxCostPerHazard = ticksPerHazard+hazAmFail-1;
-
- /**
- *
- * @param {boolean} finished
- * @returns {import("../model/worker").Estimate}
- */
- const formatResults = (finished) => {
- /**
- * @type {import("../model/worker").Refill[]}
- */
- var refills = [];
-
- // calculate and display results
- for (var extend = 0; extend <= numExtends; ++extend) {
- var exResults = results[extend];
-
- exResults.sort(function(a,b){return a-b;});
- var voyTime = exResults[Math.floor(exResults.length/2)];
-
- // compute other results
- var safeTime = exResults[Math.floor(exResults.length/10)];
- var saferTime = exResults[Math.floor(exResults.length/100)];
- var safestTime = exResults[0];
- var moonshotTime = exResults[exResults.length-Math.floor(exResults.length/100)];
-
- // compute chance of dilemma closest to median
- const lastDilemma = Math.max(Math.floor(elapsedSeconds/7200)*2+2, Math.round(voyTime/2)*2);
- const lastDilemmaSuccesses = exResults.filter(r => r >= lastDilemma).length;
-
- /**
- * @type {import("../model/worker").Refill}
- */
- var refill = {
- 'all': exResults,
- 'result': voyTime,
- 'safeResult': safeTime,
- 'saferResult': saferTime,
- 'moonshotResult': moonshotTime,
- 'lastDil': lastDilemma,
- 'dilChance': 100*lastDilemmaSuccesses/exResults.length,
- 'refillCostResult': extend > 0 ? Math.ceil(resultsRefillCostTotal[extend]/exResults.length) : 0
- }
-
- refills.push(refill);
- } // foreach extend
-
- estimate['refills'] = refills;
-
- // calculate SelectedTime results
- var timeSims = deterministic ? 1 : numSelectedTimeSims;
- estimate['dilhr20'] = Math.ceil(resultsSelectedTimeCostTotal/timeSims);
- estimate['refillshr20'] = Math.round(resultsSelectedTimeRefillsTotal/timeSims);
-
- estimate['final'] = finished;
- estimate['deterministic'] = deterministic;
-
- return estimate;
- }; //end formatResults()
-
- // more input
- var elapsedHours = elapsedSeconds/3600;
-
- if (Math.min(ps,ss,o1,o2,o3,o4,startAm) == 0) {
- ps = ss = 3000;
- o1 = o2 = o3 = o4 = 1000;
- startAm = 500;
- elapsedHours = 0;
- numSims = 1000;
- }
-
- //sizeUi();
-
- var hazSkillVariance = prof/100;
- var skills = [ps,ss,o1,o2,o3,o4];
-
- var elapsedTicks = Math.floor(elapsedSeconds/secondsPerTick);
- var elapsedCycles = Math.floor(elapsedTicks/ticksPerCycle);
- var dilemmaForHazards = Math.floor(elapsedHours/hoursBetweenDilemmas);
- var elapsedHazCount =
- elapsedCycles*hazPerCycle+Math.floor(elapsedTicks%ticksPerCycle/ticksPerHazard)-dilemmaForHazards;
- var elapsedHazSkill = elapsedHazCount*skillIncPerHaz;
- var deterministic = false;
- const maxSkill = Number.isFinite(ps) ? Math.max(ps,ss,o1,o2,o3,o4)*(1+hazSkillVariance)
- : Math.max(...[ps, ss, o1, o2, o3, o4].map(s => s.core + s.range_max));
- const minSkill = Number.isFinite(ps) ? Math.min(ps,ss,o1,o2,o3,o4)*(1-hazSkillVariance)
- : Math.min(...[ps, ss, o1, o2, o3, o4].map(s => s.core + s.range_min));
- deterministic = maxSkill < elapsedHazSkill || config.vfast;
-
- let hazardScore = 0;
- // Create an array functions to be called at each hazard tick (including rewards and dilemmaas)
- const allHazards = Array.from({length:hazPerHour*(Math.max(selectedTime + 10, selectedTime * 2))}, (v, n) => {
- if (n%7 == 6) // reward found instead of hazard
- return () => 29;
-
- if (n%90== 89) // dilemma
- return () => 30;
-
- if (maxSkill < hazardScore)
- return () => 0;
-
- hazardScore += skillIncPerHaz;
-
- if (minSkill > hazardScore)
- return () => (hazAmFail + hazAmPass);
-
- const skillChance =
- skill => Math.max(0, Math.min(1, ((skill.core+skill.range_max)-hazardScore)/(skill.range_max-skill.range_min)));
- const probaility = [psChance*skillChance(ps), ssChance*skillChance(ss),
- ...config.others?.map(s => osChance*skillChance(s))].reduce((all, p) => all + p, 0);
- //console.log(probaility);
- return config.vfast ? () => probaility*(hazAmFail+hazAmPass)
- : () => (Math.random() < probaility) ? hazAmFail+hazAmPass : 0;
- });
-
- //console.log(allHazards.map(h => h()));
- if (deterministic)
- numSims = 1; // With no more skill checks there can only be one voyage length
-
- /**
- * @type {number[][]}
- */
- var results = [];
- /**
- * @type {number[]}
- */
- var resultsRefillCostTotal = [];
- for (var iExtend = 0; iExtend <= numExtends; ++iExtend) {
- results.push([]);
- //results[iExtend].length = numSims;
- resultsRefillCostTotal.push(0);
- }
-
- var resultsSelectedTimeCostTotal = 0;
- var resultsSelectedTimeRefillsTotal = 0;
- var maxTicks = am => {
- var maxCompetedHazards = Math.floor(amLeft/33);
- amLeft -= maxCompetedHazards*33;
- var ticks = Math.min(3, amLeft);
- return (maxCompletedCycles*ticksPerCycle+maxCompetedHazards*ticksPerHazard+ticks, amLeft-ticks);
- }
-
- for (var iSim = 0; iSim < numSims; iSim++) {
- var tick = Math.floor(elapsedHours*ticksPerHour);
- var am = currentAm;
- var refillCostTotal = 0;
- var extend = 0;
-
- while ((extend < numExtends || iSim < numSelectedTimeSims) && extend < maxExtends) {
- while (am > maxCostPerHazard) {
- const potHazEncountered = Math.floor(am/maxCostPerHazard);
- const nextTick = tick+(potHazEncountered*ticksPerHazard);
- const startHaz = Math.floor(tick/ticksPerHazard);
- const endHaz = Math.ceil(nextTick/ticksPerHazard);
- const amAdded = allHazards.slice(startHaz, endHaz).reduce((total, h) => total + h(), 0);
- let amLost = potHazEncountered*maxCostPerHazard
- am -= amLost - amAdded;
- //console.log({tick: [tick, nextTick], amAdded, amLost, haz: [startHaz, endHaz]});
- tick = nextTick;
- }
-
- while (am > 0) {
- let haz = Math.floor(tick/ticksPerHazard);
- am -= tick%ticksPerHazard==3 ? hazAmFail - allHazards[haz]() : 1;
- ++tick;
- //console.log({tick, haz, to: allHazards[haz], am});
- }
-
- //console.log({tick, am});
- var voyTime = tick/ticksPerHour;
- var refillCost = Math.ceil(voyTime*60/dilPerMin);
-
- if (extend <= numExtends) {
- results[extend].push(tick/ticksPerHour);
-
- if (extend > 0) {
- resultsRefillCostTotal[extend] += refillCostTotal;
- }
- }
-
- if (voyTime > selectedTime) {
- resultsSelectedTimeCostTotal += refillCostTotal;
- resultsSelectedTimeRefillsTotal += extend;
- break;
- }
-
- am = startAm;
- refillCostTotal += refillCost;
- extend++;
- }
- if (iSim > 0 && iSim % 100 == 0)
- reportProgress(formatResults(false));
- } // foreach sim
-
- return formatResults(true);
- }
-
- module.exports.getEstimate = getEstimate;
diff --git a/src/workers/unified-worker.js b/src/workers/unified-worker.js
index d439c95842..8644c6a820 100644
--- a/src/workers/unified-worker.js
+++ b/src/workers/unified-worker.js
@@ -2,7 +2,7 @@
//unified-worker.js
import voymod from './voymod.js';
import transwarp from './transwarp.js';
-import sporedrive from './sporedrive.js';
+import sporedrive from './sporedrive.ts';
import VoyagersWorker from './voyagers.ts';
import Optimizer from './optimizer.js';
import BetaTachyon from './betatachyon.ts';
@@ -24,9 +24,9 @@ const voyageEstimate = (config, progress) => {
};
// This worker can estimate a single lineup from input config
-const voyageEstimateExtended = (config, progress) => {
+const sporeDrive = (config, progress) => {
return new Promise((resolve, reject) => {
- let estimate = sporedrive.getEstimate(config, progress);
+ let estimate = sporedrive(config, progress);
resolve(estimate);
});
};
@@ -75,10 +75,13 @@ self.onmessage = (message) => {
if (!inProgress) self.close();
};
const messageHandlers = {
- 'voyageEstimate': () => voyageEstimate(message.data.config, est => postResult(est, true)).then(estimate =>
+ 'voyageEstimate': () => sporeDrive(message.data.config, est => postResult(est, true)).then(estimate =>
postResult(estimate, false)
),
- 'voyageEstimateExtended': () => voyageEstimateExtended(message.data.config, est => postResult(est, true)).then(estimate =>
+ // 'voyageEstimate': () => voyageEstimate(message.data.config, est => postResult(est, true)).then(estimate =>
+ // postResult(estimate, false)
+ // ),
+ 'sporeDrive': () => sporeDrive(message.data.config, est => postResult(est, true)).then(estimate =>
postResult(estimate, false)
),
'citeOptimizer': () => citeOptimizer(message.data.config.playerData, message.data.config.allCrew).then(data => postResult(data, false)),
From cf66642ea04c9aea9bedecb50744b99ceabdb17f Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 14:38:41 -0400
Subject: [PATCH 07/25] Spore Drive default estimator
---
src/workers/sporedrive.ts | 314 ++++++++++++++++++++++++++++++++++++++
1 file changed, 314 insertions(+)
create mode 100644 src/workers/sporedrive.ts
diff --git a/src/workers/sporedrive.ts b/src/workers/sporedrive.ts
new file mode 100644
index 0000000000..880a616452
--- /dev/null
+++ b/src/workers/sporedrive.ts
@@ -0,0 +1,314 @@
+// Adapted from Chewable C++'s STT Voyage Estimator
+// https://codepen.io/somnivore/pen/Nabyzw
+
+import { Skill } from "../model/crew";
+import { Estimate, Refill } from "../model/voyage";
+import { SporeDriveConfig } from "../model/worker";
+import { skillSum } from "../utils/crewutils";
+
+/* eslint-disable */
+const blankSkill = {
+ core: 0,
+ range_min: 0,
+ range_max: 0,
+ skill: ""
+}
+function getEstimate(config: SporeDriveConfig, reportProgress = () => true) {
+ function performEstimation(config: SporeDriveConfig, reportProgress = () => true) {
+ let ps = skillSum(config.ps);
+ let ss = skillSum(config.ss);
+
+ if (!config.others) config.others = [
+ structuredClone(blankSkill),
+ structuredClone(blankSkill),
+ structuredClone(blankSkill),
+ structuredClone(blankSkill),
+ ];
+
+ let o1 = skillSum(config.others[0]);
+ let o2 = skillSum(config.others[1]);
+ let o3 = skillSum(config.others[2]);
+ let o4 = skillSum(config.others[3]);
+ let startAm = config.startAm;
+
+ // optional input (proficiency ratio)
+ let prof = config.prof ?? 20;
+
+ // optional input (the time to compute)
+ let selectedTime = config.selectedTime ?? 20;
+
+ // optional input (ongoing voyage)
+ let elapsedSeconds = config.elapsedSeconds ? config.elapsedSeconds : 0;
+
+ if (elapsedSeconds) {
+ let nextHour = Math.ceil(elapsedSeconds / 3600);
+ if (nextHour % 2) nextHour++;
+ if (selectedTime < nextHour) {
+ selectedTime = nextHour + 2;
+ }
+ }
+
+ let currentAm = config.currentAm ?? config.startAm;
+
+ // optional input (simulations)
+ let numSims = config.numSims ?? 20000;
+
+ // output
+ let numExtends = config.noExtends ? 0 : 2;
+ let maxExtends = 100;
+ let maxNumSelectedTimeSims = 100;
+
+ // constants
+ const ticksPerCycle = 28;
+ const secondsPerTick = 20;
+ const secondsInMinute = 60;
+ const minutesInHour = 60;
+ const hazPerCycle = 6;
+ const hoursBetweenDilemmas = 2;
+ const skillIncPerHaz = 32;
+ const ticksPerHazard = 4;
+ const hazAmPass = 5;
+ const hazAmFail = 30;
+ const psChance = 0.35;
+ const ssChance = 0.25;
+ const osChance = 0.1;
+ const dilPerMin = 5;
+
+ // calculated constants
+ const maxCostPerHazard = ticksPerHazard + hazAmFail - 1;
+ const ticksPerMinute = secondsInMinute / secondsPerTick;
+ const ticksPerHour = ticksPerMinute * minutesInHour;
+ const cycleSeconds = ticksPerCycle * secondsPerTick;
+ const cyclesPerHour = (minutesInHour * secondsInMinute) / cycleSeconds;
+ const dilemmasPerHour = 1 / hoursBetweenDilemmas;
+ const hazPerHour = hazPerCycle * cyclesPerHour - dilemmasPerHour;
+ const numSelectedTimeSims = Math.min(maxNumSelectedTimeSims, numSims);
+
+ const formatResults = (final: boolean) => {
+ let refills = [] as Refill[];
+
+ // calculate and display results
+ for (let extend = 0; extend <= numExtends; ++extend) {
+ let exResults = results[extend];
+
+ exResults.sort(function (a, b) {
+ return a - b;
+ });
+ let voyTime = exResults[Math.floor(exResults.length / 2)];
+
+ // compute other results
+ let safeTime = exResults[Math.floor(exResults.length / 10)];
+ let saferTime = exResults[Math.floor(exResults.length / 100)];
+ let moonshotTime =
+ exResults[exResults.length - Math.floor(exResults.length / 100)];
+
+ // compute chance of dilemma closest to median
+ const lastDilemma = Math.max(
+ Math.floor(elapsedSeconds / 7200) * 2 + 2,
+ Math.round(voyTime / 2) * 2,
+ );
+ const lastDilemmaSuccesses = exResults.filter(
+ (r) => r >= lastDilemma,
+ ).length;
+
+ let refill: Refill = {
+ all: exResults,
+ result: voyTime,
+ safeResult: safeTime,
+ saferResult: saferTime,
+ moonshotResult: moonshotTime,
+ lastDil: lastDilemma,
+ dilChance: (100 * lastDilemmaSuccesses) / exResults.length,
+ refillCostResult:
+ extend > 0
+ ? Math.ceil(resultsRefillCostTotal[extend] / exResults.length)
+ : 0,
+ };
+
+ refills.push(refill);
+ } // foreach extend
+
+ let timeSims = deterministic ? 1 : numSelectedTimeSims;
+ let dilhr20 = Math.round(resultsSelectedTimeCostTotal / timeSims);
+ let refillshr20 = Math.round(resultsSelectedTimeRefillsTotal / timeSims);
+
+ return {
+ refills,
+ dilhr20,
+ refillshr20,
+ final,
+ deterministic,
+ };
+ }; //end formatResults()
+
+ // more input
+ let elapsedHours = elapsedSeconds / 3600;
+
+ if (Math.min(ps, ss, o1, o2, o3, o4, startAm) == 0) {
+ ps = ss = 3000;
+ o1 = o2 = o3 = o4 = 1000;
+ startAm = 500;
+ elapsedHours = 0;
+ numSims = 1000;
+ }
+
+ let hazSkillVariance = prof / 100;
+ let skills = [config.ps, config.ss, config.others[0], config.others[1], config.others[2], config.others[3]];
+
+ let elapsedTicks = Math.floor(elapsedSeconds / secondsPerTick);
+ let elapsedCycles = Math.floor(elapsedTicks / ticksPerCycle);
+ let dilemmaForHazards = Math.floor(elapsedHours / hoursBetweenDilemmas);
+ let elapsedHazCount =
+ elapsedCycles * hazPerCycle +
+ Math.floor((elapsedTicks % ticksPerCycle) / ticksPerHazard) -
+ dilemmaForHazards;
+ let elapsedHazSkill = elapsedHazCount * skillIncPerHaz;
+ let deterministic = false;
+ const maxSkill = Number.isFinite(ps)
+ ? Math.max(ps, ss, o1, o2, o3, o4) * (1 + hazSkillVariance)
+ : Math.max(...skills.map(sk => sk.core + sk.range_max));
+ const minSkill = Number.isFinite(ps)
+ ? Math.min(ps, ss, o1, o2, o3, o4) * (1 - hazSkillVariance)
+ : Math.min(...skills.map(sk => sk.core + sk.range_max));
+ deterministic = maxSkill < elapsedHazSkill || !!config.vfast;
+
+ let hazardScore = 0;
+ // Create an array functions to be called at each hazard tick (including rewards and dilemmaas)
+ const allHazards = Array.from(
+ { length: hazPerHour * Math.max(selectedTime + 10, selectedTime * 2) },
+ (v, n) => {
+ if (n % 7 == 6)
+ // reward found instead of hazard
+ return () => 29;
+
+ if (n % 90 == 89)
+ // dilemma
+ return () => 30;
+
+ if (maxSkill < hazardScore) return () => 0;
+
+ hazardScore += skillIncPerHaz;
+
+ if (minSkill > hazardScore) return () => hazAmFail + hazAmPass;
+
+ const skillChance = (skill: Skill) =>
+ Math.max(
+ 0,
+ Math.min(
+ 1,
+ (skill.core + skill.range_max - hazardScore) /
+ (skill.range_max - skill.range_min),
+ ),
+ );
+ const probaility = [
+ psChance * skillChance(config.ps),
+ ssChance * skillChance(config.ss),
+ ...config.others?.map((s) => osChance * skillChance(s)) ?? [],
+ ].reduce((all, p) => all + p, 0);
+ //console.log(probaility);
+ return config.vfast
+ ? () => probaility * (hazAmFail + hazAmPass)
+ : () => (Math.random() < probaility ? hazAmFail + hazAmPass : 0);
+ },
+ );
+
+ //console.log(allHazards.map(h => h()));
+ if (deterministic) numSims = 1; // With no more skill checks there can only be one voyage length
+
+ /**
+ * @type {number[][]}
+ */
+ let results = [] as number[][];
+ /**
+ * @type {number[]}
+ */
+ let resultsRefillCostTotal = [] as number[];
+ for (let iExtend = 0; iExtend <= numExtends; ++iExtend) {
+ results.push([]);
+ //results[iExtend].length = numSims;
+ resultsRefillCostTotal.push(0);
+ }
+
+ let resultsSelectedTimeCostTotal = 0;
+ let resultsSelectedTimeRefillsTotal = 0;
+ let amLeft = 0;
+
+ for (let iSim = 0; iSim < numSims; iSim++) {
+ let tick = Math.floor(elapsedHours * ticksPerHour);
+ let am = currentAm;
+ let refillCostTotal = 0;
+ let extend = 0;
+
+ while (
+ (extend < numExtends || iSim < numSelectedTimeSims) &&
+ extend < maxExtends
+ ) {
+ while (am > maxCostPerHazard) {
+ const potHazEncountered = Math.floor(am / maxCostPerHazard);
+ const nextTick = tick + potHazEncountered * ticksPerHazard;
+ const startHaz = Math.floor(tick / ticksPerHazard);
+ const endHaz = Math.ceil(nextTick / ticksPerHazard);
+ const amAdded = allHazards
+ .slice(startHaz, endHaz)
+ .reduce((total, h) => total + h(), 0);
+ let amLost = potHazEncountered * maxCostPerHazard;
+ am -= amLost - amAdded;
+ //console.log({tick: [tick, nextTick], amAdded, amLost, haz: [startHaz, endHaz]});
+ tick = nextTick;
+ }
+
+ while (am > 0) {
+ let haz = Math.floor(tick / ticksPerHazard);
+ am -= tick % ticksPerHazard == 3 ? hazAmFail - allHazards[haz]() : 1;
+ ++tick;
+ //console.log({tick, haz, to: allHazards[haz], am});
+ }
+
+ //console.log({tick, am});
+ let voyTime = tick / ticksPerHour;
+ let refillCost = Math.ceil((voyTime * 60) / dilPerMin);
+
+ if (extend <= numExtends) {
+ results[extend].push(tick / ticksPerHour);
+
+ if (extend > 0) {
+ resultsRefillCostTotal[extend] += refillCostTotal;
+ }
+ }
+
+ if (voyTime > selectedTime) {
+ resultsSelectedTimeCostTotal += refillCostTotal;
+ resultsSelectedTimeRefillsTotal += extend;
+ break;
+ }
+
+ am = startAm;
+ refillCostTotal += refillCost;
+ extend++;
+ }
+ } // foreach sim
+
+ return formatResults(true);
+ }
+
+ let result = {} as Estimate;
+ let retries = 3;
+
+ for (let x = 0; x < retries; x++) {
+ result = performEstimation(config, reportProgress);
+ if (result.refills.some((r) => r.safeResult === undefined)) {
+ let maxTime = result.refills.reduce(
+ (p, n) => (n.safeResult && n.safeResult > p ? n.safeResult : p),
+ 0,
+ );
+ config.selectedTime = Math.floor(maxTime + 2);
+ }
+ else {
+ break;
+ }
+ }
+ result.refills = result.refills.filter((r) => r.safeResult);
+ return result;
+}
+
+export default getEstimate;
From f27e9f9f649cdceae2e8be763e8fe450a9376f23 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 19:14:17 -0400
Subject: [PATCH 08/25] Fix high estimate Make most recently encountered even
number.
---
src/components/voyagecalculator/stats/stats_accordion.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/components/voyagecalculator/stats/stats_accordion.tsx b/src/components/voyagecalculator/stats/stats_accordion.tsx
index 5c026c9858..69db2fb7e0 100644
--- a/src/components/voyagecalculator/stats/stats_accordion.tsx
+++ b/src/components/voyagecalculator/stats/stats_accordion.tsx
@@ -96,8 +96,9 @@ export class VoyageStatsAccordion extends Component {
- let maxTime = (message.data.result.refills.reduce((p, n) => n.safeResult && n.safeResult > p ? n.safeResult : p, 0));
- this.config.selectedTime = Math.floor(maxTime);
+ let maxTime = Math.floor(message.data.result.refills.reduce((p, n) => n.safeResult && n.safeResult > p ? n.safeResult : p, 0));
+ if (maxTime % 2) maxTime--;
+ this.config.selectedTime = maxTime;
this.setState({ estimate: message.data.result });
}
From 24bd92524ae57042279ccb95fa6bd894bca2a82c Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 19:48:19 -0400
Subject: [PATCH 09/25] Ensure the adjustedTime is passed up from the worker
---
.../voyagecalculator/stats/stats_accordion.tsx | 10 +++++-----
src/model/voyage.ts | 1 +
src/workers/sporedrive.ts | 14 ++++++++------
3 files changed, 14 insertions(+), 11 deletions(-)
diff --git a/src/components/voyagecalculator/stats/stats_accordion.tsx b/src/components/voyagecalculator/stats/stats_accordion.tsx
index 69db2fb7e0..1c4947206d 100644
--- a/src/components/voyagecalculator/stats/stats_accordion.tsx
+++ b/src/components/voyagecalculator/stats/stats_accordion.tsx
@@ -52,8 +52,6 @@ export class VoyageStatsAccordion extends Component Math.floor(voyageData.log_index / 360),
currentAm: props.voyageData.hp ?? voyageData.max_hp
};
-
- this.updateAndRun();
}
private updateAndRun(force?: boolean) {
@@ -96,9 +94,7 @@ export class VoyageStatsAccordion extends Component {
- let maxTime = Math.floor(message.data.result.refills.reduce((p, n) => n.safeResult && n.safeResult > p ? n.safeResult : p, 0));
- if (maxTime % 2) maxTime--;
- this.config.selectedTime = maxTime;
+ this.config.selectedTime = message.data.result.adjustedTime ?? 20;
this.setState({ estimate: message.data.result });
}
@@ -140,6 +136,10 @@ export class VoyageStatsAccordion extends Component true) {
for (let x = 0; x < retries; x++) {
result = performEstimation(config, reportProgress);
- if (result.refills.some((r) => r.safeResult === undefined)) {
- let maxTime = result.refills.reduce(
- (p, n) => (n.safeResult && n.safeResult > p ? n.safeResult : p),
- 0,
- );
- config.selectedTime = Math.floor(maxTime + 2);
+ let maxTime = result.refills.reduce(
+ (p, n) => (n.safeResult && n.safeResult > p ? n.safeResult : p),
+ 0,
+ );
+ if (result.refills.some((r) => r.safeResult === undefined) || (config.selectedTime ?? 20) < maxTime) {
+ config.selectedTime = Math.floor(maxTime + 1);
+ if (config.selectedTime % 2) config.selectedTime++;
}
else {
break;
}
}
+ result.adjustedTime = config.selectedTime ?? 20;
result.refills = result.refills.filter((r) => r.safeResult);
return result;
}
From dc082b67c5f0d3c0791b929774443dbea7db39cd Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 20:10:45 -0400
Subject: [PATCH 10/25] code cleanup / sporedrive.ts
---
src/workers/sporedrive.ts | 11 ++---------
1 file changed, 2 insertions(+), 9 deletions(-)
diff --git a/src/workers/sporedrive.ts b/src/workers/sporedrive.ts
index 6700fad9f4..df5893007d 100644
--- a/src/workers/sporedrive.ts
+++ b/src/workers/sporedrive.ts
@@ -6,7 +6,6 @@ import { Estimate, Refill } from "../model/voyage";
import { SporeDriveConfig } from "../model/worker";
import { skillSum } from "../utils/crewutils";
-/* eslint-disable */
const blankSkill = {
core: 0,
range_min: 0,
@@ -14,7 +13,7 @@ const blankSkill = {
skill: ""
}
function getEstimate(config: SporeDriveConfig, reportProgress = () => true) {
- function performEstimation(config: SporeDriveConfig, reportProgress = () => true) {
+ function performEstimation(config: SporeDriveConfig, reportProgress = () => true): Estimate {
let ps = skillSum(config.ps);
let ss = skillSum(config.ss);
@@ -215,17 +214,11 @@ function getEstimate(config: SporeDriveConfig, reportProgress = () => true) {
//console.log(allHazards.map(h => h()));
if (deterministic) numSims = 1; // With no more skill checks there can only be one voyage length
- /**
- * @type {number[][]}
- */
let results = [] as number[][];
- /**
- * @type {number[]}
- */
let resultsRefillCostTotal = [] as number[];
+
for (let iExtend = 0; iExtend <= numExtends; ++iExtend) {
results.push([]);
- //results[iExtend].length = numSims;
resultsRefillCostTotal.push(0);
}
From dc459767e4144fffd717a92558fc7be277be0fc0 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 20:13:31 -0400
Subject: [PATCH 11/25] Type declare bug
---
src/workers/sporedrive.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/workers/sporedrive.ts b/src/workers/sporedrive.ts
index df5893007d..58ba1fad6d 100644
--- a/src/workers/sporedrive.ts
+++ b/src/workers/sporedrive.ts
@@ -12,8 +12,8 @@ const blankSkill = {
range_max: 0,
skill: ""
}
-function getEstimate(config: SporeDriveConfig, reportProgress = () => true) {
- function performEstimation(config: SporeDriveConfig, reportProgress = () => true): Estimate {
+function getEstimate(config: SporeDriveConfig, reportProgress = (est: Estimate) => true) {
+ function performEstimation(config: SporeDriveConfig, reportProgress = (est: Estimate) => true): Estimate {
let ps = skillSum(config.ps);
let ss = skillSum(config.ss);
From b36f87d4464c42977a933c88847b5fddf5af5396 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 21:46:59 -0400
Subject: [PATCH 12/25] Add ship ability times to ship skill display.
---
src/components/item_presenters/shipskill.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/components/item_presenters/shipskill.tsx b/src/components/item_presenters/shipskill.tsx
index b75cdd655a..7780810d38 100644
--- a/src/components/item_presenters/shipskill.tsx
+++ b/src/components/item_presenters/shipskill.tsx
@@ -453,7 +453,14 @@ export const TinyShipSkill = (props: TinyShipSkillProps) => {
{crew.ship_battle.evasion}
{` `}
- }
+ }
+
+ I: {crew.action.initial_cooldown}s
+ {`, `}
+ D: {crew.action.duration}s
+ {`, `}
+ C: {crew.action.cooldown}s
+
}
)
From c77d1cbf92cc81767234edb9a8f08361ec783726 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 14 Jun 2026 21:48:26 -0400
Subject: [PATCH 13/25] booleanize passive checks
---
src/components/item_presenters/shipskill.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/components/item_presenters/shipskill.tsx b/src/components/item_presenters/shipskill.tsx
index 7780810d38..3ec5ec55b1 100644
--- a/src/components/item_presenters/shipskill.tsx
+++ b/src/components/item_presenters/shipskill.tsx
@@ -426,28 +426,28 @@ export const TinyShipSkill = (props: TinyShipSkillProps) => {
{!!action.charge_phases?.length && (+{action.charge_phases.length} charge phases)}
{!!crew &&
- {crew.ship_battle.crit_bonus && (
+ {!!crew.ship_battle.crit_bonus && (
CB: +
{crew.ship_battle.crit_bonus}
{` `}
)}
- {crew.ship_battle.crit_chance && (
+ {!!crew.ship_battle.crit_chance && (
CR: +
{crew.ship_battle.crit_chance}
{` `}
)}
- {crew.ship_battle.accuracy &&
+ {!!crew.ship_battle.accuracy &&
AC: +
{crew.ship_battle.accuracy}
{` `}
}
- {crew.ship_battle.evasion &&
+ {!!crew.ship_battle.evasion &&
EV: +
{crew.ship_battle.evasion}
From 8585449b8f9d96768895164d4f7d06aa33dcbb35 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Tue, 16 Jun 2026 12:06:26 -0400
Subject: [PATCH 14/25] page title translation for achievements
---
src/pages/achievements.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/pages/achievements.tsx b/src/pages/achievements.tsx
index c36bac7a62..58c80b7c53 100644
--- a/src/pages/achievements.tsx
+++ b/src/pages/achievements.tsx
@@ -1,11 +1,14 @@
import React from 'react';
import DataPageLayout from '../components/page/datapagelayout';
import ProfileOther from '../components/profile_other';
+import { GlobalContext } from '../context/globalcontext';
const OtherPage = () => {
- return
+ const globalContext = React.useContext(GlobalContext);
+ const { t } = globalContext.localized;
+ return
}
From ccf3674b935e71a3fb76a6659aa84f58232f8eff Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Thu, 18 Jun 2026 21:31:33 -0400
Subject: [PATCH 15/25] Refactor factions in my charts
---
src/components/profile_charts.tsx | 789 +++---------------------------
src/pages/charts.tsx | 2 +-
2 files changed, 73 insertions(+), 718 deletions(-)
diff --git a/src/components/profile_charts.tsx b/src/components/profile_charts.tsx
index 2379fd7e82..1f31757a1f 100644
--- a/src/components/profile_charts.tsx
+++ b/src/components/profile_charts.tsx
@@ -16,7 +16,9 @@ import { demandsPerSlot } from '../utils/equipment';
import { insertInStatTree, sortedStats, StatTreeNode } from '../utils/statutils';
import { AvatarView } from './item_presenters/avatarview';
import themes from './nivo_themes';
+import { OptionsPanelFlexRow } from './stats/utils';
+type FactionRecord = (DemandCounts & { ids: number[] });
type ProfileChartsProps = {
};
type HonorDebt = {
@@ -36,10 +38,29 @@ type ProfileChartsConfig = {
honorDebt?: HonorDebt;
};
+const FactionTransmissionMap = {
+ "Augments": "Augment Transmission",
+ "Bajoran": "Bajoran Transmission",
+ "Borg": "Borg Transmission",
+ "Cardassian": "Cardassian Union Transmission",
+ "Dominion": "Dominion Transmission",
+ "Federation": "Federation Transmission",
+ "Ferengi Alliance": "Ferengi Alliance Transmission",
+ "Ferengi Traditionalists": "Ferengi Traditionalists Transmission",
+ "Hirogen": "Hirogen Transmission",
+ "KCA": "Klingon-Cardassian Alliance Transmission",
+ "Klingon Empire": "Klingon Empire Transmission",
+ "Maquis": "Maquis Transmission",
+ "Romulan Star Empire": "Romulan Transmission",
+ "Section 31": "Section 31 Transmission",
+ "Terran Empire": "Terran Empire Transmission"
+}
+
const ProfileCharts = (props: ProfileChartsProps) => {
const globalContext = React.useContext(GlobalContext);
const { playerData } = globalContext.player;
+ const { t } = globalContext.localized;
const [config, setConfig] = React.useState(
{
demands: [],
@@ -107,7 +128,7 @@ const ProfileCharts = (props: ProfileChartsProps) => {
if (!demands) return { demands: null, factionRec: null, totalChronCost: null };
let totalChronCost = 0;
- let factionRec = [] as DemandCounts[];
+ let factionRec = [] as FactionRecord[];
demands.forEach((entry) => {
let cost = entry.equipment?.item_sources?.map((its: any) => its.avg_cost).filter((cost) => !!cost) ?? [];
@@ -116,23 +137,26 @@ const ProfileCharts = (props: ProfileChartsProps) => {
} else {
const factions = entry.equipment?.item_sources.filter((e) => e.type === 1);
if (factions && factions.length > 0) {
- let fe = factionRec.find((e: any) => e.name === factions[0].name);
+ let fact = factions
+ .sort((a, b) => (b.chance_grade || 0) - (a.chance_grade || 0))
+ .map(f => f.name).join(` ${t('global.or')} `)
+ let fe = factionRec.find((e: any) => e.name === fact);
if (fe) {
fe.count += entry.count;
} else {
factionRec.push({
- name: factions[0].name,
+ name: fact,
count: entry.count,
+ ids: []
});
}
}
}
});
-
if (excludeFulfilled) {
demands = demands.filter((d) => d.count > d.have);
}
- factionRec = factionRec.sort((a, b) => b.count - a.count).filter((e) => e.count > 0);
+ factionRec = factionRec.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).filter((e) => e.count > 0);
totalChronCost = Math.floor(totalChronCost);
return { demands, factionRec, totalChronCost };
}, [config, excludeFulfilled]);
@@ -255,15 +279,16 @@ const ProfileCharts = (props: ProfileChartsProps) => {
Factions with most needed non-mission items
- {factionRec?.map((e) => (
- -
- {e.name}: {e.count} items
+ {factionRec?.map((e, idx) => (
+
-
+
))}
setExcludeFulfilled(!!checked)}
checked={excludeFulfilled}
@@ -277,14 +302,16 @@ const ProfileCharts = (props: ProfileChartsProps) => {
}
- content={entry.equipment.name}
+ content={{entry.equipment.name}
}
subheader={`Need ${entry.count} ${entry.factionOnly ? ' (FACTION)' : ''} (have ${entry.have})`}
/>
}
@@ -737,713 +764,41 @@ const ProfileCharts = (props: ProfileChartsProps) => {
}
}
-// class ProfileCharts extends Component {
-// static contextType = GlobalContext;
-// declare context: React.ContextType;
-
-// constructor(props: ProfileChartsProps) {
-// super(props);
-
-// this.state = {
-// allcrew: undefined,
-// items: undefined,
-// demands: [],
-// data_ownership: [],
-// flat_skill_distribution: [],
-// skill_distribution: {} as StatTreeNode,
-// includeTertiary: false,
-// r4_stars: [],
-// r5_stars: [],
-// radar_skill_rarity: [],
-// radar_skill_rarity_owned: [],
-// honordebt: undefined,
-// excludeFulfilled: false,
-// includeAllCrew: false
-// };
-// }
-
-// componentDidMount() {
-// setTimeout(() => {
-// this.setState({ allcrew: this.props.allCrew.filter(f => !f.preview), items: this.props.items as EquipmentItem[] }, () => {
-// this._calculateStats();
-// });
-// });
-// }
-
-// _calculateStats() {
-// let owned = [0, 0, 0, 0, 0];
-// let total = [0, 0, 0, 0, 0];
-// let unowned_portal = [0,0,0,0,0];
-
-// const { playerData } = this.context.player;
-// const { allcrew, includeTertiary, items, includeAllCrew } = this.state;
-
-// let r4owned = [0, 0, 0, 0];
-// let r5owned = [0, 0, 0, 0, 0];
-
-// if (includeAllCrew) {
-// r4owned.push(0);
-// r5owned.push(0);
-// }
-
-// let ownedStars = [0, 0, 0, 0, 0];
-// let totalStars = [0, 0, 0, 0, 0];
-
-// let radar_skill_rarity = Object.values(CONFIG.SKILLS).map((r) => ({
-// name: r,
-// Common: 0,
-// Uncommon: 0,
-// Rare: 0,
-// 'Super Rare': 0,
-// Legendary: 0,
-// }));
-// let radar_skill_rarity_owned = Object.values(CONFIG.SKILLS).map((r) => ({
-// name: r,
-// Common: 0,
-// Uncommon: 0,
-// Rare: 0,
-// 'Super Rare': 0,
-// Legendary: 0,
-// }));
-
-// let craftCost = 0;
-// let demands: IDemand[] = [];
-// let dupeChecker = new Set();
-
-// let skill_distribution = [] as StatTreeNode[];
-// for (let crew of allcrew ?? []) {
-// let pcrew: PlayerCrew | undefined = undefined;
-
-// // If multiple copies, find the "best one"
-// let pcrewlist = playerData?.player.character.crew.filter((bc) => bc.symbol === crew.symbol) ?? [];
-// if (pcrewlist.length === 1) {
-// pcrew = pcrewlist[0];
-// } else if (pcrewlist.length > 1) {
-// pcrew = pcrewlist.sort((a, b) => b.level - a.level)[0];
-// }
-
-// totalStars[crew.max_rarity - 1] += crew.max_rarity;
-// if (pcrew) {
-// ownedStars[crew.max_rarity - 1] += pcrew.rarity;
-// }
-
-// for (const skill in crew.base_skills) {
-// if (crew.base_skills[skill].core > 0) {
-// let rsr = radar_skill_rarity.find((r) => r.name === CONFIG.SKILLS[skill]);
-// if (rsr) rsr[CONFIG.RARITIES[crew.max_rarity].name]++;
-// }
-// }
-
-// total[crew.max_rarity - 1]++;
-// if (pcrew) {
-// owned[crew.max_rarity - 1]++;
-
-// insertInStatTree(sortedStats(pcrew), skill_distribution, '');
-
-// if (pcrew.max_rarity === 5) {
-// r5owned[pcrew.rarity - 1]++;
-// }
-
-// if (pcrew.max_rarity === 4) {
-// r4owned[pcrew.rarity - 1]++;
-// }
-
-// for (const skill in pcrew.base_skills) {
-// if (pcrew.base_skills[skill].core > 0) {
-// let rsro = radar_skill_rarity_owned.find((r) => r.name === CONFIG.SKILLS[skill]);
-// if (rsro) rsro[CONFIG.RARITIES[pcrew.max_rarity].name]++;
-// }
-// }
-
-// let startLevel = pcrew.max_level ? pcrew.max_level - 10 : pcrew.level - (pcrew.level % 10);
-// if (pcrew.equipment?.length < 4) {
-// // If it's not fully equipped for this level band, we include the previous band as well
-// startLevel = Math.max(1, startLevel - 10);
-// }
-
-// // all levels past pcrew.level
-// crew.equipment_slots
-// .filter((es) => es.level >= startLevel)
-// .forEach((es) => {
-// craftCost += demandsPerSlot(es, items ?? [], dupeChecker, demands, crew.symbol);
-// });
-// } else {
-// if (includeAllCrew) {
-// if (crew.max_rarity === 4) {
-// r4owned[4]++;
-// }
-// else if (crew.max_rarity === 5) {
-// r5owned[5]++;
-// }
-// }
-
-// if (crew.in_portal) {
-// unowned_portal[crew.max_rarity - 1]++;
-// }
-// }
-// }
-
-// demands = demands.sort((a, b) => b.count - a.count);
-
-// for (let demand of demands) {
-// let item = playerData?.player.character.items.find((it) => it.symbol === demand.symbol);
-// demand.have = item ? (item.quantity ?? 0) : 0;
-// }
-
-// let flat_skill_distribution = [] as any[];
-// skill_distribution.forEach((sec) => {
-// sec.loc = 0;
-// sec.children.forEach((tri) => {
-// let name = tri.name
-// ?.split('>')
-// ?.map((n) => n.trim())
-// ?.sort()
-// ?.join('/') || '';
-// let existing = flat_skill_distribution.find((e) => e.name === name);
-// if (existing) {
-// existing.Count += tri.loc;
-// existing.Gauntlet += tri.valueGauntlet;
-// existing.Voyage += tri.value;
-// } else {
-// flat_skill_distribution.push({
-// name,
-// Count: tri.loc,
-// Gauntlet: tri.valueGauntlet,
-// Voyage: tri.value,
-// });
-// }
-// });
-// });
-// flat_skill_distribution.sort((a, b) => a.Count - b.Count);
-
-// if (!includeTertiary) {
-// skill_distribution.forEach((sec) => {
-// sec.children.forEach((tri) => {
-// tri.children = [];
-// });
-// });
-// }
-
-// let data_ownership = [] as any[];
-// for (let i = 0; i < 5; i++) {
-// data_ownership.push({
-// rarity: CONFIG.RARITIES[i + 1].name,
-// Owned: owned[i],
-// 'Not Owned': total[i] - owned[i] - unowned_portal[i],
-// 'Not Owned - Portal': unowned_portal[i],
-// });
-// }
-
-// const makeLabel = (i: number, rarity: number) => {
-// if (rarity === i) {
-// return `Unowned`;
-// }
-// else {
-// return `${i + 1} / ${rarity}`;
-// }
-// }
-
-// this.setState({
-// data_ownership,
-// flat_skill_distribution,
-// radar_skill_rarity,
-// radar_skill_rarity_owned,
-// demands,
-// honordebt: { ownedStars, totalStars, craftCost },
-// skill_distribution: { name: 'Skills', children: skill_distribution, value: 0, valueGauntlet: 0, loc: 0 } as StatTreeNode,
-// r4_stars: r4owned?.map((v, i) => ({ label: makeLabel(i, 4), id: makeLabel(i, 4), value: v })).filter((e) => e.value > 0),
-// r5_stars: r5owned?.map((v, i) => ({ label: makeLabel(i, 5), id: makeLabel(i, 5), value: v })).filter((e) => e.value > 0),
-// });
-// }
-
-// _onIncludeTertiary() {
-// this.setState(
-// (prevState) => ({ includeTertiary: !prevState.includeTertiary }),
-// () => {
-// this._calculateStats();
-// }
-// );
-// }
-
-// _onIncludeAllCrew() {
-// this.setState(
-// (prevState) => ({ includeAllCrew: !prevState.includeAllCrew }),
-// () => {
-// this._calculateStats();
-// }
-// );
-// }
-
-// render() {
-// const {
-// data_ownership,
-// skill_distribution,
-// flat_skill_distribution,
-// r4_stars,
-// r5_stars,
-// radar_skill_rarity,
-// radar_skill_rarity_owned,
-// honordebt,
-// excludeFulfilled,
-// } = this.state;
-
-// let { demands } = this.state;
-
-// let totalHonorDebt = 0;
-// let readableHonorDebt = '';
-
-// if (honordebt) {
-// totalHonorDebt = honordebt?.totalStars
-// ?.map((val, idx) => (val - honordebt.ownedStars[idx]) * CONFIG.CITATION_COST[idx])
-// .reduce((a, b) => a + b, 0) || 0;
-
-// let totalHonorDebtDays = totalHonorDebt / 2000;
-
-// let years = Math.floor(totalHonorDebtDays / 365);
-// let months = Math.floor((totalHonorDebtDays - years * 365) / 30);
-// let days = totalHonorDebtDays - years * 365 - months * 30;
-
-// readableHonorDebt = `${years} years ${months} months ${Math.floor(days)} days`;
-// }
-
-// let totalChronCost = 0;
-// let factionRec = [] as DemandCounts[];
-// demands.forEach((entry) => {
-// let cost = entry.equipment?.item_sources?.map((its: any) => its.avg_cost).filter((cost) => !!cost) ?? [];
-// if (cost && cost.length > 0) {
-// totalChronCost += Math.min(...cost) * entry.count;
-// } else {
-// const factions = entry.equipment?.item_sources.filter((e) => e.type === 1);
-// if (factions && factions.length > 0) {
-// let fe = factionRec.find((e: any) => e.name === factions[0].name);
-// if (fe) {
-// fe.count += entry.count;
-// } else {
-// factionRec.push({
-// name: factions[0].name,
-// count: entry.count,
-// });
-// }
-// }
-// }
-// });
-
-// if (excludeFulfilled) {
-// demands = demands.filter((d) => d.count > d.have);
-// }
-
-// factionRec = factionRec.sort((a, b) => b.count - a.count).filter((e) => e.count > 0);
-
-// totalChronCost = Math.floor(totalChronCost);
-
-// return (
-// //
-// <>
-// Owned vs. Not Owned crew per rarity
-//
-//
-//
-
-// Honor debt
-// {honordebt && (
-//
-//
-//
-//
-// Rarity
-// Required stars
-// Honor cost
-//
-//
-
-//
-// {honordebt.totalStars?.map((val, idx) => (
-//
-//
-// {CONFIG.RARITIES[idx + 1].name}
-//
-//
-// {val - honordebt.ownedStars[idx]}{' '}
-//
-//
-// ({honordebt.ownedStars[idx]} / {val})
-//
-//
-//
-// {(val - honordebt.ownedStars[idx]) * CONFIG.CITATION_COST[idx]}
-//
-// ))}
-//
-
-//
-//
-//
-//
-// Owned {honordebt.ownedStars.reduce((a, b) => a + b, 0)} out of {honordebt.totalStars.reduce((a, b) => a + b, 0)}
-//
-// {totalHonorDebt}
-//
-//
-//
-
-//
-// {readableHonorDebt}
-// That's how long will it take you to max all remaining crew in the vault at 2000 honor / day
-//
-//
-// )}
-
-// Items required to level all owned crew
-// Note: this may over-include already equipped items from previous level bands for certain crew
-//
-// Cost and faction recommendations
-//
-// Total chroniton cost to farm all these items: {totalChronCost}{' '}
-//
-//
-//
-//
-// {honordebt && (
-//
-// Total number of credits required to craft all the recipes: {honordebt.craftCost}{' '}
-//
-//
-//
-//
-// )}
-//
-
-// Factions with most needed non-mission items
-//
-// {factionRec?.map((e) => (
-// -
-// {e.name}: {e.count} items
-//
-// ))}
-//
-
-//
-//
this.setState({ excludeFulfilled: !excludeFulfilled })}
-// checked={this.state.excludeFulfilled}
-// />
-//
-// {demands?.map((entry, idx) => (
-// entry?.equipment &&
-//
-// {
-// }
-// content={entry.equipment.name}
-// subheader={`Need ${entry.count} ${entry.factionOnly ? ' (FACTION)' : ''} (have ${entry.have})`}
-// />
-// }
-// header={CONFIG.RARITIES[entry.equipment.rarity].name + ' ' + entry.equipment.name}
-// content={}
-// on='click'
-// wide
-// />}
-// || <>>
-// ))}
-//
-//
-
-// Skill coverage per rarity (yours vs. every crew in vault)
-//
-//
-//
-//
-//
-//
-//
-//
-
-// Number of stars (fused rarity) for your Super Rare and Legendary crew
-// this._onIncludeAllCrew()} checked={this.state.includeAllCrew} />
-//
-//
-//
-//
-//
-//
-//
-//
-
-// Skill distribution for owned crew (number of characters per skill combos Primary > Secondary)
-// this._onIncludeTertiary()} checked={this.state.includeTertiary} />
-//
-//
-//
-//
-//
-//
-//
-//
-// >
-// //
-// );
-// }
-// }
+type FactionFormatProps = {
+ item: FactionRecord
+}
+const FactionFormatDisplay = (props: FactionFormatProps) => {
+
+ const globalContext = React.useContext(GlobalContext);
+ const { t } = globalContext.localized;
+ const { factions: allFactions } = globalContext.core;
+
+ const { item } = props;
+
+ const factions = React.useMemo(() => {
+ let fs = item.name.split(` ${t('global.or')} `);
+ let facts = fs.map(fn => allFactions.find(ff => FactionTransmissionMap[ff.name] === fn)).filter(f => !!f);
+ return facts;
+ }, [item]);
+
+ const images = factions.map(faction => {
+ return (
+
+ )
+ });
+
+ const names = factions.map(faction => faction.name).reduce((p, n) => {
+ if (p) {
+ return {p} {t('global.or')} {n}
;
+ }
+ else {
+ return {n}
;
+ }
+ }, undefined as React.ReactNode | undefined);
+
+ return (
+ {images}{' '}{names} — {item.count} {t('global.items')}
+
)
+}
export default ProfileCharts;
diff --git a/src/pages/charts.tsx b/src/pages/charts.tsx
index 53615e49b8..88d519a287 100644
--- a/src/pages/charts.tsx
+++ b/src/pages/charts.tsx
@@ -7,7 +7,7 @@ const ChartsPage = () => {
+ demands={['episodes', 'items', 'cadet', 'all_buffs', 'all_ships', 'cadet', 'collections', 'factions']}>
)
From 8b186e020e2cd6abdca8fe21b831a9ef063742a7 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 21 Jun 2026 19:13:17 -0400
Subject: [PATCH 16/25] Fix immortal display for prepareCrew
---
src/utils/crewutils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/utils/crewutils.ts b/src/utils/crewutils.ts
index 2e18d0322c..63ce9d5648 100644
--- a/src/utils/crewutils.ts
+++ b/src/utils/crewutils.ts
@@ -517,7 +517,7 @@ export function prepareOne(origCrew: CrewMember | PlayerCrew, playerData?: Playe
}
}
- if (crew.immortal) {
+ if (crew.immortal && (crew.immortal === CompletionState.DisplayAsImmortalOwned || crew.immortal === CompletionState.Immortalized || crew.immortal > 0)) {
if (buffConfig) applyCrewBuffs(crew, buffConfig);
crew.have = true;
crew.highest_owned_rarity = crew.max_rarity ?? crew.rarity;
From 4344eaeafd39ca7802ffcffcbfdcb2a29888e9e2 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 21 Jun 2026 19:14:40 -0400
Subject: [PATCH 17/25] Correct Immortal Display
---
src/utils/crewutils.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/utils/crewutils.ts b/src/utils/crewutils.ts
index 63ce9d5648..a13fb83e36 100644
--- a/src/utils/crewutils.ts
+++ b/src/utils/crewutils.ts
@@ -517,9 +517,9 @@ export function prepareOne(origCrew: CrewMember | PlayerCrew, playerData?: Playe
}
}
- if (crew.immortal && (crew.immortal === CompletionState.DisplayAsImmortalOwned || crew.immortal === CompletionState.Immortalized || crew.immortal > 0)) {
+ if (crew.immortal) {
if (buffConfig) applyCrewBuffs(crew, buffConfig);
- crew.have = true;
+ crew.have = (crew.immortal === CompletionState.DisplayAsImmortalOwned || crew.immortal === CompletionState.Immortalized || crew.immortal > 0);
crew.highest_owned_rarity = crew.max_rarity ?? crew.rarity;
crew.highest_owned_level = crew.max_level ?? 100;
crew.q_bits ??= 0;
From 02c690c6bb1404adc601858ba21430dae948977c Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 21 Jun 2026 19:15:32 -0400
Subject: [PATCH 18/25] inroster if have only.
---
src/utils/crewutils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/utils/crewutils.ts b/src/utils/crewutils.ts
index a13fb83e36..a9a51dbf8d 100644
--- a/src/utils/crewutils.ts
+++ b/src/utils/crewutils.ts
@@ -525,7 +525,7 @@ export function prepareOne(origCrew: CrewMember | PlayerCrew, playerData?: Playe
crew.q_bits ??= 0;
crew.kwipment ??= [0, 0, 0, 0];
crew.kwipment_expiration ??= [0, 0, 0, 0];
- inroster.push(crew);
+ if (crew.have) inroster.push(crew);
crew = templateCrew;
}
}
From f2a012096c980c37505444007bd6c1010ab95617 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 21 Jun 2026 19:17:52 -0400
Subject: [PATCH 19/25] Fix up for immortalized crew with input immortal tag.
---
src/utils/crewutils.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/utils/crewutils.ts b/src/utils/crewutils.ts
index a9a51dbf8d..897d3c0bf8 100644
--- a/src/utils/crewutils.ts
+++ b/src/utils/crewutils.ts
@@ -517,15 +517,15 @@ export function prepareOne(origCrew: CrewMember | PlayerCrew, playerData?: Playe
}
}
- if (crew.immortal) {
- if (buffConfig) applyCrewBuffs(crew, buffConfig);
- crew.have = (crew.immortal === CompletionState.DisplayAsImmortalOwned || crew.immortal === CompletionState.Immortalized || crew.immortal > 0);
+ crew.have = !!crew.immortal && (crew.immortal === CompletionState.DisplayAsImmortalOwned || crew.immortal === CompletionState.Immortalized || crew.immortal > 0);
+
+ if (crew.have) {
crew.highest_owned_rarity = crew.max_rarity ?? crew.rarity;
crew.highest_owned_level = crew.max_level ?? 100;
crew.q_bits ??= 0;
crew.kwipment ??= [0, 0, 0, 0];
crew.kwipment_expiration ??= [0, 0, 0, 0];
- if (crew.have) inroster.push(crew);
+ inroster.push(crew);
crew = templateCrew;
}
}
From 659e1a8299ffb674154f8c95f8db3a1f2b9542c3 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 21 Jun 2026 19:48:21 -0400
Subject: [PATCH 20/25] Fix up avatar view for active rounds
---
src/components/item_presenters/avatarview.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/components/item_presenters/avatarview.tsx b/src/components/item_presenters/avatarview.tsx
index e2b6cc7d91..f10d46c0ac 100644
--- a/src/components/item_presenters/avatarview.tsx
+++ b/src/components/item_presenters/avatarview.tsx
@@ -243,6 +243,10 @@ export const AvatarView = (props: AvatarViewProps) => {
gen_item = { ...gen_item };
gen_item.rarity = showMaxRarity ? gen_item.max_rarity : 0;
}
+ else if (props.item) {
+ gen_item = { ...props.item};
+ gen_item.rarity = showMaxRarity ? gen_item.max_rarity : 0;
+ }
}
}
if (gen_item) {
From 650368fa5ec3cfb89af70b0ccef8bba448dddd20 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Wed, 1 Jul 2026 21:09:02 -0400
Subject: [PATCH 21/25] Add multi-search option to DataPicker
---
.../dataset_presenters/datapicker.tsx | 21 +++++++++++++++----
1 file changed, 17 insertions(+), 4 deletions(-)
diff --git a/src/components/dataset_presenters/datapicker.tsx b/src/components/dataset_presenters/datapicker.tsx
index 8b7d402818..7b8a455eeb 100644
--- a/src/components/dataset_presenters/datapicker.tsx
+++ b/src/components/dataset_presenters/datapicker.tsx
@@ -18,6 +18,7 @@ import { DataGrid } from './datagrid';
import { IDataGridSetup } from './model';
import { DataTable } from './datatable';
import { IDataTableSetup } from './model';
+import { OptionsPanelFlexRow } from '../stats/utils';
type DataPickerProps = {
id: string;
@@ -47,6 +48,7 @@ export const DataPicker = (props: DataPickerProps) => {
// Reset search query and close options on each reload
const [searchQuery, setSearchQuery] = React.useState('');
const [showOptions, setShowOptions] = React.useState(false);
+ const [multiSearch, setMultiSearch] = useStateWithStorage(`${props.id}/multiSearch`, false, { rememberForever: true });
// Persist layout preference
const [layout, setLayout] = useStateWithStorage<'grid' | 'table'>(`${props.id}/layout`, props.tableSetup ? 'table' : 'grid');
@@ -56,7 +58,7 @@ export const DataPicker = (props: DataPickerProps) => {
(!props.preFilteredIds || !props.preFilteredIds.has(datum.id))
&& (searchQuery === '' || textMatch(datum.name, searchQuery))
);
- }, [props.data, props.preFilteredIds, searchQuery]);
+ }, [props.data, props.preFilteredIds, searchQuery, multiSearch]);
// Update selected ids on external changes
React.useEffect(() => {
@@ -99,16 +101,27 @@ export const DataPicker = (props: DataPickerProps) => {
);
- function textMatch(fieldValue: string, userQuery: string): boolean {
+ function textMatch(fieldValue: string, userQuery: string, multiSearch?: boolean): boolean {
+ if (multiSearch) {
+ const uqparts = userQuery.split(",").map(p => p.trim());
+ if (uqparts.length > 1) {
+ return uqparts.some(part => fieldValue.toLowerCase().replace(/[^a-z0-9]/g, '')
+ .indexOf(part.toLowerCase().replace(/[^a-z0-9]/g, '')) >= 0)
+ }
+ }
return fieldValue.toLowerCase().replace(/[^a-z0-9]/g, '')
.indexOf(userQuery.toLowerCase().replace(/[^a-z0-9]/g, '')) >= 0;
}
function renderModalHeader(): JSX.Element {
if (!props.search) return <>{props.title}>;
- return (
+ return (
+
{
- );
+
);
}
function renderModalContent(): JSX.Element {
From 70e9e05895574a04c31f1a71afb6d97f594fc9a0 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Sun, 5 Jul 2026 12:52:28 -0400
Subject: [PATCH 22/25] force export bools to lower case
---
src/utils/crewutils.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/utils/crewutils.ts b/src/utils/crewutils.ts
index 897d3c0bf8..9e3dbd8581 100644
--- a/src/utils/crewutils.ts
+++ b/src/utils/crewutils.ts
@@ -31,7 +31,7 @@ export function exportCrewFields(t: TranslateMethod, force_english = true): Expo
},
{
label: 'Have',
- value: (row: PlayerCrew) => row.have
+ value: (row: PlayerCrew) => row.have ? 'true' : 'false'
},
{
label: 'Short name',
@@ -63,7 +63,7 @@ export function exportCrewFields(t: TranslateMethod, force_english = true): Expo
},
{
label: 'In portal',
- value: (row: PlayerCrew) => (row.in_portal === undefined ? 'N/A' : row.in_portal)
+ value: (row: PlayerCrew) => (row.in_portal === undefined ? 'N/A' : (row.in_portal ? 'true' : 'false'))
},
{
label: 'Collections',
From faa97c67c2bcc504215f97185b0908922306eb71 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Mon, 6 Jul 2026 13:47:45 -0400
Subject: [PATCH 23/25] fix multiSearch wrong scope
---
src/components/dataset_presenters/datapicker.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/components/dataset_presenters/datapicker.tsx b/src/components/dataset_presenters/datapicker.tsx
index 7b8a455eeb..fc5bee8576 100644
--- a/src/components/dataset_presenters/datapicker.tsx
+++ b/src/components/dataset_presenters/datapicker.tsx
@@ -54,7 +54,7 @@ export const DataPicker = (props: DataPickerProps) => {
const [layout, setLayout] = useStateWithStorage<'grid' | 'table'>(`${props.id}/layout`, props.tableSetup ? 'table' : 'grid');
const data = React.useMemo(() => {
- return props.data.slice().filter(datum =>
+ return props.data.filter(datum =>
(!props.preFilteredIds || !props.preFilteredIds.has(datum.id))
&& (searchQuery === '' || textMatch(datum.name, searchQuery))
);
@@ -101,9 +101,9 @@ export const DataPicker = (props: DataPickerProps) => {
);
- function textMatch(fieldValue: string, userQuery: string, multiSearch?: boolean): boolean {
+ function textMatch(fieldValue: string, userQuery: string): boolean {
if (multiSearch) {
- const uqparts = userQuery.split(",").map(p => p.trim());
+ const uqparts = userQuery.split(",").map(p => p.trim()).filter(s => !!s);
if (uqparts.length > 1) {
return uqparts.some(part => fieldValue.toLowerCase().replace(/[^a-z0-9]/g, '')
.indexOf(part.toLowerCase().replace(/[^a-z0-9]/g, '')) >= 0)
@@ -113,6 +113,7 @@ export const DataPicker = (props: DataPickerProps) => {
.indexOf(userQuery.toLowerCase().replace(/[^a-z0-9]/g, '')) >= 0;
}
+
function renderModalHeader(): JSX.Element {
if (!props.search) return <>{props.title}>;
return (
From 6d40ddfc4f18aebf596cd193b09c613d337a6063 Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Mon, 6 Jul 2026 14:52:57 -0400
Subject: [PATCH 24/25] Add pinned crew to ephemeral data
---
src/context/playercontext.tsx | 8 ++++++--
src/model/player.ts | 14 ++++++++++++++
src/utils/playerutils.ts | 2 ++
3 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/src/context/playercontext.tsx b/src/context/playercontext.tsx
index 332ea4fd46..61d1bfe319 100644
--- a/src/context/playercontext.tsx
+++ b/src/context/playercontext.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { ArchetypeRoot20 } from '../model/archetype';
import { BossBattlesRoot } from '../model/boss';
import { EquipmentItem } from '../model/equipment';
-import { BorrowedCrew, CompactCrew, Fleet, GalaxyCrewCooldown, GameEvent, ObjectiveEventRoot, PlayerCrew, PlayerData, Stimpack, Voyage, VoyageDescription } from '../model/player';
+import { BorrowedCrew, CompactCrew, Fleet, GalaxyCrewCooldown, GameEvent, ObjectiveEventRoot, PinnedCrewRoot, PinnedStatisVaultRoot, PlayerCrew, PlayerData, Stimpack, Voyage, VoyageDescription } from '../model/player';
import { Ship } from '../model/ship';
import { ShuttleAdventure } from '../model/shuttle';
import { ShipTraitNames } from '../model/traits';
@@ -57,6 +57,8 @@ export interface IEphemeralData {
seasonalEventShop?: SeasonalShop;
stimpack?: Stimpack;
borrowedCrew: BorrowedCrew[];
+ pinnedCrew?: PinnedCrewRoot;
+ pinnedStasisVault?: PinnedStatisVaultRoot;
};
export interface ISessionStates {
@@ -178,7 +180,9 @@ export const PlayerProvider = (props: DataProviderProperties) => {
galaxyCooldowns: input.player.character.galaxy_crew_cooldowns ?? [],
stimpack: input.player.character.stimpack,
seasonalEventShop: input.seasonal_event_shop_root,
- borrowedCrew: [...input.player.character.crew_borrows ?? []]
+ borrowedCrew: [...input.player.character.crew_borrows ?? []],
+ pinnedCrew: input.pinned_crew_root,
+ pinnedStasisVault: input.pinned_stasis_vault_root
});
}
diff --git a/src/model/player.ts b/src/model/player.ts
index 095531297d..b14255a796 100644
--- a/src/model/player.ts
+++ b/src/model/player.ts
@@ -61,6 +61,8 @@ export interface PlayerData {
buyback_well: PlayerCrew[];
crew_crafting_root?: CrewCraftingRoot;
objective_event_root?: ObjectiveEventRoot;
+ pinned_crew_root?: PinnedCrewRoot;
+ pinned_stasis_vault_root?: PinnedStatisVaultRoot;
}
export interface Player {
@@ -98,10 +100,22 @@ export interface Player {
consent: boolean
ccpa_opted_out: boolean
u_13: boolean
+}
+export interface PinnedCrewFeature {
+ feature_id: number;
+ crew_ids: number[];
}
+export interface PinnedCrewRoot {
+ id: number;
+ features: PinnedCrewFeature[];
+}
+export interface PinnedStatisVaultRoot {
+ id: number;
+ archetype_ids: number[];
+}
export interface CurrencyExchange {
id: number
diff --git a/src/utils/playerutils.ts b/src/utils/playerutils.ts
index 16ef01a9a1..f6b31c60d1 100644
--- a/src/utils/playerutils.ts
+++ b/src/utils/playerutils.ts
@@ -38,6 +38,8 @@ export function stripPlayerData(items: PlayerEquipmentItem[], p: PlayerData): an
delete p.archetype_cache;
delete p.item_archetype_cache;
delete p.seasonal_event_shop_root;
+ delete p.pinned_crew_root;
+ delete p.pinned_stasis_vault_root;
delete p.player.character.navmap;
delete p.player.character.tutorials;
From a4b68f32b9ebbe23847c63d20664c981ab9fe14a Mon Sep 17 00:00:00 2001
From: Nathaniel Moschkin
Date: Tue, 14 Jul 2026 16:24:53 -0400
Subject: [PATCH 25/25] Make staffer wrappable for mobile
---
src/components/ship/staffingview.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/ship/staffingview.tsx b/src/components/ship/staffingview.tsx
index 5f71d6480c..8efe780f6b 100644
--- a/src/components/ship/staffingview.tsx
+++ b/src/components/ship/staffingview.tsx
@@ -173,7 +173,8 @@ export const ShipStaffingView = (props: ShipStaffingProps) => {
justifyContent: "center",
alignItems: "center",
padding: 0,
- marginBottom: '2em'
+ marginBottom: '2em',
+ flexWrap: 'wrap'
}}>
{!!ship && ship.battle_stations?.map((bs, idx) => (