diff --git a/package.json b/package.json index 5a356ad538..64dab537fd 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", diff --git a/src/components/dataset_presenters/datapicker.tsx b/src/components/dataset_presenters/datapicker.tsx index 8b7d402818..fc5bee8576 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,16 +48,17 @@ 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'); 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)) ); - }, [props.data, props.preFilteredIds, searchQuery]); + }, [props.data, props.preFilteredIds, searchQuery, multiSearch]); // Update selected ids on external changes React.useEffect(() => { @@ -100,15 +102,27 @@ export const DataPicker = (props: DataPickerProps) => { ); function textMatch(fieldValue: string, userQuery: string): boolean { + if (multiSearch) { + 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) + } + } 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 { 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) { diff --git a/src/components/item_presenters/shipskill.tsx b/src/components/item_presenters/shipskill.tsx index b75cdd655a..3ec5ec55b1 100644 --- a/src/components/item_presenters/shipskill.tsx +++ b/src/components/item_presenters/shipskill.tsx @@ -426,34 +426,41 @@ 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} {` `} - } + }
+ + I: {crew.action.initial_cooldown}s + {`, `} + D: {crew.action.duration}s + {`, `} + C: {crew.action.cooldown}s +

} ) 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/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; 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) => (
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/src/components/voyagecalculator/stats/stats_accordion.tsx b/src/components/voyagecalculator/stats/stats_accordion.tsx index b8b158f2f7..1c4947206d 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); @@ -46,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) { @@ -66,8 +70,10 @@ export class VoyageStatsAccordion extends Component { - let maxTime = (message.data.result.refills.reduce((p, n) => n.safeResult && n.safeResult > p ? n.safeResult : p, 0)); - if (message.data.result.refills.some(r => r.safeResult === undefined)) { - 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.config.selectedTime = Math.floor(maxTime); - this.setState({ estimate: message.data.result }); + private readonly _eventListener = (message: EstimateResponse) => { + this.config.selectedTime = message.data.result.adjustedTime ?? 20; + 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++; @@ -125,17 +124,20 @@ export class VoyageStatsAccordion extends Component { 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/model/voyage.ts b/src/model/voyage.ts index 0e683a3400..e4e930593c 100644 --- a/src/model/voyage.ts +++ b/src/model/voyage.ts @@ -87,6 +87,7 @@ export interface Estimate { deterministic?: boolean; antimatter?: number; vpDetails?: VPDetails; + adjustedTime?: number; }; export interface Refill { diff --git a/src/model/worker.ts b/src/model/worker.ts index 69850d4ffa..78a39fa63f 100644 --- a/src/model/worker.ts +++ b/src/model/worker.ts @@ -77,18 +77,21 @@ export interface IMultiWorkerContext { export interface VoyageStatsConfig { - others?: number[]; + others: Skill[]; numSims: number; startAm: number; currentAm: number; elapsedSeconds: number; variance: number; - ps?: Skill; - ss?: Skill; + ps: Skill; + ss: Skill; } -export interface ExtendedVoyageStatsConfig extends VoyageStatsConfig{ +export interface SporeDriveConfig extends VoyageStatsConfig { selectedTime?: number; + prof?: number; + noExtends?: boolean; + vfast?: boolean; } export interface GameWorkerOptions { 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 } 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']}> ) diff --git a/src/utils/crewutils.ts b/src/utils/crewutils.ts index 9079c96d97..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', @@ -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; } @@ -523,9 +517,9 @@ export function prepareOne(origCrew: CrewMember | PlayerCrew, playerData?: Playe } } - if (crew.immortal) { - if (buffConfig) applyCrewBuffs(crew, buffConfig); - crew.have = true; + 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; @@ -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) { 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; diff --git a/src/workers/sporedrive.js b/src/workers/sporedrive.js deleted file mode 100644 index 8c583d5dd7..0000000000 --- a/src/workers/sporedrive.js +++ /dev/null @@ -1,286 +0,0 @@ -// Adapted from Chewable C++'s STT Voyage Estimator -// https://codepen.io/somnivore/pen/Nabyzw - -/* eslint-disable */ - -function getEstimate(config, reportProgress = () => 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/sporedrive.ts b/src/workers/sporedrive.ts new file mode 100644 index 0000000000..58ba1fad6d --- /dev/null +++ b/src/workers/sporedrive.ts @@ -0,0 +1,309 @@ +// 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"; + +const blankSkill = { + core: 0, + range_min: 0, + range_max: 0, + skill: "" +} +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); + + 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 + + let results = [] as number[][]; + let resultsRefillCostTotal = [] as number[]; + + for (let iExtend = 0; iExtend <= numExtends; ++iExtend) { + results.push([]); + 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); + 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; +} + +export default 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)), 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"