diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a77129ba..85065da63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -191,6 +191,10 @@ jobs: scons -j$(nproc) release=1 server=0 hiring-bucket-test python3 test/run-savegame-safety-tests.py --check-preferences build/src/HiringBucketHarness . + - name: Build and run the hiring level-gate regression + run: | + scons -j$(nproc) release=1 server=0 level-gate-test + python3 test/run-savegame-safety-tests.py --check-preferences build/src/LevelGateHarness . - name: Build and run the resource-fetch target regression run: | scons -j$(nproc) release=1 server=0 resource-fetch-target-test diff --git a/src/FileFormatVersions.h b/src/FileFormatVersions.h index ec8623361..d79f1d3c3 100644 --- a/src/FileFormatVersions.h +++ b/src/FileFormatVersions.h @@ -96,6 +96,11 @@ static constexpr int FILE_FORMAT_VERSION_ROUND_TRIP_FIELDS = 95; //! (MapIO.cpp:350, 410, 451, 514). static constexpr int FILE_FORMAT_VERSION_TOPOLOGY_GENERATION = 97; +//! Harvest and build became one worker level. Saves before this could hold the +//! two apart — the map editor offered a box for each — so the loader evens a +//! worker out to the higher of the two (UnitSerialization.cpp:90). +static constexpr int FILE_FORMAT_VERSION_ONE_WORKER_LEVEL = 99; + // === Save-file section signatures (4-byte ASCII tags) === // Embedded as four chars at the start of each save section so a corrupted // stream fails fast. NEVER change these values — old saves on disk depend diff --git a/src/ReplayReader.h b/src/ReplayReader.h index 8f647885e..6e42bd318 100644 --- a/src/ReplayReader.h +++ b/src/ReplayReader.h @@ -23,12 +23,12 @@ static constexpr Uint32 REPLAY_MIN_VALID_ORDERS = 5; //! Oldest replay format (the VERSION_MINOR the replay was written with) that //! the reader still accepts. Replays older than this are rejected: versions 90, 92, -//! 93, 94, 95, 96 and 97 changed the simulation (weighted pathfinding and diagonal +//! 93, 94, 95, 96, 97 and 99 changed the simulation (weighted pathfinding and diagonal //! timing, hiring-bucket iteration, trapped-colony elimination, fetch-job //! apportionment, round-trip routing and hiring, Echo building-order ids surviving a -//! load, route fields invalidated by the map's topology generation), so earlier -//! replays would diverge from what happened. -static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 97; +//! load, route fields invalidated by the map's topology generation, one worker level), +//! so earlier replays would diverge from what happened. +static constexpr Uint16 REPLAY_MINIMUM_VERSION_MINOR = 99; /// This class is used for reading replays. /// The replay stream is kept open and read every time you do retrieveOrder. diff --git a/src/SConscript b/src/SConscript index d249855d1..9a4678a4a 100644 --- a/src/SConscript +++ b/src/SConscript @@ -693,6 +693,13 @@ if not env['server'] and 'hiring-bucket-test' in COMMAND_LINE_TARGETS: hiring_sources += local.Object('HiringBucketHarness.o', '#test/HiringBucketHarness.cpp') local.Alias('hiring-bucket-test', local.Program('HiringBucketHarness', hiring_sources)) +# Which workers a building may hire: completed buildings take any worker, +# construction and upgrade sites keep the schooling-level gate. +if not env['server'] and 'level-gate-test' in COMMAND_LINE_TARGETS: + level_gate_sources = [source for source in source_files if source != 'Glob2.cpp'] + level_gate_sources += local.Object('LevelGateHarness.o', '#test/LevelGateHarness.cpp') + local.Alias('level-gate-test', local.Program('LevelGateHarness', level_gate_sources)) + if not env['server'] and 'custom-setup-test' in COMMAND_LINE_TARGETS: custom_sources = [source for source in source_files if source != 'Glob2.cpp'] custom_sources += local.Object('CustomGameSetupHarness.o', '#test/CustomGameSetupHarness.cpp') diff --git a/src/Version.h b/src/Version.h index c305debb2..725a15aa2 100644 --- a/src/Version.h +++ b/src/Version.h @@ -6,7 +6,7 @@ // This is the version of map and savegame format, and all of the recorded data on the server #define VERSION_MAJOR 0 #define MINIMUM_VERSION_MINOR 58 -#define VERSION_MINOR 98 +#define VERSION_MINOR 99 // version 91 saves the live RNG and routing state for deterministic continuation. // version 10 adds script saved in game // version 11 the gamesfiles do saves which building has been seen under fog of war. @@ -113,6 +113,8 @@ // construction, upgrades, unit/swarm management) that cooperate rather // than a phase-driven strategy. Purely additive: older clients simply // can't load a save that names this AI (see AI::load's default case). +// version 99 reads one worker level (build) where hiring used to read harvest and +// the upgrade menu build, and evens the two out on load //This must be updated when there are changes to YOG, MapHeader, GameHeader, BasePlayer, BaseTeam, //NetMessage, and the likes, in parallel to change of the VERSION_MINOR above diff --git a/src/building/Misc.cpp b/src/building/Misc.cpp index ea40a6e28..46a25f8c9 100644 --- a/src/building/Misc.cpp +++ b/src/building/Misc.cpp @@ -140,8 +140,7 @@ bool Building::canUnitWorkHere(Unit* unit) } else if(unit->typeNum == WORKER) { - int actLevel=unit->level[HARVEST]; - if(type->level <= actLevel) + if(type->level <= unit->workerLevel()) return true; } return false; diff --git a/src/building/Step.cpp b/src/building/Step.cpp index b60ebf5cf..13ac77347 100644 --- a/src/building/Step.cpp +++ b/src/building/Step.cpp @@ -33,7 +33,7 @@ namespace /// higher is preferred. int bringResourcesLevel(const Unit* unit) { - return unit->level[HARVEST] * HARVEST_LEVEL_WEIGHT + unit->level[WALK]; + return unit->workerLevel() * HARVEST_LEVEL_WEIGHT + unit->level[WALK]; } } @@ -550,7 +550,7 @@ bool Building::subscribeForFlagingStep() int hp=(unit->hp<<4)/unit->race->unitTypes[0][0].performance[HP]; int dist = distances[n]; int value=dist-timeLeft-hp; - int level = unit->level[HARVEST]; + int level = unit->workerLevel(); //We want to minimize the level of harvesting units, so that the higher level //units are available for more important work. if ((level < minLevel) || (level==minLevel && valuelevel[BUILD], selUnit->performance[BUILD]); ypos += YOFFSET_TEXT_LINE; - if (selUnit->performance[HARVEST]) - drawAbilityRow(rowX, ypos, "[Harvest]", 1 + selUnit->level[HARVEST], selUnit->performance[HARVEST]); - ypos += YOFFSET_TEXT_LINE; - if (selUnit->performance[ATTACK_SPEED]) drawAbilityRow(rowX, ypos, "[At. speed]", 1 + selUnit->level[ATTACK_SPEED], selUnit->performance[ATTACK_SPEED]); ypos += YOFFSET_TEXT_LINE; diff --git a/src/map/edit/MapEdit.h b/src/map/edit/MapEdit.h index a915f3d1b..d9f6ce807 100644 --- a/src/map/edit/MapEdit.h +++ b/src/map/edit/MapEdit.h @@ -591,8 +591,6 @@ class MapEdit ValueScrollBox* unitWalkLevelScrollBox; FractionValueText* unitSwimLevelLabel; ValueScrollBox* unitSwimLevelScrollBox; - FractionValueText* unitHarvestLevelLabel; - ValueScrollBox* unitHarvestLevelScrollBox; FractionValueText* unitBuildLevelLabel; ValueScrollBox* unitBuildLevelScrollBox; FractionValueText* unitAttackSpeedLevelLabel; diff --git a/src/map/edit/MapEditActionUnit.cpp b/src/map/edit/MapEditActionUnit.cpp index 03bafff6b..ba84711f9 100644 --- a/src/map/edit/MapEditActionUnit.cpp +++ b/src/map/edit/MapEditActionUnit.cpp @@ -107,8 +107,6 @@ bool MapEdit::performUnitAction(const std::string& action, int relMouseX, int re unitWalkLevelScrollBox->setValues(&view.selectedUnit->level[WALK]); unitSwimLevelLabel->setValues(&view.selectedUnit->level[SWIM]); unitSwimLevelScrollBox->setValues(&view.selectedUnit->level[SWIM]); - unitHarvestLevelLabel->setValues(&view.selectedUnit->level[HARVEST]); - unitHarvestLevelScrollBox->setValues(&view.selectedUnit->level[HARVEST]); unitBuildLevelLabel->setValues(&view.selectedUnit->level[BUILD]); unitBuildLevelScrollBox->setValues(&view.selectedUnit->level[BUILD]); unitAttackSpeedLevelLabel->setValues(&view.selectedUnit->level[ATTACK_SPEED]); @@ -128,11 +126,6 @@ bool MapEdit::performUnitAction(const std::string& action, int relMouseX, int re unitSwimLevelLabel->disable(); unitSwimLevelScrollBox->disable(); } - if(!view.selectedUnit->canLearn[HARVEST]) - { - unitHarvestLevelLabel->disable(); - unitHarvestLevelScrollBox->disable(); - } if(!view.selectedUnit->canLearn[BUILD]) { unitBuildLevelLabel->disable(); @@ -163,13 +156,12 @@ bool MapEdit::performUnitAction(const std::string& action, int relMouseX, int re { refreshSelectedUnitPerformance(SWIM); } - else if(action=="update unit harvest level") - { - refreshSelectedUnitPerformance(HARVEST); - } else if(action=="update unit build level") { - refreshSelectedUnitPerformance(BUILD); + // One worker level: the build box drives harvest as well. + Unit* u=game.teams[Unit::GIDtoTeam(selectedUnitGID)]->myUnits[Unit::GIDtoID(selectedUnitGID)]; + u->setWorkerLevel(u->level[BUILD]); + hasMapBeenModified = true; } else if(action=="update unit attack speed level") { diff --git a/src/map/edit/MapEditCtor.cpp b/src/map/edit/MapEditCtor.cpp index 69cf0dba1..ba41ae97a 100644 --- a/src/map/edit/MapEditCtor.cpp +++ b/src/map/edit/MapEditCtor.cpp @@ -174,10 +174,8 @@ MapEdit::MapEdit() unitWalkLevelScrollBox = new ValueScrollBox(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 300, 112, 16), "unit editor", "unit editor walk level scroll box", "update unit walk level", NULL, 3); unitSwimLevelLabel = new FractionValueText(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 316, 128, 16), "unit editor", "unit editor swim level label", "", "[Swim]", NULL, 3); unitSwimLevelScrollBox = new ValueScrollBox(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 332, 112, 16), "unit editor", "unit editor swim level scroll box", "update unit swim level", NULL, 3); - unitHarvestLevelLabel = new FractionValueText(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 348, 128, 16), "unit editor", "unit editor harvest level label", "", "[Harvest]", NULL, 3); - unitHarvestLevelScrollBox = new ValueScrollBox(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 364, 112, 16), "unit editor", "unit editor harvest level scroll box", "update unit harvest level", NULL, 3); - unitBuildLevelLabel = new FractionValueText(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 380, 128, 16), "unit editor", "unit editor build level label", "", "[Build]", NULL, 3); - unitBuildLevelScrollBox = new ValueScrollBox(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 396, 112, 16), "unit editor", "unit editor build level scroll box", "update unit build level", NULL, 3); + unitBuildLevelLabel = new FractionValueText(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 348, 128, 16), "unit editor", "unit editor build level label", "", "[Build]", NULL, 3); + unitBuildLevelScrollBox = new ValueScrollBox(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 364, 112, 16), "unit editor", "unit editor build level scroll box", "update unit build level", NULL, 3); unitAttackSpeedLevelLabel = new FractionValueText(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 348, 128, 16), "unit editor", "unit editor attack speed level label", "", "[At. speed]", NULL, 3); unitAttackSpeedLevelScrollBox = new ValueScrollBox(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 364, 112, 16), "unit editor", "unit editor attack speed level scroll box", "update unit attack speed level", NULL, 3); unitAttackStrengthLevelLabel = new FractionValueText(*this, widgetRectangle(globalContainer->gfx->getW()-RIGHT_MENU_WIDTH+8+decX, 380, 128, 16), "unit editor", "unit editor attack strength level label", "", "[At. strength]", NULL, 3); @@ -192,8 +190,6 @@ MapEdit::MapEdit() addWidget(unitWalkLevelScrollBox); addWidget(unitSwimLevelLabel); addWidget(unitSwimLevelScrollBox); - addWidget(unitHarvestLevelLabel); - addWidget(unitHarvestLevelScrollBox); addWidget(unitBuildLevelLabel); addWidget(unitBuildLevelScrollBox); addWidget(unitAttackSpeedLevelLabel); diff --git a/src/team/TeamRouting.cpp b/src/team/TeamRouting.cpp index c5c11eeb1..283b085ea 100644 --- a/src/team/TeamRouting.cpp +++ b/src/team/TeamRouting.cpp @@ -252,7 +252,7 @@ int Team::maxBuildLevel(void) Unit *u=myUnits[i]; if (u && u->performance[BUILD]) { - int unitLevel=u->level[BUILD]; + int unitLevel=u->workerLevel(); if (unitLevel>maxLevel) maxLevel=unitLevel; } diff --git a/src/unit/Unit.cpp b/src/unit/Unit.cpp index 12feba625..b8b25f5ea 100644 --- a/src/unit/Unit.cpp +++ b/src/unit/Unit.cpp @@ -324,3 +324,12 @@ void Unit::syncStep(void) if (magicActionAnimation > 0) magicActionAnimation--; } + +void Unit::setWorkerLevel(Sint32 newLevel) +{ + for (int ability : {(int)BUILD, (int)HARVEST}) + { + level[ability] = newLevel; + performance[ability] = race->getUnitType(typeNum, newLevel)->performance[ability]; + } +} diff --git a/src/unit/Unit.h b/src/unit/Unit.h index b313e5f7e..d9069d55d 100644 --- a/src/unit/Unit.h +++ b/src/unit/Unit.h @@ -260,6 +260,12 @@ class Unit : public UnitUtils //! Pathfinding swim class from the unit's walk and swim speeds (see Map::swimClass). int swimClass() const; Sint32 level[NB_ABILITY]; + //! The worker's schooling level. Harvest and build are taught together by + //! the school and mean one thing in play, which building tier the worker + //! may raise, so they are kept equal and read through here. + Sint32 workerLevel() const { return level[BUILD]; } + //! Set both halves of the worker level, and their performance, together. + void setWorkerLevel(Sint32 newLevel); bool canLearn[NB_ABILITY]; Sint32 experience; Sint32 experienceLevel; diff --git a/src/unit/UnitDisplacement.cpp b/src/unit/UnitDisplacement.cpp index d6a0ebeda..1e9c60fd8 100644 --- a/src/unit/UnitDisplacement.cpp +++ b/src/unit/UnitDisplacement.cpp @@ -342,9 +342,14 @@ void Unit::handleDisplacement(void) else { assert(canLearn[destinationPurpose]); - level[destinationPurpose] = attachedBuilding->type->level + 1; - UnitType *ut = race->getUnitType(typeNum, level[destinationPurpose]); - performance[destinationPurpose] = ut->performance[destinationPurpose]; + if (destinationPurpose == BUILD || destinationPurpose == HARVEST) + setWorkerLevel(attachedBuilding->type->level + 1); + else + { + level[destinationPurpose] = attachedBuilding->type->level + 1; + UnitType *ut = race->getUnitType(typeNum, level[destinationPurpose]); + performance[destinationPurpose] = ut->performance[destinationPurpose]; + } } diff --git a/src/unit/UnitSerialization.cpp b/src/unit/UnitSerialization.cpp index db7b58166..9e5e97830 100644 --- a/src/unit/UnitSerialization.cpp +++ b/src/unit/UnitSerialization.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière +#include #include "Unit.h" #include "Race.h" #include "Team.h" @@ -86,6 +87,12 @@ void Unit::load(GAGCore::InputStream *stream, Team *owner, Sint32 versionMinor) stream->readLeaveSection(); } stream->readLeaveSection(); + // Harvest and build are one worker level. A save from before that could + // hold the two apart (the editor used to offer a box for each), so even + // such a worker out to the higher of the two. + if (versionMinor < FILE_FORMAT_VERSION_ONE_WORKER_LEVEL + && canLearn[BUILD] && level[HARVEST] != level[BUILD]) + setWorkerLevel(std::max(level[HARVEST], level[BUILD])); experience = stream->readSint32("experience"); diff --git a/test/LevelGateHarness.cpp b/test/LevelGateHarness.cpp new file mode 100644 index 000000000..f6822b248 --- /dev/null +++ b/test/LevelGateHarness.cpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Real-engine regression for Building::canUnitWorkHere: the schooling gate +// reads the one worker level (build), not the harvest half. +#define SDL_MAIN_HANDLED +#ifdef main +#undef main +#endif +#include "GlobalContainer.h" +#include "FileManager.h" +#include +#include +#include "Game.h" +#include "GameGUI.h" +#include "Unit.h" +#include "Team.h" +#include "MapInternal.h" +#include "Race.h" +#include "Ressource.h" +#include "IntBuildingType.h" +#include +#include + +GlobalContainer* globalContainer = nullptr; + +static void require(bool ok, const char* message) +{ + if (!ok) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); } +} + +// One hiring pass: a worker of the given schooling level, carrying `carried`, +// four tiles from a building of the given kind that wants it. Returns whether +// the building hired the worker; `tooLowLevel` reports the rejection counter. +static bool hiringPass(const char* kind, int level, bool site, int carried, int workerLevel, Uint32* tooLowLevel, int harvestLevel = -1) +{ + GameGUI gui; + Game& game = gui.game; + game.map.setSize(5, 5, GRASS); + game.map.setGame(&game); + game.addTeam(0); + Team* team = game.teams[0]; + team->race.loadDefault(); + int typeNum = globalContainer->buildingsTypes.getTypeNum(kind, level, site); + require(typeNum >= 0, "building type exists"); + Building* building = game.addBuilding(8, 8, typeNum, 0); + require(building != nullptr, "create building"); + building->maxUnitWorking = 2; + building->resources[carried] = 0; + building->updateCallLists(); + Unit* unit = game.addUnit(12, 12, 0, WORKER, 0, 255, 0, 0); + require(unit != nullptr, "create worker"); + unit->level[HARVEST] = harvestLevel < 0 ? workerLevel : harvestLevel; + unit->level[BUILD] = workerLevel; + unit->carriedResource = carried; + unit->activity = Unit::ACT_RANDOM; + unit->medical = Unit::MED_FREE; + team->updateAllBuildingTasks(); + *tooLowLevel = building->unitsFailingRequirements[Building::UnitTooLowLevel]; + bool hired = building->unitsWorking.size() == 1; + std::printf("%s level %d %s, worker level %d -> %s (too low level: %u)\n", + kind, level + 1, site ? "site" : "completed", workerLevel, hired ? "hired" : "refused", *tooLowLevel); + return hired; +} + +int main(int argc, char** argv) +{ + SDL_SetMainReady(); + require(argc == 3, "usage: harness PROFILE ROOT"); + require(std::string(argv[1]).find("glob2-save-test-") == 0, "disposable profile required"); + GlobalContainer globals(argv[1]); + globals.fileManager->addDir(argv[2]); + globalContainer = &globals; + globals.runNoX = true; + globals.settings.rememberUnit = false; + globals.buildingsTypes.init(); + IntBuildingType::init(); + Race::loadDefault(); + + Uint32 tooLow = 0; + // The gate compares the building's tier with the worker level. + require(hiringPass("inn", 0, false, WHEAT, 0, &tooLow) && tooLow == 0, "unschooled worker stocks a level-1 inn"); + require(!hiringPass("inn", 1, false, WHEAT, 0, &tooLow) && tooLow == 1, "unschooled worker refused by a level-2 inn"); + require(hiringPass("inn", 1, false, WHEAT, 1, &tooLow) && tooLow == 0, "level-1 worker stocks a level-2 inn"); + require(!hiringPass("inn", 1, true, WOOD, 0, &tooLow) && tooLow == 1, "unschooled worker refused by a level-2 inn site"); + require(hiringPass("inn", 1, true, WOOD, 1, &tooLow) && tooLow == 0, "level-1 worker builds a level-2 inn site"); + require(hiringPass("inn", 2, true, WOOD, 2, &tooLow) && tooLow == 0, "level-2 worker builds a level-3 inn site"); + // It reads the worker level (build); a stale harvest value does not matter. + require(hiringPass("inn", 1, true, WOOD, 1, &tooLow, 0) && tooLow == 0, "build level 1 with harvest 0 builds a level-2 inn site"); + require(!hiringPass("inn", 1, true, WOOD, 0, &tooLow, 1) && tooLow == 1, "build level 0 with harvest 1 is refused by a level-2 inn site"); + std::puts("PASS the hiring gate reads the one worker level"); + return 0; +}