From fa0baa62c1d605d79ee663d1afcde5c92eb18a43 Mon Sep 17 00:00:00 2001 From: anibus Date: Thu, 3 Sep 2026 20:22:01 +0300 Subject: [PATCH] Add on/off weapon research dps and params tables feature --- .env | 4 +- build.ps1 | 67 ++++++++++++ lib/api-server.ts | 6 +- src/classes/WeaponFull.tsx | 207 ++++++++++++++++++++++++++++++----- src/types/IResearchShort.tsx | 1 + src/types/IUnit.tsx | 13 +++ 6 files changed, 264 insertions(+), 34 deletions(-) create mode 100644 build.ps1 diff --git a/.env b/.env index 0fc4ce1..ee1b435 100644 --- a/.env +++ b/.env @@ -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 \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..84472c7 --- /dev/null +++ b/build.ps1 @@ -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 +} diff --git a/lib/api-server.ts b/lib/api-server.ts index 6b33080..e54e06a 100644 --- a/lib/api-server.ts +++ b/lib/api-server.ts @@ -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(url: string): Promise { try { diff --git a/src/classes/WeaponFull.tsx b/src/classes/WeaponFull.tsx index 372be3e..642d21f 100644 --- a/src/classes/WeaponFull.tsx +++ b/src/classes/WeaponFull.tsx @@ -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>(new Set()); + + function applyResearchEffects(weapon: IWeapon, enabledIds: Set): 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}}} > Base damage - {weapon.minDamage} {weapon.maxDamage !== weapon.minDamage && "- " + weapon.maxDamage} + {displayWeapon.minDamage.toFixed(2)} {displayWeapon.maxDamage !== displayWeapon.minDamage && "- " + displayWeapon.maxDamage.toFixed(2)} Accuracy - {weapon.accuracy.toFixed(3).replace(/[,.]?0+$/, '')} + {displayWeapon.accuracy.toFixed(3).replace(/[,.]?0+$/, '')} Accuracy moving - {weapon.setupTime === 0 && weapon.accuracy - weapon.accuracyReductionMoving > 0 ? (weapon.accuracy - weapon.accuracyReductionMoving).toFixed(3).replace(/[,.]?0+$/, '') : "-"} + {displayWeapon.setupTime === 0 && displayWeapon.accuracy - displayWeapon.accuracyReductionMoving > 0 ? (displayWeapon.accuracy - displayWeapon.accuracyReductionMoving).toFixed(3).replace(/[,.]?0+$/, '') : "-"} @@ -163,13 +231,13 @@ export default function WeaponFull(props: {weaponId: number, isDefault: Boolean, sx={{'&:last-child td, &:last-child th': {border: 0}}} > Reload time - {weapon.reloadTime} + {displayWeapon.reloadTime} Range - {weapon.maxRange ? (weapon.maxRange) : "-"} {weapon.minRange ? "(min " + (weapon.minRange) + ")" : ""} + {displayWeapon.maxRange ? (displayWeapon.maxRange) : "-"} {weapon.minRange ? "(min " + (weapon.minRange) + ")" : ""} Setup time - {weapon.setupTime != 0 ? (weapon.setupTime).toFixed(3).replace(/[,.]?0+$/, '') : "-"} + {displayWeapon.setupTime != 0 ? (displayWeapon.setupTime).toFixed(3).replace(/[,.]?0+$/, '') : "-"} - - Dps - One hit average damage - +
+ + Dps + One hit average damage + + {weapon.researches && weapon.researches.length > 0 && ( +
+ {(() => { + // topological order: a research is always rendered to the right of its requirements + const ordered: IWeaponResearch[] = []; + const visited = new Set(); + 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 ( + { + 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 && {rs.name}/} + {rs.name} + + ); + }); + })()} +
+ )} +
{isMobile ? (() => { const items = props.mod !== undefined && props.mod.technicalName === 'UltimateApocalypse' ? [ diff --git a/src/types/IResearchShort.tsx b/src/types/IResearchShort.tsx index 833fd26..3e02512 100644 --- a/src/types/IResearchShort.tsx +++ b/src/types/IResearchShort.tsx @@ -2,5 +2,6 @@ export interface IResearchShort { id: number; name: string; icon: string; + uiIndexHint: number; buildingId: number; } \ No newline at end of file diff --git a/src/types/IUnit.tsx b/src/types/IUnit.tsx index 602c623..79575a6 100644 --- a/src/types/IUnit.tsx +++ b/src/types/IUnit.tsx @@ -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, }