Index: binaries/data/mods/public/maps/random/empire.js =================================================================== --- binaries/data/mods/public/maps/random/empire.js +++ binaries/data/mods/public/maps/random/empire.js @@ -3,246 +3,249 @@ Engine.LoadLibrary("rmgen2"); Engine.LoadLibrary("rmbiome"); -setSelectedBiome(); - -const g_Map = new RandomMap(2, g_Terrains.mainTerrain); - -initTileClasses(); - -createArea( - new MapBoundsPlacer(), - new TileClassPainter(g_TileClasses.land)); - -Engine.SetProgress(10); - -const teamsArray = getTeamsArray(); -const startAngle = randomAngle(); -createBases( - ...playerPlacementByPattern( - "stronghold", - fractionToTiles(0.37), - fractionToTiles(0.04), - startAngle, - undefined), - false); -Engine.SetProgress(20); - -// Change the starting angle and add the players again -let rotation = Math.PI; - -if (teamsArray.length == 2) - rotation = Math.PI / 2; - -if (teamsArray.length == 4) - rotation = 5/4 * Math.PI; - -createBases( - ...playerPlacementByPattern( - "stronghold", - fractionToTiles(0.15), - fractionToTiles(0.04), - startAngle + rotation, - undefined), - false); -Engine.SetProgress(40); - -addElements(shuffleArray([ - { - "func": addHills, - "avoid": [ - g_TileClasses.bluff, 5, - g_TileClasses.hill, 15, - g_TileClasses.mountain, 2, - g_TileClasses.plateau, 5, - g_TileClasses.player, 20, - g_TileClasses.valley, 2, - g_TileClasses.water, 2 - ], - "sizes": g_AllSizes, - "mixes": g_AllMixes, - "amounts": ["tons"] - }, - { - "func": addMountains, - "avoid": [ - g_TileClasses.bluff, 20, - g_TileClasses.mountain, 25, - g_TileClasses.plateau, 20, - g_TileClasses.player, 20, - g_TileClasses.valley, 10, - g_TileClasses.water, 15 - ], - "sizes": ["huge"], - "mixes": ["same", "similar"], - "amounts": ["tons"] - }, - { - "func": addPlateaus, - "avoid": [ - g_TileClasses.bluff, 20, - g_TileClasses.mountain, 25, - g_TileClasses.plateau, 20, - g_TileClasses.player, 40, - g_TileClasses.valley, 10, - g_TileClasses.water, 15 - ], - "sizes": ["huge"], - "mixes": ["same", "similar"], - "amounts": ["tons"] - } -])); -Engine.SetProgress(50); - -addElements([ - { - "func": addLayeredPatches, - "avoid": [ - g_TileClasses.bluff, 2, - g_TileClasses.dirt, 5, - g_TileClasses.forest, 2, - g_TileClasses.mountain, 2, - g_TileClasses.plateau, 2, - g_TileClasses.player, 12, - g_TileClasses.water, 3 - ], - "sizes": ["normal"], - "mixes": ["normal"], - "amounts": ["normal"] - }, - { - "func": addDecoration, - "avoid": [ - g_TileClasses.bluff, 2, - g_TileClasses.forest, 2, - g_TileClasses.mountain, 2, - g_TileClasses.plateau, 2, - g_TileClasses.player, 12, - g_TileClasses.water, 3 - ], - "sizes": ["normal"], - "mixes": ["normal"], - "amounts": ["normal"] - } -]); -Engine.SetProgress(60); - -addElements(shuffleArray([ - { - "func": addMetal, - "avoid": [ - g_TileClasses.berries, 5, - g_TileClasses.bluff, 5, - g_TileClasses.forest, 3, - g_TileClasses.mountain, 2, - g_TileClasses.player, 30, - g_TileClasses.rock, 10, - g_TileClasses.metal, 20, - g_TileClasses.plateau, 2, - g_TileClasses.water, 3 - ], - "sizes": ["normal"], - "mixes": ["same"], - "amounts": g_AllAmounts - }, - { - "func": addStone, - "avoid": [g_TileClasses.berries, 5, - g_TileClasses.bluff, 5, - g_TileClasses.forest, 3, - g_TileClasses.mountain, 2, - g_TileClasses.player, 30, - g_TileClasses.rock, 20, - g_TileClasses.metal, 10, - g_TileClasses.plateau, 2, - g_TileClasses.water, 3 - ], - "sizes": ["normal"], - "mixes": ["same"], - "amounts": g_AllAmounts - }, - { - "func": addForests, - "avoid": [ - g_TileClasses.berries, 5, - g_TileClasses.bluff, 5, - g_TileClasses.forest, 18, - g_TileClasses.metal, 3, - g_TileClasses.mountain, 5, - g_TileClasses.plateau, 2, - g_TileClasses.player, 20, - g_TileClasses.rock, 3, - g_TileClasses.water, 2 - ], - "sizes": g_AllSizes, - "mixes": g_AllMixes, - "amounts": ["few", "normal", "many", "tons"] - } -])); -Engine.SetProgress(80); - -addElements(shuffleArray([ - { - "func": addBerries, - "avoid": [ - g_TileClasses.berries, 30, - g_TileClasses.bluff, 5, - g_TileClasses.forest, 5, - g_TileClasses.metal, 10, - g_TileClasses.mountain, 2, - g_TileClasses.plateau, 2, - g_TileClasses.player, 20, - g_TileClasses.rock, 10, - g_TileClasses.water, 3 - ], - "sizes": g_AllSizes, - "mixes": g_AllMixes, - "amounts": g_AllAmounts - }, - { - "func": addAnimals, - "avoid": [ - g_TileClasses.animals, 20, - g_TileClasses.bluff, 5, - g_TileClasses.forest, 2, - g_TileClasses.metal, 2, - g_TileClasses.mountain, 1, - g_TileClasses.plateau, 2, - g_TileClasses.player, 20, - g_TileClasses.rock, 2, - g_TileClasses.water, 3 - ], - "sizes": g_AllSizes, - "mixes": g_AllMixes, - "amounts": g_AllAmounts - }, - { - "func": addStragglerTrees, - "avoid": [ - g_TileClasses.berries, 5, - g_TileClasses.bluff, 5, - g_TileClasses.forest, 7, - g_TileClasses.metal, 2, - g_TileClasses.mountain, 1, - g_TileClasses.plateau, 2, - g_TileClasses.player, 12, - g_TileClasses.rock, 2, - g_TileClasses.water, 5 - ], - "sizes": g_AllSizes, - "mixes": g_AllMixes, - "amounts": g_AllAmounts - } -])); -Engine.SetProgress(90); - -placePlayersNomad( - g_TileClasses.player, - avoidClasses( - g_TileClasses.plateau, 4, - g_TileClasses.forest, 1, - g_TileClasses.metal, 4, - g_TileClasses.rock, 4, - g_TileClasses.mountain, 4, - g_TileClasses.animals, 2)); - -g_Map.ExportMap(); +function* GenerateMap() +{ + setSelectedBiome(); + + global.g_Map = new RandomMap(2, g_Terrains.mainTerrain); + + initTileClasses(); + + createArea( + new MapBoundsPlacer(), + new TileClassPainter(g_TileClasses.land)); + + yield 10; + + const teamsArray = getTeamsArray(); + const startAngle = randomAngle(); + createBases( + ...playerPlacementByPattern( + "stronghold", + fractionToTiles(0.37), + fractionToTiles(0.04), + startAngle, + undefined), + false); + yield 20; + + // Change the starting angle and add the players again + let rotation = Math.PI; + + if (teamsArray.length == 2) + rotation = Math.PI / 2; + + if (teamsArray.length == 4) + rotation = 5/4 * Math.PI; + + createBases( + ...playerPlacementByPattern( + "stronghold", + fractionToTiles(0.15), + fractionToTiles(0.04), + startAngle + rotation, + undefined), + false); + yield 40; + + addElements(shuffleArray([ + { + "func": addHills, + "avoid": [ + g_TileClasses.bluff, 5, + g_TileClasses.hill, 15, + g_TileClasses.mountain, 2, + g_TileClasses.plateau, 5, + g_TileClasses.player, 20, + g_TileClasses.valley, 2, + g_TileClasses.water, 2 + ], + "sizes": g_AllSizes, + "mixes": g_AllMixes, + "amounts": ["tons"] + }, + { + "func": addMountains, + "avoid": [ + g_TileClasses.bluff, 20, + g_TileClasses.mountain, 25, + g_TileClasses.plateau, 20, + g_TileClasses.player, 20, + g_TileClasses.valley, 10, + g_TileClasses.water, 15 + ], + "sizes": ["huge"], + "mixes": ["same", "similar"], + "amounts": ["tons"] + }, + { + "func": addPlateaus, + "avoid": [ + g_TileClasses.bluff, 20, + g_TileClasses.mountain, 25, + g_TileClasses.plateau, 20, + g_TileClasses.player, 40, + g_TileClasses.valley, 10, + g_TileClasses.water, 15 + ], + "sizes": ["huge"], + "mixes": ["same", "similar"], + "amounts": ["tons"] + } + ])); + yield 50; + + addElements([ + { + "func": addLayeredPatches, + "avoid": [ + g_TileClasses.bluff, 2, + g_TileClasses.dirt, 5, + g_TileClasses.forest, 2, + g_TileClasses.mountain, 2, + g_TileClasses.plateau, 2, + g_TileClasses.player, 12, + g_TileClasses.water, 3 + ], + "sizes": ["normal"], + "mixes": ["normal"], + "amounts": ["normal"] + }, + { + "func": addDecoration, + "avoid": [ + g_TileClasses.bluff, 2, + g_TileClasses.forest, 2, + g_TileClasses.mountain, 2, + g_TileClasses.plateau, 2, + g_TileClasses.player, 12, + g_TileClasses.water, 3 + ], + "sizes": ["normal"], + "mixes": ["normal"], + "amounts": ["normal"] + } + ]); + yield 60; + + addElements(shuffleArray([ + { + "func": addMetal, + "avoid": [ + g_TileClasses.berries, 5, + g_TileClasses.bluff, 5, + g_TileClasses.forest, 3, + g_TileClasses.mountain, 2, + g_TileClasses.player, 30, + g_TileClasses.rock, 10, + g_TileClasses.metal, 20, + g_TileClasses.plateau, 2, + g_TileClasses.water, 3 + ], + "sizes": ["normal"], + "mixes": ["same"], + "amounts": g_AllAmounts + }, + { + "func": addStone, + "avoid": [g_TileClasses.berries, 5, + g_TileClasses.bluff, 5, + g_TileClasses.forest, 3, + g_TileClasses.mountain, 2, + g_TileClasses.player, 30, + g_TileClasses.rock, 20, + g_TileClasses.metal, 10, + g_TileClasses.plateau, 2, + g_TileClasses.water, 3 + ], + "sizes": ["normal"], + "mixes": ["same"], + "amounts": g_AllAmounts + }, + { + "func": addForests, + "avoid": [ + g_TileClasses.berries, 5, + g_TileClasses.bluff, 5, + g_TileClasses.forest, 18, + g_TileClasses.metal, 3, + g_TileClasses.mountain, 5, + g_TileClasses.plateau, 2, + g_TileClasses.player, 20, + g_TileClasses.rock, 3, + g_TileClasses.water, 2 + ], + "sizes": g_AllSizes, + "mixes": g_AllMixes, + "amounts": ["few", "normal", "many", "tons"] + } + ])); + yield 80; + + addElements(shuffleArray([ + { + "func": addBerries, + "avoid": [ + g_TileClasses.berries, 30, + g_TileClasses.bluff, 5, + g_TileClasses.forest, 5, + g_TileClasses.metal, 10, + g_TileClasses.mountain, 2, + g_TileClasses.plateau, 2, + g_TileClasses.player, 20, + g_TileClasses.rock, 10, + g_TileClasses.water, 3 + ], + "sizes": g_AllSizes, + "mixes": g_AllMixes, + "amounts": g_AllAmounts + }, + { + "func": addAnimals, + "avoid": [ + g_TileClasses.animals, 20, + g_TileClasses.bluff, 5, + g_TileClasses.forest, 2, + g_TileClasses.metal, 2, + g_TileClasses.mountain, 1, + g_TileClasses.plateau, 2, + g_TileClasses.player, 20, + g_TileClasses.rock, 2, + g_TileClasses.water, 3 + ], + "sizes": g_AllSizes, + "mixes": g_AllMixes, + "amounts": g_AllAmounts + }, + { + "func": addStragglerTrees, + "avoid": [ + g_TileClasses.berries, 5, + g_TileClasses.bluff, 5, + g_TileClasses.forest, 7, + g_TileClasses.metal, 2, + g_TileClasses.mountain, 1, + g_TileClasses.plateau, 2, + g_TileClasses.player, 12, + g_TileClasses.rock, 2, + g_TileClasses.water, 5 + ], + "sizes": g_AllSizes, + "mixes": g_AllMixes, + "amounts": g_AllAmounts + } + ])); + yield 90; + + placePlayersNomad( + g_TileClasses.player, + avoidClasses( + g_TileClasses.plateau, 4, + g_TileClasses.forest, 1, + g_TileClasses.metal, 4, + g_TileClasses.rock, 4, + g_TileClasses.mountain, 4, + g_TileClasses.animals, 2)); + + return g_Map; +} Index: binaries/data/mods/public/maps/random/rmgen/RandomMap.js =================================================================== --- binaries/data/mods/public/maps/random/rmgen/RandomMap.js +++ binaries/data/mods/public/maps/random/rmgen/RandomMap.js @@ -479,14 +479,14 @@ }; }; -RandomMap.prototype.ExportMap = function() +RandomMap.prototype.MakeExportable = function() { if (g_Environment.Water.WaterBody.Height === undefined) g_Environment.Water.WaterBody.Height = SEA_LEVEL - 0.1; this.logger.close(); - Engine.ExportMap({ + return { "entities": this.exportEntityList(), "height": this.exportHeightData(), "seaLevel": SEA_LEVEL, @@ -495,5 +495,10 @@ "tileData": this.exportTerrainTextures(), "Camera": g_Camera, "Environment": g_Environment - }); + }; +} + +RandomMap.prototype.ExportMap = function() +{ + Engine.ExportMap(this.MakeExportable()) }; Index: binaries/data/mods/public/maps/random/tests/test_Generator.js =================================================================== --- /dev/null +++ binaries/data/mods/public/maps/random/tests/test_Generator.js @@ -0,0 +1,27 @@ +Engine.GetTemplate = (path) => ( + { + "Identity": { + "GenericName": null, + "Icon": null, + "History": null + } + }); + +Engine.LoadLibrary("rmgen"); + +RandomMapLogger.prototype.printDirectly = function(string) +{ + log(string); + // print(string); +}; + + +function* GenerateMap(mapSettings) +{ + TS_ASSERT_DIFFER(mapSettings.Seed, undefined); + // Phew... that assertion took a while. ;) Let's update the progress bar. + yield 17; + const map = new RandomMap(0, "blackness"); + yield 50; + return map; +} Index: binaries/data/mods/public/maps/random/tests/test_RecoverableError.js =================================================================== --- /dev/null +++ binaries/data/mods/public/maps/random/tests/test_RecoverableError.js @@ -0,0 +1,34 @@ +Engine.GetTemplate = path => ( + { + "Identity": { + "GenericName": null, + "Icon": null, + "History": null + } + }); + +Engine.LoadLibrary("rmgen"); + +RandomMapLogger.prototype.printDirectly = function(string) +{ + log(string); + // print(string); +}; + +function* GenerateMap() +{ + try + { + yield; + } + catch (error) + { + TS_ASSERT(error instanceof Error); + TS_ASSERT_EQUALS(error.message, "Failed to convert the yielded value to an intager."); + const map = new RandomMap(0, "blackness"); + yield 50; + return map; + } + + TS_FAIL("The yield statement didn't throw an error."); +} Index: binaries/data/tests/test_setup.js =================================================================== --- binaries/data/tests/test_setup.js +++ binaries/data/tests/test_setup.js @@ -32,6 +32,12 @@ fail("Expected equal, got " + uneval(x) + " !== " + uneval(y)); }; +global.TS_ASSERT_DIFFER = function(x, y) +{ + if (x === y) + fail("Expected differ, got " + uneval(x) + " === " + uneval(y)) +} + global.TS_ASSERT_EQUALS_APPROX = function(x, y, maxDifference) { TS_ASSERT_NUMBER(maxDifference); Index: source/graphics/MapGenerator.cpp =================================================================== --- source/graphics/MapGenerator.cpp +++ source/graphics/MapGenerator.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -47,6 +47,8 @@ namespace { +constexpr const char* GENERATOR_NAME{"GenerateMap"}; + bool MapGenerationInterruptCallback(JSContext* UNUSED(cx)) { // This may not use SDL_IsQuitRequested(), because it runs in a thread separate to SDL, see SDL_PumpEvents @@ -423,5 +425,47 @@ return nullptr; } - return mapData; + LOGMESSAGE("Run RMS generator"); + bool hasGenerator; + JS::RootedObject globalAsObject{rq.cx, &JS::HandleValue{global}.toObject()}; + if (!JS_HasProperty(rq.cx, globalAsObject, GENERATOR_NAME, &hasGenerator)) + { + LOGERROR("RunMapGenerationScript: failed to search `%s`.", GENERATOR_NAME); + return nullptr; + } + + if (mapData != nullptr) + { + if (!hasGenerator) + LOGWARNING("The map generation script contains a `%s` but `Engine.ExportMap` was already " + "called. `%s` isn't called, preserving the old behavior.", GENERATOR_NAME, + GENERATOR_NAME); + return mapData; + } + + try + { + JS::RootedValue map{rq.cx, ScriptFunction::RunGenerator(rq, global, GENERATOR_NAME, settingsVal, + [&](const JS::HandleValue value) + { + int tempProgress; + if (!Script::FromJSVal(rq, value, tempProgress)) + throw std::runtime_error{"Failed to convert the yielded value to an " + "integer."}; + progress.store(tempProgress); + })}; + + JS::RootedValue exportedMap{rq.cx}; + const bool exportSuccess{ScriptFunction::Call(rq, map, "MakeExportable", &exportedMap)}; + return Script::WriteStructuredClone(rq, exportSuccess ? exportedMap : map); + } + catch(const std::exception& e) + { + LOGERROR("%s", e.what()); + return nullptr; + } + catch(...) + { + return nullptr; + } } Index: source/graphics/tests/test_MapGenerator.h =================================================================== --- source/graphics/tests/test_MapGenerator.h +++ source/graphics/tests/test_MapGenerator.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -51,19 +51,30 @@ for (const VfsPath& path : paths) { + TestLogger logger; ScriptInterface scriptInterface("Engine", "MapGenerator", g_ScriptContext); ScriptTestSetup(scriptInterface); - // It's never read in the test so it doesn't matter to what value it's initialized. For - // good practice it's initialized to 1. std::atomic progress{1}; const Script::StructuredClone result{RunMapGenerationScript(progress, scriptInterface, path, "{\"Seed\": 0}", JSPROP_ENUMERATE | JSPROP_PERMANENT)}; - // The test scripts don't call `ExportMap` so `RunMapGenerationScript` allways returns - // `nullptr`. - TS_ASSERT_EQUALS(result, nullptr); + if (path == "maps/random/tests/test_Generator.js" || + path == "maps/random/tests/test_RecoverableError.js") + { + TS_ASSERT_EQUALS(progress.load(), 50); + TS_ASSERT_DIFFERS(result, nullptr); + } + else + { + // The test scripts don't call `ExportMap` so `RunMapGenerationScript` allways + // returns `nullptr`. + TS_ASSERT_EQUALS(result, nullptr); + // Because the test scripts don't call `ExportMap`, `GenerateMap` is searched. + TS_ASSERT_STR_CONTAINS(logger.GetOutput(), + "Failed to call the generator `GenerateMap`."); + } } } }; Index: source/scriptinterface/FunctionWrapper.h =================================================================== --- source/scriptinterface/FunctionWrapper.h +++ source/scriptinterface/FunctionWrapper.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -22,8 +22,10 @@ #include "ScriptExceptions.h" #include "ScriptRequest.h" +#include #include #include +#include #include class ScriptInterface; @@ -235,7 +237,12 @@ if constexpr (!std::is_same_v) { if (success) - Script::FromJSVal(rq, jsRet, ret); + { + if constexpr (std::is_same_v) + ret = jsRet; + else + Script::FromJSVal(rq, jsRet, ret); + } } else UNUSED2(ret); // VS2017 complains. @@ -244,6 +251,14 @@ return !ScriptException::CatchPending(rq) && success; } + static void ConstructError(const ScriptRequest& rq, const char* error, JS::MutableHandleValue rval, + const std::string& message) + { + JS::RootedValue global{rq.cx, rq.globalValue()}; + if (!ScriptFunction::Call(rq, global, error, rval, message)) + throw std::runtime_error{fmt::format("Failed to call `{}`.", error)}; + } + /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// public: @@ -346,6 +361,71 @@ return Call(rq, val, name, IgnoreResult, std::forward(args)...); } + /** + * Call a JS function @a name, property of object @a val, with the argument @a args. Repeatetly + * invokes @a yieldCallback with the yielded value. + * @return the final value of the generator. + */ + template + static JS::Value RunGenerator(const ScriptRequest& rq, JS::HandleValue val, const char* name, + JS::HandleValue arg, Callback yieldCallback) + { + JS::RootedValue generator{rq.cx}; + if (!ScriptFunction::Call(rq, val, name, JS::MutableHandleValue{&generator}, arg)) + throw std::runtime_error{fmt::format("Failed to call the generator `{}`.", name)}; + + const auto continueGeneration = [&](const char* property, auto... args) -> JS::Value + { + JS::RootedValue iteratorResult{rq.cx}; + if (!ScriptFunction::Call(rq, generator, property, + JS::MutableHandleValue{&iteratorResult}, args...)) + { + throw std::runtime_error{fmt::format("Failed to call `{}`.", name)}; + } + return iteratorResult; + }; + + JS::PersistentRootedValue error{rq.cx, JS::UndefinedValue()}; + while (true) + { + JS::RootedValue iteratorResult{rq.cx, error.isUndefined() ? continueGeneration("next") : + continueGeneration("throw", std::exchange(error, JS::UndefinedValue()))}; + + JS::RootedObject iteratorResultObject{rq.cx, &JS::HandleValue{iteratorResult}.toObject()}; + + std::string erroneousProperty; + bool done; + if (!Script::FromJSProperty(rq, iteratorResult, "done", done, true)) + erroneousProperty = "done"; + + JS::RootedValue value{rq.cx}; + if (erroneousProperty.empty() && + !JS_GetProperty(rq.cx, iteratorResultObject, "value", &value)) + { + erroneousProperty = "value"; + } + + if (!erroneousProperty.empty()) + { + ConstructError(rq, "TypeError", &error, fmt::format( + "Failed to get `{}` from an IteratorResult.", std::move(erroneousProperty))); + continue; + } + + if (done) + return value; + + try + { + yieldCallback(value); + } + catch (const std::exception& e) + { + ConstructError(rq, "Error", &error, e.what()); + } + } + } + /** * Return a function spec from a C++ function. */