--- a/rts/Game/WebbarBrowser.h +++ b/rts/Game/WebbarBrowser.h @@ -45,6 +45,8 @@ #include "WebbarShields.h" #include "WebbarCloak.h" #include "WebbarWorkAudio.h" +#include "WebbarEconomy.h" +#include "WebbarCatalog.h" static bool WebbarSupports(const UnitDef* def) { @@ -61,6 +63,7 @@ #include "WebbarResurrection.h" #include "WebbarNano.h" #include "WebbarSkirmish.h" +#include "WebbarPlacement.h" #include "WebbarWorld.h" #include "WebbarTerrain.h" #include "WebbarTerrainFixture.h" @@ -82,15 +85,13 @@ if (!catalogSent) { catalogSent = true; if (std::getenv("WEBBAR_NETWORK") != nullptr) { static WebbarNetworkResult result; } - MAIN_THREAD_EM_ASM({ if (Module['webbarProtocol']) Module['webbarProtocol'](8, 127 /* restore | shields | cloak | resurrection | native work effects | area resurrection | manual launch */); }); + MAIN_THREAD_EM_ASM({ if (Module['webbarProtocol']) Module['webbarProtocol'](9, 8191 /* restore | shields | cloak | resurrection | native work effects | area resurrection | manual launch | capture | smart trajectory | energy conversion | geothermal | selectability | build restrictions */); }); MAIN_THREAD_EM_ASM({ if (Module['webbarIdentity']) Module['webbarIdentity']($0, $1, $2, $3); }, gu->myPlayerNum, gu->myTeam, gu->myAllyTeam, int(gu->spectating)); for (const UnitDef& def: unitDefHandler->GetUnitDefsVec()) { if (!WebbarSupports(&def)) continue; - MAIN_THREAD_EM_ASM({ - if (Module['webbarCatalog']) Module['webbarCatalog']($0, UTF8ToString($1), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); - }, def.id, def.name.c_str(), def.cost.metal, def.cost.energy, def.buildTime, def.xsize * SQUARE_SIZE, def.zsize * SQUARE_SIZE, def.IsBuilderUnit(), def.IsFactoryUnit(), def.IsExtractorUnit(), !def.IsImmobileUnit(), def.CanDamage()); + PublishWebbarCatalog(def); for (const auto& option: def.buildOptions) { const UnitDef* target = unitDefHandler->GetUnitDefByName(option.second); if (!WebbarSupports(target)) continue; @@ -130,6 +131,7 @@ Module['webbarCommandDescriptions'](units, $2); }, words.data(), words.size(), gs->frameNum); } + PublishWebbarEnergyConversion(); const CTeam* team = teamHandler.Team(gu->myTeam); const float economy[] = {team->res.metal, team->resStorage.metal, team->resPrevIncome.metal, team->resPrevExpense.metal, team->resPrevPull.metal, team->res.energy, team->resStorage.energy, team->resPrevIncome.energy, team->resPrevExpense.energy, team->resPrevPull.energy}; @@ -137,9 +139,9 @@ if (Module['webbarEconomy']) Module['webbarEconomy']($0, HEAPF32.slice($1 >> 2, ($1 >> 2) + 10).buffer); }, gs->frameNum, economy); const auto units = WebbarUnits(); - struct Record { uint32_t id; float progress; uint32_t queued; int32_t command; uint32_t target; float metal, energy; uint32_t transporter, cargo; }; - static_assert(sizeof(Record) == 36); - const uint32_t header[] = {0x34534257, uint32_t(gs->frameNum), uint32_t(units.size()), 0}; + struct Record { uint32_t id; float progress; uint32_t queued; int32_t command; uint32_t target; float metal, energy; uint32_t transporter, cargo, selectionFlags; }; + static_assert(sizeof(Record) == 40); + const uint32_t header[] = {0x35534257, uint32_t(gs->frameNum), uint32_t(units.size()), 0}; std::vector packet(sizeof(header) + units.size() * sizeof(Record)); std::memcpy(packet.data(), header, sizeof(header)); size_t offset = sizeof(header); @@ -150,7 +152,7 @@ const auto& queue = unit->commandAI->commandQue; const bool owned = unit->team == gu->myTeam; const Record record = {uint32_t(unit->id), unit->buildProgress, owned ? uint32_t(queue.size()) : 0, (!owned || queue.empty()) ? 0 : queue.front().GetID(), - (owned && target) ? uint32_t(target->id) : 0xffffffff, owned ? unit->resourcesMake.metal : 0.0f, owned ? unit->resourcesMake.energy : 0.0f, unit->GetTransporter() ? uint32_t(unit->GetTransporter()->id) : 0xffffffff, owned ? uint32_t(unit->transportedUnits.size()) : 0}; + (owned && target) ? uint32_t(target->id) : 0xffffffff, owned ? unit->resourcesMake.metal : 0.0f, owned ? unit->resourcesMake.energy : 0.0f, unit->GetTransporter() ? uint32_t(unit->GetTransporter()->id) : 0xffffffff, owned ? uint32_t(unit->transportedUnits.size()) : 0, (owned && unit->noSelect) ? 1u : 0u}; std::memcpy(packet.data() + offset, &record, sizeof(record)); offset += sizeof(record); } MAIN_THREAD_EM_ASM({ @@ -208,12 +210,14 @@ std::vector ids; if (order.count > 64 || gu->spectating || !playerHandler.IsValidPlayer(gu->myPlayerNum)) { status = 1; + } else if (order.kind == 1010) { + status = SetWebbarEnergyReserve(order.count, order.target, order.options); } else if (order.kind == 1000) { clientNet->Send(CBaseNetProtocol::Get().SendPause(gu->myPlayerNum, order.target != 0)); } else { for (uint32_t i = 0; i < order.count; ++i) { const CUnit* unit = unitHandler.GetUnit(order.units[i]); - if (unit != nullptr && unit->team == gu->myTeam && !unit->isDead && std::find(ids.begin(), ids.end(), unit->id) == ids.end()) + if (WebbarSelectable(unit) && std::find(ids.begin(), ids.end(), unit->id) == ids.end()) ids.push_back(unit->id); } Command command(order.kind, uint8_t(order.options & SHIFT_KEY)); @@ -237,7 +241,7 @@ } else { command.PushPos(float3(order.x, CGround::GetHeightReal(order.x, order.z), order.z)); } - } else if (order.kind == CMD_FIRE_STATE || order.kind == CMD_MOVE_STATE || order.kind == CMD_REPEAT || order.kind == CMD_ONOFF || order.kind == 37382 /* BAR CMD_WANT_CLOAK */ || order.kind == CMD_TRAJECTORY || order.kind == CMD_AUTOREPAIRLEVEL || order.kind == CMD_IDLEMODE) { + } else if (order.kind == CMD_FIRE_STATE || order.kind == CMD_MOVE_STATE || order.kind == CMD_REPEAT || order.kind == CMD_ONOFF || order.kind == 37382 /* BAR CMD_WANT_CLOAK */ || order.kind == CMD_TRAJECTORY || order.kind == 37384 /* BAR SMART_TOGGLE */ || order.kind == CMD_AUTOREPAIRLEVEL || order.kind == CMD_IDLEMODE) { requireCapability(order.kind); for (int id: ids) { const auto* desc = description(id, order.kind); @@ -316,6 +320,17 @@ if (ids.empty()) status = 8; else if (target == nullptr || target->team != gu->myTeam || target->isDead) status = 4; else command.PushParam(float(order.target)); + } else if (order.kind == CMD_CAPTURE) { + requireCapability(CMD_CAPTURE); + std::erase_if(ids, [&](int id) { const auto* def = unitHandler.GetUnit(id)->unitDef; return !def->IsBuilderUnit() || !def->canCapture; }); + const CUnit* target = unitHandler.GetUnit(order.target); + if (ids.empty()) status = 8; + else if (target == nullptr || target->isDead || target->team == gu->myTeam || !WebbarVisible(target)) status = 4; + else { + // Native BuilderCAI owns capturability, range, resource spending, + // progress and target loss, including changes before queued execution. + command.PushParam(float(order.target)); + } } else if (order.kind == CMD_RESURRECT) { requireCapability(CMD_RESURRECT); std::erase_if(ids, [&](int id) { const auto* def = unitHandler.GetUnit(id)->unitDef; return !def->IsBuilderUnit() || !def->canResurrect; }); @@ -350,7 +365,9 @@ for (const auto& option: unit->unitDef->buildOptions) if (option.second == def->name) return false; return true; }); - if (ids.empty()) { + if (WebbarBuildRestriction(def->id) != nullptr) { + status = 9; + } else if (ids.empty()) { status = 8; } else if (order.kind == 1003) { command = Command(-def->id); // one unit per click; SHIFT means five to a native factory --- /dev/null +++ b/rts/Game/WebbarCatalog.h @@ -0,0 +1,45 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ +#pragma once + +// Read-only initial match definitions, after the game's definition processing. +// They are public specifications, never unit state, occupancy or net income. +static void PublishWebbarCatalog(const UnitDef& def) +{ + const float values[] = { + def.upkeep.metal, def.upkeep.energy, def.resourceMake.metal, def.resourceMake.energy, + def.makesResources.metal, def.makesResources.energy, def.windGenerator.metal, def.windGenerator.energy, + def.tidalGenerator.metal, def.tidalGenerator.energy, def.storage.metal, def.storage.energy, + def.extractsMetal, def.extractRange, def.minWaterDepth, def.maxWaterDepth, def.maxHeightDif, def.waterline, + }; + std::vector yardmap; + yardmap.reserve(def.yardmap.size()); + for (const auto cell: def.yardmap) yardmap.push_back(uint8_t(cell)); + const auto param = [&def](const char* key) -> const char* { + const auto found = def.customParams.find(key); + return found == def.customParams.end() ? nullptr : found->second.c_str(); + }; + const auto pointer = [](const void* value) { return uint32_t(reinterpret_cast(value)); }; + const uint32_t words[] = { + pointer(yardmap.data()), uint32_t(yardmap.size()), uint32_t(def.xsize), uint32_t(def.zsize), + uint32_t(def.onoffable), uint32_t(def.activateWhenBuilt), uint32_t(def.floatOnWater), + pointer(param("energyconv_capacity")), pointer(param("energyconv_efficiency")), + pointer(param("metal_extractor")), pointer(param("energymultiplier")), uint32_t(def.levelGround), def.buildingMask, + }; + MAIN_THREAD_EM_ASM({ + if (!Module['webbarCatalog']) return; + const values = Array.from(HEAPF32.subarray($13 >> 2, ($13 >> 2) + 18)); + // Keep the host-call argument count below Emscripten's 16-argument limit. + const words = HEAPU32.subarray($14 >> 2, ($14 >> 2) + 13); + const yardmap = Array.from(HEAPU8.subarray(words[0], words[0] + words[1])); + const nativeEconomy = ({version: 1, values, yardmap, xsize: words[2], zsize: words[3], + onoffable: Boolean(words[4]), activateWhenBuilt: Boolean(words[5]), floatOnWater: Boolean(words[6]), + conversionCapacity: words[7] ? UTF8ToString(words[7]) : null, + conversionEfficiency: words[8] ? UTF8ToString(words[8]) : null, + extractionMultiplier: words[9] ? UTF8ToString(words[9]) : null, + energyMultiplier: words[10] ? UTF8ToString(words[10]) : null, levelGround: Boolean(words[11]), buildingMask: words[12]}); + Module['webbarCatalog']($0, UTF8ToString($1), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, nativeEconomy); + }, def.id, def.name.c_str(), def.cost.metal, def.cost.energy, def.buildTime, + def.xsize * SQUARE_SIZE, def.zsize * SQUARE_SIZE, def.IsBuilderUnit(), def.IsFactoryUnit(), + def.IsExtractorUnit(), !def.IsImmobileUnit(), def.CanDamage(), def.needGeo, + values, words); +} --- /dev/null +++ b/rts/Game/WebbarEconomy.h @@ -0,0 +1,50 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ +#pragma once + +#include "Lua/LuaHandle.h" +#include "Lua/LuaRulesParams.h" + +// Codex-assisted local interface to pinned BAR's original energy conversion +// gadget. Read only our team; its synced gadget remains the sole state writer. +static uint32_t ReadWebbarEnergyConversion(float (&values)[4]) +{ + std::fill(std::begin(values), std::end(values), 0.0f); + if (gu->spectating || !teamHandler.IsValidTeam(gu->myTeam)) return 0; + const CTeam* team = teamHandler.Team(gu->myTeam); + const char* names[] = {"mmLevel", "mmCapacity", "mmUse", "mmAvgEffi"}; + uint32_t mask = 0; + for (size_t i = 0; i < 4; ++i) { + const auto it = team->modParams.find(names[i]); + if (it == team->modParams.end() || !(it->second.los & LuaRulesParams::RULESPARAMLOS_PRIVATE_MASK)) continue; + const float* value = std::get_if(&it->second.value); + if (value == nullptr || !std::isfinite(*value) || *value < 0.0f || (i == 0 && *value > 1.0f)) continue; + values[i] = *value; + mask |= 1u << i; + } + return mask; +} + +static void PublishWebbarEnergyConversion() +{ + float values[4]; + const uint32_t mask = ReadWebbarEnergyConversion(values); + if (gu->spectating || !teamHandler.IsValidTeam(gu->myTeam)) return; + MAIN_THREAD_EM_ASM({ + if (Module['webbarEnergyConversion']) Module['webbarEnergyConversion']($0, $1, $2, Array.from(HEAPF32.subarray($3 >> 2, ($3 >> 2) + 4))); + }, gs->frameNum, gu->myTeam, mask, values); +} + +static int SetWebbarEnergyReserve(uint32_t count, uint32_t percent, uint32_t options) +{ + // Match gui_top_bar's ordinary slider limits, not the gadget's wider API. + if (count != 0 || options != 0 || percent < 12 || percent > 88) return 6; + float values[4]; + if (!(ReadWebbarEnergyConversion(values) & 1u)) return 8; + const std::string level = std::to_string(percent); + std::vector message = {137}; + message.insert(message.end(), level.begin(), level.end()); + // Same network path as Spring.SendLuaRulesMsg. The original RecvLuaMsg + // resolves the sender's team and rejects spectators; no team selector exists. + clientNet->Send(CBaseNetProtocol::Get().SendLuaMsg(gu->myPlayerNum, LUA_HANDLE_ORDER_RULES, 0, message)); + return 0; +} --- /dev/null +++ b/rts/Game/WebbarPlacement.h @@ -0,0 +1,66 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ +#pragma once + +#include "Lua/LuaRulesParams.h" + +// api_build_blocking publishes the original gadget's current per-team reasons. +// Read that decision instead of duplicating map/option rules or calling synced +// AllowCommand from a local preview. +static const std::string* WebbarBuildRestriction(int definition) +{ + if (gu->spectating || !teamHandler.IsValidTeam(gu->myTeam)) return nullptr; + const auto& params = teamHandler.Team(gu->myTeam)->modParams; + const auto it = params.find("unitdef_blocked_" + std::to_string(definition)); + if (it == params.end() || !(it->second.los & LuaRulesParams::RULESPARAMLOS_PRIVATE_MASK)) return nullptr; + const auto* reason = std::get_if(&it->second.value); + return reason != nullptr && !reason->empty() ? reason : nullptr; +} + +static void PublishWebbarBuildRestrictions() +{ + if (gu->spectating || !teamHandler.IsValidTeam(gu->myTeam)) return; + std::vector words; + for (const UnitDef& def: unitDefHandler->GetUnitDefsVec()) { + if (!WebbarSupports(&def)) continue; + const auto* reason = WebbarBuildRestriction(def.id); + if (reason == nullptr) continue; + words.push_back(def.id); + words.push_back(uint32_t(reinterpret_cast(reason->c_str()))); + } + MAIN_THREAD_EM_ASM({ + if (!Module['webbarBuildRestrictions']) return; + const words = HEAPU32.subarray($0 >> 2, ($0 >> 2) + $1); + const entries = []; + for (let i = 0; i < words.length; i += 2) entries.push([words[i], UTF8ToString(words[i + 1])]); + Module['webbarBuildRestrictions']($2, $3, entries); + }, words.data(), words.size(), gs->frameNum, gu->myTeam); +} + +// Local presentation/admission only. The original gadgets own noSelect and +// the original build-square checks own geothermal placement and occupancy. +static bool WebbarSelectable(const CUnit* unit) +{ + return unit != nullptr && unit->team == gu->myTeam && !unit->isDead && !unit->noSelect; +} + +static bool WebbarGeoVisible(const CFeature* feature) +{ + return feature != nullptr && !feature->deleteMe && feature->def->geoThermal && feature->IsInLosForAllyTeam(gu->myAllyTeam); +} + +static void PublishWebbarGeothermal() +{ + struct GeoRecord { uint32_t id; float x, y, z; }; + static_assert(sizeof(GeoRecord) == 16); + std::vector vents; + for (const int id: featureHandler.GetActiveFeatureIDs()) { + const CFeature* feature = featureHandler.GetFeature(id); + if (!WebbarGeoVisible(feature)) continue; + vents.push_back({uint32_t(id), feature->pos.x, feature->pos.y, feature->pos.z}); + } + const uint32_t header[] = {0x31474257, uint32_t(gs->frameNum), uint32_t(vents.size()), 0}; + std::vector packet(sizeof(header) + vents.size() * sizeof(GeoRecord)); + std::memcpy(packet.data(), header, sizeof(header)); + std::memcpy(packet.data() + sizeof(header), vents.data(), vents.size() * sizeof(GeoRecord)); + MAIN_THREAD_EM_ASM({ if (Module['webbarGeothermal']) Module['webbarGeothermal'](HEAPU8.slice($0, $0 + $1).buffer); }, packet.data(), packet.size()); +} --- a/rts/Game/WebbarWorld.h +++ b/rts/Game/WebbarWorld.h @@ -6,6 +6,8 @@ static void PublishWebbarWorld() { if (gs->frameNum % 15 != 0) return; + PublishWebbarGeothermal(); + PublishWebbarBuildRestrictions(); constexpr uint32_t grid = 256; std::vector contacts; for (const CUnit* unit: unitHandler.GetActiveUnits())