feat(i18n): integrate i18next for internationalization support and add initial translation setup
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Layout,
|
Layout,
|
||||||
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
@@ -18,68 +19,27 @@ import {
|
|||||||
import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons';
|
import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons';
|
||||||
import * as App from '../../wailsjs/go/service/App';
|
import * as App from '../../wailsjs/go/service/App';
|
||||||
import {useMessage} from '../utils/useMessage';
|
import {useMessage} from '../utils/useMessage';
|
||||||
|
import {
|
||||||
|
BonusStats,
|
||||||
|
Filters,
|
||||||
|
MainStatKey,
|
||||||
|
RawHero,
|
||||||
|
RawItem,
|
||||||
|
SetFilters,
|
||||||
|
StatKey,
|
||||||
|
emptyBonusStats,
|
||||||
|
emptyFilters,
|
||||||
|
emptySetFilters,
|
||||||
|
isFourPieceSet,
|
||||||
|
isTwoPieceSet,
|
||||||
|
} from '../utils/optimizer';
|
||||||
import './optimizer.css';
|
import './optimizer.css';
|
||||||
|
|
||||||
const {Content} = Layout;
|
const {Content} = Layout;
|
||||||
|
const MAX_ITEMS_PER_SLOT = 150;
|
||||||
type RawItem = {
|
const MAX_COMBOS = 200000;
|
||||||
id?: string | number;
|
const MAX_RESULTS = 200;
|
||||||
gear?: string;
|
const GEAR_SLOTS = ['Weapon', 'Helmet', 'Armor', 'Necklace', 'Ring', 'Boots'] as const;
|
||||||
set?: string;
|
|
||||||
main?: { type?: string; value?: number };
|
|
||||||
substats?: Array<{ type?: string; value?: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawHero = {
|
|
||||||
id?: string | number;
|
|
||||||
name?: string;
|
|
||||||
atk?: number;
|
|
||||||
def?: number;
|
|
||||||
hp?: number;
|
|
||||||
spd?: number;
|
|
||||||
cr?: number;
|
|
||||||
cd?: number;
|
|
||||||
eff?: number;
|
|
||||||
res?: number;
|
|
||||||
baseAtk?: number;
|
|
||||||
baseDef?: number;
|
|
||||||
baseHp?: number;
|
|
||||||
baseSpd?: number;
|
|
||||||
baseCr?: number;
|
|
||||||
baseCd?: number;
|
|
||||||
baseEff?: number;
|
|
||||||
baseRes?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatKey = 'atk' | 'def' | 'hp' | 'spd' | 'cr' | 'cd' | 'acc' | 'res';
|
|
||||||
type StatRange = { min?: number; max?: number };
|
|
||||||
type Filters = Record<StatKey, StatRange>;
|
|
||||||
type MainStatKey =
|
|
||||||
| 'Attack'
|
|
||||||
| 'Defense'
|
|
||||||
| 'Health'
|
|
||||||
| 'AttackPercent'
|
|
||||||
| 'DefensePercent'
|
|
||||||
| 'HealthPercent'
|
|
||||||
| 'Speed'
|
|
||||||
| 'CriticalHitChancePercent'
|
|
||||||
| 'CriticalHitDamagePercent'
|
|
||||||
| 'EffectivenessPercent'
|
|
||||||
| 'EffectResistancePercent';
|
|
||||||
|
|
||||||
type ItemStats = {
|
|
||||||
atk: number;
|
|
||||||
def: number;
|
|
||||||
hp: number;
|
|
||||||
spd: number;
|
|
||||||
cr: number;
|
|
||||||
cd: number;
|
|
||||||
acc: number;
|
|
||||||
res: number;
|
|
||||||
atkPct: number;
|
|
||||||
defPct: number;
|
|
||||||
hpPct: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BuildResult = {
|
type BuildResult = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -97,289 +57,38 @@ type BuildResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STAT_LABELS: Array<{key: StatKey; label: string}> = [
|
const STAT_LABELS: Array<{key: StatKey; label: string}> = [
|
||||||
{key: 'atk', label: '攻击'},
|
{key: 'atk', label: '\u653b\u51fb'},
|
||||||
{key: 'def', label: '防御'},
|
{key: 'def', label: '\u9632\u5fa1'},
|
||||||
{key: 'hp', label: '生命'},
|
{key: 'hp', label: '\u751f\u547d'},
|
||||||
{key: 'spd', label: '速度'},
|
{key: 'spd', label: '\u901f\u5ea6'},
|
||||||
{key: 'cr', label: '暴击'},
|
{key: 'cr', label: '\u66b4\u51fb'},
|
||||||
{key: 'cd', label: '爆伤'},
|
{key: 'cd', label: '\u7206\u4f24'},
|
||||||
{key: 'acc', label: '命中'},
|
{key: 'acc', label: '\u547d\u4e2d'},
|
||||||
{key: 'res', label: '抵抗'},
|
{key: 'res', label: '\u62b5\u6297'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const GEAR_SLOTS = ['Weapon', 'Helmet', 'Armor', 'Necklace', 'Ring', 'Boots'];
|
|
||||||
const MAX_ITEMS_PER_SLOT = 8;
|
|
||||||
const MAX_RESULTS = 200;
|
|
||||||
const MAX_COMBOS = 200000;
|
|
||||||
|
|
||||||
const FOUR_PIECE_SETS = new Set([
|
|
||||||
'AttackSet',
|
|
||||||
'SpeedSet',
|
|
||||||
'DestructionSet',
|
|
||||||
'LifestealSet',
|
|
||||||
'ProtectionSet',
|
|
||||||
'CounterSet',
|
|
||||||
'RageSet',
|
|
||||||
'RevengeSet',
|
|
||||||
'InjurySet',
|
|
||||||
'ReversalSet',
|
|
||||||
'RiposteSet',
|
|
||||||
'WarfareSet',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const TWO_PIECE_SETS = new Set([
|
|
||||||
'HealthSet',
|
|
||||||
'DefenseSet',
|
|
||||||
'CriticalSet',
|
|
||||||
'HitSet',
|
|
||||||
'ResistSet',
|
|
||||||
'UnitySet',
|
|
||||||
'ImmunitySet',
|
|
||||||
'PenetrationSet',
|
|
||||||
'TorrentSet',
|
|
||||||
'PursuitSet',
|
|
||||||
]);
|
|
||||||
|
|
||||||
type SetFilters = {
|
|
||||||
set1: string[];
|
|
||||||
set2: string[];
|
|
||||||
set3: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyFilters: Filters = {
|
|
||||||
atk: {},
|
|
||||||
def: {},
|
|
||||||
hp: {},
|
|
||||||
spd: {},
|
|
||||||
cr: {},
|
|
||||||
cd: {},
|
|
||||||
acc: {},
|
|
||||||
res: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptySetFilters: SetFilters = {
|
|
||||||
set1: [],
|
|
||||||
set2: [],
|
|
||||||
set3: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNumber = (value: any) => {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildItemStats = (item: RawItem): ItemStats => {
|
|
||||||
const stats: ItemStats = {
|
|
||||||
atk: 0,
|
|
||||||
def: 0,
|
|
||||||
hp: 0,
|
|
||||||
spd: 0,
|
|
||||||
cr: 0,
|
|
||||||
cd: 0,
|
|
||||||
acc: 0,
|
|
||||||
res: 0,
|
|
||||||
atkPct: 0,
|
|
||||||
defPct: 0,
|
|
||||||
hpPct: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyStat = (type?: string, value?: number) => {
|
|
||||||
if (!type) return;
|
|
||||||
const v = toNumber(value);
|
|
||||||
switch (type) {
|
|
||||||
case 'Attack':
|
|
||||||
stats.atk += v;
|
|
||||||
break;
|
|
||||||
case 'Defense':
|
|
||||||
stats.def += v;
|
|
||||||
break;
|
|
||||||
case 'Health':
|
|
||||||
stats.hp += v;
|
|
||||||
break;
|
|
||||||
case 'Speed':
|
|
||||||
stats.spd += v;
|
|
||||||
break;
|
|
||||||
case 'CriticalHitChancePercent':
|
|
||||||
stats.cr += v;
|
|
||||||
break;
|
|
||||||
case 'CriticalHitDamagePercent':
|
|
||||||
stats.cd += v;
|
|
||||||
break;
|
|
||||||
case 'EffectivenessPercent':
|
|
||||||
stats.acc += v;
|
|
||||||
break;
|
|
||||||
case 'EffectResistancePercent':
|
|
||||||
stats.res += v;
|
|
||||||
break;
|
|
||||||
case 'AttackPercent':
|
|
||||||
stats.atkPct += v;
|
|
||||||
break;
|
|
||||||
case 'DefensePercent':
|
|
||||||
stats.defPct += v;
|
|
||||||
break;
|
|
||||||
case 'HealthPercent':
|
|
||||||
stats.hpPct += v;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (item.main) {
|
|
||||||
applyStat(item.main.type, item.main.value);
|
|
||||||
}
|
|
||||||
if (Array.isArray(item.substats)) {
|
|
||||||
item.substats.forEach(sub => applyStat(sub.type, sub.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildBaseStats = (hero?: RawHero) => {
|
|
||||||
return {
|
|
||||||
atk: toNumber(hero?.baseAtk ?? hero?.atk),
|
|
||||||
def: toNumber(hero?.baseDef ?? hero?.def),
|
|
||||||
hp: toNumber(hero?.baseHp ?? hero?.hp),
|
|
||||||
spd: toNumber(hero?.baseSpd ?? hero?.spd),
|
|
||||||
cr: toNumber(hero?.baseCr ?? hero?.cr),
|
|
||||||
cd: toNumber(hero?.baseCd ?? hero?.cd),
|
|
||||||
acc: toNumber(hero?.baseEff ?? hero?.eff),
|
|
||||||
res: toNumber(hero?.baseRes ?? hero?.res),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
|
const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
|
||||||
if (!hero) return 0;
|
if (!hero) return 0;
|
||||||
if (key === 'acc') return toNumber(hero.eff);
|
switch (key) {
|
||||||
return toNumber((hero as any)[key]);
|
case 'atk':
|
||||||
};
|
return hero.atk ?? hero.baseAtk ?? 0;
|
||||||
|
case 'def':
|
||||||
const scoreStats = (stats: ItemStats, baseStats: ReturnType<typeof buildBaseStats>) => {
|
return hero.def ?? hero.baseDef ?? 0;
|
||||||
const atk = stats.atk + baseStats.atk * (stats.atkPct / 100);
|
case 'hp':
|
||||||
const def = stats.def + baseStats.def * (stats.defPct / 100);
|
return hero.hp ?? hero.baseHp ?? 0;
|
||||||
const hp = stats.hp + baseStats.hp * (stats.hpPct / 100);
|
case 'spd':
|
||||||
|
return hero.spd ?? hero.baseSpd ?? 0;
|
||||||
return (
|
case 'cr':
|
||||||
stats.spd * 2 +
|
return hero.cr ?? hero.baseCr ?? 0;
|
||||||
stats.cr +
|
case 'cd':
|
||||||
stats.cd +
|
return hero.cd ?? hero.baseCd ?? 0;
|
||||||
stats.acc +
|
case 'acc':
|
||||||
stats.res +
|
return hero.eff ?? hero.baseEff ?? 0;
|
||||||
(atk + def + hp) / 100
|
case 'res':
|
||||||
);
|
return hero.res ?? hero.baseRes ?? 0;
|
||||||
};
|
default:
|
||||||
|
return 0;
|
||||||
const buildTotalStats = (statsList: ItemStats[], baseStats: ReturnType<typeof buildBaseStats>) => {
|
|
||||||
const totals: ItemStats = {
|
|
||||||
atk: 0,
|
|
||||||
def: 0,
|
|
||||||
hp: 0,
|
|
||||||
spd: 0,
|
|
||||||
cr: 0,
|
|
||||||
cd: 0,
|
|
||||||
acc: 0,
|
|
||||||
res: 0,
|
|
||||||
atkPct: 0,
|
|
||||||
defPct: 0,
|
|
||||||
hpPct: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
statsList.forEach(stats => {
|
|
||||||
totals.atk += stats.atk;
|
|
||||||
totals.def += stats.def;
|
|
||||||
totals.hp += stats.hp;
|
|
||||||
totals.spd += stats.spd;
|
|
||||||
totals.cr += stats.cr;
|
|
||||||
totals.cd += stats.cd;
|
|
||||||
totals.acc += stats.acc;
|
|
||||||
totals.res += stats.res;
|
|
||||||
totals.atkPct += stats.atkPct;
|
|
||||||
totals.defPct += stats.defPct;
|
|
||||||
totals.hpPct += stats.hpPct;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
atk: totals.atk + baseStats.atk + baseStats.atk * (totals.atkPct / 100),
|
|
||||||
def: totals.def + baseStats.def + baseStats.def * (totals.defPct / 100),
|
|
||||||
hp: totals.hp + baseStats.hp + baseStats.hp * (totals.hpPct / 100),
|
|
||||||
spd: totals.spd + baseStats.spd,
|
|
||||||
cr: totals.cr + baseStats.cr,
|
|
||||||
cd: totals.cd + baseStats.cd,
|
|
||||||
acc: totals.acc + baseStats.acc,
|
|
||||||
res: totals.res + baseStats.res,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const passesFilters = (totals: ReturnType<typeof buildTotalStats>, filters: Filters) => {
|
|
||||||
return STAT_LABELS.every(stat => {
|
|
||||||
const value = totals[stat.key];
|
|
||||||
const range = filters[stat.key];
|
|
||||||
if (range.min !== undefined && value < range.min) return false;
|
|
||||||
if (range.max !== undefined && value > range.max) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFourPieceSet = (setName: string) => FOUR_PIECE_SETS.has(setName);
|
|
||||||
const isTwoPieceSet = (setName: string) => TWO_PIECE_SETS.has(setName);
|
|
||||||
|
|
||||||
const getCompletedSets = (items: RawItem[]) => {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
items.forEach(item => {
|
|
||||||
if (!item.set) return;
|
|
||||||
counts.set(item.set, (counts.get(item.set) || 0) + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const completed: string[] = [];
|
|
||||||
counts.forEach((count, setName) => {
|
|
||||||
if (FOUR_PIECE_SETS.has(setName) && count >= 4) {
|
|
||||||
completed.push(setName);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (TWO_PIECE_SETS.has(setName) && count >= 2) {
|
|
||||||
const stacks = Math.floor(count / 2);
|
|
||||||
for (let i = 0; i < stacks; i += 1) {
|
|
||||||
completed.push(setName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return completed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const passesSetFilter = (items: RawItem[], setFilters: SetFilters) => {
|
|
||||||
if (setFilters.set1.length === 0 && setFilters.set2.length === 0 && setFilters.set3.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
items.forEach(item => {
|
|
||||||
if (!item.set) return;
|
|
||||||
counts.set(item.set, (counts.get(item.set) || 0) + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasAnyMatch = (sets: string[], required: number) => {
|
|
||||||
if (sets.length === 0) return true;
|
|
||||||
return sets.some(setName => (counts.get(setName) || 0) >= required);
|
|
||||||
};
|
|
||||||
|
|
||||||
const set1HasFour = setFilters.set1.some(isFourPieceSet);
|
|
||||||
const set1HasTwo = setFilters.set1.some(isTwoPieceSet);
|
|
||||||
|
|
||||||
if (set1HasFour && !set1HasTwo) {
|
|
||||||
if (!hasAnyMatch(setFilters.set1, 4)) return false;
|
|
||||||
if (!hasAnyMatch(setFilters.set2, 2)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (set1HasTwo) {
|
|
||||||
if (!hasAnyMatch(setFilters.set1, 2)) return false;
|
|
||||||
if (!hasAnyMatch(setFilters.set2, 2)) return false;
|
|
||||||
if (!hasAnyMatch(setFilters.set3, 2)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OptimizerPage() {
|
export default function OptimizerPage() {
|
||||||
@@ -408,6 +117,9 @@ export default function OptimizerPage() {
|
|||||||
const [selectedResult, setSelectedResult] = useState<BuildResult | null>(null);
|
const [selectedResult, setSelectedResult] = useState<BuildResult | null>(null);
|
||||||
const [totalCombos, setTotalCombos] = useState(0);
|
const [totalCombos, setTotalCombos] = useState(0);
|
||||||
const {success, error, info} = useMessage();
|
const {success, error, info} = useMessage();
|
||||||
|
const [bonusByHeroId, setBonusByHeroId] = useState<Record<string, BonusStats>>({});
|
||||||
|
const [bonusEditorOpen, setBonusEditorOpen] = useState(false);
|
||||||
|
const [bonusDraft, setBonusDraft] = useState<BonusStats>(emptyBonusStats);
|
||||||
|
|
||||||
const leftPanelsRef = useRef<HTMLDivElement | null>(null);
|
const leftPanelsRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [leftPanelsHeight, setLeftPanelsHeight] = useState<number | null>(null);
|
const [leftPanelsHeight, setLeftPanelsHeight] = useState<number | null>(null);
|
||||||
@@ -423,24 +135,75 @@ export default function OptimizerPage() {
|
|||||||
const filterCardRef = useRef<HTMLDivElement | null>(null);
|
const filterCardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const rightPanelRef = useRef<HTMLDivElement | null>(null);
|
const rightPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const INGAME_SET_MAP: Record<string, string> = {
|
||||||
|
set_acc: 'HitSet',
|
||||||
|
set_att: 'AttackSet',
|
||||||
|
set_coop: 'UnitySet',
|
||||||
|
set_counter: 'CounterSet',
|
||||||
|
set_cri_dmg: 'DestructionSet',
|
||||||
|
set_cri: 'CriticalSet',
|
||||||
|
set_def: 'DefenseSet',
|
||||||
|
set_immune: 'ImmunitySet',
|
||||||
|
set_max_hp: 'HealthSet',
|
||||||
|
set_penetrate: 'PenetrationSet',
|
||||||
|
set_rage: 'RageSet',
|
||||||
|
set_res: 'ResistSet',
|
||||||
|
set_revenge: 'RevengeSet',
|
||||||
|
set_scar: 'InjurySet',
|
||||||
|
set_speed: 'SpeedSet',
|
||||||
|
set_vampire: 'LifestealSet',
|
||||||
|
set_shield: 'ProtectionSet',
|
||||||
|
set_torrent: 'TorrentSet',
|
||||||
|
set_revenant: 'ReversalSet',
|
||||||
|
set_riposte: 'RiposteSet',
|
||||||
|
set_chase: 'PursuitSet',
|
||||||
|
set_opener: 'WarfareSet',
|
||||||
|
};
|
||||||
|
|
||||||
const setPickerOptions = [
|
const setPickerOptions = [
|
||||||
{key: 'All', label: '全部', setKey: ''},
|
{key: 'All', label: '全部', setKey: ''},
|
||||||
{key: 'AttackSet', label: '攻击套装', setKey: 'AttackSet'},
|
{key: 'AttackSet', label: '攻击套装', setKey: 'AttackSet'},
|
||||||
{key: 'DefenseSet', label: '防御套装', setKey: 'DefenseSet'},
|
{key: 'DefenseSet', label: '防御套装', setKey: 'DefenseSet'},
|
||||||
{key: 'HealthSet', label: '生命套装', setKey: 'HealthSet'},
|
{key: 'HealthSet', label: '生命值套装', setKey: 'HealthSet'},
|
||||||
{key: 'SpeedSet', label: '速度套装', setKey: 'SpeedSet'},
|
{key: 'SpeedSet', label: '速度套装', setKey: 'SpeedSet'},
|
||||||
{key: 'CriticalSet', label: '暴击套装', setKey: 'CriticalSet'},
|
{key: 'CriticalSet', label: '暴击套装', setKey: 'CriticalSet'},
|
||||||
{key: 'HitSet', label: '命中套装', setKey: 'HitSet'},
|
{key: 'HitSet', label: '命中套装', setKey: 'HitSet'},
|
||||||
{key: 'ResistSet', label: '抵抗套装', setKey: 'ResistSet'},
|
{key: 'ResistSet', label: '抵抗套装', setKey: 'ResistSet'},
|
||||||
{key: 'LifestealSet', label: '吸血套装', setKey: 'LifestealSet'},
|
{key: 'LifestealSet', label: '吸血套装', setKey: 'LifestealSet'},
|
||||||
|
{key: 'UnitySet', label: '夹攻套装', setKey: 'UnitySet'},
|
||||||
|
{key: 'RageSet', label: '愤怒套装', setKey: 'RageSet'},
|
||||||
|
{key: 'RevengeSet', label: '憨恨套装', setKey: 'RevengeSet'},
|
||||||
{key: 'DestructionSet', label: '破灭套装', setKey: 'DestructionSet'},
|
{key: 'DestructionSet', label: '破灭套装', setKey: 'DestructionSet'},
|
||||||
{key: 'CounterSet', label: '反击套装', setKey: 'CounterSet'},
|
{key: 'CounterSet', label: '反击套装', setKey: 'CounterSet'},
|
||||||
{key: 'ImmunitySet', label: '免疫套装', setKey: 'ImmunitySet'},
|
{key: 'ImmunitySet', label: '免疫套装', setKey: 'ImmunitySet'},
|
||||||
{key: 'PenetrationSet', label: '穿透套装', setKey: 'PenetrationSet'},
|
{key: 'PenetrationSet', label: '穿透套装', setKey: 'PenetrationSet'},
|
||||||
{key: 'RevengeSet', label: '复仇套装', setKey: 'RevengeSet'},
|
|
||||||
{key: 'InjurySet', label: '伤口套装', setKey: 'InjurySet'},
|
{key: 'InjurySet', label: '伤口套装', setKey: 'InjurySet'},
|
||||||
|
{key: 'ProtectionSet', label: '守护套装', setKey: 'ProtectionSet'},
|
||||||
|
{key: 'TorrentSet', label: '激流套装', setKey: 'TorrentSet'},
|
||||||
|
{key: 'ReversalSet', label: '逆袭套装', setKey: 'ReversalSet'},
|
||||||
|
{key: 'RiposteSet', label: '回击套装', setKey: 'RiposteSet'},
|
||||||
|
{key: 'WarfareSet', label: '开战套装', setKey: 'WarfareSet'},
|
||||||
|
{key: 'PursuitSet', label: '追击套装', setKey: 'PursuitSet'},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const setPickerRows = Math.ceil(setPickerOptions.length / 2);
|
||||||
|
const setPickerListHeight = 28 + setPickerRows * 46 + Math.max(0, setPickerRows - 1) * 8;
|
||||||
|
|
||||||
|
|
||||||
|
const setCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
parsedItems.forEach(item => {
|
||||||
|
const setKey = item.set || (item.f ? INGAME_SET_MAP[item.f as string] : undefined);
|
||||||
|
if (!setKey) return;
|
||||||
|
counts[setKey] = (counts[setKey] || 0) + 1;
|
||||||
|
total += 1;
|
||||||
|
});
|
||||||
|
counts[''] = total;
|
||||||
|
return counts;
|
||||||
|
}, [parsedItems]);
|
||||||
|
|
||||||
|
|
||||||
const getSetLabel = (values: string[]) => {
|
const getSetLabel = (values: string[]) => {
|
||||||
const key = values[0];
|
const key = values[0];
|
||||||
if (!key) return '套装';
|
if (!key) return '套装';
|
||||||
@@ -450,7 +213,7 @@ export default function OptimizerPage() {
|
|||||||
|
|
||||||
const openSetPicker = (target: 'set1' | 'set2' | 'set3', event: React.MouseEvent<HTMLElement>) => {
|
const openSetPicker = (target: 'set1' | 'set2' | 'set3', event: React.MouseEvent<HTMLElement>) => {
|
||||||
const modalWidth = 520;
|
const modalWidth = 520;
|
||||||
const modalHeight = 520;
|
const modalHeight = Math.min(window.innerHeight - 20, setPickerListHeight);
|
||||||
const offset = 0;
|
const offset = 0;
|
||||||
let left = 0;
|
let left = 0;
|
||||||
let top = 0;
|
let top = 0;
|
||||||
@@ -571,18 +334,44 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
return parsedHeroes.find(hero => hero.id === selectedHeroId) || null;
|
return parsedHeroes.find(hero => hero.id === selectedHeroId) || null;
|
||||||
}, [parsedHeroes, selectedHeroId]);
|
}, [parsedHeroes, selectedHeroId]);
|
||||||
|
|
||||||
|
const selectedHeroKey = selectedHeroId == null ? '' : String(selectedHeroId);
|
||||||
|
const selectedHeroBonus = bonusByHeroId[selectedHeroKey] || emptyBonusStats;
|
||||||
|
|
||||||
const loadLatestData = async () => {
|
const loadLatestData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const parsed = await App.GetLatestParsedDataFromDatabase();
|
const [parsed, templates] = await Promise.all([
|
||||||
|
App.GetLatestParsedDataFromDatabase(),
|
||||||
|
App.GetHeroTemplates(),
|
||||||
|
]);
|
||||||
const items = (parsed?.items || []) as RawItem[];
|
const items = (parsed?.items || []) as RawItem[];
|
||||||
const heroes = (parsed?.heroes || []) as RawHero[];
|
const heroTemplates = (templates || []).map(t => ({
|
||||||
|
id: t.code,
|
||||||
|
code: t.code,
|
||||||
|
name: t.name,
|
||||||
|
baseAtk: t.baseAtk,
|
||||||
|
baseDef: t.baseDef,
|
||||||
|
baseHp: t.baseHp,
|
||||||
|
baseSpd: t.baseSpd,
|
||||||
|
baseCr: t.baseCr,
|
||||||
|
baseCd: t.baseCd,
|
||||||
|
baseEff: t.baseEff,
|
||||||
|
baseRes: t.baseRes,
|
||||||
|
atk: t.baseAtk,
|
||||||
|
def: t.baseDef,
|
||||||
|
hp: t.baseHp,
|
||||||
|
spd: t.baseSpd,
|
||||||
|
cr: t.baseCr,
|
||||||
|
cd: t.baseCd,
|
||||||
|
eff: t.baseEff,
|
||||||
|
res: t.baseRes,
|
||||||
|
})) as RawHero[];
|
||||||
setParsedItems(items);
|
setParsedItems(items);
|
||||||
setParsedHeroes(heroes);
|
setParsedHeroes(heroTemplates);
|
||||||
if (heroes.length > 0) {
|
if (heroTemplates.length > 0) {
|
||||||
setSelectedHeroId(heroes[0].id || '');
|
setSelectedHeroId(heroTemplates[0].id || '');
|
||||||
}
|
}
|
||||||
if (items.length === 0 && heroes.length === 0) {
|
if (items.length === 0 && heroTemplates.length === 0) {
|
||||||
info('暂无解析数据');
|
info('暂无解析数据');
|
||||||
} else {
|
} else {
|
||||||
success('数据加载成功');
|
success('数据加载成功');
|
||||||
@@ -650,108 +439,51 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
setTotalCombos(0);
|
setTotalCombos(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildResults = () => {
|
const buildResults = async () => {
|
||||||
|
console.log('OptimizerPage buildResults clicked');
|
||||||
if (!selectedHero) {
|
if (!selectedHero) {
|
||||||
info('请先选择英雄');
|
info('\u8bf7\u5148\u9009\u62e9\u82f1\u96c4');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsedItems.length === 0) {
|
if (parsedItems.length === 0) {
|
||||||
info('暂无装备数据');
|
info('\u6682\u65e0\u88c5\u5907\u6570\u636e');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const baseStats = buildBaseStats(selectedHero);
|
console.log('OptimizeBuilds calling', {
|
||||||
const items = parsedItems;
|
heroId: String(selectedHero.id ?? ''),
|
||||||
|
setFilters,
|
||||||
const itemsByGear = new Map<string, RawItem[]>();
|
statFilters,
|
||||||
GEAR_SLOTS.forEach(slot => itemsByGear.set(slot, []));
|
mainStatFilters,
|
||||||
items.forEach(item => {
|
weightValues,
|
||||||
if (item.gear && itemsByGear.has(item.gear)) {
|
bonusStats: selectedHeroBonus,
|
||||||
itemsByGear.get(item.gear)!.push(item);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
const resp = await App.OptimizeBuilds({
|
||||||
const statsCache = new Map<RawItem, ItemStats>();
|
heroId: String(selectedHero.code ?? ''),
|
||||||
const topItemsByGear: RawItem[][] = [];
|
setFilters,
|
||||||
|
statFilters,
|
||||||
for (const slot of GEAR_SLOTS) {
|
mainStatFilters,
|
||||||
const slotItems = itemsByGear.get(slot) || [];
|
weightValues,
|
||||||
const scored = slotItems.map(item => {
|
bonusStats: selectedHeroBonus,
|
||||||
const stats = buildItemStats(item);
|
maxItemsPerSlot: MAX_ITEMS_PER_SLOT,
|
||||||
statsCache.set(item, stats);
|
maxCombos: MAX_COMBOS,
|
||||||
return {
|
maxResults: MAX_RESULTS,
|
||||||
item,
|
} as any);
|
||||||
score: scoreStats(stats, baseStats),
|
console.log('OptimizeBuilds returned', resp);
|
||||||
};
|
const nextResults = (resp?.results || []) as BuildResult[];
|
||||||
});
|
setTotalCombos(resp?.totalCombos || 0);
|
||||||
scored.sort((a, b) => b.score - a.score);
|
setResults(nextResults);
|
||||||
topItemsByGear.push(scored.slice(0, MAX_ITEMS_PER_SLOT).map(x => x.item));
|
setSelectedResult(nextResults[0] || null);
|
||||||
|
if (nextResults.length > 0) {
|
||||||
|
success(`\u914d\u88c5\u5b8c\u6210\uff0c\u5171 ${nextResults.length} \u6761\u7ed3\u679c`);
|
||||||
}
|
}
|
||||||
|
if (nextResults.length === 0) {
|
||||||
const combos = topItemsByGear.reduce((acc, list) => acc * Math.max(list.length, 1), 1);
|
info('\u6ca1\u6709\u7b26\u5408\u6761\u4ef6\u7684\u914d\u88c5\u7ed3\u679c');
|
||||||
setTotalCombos(combos);
|
|
||||||
|
|
||||||
const resultsBuffer: BuildResult[] = [];
|
|
||||||
let comboCount = 0;
|
|
||||||
|
|
||||||
const [weapons, helmets, armors, necklaces, rings, boots] = topItemsByGear;
|
|
||||||
for (const w of weapons) {
|
|
||||||
for (const h of helmets) {
|
|
||||||
for (const a of armors) {
|
|
||||||
for (const n of necklaces) {
|
|
||||||
for (const r of rings) {
|
|
||||||
for (const b of boots) {
|
|
||||||
comboCount += 1;
|
|
||||||
if (comboCount > MAX_COMBOS) break;
|
|
||||||
const itemsList = [w, h, a, n, r, b];
|
|
||||||
if (!passesSetFilter(itemsList, setFilters)) continue;
|
|
||||||
const statsList = itemsList.map(item => statsCache.get(item) || buildItemStats(item));
|
|
||||||
const totals = buildTotalStats(statsList, baseStats);
|
|
||||||
if (!passesFilters(totals, statFilters)) continue;
|
|
||||||
|
|
||||||
const completedSets = getCompletedSets(itemsList);
|
|
||||||
const setNames = completedSets.length > 0 ? completedSets.join('/') : '无套装';
|
|
||||||
|
|
||||||
const score =
|
|
||||||
totals.spd * 2 +
|
|
||||||
totals.cr +
|
|
||||||
totals.cd +
|
|
||||||
totals.acc +
|
|
||||||
totals.res +
|
|
||||||
(totals.atk + totals.def + totals.hp) / 100;
|
|
||||||
|
|
||||||
resultsBuffer.push({
|
|
||||||
key: itemsList.map(item => item.id).join('-'),
|
|
||||||
sets: setNames || '未知套装',
|
|
||||||
atk: Math.round(totals.atk),
|
|
||||||
def: Math.round(totals.def),
|
|
||||||
hp: Math.round(totals.hp),
|
|
||||||
spd: Math.round(totals.spd),
|
|
||||||
cr: Math.round(totals.cr),
|
|
||||||
cd: Math.round(totals.cd),
|
|
||||||
acc: Math.round(totals.acc),
|
|
||||||
res: Math.round(totals.res),
|
|
||||||
score,
|
|
||||||
items: itemsList,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (comboCount > MAX_COMBOS) break;
|
} catch (err) {
|
||||||
}
|
const msg = err instanceof Error ? err.message : '\u914d\u88c5\u5931\u8d25';
|
||||||
if (comboCount > MAX_COMBOS) break;
|
error(msg);
|
||||||
}
|
console.error('OptimizeBuilds error:', err);
|
||||||
if (comboCount > MAX_COMBOS) break;
|
|
||||||
}
|
|
||||||
if (comboCount > MAX_COMBOS) break;
|
|
||||||
}
|
|
||||||
if (comboCount > MAX_COMBOS) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsBuffer.sort((a, b) => b.score - a.score);
|
|
||||||
const finalResults = resultsBuffer.slice(0, MAX_RESULTS);
|
|
||||||
setResults(finalResults);
|
|
||||||
setSelectedResult(finalResults[0] || null);
|
|
||||||
if (finalResults.length === 0) {
|
|
||||||
info('没有符合条件的配装结果');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -784,6 +516,16 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
options={heroOptions}
|
options={heroOptions}
|
||||||
placeholder="请选择英雄"
|
placeholder="请选择英雄"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedHeroKey) return;
|
||||||
|
setBonusDraft({...selectedHeroBonus});
|
||||||
|
setBonusEditorOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{'\u7f16\u8f91\u989d\u5916\u52a0\u6210'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
@@ -900,8 +642,9 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="optimizer-weight-panel" style={leftPanelsHeight ? {height: leftPanelsHeight} : undefined}>
|
<div className="optimizer-weight-panel" style={leftPanelsHeight ? {height: leftPanelsHeight} : undefined}>
|
||||||
<div className="optimizer-weight-row optimizer-weight-row-no-label">
|
<div className="optimizer-weight-row">
|
||||||
<span className="optimizer-weight-icon">攻</span>
|
<span className="optimizer-weight-icon">攻</span>
|
||||||
|
<span className="optimizer-weight-label">攻击</span>
|
||||||
<Slider
|
<Slider
|
||||||
min={0}
|
min={0}
|
||||||
max={5}
|
max={5}
|
||||||
@@ -1077,6 +820,75 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
</Card>
|
</Card>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
<Modal
|
||||||
|
open={bonusEditorOpen}
|
||||||
|
title={'\u989d\u5916\u52a0\u6210'}
|
||||||
|
onCancel={() => setBonusEditorOpen(false)}
|
||||||
|
onOk={() => {
|
||||||
|
if (!selectedHeroKey) return;
|
||||||
|
setBonusByHeroId(prev => ({...prev, [selectedHeroKey]: bonusDraft}));
|
||||||
|
setBonusEditorOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12}}>
|
||||||
|
<div>
|
||||||
|
<div>{'\u653b\u51fb'}</div>
|
||||||
|
<InputNumber value={bonusDraft.atk} onChange={v => setBonusDraft(prev => ({...prev, atk: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u653b\u51fb%'}</div>
|
||||||
|
<InputNumber value={bonusDraft.atkPct} onChange={v => setBonusDraft(prev => ({...prev, atkPct: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u9632\u5fa1'}</div>
|
||||||
|
<InputNumber value={bonusDraft.def} onChange={v => setBonusDraft(prev => ({...prev, def: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u9632\u5fa1%'}</div>
|
||||||
|
<InputNumber value={bonusDraft.defPct} onChange={v => setBonusDraft(prev => ({...prev, defPct: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u751f\u547d'}</div>
|
||||||
|
<InputNumber value={bonusDraft.hp} onChange={v => setBonusDraft(prev => ({...prev, hp: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u751f\u547d%'}</div>
|
||||||
|
<InputNumber value={bonusDraft.hpPct} onChange={v => setBonusDraft(prev => ({...prev, hpPct: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u901f\u5ea6'}</div>
|
||||||
|
<InputNumber value={bonusDraft.spd} onChange={v => setBonusDraft(prev => ({...prev, spd: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u66b4\u51fb\u7387'}</div>
|
||||||
|
<InputNumber value={bonusDraft.cr} onChange={v => setBonusDraft(prev => ({...prev, cr: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u66b4\u51fb\u4f24\u5bb3'}</div>
|
||||||
|
<InputNumber value={bonusDraft.cd} onChange={v => setBonusDraft(prev => ({...prev, cd: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u6548\u679c\u547d\u4e2d'}</div>
|
||||||
|
<InputNumber value={bonusDraft.eff} onChange={v => setBonusDraft(prev => ({...prev, eff: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u6548\u679c\u6297\u6027'}</div>
|
||||||
|
<InputNumber value={bonusDraft.res} onChange={v => setBonusDraft(prev => ({...prev, res: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u6700\u7ec8\u653b\u51fb\u500d\u7387%'}</div>
|
||||||
|
<InputNumber value={bonusDraft.finalAtkMultiplier} onChange={v => setBonusDraft(prev => ({...prev, finalAtkMultiplier: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u6700\u7ec8\u9632\u5fa1\u500d\u7387%'}</div>
|
||||||
|
<InputNumber value={bonusDraft.finalDefMultiplier} onChange={v => setBonusDraft(prev => ({...prev, finalDefMultiplier: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>{'\u6700\u7ec8\u751f\u547d\u500d\u7387%'}</div>
|
||||||
|
<InputNumber value={bonusDraft.finalHpMultiplier} onChange={v => setBonusDraft(prev => ({...prev, finalHpMultiplier: Number(v || 0)}))} style={{width: '100%'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
{setPickerOpen &&
|
{setPickerOpen &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -1088,7 +900,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
style={{top: setPickerPos.top, left: setPickerPos.left}}
|
style={{top: setPickerPos.top, left: setPickerPos.left}}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="optimizer-set-modal-list">
|
<div className="optimizer-set-modal-list" style={{maxHeight: setPickerListHeight}}>
|
||||||
{setPickerOptions.map(option => (
|
{setPickerOptions.map(option => (
|
||||||
<button
|
<button
|
||||||
key={option.key}
|
key={option.key}
|
||||||
@@ -1121,7 +933,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
>
|
>
|
||||||
<span className="optimizer-set-modal-icon">◎</span>
|
<span className="optimizer-set-modal-icon">◎</span>
|
||||||
<span className="optimizer-set-modal-label">{option.label}</span>
|
<span className="optimizer-set-modal-label">{option.label}</span>
|
||||||
<span className="optimizer-set-modal-count">{Math.floor(Math.random() * 200)}</span>
|
<span className="optimizer-set-modal-count">{setCounts[option.setKey] || 0}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
export namespace model {
|
export namespace model {
|
||||||
|
|
||||||
|
export class BonusStats {
|
||||||
|
atk: number;
|
||||||
|
def: number;
|
||||||
|
hp: number;
|
||||||
|
atkPct: number;
|
||||||
|
defPct: number;
|
||||||
|
hpPct: number;
|
||||||
|
spd: number;
|
||||||
|
cr: number;
|
||||||
|
cd: number;
|
||||||
|
eff: number;
|
||||||
|
res: number;
|
||||||
|
finalAtkMultiplier: number;
|
||||||
|
finalDefMultiplier: number;
|
||||||
|
finalHpMultiplier: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BonusStats(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.atk = source["atk"];
|
||||||
|
this.def = source["def"];
|
||||||
|
this.hp = source["hp"];
|
||||||
|
this.atkPct = source["atkPct"];
|
||||||
|
this.defPct = source["defPct"];
|
||||||
|
this.hpPct = source["hpPct"];
|
||||||
|
this.spd = source["spd"];
|
||||||
|
this.cr = source["cr"];
|
||||||
|
this.cd = source["cd"];
|
||||||
|
this.eff = source["eff"];
|
||||||
|
this.res = source["res"];
|
||||||
|
this.finalAtkMultiplier = source["finalAtkMultiplier"];
|
||||||
|
this.finalDefMultiplier = source["finalDefMultiplier"];
|
||||||
|
this.finalHpMultiplier = source["finalHpMultiplier"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class CaptureStatus {
|
export class CaptureStatus {
|
||||||
is_capturing: boolean;
|
is_capturing: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -16,6 +54,36 @@ export namespace model {
|
|||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class HeroTemplate {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
baseAtk: number;
|
||||||
|
baseDef: number;
|
||||||
|
baseHp: number;
|
||||||
|
baseSpd: number;
|
||||||
|
baseCr: number;
|
||||||
|
baseCd: number;
|
||||||
|
baseEff: number;
|
||||||
|
baseRes: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new HeroTemplate(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.baseAtk = source["baseAtk"];
|
||||||
|
this.baseDef = source["baseDef"];
|
||||||
|
this.baseHp = source["baseHp"];
|
||||||
|
this.baseSpd = source["baseSpd"];
|
||||||
|
this.baseCr = source["baseCr"];
|
||||||
|
this.baseCd = source["baseCd"];
|
||||||
|
this.baseEff = source["baseEff"];
|
||||||
|
this.baseRes = source["baseRes"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class NetworkInterface {
|
export class NetworkInterface {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -34,6 +102,222 @@ export namespace model {
|
|||||||
this.is_loopback = source["is_loopback"];
|
this.is_loopback = source["is_loopback"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class OptimizeStat {
|
||||||
|
type: string;
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OptimizeStat(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.type = source["type"];
|
||||||
|
this.value = source["value"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class OptimizeItem {
|
||||||
|
id: any;
|
||||||
|
gear: string;
|
||||||
|
set: string;
|
||||||
|
f: string;
|
||||||
|
main?: OptimizeStat;
|
||||||
|
substats?: OptimizeStat[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OptimizeItem(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.gear = source["gear"];
|
||||||
|
this.set = source["set"];
|
||||||
|
this.f = source["f"];
|
||||||
|
this.main = this.convertValues(source["main"], OptimizeStat);
|
||||||
|
this.substats = this.convertValues(source["substats"], OptimizeStat);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class StatRange {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StatRange(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.min = source["min"];
|
||||||
|
this.max = source["max"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SetFilters {
|
||||||
|
set1: string[];
|
||||||
|
set2: string[];
|
||||||
|
set3: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SetFilters(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.set1 = source["set1"];
|
||||||
|
this.set2 = source["set2"];
|
||||||
|
this.set3 = source["set3"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class OptimizeRequest {
|
||||||
|
heroId: string;
|
||||||
|
setFilters: SetFilters;
|
||||||
|
statFilters: Record<string, StatRange>;
|
||||||
|
mainStatFilters: Record<string, string>;
|
||||||
|
weightValues: Record<string, number>;
|
||||||
|
bonusStats: BonusStats;
|
||||||
|
maxItemsPerSlot: number;
|
||||||
|
maxCombos: number;
|
||||||
|
maxResults: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OptimizeRequest(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.heroId = source["heroId"];
|
||||||
|
this.setFilters = this.convertValues(source["setFilters"], SetFilters);
|
||||||
|
this.statFilters = this.convertValues(source["statFilters"], StatRange, true);
|
||||||
|
this.mainStatFilters = source["mainStatFilters"];
|
||||||
|
this.weightValues = source["weightValues"];
|
||||||
|
this.bonusStats = this.convertValues(source["bonusStats"], BonusStats);
|
||||||
|
this.maxItemsPerSlot = source["maxItemsPerSlot"];
|
||||||
|
this.maxCombos = source["maxCombos"];
|
||||||
|
this.maxResults = source["maxResults"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class OptimizeResult {
|
||||||
|
key: string;
|
||||||
|
sets: string;
|
||||||
|
atk: number;
|
||||||
|
def: number;
|
||||||
|
hp: number;
|
||||||
|
spd: number;
|
||||||
|
cr: number;
|
||||||
|
cd: number;
|
||||||
|
acc: number;
|
||||||
|
res: number;
|
||||||
|
score: number;
|
||||||
|
items: OptimizeItem[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OptimizeResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.key = source["key"];
|
||||||
|
this.sets = source["sets"];
|
||||||
|
this.atk = source["atk"];
|
||||||
|
this.def = source["def"];
|
||||||
|
this.hp = source["hp"];
|
||||||
|
this.spd = source["spd"];
|
||||||
|
this.cr = source["cr"];
|
||||||
|
this.cd = source["cd"];
|
||||||
|
this.acc = source["acc"];
|
||||||
|
this.res = source["res"];
|
||||||
|
this.score = source["score"];
|
||||||
|
this.items = this.convertValues(source["items"], OptimizeItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class OptimizeResponse {
|
||||||
|
totalCombos: number;
|
||||||
|
results: OptimizeResult[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OptimizeResponse(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.totalCombos = source["totalCombos"];
|
||||||
|
this.results = this.convertValues(source["results"], OptimizeResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export class ParsedResult {
|
export class ParsedResult {
|
||||||
items: any[];
|
items: any[];
|
||||||
heroes: any[];
|
heroes: any[];
|
||||||
@@ -65,5 +349,6 @@ export namespace model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
frontend/wailsjs/go/service/App.d.ts
vendored
4
frontend/wailsjs/go/service/App.d.ts
vendored
@@ -18,6 +18,8 @@ export function GetCapturedData():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function GetCurrentDataForExport():Promise<string>;
|
export function GetCurrentDataForExport():Promise<string>;
|
||||||
|
|
||||||
|
export function GetHeroTemplates():Promise<Array<model.HeroTemplate>>;
|
||||||
|
|
||||||
export function GetLatestParsedDataFromDatabase():Promise<model.ParsedResult>;
|
export function GetLatestParsedDataFromDatabase():Promise<model.ParsedResult>;
|
||||||
|
|
||||||
export function GetNetworkInterfaces():Promise<Array<model.NetworkInterface>>;
|
export function GetNetworkInterfaces():Promise<Array<model.NetworkInterface>>;
|
||||||
@@ -26,6 +28,8 @@ export function GetParsedDataByID(arg1:number):Promise<model.ParsedResult>;
|
|||||||
|
|
||||||
export function GetParsedSessions():Promise<Array<model.ParsedSession>>;
|
export function GetParsedSessions():Promise<Array<model.ParsedSession>>;
|
||||||
|
|
||||||
|
export function OptimizeBuilds(arg1:model.OptimizeRequest):Promise<model.OptimizeResponse>;
|
||||||
|
|
||||||
export function ParseData(arg1:Array<string>):Promise<string>;
|
export function ParseData(arg1:Array<string>):Promise<string>;
|
||||||
|
|
||||||
export function ReadRawJsonFile():Promise<model.ParsedResult>;
|
export function ReadRawJsonFile():Promise<model.ParsedResult>;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export function GetCurrentDataForExport() {
|
|||||||
return window['go']['service']['App']['GetCurrentDataForExport']();
|
return window['go']['service']['App']['GetCurrentDataForExport']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetHeroTemplates() {
|
||||||
|
return window['go']['service']['App']['GetHeroTemplates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLatestParsedDataFromDatabase() {
|
export function GetLatestParsedDataFromDatabase() {
|
||||||
return window['go']['service']['App']['GetLatestParsedDataFromDatabase']();
|
return window['go']['service']['App']['GetLatestParsedDataFromDatabase']();
|
||||||
}
|
}
|
||||||
@@ -50,6 +54,10 @@ export function GetParsedSessions() {
|
|||||||
return window['go']['service']['App']['GetParsedSessions']();
|
return window['go']['service']['App']['GetParsedSessions']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OptimizeBuilds(arg1) {
|
||||||
|
return window['go']['service']['App']['OptimizeBuilds'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ParseData(arg1) {
|
export function ParseData(arg1) {
|
||||||
return window['go']['service']['App']['ParseData'](arg1);
|
return window['go']['service']['App']['ParseData'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
15
internal/model/hero_template.go
Normal file
15
internal/model/hero_template.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// HeroTemplate represents template hero data from herodata.json.
|
||||||
|
type HeroTemplate struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseAtk float64 `json:"baseAtk"`
|
||||||
|
BaseDef float64 `json:"baseDef"`
|
||||||
|
BaseHp float64 `json:"baseHp"`
|
||||||
|
BaseSpd float64 `json:"baseSpd"`
|
||||||
|
BaseCr float64 `json:"baseCr"`
|
||||||
|
BaseCd float64 `json:"baseCd"`
|
||||||
|
BaseEff float64 `json:"baseEff"`
|
||||||
|
BaseRes float64 `json:"baseRes"`
|
||||||
|
}
|
||||||
106
internal/model/optimizer.go
Normal file
106
internal/model/optimizer.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// OptimizeStat represents a stat type/value pair.
|
||||||
|
type OptimizeStat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeItem represents the minimal item shape needed by the optimizer.
|
||||||
|
type OptimizeItem struct {
|
||||||
|
ID interface{} `json:"id"`
|
||||||
|
Gear string `json:"gear"`
|
||||||
|
Set string `json:"set"`
|
||||||
|
F string `json:"f"`
|
||||||
|
Main *OptimizeStat `json:"main,omitempty"`
|
||||||
|
Substats []OptimizeStat `json:"substats,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeHero represents the minimal hero shape needed by the optimizer.
|
||||||
|
type OptimizeHero struct {
|
||||||
|
ID interface{} `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Atk float64 `json:"atk"`
|
||||||
|
Def float64 `json:"def"`
|
||||||
|
Hp float64 `json:"hp"`
|
||||||
|
Spd float64 `json:"spd"`
|
||||||
|
Cr float64 `json:"cr"`
|
||||||
|
Cd float64 `json:"cd"`
|
||||||
|
Eff float64 `json:"eff"`
|
||||||
|
Res float64 `json:"res"`
|
||||||
|
BaseAtk float64 `json:"baseAtk"`
|
||||||
|
BaseDef float64 `json:"baseDef"`
|
||||||
|
BaseHp float64 `json:"baseHp"`
|
||||||
|
BaseSpd float64 `json:"baseSpd"`
|
||||||
|
BaseCr float64 `json:"baseCr"`
|
||||||
|
BaseCd float64 `json:"baseCd"`
|
||||||
|
BaseEff float64 `json:"baseEff"`
|
||||||
|
BaseRes float64 `json:"baseRes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatRange defines a min/max filter.
|
||||||
|
type StatRange struct {
|
||||||
|
Min *float64 `json:"min,omitempty"`
|
||||||
|
Max *float64 `json:"max,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFilters defines set filter groups.
|
||||||
|
type SetFilters struct {
|
||||||
|
Set1 []string `json:"set1"`
|
||||||
|
Set2 []string `json:"set2"`
|
||||||
|
Set3 []string `json:"set3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BonusStats are hero-specific bonus modifiers.
|
||||||
|
type BonusStats struct {
|
||||||
|
Atk float64 `json:"atk"`
|
||||||
|
Def float64 `json:"def"`
|
||||||
|
Hp float64 `json:"hp"`
|
||||||
|
AtkPct float64 `json:"atkPct"`
|
||||||
|
DefPct float64 `json:"defPct"`
|
||||||
|
HpPct float64 `json:"hpPct"`
|
||||||
|
Spd float64 `json:"spd"`
|
||||||
|
Cr float64 `json:"cr"`
|
||||||
|
Cd float64 `json:"cd"`
|
||||||
|
Eff float64 `json:"eff"`
|
||||||
|
Res float64 `json:"res"`
|
||||||
|
FinalAtkMultiplier float64 `json:"finalAtkMultiplier"`
|
||||||
|
FinalDefMultiplier float64 `json:"finalDefMultiplier"`
|
||||||
|
FinalHpMultiplier float64 `json:"finalHpMultiplier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeRequest represents optimizer input.
|
||||||
|
type OptimizeRequest struct {
|
||||||
|
HeroID string `json:"heroId"`
|
||||||
|
SetFilters SetFilters `json:"setFilters"`
|
||||||
|
StatFilters map[string]StatRange `json:"statFilters"`
|
||||||
|
MainStatFilters map[string]string `json:"mainStatFilters"`
|
||||||
|
WeightValues map[string]float64 `json:"weightValues"`
|
||||||
|
BonusStats BonusStats `json:"bonusStats"`
|
||||||
|
MaxItemsPerSlot int `json:"maxItemsPerSlot"`
|
||||||
|
MaxCombos int `json:"maxCombos"`
|
||||||
|
MaxResults int `json:"maxResults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeResult is a single build result.
|
||||||
|
type OptimizeResult struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Sets string `json:"sets"`
|
||||||
|
Atk float64 `json:"atk"`
|
||||||
|
Def float64 `json:"def"`
|
||||||
|
Hp float64 `json:"hp"`
|
||||||
|
Spd float64 `json:"spd"`
|
||||||
|
Cr float64 `json:"cr"`
|
||||||
|
Cd float64 `json:"cd"`
|
||||||
|
Acc float64 `json:"acc"`
|
||||||
|
Res float64 `json:"res"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Items []OptimizeItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeResponse is the optimizer output.
|
||||||
|
type OptimizeResponse struct {
|
||||||
|
TotalCombos int `json:"totalCombos"`
|
||||||
|
Results []OptimizeResult `json:"results"`
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"equipment-analyzer/internal/capture"
|
"equipment-analyzer/internal/capture"
|
||||||
"equipment-analyzer/internal/config"
|
"equipment-analyzer/internal/config"
|
||||||
"equipment-analyzer/internal/model"
|
"equipment-analyzer/internal/model"
|
||||||
|
"equipment-analyzer/internal/optimizer"
|
||||||
"equipment-analyzer/internal/utils"
|
"equipment-analyzer/internal/utils"
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
@@ -83,6 +84,71 @@ func (a *App) Shutdown(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeBuilds runs the optimizer on latest parsed data.
|
||||||
|
func (a *App) OptimizeBuilds(req model.OptimizeRequest) (*model.OptimizeResponse, error) {
|
||||||
|
log.Printf("[service] OptimizeBuilds entry heroId=%s", req.HeroID)
|
||||||
|
parsedResult, err := a.GetLatestParsedDataFromDatabase()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[service] OptimizeBuilds get data failed: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
|
||||||
|
log.Printf("[service] OptimizeBuilds no data items=%d heroes=%d", len(parsedResult.Items), len(parsedResult.Heroes))
|
||||||
|
return &model.OptimizeResponse{TotalCombos: 0, Results: []model.OptimizeResult{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := optimizer.ParseItems(parsedResult.Items)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[service] OptimizeBuilds parse items failed: %v", err)
|
||||||
|
return nil, fmt.Errorf("parse items failed: %w", err)
|
||||||
|
}
|
||||||
|
logMissingSetStats(parsedResult.Items)
|
||||||
|
heroes, err := optimizer.ParseHeroes(parsedResult.Heroes)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[service] OptimizeBuilds parse heroes failed: %v", err)
|
||||||
|
return nil, fmt.Errorf("parse heroes failed: %w", err)
|
||||||
|
}
|
||||||
|
templates, err := a.parserService.GetHeroTemplates()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[service] OptimizeBuilds hero templates missing: %v", err)
|
||||||
|
return nil, fmt.Errorf("未读取到英雄信息数据")
|
||||||
|
}
|
||||||
|
templateHeroes := make([]model.OptimizeHero, 0, len(templates))
|
||||||
|
for _, t := range templates {
|
||||||
|
templateHeroes = append(templateHeroes, model.OptimizeHero{
|
||||||
|
Code: t.Code,
|
||||||
|
Name: t.Name,
|
||||||
|
BaseAtk: t.BaseAtk,
|
||||||
|
BaseDef: t.BaseDef,
|
||||||
|
BaseHp: t.BaseHp,
|
||||||
|
BaseSpd: t.BaseSpd,
|
||||||
|
BaseCr: t.BaseCr,
|
||||||
|
BaseCd: t.BaseCd,
|
||||||
|
BaseEff: t.BaseEff,
|
||||||
|
BaseRes: t.BaseRes,
|
||||||
|
Atk: t.BaseAtk,
|
||||||
|
Def: t.BaseDef,
|
||||||
|
Hp: t.BaseHp,
|
||||||
|
Spd: t.BaseSpd,
|
||||||
|
Cr: t.BaseCr,
|
||||||
|
Cd: t.BaseCd,
|
||||||
|
Eff: t.BaseEff,
|
||||||
|
Res: t.BaseRes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Use template heroes for optimizer to match the template-based selection.
|
||||||
|
heroes = templateHeroes
|
||||||
|
|
||||||
|
log.Printf("[service] OptimizeBuilds parsed items=%d heroes=%d", len(items), len(heroes))
|
||||||
|
resp, err := optimizer.Optimize(items, heroes, req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[service] OptimizeBuilds optimize failed: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Printf("[service] OptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetNetworkInterfaces returns available network interfaces.
|
// GetNetworkInterfaces returns available network interfaces.
|
||||||
func (a *App) GetNetworkInterfaces() ([]model.NetworkInterface, error) {
|
func (a *App) GetNetworkInterfaces() ([]model.NetworkInterface, error) {
|
||||||
interfaces, err := capture.GetNetworkInterfaces()
|
interfaces, err := capture.GetNetworkInterfaces()
|
||||||
@@ -93,6 +159,11 @@ func (a *App) GetNetworkInterfaces() ([]model.NetworkInterface, error) {
|
|||||||
return interfaces, nil
|
return interfaces, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHeroTemplates returns template heroes from local herodata.json.
|
||||||
|
func (a *App) GetHeroTemplates() ([]model.HeroTemplate, error) {
|
||||||
|
return a.parserService.GetHeroTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
// StartCapture starts capture on the given interface.
|
// StartCapture starts capture on the given interface.
|
||||||
func (a *App) StartCapture(interfaceName string) error {
|
func (a *App) StartCapture(interfaceName string) error {
|
||||||
if a.captureService.IsCapturing() {
|
if a.captureService.IsCapturing() {
|
||||||
@@ -329,6 +400,7 @@ func (a *App) GetLatestParsedDataFromDatabase() (*model.ParsedResult, error) {
|
|||||||
return nil, fmt.Errorf("failed to unmarshal items: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal items: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logMissingSetStats(items)
|
||||||
|
|
||||||
var heroes []interface{}
|
var heroes []interface{}
|
||||||
if heroesJSON != "" {
|
if heroesJSON != "" {
|
||||||
@@ -424,6 +496,95 @@ func (a *App) GetAllAppSettings() (map[string]string, error) {
|
|||||||
return a.databaseService.GetAllAppSettings()
|
return a.databaseService.GetAllAppSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logMissingSetStats(items []interface{}) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
var emptyF int
|
||||||
|
var emptySet int
|
||||||
|
var healthF int
|
||||||
|
var healthSet int
|
||||||
|
var mapHp int
|
||||||
|
setCounts := map[string]int{}
|
||||||
|
uncategorized := 0
|
||||||
|
emptySetByF := map[string]int{}
|
||||||
|
emptySetByGear := map[string]int{}
|
||||||
|
ingameSetMap := map[string]string{
|
||||||
|
"set_acc": "HitSet",
|
||||||
|
"set_att": "AttackSet",
|
||||||
|
"set_coop": "UnitySet",
|
||||||
|
"set_counter": "CounterSet",
|
||||||
|
"set_cri_dmg": "DestructionSet",
|
||||||
|
"set_cri": "CriticalSet",
|
||||||
|
"set_def": "DefenseSet",
|
||||||
|
"set_immune": "ImmunitySet",
|
||||||
|
"set_max_hp": "HealthSet",
|
||||||
|
"set_penetrate": "PenetrationSet",
|
||||||
|
"set_rage": "RageSet",
|
||||||
|
"set_res": "ResistSet",
|
||||||
|
"set_revenge": "RevengeSet",
|
||||||
|
"set_scar": "InjurySet",
|
||||||
|
"set_speed": "SpeedSet",
|
||||||
|
"set_vampire": "LifestealSet",
|
||||||
|
"set_shield": "ProtectionSet",
|
||||||
|
"set_torrent": "TorrentSet",
|
||||||
|
"set_revenant": "ReversalSet",
|
||||||
|
"set_riposte": "RiposteSet",
|
||||||
|
"set_chase": "PursuitSet",
|
||||||
|
"set_opener": "WarfareSet",
|
||||||
|
}
|
||||||
|
for _, raw := range items {
|
||||||
|
item, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
if f, ok := item["f"]; !ok || f == nil || f == "" {
|
||||||
|
emptyF++
|
||||||
|
} else {
|
||||||
|
if f == "set_max_hp" {
|
||||||
|
healthF++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setValue, _ := item["set"].(string)
|
||||||
|
if setValue == "" {
|
||||||
|
emptySet++
|
||||||
|
fValue, _ := item["f"].(string)
|
||||||
|
if fValue != "" {
|
||||||
|
emptySetByF[fValue] = emptySetByF[fValue] + 1
|
||||||
|
}
|
||||||
|
if gear, ok := item["gear"].(string); ok && gear != "" {
|
||||||
|
emptySetByGear[gear] = emptySetByGear[gear] + 1
|
||||||
|
}
|
||||||
|
if fValue != "" {
|
||||||
|
if mapped, ok := ingameSetMap[fValue]; ok {
|
||||||
|
setCounts[mapped] = setCounts[mapped] + 1
|
||||||
|
} else {
|
||||||
|
uncategorized++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uncategorized++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCounts[setValue] = setCounts[setValue] + 1
|
||||||
|
}
|
||||||
|
if set, ok := item["set"].(string); ok && set == "HealthSet" {
|
||||||
|
healthSet++
|
||||||
|
}
|
||||||
|
if f, ok := item["f"]; ok && f == "set_max_hp" {
|
||||||
|
mapHp++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[service] set stats: total=%d empty_f=%d empty_set=%d set_max_hp=%d HealthSet=%d mapped_hp=%d", total, emptyF, emptySet, healthF, healthSet, mapHp)
|
||||||
|
log.Printf("[service] set counts: %+v", setCounts)
|
||||||
|
log.Printf("[service] uncategorized items: %d", uncategorized)
|
||||||
|
if emptySet > 0 {
|
||||||
|
log.Printf("[service] empty set by f: %+v", emptySetByF)
|
||||||
|
log.Printf("[service] empty set by gear: %+v", emptySetByGear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// StartCaptureWithFilter allows frontend to provide a custom BPF filter.
|
// StartCaptureWithFilter allows frontend to provide a custom BPF filter.
|
||||||
func (a *App) StartCaptureWithFilter(interfaceName string, filter string) error {
|
func (a *App) StartCaptureWithFilter(interfaceName string, filter string) error {
|
||||||
a.logger.Info("StartCaptureWithFilter requested", "interface", interfaceName, "filter", filter)
|
a.logger.Info("StartCaptureWithFilter requested", "interface", interfaceName, "filter", filter)
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +23,9 @@ type ParserService struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
logger *utils.Logger
|
logger *utils.Logger
|
||||||
hexParser *parser.HexParser
|
hexParser *parser.HexParser
|
||||||
|
heroBase map[string]heroBaseStats
|
||||||
|
heroOnce sync.Once
|
||||||
|
heroErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewParserService(cfg *config.Config, logger *utils.Logger) *ParserService {
|
func NewParserService(cfg *config.Config, logger *utils.Logger) *ParserService {
|
||||||
@@ -26,6 +33,7 @@ func NewParserService(cfg *config.Config, logger *utils.Logger) *ParserService {
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
hexParser: parser.NewHexParser(),
|
hexParser: parser.NewHexParser(),
|
||||||
|
heroBase: make(map[string]heroBaseStats),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +215,8 @@ func (ps *ParserService) convertSingleItem(item map[string]interface{}) map[stri
|
|||||||
// convertUnits 转换英雄数据
|
// convertUnits 转换英雄数据
|
||||||
func (ps *ParserService) convertUnits(rawUnits []interface{}) []map[string]interface{} {
|
func (ps *ParserService) convertUnits(rawUnits []interface{}) []map[string]interface{} {
|
||||||
var convertedUnits []map[string]interface{}
|
var convertedUnits []map[string]interface{}
|
||||||
|
ps.ensureHeroBaseData()
|
||||||
|
|
||||||
|
|
||||||
for _, rawUnit := range rawUnits {
|
for _, rawUnit := range rawUnits {
|
||||||
if unitMap, ok := rawUnit.(map[string]interface{}); ok {
|
if unitMap, ok := rawUnit.(map[string]interface{}); ok {
|
||||||
@@ -225,6 +235,12 @@ func (ps *ParserService) convertUnits(rawUnits []interface{}) []map[string]inter
|
|||||||
convertedUnit["awaken"] = z
|
convertedUnit["awaken"] = z
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if code, ok := unitMap["code"].(string); ok && code != "" {
|
||||||
|
if base, ok := ps.heroBase[code]; ok {
|
||||||
|
ps.applyHeroBase(convertedUnit, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
convertedUnits = append(convertedUnits, convertedUnit)
|
convertedUnits = append(convertedUnits, convertedUnit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,3 +536,232 @@ func (ps *ParserService) convertItemsAllWithLog(rawItems []interface{}) []map[st
|
|||||||
}
|
}
|
||||||
return convertedItems
|
return convertedItems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type heroBaseStats struct {
|
||||||
|
Atk float64
|
||||||
|
Def float64
|
||||||
|
Hp float64
|
||||||
|
Spd float64
|
||||||
|
Cr float64
|
||||||
|
Cd float64
|
||||||
|
Eff float64
|
||||||
|
Res float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type heroDataEntry struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CalculatedStatus struct {
|
||||||
|
Lv60SixStarFullyAwakened struct {
|
||||||
|
Atk float64 `json:"atk"`
|
||||||
|
Def float64 `json:"def"`
|
||||||
|
Hp float64 `json:"hp"`
|
||||||
|
Spd float64 `json:"spd"`
|
||||||
|
Chc float64 `json:"chc"`
|
||||||
|
Chd float64 `json:"chd"`
|
||||||
|
Eff float64 `json:"eff"`
|
||||||
|
Efr float64 `json:"efr"`
|
||||||
|
} `json:"lv60SixStarFullyAwakened"`
|
||||||
|
} `json:"calculatedStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *ParserService) ensureHeroBaseData() {
|
||||||
|
ps.heroOnce.Do(func() {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
ps.heroErr = err
|
||||||
|
ps.logger.Error("load hero data failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(homeDir, ".equipment-analyzer", "herodata.json")
|
||||||
|
body, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
ps.heroErr = err
|
||||||
|
ps.logger.Error("load hero data failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw map[string]heroDataEntry
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
ps.heroErr = err
|
||||||
|
ps.logger.Error("load hero data failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range raw {
|
||||||
|
if entry.Code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats := entry.CalculatedStatus.Lv60SixStarFullyAwakened
|
||||||
|
ps.heroBase[entry.Code] = heroBaseStats{
|
||||||
|
Atk: stats.Atk,
|
||||||
|
Def: stats.Def,
|
||||||
|
Hp: stats.Hp,
|
||||||
|
Spd: stats.Spd,
|
||||||
|
Cr: stats.Chc * 100,
|
||||||
|
Cd: stats.Chd * 100,
|
||||||
|
Eff: stats.Eff * 100,
|
||||||
|
Res: stats.Efr * 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ps.logger.Info("hero base data loaded", "count", len(ps.heroBase))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (ps *ParserService) FillHeroBase(heroes []model.OptimizeHero) error {
|
||||||
|
ps.ensureHeroBaseData()
|
||||||
|
if len(ps.heroBase) == 0 {
|
||||||
|
return fmt.Errorf("hero base data not loaded")
|
||||||
|
}
|
||||||
|
for i := range heroes {
|
||||||
|
code := heroes[i].Code
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base, ok := ps.heroBase[code]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if heroes[i].BaseAtk == 0 {
|
||||||
|
heroes[i].BaseAtk = base.Atk
|
||||||
|
}
|
||||||
|
if heroes[i].BaseDef == 0 {
|
||||||
|
heroes[i].BaseDef = base.Def
|
||||||
|
}
|
||||||
|
if heroes[i].BaseHp == 0 {
|
||||||
|
heroes[i].BaseHp = base.Hp
|
||||||
|
}
|
||||||
|
if heroes[i].BaseSpd == 0 {
|
||||||
|
heroes[i].BaseSpd = base.Spd
|
||||||
|
}
|
||||||
|
if heroes[i].BaseCr == 0 {
|
||||||
|
heroes[i].BaseCr = base.Cr
|
||||||
|
}
|
||||||
|
if heroes[i].BaseCd == 0 {
|
||||||
|
heroes[i].BaseCd = base.Cd
|
||||||
|
}
|
||||||
|
if heroes[i].BaseEff == 0 {
|
||||||
|
heroes[i].BaseEff = base.Eff
|
||||||
|
}
|
||||||
|
if heroes[i].BaseRes == 0 {
|
||||||
|
heroes[i].BaseRes = base.Res
|
||||||
|
}
|
||||||
|
|
||||||
|
if heroes[i].Atk == 0 {
|
||||||
|
heroes[i].Atk = base.Atk
|
||||||
|
}
|
||||||
|
if heroes[i].Def == 0 {
|
||||||
|
heroes[i].Def = base.Def
|
||||||
|
}
|
||||||
|
if heroes[i].Hp == 0 {
|
||||||
|
heroes[i].Hp = base.Hp
|
||||||
|
}
|
||||||
|
if heroes[i].Spd == 0 {
|
||||||
|
heroes[i].Spd = base.Spd
|
||||||
|
}
|
||||||
|
if heroes[i].Cr == 0 {
|
||||||
|
heroes[i].Cr = base.Cr
|
||||||
|
}
|
||||||
|
if heroes[i].Cd == 0 {
|
||||||
|
heroes[i].Cd = base.Cd
|
||||||
|
}
|
||||||
|
if heroes[i].Eff == 0 {
|
||||||
|
heroes[i].Eff = base.Eff
|
||||||
|
}
|
||||||
|
if heroes[i].Res == 0 {
|
||||||
|
heroes[i].Res = base.Res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *ParserService) GetHeroTemplates() ([]model.HeroTemplate, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(homeDir, ".equipment-analyzer", "herodata.json")
|
||||||
|
body, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raw map[string]heroDataEntry
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]model.HeroTemplate, 0, len(raw))
|
||||||
|
for _, entry := range raw {
|
||||||
|
if entry.Code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats := entry.CalculatedStatus.Lv60SixStarFullyAwakened
|
||||||
|
list = append(list, model.HeroTemplate{
|
||||||
|
Code: entry.Code,
|
||||||
|
Name: entry.Name,
|
||||||
|
BaseAtk: stats.Atk,
|
||||||
|
BaseDef: stats.Def,
|
||||||
|
BaseHp: stats.Hp,
|
||||||
|
BaseSpd: stats.Spd,
|
||||||
|
BaseCr: stats.Chc * 100,
|
||||||
|
BaseCd: stats.Chd * 100,
|
||||||
|
BaseEff: stats.Eff * 100,
|
||||||
|
BaseRes: stats.Efr * 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].Name < list[j].Name
|
||||||
|
})
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *ParserService) applyHeroBase(unit map[string]interface{}, base heroBaseStats) {
|
||||||
|
if _, ok := unit["baseAtk"]; !ok {
|
||||||
|
unit["baseAtk"] = base.Atk
|
||||||
|
}
|
||||||
|
if _, ok := unit["baseDef"]; !ok {
|
||||||
|
unit["baseDef"] = base.Def
|
||||||
|
}
|
||||||
|
if _, ok := unit["baseHp"]; !ok {
|
||||||
|
unit["baseHp"] = base.Hp
|
||||||
|
}
|
||||||
|
if _, ok := unit["baseSpd"]; !ok {
|
||||||
|
unit["baseSpd"] = base.Spd
|
||||||
|
}
|
||||||
|
if _, ok := unit["baseCr"]; !ok {
|
||||||
|
unit["baseCr"] = base.Cr
|
||||||
|
}
|
||||||
|
if _, ok := unit["baseCd"]; !ok {
|
||||||
|
unit["baseCd"] = base.Cd
|
||||||
|
}
|
||||||
|
if _, ok := unit["baseEff"]; !ok {
|
||||||
|
unit["baseEff"] = base.Eff
|
||||||
|
}
|
||||||
|
if _, ok := unit["baseRes"]; !ok {
|
||||||
|
unit["baseRes"] = base.Res
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := unit["atk"]; !ok {
|
||||||
|
unit["atk"] = base.Atk
|
||||||
|
}
|
||||||
|
if _, ok := unit["def"]; !ok {
|
||||||
|
unit["def"] = base.Def
|
||||||
|
}
|
||||||
|
if _, ok := unit["hp"]; !ok {
|
||||||
|
unit["hp"] = base.Hp
|
||||||
|
}
|
||||||
|
if _, ok := unit["spd"]; !ok {
|
||||||
|
unit["spd"] = base.Spd
|
||||||
|
}
|
||||||
|
if _, ok := unit["cr"]; !ok {
|
||||||
|
unit["cr"] = base.Cr
|
||||||
|
}
|
||||||
|
if _, ok := unit["cd"]; !ok {
|
||||||
|
unit["cd"] = base.Cd
|
||||||
|
}
|
||||||
|
if _, ok := unit["eff"]; !ok {
|
||||||
|
unit["eff"] = base.Eff
|
||||||
|
}
|
||||||
|
if _, ok := unit["res"]; !ok {
|
||||||
|
unit["res"] = base.Res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user