Add on/off weapon research dps and params tables feature

This commit is contained in:
anibus 2026-09-03 20:22:01 +03:00
parent 23bfad8956
commit fa0baa62c1
6 changed files with 264 additions and 34 deletions

4
.env
View File

@ -1,5 +1,5 @@
NEXT_PUBLIC_HOST_URL=https://wiki-backend.dawn-of-war.pro
#NEXT_PUBLIC_HOST_URL=http://localhost:8082
NEXT_PUBLIC_HOST_URL=https://wiki-backend.dawn-of-war.pro
HOST_URL=http://localhost:8082
# Домен сайта для sitemap/robots/canonical (замените на реальный)
NEXT_PUBLIC_SITE_URL=https://wiki.dawn-of-war.pro

67
build.ps1 Normal file
View File

@ -0,0 +1,67 @@
# Скрипт сборки дистрибутива dow-wiki-frontend
# Запуск: .\build.ps1
# С шагами: .\build.ps1 -VerboseOutput
param(
[switch]$VerboseOutput
)
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
$deployDir = Join-Path $root "deploy"
$archive = Join-Path $root "dow-wiki-deploy.tar.gz"
# Файлы и папки, попадающие в дистрибутив
$artifacts = @(
".next",
"public",
"node_modules",
".env",
"next.config.mjs",
"package.json",
"package-lock.json",
"tsconfig.json",
"next-env.d.ts"
)
function Step($message) {
Write-Host "==> $message" -ForegroundColor Cyan
}
Set-Location $root
try {
# 1. Сборка Next.js
Step "Сборка Next.js (npm run build)"
npm run build
if ($LASTEXITCODE -ne 0) { throw "Сборка Next.js завершилась с ошибкой" }
# 2. Очистка папки deploy
Step "Очистка папки deploy"
if (Test-Path $deployDir) { Remove-Item $deployDir -Recurse -Force }
New-Item -ItemType Directory -Path $deployDir | Out-Null
# 3. Копирование артефактов
Step "Копирование артефактов в deploy"
Copy-Item -Path $artifacts -Destination $deployDir -Recurse -Force
# 4. Удаление старого архива
Step "Удаление старого архива"
if (Test-Path $archive) { Remove-Item $archive -Force }
# 5. Создание архива
Step "Создание архива dow-wiki-deploy.tar.gz"
tar -czf $archive -C $deployDir .
if ($LASTEXITCODE -ne 0) { throw "Ошибка при создании архива" }
# Итог
$size = "{0:N1} МБ" -f ((Get-Item $archive).Length / 1MB)
Write-Host ""
Write-Host "Сборка завершена успешно." -ForegroundColor Green
Write-Host "Дистрибутив: $archive ($size)"
}
catch {
Write-Host ""
Write-Host "ОШИБКА: $_" -ForegroundColor Red
exit 1
}

View File

@ -1,5 +1,5 @@
// Server-side API helpers for metadata generation.
// Runs only on the server (Node.js) — uses NEXT_PUBLIC_HOST_URL which is inlined at build time.
// Runs only on the server (Node.js) — uses HOST_URL (not exposed to the browser).
import { IMod } from '@/src/types/Imod';
import { Irace } from '@/src/types/Irace';
@ -8,8 +8,8 @@ import { IRaceBuildings } from '@/src/types/IBuildingShort';
import { IUnit } from '@/src/types/IUnit';
import { IBuilding } from '@/src/types/IBuilding';
const API = process.env.NEXT_PUBLIC_HOST_URL || '';
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://dow-wiki.example.com';
const API = process.env.HOST_URL || '';
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://wiki.dawn-of-war.pro';
async function fetchJson<T>(url: string): Promise<T | null> {
try {

View File

@ -2,7 +2,6 @@ import React, {useEffect, useState} from "react";
import {useTheme} from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import {WeaponUrl} from "../core/api";
import {IWeapon} from "../types/IUnit";
import ArmorTypeNames from "../types/ArmorTypeValues";
import {
Grid2,
@ -17,10 +16,12 @@ import Required from "./Required";
import {AffectedResearches} from "./building/Research";
import {IMod} from "../types/Imod";
import {Irace} from "../types/Irace";
import {ModifiersProvidesTable} from "./ModifiersProvideTable";
import {ModifiersProvidesTable, getIcon} from "./ModifiersProvideTable";
import {StyledPaper} from "../commons/StyledPaper";
import {StyledTableCell} from "../commons/StyledTableCell";
import AvTimerOutlinedIcon from "@mui/icons-material/AvTimer";
import {IWeapon, IWeaponResearch} from "../types/IUnit";
interface IWeaponFull {
currentTable: string;
@ -41,6 +42,71 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
weapon: undefined,
});
const [enabledResearchIds, setEnabledResearchIds] = useState<Set<number>>(new Set());
function applyResearchEffects(weapon: IWeapon, enabledIds: Set<number>): IWeapon {
if (enabledIds.size === 0 || !weapon.researches || weapon.researches.length === 0) {
return weapon;
}
const modified: any = { ...weapon };
const enabledResearches = weapon.researches.filter((r: IWeaponResearch) => enabledIds.has(r.research.id));
for (const research of enabledResearches) {
if (!research.effects || research.effects.length === 0) continue;
for (const effect of research.effects) {
const usageType: string = effect.usageType;
const apply = (value: number): number => {
if (usageType.includes('multiplication')) {
return value * effect.modifierValue;
} else if (usageType.includes('addition')) {
return value + effect.modifierValue;
} else if (usageType.includes('replace')) {
return effect.modifierValue;
}
return value;
};
const field = effect.field;
switch (field) {
case 'armourPiercing':
if (!modified.weaponArmorPiercing || modified.weaponArmorPiercing.length === 0) continue;
modified.weaponArmorPiercing = modified.weaponArmorPiercing.map((p: any) => ({
...p,
piercingValue: apply(p.piercingValue),
}));
break;
case 'accuracyReductionMoving':
modified.accuracyReductionMoving = apply(modified.accuracyReductionMoving);
break;
case 'accuracy':
modified.accuracy = apply(modified.accuracy);
break;
case 'maxDamage':
modified.maxDamage = apply(modified.maxDamage);
break;
case 'maxRange':
modified.maxRange = apply(modified.maxRange);
break;
case 'minDamage':
modified.minDamage = apply(modified.minDamage);
break;
case 'reloadTime':
modified.reloadTime = apply(modified.reloadTime);
break;
case 'setupTime':
modified.setupTime = apply(modified.setupTime);
break;
}
}
}
return modified as IWeapon;
}
useEffect(() => {
fetch(WeaponUrl + "/" + props.mod.id + "/" + props.weaponId)
@ -59,8 +125,10 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
const weapon = weaoponFull.weapon
const displayWeapon = applyResearchEffects(weapon, enabledResearchIds);
function getPiercingK(armorType: string): number {
const weaponPiercing = weapon.weaponArmorPiercing.find((p) => p.armorType.name === armorType)
const weaponPiercing = displayWeapon.weaponArmorPiercing.find((p) => p.armorType.name === armorType)
return (typeof weaponPiercing !== "undefined" ? weaponPiercing.piercingValue : 10) / 100
}
@ -71,8 +139,8 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
});
}
if(weapon.reloadTime < 0.125) weapon.reloadTime = 0.125
const dpsK = (weaoponFull.currentTable == "dps") ? weapon.accuracy * (1 / (weapon.reloadTime - (weapon.reloadTime % 0.125))) : 1
if(displayWeapon.reloadTime < 0.125) displayWeapon.reloadTime = 0.125
const dpsK = (weaoponFull.currentTable == "dps") ? displayWeapon.accuracy * (1 / (displayWeapon.reloadTime - (displayWeapon.reloadTime % 0.125))) : 1
const infLowPiercing = getPiercingK(ArmorTypeNames.InfantryLow)
const infMedPiercing = getPiercingK(ArmorTypeNames.InfantryMedium)
@ -102,14 +170,14 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
if (!isAir && !weapon.canAttackGround && !weapon.isMeleeWeapon) return ""
if (isAir && !weapon.canAttackAir) return ""
var minDamage = damagePiercing * weapon.minDamage
var maxDamage = damagePiercing * weapon.maxDamage
var minDamage = damagePiercing * displayWeapon.minDamage
var maxDamage = damagePiercing * displayWeapon.maxDamage
if (minDamage < weapon.minDamageValue) {
minDamage = weapon.minDamageValue
if (minDamage < displayWeapon.minDamageValue) {
minDamage = displayWeapon.minDamageValue
}
if (maxDamage < weapon.minDamageValue) {
maxDamage = weapon.minDamageValue
if (maxDamage < displayWeapon.minDamageValue) {
maxDamage = displayWeapon.minDamageValue
}
const averageDmg = (minDamage + maxDamage) / 2
@ -118,7 +186,7 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
}
const getMoraleDamage = () => {
return (weapon.moraleDamage * dpsK / 2).toFixed(2)
return (displayWeapon.moraleDamage * dpsK / 2).toFixed(2)
}
@ -137,19 +205,19 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" >Base damage</TableCell>
<TableCell>{weapon.minDamage} {weapon.maxDamage !== weapon.minDamage && "- " + weapon.maxDamage}</TableCell>
<TableCell>{displayWeapon.minDamage.toFixed(2)} {displayWeapon.maxDamage !== displayWeapon.minDamage && "- " + displayWeapon.maxDamage.toFixed(2)}</TableCell>
</TableRow>
<TableRow
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" >Accuracy</TableCell>
<TableCell>{weapon.accuracy.toFixed(3).replace(/[,.]?0+$/, '')}</TableCell>
<TableCell>{displayWeapon.accuracy.toFixed(3).replace(/[,.]?0+$/, '')}</TableCell>
</TableRow>
<TableRow
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" >Accuracy moving</TableCell>
<TableCell>{weapon.setupTime === 0 && weapon.accuracy - weapon.accuracyReductionMoving > 0 ? (weapon.accuracy - weapon.accuracyReductionMoving).toFixed(3).replace(/[,.]?0+$/, '') : "-"}</TableCell>
<TableCell>{displayWeapon.setupTime === 0 && displayWeapon.accuracy - displayWeapon.accuracyReductionMoving > 0 ? (displayWeapon.accuracy - displayWeapon.accuracyReductionMoving).toFixed(3).replace(/[,.]?0+$/, '') : "-"}</TableCell>
</TableRow>
</TableBody>
</Table>
@ -163,13 +231,13 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" >Reload time</TableCell>
<TableCell>{weapon.reloadTime}</TableCell>
<TableCell>{displayWeapon.reloadTime}</TableCell>
</TableRow>
<TableRow
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" >Range</TableCell>
<TableCell>{weapon.maxRange ? (weapon.maxRange) : "-"} {weapon.minRange ? "(min " + (weapon.minRange) + ")" : ""}</TableCell>
<TableCell>{displayWeapon.maxRange ? (displayWeapon.maxRange) : "-"} {weapon.minRange ? "(min " + (weapon.minRange) + ")" : ""}</TableCell>
</TableRow>
<TableRow
sx={{'&:last-child td, &:last-child th': {border: 0}}}
@ -210,7 +278,7 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
sx={{'&:last-child td, &:last-child th': {border: 0}}}
>
<TableCell component="th" scope="row" >Setup time</TableCell>
<TableCell>{weapon.setupTime != 0 ? (weapon.setupTime).toFixed(3).replace(/[,.]?0+$/, '') : "-"}</TableCell>
<TableCell>{displayWeapon.setupTime != 0 ? (displayWeapon.setupTime).toFixed(3).replace(/[,.]?0+$/, '') : "-"}</TableCell>
</TableRow>
<TableRow
sx={{'&:last-child td, &:last-child th': {border: 0}}}
@ -224,17 +292,98 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean,
</Grid2>
<Grid2 size={12}>
<ToggleButtonGroup
color="primary"
exclusive
aria-label="Platform"
value={weaoponFull.currentTable}
onChange={handleChange}
sx={{ mb: 2 }}
>
<ToggleButton size="small" value="dps" sx={{ color: textColor, '&.Mui-selected': { borderColor: textColor, color: textColor, backgroundColor: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.15)' } }}>Dps</ToggleButton>
<ToggleButton size="small" value="one hit" sx={{ color: textColor, '&.Mui-selected': {borderColor: textColor, color: textColor, backgroundColor: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.15)' } }}>One hit average damage</ToggleButton>
</ToggleButtonGroup>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 8 }}>
<ToggleButtonGroup
color="primary"
exclusive
aria-label="Platform"
value={weaoponFull.currentTable}
onChange={handleChange}
>
<ToggleButton size="small" value="dps" sx={{ color: textColor, '&.Mui-selected': { borderColor: textColor, color: textColor, backgroundColor: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.15)' } }}>Dps</ToggleButton>
<ToggleButton size="small" value="one hit" sx={{ color: textColor, '&.Mui-selected': {borderColor: textColor, color: textColor, backgroundColor: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.15)' } }}>One hit average damage</ToggleButton>
</ToggleButtonGroup>
{weapon.researches && weapon.researches.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{(() => {
// topological order: a research is always rendered to the right of its requirements
const ordered: IWeaponResearch[] = [];
const visited = new Set<number>();
const visit = (wr: IWeaponResearch) => {
if (visited.has(wr.research.id)) return;
visited.add(wr.research.id);
wr.researchRequired?.forEach(req => {
const dep = weapon.researches.find((w: IWeaponResearch) => w.research.id === req.id);
if (dep) visit(dep);
});
ordered.push(wr);
};
weapon.researches.forEach(visit);
return ordered.map((r: IWeaponResearch) => {
const rs = r.research;
const selected = enabledResearchIds.has(rs.id);
return (
<ToggleButton
key={rs.id}
value={rs.id}
size="small"
selected={selected}
onChange={() => {
const next = new Set(enabledResearchIds);
if (next.has(rs.id)) {
next.delete(rs.id);
// researches that require this one (directly or transitively) can no longer be applied
let changed = true;
while (changed) {
changed = false;
weapon.researches.forEach((wr: IWeaponResearch) => {
if (next.has(wr.research.id) && wr.researchRequired?.some(req => !next.has(req.id))) {
next.delete(wr.research.id);
changed = true;
}
});
}
} else {
next.add(rs.id);
// research can only be applied if its required researches are done
// (requirements can have their own requirements, so enable transitively)
const queue = [...(r.researchRequired ?? [])];
while (queue.length > 0) {
const req = queue.pop()!;
if (!next.has(req.id)) {
next.add(req.id);
const wr = weapon.researches.find((w: IWeaponResearch) => w.research.id === req.id);
wr?.researchRequired?.forEach(rr => queue.push(rr));
}
}
}
setEnabledResearchIds(next);
}}
sx={{
color: textColor,
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'rgba(255,255,255,0.23)' : 'rgba(0,0,0,0.23)',
textTransform: 'none',
'&.Mui-selected': {
borderColor: isDark ? 'rgba(25, 118, 210, 0.7)' : 'rgba(25, 118, 210, 0.5)',
color: textColor,
backgroundColor: isDark ? 'rgba(25, 118, 210, 0.3)' : 'rgba(25, 118, 210, 0.15)',
'&:hover': {
backgroundColor: isDark ? 'rgba(25, 118, 210, 0.4)' : 'rgba(25, 118, 210, 0.22)',
},
},
}}
>
{rs.icon && <img style={{height: 20, marginRight: 6, verticalAlign: 'middle'}} src={getIcon(rs.icon)} alt={rs.name}/>}
{rs.name}
</ToggleButton>
);
});
})()}
</div>
)}
</div>
<TableContainer>
{isMobile ? (() => {
const items = props.mod !== undefined && props.mod.technicalName === 'UltimateApocalypse' ? [

View File

@ -2,5 +2,6 @@ export interface IResearchShort {
id: number;
name: string;
icon: string;
uiIndexHint: number;
buildingId: number;
}

View File

@ -141,6 +141,18 @@ export interface IShortWeapon {
isMeleeWeapon: boolean
}
export interface IResearchEffect {
field: string;
usageType: string;
modifierValue: number;
}
export interface IWeaponResearch {
research: IResearchShort;
researchRequired: IResearchShort[];
effects: IResearchEffect[];
}
export interface IWeapon {
id: number
name: string
@ -172,6 +184,7 @@ export interface IWeapon {
modifiers: IModifier[]
weaponArmorPiercing: IPiercing[]
affectedResearches: IResearchShort[],
researches: IWeaponResearch[],
hotkey?: string,
}