diff --git a/.gitignore b/.gitignore index 32bf49eb39f5e..47324598c0c77 100644 --- a/.gitignore +++ b/.gitignore @@ -117,7 +117,6 @@ Thumbs.db !/ios-moe/xcode/*.xcodeproj/xcshareddata !/ios-moe/xcode/*.xcodeproj/project.pbxproj /ios-moe/xcode/native/ -SaveFiles/ android/android-release.apk android/assets/GameSettings.json android/release/output.json @@ -142,3 +141,17 @@ android/assets/music/ # Visual Studio Code .vscode/ + +# Unciv +maps/ +mods/ +SaveFiles/ +SaveFiles +GameSettings.json + +# Python +*.pyc +__pycache__ + +# Geany +.geany diff --git a/android/Images/OtherIcons/Code.png b/android/Images/OtherIcons/Code.png new file mode 100644 index 0000000000000..e6e3bbc442cfc Binary files /dev/null and b/android/Images/OtherIcons/Code.png differ diff --git a/android/Images/OtherIcons/Script.png b/android/Images/OtherIcons/Script.png new file mode 100644 index 0000000000000..826bcefa90e3b Binary files /dev/null and b/android/Images/OtherIcons/Script.png differ diff --git a/android/assets/jsons/Civ V - Gods & Kings/Terrains.json b/android/assets/jsons/Civ V - Gods & Kings/Terrains.json index f18262e4dcc28..63d4a57f1df34 100644 --- a/android/assets/jsons/Civ V - Gods & Kings/Terrains.json +++ b/android/assets/jsons/Civ V - Gods & Kings/Terrains.json @@ -230,7 +230,7 @@ "uniques": ["Nullifies all other stats this tile provides", "Doesn't generate naturally"], // For map editor only - the generator won't place it without code or enabling uniques // If the map generator is ever updated to always take these into account, it should also take the "Doesn't generate naturally" unique into account - "occursOn": ["Grassland","Plains","Desert","Tundra","Snow","Forest","Jungle","Hill","Flood plains","Marsh","Oasis"] + "occursOn": ["Grassland","Plains","Desert","Tundra","Snow","Forest","Jungle","Hill","Flood plains","Marsh","Oasis"], "defenceBonus": -0.15 }, { @@ -490,7 +490,7 @@ "impassable": true, "unbuildable": true, "weight": 10 - }, + }//, /* // BNW wonders { diff --git a/android/assets/jsons/Civ V - Vanilla/Terrains.json b/android/assets/jsons/Civ V - Vanilla/Terrains.json index 74b41ae5a3a9f..bf71b2a802fa5 100644 --- a/android/assets/jsons/Civ V - Vanilla/Terrains.json +++ b/android/assets/jsons/Civ V - Vanilla/Terrains.json @@ -230,7 +230,7 @@ "uniques": ["Nullifies all other stats this tile provides", "Doesn't generate naturally"], // For map editor only - the generator won't place it without code or enabling uniques // If the map generator is ever updated to always take these into account, it should also take the "Doesn't generate naturally" unique into account - "occursOn": ["Grassland","Plains","Desert","Tundra","Snow","Forest","Jungle","Hill","Flood plains","Marsh","Oasis"] + "occursOn": ["Grassland","Plains","Desert","Tundra","Snow","Forest","Jungle","Hill","Flood plains","Marsh","Oasis"], "defenceBonus": -0.15 }, { @@ -490,7 +490,7 @@ "impassable": true, "unbuildable": true, "weight": 10 - }, + }//, /* // BNW wonders { diff --git a/android/assets/scripting/LICENSE b/android/assets/scripting/LICENSE new file mode 100644 index 0000000000000..9c59f04b1059b --- /dev/null +++ b/android/assets/scripting/LICENSE @@ -0,0 +1 @@ +Copyright 2021 will-ca. All rights reserved (for now). diff --git a/android/assets/scripting/ScriptingEngineConstants.json b/android/assets/scripting/ScriptingEngineConstants.json new file mode 100644 index 0000000000000..f5ef5fec0a6f3 --- /dev/null +++ b/android/assets/scripting/ScriptingEngineConstants.json @@ -0,0 +1,61 @@ +{ // Internal directories can't be identified, traversed, or copied on Desktop because all assets are indistinguishable on the classpath or something. So manually list out all files that are part of each engine's runtime environment instead. + engines: { + python: { + files: [ + unciv_lib/ + unciv_lib/__init__.py + unciv_lib/api.py + unciv_lib/autocompletion.py + unciv_lib/ipc.py + unciv_lib/shadow.py + unciv_lib/utils.py + unciv_lib/wrapping.py + PythonScripting.md + unciv_pyhelpers.py + main.py + unciv_scripting_examples/ + unciv_scripting_examples/__init__.py + unciv_scripting_examples/EndTimes.py + unciv_scripting_examples/EventPopup.py + unciv_scripting_examples/ExternalPipe.py + unciv_scripting_examples/MapEditingMacros.py + unciv_scripting_examples/Merfolk.py + unciv_scripting_examples/PlayerMacros.py + unciv_scripting_examples/ProceduralTechtree.py + unciv_scripting_examples/Tests.py + unciv_scripting_examples/TicTacToe.py + unciv_scripting_examples/Utils.py + unciv_scripting_examples/example_assets/EarthTerrainFantasyHex.jpg + unciv_scripting_examples/example_assets/EarthTerrainRaw.png + unciv_scripting_examples/example_assets/EarthTopography.png + unciv_scripting_examples/example_assets/Elizabeth300 + unciv_scripting_examples/example_assets/StarryNight.jpg + unciv_scripting_examples/example_assets/TurboRainbow.png + unciv_scripting_examples/example_assets/WheatField.jpg + ] + syntaxHighlightingRegexStack: [ + ] + } + lua: { + files: [ + unciv/ + main.lua + ] + syntaxHighlightingRegexStack: [ + ] + } + qjs: { + files: [ + unciv/ + main.js + ] + syntaxHighlightingRegexStack: [ + ] + } + } + + sharedfiles: [ + ScriptAPI.json + ScriptAPIConstants.json + ] +} diff --git a/android/assets/scripting/enginefiles/lua/main.lua b/android/assets/scripting/enginefiles/lua/main.lua new file mode 100644 index 0000000000000..a21f02f41865f --- /dev/null +++ b/android/assets/scripting/enginefiles/lua/main.lua @@ -0,0 +1,19 @@ + +io.stdout:setvbuf('full') + + +function motd () + return "\nRunning ".._VERSION..".\n\nThis backend is HIGHLY EXPERIMENTAL. It does not implement any API bindings yet, and it may not be stable. Use it at your own risk!\n\n" +end + + +while true do + _in = io.stdin:read() + io.stdout:write("> ".._in.."\n") + _, _out = pcall(load("return ".._in)) + if not _ then + _, _out = pcall(load(_in)) + end + io.stdout:write((_out or "").."\n") + io.stdout:flush() +end diff --git a/android/assets/scripting/enginefiles/python/PythonScripting.md b/android/assets/scripting/enginefiles/python/PythonScripting.md new file mode 100644 index 0000000000000..1c34da62d2ca2 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/PythonScripting.md @@ -0,0 +1,522 @@ + + +The Python API described by this document is built on the [IPC protocols and execution model described in `/core/Module.md`](../../../../../core/Module.md#package-comuncivscriptingprotocol). + +TODO: Most of this is completely obsolete now, with the switch from bind-by-path to bind-by-reference. + +All values used in Python should pass through real(). + +--- + +## Overview + +There are basically two types of Python objects in this API: + +* Wrappers. +* Tokens. + +--- + +## Foreign Object Wrappers + +A **wrapper** is an object that stores a list of attribute names, keys, and function call parameters corresponding to a path in the Kotlin/JVM namespace. E.G.: `"civInfo.civilizations[0].population.setPopulation"` is a string representation of a wrapped path that begins with two attribute accesses, followed by one array index, and two more attribute names. + +```python3 +[ + {'type': 'Property', 'name': 'someAttribute', 'params': []}, + {'type': 'Key', 'name': , 'params': [5]}, + {'type': 'Call', 'name': , 'params': ["arg1", "arg2"]} +] +# A path such as those used internally by wrapper objects. + +Scope.someAttribute[5]("arg1", "arg2") +# Equivalent expression of the path. +``` + +A wrapper object does not store any more values than that. It is basically an unsent packet that contains only the path where the value it represents can be found, and not the value itself. When it is evaluated, it uses a simple IPC protocol to request an up-to-date real value from the game's Kotlin code. The `unciv_lib.api.real()`/`unciv_pyhelpers.real()` function can be used to manually get a real Python value from a foreign instance wrapper. + +However, because the wrapper class implements many Magic Methods that automatically call its evaluation method, many programming idioms common in Python are possible with them even without manual evaluation. Comparisons, equality, arithmetic, concatenation, in-place operations and more are all supported. + +Generally, any foreign objects that are accessible in this API start out as foreign object wrappers. + +Accessing an attribute on a wrapper object returns a new wrapper object that has an additional name set to "Property" at the end of its path list. Performing an array or dictionary index returns a new wrapper with an additional "Key" element in its path list. + +```python3 +print(civInfo) +# Wrapper object. + +print(civInfo.cities[0]) +# Also a wrapper object, with two extra path elements. + +print(civInfo.cities[0].isCapital) +# Still a wrapper object, with another extra path element. + +a = civInfo.cities +b = civInfo.cities +print(id(a) == id(b)) +# False, because each attribute access dynamically creates a new wrapper object. +``` + +Calling a wrapper object as a function or method also creates a new path list with an extra "Call" element at the end. But in this case, the new path list is immediately sent as a request to Kotlin/the JVM instead of being used in a new wrapper object, and the returned value is the naked result from the requested function call in the Kotlin/JVM namespace. + +```python3 +print(civInfo.cities[0].isCapital()) +# Goes through four wrapper objects, but ultimately sends its path as a request on the function call, and returns a real value. +``` + +Likewise, assigning to an attribute or an index/key on a wrapper object sends an IPC request to assign to the Kotlin/JVM value at its path, instead of modifying the wrapper object. + +```python3 +civInfo.cities[0].name = "Metropolis" +# Uses IPC request to modify Kotlin/JVM property. + +civInfo.cities[0].cityConstructions.constructionQueue[0] = "Missile Cruiser" +# Uses IPC request to modify Kotlin/JVM container. +``` + +When a Kotlin/JVM class implements a property for size or keys, Python wrappers for its instances can be iterated like a `tuple` or a `dict`, such as in `for` loops and iterable comprehensions. + +```python3 +print([real(city.name)+str(real(city.population.population)) for city in civInfo.cities]) +print({name: real(empire.cities and empire.cities[0]) for name, empire in gameInfo.ruleSet.nations.items()}) +``` + +In the Python implementation of the IPC protocol, wrapper objects are automatically evaluated and serialized as their resolved values when used in IPC requests and responses. + +```python3 +somePythonVariable = gameInfo.turns +# Assigns a wrapper object to somePythonVariable. Does not evaluate real value for `gameInfo.turns`. + +civInfo.tech.freeTechs = real(gameInfo.turns) +# Explicitly evaluate gameInfo.turns before using it in IPC assignment. +# 1. Makes IPC request for gameInfo.turns. +# 2. Receives IPC response for gameInfo.turns as integer. +# 3. Makes IPC request to assign resulting integer to civInfo.tech.freeTechs. + +civInfo.techs.freeTechs = gameInfo.turns +# Does the same thing as above, because the gameInfo.turns wrapper object is resolved at the point of serialization. +``` + +The magic methods implemented on wrapper objects also automatically evaluate wrappers into real Python values if they are used in Python-space operations. + +```python3 +gameInfo.turns + 5 +5 + gameInfo.turns +x = 5 +x += gameInfo.turns +# All works, because the gameInfo.turns wrapper automatically sends and receives IPC packets to resolve into its real value as an integer when it's added. +``` + +In-place operations on wrapper objects are implemented by performing the operation using Python semantics and then making an IPC request to assign the result in Kotlin/the JVM. + +```python3 +gameInfo.turns += 5 + +# Equivalent to: + +gameInfo.turns = real(gameInfo.turns) + real(5) +``` + +--- + +## Foreign Object Tokens + +A **token** is a string that has been generated by `InstanceTokenizer.kt` to represent a Kotlin instance. + +The `unciv_lib.api.isForeignToken()`/`unciv_pyhelpers.api.isForeignToken()` function can be used to check whether a Python object is either a foreign token string or a wrapper that resolves to a foreign token string. + +When a Kotlin/JVM path requested by a script resolves to an immutable primitive like a number or boolean, or something that is otherwise practical to serialize, then the value returned to Python is usually a real object. + +However, if the value requested is an instance of a complicated Kotlin/JVM class, then the IPC protocol instead creates a unique string to identify it. + +```python3 +isForeignToken("Some random string.") +# False. + +isForeignToken(uncivGame.version) +# False. Version is stored as a simple string. + +isForeignToken(gameInfo.turns) +# False. Turn count is stored as a simple integer. + +isForeignToken(real(uncivGame)) +# True. Unserializable instances are turned into token strings on evaluation. + +isForeignToken(civInfo.getWorkerAutomation()) +# True. This method returns a complicated type that gets turned into a token string. + +isForeignToken(uncivGame) +# True. `uncivGame` is technically a wrapper object, but `isForeignToken` returns True based on evaluated results. +``` + +The original instance is stored in the JVM in a mapping as a weak reference. The string doesn't have any special properties as a Python object. But if the string is sent back to Kotlin/the JVM at any point, then it will be parsed and substituted with the original instance (provided the original instance still exists). + +This is meant to allow Kotlin/JVM instances to be, E.G., used as function arguments and mapping keys from scripts. + +```python3 +civunits = civInfo.getCivUnits() +# List of token strings representing `MapUnit()` instances. + +unit = civunits[0] +# Single token string. + +civInfo.removeUnit(unit) +# Token string gets get transformed back into original `MapUnit()` when used as function argument. +``` + +The rules for which classes are serialized as JSON values and which are serialized as token strings may be a little bit fuzzy and variable, as they are designed to maximise use cases. + +In general, Kotlin/JVM instances that *can* be cast into a type compatible with a JSON type will be serialized as such— Unless they are of classes defined within the Unciv packages themselves, in which case they will always be tokenized. The exemption for certain classes prevents everything that inherits from iterable interfaces— Like `Building()`, which inherits from `Stats:Iterable`— From being stripped down into JSON arrays, as having access to their members and instances is often much more useful. + +--- + +## Assigning Tokens to Paths to Get Wrappers + +Sometimes, you may want to access a path or call a method on a foreign object that you have only as a token string— For example, an object returned from a foreign function or method call. + +Usually this would be impossible because you need a path in order to access foreign attributes. Without a valid path to an object, the wrapper code and the IPC protocol have no way to identify where an object is or what to do with it. In fact, if the Kotlin/JVM code hasn't kept its own references to the object, the object may not even exist anymore. + +To get around this, you can use the foreign token to assign the object it represents to a concrete path in Kotlin/the JVM. + +The `apiHelpers.registeredInstances` helper object can be used for this: + +TODO: In most cases, instancesAsInstances is better than registeredInstances. registeredInstances is needed only when persisting a Kotlin data structure between script executions (…Which— Why would you do?). + +```python3 +token = civInfo.cities[0].getCenterTile() +# Token string representing a `TileInfo()` instance. + +print(type(token)) +# . Cannot be used for foreign attribute access. + +apiHelpers.registeredInstances["centertile"] = token +# Token string gets transformed back into `TileInfo()` in Kotlin/JVM assignment. + +print(type(apiHelpers.registeredInstances["centertile"])) +# . A full wrapper with path, that can be used for full attribute, key, and method access. + +print(apiHelpers.registeredInstances["centertile"].baseTerrain) +# Successful attribute access. + +del apiHelpers.registeredInstances["centertile"] +# Delete the reference so it doesn't become a memory leak. +``` + +In order to use this technique properly, the assignment of an object to a concrete path should be done within the same REPL loop as the generation of the token used to assign it. This is because the Kotlin code responsible for generating tokens and managing the REPL loop keeps references to all returned objects within each REPL loop. Afterwards, these references are immediately cleared, so any objects that do not have references elsewhere in Kotlin/the JVM are liable to be garbage-collected in between REPL loops. + +Note that because of this, it is also perfectly safe to use token strings as arguments for foreign functions without assigning them to concrete paths, as long as they are requested and used within the same REPL loop. + +```python3 +# Each ">>>" represents a new script execution initiated from Kotlin— E.G., A new command entered into the console screen, or a new handler execution from the modding API— And not just a new line of code. Code on multiple lines can still be run in the same REPL loop, as long as the script's control isn't handed back to Kotlin/the JVM in between. + +>>> worldScreen.mapHolder.setCenterPosition(apiHelpers.Jvm.Vector2(1,2), True, True) +# Works, because the instance creation and the call with a tokenized argument happen in the same REPL execution. + +>>> apiHelpers.registeredInstances["x"] = apiHelpers.Jvm.Vector2(1,2) +>>> worldScreen.mapHolder.setCenterPosition(apiHelpers.registeredInstances["x"], True, True) #TODO: This doesn't actually use any subpath. +# Works, because the instance creation and token-based assignment in Kotlin are done in the same REPL execution. + +>>> x = apiHelpers.Jvm.Vector2(1,2); civInfo.endTurn(); apiHelpers.registeredInstances["x"] = x +>>> worldScreen.mapHolder.setCenterPosition(apiHelpers.registeredInstances["x"], True, True) +# Also works. +``` + +```python3 +>>> x = apiHelpers.Jvm.Vector2(1,2) +>>> apiHelpers.registeredInstances["x"] = x +>>> worldScreen.mapHolder.setCenterPosition(apiHelpers.registeredInstances["x"], True, True) +# May not work, because the created instance has no reference in Kotlin between the first two script executions and can be garbage-collected. + +>>> x = apiHelpers.Jvm.Vector2(1,2) +>>> worldScreen.mapHolder.setCenterPosition(x, True, True) +# Also may not work. +``` + +**It is very important that you delete concrete paths you have set after you are done with them.** Any objects held at paths you do not delete will continue to occupy system memory for the remaining run time of the application's lifespan. We can't rely on Python's garbage collection in this case because it doesn't control the Kotlin objects, nor can we rely on the JVM's garbage collector because it doesn't know whether Python code still needs the objects in question, so you will have to manage the memory yourself by keeping a reference as long as you need an object and deleting it to free up memory afterwards. + +For any complicated script in Python, it is suggested that you write a context manager class to automatically take care of saving and freeing each object where appropriate. + +It is also recommended that all scripts create a separate mapping with a unique and identifiable key in `apiHelpers.registeredInstances`, instead of assigning directly to the top level. + +```python3 +apiHelpers.registeredInstances["python-module:myName/myCoolScript"] = {} + +memalloc = apiHelpers.registeredInstances["python-module:myName/myCoolScript"] + +memalloc["capitaltile"] = civInfo.cities[0].getCenterTile() + +worldScreen.mapHolder.setCenterPosition(memalloc["capitaltile"].position, True, True) + +del memalloc["capitaltile"] + +del apiHelpers.registeredInstances["python-module:myName/myCoolScript"] +``` + +```python3 +apiHelpers.registeredInstances["python-module:myName/myCoolScript"] = {} + +memalloc = apiHelpers.registeredInstances["python-module:myName/myCoolScript"] +# Wrapper object. + +class MyForeignContextManager: + def __init__(self, *tokens): + self.tokens = tokens + self.memallocKeys = [] + def __enter__(self): + for token in self.tokens: + assert isForeignToken(token) + key = f"{random.getrandbits(30)}_{time.time_ns()}" + # Actual uses should locally check for key uniqueness. + memalloc[key] = token + self.memallocKeys.append(key) + return tuple(memalloc[k] for k in self.memallocKeys) + def __exit__(self, *exc): + for key in self.memallocKeys: + del memalloc[key] + self.memallocKeys.clear() + +with MyForeignContextManager(apiHelpers.Jvm.MapUnit(), ) as mapUnit, : + mapUnit + +del apiHelpers.registeredInstances["python-module:myName/myCoolScript"] +``` + +The recommended format for keys added to `apiHelpers.registeredInstances` is as follows: + +``` +-<'mod'|'module'|'package'>:/ +``` + +--- + +## API Modules + +The top-level namespace of the API can be imported as the `unciv` module in any script running in the same interpreter as it.\ +Further tools can be imported as `unciv_pyhelpers`. + +This is useful when writing modules that are meant to be imported from the main Unciv Python namespace. + +```python3 +# MyCoolModule.py +# In PYTHONPATH/sys.path. + +import unciv +import unciv_pyhelpers + +def printCivilizations(): + for civ in unciv.gameInfo.civilizations: + print(f"{unciv_pyhelpers.real(civ.nation.name)}: {len(civ.cities)} cities") +``` + +```python3 +# In Unciv. + +>>> import MyCoolModule +>>> MyCoolModule.printCivilizations() +``` + +--- + +## Examples + +--- + +## Performance and Gotchas + +Initiating a foreign action is likely to be expensive. They have to be encoded as request packets, serialized as JSON, sent to Kotlin/the JVM, decoded there, and evaluated in Kotlin/JVM using slow reflective mechanisms. The results then have to go through this entire process in reverse in order to return a value to Python. + +However, code running in Kotlin/the JVM is also likely to be much faster than code running in Python. The danger is in wasting lots of time bouncing back and forth just to exchange small amounts of data. + +Efficient scripts should try to do as much of their work in the same environment as possible. + +If something can be done with a single foreign action, then it probably should be, as that way the statically compiled and JIT-optimized JVM bytecode can do most of the heavy lifting. However, if a task can't be done in a single foreign action, then as much work should be done completely in Python as possible, in order to reduce the number of high-overhead IPC calls used. + +```python3 +def slow(): + for tile in gameInfo.tileMap.values: + # Iteration implicitly uses 1 IPC "length" action at the start. + tile.naturalWonder = "Krakatoa" if random.random() < 1/len(gameInfo.tileMap.values)*20 else tile.naturalWonder + # On every loop: + # +1 IPC "length" action in the "if". + # +1 IPC "read" action for the current .naturalWonder if reaching the "else" expression. (Only gets resolved on serialization in the next step.) + # +1 IPC "assign" action to update .naturalWonder, even if it's not changing. + # This happens once for every tile— Hundreds or thousands of times in total. +# Total IPC actions: ~1,000 to ~15,000. Just below 3 on average for every tile on the map. + +def faster(): + sizex = len(gameInfo.tileMap.tileMatrix) - 1 + sizey = len(gameInfo.tileMap.tileMatrix[0]) - 1 + # 2 IPC "read" actions for max map bounds at the start. + targetcount = random.randint(15, 25) + i = 0 + while i < targetcount: + x = random.randint(0, sizex) + y = random.randint(0, sizey) + # Instead of iterating over every tile on the map, generate the coords in Python, and then work with foreign objects only after having determined the coordinates. + if real(gameInfo.tileMap.tileMatrix[x][y]) is not None: + # +1 IPC "read" action. + # On hexagonal maps, check for validity. To be faster yet, this could also be done numerically in Python, or short-circuited on rectangular maps. + gameInfo.tileMap.tileMatrix[x][y].naturalWonder = "Krakatoa" + # +1 IPC "assign" action. + # Only done after already selecting coordinates and checking validity. + i += 1 + # Only iterate for as long as needed to change the target number of tiles. +# Total IPC actions: ~40 to ~60. Only one assignment, plus one check, for each tile that actually changes. + +def fastest(): + apiHelpers.scatterRandomFeature("Krakatoa", random.randint(15, 25)) +# Total IPC actions: 1. All the heavy lifting is done in the Kotlin function it calls. +# The "scatterRandomFeature" function doesn't actually exist. But the point is that when available, a single IPC call that causes all of the work to then be done in the JVM is likely to be faster than a script-micromanaged solution. E.G., use one call to List<*>.addAll() instead of many calls to List<*>.add(). +``` + +Every time you access an attribute or item on a foreign wrapper in Python creates and initializes a new foreign wrapper object. So for code blocks that use a wrapper object at the same path multiple times, it may be worth saving a single wrapper at the start instead. + +```python3 +def slow(): + for i in len(civInfo.cities[0].cityStats.cityInfo.tilesInRange): + print(civInfo.cities[0].cityStats.cityInfo.tilesInRange[i]) # FIXME: This doesn't actually take indices, does it? + # Every loop starts out with civInfo, and then constructs a new wrapper object in Python for every attribute and item access. + +def faster(): + tilesInRange = civInfo.cities[0].cityStats.cityInfo.tilesInRange + for i in len(tilesInRange): + print(tilesInRange[i]) + # Saves 5 Python object instantiations with every loop! +``` + +There are additionally a number of Kotlin/JVM helper functions that can speed up scripts by applying simple operations to or reading simple values from lots of Kotlin/JVM instances at once. These functions accept and return lists and mappings that can be serialized as JSON arrays and objects, allowing hundreds or thousands of operations to be performed with only a single IPC request. + +```python3 +# TODO +def slow(): + for t in gameInfo.tileMap.values: + if t.militaryUnit is not None or t.civilianUnit is not None: + t.terrainFeatures.add("Fallout") + +def fast(): + tileproperties = apiHelpers.mapPathCodes(gameInfo.tileMap.values, ('.militaryUnit', '.civilianUnit')) + apiHelpers.applyPathCodes({tile:{'.terrainFeatures.add("Fallout")'} for tile in tileproperties if tileproperties['.militaryunit'] is None or tileproperties['.civilianUnit'] is None}) + +def also_slow(): + +``` + +Every element in the path sent by a wrapper object to Kotlin/the JVM also requires the Kotlin side to perform an additional reflective member resolution step. + +```python3 +def slow(): + for i in range(1000): + pass # TODO + + +def alsoSlow(): + uselesscache = + for i in range(1000): + uselesscache += + # Assigning the wrapper object to a name in Python saves on Python attribute access time. But it doesn't actually shorten its Kotlin/JVM path, so the same number of steps still have to be taken when the Kotlin/JVM side processes the packet sent by Python. + +def fast(): + apiHelpers.registeredInstances["usefulcache"] = + usefulcache = apiHelpers.registeredInstances["usefulcache"] + for i in range(1000): + usefulcache. += + # Assigning the leaf wrapper to a single Python name reduces the number of new wrapper objects built in Python each loop to . + # Assigning the foreign object to + del apiHelpers.registeredInstances["usefulcache"] + +``` + +Because iteration over wrapper objects is currently implemented in Python by returning a new wrapper for every index within their length, foreign set-like containers without indices and generator-like iterables without fixed lengths cannot be idiomatically iterated over from Python. + +To get around this, you can simply resolve them into their serialized JSON forms. This turns them into JSON arrays and Python lists of primitive values and foreign token strings, on which regular Python iteration can take operate. + +```python3 +for e in civInfo.cities[0].cityStats.cityInfo.tiles: + print(e) + # Fails. CityInfo.tiles is a set-like instance that does not take indices. + +for i in range(len(civInfo.cities[0].cityStats.currentCityStats.values)): + #civInfo.units() returns a sequence. + print(i) + # Also fails. CityStats.currentCityStats.values is an iterator-like instance without a known length. + +for e in real(civInfo.cities[0].cityStats.currentCityStats.values): + print(e) + # Works. But yields only primitive JSON-serializable values and/or token strings, not wrappers. +``` + +Because the elements yielded this way do not have equivalent paths in the Kotlin/JVM namespace, and are not foreign object wrappers, any complex objects will have to be assigned as token strings to a concrete path in order to do anything with them. + +When using every value from a Kotlin/JVM container, iterating over its wrapper object is also likely to be slower than iterating over its resolved value. This is because iteration over wrappers is implemented on the Python side by creating a new wrapper at the next index for every item, so every use of the yielded value requires another IPC call, while evaluating the container itself means that the object being iterated over is a real container deserialized from a JSON array. + +``` +def slow(): + for name in civInfo.naturalWonders: + print("We have the natural wonder: " + name) + # Uses IPC call on every loop, as name is a foreign wrapper. + # Equivalent to accessing real(civInfo.naturalWonders[i]) on every loop. + +def fast(): + for name in real(civInfo.naturalWonders): + print("We have the natural wonder: " + name) + # Uses only one IPC call at the start of the loop, and iterates over resulting JSON array. + # Name is a real Python string. + +def alsofastish(): + for name in civInfo.naturalWonders: + print("We have a natural wonder!") + # Even though name is a foreign object wrapper here too, it's never used, so no extra IPC calls are generated. +``` + +--- + +## Error Handling + +Usually, errors in Kotlin/the JVM during foreign calls are caught by the Kotlin implementation of the IPC protocol. They are then gracefully serialized and returned in a specially flagged packet, which causes a `unciv_lib.ipc.ForeignError()`/`unciv_pyhelpers.ForeignError()` to be raised in Python. + +```python3 +>>> uncivGame.fakeAttributeName +#TODO + +>>> civInfo.addGold("Fifty-Nine") + +``` + +Because of this, malformed foreign actions requested by Python usually cannot crash Unciv. In fact, it is possible to catch such errors in Python to create Python-style exception-controlled program flow. + +```python3 +try: + print(gameInfo.civilizations) + # gameInfo is null in the main menu screen. +except ForeignError: + # Comes here with ForeignError("java.lang.NullPointerException"). + print("Currently not in game!") + +# (I still don't like it personally. I guess it doesn't incur any major overhead since you'd need an IPC action anyway to check validity/LBYL, but this seems kinda gross.) +``` + +The only major caveat to the robustness of this error handling is that it does not protect against valid Kotlin/JVM actions that lead to unexpected states which then cause exceptions in later use by unrelated game code. Assigning an inappropriate value to a Kotlin/JVM member, or deleting a key-value pair where it is required by internal game code, for example, will likely cause the core game to crash the next time the invalid value is used. + +```python3 +>>> gameInfo.tileMap.values[0].naturalWonder = "Crash" +# Executes and sets .naturalWonder to "Crash" successfully. +# But the game crashes when you click on the changed tile because there aren't any textures or stats for the "Crash" natural wonder. + +>>> del gameInfo.ruleSet.technologies["Sailing"] +# Executes and removes "Sailing" technology from tech tree successfully. +# But the game crashes if you try to select any techs that required "Sailing", because they still have "Sailing" in their prerequisites. + +>>> civInfo.cities[0].tiles.add("Crash") +# Executes and adds "Crash" string to the set containing your capital's tiles' coordinates. +# But the game breaks when you press "Next Turn", because the JVM thread processing the turn tries to use the string "Crash" as a Vector2. +``` + +--- + +Top-level functions are translated by the Kotlin compiler into static methods in JVM classes named by appending 'Kt' to the package plus name of the file in which they're defined. TODO. + +--- + +## Other Languages + +The Python-specific behaviour is not meant as a hard standard, in that it doesn't have to be copied exactly in any other languages. Some other design may be more suited for ECMAScript, Lua, and other possible backends. If implementing another language, I think some attempt should still be made to keep a similar API and feature equivalence, though. diff --git a/android/assets/scripting/enginefiles/python/main.py b/android/assets/scripting/enginefiles/python/main.py new file mode 100644 index 0000000000000..215122a0d43c7 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/main.py @@ -0,0 +1,62 @@ +# This should never be used to run untrusted code. AFAICT, Python is basically impossible to sandbox, short of running it in a VM. +# Example: https://lwn.net/Articles/574215/ +# Even if Python's sandboxed, the full reflective access on the Kotlin/JVM side isn't. + +# Huh: https://stackoverflow.com/questions/15093663/packaging-linux-binary-in-android-apk + + +#"""Due to the massive standard library and third-party libraries available to Python, due to the similarly heavy footprint of the CPython interpreter, the recommended use cases of this scripting backend are user automation, custom tools, prototyping, and experimentation or research. For mods, use the JS backend instead. + +#It is not considered feasible to support scripting by Python on mobile platforms.""" + + +try: + import os + with open(os.path.join(os.path.dirname(__file__), "PythonScripting.md"), 'r') as f: + __doc__ = f.read() +except Exception as e: + try: + __doc__ = f"{repr(e)}" + except: + pass + + +try: + + import sys, types + + stdout = sys.stdout + + import unciv_lib + + + uncivModule = types.ModuleType(name='unciv', doc=__doc__) + + sys.modules['unciv'] = uncivModule + # Let the entire API be imported from external scripts. + # None of this will work on Upy. + + # uncivModule.help = lambda thing=None: print(__doc__) if thing is None else print(unciv_lib.api.get_doc(thing)) if isinstance(thing, unciv_lib.wrapping.ForeignObject) else help(thing) + + replScope = {'help': lambda thing=None: print(__doc__) if thing is None else print(unciv_lib.api.get_doc(thing)) if isinstance(thing, unciv_lib.wrapping.ForeignObject) else help(thing)} + + # exec('from unciv_pyhelpers import *', replScope, replScope) + # TODO: This, and the scope update in UncivReplTransceiver, should probably be a default exec in Kotlin-side game options instead. + + + foreignAutocompleter = unciv_lib.autocompletion.PyAutocompleteManager(replScope, **unciv_lib.api.autocompleterkwargs) + + foreignActionReceiver = unciv_lib.api.UncivReplTransceiver(scope=replScope, apiscope=uncivModule.__dict__, autocompleter=foreignAutocompleter) + + foreignActionReceiver.ForeignREPL() + + raise RuntimeError("No REPL. Did you forget to uncomment a line in `main.py`?") + +except Exception as e: + # try: + # import unciv_lib.utils + # exc = unciv_lib.utils.formatException(e) + # Disable this. A single line with undefined format is more likely to be printed than multiple. + # except: + # exc = repr(e) + print(f"Fatal error in Python interepreter: {repr(e)}", file=stdout, flush=True) diff --git a/android/assets/scripting/enginefiles/python/unciv_lib/__init__.py b/android/assets/scripting/enginefiles/python/unciv_lib/__init__.py new file mode 100644 index 0000000000000..defdba015f21f --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_lib/__init__.py @@ -0,0 +1,4 @@ +__all__ = ['autocompletion', 'ipc', 'utils', 'wrapping', 'api'] +#Unsupported by Micropython + +from . import autocompletion, ipc, utils, wrapping, api diff --git a/android/assets/scripting/enginefiles/python/unciv_lib/api.py b/android/assets/scripting/enginefiles/python/unciv_lib/api.py new file mode 100644 index 0000000000000..ecd6fed92b799 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_lib/api.py @@ -0,0 +1,166 @@ +import json, os, builtins, sys + +from . import ipc, utils + + +enginedir = os.path.dirname(__file__) + +def readlibfile(fp): + """Return the text contents of a file that should be available""" + try: + # In an Unciv scripting backend, `SourceManager.kt` uses LibGDX to merge `sharedfiles/` with `enginedaata/{engine}/` in a temporary directory. + with open(os.path.join(enginedir, "..", fp)) as file: + return file.read() + except OSError: + # For debug with standalone Python, `sharedfiles` has to be accessed manually. + with open(os.path.join(enginedir, "../../../sharedfiles", fp)) as file: + return file.read() + +apiconstants = json.loads(readlibfile("ScriptAPIConstants.json")) + + +Expose = {} + +def expose(name=None): + """Returns a decorator that adds objects to a mapping of names to expose in the Unciv Python scripting API.""" + def _expose(obj): + Expose[name or obj.__name__] = obj + return obj + return _expose + + +def get_keys(obj): + """Get keys of object. Fail silently if it has no keys. Used to let PyAutocompleter work with ForeignObject.""" + try: + return obj.keys() # FIXME: This results in function calls over IPC since hiding the .keys() IPC protcol-based method for non-mappings in ForeignObject. + except (AttributeError, ipc.ForeignError): + return () + +def get_help(obj): + """Get docstring of object. Fail silently if it has none, or get one through its IPC methods if it's a ForeignObject(). Used for PyAutocompleter.""" + try: + if isinstance(obj, wrapping.ForeignObject): + doc = f"\n\n{str(obj._docstring_() or wrapping.stringPathList(obj._getpath_()))}\n" + # TODO: Can this be in ForeignObject.__getattr__/__getattribute__? + for funcsig, funcargs in obj._args_().items(): + doc += f"\n{funcsig}\n" + doc += "\n".join(f"\t{argname}: {argtype}" for argname, argtype in funcargs) + return doc + else: + with ipc.FakeStdout() as fakeout: + print() + help(obj) + return fakeout.getvalue() + except Exception as e: + return f"Error accessing help text: {repr(e)}" + + +@expose() +def callable(obj): + """Return whether or not an object is callable. Used to let PyAutocompleter work with ForeignObject by calling the latters' IPC callability method.""" + if isinstance(obj, wrapping.ForeignObject): + return obj._callable_(raise_exceptions=False) + else: + return builtins.callable(obj) + + +autocompleterkwargs = { + 'get_keys': get_keys, + 'get_help': get_help, + 'check_callable': callable +} + + +@expose() +def real(obj): + """Evaluate a foreign object wrapper into a real Python value, or return a value unchanged if not given a foreign object wrapper.""" + if isinstance(obj, wrapping.ForeignObject): + return obj._getvalue_() + return obj + +@expose() +def isForeignToken(obj): + """Return whether an object represents a token for a non-serializable foreign object.""" + resolved = real(obj) + return isinstance(resolved, str) and resolved.startswith(apiconstants['kotlinInstanceTokenPrefix']) + +@expose() +def pathcodeFromWrapper(wrapper): + return wrapping.stringPathList(wrapper._getpath_()) + # TODO + +expose()(ipc.ForeignError) + + +class UncivReplTransceiver(ipc.ForeignActionReceiver, ipc.ForeignActionSender): + """Class that implements the Unciv IPC and scripting protocol by receiving and responding to its packets. See Module.md.""" + def __init__(self, *args, apiscope=None, autocompleter=None, **kwargs): + ipc.ForeignActionReceiver.__init__(self, *args, **kwargs) + self.autocompleter = autocompleter + self.apiscope = {} if apiscope is None else apiscope + def populateApiScope(self): + """Use dir() on a foreign object wrapper with an empty path to populate the execution scope with all available names.""" + uncivscope = wrapping.ForeignObject(path=(), foreignrequester=self.GetForeignActionResponse) + #self.apiscope['unciv'] = uncivscope + for n in dir(uncivscope): + if n not in self.apiscope: + self.apiscope[n] = wrapping.ForeignObject(path=n, foreignrequester=self.GetForeignActionResponse) + # self.scope.update({**self.apiscope, **self.scope}) + # TODO: Populate module, let scripts import it themselves. + def passMic(self): + """Send a 'PassMic' packet.""" + self.SendForeignAction({'action':None, 'identifier': None, 'data':None, 'flags':('PassMic',)}) + @ipc.receiverMethod('motd', 'motd_response') + def EvalForeignMotd(self, packet): + """Populate the exeuction scope, and then reply to a MOTD request.""" + self.populateApiScope() + self.passMic() + return f""" +sys.implementation == {str(sys.implementation)} + +Current imports: + from unciv import * + from unciv_pyhelpers import * + +Run "help()", or read PythonScripting.md, for an overview of this API. + +Extensive example scripts can be imported as the "unciv_scripting_examples" module. +These can also also accessed from the game files either externally or through the API: + print(apiHelpers.assetFileString("scripting/enginefiles/python/unciv_scripting_examples/PlayerMacros.py")) + +Press [TAB] at any time to trigger autocompletion at the current cursor position, or display help text for an empty function call. + +""", ()#TODO: Replace current imports with startup command managed by ConsoleScreen and GameSettings. + @ipc.receiverMethod('autocomplete', 'autocomplete_response') + def EvalForeignAutocomplete(self, packet): + assert 'PassMic' in packet.flags, f"Expected 'PassMic' in packet flags: {packet}" + res = self.autocompleter.GetAutocomplete(packet.data["command"], packet.data["cursorpos"]) if self.autocompleter else "No autocompleter set." + self.passMic() + return res, () + @ipc.receiverMethod('exec', 'exec_response') + def EvalForeignExec(self, packet): + line = packet.data + assert 'PassMic' in packet.flags, f"Expected 'PassMic' in packet flags: {packet}" + with ipc.FakeStdout() as fakeout: + print(f">>> {str(line)}") + isException = False + try: + try: + code = compile(line, 'STDIN', 'eval') + except SyntaxError: + exec(compile(line, 'STDIN', 'exec'), self.scope, self.scope) + else: + print(repr(eval(code, self.scope, self.scope))) + except Exception as e: + print(utils.formatException(e)) + isException = True + finally: + self.passMic() + return fakeout.getvalue(), (('Exception',) if isException else ()) + @ipc.receiverMethod('terminate', 'terminate_response') + def EvalForeignTerminate(self, packet): + return None, () + + +from . import wrapping +# Should only need it at run time anyway, so import at end makes a circular import more predictable. Basically, this whole file gets prepended to wrapping under the api name. diff --git a/android/assets/scripting/enginefiles/python/unciv_lib/autocompletion.py b/android/assets/scripting/enginefiles/python/unciv_lib/autocompletion.py new file mode 100644 index 0000000000000..775010f30f8a3 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_lib/autocompletion.py @@ -0,0 +1,152 @@ +import rlcompleter, itertools, keyword + +from . import utils + + +class AutocompleteManager: + def __init__( + self, + scope=None, + *, + get_keys=lambda o: o.keys() if hasattr(o, 'keys') else (), + get_help=lambda o: getattr(o, '__doc__', None), + check_callable=lambda o: callable(o) + ): + self.scope = globals() if scope is None else scope + self.get_keys, self.get_help, self.check_callable = get_keys, get_help, check_callable + def getCommandComponents(self, command): + """Try to return the the last atomic evaluable expression in a statement, everything before it, and the token at the end of everything before it.""" + #Call recursively if you need to resolve multiple values. Test string: + # abc.cde().fgh[0].ijk(lmn[1].opq["dea + lasttoken = None + prefixsplit = len(command)-1 + while prefixsplit >= 0: + char = command[prefixsplit] + if char in ')': + #Don't mess with potential function calls. + prefixsplit = 0 + lasttoken = char + break + if char in ']': + _bdepth = 1 + prefixsplit -= 1 + while _bdepth and prefixsplit: + # Skip over whole blocks of matched brackets. + char = command[prefixsplit] + if char == '[': + _bdepth -= 1 + if char == ']': + _bdepth += 1 + prefixsplit -= 1 + char = None + continue + if char in '([:,;+-*/|&<>=%{~^@': + # Should probably split at multi-character tokens like 'in', 'for', 'if', etc. too. + # TODO: Maybe just put read spaces as a token type for now?.. No, do it properly by checking against word tokens. + prefixsplit += 1 + lasttoken = char + break + prefixsplit -= 1 + else: + prefixsplit = 0 + # I think this will happen anyway without the break, but do it explicitly. + prefix, workingcode = command[:prefixsplit], command[prefixsplit:] + assert (not (lasttoken or prefix)) or lasttoken in ')' or prefix[-1] == lasttoken, f"{prefix, workingcode, lasttoken}" + return prefix, workingcode, lasttoken + def GetAutocomplete(self, command): + """Return either a sequence of full autocomplete matches or a help string for a given command.""" + return () + + +# class AstAutocompleteManager(AutocompleteManager): + # pass + # TODO. Or not. Somehow sounds messier than string parsing. + + +class PyAutocompleteManager(AutocompleteManager): + """Advanced autocompleter. Returns keys when accessing mappings. Implements API that returns docstrings as help text for callables. Adds opening round and square brackets to autocomplete matches to show callables and mappings.""" + # FIXME: Dot after a mapping fails. + # FIXME: Also fails: apiHelpers.Enums.enumMapsByQualname["com.unciv.logic.automation.ThreatLevel"] + # It's the whitespaceadjusted_workingcode.rpartition('.'), I think. + def Evaled(self, path): + assert ')' not in path, f"Closing brackets not currently allowed in autocomplete eval: {path}" + return eval(path, self.scope, self.scope) + #Seems safe. Well, I'm already checking before calling here that there's no closing brackets that could mean a function call. Let's check again, I guess. + def GetAutocomplete(self, command, cursorpos=None): + try: + if cursorpos is None: + cursorpos = len(command) + (prefix, workingcode, lasttoken), suffix = self.getCommandComponents(command[:cursorpos]), command[cursorpos:] + if ')' in workingcode: + # Avoid function calls. + return () + if lasttoken in {*'[('}:# Compare to set because None can't be used in string containment check. + prefix_prefix, prefix_workingcode, prefix_lasttoken = self.getCommandComponents(prefix[:-1]) + assert prefix[-1] == lasttoken + if ')' not in prefix_workingcode: + # Avoid function calls. + if lasttoken == '[' and ((not workingcode) or workingcode[0] in '\'"'): +# return f"Return keys matching {workingcode} in {prefix_workingcode}." + key_obj = self.Evaled(prefix_workingcode) + if hasattr(key_obj, 'keys'): + if not workingcode: + return tuple(prefix+repr(k)+']' + suffix for k in self.get_keys(key_obj)) + quote = workingcode[0] + key_current = workingcode[1:] + return tuple(prefix + quote + k + quote + ']' + suffix for k in self.get_keys(key_obj) if k.startswith(key_current)) + return () + if lasttoken == '(' and (not workingcode): +# return f"Show docstring of {prefix_workingcode}." + func_obj = self.Evaled(prefix_workingcode) + return (self.get_help(func_obj) or "No help text available.") + "\n" +# return f"Return attributes to complete {workingcode}." + whitespaceadjusted_workingcode = workingcode.lstrip() + whitespaceadjusted_prefix = prefix + workingcode[:len(workingcode)-len(whitespaceadjusted_workingcode)] + # Move leading whitespace onto prefix, so function arguments, list items, etc, resolve correctly. + working_base, working_dot, working_leaf = whitespaceadjusted_workingcode.rpartition('.') + if working_base: + base_obj = self.Evaled(working_base) + attrs = dir(base_obj) + def get_a(a): + try: return getattr(base_obj, a, None) + except: return None + else: + attrs = self.scope + get_a = lambda a: self.scope[a] + return tuple([ + whitespaceadjusted_prefix + + working_base + + working_dot + + ( + f"{a}[" + if self.get_keys(get_a(a)) else # TODO: Use the new ismapping check. + f"{a}(" + if self.check_callable(get_a(a)) else + a + ) + + suffix#TODO: Merge end of matches with beginnnings of suffixes if there's overlap. + for a in sorted(attrs) + if a.startswith(working_leaf) + ]) + except Exception as e: + return "No autocompletion found: "+utils.formatException(e) + + +class RlAutocompleteManager(AutocompleteManager): + """Autocompleter that uses the default Python autocompleter.""" + def GetAutocomplete(self, command, cursorpos=None): + #Adds brackets to everything, due to presence of dynamic `.__call__` on `ForeignObject`.. Technically, I might be able to control `callable()` by implementing a metaclass with a custom `.__getattribute__` with custom descriptors on `ForeignObject`. Perhaps such a sin is still beyond even my bumbling arrogance, though. + completer = rlcompleter.Completer(self.scope) + (prefix, workingcode, lasttoken), suffix = self.getCommandComponents(command[:cursorpos]), command[cursorpos:] + if workingcode: + matches = [] + for i in itertools.count(): + m = completer.complete(workingcode, i) + if m is None: + break + else: + matches.append(m) + else: + matches = [*self.scope.keys()]#, *keyword.kwlist] + return tuple([prefix+m+suffix for m in matches]) + diff --git a/android/assets/scripting/enginefiles/python/unciv_lib/ipc.py b/android/assets/scripting/enginefiles/python/unciv_lib/ipc.py new file mode 100644 index 0000000000000..1b764e0163e90 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_lib/ipc.py @@ -0,0 +1,119 @@ +import sys, io, json, time, random + +stdout = sys.stdout + +class IpcJsonEncoder(json.JSONEncoder): + """JSONEncoder that lets classes define a special ._ipcjson_() method to control how they'll be serialized. Used by ForeignObject to send its resolved value.""" + def default(self, obj): + if hasattr(obj.__class__, '_ipcjson_'): + return obj._ipcjson_() + return json.JSONEncoder.default(self, obj) + + +def makeUniqueId(): + """Return a string that should never repeat or collide. Used for IPC packet identity fields.""" + return f"{time.time_ns()}-{random.getrandbits(30)}" + +class ForeignError(RuntimeError): + pass + +class ForeignPacket: + """Class for IPC packet conforming to specification in Module.md and ScriptingProtocol.kt.""" + # TODO: Speed? Well, I'll cProfile the whole thing eventaully I guess. + def __init__(self, action, identifier, data, flags=()): + self.action = action + self.identifier = identifier + self.data = data + self.flags = flags + def __repr__(self): + return self.__class__.__name__+"(**"+str(self.as_dict())+")" + @classmethod + def deserialized(cls, serialized): + """Return a packet object from a JSON string.""" + return cls(**json.loads(serialized)) + def enforce_type(self, expect_action=None, expect_identifier=None): + if expect_action is not None and self.action != expect_action: + raise ForeignError("Expected foreign data of action "+repr(expect_action)+", got "+repr(self)+".") + if expect_identifier is not None and self.identifier != expect_identifier: + raise ForeignError("Expected foreign data with identifier "+repr(expect_identifier)+", got "+repr(self)+".") + return self + def as_dict(self): + return { + 'action': self.action, + 'identifier': self.identifier, + 'data': self.data, + 'flags': (*self.flags,) + } + def serialized(self): + return json.dumps(self.as_dict(), cls=IpcJsonEncoder) + + +class ForeignActionManager: + def __init__(self, sender=None, receiver=None): + if sender is not None: + self.sender = sender + if receiver is not None: + self.receiver = receiver + def sender(self, message): + try: + print(message, file=stdout, flush=True) + except TypeError: + #No flush on `micropython`. + print(message, file=stdout) + def receiver(self): + return sys.stdin.readline() + + +class ForeignActionSender(ForeignActionManager): + def SendForeignAction(self, actionparams): + self.sender(ForeignPacket(**actionparams).serialized()) + def GetForeignActionResponse(self, actionparams, responsetype): + identifier = makeUniqueId() + self.SendForeignAction({**actionparams, 'identifier': identifier}) + return ForeignPacket.deserialized(self.receiver()).enforce_type(responsetype, identifier) + + +def receiverMethod(action, response): + def receiverMethodDec(func): + func.__foreignActionReceiver = (action, response) + #Won't work on Upy, I think. + return func + return receiverMethodDec + +class ForeignActionReceiver(ForeignActionManager): + def __init__(self, sender=None, receiver=None, scope=None): + ForeignActionManager.__init__(self, sender=sender, receiver=receiver) + self.scope = globals() if scope is None else scope + self._responders = {} + for name in dir(self): + value = getattr(self, name) + action, response = getattr(value, '__foreignActionReceiver', (None, None)) + if action: + self._responders[action] = (value, response) + def RespondForeignAction(self, request): + decoded = ForeignPacket.deserialized(request) + action = decoded.action + raction = None + rdata = None + if action in self._responders: + method, raction = self._responders[action] + rdata, rflags = method(decoded) + else: + raise ForeignError("Unknown action type for foreign action request: " + repr(decoded)) + self.sender(ForeignPacket(raction, decoded.identifier, rdata, rflags).serialized()) + def AwaitForeignAction(self):#, *, ignoreempty=True): + self.RespondForeignAction(self.receiver()) + def ForeignREPL(self): + while True: + self.AwaitForeignAction() + + +class FakeStdout: + """Context manager that returns a StringIO and sets sys.stdout to it on entrance, then restores sys.stdout to its original value on exit.""" + def __init__(self): + self.stdout = sys.stdout + def __enter__(self): + self.fakeout = sys.stdout = io.StringIO() # Won't work with MicroPython. I think it's slotted? + return self.fakeout + def __exit__(self, *exc): + sys.stdout = self.stdout diff --git a/android/assets/scripting/enginefiles/python/unciv_lib/shadow.py b/android/assets/scripting/enginefiles/python/unciv_lib/shadow.py new file mode 100644 index 0000000000000..4487ab7c8227e --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_lib/shadow.py @@ -0,0 +1,119 @@ +""" +Micro-library meant to allow Unciv scripts to be run even without running the game, by implementing fake versions of as many Python operators as possible. Script logic may error, but the basic control flow shouldn't. + +The objects resulting from the shadowed API keep track of how they were created and any further access to them. +The repr() of each object visualizes the tree of everything done under it. + +E.G.: Shadowed result from a function that shows an event popup when run in Unciv: + + )> + │ + ├ .addGoodSizedLabel + │ ├ (Something has happened in your empire!, 24) + │ │ ├ .row + │ │ │ ├ () + ├ .addSeparator + │ ├ () + │ │ ├ .row + │ │ │ ├ () + ├ .addGoodSizedLabel + │ ├ (A societally and politically s...lay a political decision: , 18) + │ │ ├ .row + │ │ │ ├ () + ├ .add + │ ├ ()>) + │ │ ├ .row + │ │ │ ├ () + ├ .add + │ ├ ()>) + │ │ ├ .row + │ │ │ ├ () + ├ .open + │ ├ (False) +""" + +import sys, random, re + +def rep(obj): + if isinstance(obj, FakeApi): + return str(obj) + else: + return repr(obj) + +def strCall(a, kw): + return ", ".join(str(p) for l in (a, (f"{k}={rep(v)}" for k, v in kw.items())) for p in l) + + +def doAction(self, action): + res = self.__class__(preceding=self, action=action) + self._following.append(res) + return res + + +MagicNames = {'__getattr__', *(n for t in __builtins__.values() if isinstance(t, type) for n, m in t.__dict__.items() if n.startswith('__') and callable(m) and n not in object.__dict__)} + +class FakeApiMetaclass(type): + def __new__(meta, name, bases, namespace, **kwds): + for n in MagicNames: + if n not in namespace: + namespace[n] = lambda self, *a, **kw: doAction(self, f".{n}({strCall(a, kw)})") + return super(FakeApiMetaclass, meta).__new__(meta, name, bases, namespace, **kwds) + + +class FakeApi(str, metaclass=FakeApiMetaclass): + __all__ = ('Unciv', 'apiExecutionContext', 'apiHelpers', 'civInfo', 'gameInfo', 'mapEditorScreen', 'modApiHelpers', 'toString', 'uncivGame', 'worldScreen') + def _init(self, *, preceding=None, action=None): + self._preceding = preceding + self._action = action + self._following = [] + def __new__(cls, preceding=None, action=""): + self = str.__new__(cls, "") + self._init(preceding=preceding, action=action) + return self + def __repr__(self): + lines = [self.__str__(), "│"] + def traverseChildren(node, depth): + action = re.sub("\s+", " ", re.sub("[│├]", "", node._action)).strip() + if len(action) > 65: + action = action[:31] + "..." + action[-31:] + lines.append("│ "*(depth-1) + "├ " + action) + for child in node._following: + traverseChildren(child, depth+1) + for child in self._following: + traverseChildren(child, 1) + return "\n".join(lines) + def __str__(self): + def path(): + x = self + while x is not None: + yield str(x._action) + x = x._preceding + return f"" + def __bool__(self): + # doAction(".__bool__()") + return True + def __int__(self): + # doAction(".__int__()") + return random.getrandbits(5) + def __getattr__(self, name): + return doAction(self, f".{name}") + def __getitem__(self, key): + return doAction(self, f"[{rep(key)}]") + def __call__(self, *a, **kw): + return doAction(self, f"({strCall(a, kw)})") + def __next__(self): + if not random.getrandbits(2): + raise StopIteration() + return self + def __iter__(self): + yield self + while not random.getrandbits(2): + yield self + def __length_hint__(self): + return NotImplemented + + +FAKE_API = FakeApi() + +if 'unciv' not in sys.modules: + sys.modules['unciv'] = FAKE_API diff --git a/android/assets/scripting/enginefiles/python/unciv_lib/utils.py b/android/assets/scripting/enginefiles/python/unciv_lib/utils.py new file mode 100644 index 0000000000000..adcbaaeb8950f --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_lib/utils.py @@ -0,0 +1,8 @@ +def formatException(exception): + try: + #Won't work on Upy. + import traceback + return "".join(traceback.format_exception(type(exception), exception, exception.__traceback__)) + except: + return repr(exception) + diff --git a/android/assets/scripting/enginefiles/python/unciv_lib/wrapping.py b/android/assets/scripting/enginefiles/python/unciv_lib/wrapping.py new file mode 100644 index 0000000000000..e6a6148bf053a --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_lib/wrapping.py @@ -0,0 +1,452 @@ +import json, sys, operator +stdout = sys.stdout + +from . import ipc, api + +# TODO: Definitely CProfile this. + +# Random.sample, collection ABCs sequence? + +class ForeignRequestMethod: + """Decorator and descriptor protocol implementation for methods of ForeignObject subclasses that return values from foreign requests.""" + def __init__(self, func): + self.func = func + try: + self.__name__, self.__doc__ = func.__name__, func.__doc__ + except AttributeError: + pass + def __get__(self, obj, cls): + def meth(*a, **kw): + actionparams, responsetype, responseparser = self.func(obj, *a, **kw) + response = obj._foreignrequester(actionparams, responsetype) + if callable(responseparser): + response = responseparser(response) + return response + try: + meth.__name__, meth.__doc__ = self.func.__name__, self.func.__doc__ + except AttributeError: + pass + return meth + + +def resolvingFunction(op, *, allowforeigntokens=False): + """Return a function that passes its arguments through `api.real()`.""" + def _resolvingfunction(*arguments, **keywords): + args = [api.real(a) for a in arguments] + kwargs = {k:api.real(v) for k, v in keywords.items()} + if not allowforeigntokens: + # Forbid foreign token strings from being manipulated through this operation. + for l in (args, kwargs.values()): + for o in l: + if api.isForeignToken(o): + raise TypeError(f"Not allowed to call `{op.__name__}()` function with foreign object token: {o}") + return op(*args, **kwargs) + _resolvingfunction.__name__ = op.__name__ + _resolvingfunction.__doc__ = f"{op.__doc__ or name + ' operator.'}\n\nCalls `api.real()` on all arguments." + return _resolvingfunction + +def reversedMethod(func): + """Return a `.__rop__` version of an `.__op__` magic method function.""" + def _reversedop(a, b, *args, **kwargs): + return func(b, a, *args, **kwargs) + _reversedop.__name__ = func.__name__ + _reversedop.__doc__ = f"{func.__doc__ or name + ' operator.'}\n\nReversed version." + return _reversedop + +def inplaceMethod(func): + """Return a wrapped a function that calls ._setvalue_() on its first self argument with its original result.""" + def _inplacemethod(self, *args, **kwargs): + self._setvalue_(func(self, *args, **kwargs)) + return self + return _inplacemethod + + +def dummyForeignRequester(actionparams, responsetype): + return actionparams, responsetype + + +def foreignValueParser(packet, *, raise_exceptions=True): + """Value parser that reads a foreign request packet fitting a common structure.""" + if 'Exception' in packet.flags and raise_exceptions: + raise ipc.ForeignError(packet.data) + return packet.data + + +def makePathElement(ttype='Property', name='', params=()): + assert ttype in ('Property', 'Key', 'Call'), f"{repr(ttype)} not a valid path element type." + return {'type': ttype, 'name': name, 'params': params} + +def stringPathList(pathlist): + items = [] + for p in pathlist: + if p['type'] == 'Property': + items.append(f".{p['name']}") + if p['type'] == 'Key': + items.append(f"[{json.dumps(p['params'][0], cls=ipc.IpcJsonEncoder)}]") + if p['type'] == 'Call': + items.append(f"({', '.join(p['params'])}])") + return "".join(items) + + +operator.hash = hash + +_magicmeths = ( + '__lt__', + '__le__', + '__eq__', # Kinda undefined behaviour for comparison with Kotlin object tokens. Well, tokens are just strings that will always equal themselves, but multiple tokens can refer to the same Kotlin object. `ForeignObject()`s resolve to new tokens, that are currently uniquely generated in InstanceTokenizer.kt, on every `._getvalue_()`, so I think even the same `ForeignObject()` will never equal itself. # Actually, now raises exception when used with tokens, I think. Do you support hash too? + # TODO: Once guaranteed token reuse is implemented in Kotlin, this should work in all cases. And probably just use Python hashes. + '__ne__', + '__ge__', + '__gt__', + '__not__', + ('__bool__', 'truth'), +# @is # This could get messy. It's probably best to just not support identity comparison. What do you compare? JVM Kotlin value? Resolved Python value? Python data path? Token strings from InstanceTokenizer.kt— Which are currently randomly re-generated for multiple accesses to the same Kotlin object, and thus always unique, and which would require another protocol-level guarantee to not do that, in addition to being (kinda by design) procedurally indistinguishable from "real" resovled Python values? +# @is_not # Also, these aren't even magic methods. + '__abs__', + '__add__', + '__and__', + '__floordiv__', + '__index__', + '__inv__', + '__invert__', + '__lshift__', + '__mod__', + '__mul__', + '__matmul__', + '__neg__', + '__or__', + '__pos__', + '__pow__', + '__rshift__', # I think one of these might be used for int(), which seems to mysteriously work? + '__sub__', # Further indication that a bitshift or something is used for int(): A wrapped foreign float can't be converted. + '__truediv__', + '__xor__', + '__concat__', + '__contains__', # Implemented through foreign request. + '__delitem__', # Implemented through foreign request. + '__getitem__', # Implemented through foreign request. +# @indexOf # Not actually totally sure what this is. I thought it was implemented in lists and tuples as `.index()`? +# '__setitem__', # Implemented through foreign request. + ('__hash__', 'hash') # Monkey-patched into operator module above. +) # TODO: __int__, __float__, and other stuff missing from operator. https://docs.python.org/3/reference/datamodel.html + +_rmagicmeths = ( + '__radd__', + '__rsub__', + '__rmul__', + '__rmatmul__', + '__rtruediv__', + '__rfloordiv__', + '__rmod__', + '__rdivmod__', + '__rpow__', + '__rlshift__', + '__rrshift__', + '__rand__', + '__rxor__', + '__ror__', +) + +_imagicmethods = ( + '__iadd__', + '__isub__', + '__imul__', + '__imatmul__', + '__itruediv__', + '__ifloordiv__', + '__imod__', + '__ipow__', + '__ilshift__', + '__irshift__', + '__iand__', + '__ixor__', + '__ior__' +) + +_tokensafemethods = { + '__eq__', + '__ne__', + '__hash__' +} + +def resolveForOperators(cls): + """Decorator. Adds missing magic methods to a class, which resolve their arguments with `api.real(a)`.""" + def alreadyhas(name): + return (hasattr(cls, name) and getattr(cls, name) is not getattr(object, name, None)) + for meth in _magicmeths: + if isinstance(meth, str): + name = opname = meth + else: + name, opname = meth + if not alreadyhas(name): + # Set the magic method only if neither it nor any of its base classes have already defined a custom implementation. + setattr(cls, name, resolvingFunction(getattr(operator, opname), allowforeigntokens=name in _tokensafemethods)) + for rmeth in _rmagicmeths: + normalname = rmeth.replace('__r', '__', 1) + if not alreadyhas(rmeth) and hasattr(cls, normalname): + setattr(cls, rmeth, reversedMethod(getattr(cls, normalname))) + for imeth in _imagicmethods: + normalname = imeth.replace('__i', '__', 1) + if not alreadyhas(imeth) and hasattr(cls, normalname): + normalfunc = getattr(cls, normalname) + setattr(cls, imeth, inplaceMethod(normalfunc)) + return cls + + +# class ForeignToken(str): + # __slots__ = () + # TODO: Could do this for more informative error messages, hidden magic methods that don't make sense. + # Would have to instantiate in the JSON decoder, though. + # I'm not sure it's necessary, since tokens will still have to be encoded as strings in JSON, which means you'd still need apiconstants['kotlinInstanceTokenPrefix'] and isForeignToken in api.py. + # Hm. Enable Python semantics with isinstance(), though. + + +class AttributeProxy: + def __init__(self, obj): + object.__setattr__(self, 'obj', obj) + def __getattribute__(self, name): + return object.__getattribute__(object.__getattribute__(self, 'obj'), name) + def __setattr__(self, name, value): + return object.__setattr__(object.__getattribute__(self, 'obj'), name, value) + # FIXME: Does this seem like a performance issue? + + +BIND_BY_REFERENCE = True # Should be True. +"""Early versions of this API bound Python objects to Kotlin/JVM instances by keeping track of paths and lazily evaluating them as needed. E.G. ".a.b[5].c" would create an internal tuple like `("a", "b", [5], "c")`, without actually accessing any Kotlin/JVM values at first. The Kotlin/JVM value at that path would only be resolved when it was needed. Benefits: Fewer IPC actions, lazy resolution of values only as they're used. Drawbacks: Deeper (slow) reflective loops per IPC action, scripting semantics not perfectly synced with JVM state, ugly tricks needed to deal with values that can't be safely accessed as paths from the same scope root, like the properties and methods of instances returned by function calls. + +The current API instead evaluates the real Kotlin/JVM values of most attributes/items/returns as soon as they're accessed in Python, and keeps track of those internally, using paths only relative to those root values. Of the scripting semantics made more convenient by the tighter binding, the most significant is probably that properties and methods can be directly used on the results returned from function calls. + +Set this flag to False to go back to the old, bind-by-path behaviour. Every Python backend can actually choose for themselves. But in general, bind-by-reference is increasingly the canonical model.""" + +# timeit.repeat results for running Python tests 3 times as of this comment: +# Bind-by-path: [30.61577351100277, 29.83002521295566, 32.639805707964115] +# Bind-by-reference: [33.53914805594832, 35.568275933968835, 36.279464731924236] +# This is with code that was written to try to squeeze out performance from the bind-by-path model, though. +# Subjectively, some of the tests feel like they may be faster with bind-by-reference too. +# Also of note: Kotlin/JVM-side token count reached well above 100k with bind-by-reference (though a realistic script would provide opportunities for cleanup to keep the count at any one time far below that), but didn't seem to reach 2k wtih bind-by-path. + +# TODO: Try to reduce IPC calls where it's not needed. + +@resolveForOperators +class ForeignObject: + """Wrapper for a foreign object. Implements the specifications on IPC packet action types and data structures in Module.md.""" + def __init__(self, *, path, use_root=False, root=None, foreignrequester=dummyForeignRequester): + object.__setattr__(self, '_attrs', AttributeProxy(self)) + self._attrs._isbaked = False + self._attrs._unbaked = None # For in-place operations, a version should be kept that + self._attrs._use_root = use_root + self._attrs._root = root + self._attrs._path = (makePathElement(name=path),) if isinstance(path, str) else tuple(path) + self._attrs._foreignrequester = foreignrequester + def __repr__(self): + return f"{self.__class__.__name__}({repr(self._root)}{', '+stringPathList(self._getpath_()) if self._getpath_() else ''}){':'+repr(self._getvalue_()) if self._getpath_() else ''}" + # TODO: This has become less informative under bind-by-reference. Look at tokenization format too. + def __str__(self): + return f"<{repr(self._getvalue_())}>" + def _clone_(self, **kwargs): + return self.__class__(**{'path': self._path, 'use_root': self._use_root, 'root': self._root, 'foreignrequester': self._foreignrequester, **kwargs}) + def _ipcjson_(self): + return self._getvalue_() + def _getpath_(self): # FIXME: Slow and unncessary? + return tuple(self._path) + def _bakereal_(self): + assert not self._isbaked + self._attrs._unbaked = self._clone_() # For in-place operations. + self._attrs._root = self._getvalue_() # TODO: Would the fallback for inplaces go through __setattr__, and result in one fewer IPC call? + self._attrs._use_root = True + self._attrs._path = () + self._attrs._isbaked = True + def __getattr__(self, name, *, do_bake=True): + # Due to lazy IPC calling, hasattr will never work with this. Instead, check for in dir(). + # TODO: Shouldn't I special-casing get_help or _docstring_ here? Wait, no, I think I thought it would be accessed on the class. + attr = self._clone_(path=(*self._path, makePathElement(name=name))) + if BIND_BY_REFERENCE and do_bake: + try: + attr._bakereal_() + except ipc.ForeignError as e: + raise AttributeError(e) + return attr + def __getattribute__(self, name, **kwargs): + if name in ('values', 'keys', 'items'): + # Don't expose real .keys, .values, or .items unless wrapping a foreign mapping. This prevents foreign attributes like TileMap.values from being blocked. + if not self._ismapping_(): + raise AttributeError(name) + return object.__getattribute__(self, name) + def __getitem__(self, key, *, do_bake=True): + item = self._clone_(path=(*self._path, makePathElement(ttype='Key', params=(key,)))) + if BIND_BY_REFERENCE and do_bake: + item._bakereal_() # Since __contains__ exists, there's no need to hide ForeignError()s from this behind a KeyError like how an AttributeError is needed for hasattr(). + return item + # Indexing from end with negative numbers is not supported. + # Mostly a complexity choice. Matching Kotlin semantics is better than translating with an extra IPC call for length. + def __iter__(self): + try: + return iter(self.keys()) + except: + return (self[i] for i in range(0, len(self))) + def __setattr__(self, name, value): + return self.__getattr__(name, do_bake=False)._setvalue_(value) + def __setitem__(self, key, value): + return self.__getitem__(key, do_bake=False)._setvalue_(value) + def _getvalue_(self): + if self._isbaked: + return self._root + else: + return self._getvalueraw_() + def _setvalue_(self, value): + if self._isbaked: + return self._unbaked._setvalue_(value) + else: + return self._setvalueraw_(value) + def __call__(self, *args): + result = self._clone_(path=(*self._getpath_(), makePathElement(ttype='Call', params=args))) + # Care must be taken that the resulting ForeignObject never has ._getvalueraw_ called more than once, including by __repr__ or __str__, as resolving it means actually running the call in Kotlin/the JVM. This also means it must not be able to produce children with the same 'Call' element in their paths either. + if BIND_BY_REFERENCE: + result._bakereal_() # Resolve the foreign value right away and clear path with bind-by-reference, so future accesses don't cause IPC actions. + return result + else: + return result._getvalue_() # Also resolve the foreign value right away with bind-by-path, and discard the ForeignObject. + @ForeignRequestMethod + def _getvalueraw_(self): + # Should never be called except for by _getvalue_. + assert not self._isbaked + return ({ + 'action': 'read', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_() + } + }, + 'read_response', + foreignValueParser) + @ForeignRequestMethod + def _setvalueraw_(self, value): + # Should never be called except for by _setvalue_. + assert not self._isbaked + return ({ + 'action': 'assign', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_(), + 'value': value + } + }, + 'assign_response', + foreignValueParser) + @ForeignRequestMethod + def _ismapping_(self): + return ({ + 'action': 'ismapping', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_() + } + }, + 'ismapping_response', + foreignValueParser) + @ForeignRequestMethod + def _callable_(self, *, raise_exceptions=True): + return ({ + 'action': 'callable', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_() + } + }, + 'callable_response', + lambda packet: foreignValueParser(packet, raise_exceptions=raise_exceptions)) + @ForeignRequestMethod + def _args_(self, *, raise_exceptions=True): + return ({ + 'action': 'args', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_() + } + }, + 'args_response', + lambda packet: foreignValueParser(packet, raise_exceptions=raise_exceptions)) + @ForeignRequestMethod + def _docstring_(self, *, raise_exceptions=True): + return ({ + 'action': 'docstring', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_() + } + }, + 'docstring_response', + lambda packet: foreignValueParser(packet, raise_exceptions=raise_exceptions)) + @ForeignRequestMethod + def __dir__(self): + return ({ + 'action': 'dir', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_() + } + }, + 'dir_response', + foreignValueParser) + @ForeignRequestMethod + def __delitem__(self, key): + return ({ + 'action': 'delete', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self.__getitem__(key, do_bake=False)._getpath_() + } + }, + 'delete_response', + foreignValueParser) + @ForeignRequestMethod + def __len__(self): + return ({ + 'action': 'length', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_(), + } + }, + 'length_response', + foreignValueParser) + @ForeignRequestMethod + def __contains__(self, item): + return ({ + 'action': 'contains', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_(), + 'value': item + } + }, + 'contains_response', + foreignValueParser) + @ForeignRequestMethod + def keys(self): + return ({ + 'action': 'keys', + 'data': { + 'use_root': self._use_root, + 'root': self._root, + 'path': self._getpath_(), + } + }, + 'keys_response', + foreignValueParser) + def values(self): + return (self[k] for k in self.keys()) + def items(self): + return ((k, self[k]) for k in self.keys()) + + diff --git a/android/assets/scripting/enginefiles/python/unciv_pyhelpers.py b/android/assets/scripting/enginefiles/python/unciv_pyhelpers.py new file mode 100644 index 0000000000000..166825f654c65 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_pyhelpers.py @@ -0,0 +1,11 @@ +""" +Python helpers for handling wrapped Unciv objects. + +Copied from a dictionary in unciv_lib.api. +""" + +import unciv_lib.api + +globals().update(unciv_lib.api.Expose) + +del unciv_lib diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/EndTimes.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/EndTimes.py new file mode 100644 index 0000000000000..1b17bccd031bb --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/EndTimes.py @@ -0,0 +1,91 @@ +""" +Example and developmental test case for potential future modding API. + +Adds scripted events that dynamically modify map and civilization state. + +Watch out for volcanoes, meteor strikes, wildfires, and alien invaders! + + +Call onNewTurn() on every new turn. + +Call onUnitMove(worldScreen.bottomUnitTable.selectedUnit) every time a unit moves. +""" + +import unciv + + +# What does it say about me that my go-to for a developmental test case is to implement an apocalypse mod? +# Then again, randomly flipping data values is naturally the easiest test case to code regardless of lore. +# But randomness is entropy, so random changes to the world around you are by definition apocalyptic. +# ...At which point, the lore writes itself. + + +# Could inject new tiles for wildfires, fissures, etc into the ruleset. +# Nah. Let's try to keep this and Merfolk.py save-compatible, and leave the ruleset modification to ProceduralTechTree. + +# Midgame: Defanged unkillable Barbarian "alien" scouts. +# Late game: Barbarian GDS, Helis. +# I guess could also give positive bonuses from alien encounters. + +turnNotifications = [] + + +def gaussianTileSelector(focus): + def _gaussianTileSelector(tile): + return True + return _gaussianTileSelector + + +def depopulateCity(city, migrationsuccess=0.5): + pass + + +def scatterFallout(focus, improvementtype, maxdistance, tileselector=lambda t: True): + pass + +def spawnNewDisasters(naturalwonder, *falloutparams): + pass + +def spreadFalloutType(improvementtype, tilepermitter=lambda t: True): + pass + +#worldScreen.mapHolder.selectedTile.position in civInfo.exploredTiles +#civInfo.addNotification("A volcano has risen from the Earth!", worldScreen.mapHolder.selectedTile.position, apiHelpers.Jvm.arrayOfString(["TileSets/FantasyHex/Tiles/Krakatoa"])) +#civInfo.exploredTiles.add(worldScreen.mapHolder.selectedTile.position) + +def eruptVolcanoes(): + pass + +def damageBurningUnits(): + pass + +def damageBurningCities(): + pass + +def depopulateBurningCities(): + # Gods.. This sounds kinda... bad, when I write it down. + # Meh. I'll comfort myself by telling myself I go to war less than most Civ players. + pass + +def landAlienInvaders():#TileMap.placeUnitNearTile + pass + +def erodeMountains(): + pass + +def erodeShorelines(): + pass + + +def onNewTurn(): + """""" + spawnNewDisasters('Krakatoa') + spawnNewDisasters('Barringer Crater') + + + +def ambushUnit(unit): + pass + +def onUnitMove(unit): + pass diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/EventPopup.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/EventPopup.py new file mode 100644 index 0000000000000..b32c333be0500 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/EventPopup.py @@ -0,0 +1,148 @@ +""" +Examples for scripting simple, Paradox-style/Beyond Earth-style event popups. + +""" + +#from unciv_scripting_examples.EventPopup import *; uncivGame.setWorldScreen(); r=showEventPopup(**EVENT_POPUP_DEMOARGS()) + +# modApiHelpers.lambdifyReadPathcode(None, 'apiHelpers.Jvm.constructorByQualname["com.unciv.ui.utils.ToastPopup"]("Test", uncivGame.getScreen(), 8000)') + +#apiHelpers.Jvm.constructorByQualname["com.unciv.ui.utils.ToastPopup"]("Test", uncivGame.getScreen(), 8000) + +#from . import Utils + +from unciv import * + +Constructors = apiHelpers.Jvm.constructorByQualname +ExtensionFunctions = apiHelpers.Jvm.functionByQualClassAndName['com.unciv.ui.utils.ExtensionFunctionsKt'] +Singletons = apiHelpers.Jvm.singletonByQualname +Constants = Singletons['com.unciv.Constants'] # With bind-by-reference, doing fewer key and attribute accesses by doing them earlier should actually be faster. + +def showPopup(): + p = Constructors['com.unciv.ui.utils.Popup'](uncivGame.getScreen()) + p.add(ExtensionFunctions['toLabel']("Test Text.")).row() + p.add(ExtensionFunctions['toTextButton']("Test Button.")).row() + closebutton = ExtensionFunctions['toTextButton'](Constants.close) + ExtensionFunctions['onClick']( + closebutton, + modApiHelpers.lambdifyReadPathcode(p, '.close()') + ) + p.add(closebutton) + p.open(False) + + +def showPopup2(): + p = Constructors['com.unciv.ui.utils.Popup'](uncivGame.getScreen()) + p.add(ExtensionFunctions['toLabel']("Test Text.")).row() + p.add(ExtensionFunctions['toTextButton']("Test Button.")).row() + closebutton = ExtensionFunctions['toTextButton'](Constants.close) + ExtensionFunctions['onClick']( + closebutton, + modApiHelpers.lambdifyReadPathcode(p, '.close()') + ) + p.add(closebutton) + p.open(False) + pass # Make button show toast and do something else. + + +Companions = apiHelpers.Jvm.companionByQualClass +Enums = apiHelpers.Jvm.enumMapsByQualname +GdxColours = apiHelpers.Jvm.staticPropertyByQualClassAndName['com.badlogic.gdx.graphics.Color'] +#StatColours = TODO +Fonts = Singletons['com.unciv.ui.utils.Fonts'] + + +import math +from unciv_pyhelpers import * + + +def showEventPopup(title=None, image=None, text="No text event text provided!", options={}): + assert apiHelpers.isInGame + # uncivGame.getScreen().stage.width + defaultcolour = GdxColours['WHITE'] + popup = Constructors['com.unciv.ui.utils.Popup'](uncivGame.getScreen()) + closeaction = modApiHelpers.lambdifyReadPathcode(popup, '.close()') + if title is not None: + popup.addGoodSizedLabel(title, 24).row() + popup.addSeparator().row() + popup.addGoodSizedLabel(text, 18).row() + for labels, clickaction in options.items(): + button = Constructors['com.badlogic.gdx.scenes.scene2d.ui.Button'](Companions['com.unciv.ui.utils.BaseScreen'].skin) + if isinstance(labels, str): + labels = (labels,) + elif isinstance(labels[0], str): + labels = (labels,) + for label in labels: + buttontext, buttoncolour = (label, None) if isinstance(label, str) else label + buttonlabel = ExtensionFunctions['toLabel'](buttontext, real(buttoncolour) or defaultcolour, 18) + button.add(buttonlabel).row() + ExtensionFunctions['onClick']( + button, + modApiHelpers.lambdifyCombine([ + *((clickaction,) if real(clickaction) else ()), + closeaction + ]) + ) + popup.add(button).row() + popup.open(False) + return popup # TODO: Need to update worldScreen. + + +def EVENT_POPUP_DEMOARGS(): + stats = civInfo.statsForNextTurn + goldboost, cultureboost, scienceboost = int(50+stats.gold*10), int(50+stats.culture*10), int(50+stats.science*10) + omniboost = 70 + (goldboost+cultureboost+scienceboost) // 2 + omniresistance = 20 + resistanceFlag = Enums["com.unciv.logic.city.CityFlags"]["Resistance"] + cities = tuple(civInfo.cities) + omniproductionboosts = tuple(int(real(min(production*10, max(production, cityconstructions.getRemainingWork(cityconstructions.getCurrentConstruction().name, True)+1)))) for city in cities for production, cityconstructions in [(city.cityStats.currentCityStats.production, city.cityConstructions)]) + return { + 'title': "Something has happened in your empire!", + 'image': "Generic And Dramatic Artwork!", # TODO # Note: Recommended method for mods is to ship file as internal image— Asynchronous/callback load from placeholder website? + 'text': """A societally and politically significant event has occurred in your empire! + +A political factor has been invisibly building up over the last ten turns or so of gameplay, and it has finally reached a tipping point where we think it will be narratively compelling! Because of the old way things were, things happened. Because things happened, things have changed, and now things have to change some more. From now on, the new way your empire is will be different from the old way it was before! + +Things can change in different ways. If we do one thing, things can change. If we do another thing, things can also change. + +This is your chance to roleplay a political decision: +""", + 'options': { # TODO: Serialize Chars as string? + (f"I'll take a Gold stat boost. (+{goldboost} {real(Fonts.gold.toString())})", GdxColours['GOLD']): + modApiHelpers.lambdifyReadPathcode(civInfo, f'.addGold({goldboost})'), # Can actually just read addGold for this. + (f"I'll take a Culture stat boost. (+{cultureboost} {real(Fonts.culture.toString())})", GdxColours['VIOLET']): + modApiHelpers.lambdifyReadPathcode(civInfo, f'.policies.addCulture({cultureboost})'), + (f"I'll take a Science stat boost. (+{scienceboost} {real(Fonts.science.toString())})", GdxColours['CYAN']): + modApiHelpers.lambdifyReadPathcode(civInfo, f'.tech.addScience({scienceboost})'), + ( + (f"Let Chaos reign! (+{omniboost} {real(Fonts.gold.toString())}, {real(Fonts.culture.toString())}, {real(Fonts.science.toString())})", None), + (f"(+{sum(omniproductionboosts)} {real(Fonts.production.toString())} spread across all your cities.)", None), + (f"All your cities enter resistance for +{omniresistance} turns.", GdxColours['SCARLET']) + ): + modApiHelpers.lambdifyCombine([ + modApiHelpers.lambdifyReadPathcode(civInfo, f'.addGold({omniboost})'), + modApiHelpers.lambdifyReadPathcode(civInfo, f'.policies.addCulture({omniboost})'), + modApiHelpers.lambdifyReadPathcode(civInfo, f'.tech.addScience({omniboost})'), + *( + modApiHelpers.lambdifyReadPathcode(None, f'civInfo.cities[{i}].cityConstructions.addProductionPoints({p})') # Will be a wrong result if somthing else changes the resistance turns between the popup being spawned and being shown. + for i, p in enumerate(omniproductionboosts) + ), + *( + modApiHelpers.lambdifyReadPathcode(None, f'''civInfo.cities[{ + i + }].setFlag(apiHelpers.Jvm.enumMapsByQualname["com.unciv.logic.city.CityFlags"]["Resistance"], { + int( + (omniresistance + city.getFlag(resistanceFlag)) + if city.hasFlag(resistanceFlag) else + omniresistance + ) + })''') # Will be a wrong result if somthing else changes the resistance turns between the popup being spawned and being shown. + for i, city in enumerate(civInfo.cities) + ), + modApiHelpers.lambdifyReadPathcode(None, 'civInfo.addNotification("Your empire is FURIOUS!!!\nWhat did you even do???", civInfo.cities[0].location, apiHelpers.Jvm.arrayOfTyped1("StatIcons/Resistance"))') + ]), + "Nah. I'm good.": + None + } + } + diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/ExternalPipe.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/ExternalPipe.py new file mode 100644 index 0000000000000..e27a16e1066d2 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/ExternalPipe.py @@ -0,0 +1,30 @@ +""" +Demo for using the scripting API to get and return data from the main Unciv process's STDIN and STDOUT. + +Could potentially be useful for "AI/ML" applications and the like. + + +It's a bit weird, because it means that every call basically goes through two different REPLs and at least six back and forth IPC packets: + +Kotlin/JVM:Unciv +—> CPython:EmulatedREPL +—> Kotlin/JVM:Unciv +—> ExternalProcess:REPLOrAutomation +—> Kotlin/JVM:Unciv +—> CPython:EmulatedREPL +—> Kotlin/JVM:Unciv + +But I think letting the script have control of the process's STDIN and STDOUT is preferable to hardcoding something into the Kotlin/JVM Unciv code. +""" + +def readUncivSdtOn(): + pass + +def printUncivStdOut(): + pass + +def evalFromUncivStdIn(): + pass + +def execFromUncivStdIn(): + pass diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/MapEditingMacros.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/MapEditingMacros.py new file mode 100644 index 0000000000000..ef9894f39143f --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/MapEditingMacros.py @@ -0,0 +1,381 @@ +""" +Example scripted map editor tools. + +Shows benefits of using the scripting language: +User modifiability, rapid development, idiomatic and expressive syntax, and dynamic access to system libraries. + +These example features below would be a nightmare to implement and maintain in the Kotlin code. +But in Python, they can be drafted up fairly quickly, and it's not a critical issue if they break, because they can't really interfere with anything else. + +And most importantly, they can be made and distributed by the user themselves as external files in their interpreter's library path. This lets very obscure and niche features be implemented and used by those who want them, without adding anything to the maintenance burden for the core Unciv codebase. +""" + +import math, random, os, json, re, base64, io + +import unciv +from unciv_pyhelpers import * + +from . import Utils + +# If you modify this file, please add any new functions to Tests.py. + +def stripJsonComments(text): + re.sub("//.*", "", re.sub('/\*.*?\*/', "", text, flags=re.DOTALL)) + +try: + terrainsjson = json.loads(stripJsonComments(real(unciv.apiHelpers.App.assetFileString("jsons/Civ V - Gods & Kings/Terrains.json")))) + # In an actual implementation, you would want to read from the ruleset instead of the JSON. But this is easier for me. +except Exception as e: + print("Couldn't load terrains Terrains.json") +else: + terrainbases = {t['name']: t for t in terrainsjson if t['type'] in ('Water', 'Land')} + terrainfeatures = {t['name']: t for t in terrainsjson if t['type'] == 'TerrainFeature'} + + +def showProgress(): + return progressupdater, finisher + + +def genValidTerrains(*, forbid=('Fallout',)): + #Searches only two layers deep. I.E. Only combinations with at most two TerrainFeatures will be found. + terrains = set() + + for terrain in terrainbases: + if terrain in forbid: + continue + terrains.add(terrain) + for feature, fparams in terrainfeatures.items(): + if feature not in forbid and terrain in fparams['occursOn']: + terrains.add(f"{terrain}/{feature}") + for otherfeature in fparams['occursOn']: + if otherfeature not in forbid and otherfeature in terrainfeatures: + otherparams = terrainfeatures[otherfeature] + if terrain in otherparams['occursOn']: + terrains.add(f"{terrain}/{otherfeature},{feature}") + return tuple(sorted(terrains)) + +# naturalterrains = tuple(sorted(genValidTerrains())) + + +_altitudeterrainsequence = ( + "Ocean", + ("Ocean", "Coast"), + (*("Coast",)*9, "Coast/Atoll"), + ("Desert/Flood plains", "Desert", "Desert/Marsh"), + (*("Plains",)*5, "Plains/Forest"), + (*("Grassland",)*3, "Grassland/Forest"), + ("Grassland/Hill", "Grassland/Hill,Forest"), + ("Plains/Hill,Forest", "Grassland/Hill,Jungle", "Plains/Hill,Jungle", "Desert/Hill", "Lakes"), + ("Mountain", "Tundra/Hill"), + ("Mountain", "Snow/Hill", "Mountain/Ice") +) + +_hardterrainsequence = ( + "Ocean", + "Coast", + "Desert/Marsh", + "Desert/Flood plains", + "Desert", + "Plains", + "Grassland", + "Plains/Forest", + "Grassland/Forest", + "Grassland/Hill" + "Plains/Hill", + "Desert/Hill", + "Mountain", + "Snow/Hill", + "Tundra/Hill", + "Tundra", + "Snow", + "Snow/Ice" +) + + +def indexClipped(items, index): + return items[max(0, min(len(items)-1, index))] + +def defaultArgTilemap(tileMap=None): + if tileMap is None: + if unciv.apiHelpers.isInGame: + return unciv.gameInfo.tileMap + else: + return unciv.mapEditorScreen.tileMap + else: + return tileMap + + +def terrainAsString(tileInfo): + s = real(tileInfo.baseTerrain) + if len(tileInfo.terrainFeatures): + s += "/" + ",".join(real(f) for f in tileInfo.terrainFeatures) + return s + +def terrainFromString(terrainstring): + baseterrain, _, terrainfeatures = terrainstring.partition("/") + terrainfeatures = terrainfeatures.split(",") if terrainfeatures else [] + return baseterrain, tuple(terrainfeatures) + +def setTerrain(tileInfo, terraintype): + if not isinstance(terraintype, str): + terraintype = random.sample(terraintype, 1)[0] + base, features = terrainFromString(terraintype) + tileInfo.baseTerrain = base + tileInfo.terrainFeatures.clear() + for f in features: + tileInfo.terrainFeatures.add(f) + + +def spreadResources(resourcetype="Horses", mode="random", restrictfrom=(), restrictto=None): + if mode == "random": + raise NotImplementedError() + elif mode in ("jittered", "grid", "clustered", "bluenoise"): + raise NotImplementedError(f"") + else: + raise TypeError() + + +def dilateTileTypes(tiletypes=("Coast", "Flood Plains"), chance=1.0, forbidreplace=("Ocean", "Mountain"), dilateas=("Desert/Flood Plains", "Coast"), iterations=1): + raise NotImplementedError() + +def erodeTileType(tiletypes=("Mountains", "Plains/Hill", "Grassland/Hill")): + raise NotImplementedError() + +def floodFillSelected(start=None, fillas=None, *, alsopropagateto=()): + raise NotImplementedError() + + +mandlebrotpresets = { + "Minibrot": {'center': (-0.105, -0.925), 'viewport': 0.006, 'indexer': "round((1/(i/8+1))*len(terrains))"}, + "Hat": {'center': (-1.301, -0.063), 'viewport': 2E-2, 'iterations': 300, 'indexer': "round(i/300*len(terrains))"}, + "TwinLakes": {'center': (-1.4476, -0.0048), 'viewport': 1E-3, 'iterations': 50, 'indexer': "round(i/200*len(terrains))"}, + "Curly": {'center': (-0.221, -0.651), 'viewport': 6E-3, 'iterations': 70, 'indexer': "round((1/(i/6+1))*len(terrains))"}, + "Crater": {'center': (-1.447858, -0.004673), 'viewport': 3.5E-5, 'iterations': 80, 'indexer': "round((1-1/(i+1))*len(terrains))"}, + "Rift": {'center': (-0.700, -0.295), 'viewport': 3E-3, 'iterations': 100}, + "Spiral": {'center': (-0.676, -0.362), 'viewport': 3E-3, 'iterations': 100, 'indexer': "round((1-1/(i+1))*len(terrains))"}, + "Pentabrot": {'expo': 6} +} + +def _mandelbrot(x, y, iterations=100, *, expo=2, escaperadius=12, innervalue=None): + c = complex(x,y) + z = 0+0j + dist = 0 + if innervalue is None: + innervalue = iterations + for i in range(iterations): + dist = math.sqrt(z.real**2+z.imag**2) + if dist > escaperadius: + break + z = z**expo+c + return innervalue if dist <= escaperadius else i + 1 - math.log(math.log(dist), expo) + + +def makeMandelbrot(tileMap=None, *, viewport=4, center=(-0.5,0), iterations=100, expo=2, indexer="round((1/(i+1))*len(terrains))", terrains=_hardterrainsequence, innervalue=0): + tileMap = defaultArgTilemap(tileMap) + scalefac = viewport / max(tileMap.mapParameters.mapSize.width, tileMap.mapParameters.mapSize.height) + offset_x, offset_y = center + indexer = compile(indexer, filename="indexer", mode='eval') + def coordsfromtile(tile): + return -tile.longitude*scalefac+offset_x, -tile.latitude*scalefac+offset_y + for tile in tileMap.values: + setTerrain( + tile, + indexClipped( + terrains, + eval( + indexer, + { + 'i': _mandelbrot(*coordsfromtile(tile), iterations=iterations, expo=expo, innervalue=innervalue), + 'terrains': terrains, + 'iterations': iterations + } + ) + ) + ) + + +def graph2D(tileMap=None, expr="sin(x/3)*5", north="Ocean", south="Desert"): + tileMap = defaultArgTilemap(tileMap) + expr = compile(expr, filename="expr", mode='eval') + for tile in tileMap.values: + setTerrain( + tile, + north if tile.latitude > eval(expr, {**math.__dict__, 'x': tile.longitude}) else south + ) + +def graph3D(tileMap=None, expr="sqrt(x**2+y**2)/6%5/5*len(terrains)", terrains=_hardterrainsequence): + tileMap = defaultArgTilemap(tileMap) + expr = compile(expr, filename="expr", mode='eval') + for tile in tileMap.values: + setTerrain( + tile, + indexClipped( + terrains, + math.floor(eval( + expr, + { + **math.__dict__, + 'x': real(tile.longitude), + 'y': real(tile.latitude), + 'terrains': terrains + } + )) + ) + ) + + +def setMapFromImage(tileMap, image, pixelinterpreter=lambda pixel: "Ocean"): + + longitudes, latitudes = ([real(getattr(t, a)) for t in tileMap.values] for a in ('longitude', 'latitude')) + min_long, max_long, min_lat, max_lat = (f(c) for c in (longitudes, latitudes) for f in (min, max)) + del longitudes, latitudes + width = max_long - min_long + height = max_lat - min_lat + + width_fac = (image.size[0] - 1) / width + height_fac = (image.size[1] - 1) / height + + for tile in tileMap.values: + # Since this just uses PIL images, we could also blur the image, or perhaps just jitter the sampled coordinates, by the projected tile radius here. Or could have earlier functions in the call stack wrap the image in a class that does that. + # See if reducing the Kotlin/JVM reflection depth by assigning tile to apiHelpers.registeredInstances reduces run time here. + setTerrain( + tile, + pixelinterpreter(image.getpixel(( + round((-tile.longitude + max_long) * width_fac), + round((-tile.latitude + max_lat) * height_fac) + ))) + ) + + +def _imageFallbackPath(imagepath): + if not os.path.exists(imagepath): + _fallbackpath = Utils.exampleAssetPath(imagepath) + if os.path.exists(_fallbackpath): + imagepath = _fallbackpath + print(f"Invalid image path given. Interpreting as example path at {repr(imagepath)}") + del _fallbackpath + return imagepath + + + +def loadImageHeightmap(tileMap=None, imagepath="EarthTopography.png", transform="pixel*len(terrains)", terrains=_altitudeterrainsequence, normalizevalues=255): + tileMap = defaultArgTilemap(tileMap) + import PIL.Image + transform = compile(transform, filename="transform", mode='eval') + imagepath = _imageFallbackPath(imagepath) + def pixinterp(pixel): + if isinstance(pixel, tuple): + pixel = sum(pixel)/len(pixel) + pixel /= normalizevalues + pixel = round(eval(transform, {'pixel': pixel, 'terrains': terrains})) + return indexClipped(terrains, pixel) + + with PIL.Image.open(imagepath) as image: + setMapFromImage(tileMap=tileMap, image=image, pixelinterpreter=pixinterp) + + + +def terrainImagePath(feature): + # Look in TileGroup.kt if you want to replace this with something that handles different tilesets. + return f"TileSets/FantasyHex/Tiles/{feature}" + +def compositedTerrainImage(terrain): + import PIL.Image + base, features = terrainFromString(terrain) + image = PIL.Image.open(io.BytesIO(base64.b64decode(real(unciv.apiHelpers.App.assetImageB64(terrainImagePath(base)))))) + for feature in features: + with PIL.Image.open(io.BytesIO(base64.b64decode(real(unciv.apiHelpers.App.assetImageB64(terrainImagePath(feature)))))) as layer: + image.alpha_composite(layer, (0, image.size[1]-layer.size[1])) + return image + +def getImageAverageRgb(image): + #image.convert('P', palette=PIL.Image.ADAPTIVE, colors=1) (Doesn't work by nearest.) + depth = 255 + assert image.mode in ("RGB", "RGBA") + hasalpha = image.mode == "RGBA" + r_sum = g_sum = b_sum = total_alpha = 0 + for x in range(image.size[0]): + for y in range(image.size[1]): + if hasalpha: + r, g, b, a = image.getpixel((x, y)) + a /= depth + else: + r, g, b = image.getpixel((x, y)) + a = 1.0 + r_sum += r * a + g_sum += g * a + b_sum += b * a + total_alpha += a + return tuple(c/total_alpha for c in (r_sum, g_sum, b_sum)) + + +def computeTerrainAverageColours(terrains=None): + if terrains is None: + terrains = genValidTerrains() + def terraincol(terrain): + with compositedTerrainImage(terrain) as i: + return getImageAverageRgb(i) + return {terrain: tuple(round(n) for n in terraincol(terrain)) for terrain in terrains} + + +class _TerrainColourInterpreter: + # To actually look good, this should use CIE, YUV, or at least HSV with a compressed saturation axis. + def __init__(self, terraincolours, maxdither=0): + self.terraincolours = terraincolours + self.maxdither = maxdither + if self.maxdither: + self.dithererror = [0, 0, 0] + @classmethod + def rgb_distance(cls, rgb1, rgb2): + return math.sqrt(sum([(a-b)**2 for a, b in zip(rgb1, rgb2)])) + def get_terrainandcolour(self, rgb): + return min(self.terraincolours.items(), key=lambda item: self.rgb_distance(item[1], rgb)) + def get_terraindithered(self, rgb): + rgb_compensated = tuple(c_target-c_error for c_target, c_error in zip(rgb, self.dithererror)) + terrain, rgb_final = self.get_terrainandcolour(rgb_compensated) + for i, (c_target, c_final, error_current) in enumerate(zip(rgb, rgb_final, self.dithererror)): + self.dithererror[i] = max(-self.maxdither*256, min(self.maxdither*256, error_current + (c_final - c_target))) + #Because the "colour palette" is usually very limited in range, and particularly because it often doesn't have any low-green values to bring the green error down (I.E. the mean channel value over the whole image may well be darker than the minimum available colour), limiting the maximum accumulatable error is necessary to avoid it running away. + # print(self.dithererror) # This should generally tend back towards [0,0,0]. + return terrain + def __call__(self, pixel): + if self.maxdither: + return self.get_terraindithered(pixel) + else: + return self.get_terrainandcolour(pixel)[0] + +def loadImageColours(tileMap=None, imagepath="EarthTerrainFantasyHex.jpg", terraincolours=None, maxdither=0, visualspace=True): + """ + Set a given tileMap or the active tileMap's terrain based on an image file and a mapping of terrain strings to RGB tuples. + + Recommended example values for imagepath: EarthTerrainFantasyHex.png, StarryNight.jpg, TurboRainbow.png (Try maxdither=0.5.), WheatField.jpg + """ + #https://visibleearth.nasa.gov/images/73801/september-blue-marble-next-generation-w-topography-and-bathymetry + #Generate TurboRainbow.png: from matplotlib import cm; from PIL import Image, ImageDraw; width=512; image = Image.new('RGB', (width,1), "white"); draw=ImageDraw.Draw(image); [draw.point((x,0), tuple(int(c*256) for c in cm.turbo(x/width)[:3])) for x in range(width)]; image.show("TurboRainbow.png"); image.close() + import PIL.Image + assert visualspace + tileMap = defaultArgTilemap(tileMap) + imagepath = _imageFallbackPath(imagepath) + if terraincolours is None: + print(f"\nNo terrain colours given. Computing average tile colours based on FantasyHex tileset. This may take several seconds.") + terraincolours = computeTerrainAverageColours(genValidTerrains()) + print(f"\nTerrain colours computed:\n{repr(terraincolours)}") + pixinterp = _TerrainColourInterpreter(terraincolours, maxdither=maxdither) + with PIL.Image.open(imagepath) as image: + setMapFromImage(tileMap, image, pixinterp) + + +def makeImageFromTerrainColours(allowedterrains, allow_compute_colours, visualspace=True): + requireComputedColours + pass# Make a PIL image, but don't mess with the user's filesystem. + #I guess inversing the output from this using loadImageColours could be a unit test. + + +def showToolPopup(): + pass + +def showToolboxPopup(): + pass + +def injectButton(actor): + pass diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Merfolk.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Merfolk.py new file mode 100644 index 0000000000000..09be7ebd96c40 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Merfolk.py @@ -0,0 +1,55 @@ +""" +Example and developmental test case for potential future modding API. + +Adds peaceful, playful Merpeople communities. + +But they won't hesitate to defend themselves with powerful magic if you bully them or others! + + +Call onGameStart() **once** at start of game. + +Call onNewTurn() on every new turn. + +Call onUnitMove(worldScreen.bottomUnitTable.selectedUnit) every time a unit moves. +""" + + +# Have them be like rubberbanding mobile city states in the early game, popping up in shorelines, lakes, rivers, and land to give gifts to the weakest players. +# Instantly declare war on nuclear weapon use, foreign capital city capture, or city state capture. +# Wage war by spawning units in water tiles, poisoning the water supply/charming the populace into resistance in landlocked cities. +# Stay at war until capital puppeted in case of nuclear weapon use, all foreign cities liberated in case of foreign capital/CS capture. +# Fight against mechanized barbarians (synergy with EndTimes.py alien invaders). +# In late game, cast spells to destroy volcanoes/craters (synergy with EndTimes.py natural disasters). +# Migratory cities. +# Seed the oceans with basic and luxury resources. +# If you really piss them off, flood/destroy your capital and displace its population. + +#civInfo.cities[0].cityStats.cityInfo.resistanceCounter +#CityFlags.Resistance + +#civInfo.addNotification("Test", civInfo.cities[0].location, apiHelpers.Jvm.arrayOfString(["StatIcons/Gold"])) + +#import random; [random.sample([*gameInfo.civilizations], 1)[0].placeUnitNearTile(t.position, random.sample(gameInfo.ruleSet.units.keys(), 1)[0]) for t in gameInfo.tileMap.values] + +# import random; [civInfo.placeUnitNearTile(t.position, unitgetter()) for unitgetter in (lambda: random.sample(gameInfo.ruleSet.units.keys(), 1)[0], lambda: random.sample(('Missile Cruiser', 'Nuclear Submarine'), 1)[0], lambda: 'Worker', lambda: 'Guided Missile') for t in gameInfo.tileMap.values]; ([setattr(unit, 'action', random.sample(('Sleep', 'Automate', 'Fortify 0', 'Explore'), 1)[0]) for unit in apiHelpers.Jvm.toList(civInfo.getCivUnits())], civInfo.addGold(99999)) + +# [setattr(unit, 'action', 'Sleep') for unit in apiHelpers.Jvm.toList(civInfo.getIdleUnits())] + +def moveCity(): + tileInfo.owningCity = city #Seems to be used for rendering only. + #city.tiles.add(tile) Causes exception in worker thread in next turn. + cities.tiles.add(tileInfo.position) #Requires next turn for visual update. + tileInfo.improvement = None #Requires next turn for visual update. + tileInfo.improvement = "City center" + city.location.x, city.location.y = x, y + + +def hammerOfTheOceans(): + pass + + +#TileMap.placeUnitNearTile + + +def onGameStart(): + pass diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/PlayerMacros.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/PlayerMacros.py new file mode 100644 index 0000000000000..42b0012c5712b --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/PlayerMacros.py @@ -0,0 +1,88 @@ +""" +Example automations to quickly do repetitive in-game tasks. + +Currently enables actions that break game rules to be done. + +IMO, that should be changed at the Kotlin side, by adding some kind of `PlayerAPI` class that unifies what the GUI is allowed to do with what the CLI is allowed to do. +""" + +from unciv import * +from unciv_pyhelpers import * + +def gatherBiggestCities(cities, ratio, stat='production'): + """Return the biggest cities from given cities that collectively comprise at least a given ratio of the total stats of all the cities. E.G: `gatherBiggestCities(civInfo.cities, 0.25, 'production')` will return a list of cities that together produce 25% of your empire's production.""" + assert 0 < ratio <= 1 + getstat = lambda c: getattr(c.cityStats.currentCityStats, stat) + total = sum(getstat(c) for c in cities) + inorder = sorted(cities, key=getstat, reverse=True) + currsum = 0 + threshold = total*ratio + selected = [] + for c in cities: + if currsum >= threshold: + break + selected.append(c) + currsum += getstat(c) + assert sum(getstat(c) for c in cities) >= threshold + print(f"Chose {len(selected)}/{len(inorder)} cities, representing {str(currsum/total*100)[:5]}% of total {total} {stat}.") + return selected + +def clearCitiesProduction(cities): + """Clear given cities of all queued and current production, and return the iterable of cities.""" + i = 0 # Loop never run when no cities? + for i, city in enumerate(cities): + city.cityConstructions.constructionQueue.clear() + print(f"Cleared production from {i+1} cities.") + return cities + +def addCitiesProduction(cities, queue=()): + """Add given construction items to the construction queues of given cities, and return the iterable of cities.""" + i = 0 + for i, city in enumerate(cities): + for build in queue: + city.cityConstructions.addToQueue(build) + print(f"Set {i+1} cities to build {queue}.") + return cities + +def clearCitiesSpecialists(cities): + """Unassign all specialist jobs in given cities.""" + for city in cities: + city.population.specialistAllocations.clear() + return cities + +def focusCitiesFood(cities): + """Assign all unassigned population in given cities to focus on food production.""" + for city in cities: + city.population.autoAssignPopulation(999) + return cities + +def buildCitiesQueue(cities, order): + """Assign all given cities to follow a given build order after their current queue.""" + raise NotImplementedError() + for city in cities: + with TokensAsWrappers(city.cityConstructions.getBuildableBuildings()) as queue: + pass + #apiHelpers.registeredObjects["x"] = city.cityConstructions.getBuildableBuildings() + #civInfo.cities[0].cityStats.cityInfo.cityConstructions.builtBuildings # HashSet(). Can do "in" via IPC magic, and made real(). But not iterable since __iter__ requires indexing. + +def rebaseUnitsEvenly(units=('Guided Missile',), ): + raise NotImplementedError() + if isinstance(units, str): + units = (units,) + +#import os, sys; sys.path.append(os.path.join(os.getcwd(), "../..")); from democomms import * + +#from unciv_scripting_examples.PlayerMacros import * +#[real(c.name) for c in addCitiesProduction(clearCitiesProduction(gatherBiggestCities(civInfo.cities, 0.5)), ('Missile Cruiser', 'Nuclear Submarine', 'Mobile SAM'))] + +#focusCitiesFood(clearCitiesSpecialists(civInfo.cities)) + +#apiHelpers.toString(worldScreen.bottomUnitTable.selectedCity.cityConstructions.getBuildableBuildings()) + +#worldScreen.mapHolder.selectedTile.terrainFeatures.addAll([k for k, v in gameInfo.ruleSet.terrains.items() if v.type.name == 'NaturalWonder']) + +##[setattr(city, 'civInfo', gameInfo.getCivilization(city.previousOwner)) for civ in gameInfo.civilizations for city in civ.cities if real(city.previousOwner)] +##[(lambda prev: [city.civInfo.cities.remove(city), prev.cities.add(city)] if 'remove' in dir(city.civInfo.cities) and 'add' in dir(prev.cities) else None )(next(ci for ci in gameInfo.civilizations if ci.nation.name == city.previousOwner)) for civ in gameInfo.civilizations for city in civ.cities if real(city.previousOwner)] +#[setattr(city, 'resistanceCounter', 0) for civ in gameInfo.civilizations for city in civ.cities if real(city.previousOwner)] +#[setattr(city, 'isPuppet', False) for civ in gameInfo.civilizations for city in civ.cities if real(city.previousOwner)] +#[setattr(city, 'previousOwner', "") for civ in gameInfo.civilizations for city in civ.cities if real(city.previousOwner)] diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/ProceduralTechtree.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/ProceduralTechtree.py new file mode 100644 index 0000000000000..9a819c0842820 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/ProceduralTechtree.py @@ -0,0 +1,180 @@ +""" +Proof-of-concept and developmental test case for potential future modding API. + +Adds functions to extend the tech tree indefinitely, with randomly generated new buildings, wonders, and units. + + +Call extendTechTree() at any time to add a new tier of technology. + +Call clearTechTree() and then extendTechTree(iterations=20) at any time to replace all undiscovered parts of the base tech tree with an entire new randomly generated one. + +Call scrambleTechTree() to keep all current technologies but randomize the order in which they are unlocked. + +Call reorgTechTree(webifyTechTree()) to turn the current tech tree into a tech web. +""" + +# from unciv_scripting_examples.ProceduralTechtree import * +# from unciv_scripting_examples.ProceduralTechtree import *; scrambleTechTree() +# from unciv_scripting_examples.ProceduralTechtree import *; reorgTechTree(webifyTechTree()) +# tuple(real(t) for t in apiHelpers.instancesAsInstances[gameInfo.ruleSet.technologies['Civil Service'].prerequisites.toArray()]) + +# This means that some kind of handler for the modding API, once it's implemented, would have to be called before the JVM has a chance to crash, so the script can read its own serialized data out of GameInfo and inject items into the ruleset… You can already provably have a GameInfo with invalid values that doesn't crash until WorldScreen tries to render it, so running onGameLoad immediately after deserializing the save might be good enough. +# Or I guess there could be a Kotlin-side mechanism for serializing injected rules. But on the Kotlin side, I think it would be cleaner to just let the script handle everything. Such a mechanism still may not cover the entire range of wild behaviours that can be done by scripts, and the entire point of having a dynamic scripting API is to avoid having to statically hard-code niche or esoteric uses. + + +import random, math + +from unciv import * +from unciv_pyhelpers import * + + +def techtree(): return gameInfo.ruleSet.technologies + +name_parts = { + "Building": ( + ("Old ", "Earth ", "Alien ", "Feedsite ", "Holo-", "Cel ", "Xeno ", "Xeno-" "Terra ", "Thorium ", "Xenofuel ", "Biofuel ", "Gaian ", "Field ", "Tidal ", "Cloning ", "Mass ", "Grow-", "Molecular ", "Nano-", "Civil ", "Cyto-", "Pharma-", "Gene ", "Bionics ", "Optical ", "Soma ", "Progenitor ", "Neuro-", "Organ ", "Hyper-", "Trade ", "Auto-", "Bio-", "Alloy ", "Dry-", "Repair ", "LEV ", "Microbial ", "Bore-", "Bioglass ", "Sky-", "Warp ", "Ultrasonic ", "Rocket ", "Defence ", "Surveillance ", "Command ", "Node ", "Mosaic ", "Sonar ", "Torpedo ", "Frontier ", "Drone ", "Launch ", "Mind ", "Neo-", "Voice ", "Pan-Spectral ", "Petrochemical ", "Thermohaline ", "Water ", "Xenomass "), + ("Relic", "Preserve", "Hub", "Suite", "Cradle", "Sanctuary", "Vault", "Reactor", "Plant", "Well", "Turbine", "Vivarium", "Digester", "Lab", "Forge", "Pasture", "Crèche", "Clinic", "Nursery", "Garden", "Smelter", "Surgery", "Distillery", "Laboratory", "Observatory", "Network", "Institute", "Printer", "Mantle", "Core", "Depot", "Recycler", "Factory", "Foundry", "Dock", "Facility", "Mine", "Hole", "Furnace", "Crane", "Spire", "Fence", "Battery", "Perimeter", "Web", "Center", "Bank", "Hull", "Net", "Stadium", "Augmentery", "Command", "Complex", "Stem", "Planetarium", "Archives", "Rudder", "Refinery", "House") + ), + "Unit": ( + ("Combat ", "Missile ", "Patrol ", "Gun-", "Tac-", "Xeno ", "Rock-", "Battle-", "LEV ", "Drone ", "Auto-", "Nano-", "Gelio-", "All-", "Laser-", "Phasal ", "Solar ", "Wolf ", "Raptor ", "Siege ", "Sea ", "Hydra-", "Tide-", "Under-", "Needle-", "Evolved ", "True ", "Prime ", "First ", "Master ", "Elder "), + ("Explorer", "Soldier", "Ranger", "Rover", "Boat", "Submarine", "Carrier", "Jet", "Swarm", "Cavalry", "Octopus", "Titan", "Suit", "Aegis", "Tank", "Destroyer", "CNDR", "CARVR", "SABR", "ANGEL", "Immortal", "Architect", "Throne", "Cage", "Sled", "Golem", "Hive", "Pod", "Aquilon", "Seer", "Matrix", "Laser", "Carver", "Siren", "Batle", "Bug", "Worm", "Drones", "Manticore", "Dragon", "Kraken", "Coral", "Makara", "Ripper", "Scarab", "Marine", "Brawler", "Sentinel", "Disciple", "Maurauder", "Centurion", "Apostle", "Champion", "Eidolon", "Hellion", "Striker", "Guardian", "Overseer", "Shredder", "Warden", "Executor", "Kodiak", "Virtuoso", "Fury", "Armor", "Viper", "Lancer", "Prophet", "Cobra", "Dragoon", "Redeemer", "Gladiator", "Maestro", "Savage", "Artillery", "Centaur", "Punisher", "Educator", "Minotaur", "Devastator", "Ambassador", "Cutter", "Screamer", "Broadside", "Tenet", "Reaver", "Cannonade", "Edict", "Argo", "Baron", "Vortex", "Cruiser", "Triton", "Destroyer", "Arbiter", "Poseidon", "Dreadnought", "Vindicator", "Mako", "Countess", "Wrath", "Hunter", "Lurker", "Taker", "Whisper", "Leviathan", "Eradicator", "Shroud", "Hydra", "Bastion", "Shepherd", "Locust", "Raider", "Herald", "Shrike", "Predator", "Seraph") + ), + "Wonder": ( + ("Spy ", "Culper ", "Tessellation ", "Machine-Assisted ", "Dimensional ", "Folding ", "Dimensional Folding ", "Quantum ", "Temporal ", "Relativistic ", "Abyssal ", "Archimedes ", "Arma-", "Benthic ", "Byte-", "Daedaleus ", "Deep ", "Drone ", "Ecto-", "Genesis ", "Euphotic ", "Faraday ", "Gene ", "Guo Pu ", "Holon ", "Human ", "Markov ", "Mass ", "Master ", "Memet-", "Nano-", "New Terran ", "Pan-", "Precog ", "Promethean ", "Quantum ", "Resurrection ", "Stellar ", "Tectonic ", "The ", "Xeno-", "Emancipation ", "Exodus ", "Mind ", "Transcendental ", "Decode "), + ("Mirror", "Ansible", "Lever", "Sail", "Auger", "Geist", "Crawler", "Cynosure", "Ladder", "Memory", "Sphere", "Pod", "Genesis Pod", "Strand", "Gyre", "Vault", "Yaolan", "Chamber", "Hive", "Eclipse", "Driver", "Control", "Work", "Thermite", "Myth", "Opticon", "Project", "Promethean", "Computer", "Device", "Codex", "Anvil", "Akkorokamui", "Drome", "Malleum", "Nova", "Gate", "Flower", "Equation", "Signal", "Beacon") + ) +} + + +usedNames = set() + +def genRandomName(nametype): #Mix and match from BE. + prefixes, suffixes = name_parts[nametype] + prefix, suffix = random.sample(prefixes, 1)[0], random.sample(suffixes, 1)[0] + return prefix[:-1]+suffix[0].lower()+suffix[1:] if prefix[-1] == "-" else prefix+suffix + # Could make first letter fit second letter's capitalization. + +def genRandomNameUnused(nametype, *, maxattempts=1000): + for i in range(maxattempts): + name = genRandomName(nametype) + if name not in usedNames: + break + else: + raise Exception() + usedNames.add(name) + return name + +def genRandomIcon(): + pass #Define randomized number of randomized shapes. Randomize order. Draw area union. Draw layer-occluded boundary lines separately. Subtract edges from area. + + + +# def genRandomEra(): + # pass + +def genRandomUnit(): + pass + +def genRandomBuildingUnique(): + # See replaceExamples in UniqueDocsWriter.kt. + pass + +def genRandomBuildingStats(name, totalstats, statsskew, numuniques): + assert 0 <= statsskew <= 1 + for x in x: + x *= 1+(random.random()*2-1)*statsskew + +def genRandomBuilding(): + pass + +def genRandomWonder(): + pass + + +def genRandomTech(column, row): + connections = random.sample((0, 0, -2, -1, 1, 2), random.randint(1,3)) + + +def _getInvalidTechs(): + pass + + +#t=gameInfo.ruleSet.technologies['Mining']; t.row -= 1; t.column.techs.remove(t); b=gameInfo.ruleSet.technologies['Masonry']; b.column.techs.add(t); t.column=b.column + + +def extendTechTree(iterations=1): + raise NotImplementedError() + pass + +def clearTechTree(*, safe=True): + """Clear all items on the tech tree that haven't yet been researched by any civilizations. Pass safe=False to also clear technologies that have already been researched.""" + for name in techtree().keys(): + if (not safe) or not any(name in civinfo.tech.techsResearched or name in civinfo.tech.techsInProgress or name == civinfo.tech.currentTechnologyName() for civinfo in gameInfo.civilizations): + del techtree()[name] + +def scrambleTechTree(): + """Randomly shuffle the order of all items on the tech tree.""" + technames = [*techtree().keys()] + random.shuffle(technames) + techpositions = {n:n for n in technames} + originalpreqs = {n:tuple(real(tname) for tname in apiHelpers.instancesAsInstances[techtree()[n].prerequisites.toArray()]) for n in technames} # .prerequisites is a HashSet that becomes inaccessible with always-tokenizing serialization. + for tname in technames: + oname = random.sample(technames, 1)[0] + tech, other = (techtree()[n] for n in (tname, oname)) + for t in (tech, other): + t.column.techs.remove(t) + tech.column, other.column = real(other.column), real(tech.column) + for t in (tech, other): + t.column.techs.add(t) + tech.row, other.row = real(other.row), real(tech.row) + techpositions[tname], techpositions[oname] = techpositions[oname], techpositions[tname] + techreplacements = {v:k for k, v in techpositions.items()} + assert len(techreplacements) == len(techpositions) + for tname in technames: + tech = techtree()[tname] + tech.prerequisites.clear() + for ot in originalpreqs[techpositions[tname]]: + try: techreplacements[ot] + except: print(tname, ot) + tech.prerequisites.addAll([techreplacements[ot] for ot in originalpreqs[techpositions[tname]]]) + # toprereqs, oprereqs = (real(t.prerequisites) for t in (tech, other)) + +def reorgTechTree(techmap): + Constructors = apiHelpers.Jvm.constructorByQualname + techs = techtree() + columns = {x: Constructors['com.unciv.models.ruleset.tech.TechColumn']() for x in set(x for (x, y, era) in techmap.values())} + columneras = {x: era for (x, y, era) in techmap.values()} + for x, column in columns.items(): + column.era = columneras[x] + column.columnNumber = x + for name, (x, y, era) in techmap.items(): + techs[name].row = y + techs[name].column = columns[x] + +def webifyTechTree(*, coordscale=(0.5, 2.0), fuzz=1.0): + techlocs = {} + for name, tech in techtree().items(): + techlocs[name] = (tech.column.columnNumber, tech.row) + if tech.hasUnique("Starting tech", None): + era = tech.column.era + startingpos = techlocs[name] + oldmaxrow = max(y for (x, y) in techlocs.values()) + techlocs = {name: tuple(round(trig((y-startingpos[1]+fuz)/oldmaxrow*math.pi*2)*(x-startingpos[0])*cscale) for trig, cscale in zip((math.sin, math.cos), coordscale)) for name, (x, y) in techlocs.items() for fuz in (fuzz and random.random()*fuzz,)} + def inc(n, chance=1.0): + return (round(n-1) if n < 0 else round(n+1) if n > 0 else round(n+random.getrandbits(1)*2-1)) if random.random() < chance else n + mutatechances = max(coordscale) + mutatechances = tuple(c/mutatechances for c in coordscale) + for i in range(100): + if len(set(techlocs.values())) == len(techlocs): + break + for name, pos in techlocs.items(): + if tuple(techlocs.values()).count(pos) > 1: + techlocs[name] = tuple(inc(c, cscale) for c, cscale in zip(pos, mutatechances)) + else: + raise RuntimeError(f"Couldn't generate unique tech positions:\n{locals()}") + mincolumn = min(x for (x, y) in techlocs.values()) + minrow = min(y for (x, y) in techlocs.values()) + techmap = {name: (x-mincolumn, y-minrow+1, era) for name, (x, y) in techlocs.items()} + return techmap + + diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Reskin.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Reskin.py new file mode 100644 index 0000000000000..16ec0f37771e9 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Reskin.py @@ -0,0 +1,3 @@ +""" +Experimental example for applying a custom look and feel to the entire Unciv app by mutating the global Skin instance. +""" diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Tests.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Tests.py new file mode 100644 index 0000000000000..a3f0546db0356 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Tests.py @@ -0,0 +1,326 @@ +""" +Automated testing of practical Python scripting examples. +Intended to also catch breaking changes to the scripting API, IPC protocol, and reflective tools. + + +Call TestRunner.run_tests() to use. + +Pass debugprint=False if running from Kotlin as part of build tests, because running scripts' STDOUT is already captured by the Python REPL and sent to Kotlin code, which should then check for the presence of the 'Exception' IPC packet flag. +""" + +import os + +import unciv, unciv_pyhelpers#, unciv_lib + +from . import EndTimes, EventPopup, ExternalPipe, MapEditingMacros, Merfolk, PlayerMacros, ProceduralTechtree, Utils + + +# from unciv_scripting_examples.Tests import *; TestRunner.run_tests() +# import cProfile; cProfile.run("from unciv_scripting_examples.Tests import *; TestRunner.run_tests()", sort='tottime') +# from unciv_scripting_examples.Tests import *; InMapEditor.__enter__() +# from unciv_scripting_examples.Tests import *; import time; Start=time.time(); tryRun(TestRunner.run_tests); print(time.time()-Start) +# from unciv_scripting_examples.Tests import *; import timeit; x=timeit.repeat(stmt='tryRun(TestRunner.run_tests)', setup='tryRun(TestRunner.run_tests)', repeat=3, number=3, globals=globals()); print(x); unciv.apiHelpers.Sys.copyToClipboard(repr(x)); unciv.apiHelpers.Sys.printLine(repr(x)) + +# TODO: Protocol desync: +# import cProfile; cProfile.run("from unciv_scripting_examples.Tests import *; TestRunner.run_tests()", sort='cumtime') +# Ran first without sort, then with. +# Or just run twice. +# Wait, there's erroring due to unrelated handlers too… + + +# I have no idea how it managed to print out to the system terminal. + # 4932599 function calls (4929366 primitive calls) in 23.806 seconds + + # Ordered by: internal time + + # ncalls tottime percall cumtime percall filename:lineno(function) + # 40610 17.682 0.000 17.822 0.000 {method 'readline' of '_io.TextIOWrapper' objects} + # 1624812/1623564 0.764 0.000 0.869 0.000 wrapping.py:267(__getattribute__) + # 40610/40604 0.534 0.000 0.540 0.000 encoder.py:204(iterencode) + # 40657 0.440 0.000 0.440 0.000 {built-in method builtins.print} + # 75512 0.381 0.000 0.865 0.000 wrapping.py:231(__init__) + # 631561 0.358 0.000 0.358 0.000 wrapping.py:207(__setattr__) + # 75512 0.244 0.000 1.263 0.000 wrapping.py:244(_clone_) + # 35849 0.206 0.000 21.154 0.001 wrapping.py:250(_bakereal_) + # 40610/40604 0.183 0.000 20.482 0.001 ipc.py:70(GetForeignActionResponse) + # 40611 0.178 0.000 0.178 0.000 decoder.py:343(raw_decode) + # 40610 0.156 0.000 0.695 0.000 ipc.py:30(deserialized) + # 40610/40604 0.155 0.000 20.890 0.001 wrapping.py:17(meth) + # 40610/40604 0.135 0.000 0.859 0.000 __init__.py:183(dumps) + # 40611 0.116 0.000 0.392 0.000 decoder.py:332(decode) + # 40610/40604 0.114 0.000 0.682 0.000 encoder.py:182(encode) + # 27010 0.100 0.000 7.731 0.000 wrapping.py:257(__getattr__) + # 40610 0.099 0.000 0.134 0.000 ipc.py:13(makeUniqueId) + +# Is it going to turn out that Kotlin, not Python, is the bottleneck? I suppose that wouldn't be wholly a bad thing; It also means that the API design is sanely keeping the heavier lifting in the compiled part. + + +def tryRun(func): + # For debug and manual runs. Not used otherwise. + try: return func() + except Exception as e: return e + + +with open(Utils.exampleAssetPath("Elizabeth300"), 'r') as save: + # TODO: Compress this. Unciv uses Base64 and GZIP. + Elizabeth300 = save.read() + + +def getTestGame(): + return unciv.Unciv.GameSaver.gameInfoFromString(Elizabeth300) + +def goToMainMenu(): + unciv.uncivGame.setScreen(unciv.apiHelpers.Jvm.constructorByQualname['com.unciv.MainMenuScreen']())#unciv.apiHelpers.Jvm.Gui.MainMenuScreen()) + + +@Utils.singleton() +class InGame: + """Context manager object that loads a test save on entrance and returns to the main menu on exit.""" + def __enter__(self): + unciv.uncivGame.loadGame(getTestGame()) + def __exit__(self, *exc): + goToMainMenu() + +@Utils.singleton() +class InMapEditor: + """Context manager object that loads a test map in the map editor on entrance and returns to the main menu on exit.""" + def __enter__(self): + with Utils.TokensAsWrappers(getTestGame()) as (gameinfo,): + unciv.uncivGame.setScreen( + unciv.apiHelpers.Jvm.constructorByQualname['com.unciv.ui.mapeditor.MapEditorScreen'](gameinfo.tileMap) + # SetScreen doesn't seem to be needed here. But that seems like a glitch in the core Unciv code. + ) + def __exit__(self, *exc): + goToMainMenu() + + +@Utils.singleton() +class TestRunner: + """Class for registering and running tests.""" + # No point using any third-party or Standard Library testing framework, IMO. The required behaviour's simple enough, and the output format to Kotlin ('Exception' flag or not) is direct enough that it's easier and more concise to just implement everything here. + def __init__(self): + self._tests = [] + class _TestCls: + """Class to define and run a single test. Accepts the function to test, a human-readable name for the test, a context manager with which to run it, and args and kwargs with which to call the function.""" + def __init__(self, func, name=None, runwith=None, args=(), kwargs={}): + self.func = func + self.name = getattr(func, '__name__', None) if name is None else name + self.runwith = runwith + self.args = args + self.kwargs = kwargs + def __call__(self): + def run(): + self.func(*(self.args() if callable(self.args) else self.args), **(self.kwargs() if callable(self.kwargs) else self.kwargs)) + if self.runwith is None: + run() + else: + with self.runwith: + run() + def keys(self): + return [t.name for t in self._tests] + def __getitem__(self, key): + return next(t for t in self._tests if t.name == key) + def Test(self, *args, **kwargs): + """Return a decorator that registers a function to be run as a test, and then returns it unchanged. Accepts the same configuration arguments as _TestCls.""" + # Return values aren't checked. A call that completes is considered a pass. A call that raises an exception is considered a fail. + # If you need to check return values for a function, then just wrap them in another function with an assert. + def _testdeco(func): + self._tests.append(self._TestCls(func, *args, **kwargs)) + return func + return _testdeco + def run_tests(self, *, debugprint=True): + """Run all registered tests, printing out their results, and raising an exception if any of them fail.""" + failures = {} + def _print(*args, **kwargs): + print(*args, **kwargs) + if debugprint: + # When run as part of build, the Kotlin test-running code should be capturing the Python STDOUT anyway. + unciv.apiHelpers.Sys.printLine(str(args[0]) if len(args) == 1 and not kwargs else " ".join(str(a) for a in args)) + for test in self._tests: + try: + test() + except Exception as e: + failures[test] = e + n, t = '\n\t' + _print(f"Python test FAILED: {test.name}\n\t{repr(e).replace(n, n+t)}") + else: + _print(f"Python test PASSED: {test.name}") + _print("\n") + if failures: + failcounts = {} + for exc in failures.values(): + exc_name = exc.__class__.__name__ + if exc_name not in failcounts: + failcounts[exc_name] = 0 + failcounts[exc_name] += 1 + del exc_name + exc = AssertionError(f"{len(failures)}/{len(self._tests)} Python tests FAILED: {[test.name for test in failures]}\nFailure types: {failcounts}\n\n") + _print(exc) + raise exc + else: + _print(f"All {len(self._tests)} Python tests PASSED!\n\n") + + + +#### Tests begin here. #### + + +@TestRunner.Test() +def AssertionsEnabledTest(): + try: + assert False + # Can also check __debug__. Meh. Explicit(ly using the behaviour) is better here than implicit(ly relying on related behaviour). + except: + pass + else: + raise RuntimeError("Assertions must be enabled to run Python tests. Results may contain false passes.") + + +@TestRunner.Test(runwith=InGame) +def LoadGameTest(): + """Example test. Explicitly tests that the InGame context manager is working.""" + # The other tests below are all set up the same, just by explicitly passing existing functions to the registration function instead of using it as a decorator. + assert unciv.apiHelpers.isInGame + for v in (unciv.gameInfo, unciv.civInfo, unciv.worldScreen): + assert unciv_pyhelpers.real(v) is not None + assert unciv_pyhelpers.isForeignToken(v) + + +# @TestRunner.Test(runwith=InGame, name="NoPrivatesTest-InGame", args=(unciv, 2)) +# @TestRunner.Test(runwith=InMapEditor, name="NoPrivatesTest-InMapEditor", args=(unciv, 2)) +# Enable this if it's ever decided to guarantee that the 'dir' IPC action type won't return inaccessible names. (I don't think that's necssary, though. I mean, I don't think successful property access is guaranteed by Python semantics either? And if it's *really* an issue, the AttributeError raised by ForeignObject.__getattr__ can be checked for in ForeignObject.__dir__.) +def NoPrivatesTest(start, maxdepth, *, _depth=0, _failures=None, _namestack=None): + # Would have to differentiate between unitialized properties and the like, and privates. + if _failures is None: + _failures = [] + if _namestack is None: + _namestack = () + try: + names = dir(start) + except: + _failures.append('.'.join(_namestack)) + else: + for name in dir(start): + namestack = (*_namestack, name) + try: + v = getattr(start, name) + except: + _failures.append('.'.join(namestack)) + else: + if _depth < maxdepth: + NoPrivatesTest(v, maxdepth, _depth=_depth+1, _failures=_failures, _namestack=namestack) + if _depth == 0: + assert not _failures, _failures + + +@TestRunner.Test() +def NoRecursiveScriptingTest(): + try: + unciv.modApiHelpers.callLambdaAllowException( + unciv.modApiHelpers.lambdifySuppressReturn( + unciv.modApiHelpers.lambdifyReadPathcode(None, "") + ) + ) + # Should work, because those lambdas are run in JVM. + except Exception as e: + raise AssertionException(f"Normal lambda failed: {e}") + try: + unciv.modApiHelpers.callLambdaAllowException( + unciv.modApiHelpers.lambdifyExecScript("print()") + ) + # Should fail, because recursive scripting is forbidden. + except Exception as e: + re = repr(e) + assert (isinstance(e, unciv_pyhelpers.ForeignError) and 'already in use' in re and 'Cannot acquire' in re), f"Recursive scripting failed like expected, but did not produce the expected exception: {repr(e)}" + else: + raise AssertionError(f"Recursive scripting succeeded, but it isn't supposed to.") + # If it ever gets here, the entire IPC loop will almost definitely be broken, so this will never actually make it to the JVM. But oh well. Let's still keep an explicit test nonetheless for semantic/spec clarity. + + +# Tests for PlayerMacros.py. + +TestRunner.Test(runwith=InGame, args=lambda: (unciv.civInfo.cities, 0.5))( + PlayerMacros.gatherBiggestCities +) +TestRunner.Test(runwith=InGame, args=lambda: (unciv.civInfo.cities,))( + PlayerMacros.clearCitiesProduction +) +TestRunner.Test(runwith=InGame, args=lambda: (unciv.civInfo.cities, ("Scout", "Warrior", "Worker")))( + PlayerMacros.addCitiesProduction +) +TestRunner.Test(runwith=InGame, args=lambda: (unciv.civInfo.cities,))( + PlayerMacros.clearCitiesSpecialists +) +TestRunner.Test(runwith=InGame, args=lambda: (unciv.civInfo.cities,))( + PlayerMacros.focusCitiesFood +) +TestRunner.Test(runwith=InGame, args=lambda: (unciv.civInfo.cities, ("Monument", "Shrine", "Worker")))( + PlayerMacros.buildCitiesQueue +) +TestRunner.Test(runwith=InGame)( + PlayerMacros.rebaseUnitsEvenly +) + + +# Tests for MapEditingMacros.py. + +_m = MapEditingMacros + +for _func in ( + _m.spreadResources, + _m.dilateTileTypes, + _m.erodeTileType, + _m.floodFillSelected, + _m.makeMandelbrot, + _m.graph2D, + _m.graph3D, + _m.loadImageHeightmap, + _m.loadImageColours +): + for _cm in (InGame, InMapEditor): + TestRunner.Test(runwith=_cm, name=f"{_func.__name__}-{_cm.__class__.__name__}")(_func) + +del _m, _func, _cm + + +# Tests for ProceduralTechTree.py. + +_m = ProceduralTechtree + +for _func in ( + _m.extendTechTree, + _m.clearTechTree, + _m.scrambleTechTree +): + TestRunner.Test(runwith=InGame)(_func) + +del _m, _func + + +# Tests for EventPopup.py + +TestRunner.Test()( + EventPopup.showPopup +) +TestRunner.Test()( + EventPopup.showPopup2 +) +TestRunner.Test(runwith=InGame, kwargs=EventPopup.EVENT_POPUP_DEMOARGS)( + EventPopup.showEventPopup +) + + +#TODO: Add tests. Will probably require exception field in IPC protocol to use. + +#Basic IPC protocol specs and Pythonic operators. + +#No error in any examples. + +#ScriptingScope properties correctly set and nullified by different screens. + +#Token reuse, once that's implemented. + +#Probably don't bother with DOCTEST, or anything. Just use assert statements where needed, print out any errors, and check in the build tests that there's no exceptions (by flag, or by printout value). + +#ForeignObject equality and hash comparisons. + diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/TicTacToe.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/TicTacToe.py new file mode 100644 index 0000000000000..ff6ee5993ea2e --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/TicTacToe.py @@ -0,0 +1,31 @@ +""" +Demo and experimental mod for script-created and script-controlled GUI elements. + +Adds Tic-Tac-Toe minigame. Winner gets whatever they're asking for from any currently open trade deal. + + +Call showPopup() to start playing. + +Call injectAnywhere() to create a UI button to launch the game. + + +Due to performance and stability concerns, it's not recommended for a mod to write a lot of code like this. + +But it can be done. +""" + + +# TradeEvaluation().isTradeAcceptable + + +def injectWorldScreen(): + pass + +def injectMainMenu(): + pass + +def injectGeneric(): + pass + +def injectAnywhere(): + pass diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/UniqueInjection.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/UniqueInjection.py new file mode 100644 index 0000000000000..0a99197d73a4c --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/UniqueInjection.py @@ -0,0 +1,7 @@ +""" +Demonstration of injecting uniques outside of the ruleset into a +""" + +# Precedent for injected unique: "Requires a [] in all cities". +# Since uniques are transient and such, probably inject into promotions. (Except that checks against the ruleset.) +# Inject into temporaryUniques. diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Utils.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Utils.py new file mode 100644 index 0000000000000..e929c676af7ae --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/Utils.py @@ -0,0 +1,69 @@ +import os, atexit, random, time, sys + +import unciv, unciv_pyhelpers + + +RegistryKey = "python-package:Unciv/unciv_scripting_examples" + +registeredInstances = unciv.apiHelpers.registeredInstances + +if RegistryKey not in registeredInstances: + registeredInstances[RegistryKey] = {} + +memalloc = registeredInstances[RegistryKey] + + +def singleton(*args, **kwargs): + def _singleton(cls): + return cls(*args, **kwargs) + return _singleton + + +def exampleAssetPath(*path): + return os.path.join(os.path.dirname(__file__), "example_assets", *path) + + +class TokensAsWrappers: + def __init__(self, *tokens): + self.tokens = tokens + self.memallocKeys = [] + currentRegisteredKeys = set() + @classmethod + def genUniqueKey(cls): + key = None + while key is None or key in cls.currentRegisteredKeys: + key = f"{random.getrandbits(30)}_{time.time_ns()}" + cls.currentRegisteredKeys.add(key) + return key + @classmethod + def freeUniqueKey(cls, key): + cls.currentRegisteredKeys.remove(key) + def __enter__(self): + global memalloc + for token in self.tokens: + assert unciv_pyhelpers.isForeignToken(token) + key = self.genUniqueKey() + memalloc[key] = token + self.memallocKeys.append(key) + return tuple(memalloc[k] for k in self.memallocKeys) + def __exit__(self, *exc): + global memalloc + for key in self.memallocKeys: + del memalloc[key] + self.freeUniqueKey(key) + self.memallocKeys.clear() + + +def execCodeInModule(moduleQualname, code): + exec(code, sys.modules[moduleQualname].__dict__, None) + +def makeLocalLambdaCode(moduleQualname, code): + """Return a Python code string that, when executed, will execute the given code string inside the module at the given qualified name.""" + return f'import {__name__}; {__name__}.execCodeInModule({repr(moduleQualname)}, {repr(code)})' + # Could cache a compile(code) if Python performance is a huge issue. + + +@atexit.register +def on_exit(): + # I don't think this actually works. + del unciv.apiHelpers.instanceRegistry[RegistryKey] diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/__init__.py b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/__init__.py new file mode 100644 index 0000000000000..2d36ff514f061 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/__init__.py @@ -0,0 +1,8 @@ +""" + +Nothing should depend on anything in this module. +""" + +__all__ = ["EndTimes", "EventPopup", "ExternalPipe", "MapEditingMacros", "Merfolk", "PlayerMacros", "ProceduralTechtree", "Tests", "TicTacToe", "Utils"] + +from . import EndTimes, EventPopup, ExternalPipe, MapEditingMacros, Merfolk, PlayerMacros, ProceduralTechtree, Tests, TicTacToe, Utils diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTerrainFantasyHex.jpg b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTerrainFantasyHex.jpg new file mode 100644 index 0000000000000..05f7aa1d64f76 Binary files /dev/null and b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTerrainFantasyHex.jpg differ diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTerrainRaw.png b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTerrainRaw.png new file mode 100644 index 0000000000000..e8bc5b8d890b9 Binary files /dev/null and b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTerrainRaw.png differ diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTopography.png b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTopography.png new file mode 100644 index 0000000000000..8724bb2e847a6 Binary files /dev/null and b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/EarthTopography.png differ diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/Elizabeth300 b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/Elizabeth300 new file mode 100644 index 0000000000000..7344834a6e203 --- /dev/null +++ b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/Elizabeth300 @@ -0,0 +1 @@ +{civilizations:[{gold:-318,civName:Barbarians,tech:{techsResearched:[Drama and Poetry,Optics,Mathematics,Guilds,Mining,Engineering,Sailing,Archery,Currency,Pottery,Agriculture,Iron Working,Theology,Metal Casting,Construction,Calendar,Masonry,Compass,Horseback Riding,Physics,Trapping,Bronze Working,The Wheel,Animal Husbandry,Writing,Civil Service,Philosophy]},policies:{},civConstructions:{},questManager:{},religionManager:{storedFaith:7},goldenAges:{storedHappiness:1134,numberOfGoldenAges:3},greatPeople:{},victoryManager:{},ruinsManager:{},diplomacy:{Vancouver:{otherCivName:Vancouver,diplomaticModifiers:{DeclaredWarOnUs:-12.5}},Quebec City:{otherCivName:Quebec City,diplomaticModifiers:{DeclaredWarOnUs:-12.5}},Florence:{otherCivName:Florence,diplomaticModifiers:{DeclaredWarOnUs:-6.875}}},popupAlerts:[{type:GoldenAge,value:""},{type:GoldenAge,value:""},{type:FirstContact,value:Florence},{type:WarDeclaration,value:Florence},{type:GoldenAge,value:""},{type:FirstContact,value:Quebec City},{type:WarDeclaration,value:Quebec City},{type:FirstContact,value:Vancouver},{type:WarDeclaration,value:Vancouver}],exploredTiles:[{x:1,y:12},{x:3},{x:8,y:6},{x:6,y:-4},{x:-1,y:-12},{x:3,y:-2},{x:4,y:-3},{x:7,y:5},{x:6,y:4},{x:4,y:3},{x:3,y:2},{x:6,y:5},{x:5,y:4},{x:5,y:-4},{x:6,y:-5},{x:8,y:7},{x:7,y:6},{x:1,y:8},{x:8,y:4},{x:4,y:-2},{x:-1,y:8},{x:2,y:-1},{x:1,y:-8},{x:10,y:5},{y:1},{x:4},{y:-1},{x:-1,y:-8},{x:6,y:-3},{x:6,y:3},{x:4,y:2},{x:2,y:1},{x:-2,y:-1},{x:-1,y:9},{x:1,y:9},{x:9,y:5},{x:-1,y:-9},{x:-1,y:-10},{x:5,y:-3},{x:8,y:5},{x:-1,y:10},{x:1,y:-10},{x:7,y:4},{x:5,y:3},{x:1,y:10},{x:-7,y:4},{x:7,y:-4},{x:9,y:6},{x:1,y:11},{x:-1,y:-11},{x:6},{x:-2,y:-12},{x:-8,y:3},{x:6,y:-2},{x:8,y:-3},{x:1,y:6},{x:-1,y:-6},{x:3,y:-1},{x:-1,y:6},{x:1,y:-6},{x:8,y:3},{x:2,y:12},{x:3,y:1},{x:6,y:2},{x:11,y:4},{x:2,y:13},{x:5,y:2},{x:5},{x:5,y:-2},{x:7,y:3},{x:-1,y:-7},{x:1,y:7},{x:-10,y:-4},{x:-1,y:7},{x:10,y:4},{x:1,y:-7},{x:-7,y:3},{x:7,y:-3},{x:9,y:4},{x:-8,y:-2},{x:4,y:-1},{x:12,y:3},{x:8,y:2},{y:8},{x:1,y:-4},{x:-8},{x:-2,y:8},{x:2,y:-8},{x:-8,y:2},{x:8,y:-2},{x:8},{y:-8},{x:1,y:4},{x:4,y:1},{x:3,y:12},{x:2,y:8},{x:-1,y:-4},{x:-2,y:-8},{x:-11,y:-3},{y:9},{x:-2,y:9},{x:11,y:3},{y:-9},{x:2,y:9},{x:-10,y:-3},{x:2,y:10},{y:-10},{x:-7,y:2},{x:7,y:-2},{y:10},{x:-7},{x:1,y:-5},{x:-1,y:5},{x:10,y:3},{x:7,y:2},{x:-2,y:-10},{x:7},{x:-1,y:-5},{x:1,y:5},{y:11},{x:2,y:11},{y:-11},{x:-2,y:-11},{x:-9,y:-3},{x:9,y:3},{x:6,y:-1},{x:3,y:-8},{x:-2,y:6},{x:2,y:-6},{x:-3,y:8},{x:12,y:2},{x:-12,y:-2},{x:1,y:-3},{y:6},{x:6,y:1},{x:2,y:6},{x:1,y:3},{x:-2,y:-6},{x:-1,y:-3},{y:-6},{x:3,y:8},{x:11},{x:11,y:2},{x:-11},{x:-11,y:-2},{x:3,y:9},{x:2,y:7},{x:10},{y:-7},{x:5,y:1},{x:3,y:10},{x:-2,y:-7},{x:5,y:-1},{x:-2,y:7},{x:2,y:-7},{x:-10,y:-2},{x:10,y:2},{x:-10},{y:7},{x:-9},{x:-9,y:-2},{x:-3,y:-11},{x:-9,y:2},{x:9},{x:9,y:2},{x:9,y:-2},{x:3,y:11},{x:8,y:-1},{x:3,y:-6},{x:1,y:-2},{x:-1,y:2},{x:-1},{x:2,y:-4},{x:-3,y:6},{x:-8,y:1},{x:-8,y:-1},{x:8,y:1},{x:3,y:6},{x:2,y:4},{x:1,y:2},{x:1},{y:-4},{x:-1,y:-2},{x:4,y:8},{x:5,y:10},{x:4,y:9},{y:-5},{x:7,y:-1},{x:3,y:-7},{x:3,y:7},{x:2,y:5},{x:-2,y:-5},{x:7,y:1},{y:5},{x:4,y:10},{x:-7,y:1},{x:-3,y:7},{x:2,y:-5},{x:13,y:2},{x:4,y:11},{x:5,y:7},{x:4,y:-6},{x:3,y:-4},{x:-4,y:6},{x:2,y:-3},{x:12,y:1},{y:3},{x:-12,y:-1},{x:6,y:8},{x:4,y:6},{x:3,y:4},{x:2,y:3},{x:-2,y:-3},{y:-3},{x:11,y:1},{x:-11,y:-1},{x:6,y:9},{x:10,y:-1},{x:4,y:-7},{x:3,y:-5},{x:-4,y:7},{x:-10,y:-1},{x:10,y:1},{x:4,y:7},{x:3,y:5},{x:5,y:8},{x:-10,y:1},{x:-9,y:-1},{x:-9,y:1},{x:9,y:1},{x:9,y:-1},{x:5,y:9},{x:4,y:4},{x:5,y:-5},{x:4,y:-4},{x:6,y:6},{x:7,y:7},{x:1,y:-1},{x:-1,y:1},{y:2},{x:2,y:-2},{x:3,y:-3},{x:5,y:5},{x:3,y:3},{x:2,y:2},{x:2},{x:1,y:1},{x:-1,y:-1},{},{x:-2,y:-2},{y:-2},{x:-5,y:6},{x:7,y:8},{x:4,y:-5},{x:5,y:-6},{x:6,y:7},{x:5,y:6},{x:4,y:5}],hasEverOwnedOriginalCapital:false,totalFaithForContests:7},{playerType:Human,gold:126,civName:England,tech:{freeTechs:1,scienceOfLast8Turns:[254,321,321,321,254,254,254,254],techsResearched:[Optics,Steam Power,Acoustics,Mining,Sailing,Education,Archery,Currency,Gunpowder,Fertilizer,Metal Casting,Economics,Construction,Architecture,Banking,Compass,Horseback Riding,Chemistry,Industrialization,Bronze Working,Scientific Theory,Philosophy,Drama and Poetry,Military Science,Printing Press,Mathematics,Guilds,Biology,Machinery,Engineering,Archaeology,Dynamite,Electricity,Pottery,Agriculture,Iron Working,Rifling,Theology,Navigation,Calendar,Masonry,Steel,Physics,Trapping,Chivalry,The Wheel,Animal Husbandry,Writing,Civil Service,Metallurgy,Astronomy],techsToResearch:[Radio],techsInProgress:{Radio:1137}},policies:{storedCulture:1826,adoptedPolicies:[Honor,Meritocracy,Warrior Code,Tradition,Liberty Complete,Mandate Of Heaven,Citizenship,Piety,Commerce,Representation,Reformation,Liberty,Republic,Naval Tradition,Aristocracy,Organized Religion,Collective Rule],numberOfAdoptedPolicies:15},civConstructions:{boughtItemsWithIncreasingPrice:{Great Prophet:{class:java.lang.Integer,value:4}}},questManager:{},religionManager:{storedFaith:3077,religionState:EnhancedReligion},goldenAges:{storedHappiness:118,numberOfGoldenAges:2},greatPeople:{pointsForNextGreatPerson:3200,greatPersonPointsCounter:{Great Engineer:{class:java.lang.Integer,value:1542},Great Scientist:{class:java.lang.Integer,value:84},Great Merchant:{class:java.lang.Integer,value:489},Great Artist:{class:java.lang.Integer,value:676}},greatGeneralPoints:161},victoryManager:{},ruinsManager:{lastChosenRewards:[a stash of gold,discover holy symbols]},diplomacy:{Inca:{otherCivName:Inca,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Vancouver:{otherCivName:Vancouver,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5}},Singapore:{otherCivName:Singapore,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Quebec City:{otherCivName:Quebec City,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5}},Songhai:{otherCivName:Songhai,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Florence:{otherCivName:Florence,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5}}},proximity:{Inca:Close,Vancouver:Far,Singapore:Far,Quebec City:Close,Songhai:Close,Florence:Neighbors},notifications:[{text:"Work has started on [Science]",icons:[StatIcons/Production],action:{class:com.unciv.logic.civilization.CityAction,city:{x:6,y:-5}}},{text:"Work has started on [Amphitheater]",icons:[StatIcons/Production],action:{class:com.unciv.logic.civilization.CityAction,city:{x:10,y:2}}},{text:"[Temple] has been built in [Nottingham]",icons:[StatIcons/Production,BuildingIcons/Temple],action:{class:com.unciv.logic.civilization.LocationAction,locations:[{x:10,y:2}]}},{text:"Work has started on [Amphitheater]",icons:[StatIcons/Production],action:{class:com.unciv.logic.civilization.CityAction,city:{x:1,y:-9}}},{text:"[Temple] has been built in [Hastings]",icons:[StatIcons/Production,BuildingIcons/Temple],action:{class:com.unciv.logic.civilization.LocationAction,locations:[{x:1,y:-9}]}},{text:"A [Great Engineer] has been born in [London]!",icons:[Great Engineer],action:{class:com.unciv.logic.civilization.LocationAction,locations:[{x:6,y:-4}]}},{text:"A [Great Scientist] has been born in [London]!",icons:[Great Scientist],action:{class:com.unciv.logic.civilization.LocationAction,locations:[{x:5,y:-5}]}}],naturalWonders:[Krakatoa],cities:[{location:{x:6,y:-5},id:8aa5d987-030c-4577-b34c-41543f4430a3,name:London,foundingCiv:England,turnAcquired:9,health:300,religion:{religionsAtSomePointAdopted:[Christianity,God of War],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:200},God of War:{class:java.lang.Integer,value:1000},Christianity:{class:java.lang.Integer,value:24608}},religionThisIsTheHolyCityOf:Christianity},population:{population:21,foodStored:322,specialistAllocations:{Scientist:{class:java.lang.Integer,value:3},Engineer:{class:java.lang.Integer,value:1}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Mosque,Ironworks,Lighthouse,Terracotta Army,The Oracle,Statue of Zeus,The Great Lighthouse,Colosseum,Aqueduct,Windmill,Hospital,Granary,Palace,Forbidden Palace,Circus Maximus,Military Academy,Himeji Castle,Market,Stonehenge,Hagia Sophia,Barracks,Opera House,Shrine,University,Theatre,Museum,Seaport,Library,Walls,Bank,Circus,Harbor,Alhambra,Factory,The Great Library,Hanging Gardens,The Pyramids,Great Mosque of Djenne,Public School,Arsenal,Workshop,Temple of Artemis,Armory,Stock Exchange,Garden,The Louvre,National Treasury,National Epic,Stable,National College,Colossus,Brandenburg Gate,Temple,Great Wall,Castle,Kremlin],inProgressConstructions:{Monument:5,Shrine:5,Library:5,Granary:5,Barracks:11},constructionQueue:[Science],productionOverflow:65,freeBuildingsProvidedFromThisCity:{8aa5d987-030c-4577-b34c-41543f4430a3:[{class:java.lang.String,value:Garden},{class:java.lang.String,value:Mosque}]}},expansion:{cultureStored:4895},tiles:[{x:5,y:-5},{x:6,y:-4},{x:8,y:-3},{x:4,y:-6},{x:6,y:-3},{x:8,y:-2},{x:8,y:-1},{x:3,y:-8},{x:3,y:-6},{x:2,y:-6},{x:11},{x:7,y:-2},{x:6,y:-5},{x:5,y:-4},{x:4,y:-7},{x:4,y:-5},{x:3,y:-7},{x:2,y:-7},{x:7,y:-1},{x:7,y:-3},{x:5,y:-6},{x:7,y:-4},{x:10,y:-1},{x:10},{x:9,y:-2},{x:9,y:-1},{x:9}],workedTiles:[{x:4,y:-6},{x:8,y:-3},{x:6,y:-3},{x:3,y:-8},{x:8,y:-2},{x:5,y:-5},{x:6,y:-4},{x:3,y:-6},{x:7,y:-2},{x:4,y:-5},{x:7,y:-3},{x:7,y:-4},{x:5,y:-6},{x:4,y:-7},{x:5,y:-4},{x:3,y:-7},{x:9,y:-2}],isOriginalCapital:true},{location:{x:4,y:-3},id:72e90b7f-27fd-40f4-a3e9-d63ed9584e70,name:York,foundingCiv:England,turnAcquired:70,health:300,religion:{religionsAtSomePointAdopted:[Christianity,God of War],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:200},God of War:{class:java.lang.Integer,value:1300},Christianity:{class:java.lang.Integer,value:13958},Sikhism:{class:java.lang.Integer,value:2601}}},population:{population:20,foodStored:178,specialistAllocations:{Scientist:{class:java.lang.Integer,value:3}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Circus,Harbor,Leaning Tower of Pisa,Factory,Lighthouse,Colosseum,Aqueduct,Public School,Arsenal,Windmill,Workshop,Hospital,Granary,Armory,Stock Exchange,Angkor Wat,Garden,Big Ben,Stable,Market,Barracks,Opera House,Shrine,Temple,University,Theatre,Castle,Seaport,Library,Walls],inProgressConstructions:{Monument:2,Shrine:2,Museum:111,Library:3,Granary:2,Walls:3},constructionQueue:[Museum]},expansion:{cultureStored:174},tiles:[{x:6,y:-1},{x:3,y:-4},{x:3,y:-3},{x:3,y:-2},{x:2,y:-3},{x:2,y:-4},{x:4,y:-2},{x:4,y:-1},{x:4,y:-3},{x:4,y:-4},{x:6,y:-2},{x:4},{x:6},{x:4,y:1},{x:8,y:1},{x:6,y:1},{x:5},{x:5,y:-2},{x:5,y:-3},{x:5,y:-1},{x:2,y:-5},{x:3,y:-5},{x:7},{x:7,y:1},{x:7,y:2},{x:5,y:1}],workedTiles:[{x:4,y:-2},{x:4,y:-1},{x:6,y:-1},{x:6,y:-2},{x:4},{x:6},{x:3,y:-3},{x:3,y:-2},{x:2,y:-3},{x:2,y:-4},{x:4,y:-4},{x:3,y:-4},{x:5,y:-3},{x:5},{x:5,y:-1},{x:5,y:-2},{x:2,y:-5}]},{location:{x:10,y:2},id:3d84c4d3-bfd4-499b-a8d3-5508f608ea5b,name:Nottingham,foundingCiv:England,turnAcquired:147,health:250,religion:{religionsAtSomePointAdopted:[Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:100},Christianity:{class:java.lang.Integer,value:10111},Sikhism:{class:java.lang.Integer,value:63}}},population:{population:6,foodStored:1},cityConstructions:{builtBuildings:[Monument,Bank,Harbor,Forge,Lighthouse,Market,Stone Works,Colosseum,Barracks,Shrine,University,Temple,Theatre,Windmill,Seaport,Library,Workshop,Granary,Walls],constructionQueue:[Amphitheater],productionOverflow:36},expansion:{cultureStored:5},tiles:[{x:10,y:2},{x:10,y:1},{x:10,y:3},{x:12,y:3},{x:8},{x:12,y:2},{x:8,y:2},{x:12,y:1},{x:9,y:1},{x:11,y:3},{x:11,y:2},{x:9,y:2},{x:9,y:3},{x:11,y:1},{x:11,y:4},{x:13,y:2}],workedTiles:[{x:9,y:3},{x:8},{x:8,y:2},{x:9,y:1},{x:11,y:1},{x:12,y:3}]},{location:{x:1,y:-9},id:febdacbf-d1bf-43b5-bf2f-b9b45b1a2121,name:Hastings,foundingCiv:England,turnAcquired:186,health:250,religion:{religionsAtSomePointAdopted:[Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:100},Christianity:{class:java.lang.Integer,value:7912}}},population:{population:6,foodStored:50},cityConstructions:{builtBuildings:[Monument,Bank,Harbor,Stable,Lighthouse,Market,Colosseum,Barracks,Shrine,University,Temple,Theatre,Windmill,Seaport,Library,Workshop,Granary,Walls],constructionQueue:[Amphitheater],productionOverflow:24},expansion:{cultureStored:156},tiles:[{y:-10},{x:2,y:-8},{x:1,y:-10},{x:1,y:-8},{x:-1,y:-10},{y:-8},{x:-2,y:-12},{x:-1,y:-12},{x:1,y:-9},{x:2,y:-9},{y:-9},{y:-11},{x:-1,y:-11}],workedTiles:[{y:-11},{x:1,y:-10},{x:1,y:-8},{y:-8},{y:-10},{x:2,y:-8}]},{location:{x:1,y:-5},id:9a89b8bb-12f8-4c47-939e-580379c14abf,name:Canterbury,foundingCiv:England,turnAcquired:191,health:220,religion:{religionsAtSomePointAdopted:[Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:100},Christianity:{class:java.lang.Integer,value:9738},Sikhism:{class:java.lang.Integer,value:2250}}},population:{population:9,foodStored:71,specialistAllocations:{Engineer:{class:java.lang.Integer,value:1},Merchant:{class:java.lang.Integer,value:1}}},cityConstructions:{builtBuildings:[Monument,Shrine,Harbor,Lighthouse,Library,Workshop,Granary,Market,Colosseum,Walls],inProgressConstructions:{Artillery:15},constructionQueue:[Artillery]},expansion:{cultureStored:207},tiles:[{x:1,y:-5},{y:-6},{x:1,y:-6},{x:1,y:-4},{y:-5},{y:-7},{x:1,y:-7},{x:-1,y:-6}],workedTiles:[{y:-6},{y:-7},{x:1,y:-7},{x:-1,y:-6},{x:1,y:-6},{x:1,y:-4},{y:-5}]},{location:{y:10},id:821b6b50-b57d-4739-8cc5-a646521afcc2,name:Coventry,foundingCiv:England,turnAcquired:209,religion:{religionsAtSomePointAdopted:[Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:100},Christianity:{class:java.lang.Integer,value:2538},Sikhism:{class:java.lang.Integer,value:2250}}},population:{population:10,foodStored:24,specialistAllocations:{Engineer:{class:java.lang.Integer,value:1}}},cityConstructions:{builtBuildings:[Monument,Shrine,Lighthouse,Library,Workshop,Granary,Market],inProgressConstructions:{Harbor:69},constructionQueue:[Harbor]},expansion:{cultureStored:49},tiles:[{y:10},{x:1,y:10},{x:-1,y:10},{x:1,y:12},{x:-1,y:8},{y:8},{x:-2,y:8},{x:-3,y:7},{x:-1,y:9},{x:1,y:11},{y:9},{y:11},{x:-2,y:9}],workedTiles:[{x:-1,y:8},{y:8},{x:-3,y:7},{x:1,y:10},{x:-2,y:8},{x:-2,y:9},{x:-1,y:9},{x:1,y:11},{y:9}]}],citiesCreated:6,exploredTiles:[{x:-1,y:-12},{x:6,y:-4},{x:6,y:4},{x:7,y:5},{x:8,y:6},{x:4,y:3},{x:3,y:2},{x:1,y:12},{x:3},{x:4,y:-3},{x:-6,y:4},{x:3,y:-2},{x:6,y:-5},{x:5,y:-4},{x:6,y:5},{x:7,y:6},{x:5,y:4},{x:-5,y:4},{x:-6,y:5},{x:2,y:1},{x:6,y:-3},{x:-6,y:3},{x:4},{x:1,y:-8},{x:2,y:-1},{x:-1,y:-8},{x:1,y:8},{x:4,y:2},{x:6,y:3},{x:10,y:5},{y:1},{x:-1,y:8},{x:8,y:4},{x:4,y:-2},{x:1,y:-9},{x:-1,y:-9},{x:9,y:5},{x:1,y:9},{x:-1,y:9},{x:-1,y:-10},{x:5,y:-3},{x:1,y:-10},{x:-1,y:10},{x:7,y:4},{x:8,y:5},{x:5,y:3},{x:7,y:-4},{x:1,y:10},{x:-7,y:4},{x:-5,y:3},{x:-1,y:-11},{x:1,y:11},{x:6,y:2},{x:8,y:-3},{x:6,y:-2},{x:-8,y:3},{x:1,y:-6},{x:-1,y:6},{x:3,y:1},{x:2,y:12},{x:8,y:3},{x:-2,y:-12},{x:6},{x:-1,y:-6},{x:1,y:6},{x:3,y:-1},{x:11,y:4},{x:2,y:13},{x:-1,y:-7},{x:7,y:-3},{x:1,y:-7},{x:-1,y:7},{x:10,y:4},{x:5,y:2},{x:1,y:7},{x:7,y:3},{x:5},{x:-7,y:3},{x:5,y:-2},{x:9,y:4},{x:1,y:4},{y:-8},{x:8,y:-2},{x:8},{x:-1,y:-4},{x:12,y:3},{x:1,y:-4},{x:-1,y:4},{y:8},{x:-2,y:-8},{x:8,y:2},{x:4,y:1},{x:3,y:12},{x:2,y:8},{x:4,y:-1},{x:2,y:-8},{x:-2,y:8},{x:2,y:-9},{y:-9},{x:11,y:3},{x:-2,y:-9},{y:9},{x:2,y:9},{x:-2,y:9},{x:1,y:5},{x:-2,y:-10},{x:7,y:-2},{x:7},{x:-1,y:-5},{x:10,y:3},{x:1,y:-5},{y:10},{x:-1,y:5},{x:7,y:2},{x:2,y:10},{y:-10},{y:-11},{x:-2,y:-11},{x:9,y:3},{x:2,y:11},{y:11},{x:6,y:1},{x:12,y:2},{x:1,y:-3},{y:6},{x:-1,y:3},{x:-1,y:-3},{x:1,y:3},{x:-3,y:-8},{x:-2,y:-6},{y:-6},{x:6,y:-1},{x:2,y:-6},{x:-2,y:6},{x:3,y:-8},{x:-3,y:8},{x:11},{x:11,y:2},{x:-3,y:-9},{x:-2,y:-7},{x:-3,y:-10},{x:10},{x:5,y:1},{x:10,y:2},{y:7},{y:-7},{x:5,y:-1},{x:2,y:-7},{x:-2,y:7},{x:9,y:-2},{x:9},{x:9,y:2},{x:-3,y:-11},{x:3,y:11},{x:-2,y:-4},{x:8,y:-1},{y:-4},{x:1},{x:8,y:1},{x:1,y:2},{x:2,y:4},{x:-3,y:-6},{x:3,y:6},{x:-4,y:-8},{x:1,y:-2},{x:-1,y:2},{y:4},{x:3,y:-6},{x:-3,y:6},{x:2,y:-4},{x:-2,y:4},{x:-4,y:-9},{x:-2,y:-5},{x:7,y:-1},{y:5},{x:7,y:1},{x:-4,y:-10},{x:2,y:5},{x:-3,y:-7},{x:3,y:-7},{x:2,y:-5},{x:-2,y:5},{x:-3,y:7},{y:-5},{x:13,y:2},{x:-4,y:-6},{x:12,y:1},{y:3},{x:4,y:6},{x:3,y:4},{x:-5,y:-7},{y:-3},{x:2,y:3},{x:4,y:-6},{x:2,y:-3},{x:-2,y:3},{x:3,y:-4},{x:-4,y:6},{x:-3,y:4},{x:11,y:1},{x:3,y:5},{x:-4,y:-7},{x:10,y:1},{x:-3,y:-5},{x:-5,y:-8},{x:10,y:-1},{x:-4,y:7},{x:3,y:-5},{x:-3,y:5},{x:4,y:-7},{x:9,y:-1},{x:9,y:1},{x:-5,y:-9},{x:2,y:2},{x:2},{x:1,y:1},{},{y:2},{x:-1,y:1},{x:1,y:-1},{x:4,y:4},{x:5,y:5},{x:3,y:3},{x:4,y:-4},{x:-4,y:4},{x:-5,y:5},{x:5,y:-5},{x:2,y:-2},{x:-2,y:2},{x:3,y:-3},{x:5,y:-6},{x:4,y:5},{x:5,y:6},{x:4,y:-5},{x:-4,y:5},{x:-5,y:6}],hasEverOwnedOriginalCapital:true,numMinorCivsAttacked:3,totalCultureForContests:11171,totalFaithForContests:4293},{gold:-1997,civName:Songhai,tech:{scienceOfLast8Turns:[89,89,91,106,89,89,89,89],techsResearched:[Drama and Poetry,Printing Press,Optics,Mathematics,Guilds,Acoustics,Machinery,Mining,Engineering,Sailing,Education,Archery,Currency,Pottery,Agriculture,Iron Working,Gunpowder,Metal Casting,Theology,Construction,Navigation,Calendar,Banking,Masonry,Steel,Compass,Horseback Riding,Physics,Trapping,Bronze Working,Chivalry,The Wheel,Animal Husbandry,Writing,Civil Service,Metallurgy,Philosophy,Astronomy],techsToResearch:[Chemistry],techsInProgress:{Chemistry:580}},policies:{storedCulture:762,adoptedPolicies:[Honor,Warrior Code,Citizenship,Representation,Military Caste,Professional Army,Military Tradition,Liberty,Discipline,Honor Complete,Republic,Collective Rule],numberOfAdoptedPolicies:11},civConstructions:{boughtItemsWithIncreasingPrice:{Great Prophet:{class:java.lang.Integer,value:4}}},questManager:{},religionManager:{storedFaith:97,religionState:Pantheon},goldenAges:{storedHappiness:178,numberOfGoldenAges:3},greatPeople:{pointsForNextGreatPerson:800,greatPersonPointsCounter:{Great Merchant:{class:java.lang.Integer,value:309},Great Engineer:{class:java.lang.Integer,value:168},Great Scientist:{class:java.lang.Integer,value:251}},greatGeneralPoints:75},victoryManager:{},ruinsManager:{},diplomacy:{Inca:{otherCivName:Inca,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Vancouver:{otherCivName:Vancouver,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5}},Singapore:{otherCivName:Singapore,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Quebec City:{otherCivName:Quebec City,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5}},Florence:{otherCivName:Florence,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5}},England:{otherCivName:England,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}}},proximity:{Inca:Far,Vancouver:Far,Singapore:Far,Quebec City:Neighbors,Florence:Far,England:Close},cities:[{location:{x:2,y:6},id:07711111-a63e-424d-a0ab-7d08a3eb7c7b,name:Gao,foundingCiv:Songhai,health:300,religion:{religionsAtSomePointAdopted:[Desert Folklore],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:1100},Sikhism:{class:java.lang.Integer,value:4977},Desert Folklore:{class:java.lang.Integer,value:2200},Christianity:{class:java.lang.Integer,value:5678}}},population:{population:15,foodStored:85,specialistAllocations:{Merchant:{class:java.lang.Integer,value:1},Scientist:{class:java.lang.Integer,value:2}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Palace,Harbor,National Epic,Stable,Lighthouse,Market,Colosseum,Barracks,Aqueduct,Shrine,Mud Pyramid Mosque,University,Theatre,Arsenal,Castle,Seaport,Library,Workshop,Granary,Walls,Armory],inProgressConstructions:{Bank:141},constructionQueue:[Bank]},expansion:{cultureStored:180},tiles:[{x:2,y:6},{x:3,y:6},{x:4,y:6},{x:3,y:8},{x:4,y:8},{x:1,y:6},{x:1,y:4},{y:4},{y:6},{x:-1,y:6},{x:2,y:4},{x:1,y:8},{x:2,y:8},{x:3,y:3},{x:1,y:5},{x:3,y:7},{x:2,y:5},{x:2,y:7},{x:3,y:5},{x:4,y:7},{x:1,y:7},{y:7}],workedTiles:[{x:2,y:4},{x:1,y:4},{y:4},{y:6},{x:-1,y:6},{x:3,y:6},{x:1,y:6},{x:1,y:8},{x:2,y:5},{x:2,y:7},{x:3,y:5},{y:7}],isOriginalCapital:true},{location:{x:6,y:7},id:29c5388e-ae9c-426c-9de8-cdac3b056419,name:Tombouctu,foundingCiv:Songhai,turnAcquired:69,health:300,religion:{religionsAtSomePointAdopted:[Desert Folklore,Sikhism,Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:400},Sikhism:{class:java.lang.Integer,value:3267},Desert Folklore:{class:java.lang.Integer,value:600},Christianity:{class:java.lang.Integer,value:9127}}},population:{population:8,foodStored:56},cityConstructions:{builtBuildings:[Monument,Amphitheater,Harbor,Lighthouse,National College,Market,Colosseum,Barracks,Aqueduct,Shrine,Mud Pyramid Mosque,University,Theatre,Arsenal,Castle,Seaport,Library,Workshop,Granary,Walls,Armory],inProgressConstructions:{Bank:25},constructionQueue:[Bank]},expansion:{cultureStored:1},tiles:[{x:6,y:6},{x:5,y:7},{x:6,y:8},{x:7,y:7},{x:5,y:5},{x:8,y:6},{x:3,y:4},{x:4,y:4},{x:6,y:4},{x:6,y:2},{x:4,y:3},{x:6,y:9},{x:6,y:7},{x:5,y:6},{x:7,y:8},{x:4,y:5},{x:8,y:7},{x:5,y:8},{x:6,y:5},{x:7,y:6},{x:5,y:4}],workedTiles:[{x:4,y:5},{x:5,y:5},{x:4,y:4},{x:8,y:6},{x:6,y:9},{x:3,y:4},{x:6,y:4},{x:6,y:6}]},{location:{x:5,y:10},id:19ca83b7-175c-4364-b926-06376fbf6c1a,name:Jenne,foundingCiv:Songhai,turnAcquired:76,health:275,religion:{religionsAtSomePointAdopted:[Desert Folklore,Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:400},Desert Folklore:{class:java.lang.Integer,value:600},Christianity:{class:java.lang.Integer,value:3617},Sikhism:{class:java.lang.Integer,value:792}}},population:{population:4},cityConstructions:{builtBuildings:[Monument,Amphitheater,Market,Colosseum,Barracks,Aqueduct,Shrine,Mud Pyramid Mosque,University,Castle,Library,Workshop,Granary,Walls],inProgressConstructions:{Armory:104},constructionQueue:[Armory]},expansion:{cultureStored:154},tiles:[{x:5,y:10},{x:4,y:10},{x:3,y:10},{x:2,y:10},{x:3,y:12},{x:2,y:12},{x:4,y:9},{x:5,y:9},{x:4,y:11},{x:3,y:9},{x:3,y:11},{x:2,y:9},{x:2,y:11},{x:1,y:9}],workedTiles:[{x:4,y:11},{x:2,y:10},{x:3,y:9},{x:3,y:11}]},{location:{x:9,y:6},id:4b30a528-ca87-423a-add6-d8af7b10c30f,name:Taghaza,foundingCiv:Songhai,turnAcquired:162,religion:{religionsAtSomePointAdopted:[Desert Folklore],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:100},Desert Folklore:{class:java.lang.Integer,value:200},Christianity:{class:java.lang.Integer,value:8186},Sikhism:{class:java.lang.Integer,value:63}}},population:{population:10,foodStored:70,specialistAllocations:{Merchant:{class:java.lang.Integer,value:1}}},cityConstructions:{builtBuildings:[Monument,Shrine,Lighthouse,Library,Granary,Market],inProgressConstructions:{Workshop:5},constructionQueue:[Workshop]},expansion:{cultureStored:56},tiles:[{x:8,y:5},{x:10,y:5},{x:7,y:4},{x:8,y:4},{x:7,y:5},{x:10,y:4},{x:6,y:3},{x:7,y:3},{x:9,y:6},{x:9,y:5},{x:9,y:4}],workedTiles:[{x:10,y:5},{x:7,y:4},{x:8,y:4},{x:6,y:3},{x:7,y:3},{x:8,y:5},{x:7,y:5},{x:9,y:5},{x:9,y:4}]}],citiesCreated:4,exploredTiles:[{x:-7,y:-5},{x:-6,y:-4},{x:1,y:12},{x:-4,y:3},{x:3},{x:4,y:-3},{x:-6,y:4},{x:-3,y:2},{x:3,y:-2},{x:-8,y:-6},{x:6,y:4},{x:7,y:5},{x:8,y:6},{x:-3},{x:-4,y:-3},{x:4,y:3},{x:-3,y:-2},{x:3,y:2},{x:6,y:5},{x:-6,y:-5},{x:-5,y:-4},{x:-5,y:4},{x:-6,y:5},{x:-7,y:-6},{x:8,y:7},{x:7,y:6},{x:5,y:4},{x:6,y:3},{x:-6,y:-3},{x:-4,y:2},{x:-6,y:3},{x:4,y:-2},{y:-1},{y:1},{x:10,y:5},{x:8,y:4},{x:-8,y:-4},{x:-4},{x:-2,y:1},{x:2,y:-1},{x:-1,y:8},{x:1,y:-8},{x:-4,y:-2},{x:4,y:2},{x:-2,y:-1},{x:1,y:8},{x:4},{x:-1,y:-8},{x:2,y:1},{x:-9,y:-5},{x:1,y:9},{x:-1,y:9},{x:9,y:5},{x:-1,y:-9},{x:1,y:-9},{x:-5,y:-3},{x:5,y:3},{x:-1,y:10},{x:-5,y:3},{x:-7,y:4},{x:1,y:10},{x:7,y:4},{x:-7,y:-4},{x:-8,y:-5},{x:8,y:5},{x:1,y:11},{x:9,y:6},{x:6,y:2},{x:-6,y:2},{x:-3,y:1},{x:3,y:-1},{x:8,y:3},{x:-8,y:-3},{x:-6,y:-2},{x:-6},{x:-1,y:6},{x:1,y:-6},{x:1,y:6},{x:-1,y:-6},{x:6},{x:-8,y:3},{x:2,y:12},{x:-3,y:-1},{x:3,y:1},{x:2,y:13},{x:11,y:4},{x:-7,y:-3},{x:-5,y:-2},{x:5,y:2},{x:1,y:7},{x:-1,y:-7},{x:-5,y:2},{x:-7,y:3},{x:5,y:-2},{x:5},{x:-5},{x:10,y:4},{x:-1,y:7},{x:1,y:-7},{x:-10,y:-4},{x:7,y:3},{x:-9,y:-4},{x:9,y:4},{x:2,y:8},{x:1,y:4},{x:-1,y:-4},{x:-2,y:-8},{x:8},{y:-8},{x:8,y:-2},{x:-4,y:1},{x:-2,y:8},{x:4,y:-1},{x:2,y:-8},{y:8},{x:-8,y:-2},{x:8,y:2},{x:12,y:3},{x:-1,y:4},{x:1,y:-4},{x:-4,y:-1},{x:4,y:1},{x:3,y:12},{x:2,y:9},{y:9},{x:-2,y:9},{x:-2,y:-9},{y:-9},{x:11,y:3},{x:7,y:2},{x:-7},{x:1,y:5},{x:7},{x:7,y:-2},{x:-1,y:-5},{x:-1,y:5},{x:1,y:-5},{x:-7,y:-2},{y:10},{x:10,y:3},{x:2,y:10},{x:-9,y:-3},{x:2,y:11},{y:11},{x:9,y:3},{x:1,y:3},{x:2,y:6},{x:-2,y:-6},{x:-6,y:1},{x:6,y:-1},{y:-6},{x:-3,y:8},{x:-2,y:6},{x:2,y:-6},{x:-6,y:-1},{y:6},{x:12,y:2},{x:-1,y:3},{x:1,y:-3},{x:3,y:8},{x:6,y:1},{x:3,y:9},{x:11,y:2},{x:11},{x:2,y:7},{x:-5,y:-1},{x:-5,y:1},{y:-7},{x:10},{x:-2,y:7},{x:2,y:-7},{y:7},{x:3,y:10},{x:-2,y:-7},{x:10,y:2},{x:5,y:-1},{x:5,y:1},{x:-9,y:-2},{x:3,y:11},{x:9,y:2},{x:9},{x:9,y:-2},{x:2,y:4},{x:3,y:6},{x:4,y:8},{x:5,y:10},{x:-2,y:4},{x:1,y:2},{y:-4},{x:1},{x:-3,y:6},{x:3,y:-6},{x:8,y:-1},{x:2,y:-4},{y:4},{x:-1},{x:-8,y:-1},{x:8,y:1},{x:-1,y:2},{x:1,y:-2},{x:4,y:9},{x:3,y:7},{x:2,y:5},{x:-2,y:-5},{x:7,y:-1},{y:-5},{x:4,y:10},{y:5},{x:-2,y:5},{x:2,y:-5},{x:-3,y:7},{x:-7,y:-1},{x:7,y:1},{x:4,y:11},{x:13,y:2},{x:4,y:6},{x:5,y:7},{x:-3,y:4},{x:-2,y:3},{x:2,y:-3},{y:3},{x:-5,y:-7},{x:6,y:8},{x:12,y:1},{x:3,y:4},{x:-4,y:-6},{x:2,y:3},{y:-3},{x:3,y:-4},{x:-4,y:6},{x:-3,y:-4},{x:6,y:9},{x:11,y:1},{x:-3,y:-5},{x:4,y:7},{x:3,y:5},{x:5,y:8},{x:-3,y:5},{x:10,y:1},{x:3,y:-5},{x:-4,y:7},{x:10,y:-1},{x:5,y:9},{x:9,y:1},{x:9,y:-1},{x:-5,y:-5},{x:-3,y:3},{x:3,y:-3},{x:-4,y:4},{x:1,y:1},{x:2},{y:-2},{x:-5,y:5},{x:-2,y:2},{x:2,y:-2},{y:2},{x:-7,y:-7},{x:-6,y:-6},{x:7,y:7},{x:6,y:6},{x:5,y:5},{x:-2},{x:-1,y:1},{x:1,y:-1},{x:-4,y:-4},{x:4,y:4},{x:2,y:2},{},{x:-3,y:-3},{x:3,y:3},{x:-6,y:-7},{x:-4,y:5},{x:7,y:8},{x:-5,y:-6},{x:-4,y:-5},{x:4,y:5},{x:-5,y:6},{x:5,y:6},{x:6,y:7}],hasEverOwnedOriginalCapital:true,numMinorCivsAttacked:2,totalCultureForContests:4652,totalFaithForContests:1323},{gold:1289,civName:Inca,tech:{freeTechs:1,scienceOfLast8Turns:[417,422,422,419,358,358,358,377],techsResearched:[Flight,Optics,Steam Power,Acoustics,Mining,Sailing,Education,Archery,Currency,Gunpowder,Fertilizer,Metal Casting,Economics,Construction,Architecture,Banking,Compass,Horseback Riding,Chemistry,Industrialization,Bronze Working,Scientific Theory,Philosophy,Drama and Poetry,Military Science,Printing Press,Mathematics,Guilds,Biology,Machinery,Engineering,Archaeology,Electricity,Dynamite,Pottery,Agriculture,Iron Working,Rifling,Theology,Navigation,Calendar,Masonry,Steel,Physics,Trapping,Chivalry,The Wheel,Animal Husbandry,Writing,Civil Service,Metallurgy,Astronomy],techsToResearch:[Radio],techsInProgress:{Radio:859}},policies:{storedCulture:1594,adoptedPolicies:[Free Speech,Tradition,Mandate Of Heaven,Freedom Complete,Commerce,Reformation,Tradition Complete,Freedom,Free Religion,Piety Complete,Civil Society,Trade Unions,Theocracy,Constitution,Mercantilism,Piety,Legalism,Aristocracy,Naval Tradition,Oligarchy,Organized Religion,Landed Elite,Monarchy,Democracy,Universal Suffrage],numberOfAdoptedPolicies:22},civConstructions:{boughtItemsWithIncreasingPrice:{Great Prophet:{class:java.lang.Integer,value:6}},freeBuildings:{f7388e2f-e868-41b2-ab19-4c1e7d0338a5:[{class:java.lang.String,value:Monument}],c03b1f6d-e58c-4adb-9817-b0de51e68981:[{class:java.lang.String,value:Monument}],906f9a8b-7b46-4076-bc66-15921d8fa82f:[{class:java.lang.String,value:Monument}],ca160eed-468f-4a65-8444-3d2e42de02c1:[{class:java.lang.String,value:Amphitheater}]},freeStatBuildingsProvided:{Gold:[],Happiness:[],Production:[],Science:[],Faith:[],Culture:[{class:java.lang.String,value:f7388e2f-e868-41b2-ab19-4c1e7d0338a5},{class:java.lang.String,value:c03b1f6d-e58c-4adb-9817-b0de51e68981},{class:java.lang.String,value:906f9a8b-7b46-4076-bc66-15921d8fa82f},{class:java.lang.String,value:ca160eed-468f-4a65-8444-3d2e42de02c1}],Food:[]}},questManager:{},religionManager:{storedFaith:3453,religionState:EnhancedReligion},goldenAges:{storedHappiness:409,numberOfGoldenAges:5},greatPeople:{pointsForNextGreatPerson:1600,greatPersonPointsCounter:{Great Engineer:{class:java.lang.Integer,value:395},Great Merchant:{class:java.lang.Integer,value:390},Great Scientist:{class:java.lang.Integer,value:451},Great Artist:{class:java.lang.Integer,value:483}}},victoryManager:{},ruinsManager:{},diplomacy:{Vancouver:{otherCivName:Vancouver,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5,OpenBorders:1.25}},Singapore:{otherCivName:Singapore,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Quebec City:{otherCivName:Quebec City,diplomaticStatus:Peace,flagsCountdown:{EverBeenFriends:-1},diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5,OpenBorders:7.375}},Songhai:{otherCivName:Songhai,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Florence:{otherCivName:Florence,diplomaticStatus:Peace,flagsCountdown:{EverBeenFriends:-1},diplomaticModifiers:{YearsOfPeace:30,SharedEnemy:5,OpenBorders:7.75}},England:{otherCivName:England,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}}},proximity:{Vancouver:Neighbors,Singapore:Close,Quebec City:Neighbors,Songhai:Far,Florence:Close,England:Close},naturalWonders:[Krakatoa],cities:[{location:{x:-3,y:-2},id:ca160eed-468f-4a65-8444-3d2e42de02c1,name:Cuzco,foundingCiv:Inca,health:300,religion:{religionsAtSomePointAdopted:[Dance of the Aurora,Sikhism],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:600},Dance of the Aurora:{class:java.lang.Integer,value:1500},Sikhism:{class:java.lang.Integer,value:18281},Christianity:{class:java.lang.Integer,value:7057}},religionThisIsTheHolyCityOf:Sikhism},population:{population:20,foodStored:180,specialistAllocations:{Scientist:{class:java.lang.Integer,value:3}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Harbor,Hermitage,Sistine Chapel,Ironworks,Lighthouse,Colosseum,Grand Temple,Aqueduct,Public School,Arsenal,Oxford University,Workshop,Porcelain Tower,Hospital,Taj Mahal,Granary,Armory,Stock Exchange,Machu Picchu,Mausoleum of Halicarnassus,National Treasury,Palace,Mint,National Epic,Chichen Itza,Stable,Military Academy,National College,Market,Barracks,Opera House,Shrine,Temple,University,Notre Dame,Theatre,Heroic Epic,Petra,Castle,Museum,Seaport,Library,Walls],inProgressConstructions:{Lancer:18},constructionQueue:[Science]},expansion:{cultureStored:9139},tiles:[{x:-4,y:-1},{x:1,y:3},{x:-3,y:-2},{x:-1},{x:-4,y:-4},{x:-4,y:-3},{x:-4,y:-2},{x:-3,y:-6},{x:-3,y:-3},{x:-3,y:-4},{y:1},{x:-2},{x:-3},{x:-1,y:1},{y:2},{x:-2,y:-1},{x:-1,y:-1},{x:1,y:1},{x:1,y:2},{x:-2,y:-2},{x:2,y:2},{x:2,y:3},{x:-3,y:-1},{x:-2,y:-3},{x:-5,y:-4},{x:-4,y:-5},{x:-5,y:-2},{x:-5,y:-3},{x:-3,y:-5},{x:-2,y:-5},{x:-1,y:-5}],workedTiles:[{x:-4,y:-2},{y:1},{x:-4,y:-1},{x:-3,y:-3},{x:-2},{x:-1,y:1},{x:-3},{x:-4,y:-3},{x:-4,y:-4},{x:-3,y:-1},{x:-2,y:-1},{x:-2,y:-2},{x:-5,y:-2},{x:-4,y:-5},{x:-5,y:-4},{x:-5,y:-3},{x:-3,y:-5}],isOriginalCapital:true},{location:{x:-7,y:-6},id:c03b1f6d-e58c-4adb-9817-b0de51e68981,name:Tiwanaku,foundingCiv:Inca,turnAcquired:32,health:300,religion:{religionsAtSomePointAdopted:[Dance of the Aurora,Sikhism],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:800},Dance of the Aurora:{class:java.lang.Integer,value:1100},Sikhism:{class:java.lang.Integer,value:5976},Christianity:{class:java.lang.Integer,value:4041}}},population:{population:12,foodStored:64,specialistAllocations:{Scientist:{class:java.lang.Integer,value:1}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Harbor,Factory,Lighthouse,Stone Works,Colosseum,Aqueduct,Public School,Arsenal,Windmill,Workshop,Granary,Armory,Stock Exchange,Circus Maximus,Stable,Market,Barracks,Opera House,Shrine,Temple,University,Theatre,Castle,Museum,Seaport,Library,Walls],inProgressConstructions:{Theatre:109,Museum:144,Artillery:58,Cavalry:59},constructionQueue:[Artillery]},expansion:{cultureStored:1319},tiles:[{x:-6,y:-4},{x:-7,y:-5},{x:-7,y:-7},{x:-6,y:-6},{x:-8,y:-6},{x:-5,y:-5},{x:-6,y:-8},{x:-5,y:-7},{x:-4,y:-6},{x:-2,y:-6},{x:-4,y:-8},{x:-7,y:-6},{x:-6,y:-5},{x:-5,y:-6},{x:-7,y:-4},{x:-8,y:-5},{x:-6,y:-7},{x:-5,y:-8},{x:-4,y:-7}],workedTiles:[{x:-6,y:-6},{x:-7,y:-5},{x:-6,y:-8},{x:-5,y:-7},{x:-4,y:-6},{x:-6,y:-4},{x:-8,y:-6},{x:-5,y:-6},{x:-7,y:-4},{x:-8,y:-5},{x:-6,y:-7}]},{location:{x:-9,y:-4},id:f7388e2f-e868-41b2-ab19-4c1e7d0338a5,name:Machu,foundingCiv:Inca,turnAcquired:45,health:275,religion:{religionsAtSomePointAdopted:[Dance of the Aurora],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:200},Dance of the Aurora:{class:java.lang.Integer,value:700},Sikhism:{class:java.lang.Integer,value:5609},Christianity:{class:java.lang.Integer,value:1251}}},population:{population:2,foodStored:1},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Factory,Market,Stone Works,Colosseum,Observatory,Barracks,Opera House,Aqueduct,Shrine,Temple,University,Public School,Theatre,Castle,Museum,Library,Workshop,Granary,Walls,Armory,Stock Exchange],inProgressConstructions:{Hospital:73},constructionQueue:[Hospital]},expansion:{cultureStored:1401},tiles:[{x:-8,y:-3},{x:-8,y:-4},{x:-6,y:-3},{x:-8,y:-1},{x:-12,y:-2},{x:-9,y:-5},{x:-11,y:-3},{x:-11,y:-2},{x:-11,y:-1},{x:-10,y:-4},{x:-7,y:-2},{x:-10,y:-3},{x:-10,y:-2},{x:-7,y:-3},{x:-9,y:-4},{x:-9,y:-3},{x:-9,y:-2}],workedTiles:[{x:-7,y:-2},{x:-8,y:-4}]},{location:{y:-2},id:906f9a8b-7b46-4076-bc66-15921d8fa82f,name:Ollantaytambo,foundingCiv:Inca,turnAcquired:73,health:300,religion:{religionsAtSomePointAdopted:[Dance of the Aurora,Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:400},Dance of the Aurora:{class:java.lang.Integer,value:600},Christianity:{class:java.lang.Integer,value:14305},Sikhism:{class:java.lang.Integer,value:5769}}},population:{population:11,foodStored:135,specialistAllocations:{Scientist:{class:java.lang.Integer,value:3}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Harbor,Factory,Lighthouse,Colosseum,Aqueduct,Public School,Arsenal,Windmill,Workshop,Granary,Armory,Stock Exchange,Stable,Military Academy,Market,Barracks,Opera House,Shrine,Temple,University,Theatre,Castle,Museum,Seaport,Library,Walls],inProgressConstructions:{Military Academy:124,Hospital:140},constructionQueue:[Hospital]},expansion:{cultureStored:422},tiles:[{x:-1,y:-2},{x:-1,y:-4},{x:3,y:2},{x:4,y:2},{x:1,y:-1},{x:1,y:-2},{x:1,y:-3},{x:2,y:-1},{x:3,y:1},{x:-2,y:-4},{},{x:2,y:1},{x:-1,y:-3},{x:3},{y:-4},{y:-3},{x:2},{y:-2},{y:-1},{x:1},{x:2,y:-2},{x:3,y:-1},{x:5,y:3},{x:5,y:2}],workedTiles:[{x:1,y:-2},{},{x:1,y:-3},{x:2,y:-1},{y:-3},{x:3,y:1},{x:-2,y:-4},{x:2,y:1}]}],citiesCreated:4,exploredTiles:[{x:4,y:-3},{x:-6,y:-4},{x:-3,y:2},{x:3,y:-2},{x:-4,y:3},{x:3},{x:-1,y:-12},{x:6,y:-4},{x:-6,y:4},{x:-8,y:-6},{x:7,y:5},{x:8,y:6},{x:-7,y:-5},{x:6,y:4},{x:-3},{x:-3,y:-2},{x:-4,y:-3},{x:4,y:3},{x:3,y:2},{x:5,y:4},{x:-6,y:-5},{x:-5,y:-4},{x:5,y:-4},{x:-5,y:4},{x:6,y:5},{x:7,y:6},{x:-7,y:-6},{x:8,y:7},{x:4},{x:-4,y:2},{x:4,y:-2},{y:-1},{x:6,y:-3},{y:1},{x:10,y:5},{x:-8,y:-4},{x:-6,y:-3},{x:6,y:3},{x:8,y:4},{x:-4},{x:-2,y:1},{x:1,y:-8},{x:2,y:-1},{x:-2,y:-1},{x:2,y:1},{x:1,y:8},{x:-1,y:-8},{x:-4,y:-2},{x:4,y:2},{x:-9,y:-5},{x:1,y:9},{x:1,y:-9},{x:-1,y:-9},{x:9,y:5},{x:-5,y:-3},{x:-7,y:-4},{x:5,y:3},{x:7,y:4},{x:-8,y:-5},{x:-5,y:3},{x:1,y:10},{x:5,y:-3},{x:7,y:-4},{x:1,y:-10},{x:8,y:5},{x:-1,y:-10},{x:1,y:11},{x:-1,y:-11},{x:-1,y:-6},{x:-6,y:-2},{x:-3,y:1},{x:3,y:-1},{x:1,y:6},{x:-6,y:2},{x:6,y:-2},{x:8,y:-3},{x:6},{x:-8,y:-3},{x:-6},{x:8,y:3},{x:1,y:-6},{x:-3,y:-1},{x:3,y:1},{x:-2,y:-12},{x:6,y:2},{x:11,y:4},{x:-1,y:-7},{x:-5,y:-2},{x:7,y:3},{x:-5,y:2},{x:5,y:-2},{x:5},{x:7,y:-3},{x:1,y:7},{x:5,y:2},{x:-7,y:-3},{x:-10,y:-4},{x:-5},{x:1,y:-7},{x:-1,y:7},{x:10,y:4},{x:-9,y:-4},{x:9,y:4},{x:-1,y:-4},{x:-4,y:1},{x:4,y:-1},{x:2,y:-8},{x:2,y:8},{x:-2,y:-8},{x:-8,y:-2},{y:8},{x:-8},{x:12,y:3},{x:8,y:2},{x:-1,y:4},{x:1,y:-4},{x:1,y:4},{x:8},{x:8,y:-2},{y:-8},{x:-4,y:-1},{x:4,y:1},{x:3,y:12},{x:2,y:9},{x:-11,y:-3},{y:9},{x:11,y:3},{y:-9},{x:2,y:-9},{x:-2,y:-9},{x:-10,y:-3},{x:-7,y:-2},{x:-1,y:5},{x:1,y:-5},{x:-7},{y:10},{x:10,y:3},{x:1,y:5},{x:-1,y:-5},{x:7},{x:7,y:-2},{y:-10},{x:2,y:10},{x:-2,y:-10},{x:7,y:2},{x:-9,y:-3},{x:2,y:11},{x:9,y:3},{y:-11},{x:-2,y:-11},{x:-1,y:-3},{x:-6,y:-1},{x:-6,y:1},{x:2,y:-6},{x:3,y:-8},{x:-2,y:6},{x:6,y:-1},{y:-6},{x:1,y:3},{y:6},{x:12,y:2},{x:-12,y:-2},{x:-1,y:3},{x:1,y:-3},{x:2,y:6},{x:-2,y:-6},{x:6,y:1},{x:-3,y:-8},{x:3,y:8},{x:3,y:9},{x:-11,y:-2},{x:11},{x:11,y:2},{x:-3,y:-9},{x:-11},{x:2,y:7},{x:-5,y:1},{x:5,y:-1},{x:2,y:-7},{y:-7},{x:10},{x:3,y:10},{x:-2,y:-7},{x:-3,y:-10},{x:5,y:1},{x:-5,y:-1},{x:10,y:2},{x:-10,y:-2},{y:7},{x:-10},{x:-9,y:-2},{x:3,y:11},{x:9},{x:9,y:-2},{x:9,y:2},{x:-3,y:-11},{x:-1,y:-2},{x:4,y:8},{x:-4,y:-8},{x:1},{x:8,y:-1},{x:3,y:-6},{x:-2,y:4},{x:2,y:-4},{x:1,y:2},{y:-4},{x:2,y:4},{x:-2,y:-4},{x:-1},{x:5,y:10},{x:-8,y:-1},{x:8,y:1},{y:4},{x:1,y:-2},{x:-1,y:2},{x:3,y:6},{x:-3,y:-6},{x:4,y:9},{x:-4,y:-9},{x:3,y:7},{x:-7,y:-1},{x:2,y:5},{x:-3,y:-7},{y:-5},{x:7,y:-1},{x:2,y:-5},{x:3,y:-7},{x:-2,y:5},{x:-2,y:-5},{y:5},{x:7,y:1},{x:4,y:10},{x:-4,y:-10},{x:4,y:11},{x:13,y:2},{y:-3},{x:-3,y:-4},{x:-2,y:-3},{x:-2,y:3},{x:2,y:-3},{x:2,y:3},{x:-3,y:4},{x:3,y:-4},{x:4,y:-6},{y:3},{x:12,y:1},{x:-12,y:-1},{x:6,y:8},{x:4,y:6},{x:5,y:7},{x:-5,y:-7},{x:-6,y:-8},{x:3,y:4},{x:-4,y:-6},{x:6,y:9},{x:11,y:1},{x:-11,y:-1},{x:4,y:7},{x:5,y:8},{x:3,y:5},{x:-3,y:-5},{x:-10,y:-1},{x:10,y:-1},{x:3,y:-5},{x:-3,y:5},{x:4,y:-7},{x:10,y:1},{x:-4,y:-7},{x:-5,y:-8},{x:5,y:9},{x:-9,y:-1},{x:9,y:1},{x:9,y:-1},{x:-5,y:-9},{x:-3,y:-3},{},{x:-4,y:-4},{x:4,y:4},{x:-1,y:-1},{x:-4,y:4},{x:5,y:-5},{y:-2},{x:2},{x:1,y:1},{x:-2,y:2},{x:-3,y:3},{x:3,y:-3},{x:4,y:-4},{x:2,y:-2},{x:-2,y:-2},{x:2,y:2},{y:2},{x:-7,y:-7},{x:6,y:6},{x:7,y:7},{x:-6,y:-6},{x:-5,y:-5},{x:5,y:5},{x:-2},{x:-1,y:1},{x:1,y:-1},{x:3,y:3},{x:-5,y:-6},{x:5,y:6},{x:-4,y:-5},{x:6,y:7},{x:-6,y:-7},{x:7,y:8},{x:4,y:5},{x:4,y:-5},{x:-4,y:5},{x:5,y:-6}],hasEverOwnedOriginalCapital:true,totalCultureForContests:26404,totalFaithForContests:6617},{gold:418,civName:Singapore,tech:{scienceOfLast8Turns:[67,67,67,67,67,67,67,67],techsResearched:[Flight,Optics,Steam Power,Acoustics,Mining,Sailing,Education,Archery,Currency,Gunpowder,Fertilizer,Metal Casting,Economics,Construction,Architecture,Banking,Compass,Horseback Riding,Chemistry,Industrialization,Bronze Working,Scientific Theory,Philosophy,Drama and Poetry,Military Science,Printing Press,Mathematics,Guilds,Biology,Machinery,Engineering,Archaeology,Electricity,Dynamite,Pottery,Agriculture,Iron Working,Rifling,Theology,Navigation,Calendar,Masonry,Steel,Physics,Trapping,Chivalry,The Wheel,Animal Husbandry,Writing,Civil Service,Metallurgy,Astronomy],techsToResearch:[Refrigeration],techsInProgress:{Refrigeration:737}},policies:{storedCulture:1602,shouldOpenPolicyPicker:true},civConstructions:{},questManager:{assignedQuests:[{questName:Acquire Great Person,assigner:Singapore,assignee:Inca,assignedOnTurn:82,data1:Great Engineer},{questName:Connect Resource,assigner:Singapore,assignee:Inca,assignedOnTurn:243,data1:Incense}],globalQuestCountdown:1,individualQuestCountdown:{Inca:0,Songhai:0,England:0}},religionManager:{storedFaith:875},goldenAges:{storedHappiness:577,numberOfGoldenAges:4},greatPeople:{},victoryManager:{},ruinsManager:{},diplomacy:{Inca:{otherCivName:Inca,diplomaticStatus:Protector,flagsCountdown:{RecentlyPledgedProtection:2,EverBeenFriends:-1},diplomaticModifiers:{YearsOfPeace:26},influence:33.25},Vancouver:{otherCivName:Vancouver,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30},influence:10},Quebec City:{otherCivName:Quebec City,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30},influence:10},Songhai:{otherCivName:Songhai,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},England:{otherCivName:England,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}}},proximity:{Inca:Close,Vancouver:Close,Quebec City:Close,Songhai:Far,Florence:Far,England:Far},cities:[{location:{x:-8,y:1},id:7803ef58-2479-49c2-a9ee-d509ddf34ed0,name:Singapore,foundingCiv:Singapore,health:300,religion:{pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:1500},Sikhism:{class:java.lang.Integer,value:6006},Christianity:{class:java.lang.Integer,value:1376}}},population:{population:18,foodStored:367,specialistAllocations:{Scientist:{class:java.lang.Integer,value:3}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Harbor,Lighthouse,Colosseum,Aqueduct,Public School,Arsenal,Windmill,Workshop,Granary,Armory,Stock Exchange,Palace,Mint,Market,Barracks,Opera House,Shrine,Temple,University,Theatre,Castle,Museum,Seaport,Library,Walls],inProgressConstructions:{Hospital:75},constructionQueue:[Hospital]},expansion:{cultureStored:183},tiles:[{x:-8,y:1},{x:-8},{x:-8,y:2},{x:-6,y:3},{x:-8,y:3},{x:-12,y:-1},{x:-11},{x:-7,y:2},{x:-7,y:1},{x:-10,y:-1},{x:-10,y:1},{x:-10},{x:-7,y:3},{x:-7,y:4},{x:-6,y:5},{x:-9},{x:-9,y:1},{x:-9,y:2},{x:-9,y:-1}],workedTiles:[{x:-8},{x:-8,y:2},{x:-6,y:3},{x:-8,y:3},{x:-11},{x:-7,y:1},{x:-10,y:-1},{x:-10,y:1},{x:-10},{x:-7,y:4},{x:-7,y:2},{x:-7,y:3},{x:-9,y:1},{x:-9,y:2},{x:-9}],isOriginalCapital:true}],citiesCreated:1,exploredTiles:[{x:-6,y:2},{x:-6,y:1},{x:-4,y:6},{x:-4,y:3},{x:-3,y:8},{x:-3,y:4},{x:-2,y:6},{x:-6},{y:6},{x:-1,y:6},{x:-12,y:-1},{x:-6,y:-1},{x:-8,y:-3},{x:-12,y:-2},{x:-8,y:3},{x:-6,y:4},{x:-11},{x:-11,y:-1},{x:-11,y:-2},{x:-6,y:5},{x:-10,y:1},{x:-5,y:4},{x:-5,y:2},{x:-5,y:1},{x:-3,y:5},{x:-2,y:7},{x:-4,y:7},{x:-7,y:3},{x:-10},{y:7},{x:-10,y:-4},{x:-1,y:7},{x:-10,y:-1},{x:-10,y:-2},{x:-9,y:-1},{x:-9},{x:-9,y:1},{x:-9,y:2},{x:-9,y:-2},{x:-5,y:5},{x:-8,y:1},{x:-4,y:2},{x:-3,y:6},{x:-3,y:3},{x:-2,y:4},{x:-2,y:8},{x:-4,y:4},{x:-8,y:-1},{x:-8},{x:-1,y:8},{x:-8,y:-2},{y:8},{x:-6,y:3},{x:-8,y:2},{x:-11,y:-3},{x:-1,y:9},{y:9},{x:-2,y:9},{x:-7,y:1},{x:-7},{x:-1,y:5},{x:-5,y:6},{x:-4,y:5},{x:-3,y:7},{x:-2,y:5},{x:-5,y:3},{x:-7,y:2},{x:-7,y:-1},{x:-7,y:4},{x:-10,y:-3},{x:-7,y:-2},{x:-9,y:-3}],hasEverOwnedOriginalCapital:true,totalCultureForContests:1602,totalFaithForContests:875,cityStateResource:Jewelry},{gold:927,civName:Florence,tech:{scienceOfLast8Turns:[46,46,46,58,46,46,46,46],techsResearched:[Flight,Optics,Steam Power,Acoustics,Mining,Sailing,Education,Archery,Currency,Gunpowder,Fertilizer,Metal Casting,Economics,Construction,Architecture,Banking,Compass,Horseback Riding,Chemistry,Industrialization,Bronze Working,Scientific Theory,Philosophy,Drama and Poetry,Military Science,Printing Press,Mathematics,Guilds,Biology,Machinery,Engineering,Archaeology,Electricity,Dynamite,Pottery,Agriculture,Iron Working,Rifling,Theology,Navigation,Calendar,Masonry,Steel,Physics,Trapping,Chivalry,The Wheel,Animal Husbandry,Writing,Civil Service,Metallurgy,Astronomy],techsToResearch:[Radio],techsInProgress:{Radio:518}},policies:{storedCulture:1373,shouldOpenPolicyPicker:true},civConstructions:{},questManager:{assignedQuests:[{questName:Spread Religion,assigner:Florence,assignee:Inca,assignedOnTurn:170,data1:Sikhism,data2:Sikhism},{questName:Connect Resource,assigner:Florence,assignee:Inca,assignedOnTurn:186,data1:Copper},{questName:Contest Technologies,assigner:Florence,assignee:England,assignedOnTurn:281,data1:49},{questName:Contest Technologies,assigner:Florence,assignee:Songhai,assignedOnTurn:281,data1:37},{questName:Contest Technologies,assigner:Florence,assignee:Inca,assignedOnTurn:281,data1:49}],globalQuestCountdown:19,individualQuestCountdown:{Inca:0,Songhai:0,England:0}},religionManager:{storedFaith:400},goldenAges:{storedHappiness:614,numberOfGoldenAges:3},greatPeople:{},victoryManager:{},ruinsManager:{},diplomacy:{Inca:{otherCivName:Inca,diplomaticStatus:Protector,flagsCountdown:{EverBeenFriends:-1},diplomaticModifiers:{OpenBorders:7.875},influence:91},Songhai:{otherCivName:Songhai,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},England:{otherCivName:England,diplomaticStatus:Peace,flagsCountdown:{WaryOf:-1},diplomaticModifiers:{YearsOfPeace:30,DeclaredWarOnUs:-2.375},influence:-20},Barbarians:{otherCivName:Barbarians,influence:-59}},proximity:{Inca:Close,Vancouver:Far,Singapore:Far,Quebec City:Far,Songhai:Far,England:Neighbors},allyCivName:Inca,naturalWonders:[Krakatoa],flagsCountdown:{RecentlyBullied:0},cities:[{location:{x:-3,y:-9},id:fcf44add-45a1-4c9e-8458-ac9ed7a53f18,name:Florence,foundingCiv:Florence,health:300,religion:{religionsAtSomePointAdopted:[Christianity],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:1000},Christianity:{class:java.lang.Integer,value:9869},Sikhism:{class:java.lang.Integer,value:3033}}},population:{population:12,foodStored:165,specialistAllocations:{Scientist:{class:java.lang.Integer,value:3}}},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Harbor,Lighthouse,Colosseum,Aqueduct,Public School,Arsenal,Windmill,Workshop,Hospital,Granary,Armory,Stock Exchange,Palace,Military Academy,Market,Barracks,Opera House,Shrine,University,Temple,Theatre,Castle,Museum,Seaport,Library,Walls],inProgressConstructions:{Artillery:79},constructionQueue:[Artillery]},expansion:{cultureStored:600},tiles:[{x:-2,y:-8},{x:-3,y:-8},{x:-1,y:-8},{x:-3,y:-9},{x:-2,y:-9},{x:-4,y:-9},{x:-1,y:-9},{x:-4,y:-10},{x:-3,y:-10},{x:-3,y:-7},{x:-2,y:-7},{x:-2,y:-10},{x:-1,y:-7},{x:-3,y:-11},{x:-5,y:-9},{x:-2,y:-11}],workedTiles:[{x:-3,y:-8},{x:-2,y:-7},{x:-3,y:-7},{x:-3,y:-10},{x:-2,y:-10},{x:-2,y:-9},{x:-1,y:-9},{x:-3,y:-11},{x:-2,y:-11}],isOriginalCapital:true}],citiesCreated:1,exploredTiles:[{x:-1,y:-6},{x:-3,y:-8},{x:-4,y:-6},{x:-5,y:-7},{x:1,y:-6},{x:-6,y:-8},{x:-2,y:-12},{x:-3,y:-4},{x:4,y:-6},{y:-6},{x:2,y:-6},{x:3,y:-8},{x:-1,y:-12},{x:-2,y:-6},{x:-3,y:-9},{x:-2,y:-7},{x:-3,y:-10},{x:-1,y:-7},{x:-4,y:-7},{x:1,y:-7},{x:-5,y:-8},{x:-3,y:-5},{y:-7},{x:2,y:-7},{x:4,y:-7},{x:3,y:-5},{x:-5,y:-9},{x:-3,y:-11},{x:-2,y:-8},{x:-1,y:-8},{x:-1,y:-4},{x:-2,y:-4},{x:-4,y:-8},{y:-8},{x:2,y:-8},{x:3,y:-6},{y:-4},{x:1,y:-8},{x:-3,y:-6},{x:-1,y:-9},{x:-2,y:-9},{x:-4,y:-9},{y:-9},{x:1,y:-9},{x:2,y:-9},{x:-1,y:-10},{x:-2,y:-10},{x:-1,y:-5},{x:-4,y:-10},{x:1,y:-10},{x:1,y:-5},{x:-6,y:-7},{x:-5,y:-6},{x:-2,y:-5},{x:-3,y:-7},{x:-4,y:-5},{y:-10},{x:5,y:-6},{y:-5},{x:3,y:-7},{x:2,y:-5},{x:4,y:-5},{x:-2,y:-11},{x:-1,y:-11},{y:-11}],hasEverOwnedOriginalCapital:true,totalCultureForContests:1373,totalFaithForContests:400},{gold:-611,civName:Vancouver,tech:{scienceOfLast8Turns:[42,42,42,42,42,42,42,42],techsResearched:[Flight,Optics,Steam Power,Acoustics,Mining,Sailing,Education,Archery,Currency,Gunpowder,Fertilizer,Metal Casting,Economics,Construction,Architecture,Banking,Compass,Horseback Riding,Chemistry,Industrialization,Bronze Working,Scientific Theory,Philosophy,Drama and Poetry,Military Science,Printing Press,Mathematics,Guilds,Biology,Machinery,Engineering,Archaeology,Electricity,Dynamite,Pottery,Agriculture,Iron Working,Rifling,Theology,Navigation,Calendar,Masonry,Steel,Physics,Trapping,Chivalry,The Wheel,Animal Husbandry,Writing,Civil Service,Metallurgy,Astronomy],techsToResearch:[Replaceable Parts],techsInProgress:{Replaceable Parts:339}},policies:{storedCulture:1844,shouldOpenPolicyPicker:true},civConstructions:{},questManager:{assignedQuests:[{questName:Conquer City State,assigner:Vancouver,assignee:Inca,assignedOnTurn:120,data1:Quebec City},{questName:Connect Resource,assigner:Vancouver,assignee:Inca,assignedOnTurn:240,data1:Silver},{questName:Contest Technologies,assigner:Vancouver,assignee:England,assignedOnTurn:291,data1:50},{questName:Contest Technologies,assigner:Vancouver,assignee:Songhai,assignedOnTurn:291,data1:38},{questName:Contest Technologies,assigner:Vancouver,assignee:Inca,assignedOnTurn:291,data1:51}],globalQuestCountdown:30,individualQuestCountdown:{Inca:0,Songhai:0,England:0}},religionManager:{storedFaith:649},goldenAges:{storedHappiness:10,numberOfGoldenAges:4,turnsLeftForCurrentGoldenAge:4},greatPeople:{},victoryManager:{},ruinsManager:{},diplomacy:{Inca:{otherCivName:Inca,diplomaticStatus:Protector,flagsCountdown:{EverBeenFriends:-1},diplomaticModifiers:{OpenBorders:1.125},influence:48},Singapore:{otherCivName:Singapore,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Quebec City:{otherCivName:Quebec City,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30},influence:10},Songhai:{otherCivName:Songhai,diplomaticStatus:Peace,flagsCountdown:{EverBeenFriends:-1},diplomaticModifiers:{YearsOfPeace:30}},England:{otherCivName:England,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30},influence:5.5},Barbarians:{otherCivName:Barbarians,influence:-59}},proximity:{Inca:Neighbors,Singapore:Close,Quebec City:Neighbors,Songhai:Far,Florence:Far,England:Far},cities:[{location:{x:-6},id:c813cf06-9c9b-4532-96b0-8ee7e973945f,name:Vancouver,foundingCiv:Vancouver,health:300,religion:{religionsAtSomePointAdopted:[Sikhism],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:1000},Sikhism:{class:java.lang.Integer,value:5880},Christianity:{class:java.lang.Integer,value:2636}}},population:{population:13,foodStored:136},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Harbor,Lighthouse,Colosseum,Aqueduct,Public School,Arsenal,Workshop,Granary,Armory,Stock Exchange,Palace,Stable,Military Academy,Market,Barracks,Opera House,Shrine,Temple,University,Theatre,Castle,Museum,Seaport,Library,Walls],inProgressConstructions:{Hospital:333},constructionQueue:[Hospital]},expansion:{cultureStored:160},tiles:[{x:-6,y:-1},{x:-6},{x:-6,y:1},{x:-8,y:-2},{x:-4,y:2},{x:-6,y:-2},{x:-4},{x:-6,y:2},{x:-6,y:4},{x:-5,y:5},{x:-7,y:-1},{x:-5,y:1},{x:-5},{x:-7},{x:-5,y:2},{x:-5,y:-1},{x:-5,y:3},{x:-5,y:4},{x:-4,y:5},{x:-3,y:5}],workedTiles:[{x:-8,y:-2},{x:-4},{x:-4,y:2},{x:-6,y:-1},{x:-6,y:-2},{x:-6,y:1},{x:-6,y:2},{x:-5},{x:-5,y:2},{x:-5,y:1},{x:-7,y:-1},{x:-7},{x:-5,y:-1}],isOriginalCapital:true}],citiesCreated:1,exploredTiles:[{x:-6,y:-1},{x:-6,y:-2},{x:-3,y:2},{x:-3,y:1},{x:-2,y:3},{x:-2,y:6},{x:-4,y:-3},{x:-3,y:-2},{x:-3,y:-1},{x:-8,y:3},{x:-6},{x:-3},{x:-8,y:-3},{x:-6,y:1},{x:-4,y:3},{x:-4,y:6},{x:-3,y:4},{x:-6,y:2},{x:-6,y:4},{x:-6,y:-4},{x:-11,y:-2},{x:-5,y:-1},{x:-7,y:-3},{x:-5},{x:-10,y:-2},{x:-10,y:-1},{x:-5,y:1},{x:-3,y:5},{x:-5,y:-4},{x:-5,y:-2},{x:-5,y:2},{x:-5,y:4},{x:-7,y:3},{x:-6,y:5},{x:-9,y:-2},{x:-9,y:-1},{x:-9},{x:-9,y:1},{x:-9,y:2},{x:-4,y:-1},{x:-4},{x:-4,y:1},{x:-2,y:2},{x:-3,y:3},{x:-3,y:6},{x:-2,y:4},{x:-6,y:3},{x:-5,y:5},{x:-8,y:1},{x:-8,y:2},{x:-4,y:-2},{x:-4,y:2},{x:-4,y:4},{x:-8},{x:-2,y:1},{x:-8,y:-2},{x:-8,y:-4},{x:-2},{x:-8,y:-1},{x:-6,y:-3},{x:-11,y:-3},{x:-7,y:-1},{x:-7,y:-4},{x:-10,y:-3},{x:-7,y:-2},{x:-5,y:-3},{x:-7,y:4},{x:-7},{x:-7,y:1},{x:-5,y:6},{x:-7,y:2},{x:-5,y:3},{x:-4,y:5},{x:-2,y:5},{x:-9,y:-3}],hasEverOwnedOriginalCapital:true,totalCultureForContests:1844,totalFaithForContests:649,cityStatePersonality:Hostile},{gold:-205,civName:Quebec City,tech:{scienceOfLast8Turns:[64,64,64,64,42,42,42,42],techsResearched:[Flight,Optics,Steam Power,Acoustics,Mining,Sailing,Education,Archery,Currency,Gunpowder,Fertilizer,Metal Casting,Economics,Construction,Architecture,Banking,Compass,Horseback Riding,Chemistry,Industrialization,Bronze Working,Scientific Theory,Philosophy,Drama and Poetry,Military Science,Printing Press,Mathematics,Guilds,Biology,Machinery,Engineering,Archaeology,Electricity,Dynamite,Pottery,Agriculture,Iron Working,Rifling,Theology,Navigation,Calendar,Masonry,Steel,Physics,Trapping,Chivalry,The Wheel,Animal Husbandry,Writing,Civil Service,Metallurgy,Astronomy],techsToResearch:[Radio],techsInProgress:{Radio:1202}},policies:{storedCulture:1961,shouldOpenPolicyPicker:true},civConstructions:{},questManager:{assignedQuests:[{questName:Route,assigner:Quebec City,assignee:Inca,assignedOnTurn:100},{questName:Connect Resource,assigner:Quebec City,assignee:Inca,assignedOnTurn:207,data1:Silk}],globalQuestCountdown:4,individualQuestCountdown:{Inca:0,Songhai:0,England:0}},religionManager:{storedFaith:627},goldenAges:{storedHappiness:285,numberOfGoldenAges:4},greatPeople:{},victoryManager:{},ruinsManager:{},diplomacy:{Inca:{otherCivName:Inca,diplomaticStatus:Protector,flagsCountdown:{EverBeenFriends:-1},diplomaticModifiers:{OpenBorders:7.5},influence:65.75},Vancouver:{otherCivName:Vancouver,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30},influence:10},Singapore:{otherCivName:Singapore,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Songhai:{otherCivName:Songhai,diplomaticStatus:Peace,flagsCountdown:{WaryOf:-1},diplomaticModifiers:{YearsOfPeace:30},influence:-20},England:{otherCivName:England,diplomaticStatus:Peace,diplomaticModifiers:{YearsOfPeace:30}},Barbarians:{otherCivName:Barbarians,influence:-59}},proximity:{Inca:Neighbors,Vancouver:Neighbors,Singapore:Close,Songhai:Neighbors,Florence:Far,England:Close},allyCivName:Inca,cities:[{location:{x:-2,y:3},id:292c9f29-2dfd-4b0a-8391-0ae52efa92d6,name:Quebec City,foundingCiv:Quebec City,turnAcquired:1,health:300,religion:{religionsAtSomePointAdopted:[Sikhism],pressures:{The religion of TheLegend27:{class:java.lang.Integer,value:1100},Sikhism:{class:java.lang.Integer,value:6014},Christianity:{class:java.lang.Integer,value:4544}}},population:{population:13,foodStored:186},cityConstructions:{builtBuildings:[Monument,Amphitheater,Bank,Circus,Harbor,Factory,Lighthouse,Colosseum,Aqueduct,Public School,Arsenal,Workshop,Hospital,Granary,Armory,Stock Exchange,Palace,Stable,Military Academy,Market,Barracks,Opera House,Shrine,Temple,University,Theatre,Castle,Museum,Seaport,Library,Walls],constructionQueue:[Science],productionOverflow:35},expansion:{cultureStored:277},tiles:[{x:-3,y:2},{x:-4,y:3},{x:-3,y:3},{x:-4,y:4},{x:-1,y:2},{x:-1,y:3},{x:-1,y:4},{x:-2,y:1},{y:3},{x:-2,y:4},{x:-2,y:3},{x:-2,y:2},{x:-2,y:6},{x:-3,y:1},{x:-4,y:1},{x:-3,y:4},{x:-1,y:5},{x:-2,y:5},{y:5},{x:-1,y:7}],workedTiles:[{x:-4,y:3},{x:-4,y:1},{x:-4,y:4},{x:-1,y:2},{x:-3,y:3},{y:3},{x:-2,y:2},{x:-2,y:6},{x:-3,y:1},{x:-3,y:2},{x:-1,y:3},{x:-2,y:1},{x:-1,y:5}],isOriginalCapital:true}],citiesCreated:1,exploredTiles:[{x:1,y:3},{x:-4,y:3},{x:-1,y:3},{x:-3,y:2},{x:-3,y:8},{x:-2,y:6},{x:-6,y:1},{x:-6,y:4},{x:2,y:6},{x:3,y:8},{y:6},{x:-3},{x:-6,y:-1},{x:3,y:9},{x:-5,y:4},{y:7},{x:-5,y:1},{x:-2,y:7},{x:-6,y:5},{x:2,y:7},{x:-5,y:-1},{x:3,y:10},{x:3,y:11},{x:-6,y:3},{x:-2,y:1},{x:-1,y:8},{x:-3,y:6},{x:-2,y:4},{x:-4,y:2},{x:1,y:2},{x:3,y:6},{y:1},{x:-1},{x:1,y:8},{x:-2,y:-1},{x:2,y:4},{y:4},{x:-1,y:2},{x:-4},{x:-1,y:9},{x:1,y:9},{x:-7,y:4},{x:2,y:5},{x:-5,y:3},{x:-7,y:1},{x:-1,y:10},{x:-2,y:5},{x:-3,y:7},{y:5},{x:1,y:10},{x:3,y:7},{x:1,y:11},{x:-6,y:2},{x:-3,y:1},{x:-1,y:6},{x:-2,y:3},{x:-3,y:4},{x:-4,y:6},{x:1,y:6},{x:2,y:3},{x:-8,y:3},{x:-3,y:-1},{x:-6},{y:3},{x:-7,y:3},{x:-5},{x:-1,y:7},{x:-5,y:2},{x:-3,y:5},{x:-4,y:7},{x:1,y:7},{x:3,y:5},{x:2,y:8},{x:-4,y:1},{x:-3,y:3},{x:-2,y:2},{x:-1,y:4},{x:-2,y:8},{x:-4,y:4},{x:-8,y:2},{x:1,y:1},{x:-5,y:5},{y:2},{x:-2},{x:-4,y:-1},{x:1,y:4},{x:2,y:2},{y:8},{x:-1,y:1},{y:9},{x:2,y:9},{x:-2,y:9},{y:10},{x:-1,y:5},{x:-4,y:5},{x:1,y:5},{x:-5,y:6},{x:-7,y:2},{x:2,y:10},{y:11},{x:2,y:11}],hasEverOwnedOriginalCapital:true,totalCultureForContests:1961,totalFaithForContests:627,cityStatePersonality:Friendly}],barbarians:{},religions:{Dance of the Aurora:{name:Dance of the Aurora,foundingCivName:Inca,followerBeliefs:[Dance of the Aurora]},Sikhism:{name:Sikhism,displayName:Sikhism,foundingCivName:Inca,founderBeliefs:[Pilgrimage,Reliquary],followerBeliefs:[Dance of the Aurora,Religious Art,Swords into Ploughshares]},Desert Folklore:{name:Desert Folklore,foundingCivName:Songhai,followerBeliefs:[Desert Folklore]},Christianity:{name:Christianity,displayName:Christianity,foundingCivName:England,founderBeliefs:[Religious Texts,Ceremonial Burial],followerBeliefs:[Religious Community,God of War,Holy Warriors]},God of War:{name:God of War,foundingCivName:England,followerBeliefs:[God of War]}},difficulty:Prince,tileMap:{mapParameters:{type:Perlin,shape:Rectangular,mapSize:{radius:10,width:23,height:15,name:Tiny},createdWithVersion:3.18.3,seed:1637770344994,temperatureExtremeness:0.59999996},tileList:[{position:{x:13,y:2},baseTerrain:Snow,continent:2},{position:{x:12,y:1},baseTerrain:Snow,continent:2},{position:{x:11},baseTerrain:Snow,continent:2},{position:{x:10,y:-1},baseTerrain:Tundra,terrainFeatures:[Forest],improvement:Lumber mill,continent:2},{position:{x:9,y:-2},baseTerrain:Plains,resource:Horses,resourceAmount:2,improvement:Pasture,continent:2},{position:{x:8,y:-3},baseTerrain:Grassland,resource:Wine,improvement:Plantation,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Cannon,currentMovement:2,promotions:{promotions:[Accuracy I],numberOfPromotions:2}},civilianUnit:{owner:England,originalOwner:England,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Christianity},position:{x:7,y:-4},baseTerrain:Plains,resource:Sheep,improvement:Pasture,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Gatling Gun,currentMovement:2,promotions:{promotions:[Extended Range]}},civilianUnit:{owner:England,originalOwner:England,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Christianity},position:{x:6,y:-5},baseTerrain:Plains,improvement:City center,roadStatus:Road,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Lancer,currentMovement:4,promotions:{promotions:[Formation II,Shock I,Formation I,Drill I],numberOfPromotions:2}},civilianUnit:{owner:England,originalOwner:England,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Christianity},position:{x:5,y:-6},baseTerrain:Plains,terrainFeatures:[Jungle],improvement:Trading post,continent:2},{position:{x:4,y:-7},baseTerrain:Plains,resource:Incense,improvement:Plantation,continent:2},{civilianUnit:{owner:England,originalOwner:England,name:Great Scientist,currentMovement:2,promotions:{}},position:{x:3,y:-8},baseTerrain:Desert,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:2,y:-9},baseTerrain:Desert,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:1,y:-10},baseTerrain:Plains,terrainFeatures:[Hill,Forest],improvement:Lumber mill,continent:2},{position:{y:-11},baseTerrain:Tundra,resource:Dyes,improvement:Plantation,continent:2},{position:{x:-1,y:-12},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:12,y:2},baseTerrain:Snow,resource:Uranium,resourceAmount:2,continent:2},{position:{x:11,y:1},baseTerrain:Snow,resource:Copper,improvement:Mine,continent:2},{position:{x:10},baseTerrain:Tundra,improvement:Trading post,continent:2},{position:{x:9,y:-1},baseTerrain:Grassland,resource:Iron,resourceAmount:2,improvement:Mine,continent:2},{civilianUnit:{owner:England,originalOwner:England,name:Great Artist,currentMovement:2,promotions:{}},position:{x:8,y:-2},baseTerrain:Desert,terrainFeatures:[Oasis],continent:2},{position:{x:7,y:-3},baseTerrain:Plains,improvement:Manufactory,continent:2},{civilianUnit:{owner:England,originalOwner:England,name:Great Engineer,currentMovement:4,promotions:{}},position:{x:6,y:-4},baseTerrain:Coast},{civilianUnit:{owner:England,originalOwner:England,name:Great Scientist,currentMovement:4,promotions:{}},position:{x:5,y:-5},baseTerrain:Coast},{civilianUnit:{owner:England,originalOwner:England,name:Worker,currentMovement:2,promotions:{}},position:{x:4,y:-6},baseTerrain:Plains,resource:Aluminum,resourceAmount:3,improvement:Mine,continent:2},{position:{x:3,y:-7},baseTerrain:Coast},{civilianUnit:{owner:England,originalOwner:England,name:Worker,currentMovement:2,promotions:{}},position:{x:2,y:-8},baseTerrain:Desert,terrainFeatures:[Hill],improvement:Mine,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Cannon,currentMovement:2,promotions:{XP:1,promotions:[Barrage I],numberOfPromotions:2}},position:{x:1,y:-9},baseTerrain:Grassland,resource:Cattle,improvement:City center,roadStatus:Road,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Rifleman,currentMovement:2,promotions:{}},position:{y:-10},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:-1,y:-11},baseTerrain:Snow,continent:2},{position:{x:-2,y:-12},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:12,y:3},baseTerrain:Snow,resource:Stone,improvement:Quarry,continent:2},{position:{x:11,y:2},baseTerrain:Snow,continent:2},{position:{x:10,y:1},baseTerrain:Snow,continent:2},{position:{x:9},baseTerrain:Tundra,terrainFeatures:[Forest],improvement:Lumber mill,continent:2},{position:{x:8,y:-1},baseTerrain:Desert,improvement:Farm,continent:2},{position:{x:7,y:-2},baseTerrain:Desert,terrainFeatures:[Hill],improvement:Academy,continent:2},{position:{x:6,y:-3},baseTerrain:Coast,terrainFeatures:[Atoll]},{position:{x:5,y:-4},baseTerrain:Coast},{position:{x:4,y:-5},baseTerrain:Plains,resource:Coal,resourceAmount:3,improvement:Mine,continent:2},{position:{x:3,y:-6},baseTerrain:Coast},{civilianUnit:{owner:England,originalOwner:England,name:Work Boats,currentMovement:6,promotions:{}},position:{x:2,y:-7},baseTerrain:Coast,resource:Oil,resourceAmount:3},{civilianUnit:{owner:England,originalOwner:England,name:Worker,currentMovement:4,promotions:{}},position:{x:1,y:-8},baseTerrain:Coast},{position:{y:-9},baseTerrain:Tundra,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:-1,y:-10},baseTerrain:Snow,continent:2},{militaryUnit:{owner:Florence,originalOwner:Florence,name:Gatling Gun,promotions:{XP:15,promotions:[Barrage I,Accuracy I],numberOfPromotions:2}},position:{x:-2,y:-11},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Cannon,currentMovement:2,promotions:{}},position:{x:11,y:3},baseTerrain:Snow,continent:2},{position:{x:10,y:2},baseTerrain:Snow,improvement:City center,roadStatus:Road,continent:2},{position:{x:9,y:1},baseTerrain:Coast},{position:{x:8},baseTerrain:Grassland,terrainFeatures:[Forest],improvement:Lumber mill,continent:2},{position:{x:7,y:-1},baseTerrain:Desert,terrainFeatures:[Hill],improvement:Academy,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Gatling Gun,currentMovement:2,promotions:{promotions:[Barrage I],numberOfPromotions:2}},civilianUnit:{owner:England,originalOwner:England,name:Settler,currentMovement:2,promotions:{}},position:{x:6,y:-2},baseTerrain:Plains,terrainFeatures:[Hill],improvement:Landmark,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Rifleman,currentMovement:2,promotions:{XP:27,promotions:[Ambush I,Shock I],numberOfPromotions:2}},civilianUnit:{owner:England,originalOwner:England,name:Great General,currentMovement:2,promotions:{}},position:{x:5,y:-3},baseTerrain:Plains,resource:Sheep,improvement:Pasture,continent:2},{position:{x:4,y:-4},baseTerrain:Coast},{position:{x:3,y:-5},baseTerrain:Coast},{position:{x:2,y:-6},baseTerrain:Ocean},{position:{x:1,y:-7},baseTerrain:Ocean},{position:{y:-8},baseTerrain:Coast},{position:{x:-1,y:-9},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{civilianUnit:{owner:Florence,originalOwner:Florence,name:Worker,promotions:{}},position:{x:-2,y:-10},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:-3,y:-11},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{position:{x:11,y:4},baseTerrain:Snow,continent:2},{position:{x:10,y:3},baseTerrain:Snow,continent:2},{position:{x:9,y:2},baseTerrain:Snow,continent:2},{position:{x:8,y:1},baseTerrain:Coast},{position:{x:7},baseTerrain:Coast},{civilianUnit:{owner:England,originalOwner:England,name:Great General,currentMovement:2,promotions:{}},position:{x:6,y:-1},baseTerrain:Desert,terrainFeatures:[Hill],resource:Sheep,improvement:Pasture,continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Cavalry,currentMovement:4,promotions:{promotions:[Shock I],numberOfPromotions:2}},civilianUnit:{owner:England,originalOwner:England,name:Great Scientist,currentMovement:2,promotions:{}},position:{x:5,y:-2},baseTerrain:Desert,terrainFeatures:[Oasis],continent:2},{militaryUnit:{owner:England,originalOwner:England,name:Cannon,currentMovement:2,promotions:{promotions:[Accuracy I],numberOfPromotions:2}},position:{x:4,y:-3},baseTerrain:Plains,improvement:City center,roadStatus:Road,continent:2},{position:{x:3,y:-4},baseTerrain:Coast},{position:{x:2,y:-5},baseTerrain:Coast},{position:{x:1,y:-6},baseTerrain:Coast},{position:{y:-7},baseTerrain:Ocean},{position:{x:-1,y:-8},baseTerrain:Coast},{militaryUnit:{owner:Florence,originalOwner:Florence,name:Crossbowman,currentMovement:4,promotions:{XP:10,promotions:[Accuracy I],numberOfPromotions:1}},position:{x:-2,y:-9},baseTerrain:Coast,resource:Fish,improvement:Fishing Boats},{militaryUnit:{owner:Florence,originalOwner:Florence,name:Gatling Gun,promotions:{XP:15,promotions:[Volley,Accuracy I],numberOfPromotions:2}},position:{x:-3,y:-10},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:2},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Settler,currentMovement:2,promotions:{}},position:{x:10,y:4},baseTerrain:Snow,continent:2},{position:{x:9,y:3},baseTerrain:Tundra,resource:Silk,improvement:Plantation,continent:2},{position:{x:8,y:2},baseTerrain:Coast},{position:{x:7,y:1},baseTerrain:Ocean},{position:{x:6},baseTerrain:Coast},{position:{x:5,y:-1},baseTerrain:Plains,improvement:Farm,continent:2},{position:{x:4,y:-2},baseTerrain:Plains,improvement:Farm,continent:2},{position:{x:3,y:-3},baseTerrain:Grassland,improvement:Farm,continent:2},{civilianUnit:{owner:England,originalOwner:England,name:Great General,currentMovement:4,promotions:{}},position:{x:2,y:-4},baseTerrain:Coast},{militaryUnit:{owner:England,originalOwner:England,name:Cannon,currentMovement:2,promotions:{numberOfPromotions:2}},position:{x:1,y:-5},baseTerrain:Plains,improvement:City center,roadStatus:Road,continent:5},{position:{y:-6},baseTerrain:Coast,terrainFeatures:[Atoll]},{militaryUnit:{owner:Florence,originalOwner:Florence,name:Artillery,promotions:{promotions:[Accuracy II,Accuracy I],numberOfPromotions:2}},position:{x:-1,y:-7},baseTerrain:Ocean},{position:{x:-2,y:-8},baseTerrain:Coast},{position:{x:-3,y:-9},baseTerrain:Tundra,resource:Deer,improvement:City center,roadStatus:Road,continent:2},{position:{x:-4,y:-10},baseTerrain:Coast},{position:{x:10,y:5},baseTerrain:Coast,resource:Fish,improvement:Fishing Boats},{position:{x:9,y:4},baseTerrain:Coast},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Crossbowman,currentMovement:4,promotions:{XP:5,promotions:[Cover I],numberOfPromotions:1}},position:{x:8,y:3},baseTerrain:Coast},{position:{x:7,y:2},baseTerrain:Ocean},{position:{x:6,y:1},baseTerrain:Coast},{position:{x:5},baseTerrain:Grassland,improvement:Farm,continent:2},{position:{x:4,y:-1},baseTerrain:Plains,resource:Ivory,improvement:Camp,continent:2},{position:{x:3,y:-2},baseTerrain:Plains,improvement:Farm,continent:2},{position:{x:2,y:-3},baseTerrain:Coast},{position:{x:1,y:-4},baseTerrain:Coast},{position:{y:-5},baseTerrain:Coast},{position:{x:-1,y:-6},baseTerrain:Ocean},{position:{x:-2,y:-7},baseTerrain:Coast,resource:Whales,improvement:Fishing Boats},{militaryUnit:{owner:Florence,originalOwner:Florence,name:Rifleman,promotions:{XP:5,promotions:[Ambush I],numberOfPromotions:1}},position:{x:-3,y:-8},baseTerrain:Coast,resource:Fish,improvement:Fishing Boats},{militaryUnit:{owner:Florence,originalOwner:Florence,name:Galleass,promotions:{XP:9,promotions:[Bombardment I],numberOfPromotions:1}},position:{x:-4,y:-9},baseTerrain:Coast},{position:{x:9,y:5},baseTerrain:Coast},{position:{x:8,y:4},baseTerrain:Ocean},{position:{x:7,y:3},baseTerrain:Ocean},{position:{x:6,y:2},baseTerrain:Ocean},{position:{x:5,y:1},baseTerrain:Coast},{position:{x:4},baseTerrain:Coast},{position:{x:3,y:-1},baseTerrain:Coast},{position:{x:2,y:-2},baseTerrain:Coast},{position:{x:1,y:-3},baseTerrain:Plains,terrainFeatures:[Jungle],improvement:Trading post,continent:4},{position:{y:-4},baseTerrain:Coast},{position:{x:-1,y:-5},baseTerrain:Ocean},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Caravel,currentMovement:5,promotions:{}},position:{x:-2,y:-6},baseTerrain:Coast},{position:{x:-3,y:-7},baseTerrain:Mountain,naturalWonder:Krakatoa},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Crossbowman,currentMovement:4,promotions:{}},position:{x:-4,y:-8},baseTerrain:Coast},{position:{x:-5,y:-9},baseTerrain:Ocean},{position:{x:9,y:6},baseTerrain:Snow,improvement:City center,roadStatus:Road,continent:0},{position:{x:8,y:5},baseTerrain:Coast},{position:{x:7,y:4},baseTerrain:Ocean},{position:{x:6,y:3},baseTerrain:Ocean},{position:{x:5,y:2},baseTerrain:Ocean},{position:{x:4,y:1},baseTerrain:Ocean},{position:{x:3},baseTerrain:Ocean},{position:{x:2,y:-1},baseTerrain:Coast,terrainFeatures:[Atoll]},{position:{x:1,y:-2},baseTerrain:Grassland,resource:Cattle,improvement:Pasture,continent:4},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Artillery,currentMovement:2,promotions:{promotions:[Accuracy II,Accuracy I],numberOfPromotions:2}},civilianUnit:{owner:Inca,originalOwner:Inca,name:Great General,currentMovement:2,promotions:{}},position:{y:-3},baseTerrain:Plains,terrainFeatures:[Forest],improvement:Lumber mill,continent:4},{position:{x:-1,y:-4},baseTerrain:Coast},{position:{x:-2,y:-5},baseTerrain:Ocean},{position:{x:-3,y:-6},baseTerrain:Coast},{position:{x:-4,y:-7},baseTerrain:Coast},{position:{x:-5,y:-8},baseTerrain:Ocean},{position:{x:8,y:6},baseTerrain:Snow,resource:Copper,improvement:Mine,continent:0},{position:{x:7,y:5},baseTerrain:Coast},{position:{x:6,y:4},baseTerrain:Ocean},{position:{x:5,y:3},baseTerrain:Ocean},{position:{x:4,y:2},baseTerrain:Ocean},{position:{x:3,y:1},baseTerrain:Ocean},{position:{x:2},baseTerrain:Ocean},{position:{x:1,y:-1},baseTerrain:Coast},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Gatling Gun,currentMovement:2,promotions:{XP:2,promotions:[Slinger Withdraw]}},position:{y:-2},baseTerrain:Plains,improvement:City center,roadStatus:Road,continent:4},{position:{x:-1,y:-3},baseTerrain:Coast},{position:{x:-2,y:-4},baseTerrain:Ocean},{position:{x:-3,y:-5},baseTerrain:Ocean},{position:{x:-4,y:-6},baseTerrain:Ocean},{position:{x:-5,y:-7},baseTerrain:Ocean},{position:{x:-6,y:-8},baseTerrain:Ocean},{position:{x:8,y:7},baseTerrain:Snow,continent:0},{position:{x:7,y:6},baseTerrain:Coast},{position:{x:6,y:5},baseTerrain:Coast},{position:{x:5,y:4},baseTerrain:Coast},{position:{x:4,y:3},baseTerrain:Ocean},{position:{x:3,y:2},baseTerrain:Ocean},{position:{x:2,y:1},baseTerrain:Ocean},{position:{x:1},baseTerrain:Coast},{position:{y:-1},baseTerrain:Coast},{position:{x:-1,y:-2},baseTerrain:Coast},{position:{x:-2,y:-3},baseTerrain:Coast},{position:{x:-3,y:-4},baseTerrain:Coast},{position:{x:-4,y:-5},baseTerrain:Coast,resource:Fish,improvement:Fishing Boats},{position:{x:-5,y:-6},baseTerrain:Coast,resource:Pearls,improvement:Fishing Boats},{position:{x:-6,y:-7},baseTerrain:Ocean},{position:{x:7,y:7},baseTerrain:Snow,continent:0},{position:{x:6,y:6},baseTerrain:Coast},{position:{x:5,y:5},baseTerrain:Tundra,resource:Deer,improvement:Camp,continent:0},{position:{x:4,y:4},baseTerrain:Coast,terrainFeatures:[Atoll]},{position:{x:3,y:3},baseTerrain:Coast},{position:{x:2,y:2},baseTerrain:Ocean},{position:{x:1,y:1},baseTerrain:Coast},{baseTerrain:Grassland,terrainFeatures:[Hill],resource:Gems,improvement:Mine,continent:0},{position:{x:-1,y:-1},baseTerrain:Coast},{position:{x:-2,y:-2},baseTerrain:Plains,improvement:Farm,continent:0},{position:{x:-3,y:-3},baseTerrain:Coast,terrainFeatures:[Atoll]},{position:{x:-4,y:-4},baseTerrain:Grassland,improvement:Farm,continent:0},{position:{x:-5,y:-5},baseTerrain:Tundra,improvement:Trading post,continent:0},{position:{x:-6,y:-6},baseTerrain:Coast,terrainFeatures:[Atoll]},{position:{x:-7,y:-7},baseTerrain:Coast,terrainFeatures:[Ice]},{position:{x:7,y:8},baseTerrain:Snow,continent:0},{position:{x:6,y:7},baseTerrain:Snow,improvement:City center,roadStatus:Road,continent:0},{position:{x:5,y:6},baseTerrain:Tundra,improvement:Trading post,continent:0},{position:{x:4,y:5},baseTerrain:Plains,improvement:Academy,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Worker,currentMovement:2,promotions:{}},position:{x:3,y:4},baseTerrain:Plains,improvement:Farm,continent:0},{position:{x:2,y:3},baseTerrain:Coast},{position:{x:1,y:2},baseTerrain:Coast},{position:{y:1},baseTerrain:Plains,terrainFeatures:[Hill],improvement:Academy,continent:0},{position:{x:-1},baseTerrain:Mountain},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Cavalry,currentMovement:4,promotions:{promotions:[Morale,Shock I,Drill I],numberOfPromotions:2}},civilianUnit:{owner:Inca,originalOwner:Inca,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Sikhism},position:{x:-2,y:-1},baseTerrain:Grassland,improvement:Farm,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Gatling Gun,currentMovement:2,promotions:{promotions:[Slinger Withdraw]}},civilianUnit:{owner:Inca,originalOwner:Inca,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Sikhism},position:{x:-3,y:-2},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:City center,roadStatus:Road,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Rifleman,currentMovement:2,promotions:{XP:5,promotions:[Cover I,Morale],numberOfPromotions:1}},civilianUnit:{owner:Inca,originalOwner:Inca,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Sikhism},position:{x:-4,y:-3},baseTerrain:Grassland,terrainFeatures:[Hill],resource:Sheep,improvement:Pasture,continent:0},{position:{x:-5,y:-4},baseTerrain:Grassland,improvement:Farm,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Rifleman,currentMovement:2,promotions:{XP:5,promotions:[Drill I],numberOfPromotions:1}},position:{x:-6,y:-5},baseTerrain:Tundra,improvement:Trading post,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Gatling Gun,currentMovement:2,promotions:{promotions:[Slinger Withdraw]}},position:{x:-7,y:-6},baseTerrain:Snow,improvement:City center,roadStatus:Road,continent:0},{position:{x:6,y:8},baseTerrain:Snow,continent:0},{position:{x:5,y:7},baseTerrain:Snow,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Worker,currentMovement:2,promotions:{}},position:{x:4,y:6},baseTerrain:Tundra,improvement:Trading post,continent:0},{position:{x:3,y:5},baseTerrain:Grassland,improvement:Farm,continent:0},{position:{x:2,y:4},baseTerrain:Plains,improvement:Customs house,continent:0},{position:{x:1,y:3},baseTerrain:Plains,continent:0},{position:{y:2},baseTerrain:Grassland,terrainFeatures:[Forest],continent:0},{position:{x:-1,y:1},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:Customs house,continent:0},{position:{x:-2},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:Academy,continent:0},{civilianUnit:{owner:Inca,originalOwner:Inca,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Sikhism},position:{x:-3,y:-1},baseTerrain:Plains,terrainFeatures:[Hill],resource:Sheep,improvement:Pasture,continent:0},{position:{x:-4,y:-2},baseTerrain:Desert,resource:Gold Ore,improvement:Mine,continent:0},{position:{x:-5,y:-3},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:Mine,continent:0},{position:{x:-6,y:-4},baseTerrain:Tundra,terrainFeatures:[Hill],improvement:Terrace farm,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Cavalry,currentMovement:4,promotions:{promotions:[Shock I,Drill I],numberOfPromotions:2}},position:{x:-7,y:-5},baseTerrain:Tundra,terrainFeatures:[Hill],improvement:Mine,continent:0},{position:{x:-8,y:-6},baseTerrain:Snow,improvement:Farm,continent:0},{position:{x:6,y:9},baseTerrain:Snow,resource:Copper,improvement:Mine,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Great General,promotions:{}},position:{x:5,y:8},baseTerrain:Snow,continent:0},{position:{x:4,y:7},baseTerrain:Snow,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Worker,currentMovement:2,promotions:{}},position:{x:3,y:6},baseTerrain:Tundra,terrainFeatures:[Forest],improvement:Lumber mill,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Desert Folklore},position:{x:2,y:5},baseTerrain:Plains,resource:Sheep,improvement:Pasture,continent:0},{position:{x:1,y:4},baseTerrain:Desert,resource:Wheat,improvement:Farm,continent:0},{position:{y:3},baseTerrain:Grassland,improvement:Farm,continent:0},{position:{x:-1,y:2},baseTerrain:Grassland,terrainFeatures:[Hill],resource:Aluminum,resourceAmount:3,improvement:Mine,continent:0},{position:{x:-2,y:1},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:Mine,continent:0},{position:{x:-3},baseTerrain:Grassland,improvement:Farm,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Artillery,currentMovement:2,promotions:{}},position:{x:-4,y:-1},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:Academy,continent:0},{position:{x:-5,y:-2},baseTerrain:Plains,terrainFeatures:[Hill],improvement:Academy,continent:0},{position:{x:-6,y:-3},baseTerrain:Tundra,improvement:Trading post,continent:0},{position:{x:-7,y:-4},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:0},{position:{x:-8,y:-5},baseTerrain:Lakes},{position:{x:5,y:9},baseTerrain:Snow,continent:0},{position:{x:4,y:8},baseTerrain:Snow,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Worker,currentMovement:2,promotions:{}},position:{x:3,y:7},baseTerrain:Snow,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Desert Folklore},position:{x:2,y:6},baseTerrain:Grassland,resource:Sugar,improvement:City center,roadStatus:Road,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Great Prophet,currentMovement:2,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Desert Folklore},position:{x:1,y:5},baseTerrain:Desert,improvement:Farm,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Great Merchant,currentMovement:2,promotions:{}},position:{y:4},baseTerrain:Desert,terrainFeatures:[Oasis],continent:0},{militaryUnit:{owner:Quebec City,originalOwner:Quebec City,name:Privateer,promotions:{XP:15,promotions:[Coastal Raider I,Boarding Party I],numberOfPromotions:2}},position:{x:-1,y:3},baseTerrain:Coast,terrainFeatures:[Atoll]},{position:{x:-2,y:2},baseTerrain:Grassland,improvement:Farm,continent:0},{position:{x:-3,y:1},baseTerrain:Grassland,improvement:Farm,continent:0},{position:{x:-4},baseTerrain:Plains,improvement:Farm,continent:0},{position:{x:-5,y:-1},baseTerrain:Desert,improvement:Farm,continent:0},{position:{x:-6,y:-2},baseTerrain:Plains,terrainFeatures:[Hill],improvement:Mine,continent:0},{civilianUnit:{owner:Inca,originalOwner:Inca,name:Worker,currentMovement:2,promotions:{}},position:{x:-7,y:-3},baseTerrain:Tundra,improvement:Trading post,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Rifleman,currentMovement:2,promotions:{promotions:[Ambush I,Cover I],numberOfPromotions:2}},civilianUnit:{owner:Inca,originalOwner:Inca,name:Worker,currentMovement:2,promotions:{}},position:{x:-8,y:-4},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:0},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Artillery,currentMovement:2,promotions:{}},civilianUnit:{owner:Inca,originalOwner:Inca,name:Worker,currentMovement:2,promotions:{}},position:{x:-9,y:-5},baseTerrain:Snow,resource:Stone,improvement:Quarry,continent:0},{position:{x:5,y:10},baseTerrain:Snow,improvement:City center,roadStatus:Road,continent:0},{position:{x:4,y:9},baseTerrain:Snow,continent:0},{position:{x:3,y:8},baseTerrain:Snow,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Settler,currentMovement:2,promotions:{}},position:{x:2,y:7},baseTerrain:Tundra,resource:Deer,improvement:Camp,continent:0},{civilianUnit:{owner:Songhai,originalOwner:Songhai,name:Great Prophet,currentMovement:3,promotions:{},abilityUsesLeft:{Spread Religion:4},maxAbilityUses:{Spread Religion:4},religion:Desert Folklore},position:{x:1,y:6},baseTerrain:Coast},{position:{y:5},baseTerrain:Coast},{position:{x:-1,y:4},baseTerrain:Coast},{militaryUnit:{owner:Quebec City,originalOwner:Quebec City,name:Cavalry,promotions:{XP:15,promotions:[Shock I],numberOfPromotions:2}},position:{x:-2,y:3},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:City center,roadStatus:Road,continent:0},{position:{x:-3,y:2},baseTerrain:Grassland,terrainFeatures:[Hill],improvement:Mine,continent:0},{militaryUnit:{owner:Quebec City,originalOwner:Quebec City,name:Cavalry,promotions:{promotions:[Shock I,Drill I],numberOfPromotions:2}},position:{x:-4,y:1},baseTerrain:Grassland,terrainFeatures:[Hill],resource:Coal,resourceAmount:3,improvement:Mine,continent:0},{position:{x:-5},baseTerrain:Grassland,resource:Cattle,improvement:Pasture,continent:0},{position:{x:-6,y:-1},baseTerrain:Desert,terrainFeatures:[Hill],improvement:Mine,continent:0},{position:{x:-7,y:-2},baseTerrain:Tundra,terrainFeatures:[Hill,Forest],improvement:Lumber mill,continent:0},{position:{x:-8,y:-3},baseTerrain:Mountain},{militaryUnit:{owner:Inca,originalOwner:Inca,name:Gatling Gun,currentMovement:2,promotions:{promotions:[Slinger Withdraw]}},civilianUnit:{owner:Inca,originalOwner:Inca,name:Worker,currentMovement:2,promotions:{}},position:{x:-9,y:-4},baseTerrain:Snow,terrainFeatures:[Hill],improvement:City center,roadStatus:Road,continent:0},{position:{x:4,y:10},baseTerrain:Snow,continent:0},{position:{x:3,y:9},baseTerrain:Snow,terrainFeatures:[Hill],improvement:Mine,continent:0},{position:{x:2,y:8},baseTerrain:Tundra,improvement:Trading post,continent:0},{position:{x:1,y:7},baseTerrain:Coast},{position:{y:6},baseTerrain:Ocean},{position:{x:-1,y:5},baseTerrain:Ocean},{militaryUnit:{owner:Quebec City,originalOwner:Quebec City,name:Cannon,currentMovement:4,promotions:{promotions:[Barrage I],numberOfPromotions:2}},position:{x:-2,y:4},baseTerrain:Coast},{militaryUnit:{owner:Quebec City,originalOwner:Quebec City,name:Gatling Gun,promotions:{XP:15,promotions:[Cover I,Accuracy I],numberOfPromotions:2}},position:{x:-3,y:3},baseTerrain:Grassland,terrainFeatures:[Hill],resource:Horses,resourceAmount:2,improvement:Pasture,continent:0},{civilianUnit:{owner:Vancouver,originalOwner:Vancouver,name:Worker,promotions:{}},position:{x:-4,y:2},baseTerrain:Grassland,terrainFeatures:[Forest],improvement:Lumber mill,continent:0},{position:{x:-5,y:1},baseTerrain:Plains,terrainFeatures:[Hill],improvement:Mine,continent:0},{position:{x:-6},baseTerrain:Desert,terrainFeatures:[Hill],improvement:City center,roadStatus:Road,continent:0},{position:{x:-7,y:-1},baseTerrain:Coast},{position:{x:-8,y:-2},baseTerrain:Coast,resource:Crab,improvement:Fishing Boats},{position:{x:-9,y:-3},baseTerrain:Mountain},{position:{x:-10,y:-4},baseTerrain:Mountain},{position:{x:4,y:11},baseTerrain:Coast,resource:Fish},{position:{x:3,y:10},baseTerrain:Snow,continent:0},{position:{x:2,y:9},baseTerrain:Coast},{position:{x:1,y:8},baseTerrain:Coast},{position:{y:7},baseTerrain:Ocean},{position:{x:-1,y:6},baseTerrain:Ocean},{position:{x:-2,y:5},baseTerrain:Ocean},{position:{x:-3,y:4},baseTerrain:Coast},{civilianUnit:{owner:Quebec City,originalOwner:Quebec City,name:Worker,promotions:{}},position:{x:-4,y:3},baseTerrain:Plains,resource:Cotton,improvement:Plantation,continent:0},{position:{x:-5,y:2},baseTerrain:Grassland,improvement:Farm,continent:0},{position:{x:-6,y:1},baseTerrain:Coast},{position:{x:-7},baseTerrain:Coast},{position:{x:-8,y:-1},baseTerrain:Ocean},{position:{x:-9,y:-2},baseTerrain:Coast},{position:{x:-10,y:-3},baseTerrain:Snow,terrainFeatures:[Hill],continent:3},{position:{x:3,y:11},baseTerrain:Coast},{position:{x:2,y:10},baseTerrain:Coast},{position:{x:1,y:9},baseTerrain:Ocean},{position:{y:8},baseTerrain:Ocean},{position:{x:-1,y:7},baseTerrain:Ocean},{position:{x:-2,y:6},baseTerrain:Ocean},{position:{x:-3,y:5},baseTerrain:Ocean},{militaryUnit:{owner:Quebec City,originalOwner:Quebec City,name:Rifleman,promotions:{XP:15,promotions:[Ambush I,Drill I],numberOfPromotions:2}},position:{x:-4,y:4},baseTerrain:Coast,resource:Fish,improvement:Fishing Boats},{position:{x:-5,y:3},baseTerrain:Coast},{position:{x:-6,y:2},baseTerrain:Coast},{position:{x:-7,y:1},baseTerrain:Coast,resource:Fish,improvement:Fishing Boats},{militaryUnit:{owner:Singapore,originalOwner:Singapore,name:Caravel,promotions:{XP:5,promotions:[Coastal Raider I],numberOfPromotions:1}},position:{x:-8},baseTerrain:Coast,resource:Fish,improvement:Fishing Boats},{position:{x:-9,y:-1},baseTerrain:Coast},{position:{x:-10,y:-2},baseTerrain:Snow,resource:Stone,continent:3},{position:{x:-11,y:-3},baseTerrain:Snow,continent:3},{position:{x:3,y:12},baseTerrain:Ocean},{position:{x:2,y:11},baseTerrain:Ocean},{militaryUnit:{owner:England,originalOwner:England,name:Musketman,currentMovement:4,promotions:{XP:5,promotions:[Cover I],numberOfPromotions:1}},position:{x:1,y:10},baseTerrain:Coast},{position:{y:9},baseTerrain:Coast},{position:{x:-1,y:8},baseTerrain:Coast,terrainFeatures:[Atoll]},{position:{x:-2,y:7},baseTerrain:Ocean},{position:{x:-3,y:6},baseTerrain:Ocean},{position:{x:-4,y:5},baseTerrain:Ocean},{position:{x:-5,y:4},baseTerrain:Ocean},{position:{x:-6,y:3},baseTerrain:Ocean},{position:{x:-7,y:2},baseTerrain:Coast},{militaryUnit:{owner:Singapore,originalOwner:Singapore,name:Gatling Gun,currentMovement:2,promotions:{XP:2}},civilianUnit:{owner:Singapore,originalOwner:Singapore,name:Worker,promotions:{}},position:{x:-8,y:1},baseTerrain:Grassland,improvement:City center,roadStatus:Road,continent:3},{militaryUnit:{owner:Singapore,originalOwner:Singapore,name:Pikeman,promotions:{}},position:{x:-9},baseTerrain:Tundra,terrainFeatures:[Hill],improvement:Mine,continent:3},{position:{x:-10,y:-1},baseTerrain:Tundra,improvement:Farm,continent:3},{position:{x:-11,y:-2},baseTerrain:Snow,continent:3},{position:{x:2,y:12},baseTerrain:Coast},{position:{x:1,y:11},baseTerrain:Coast},{militaryUnit:{owner:England,originalOwner:England,name:Gatling Gun,currentMovement:2,promotions:{XP:17,promotions:[Barrage I,Extended Range],numberOfPromotions:1}},position:{y:10},baseTerrain:Snow,improvement:City center,roadStatus:Road,continent:1},{position:{x:-1,y:9},baseTerrain:Grassland,resource:Uranium,resourceAmount:2,continent:1},{position:{x:-2,y:8},baseTerrain:Coast},{position:{x:-3,y:7},baseTerrain:Ocean},{militaryUnit:{owner:Quebec City,originalOwner:Quebec City,name:Privateer,currentMovement:2,promotions:{XP:15,promotions:[Mobility,Coastal Raider I],numberOfPromotions:2}},position:{x:-4,y:6},baseTerrain:Ocean},{position:{x:-5,y:5},baseTerrain:Ocean},{position:{x:-6,y:4},baseTerrain:Ocean},{position:{x:-7,y:3},baseTerrain:Coast},{position:{x:-8,y:2},baseTerrain:Desert,resource:Silver,improvement:Mine,continent:3},{position:{x:-9,y:1},baseTerrain:Grassland,resource:Oil,resourceAmount:3,improvement:Oil well,continent:3},{position:{x:-10},baseTerrain:Tundra,improvement:Farm,continent:3},{position:{x:-11,y:-1},baseTerrain:Lakes},{position:{x:-12,y:-2},baseTerrain:Snow,resource:Iron,resourceAmount:2,continent:3},{position:{x:2,y:13},baseTerrain:Coast,terrainFeatures:[Ice]},{position:{x:1,y:12},baseTerrain:Snow,continent:1},{position:{y:11},baseTerrain:Snow,continent:1},{position:{x:-1,y:10},baseTerrain:Tundra,resource:Furs,continent:1},{position:{x:-2,y:9},baseTerrain:Plains,terrainFeatures:[Forest],resource:Deer,continent:1},{position:{x:-3,y:8},baseTerrain:Coast},{position:{x:-4,y:7},baseTerrain:Ocean},{position:{x:-5,y:6},baseTerrain:Ocean},{militaryUnit:{owner:Singapore,originalOwner:Singapore,name:Privateer,promotions:{promotions:[Mobility,Coastal Raider I],numberOfPromotions:2}},position:{x:-6,y:5},baseTerrain:Ocean},{position:{x:-7,y:4},baseTerrain:Ocean},{position:{x:-8,y:3},baseTerrain:Coast},{militaryUnit:{owner:Singapore,originalOwner:Singapore,name:Spearman,promotions:{XP:18,numberOfPromotions:1}},position:{x:-9,y:2},baseTerrain:Plains,terrainFeatures:[Forest],resource:Deer,improvement:Camp,continent:3},{position:{x:-10,y:1},baseTerrain:Tundra,improvement:Farm,continent:3},{position:{x:-11},baseTerrain:Lakes},{position:{x:-12,y:-1},baseTerrain:Lakes}],startingLocations:[]},gameParameters:{gameSpeed:Quick,players:[{playerType:Human},{},{}],numberOfCityStates:4,religionEnabled:true,victoryTypes:[Domination,Scientific,Cultural]},turns:300,currentPlayer:England,currentTurnStartTime:1637770666921,gameId:13329e21-0b05-4ae5-8576-f5ae27bcaf0e} diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/StarryNight.jpg b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/StarryNight.jpg new file mode 100644 index 0000000000000..0b9e70fd804b4 Binary files /dev/null and b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/StarryNight.jpg differ diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/TurboRainbow.png b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/TurboRainbow.png new file mode 100644 index 0000000000000..78c91f947432b Binary files /dev/null and b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/TurboRainbow.png differ diff --git a/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/WheatField.jpg b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/WheatField.jpg new file mode 100644 index 0000000000000..85c32e92a5985 Binary files /dev/null and b/android/assets/scripting/enginefiles/python/unciv_scripting_examples/example_assets/WheatField.jpg differ diff --git a/android/assets/scripting/enginefiles/qjs/main.js b/android/assets/scripting/enginefiles/qjs/main.js new file mode 100644 index 0000000000000..9cb12819c77f9 --- /dev/null +++ b/android/assets/scripting/enginefiles/qjs/main.js @@ -0,0 +1,100 @@ +`The recommended use case of the JS backend is for modding. For user automation, debug, custom tools, prototyping, or experimentation, the Python backend may provide more features. + +However, JS is the only scripting backend planned to be supported on mobile platforms.` + + +function motd() { + return "\nThis backend is HIGHLY EXPERIMENTAL. It does not implement any API bindings yet, and it may not be stable. Use it at your own risk!\n\n" +} + + +// So... cashapp/zipline is clearly the best JS library to use for this (which in turn means that embedded QuickJS, and not Webview V8 or anything will indeed be the engine). +// Maybe LiquidCore? But I assume that's way heavier. + + +// QuickJS, like CPython, has a C API for native modules. But that probably shouldn't be used here. + + +` +Due to the basic design of Python, any Python environment that you lock down enough to be safe will also be nearly useless. + +So, I'm thinking that the two or three different backend languages can be specialized for different uses. + +With the lightweight nature and easy sandboxing of JS and Lua, plus the ready availability of JS engines on Android and the easy embedability of Lua, JS and Lua are well-suited for running mods. + +Python should be disabled for downloaded mods, I think. CPython's big and porous, PyPy has a sandbox but it's complicated, and MicroPython's just a smaller and less compatible reimplementation of CPython— At which point, you as well just use JS/Lua. + +Instead, CPython can be the favoured interpreter for developer tools and user script macros. Debug inspection, map editor tools, prototype features and research projects, player-written automation, etc. Because it wouldn't need to be sandboxed in these types of uses, this would let Python's massive library ecosystem and high extensibility shine. Numpy, Cython modules, C extensions and CTypes, PIL, Tensorflow, etc would all be possible to use, as would the user's filesystem and their own modules. + +So JS and Lua can be made highly portable/lightweight, and safely sandboxed to run mods. Meanwhile, CPython, if it's installed on the user's system, can be used as a richer scripting environment for developer/modder tools and user customization. +` + +function ContextManager() { +} +Object.assign(ContextManager.prototype, { + enter: function() { + return this; + }, + exit: function(exception) { + return false; + }, + withRun: function(callfunc) { + let value = this.enter(); + let error = null; + let result = undefined; + try { + result = callfunc(value); + } catch (e) { + error = e; + } + if (this.exit(error)) { + throw error; + } + return result; + } +}); + +function FakeStdOut() { +} +FakeStdOut.prototype = Object.assign(Object.create(ContextManager.prototype), { + enter: function() { + this.fakeout = [] + }, + exit: function() { + } +}); + +function makeScopeProxy() { + +} + +//let handlers={get: (target, prop, receiver) => prop == 'real' ? target : new Proxy([...target, prop], handlers)}; let p=new Proxy([], handlers) +// Chrome, Node, and SpiderMonkey can print this fine. QuickJS and Deno use inspection in their REPL. +// TODO: Implement JS bindings. +// Basically port over the subset of the Python behaviour that implements bindings. I don't want to bother with adding many richer features like autocompletion, though— And there's a benefit to that because the language bindings/library get copied over for every + +// https://stackoverflow.com/questions/9781285/specify-scope-for-eval-in-javascript +// https://www.figma.com/blog/how-we-built-the-figma-plugin-system/#attempt-3-realms +// https://stackoverflow.com/questions/37010237/android-how-to-use-isolatedprocess + +try { + while (false) { + let line = std.in.getline(); + if (line === null) { + // std.in.getline() returns null in case of error. So this check prevents it from eating 100% CPU in a loop, which could previously happen if you closed Unciv with the window button. + throw Error("Null on STDIN.") + } + let out = `qjs > ${line}\n`; + try { + out += String(eval(line)); + } catch (e) { + out += String(e) + } + out += "\n" + std.out.puts(out) + std.out.flush() + } +} catch (e) { +} finally { + //std.exit() +} diff --git a/android/assets/scripting/sharedfiles/ScriptAPI.json b/android/assets/scripting/sharedfiles/ScriptAPI.json new file mode 100644 index 0000000000000..3cb1e5bdb3159 --- /dev/null +++ b/android/assets/scripting/sharedfiles/ScriptAPI.json @@ -0,0 +1,4 @@ +[ + "//The idea is to generate lists of all available names, types, and parameters in the scripting API scope here, flat, nested, and of classes, using `ApiSpecGenerator.kt`. That way different scripting language implemenations of the API would be able to iterate through whatever structure makes the most sense for them in order to generate bindings. However, a quick count of the number of members of all classes traversable from `ScriptingScope` suggested something like 5,400 unique members that would have to be parametrized, so for the Python bindings I opted to dynamically generate the bindings using Python's magic methods and the reflective tools in `ScriptingProtocol` instead.." +] + diff --git a/android/assets/scripting/sharedfiles/ScriptAPIConstants.json b/android/assets/scripting/sharedfiles/ScriptAPIConstants.json new file mode 100644 index 0000000000000..cee3221fb4b3f --- /dev/null +++ b/android/assets/scripting/sharedfiles/ScriptAPIConstants.json @@ -0,0 +1,3 @@ +{ + "kotlinInstanceTokenPrefix": "_unciv-kt-obj@" +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 3ea8261147cdf..704271143f784 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -30,6 +30,10 @@ android { versionName = BuildConfig.appVersion base.archivesBaseName = "Unciv" + + multiDexEnabled = true + // As of the first attempts to build for Android with the scripting API, the project is too large to fit in one DEX file (71052 > 65536). + // Only needed with minSdk below 21: https://developer.android.com/studio/build/multidex } // necessary for Android Work lib @@ -125,4 +129,5 @@ dependencies { // run `./gradlew build --scan` to see details implementation("androidx.core:core-ktx:1.6.0") implementation("androidx.work:work-runtime-ktx:2.6.0") + implementation("androidx.multidex:multidex:2.0.1") } diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro index 1dfea15e6e299..f2bc3d885ee8e 100644 --- a/android/proguard-rules.pro +++ b/android/proguard-rules.pro @@ -31,3 +31,5 @@ -keepclassmembers class com.badlogic.gdx.backends.android.AndroidInput* { (com.badlogic.gdx.Application, android.content.Context, java.lang.Object, com.badlogic.gdx.backends.android.AndroidApplicationConfiguration); } + +# TODO: Probably need this: https://github.com/Kotlin/kotlinx.serialization/blob/master/README.md#android diff --git a/build.gradle.kts b/build.gradle.kts index d14e8f9371c2f..8c241ac777d1e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,10 +2,10 @@ import com.unciv.build.BuildConfig.gdxVersion import com.unciv.build.BuildConfig.roboVMVersion -// You'll still get kotlin-reflect-1.3.70.jar in your classpath, but will no longer be used -configurations.all { resolutionStrategy { - force("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") -} } +//// You'll still get kotlin-reflect-1.3.70.jar in your classpath, but will no longer be used +//configurations.all { resolutionStrategy { +// force("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") +//} } buildscript { @@ -31,12 +31,14 @@ buildscript { // This is for wrapping the .jar file into a standalone executable classpath("com.github.anuken:packr:-SNAPSHOT") + classpath(kotlin("serialization:${com.unciv.build.BuildConfig.kotlinVersion}")) } } - + allprojects { apply(plugin = "eclipse") apply(plugin = "idea") + apply(plugin = "kotlinx-serialization") version = "1.0.1" @@ -62,6 +64,8 @@ project(":desktop") { dependencies { "implementation"(project(":core")) + "implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") // Seem to need here as well as in `:core`, or else fails to find class def at run time. + "implementation"("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0") "implementation"("com.badlogicgames.gdx:gdx-backend-lwjgl3:${gdxVersion}") "implementation"("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop") @@ -82,6 +86,8 @@ project(":android") { dependencies { "implementation"(project(":core")) + "implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") + "implementation"("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0") "implementation"("com.badlogicgames.gdx:gdx-backend-android:$gdxVersion") natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a") natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a") @@ -96,6 +102,8 @@ project(":ios") { dependencies { "implementation"(project(":core")) + "implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") + "implementation"("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0") "implementation"("com.mobidevelop.robovm:robovm-rt:$roboVMVersion") "implementation"("com.mobidevelop.robovm:robovm-cocoatouch:$roboVMVersion") "implementation"("com.badlogicgames.gdx:gdx-backend-robovm:$gdxVersion") @@ -109,6 +117,8 @@ project(":core") { dependencies { "implementation"("com.badlogicgames.gdx:gdx:$gdxVersion") + "implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") // Used for scripting API backends. + "implementation"("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0") } @@ -133,6 +143,8 @@ project(":core") { "testImplementation"("com.badlogicgames.gdx:gdx-backend-headless:$gdxVersion") "testImplementation"("com.badlogicgames.gdx:gdx:$gdxVersion") "testImplementation"("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop") + + "implementation"(project(":desktop")) } } } diff --git a/core/Module.md b/core/Module.md new file mode 100644 index 0000000000000..cf5212d36fba9 --- /dev/null +++ b/core/Module.md @@ -0,0 +1,348 @@ +# Package com.unciv + +## Table of Contents + +* [Package `com.unciv.scripting`](#package-comuncivscripting) + * [Design Principles](#design-principles) + * [Class Overview](#class-overview) +* [Package `com.unciv.scripting.protocol`](#package-comuncivscriptingprotocol) + * [REPL Loop](#repl-loop) + * [IPC Protocol](#ipc-protocol) + * [Python Binding Implementation](#python-binding-implementation) + +# Package com.unciv.scripting + +## Design Principles + +**The Kotlin/JVM code should neither know nor care about the language running on the other end of its scripting API.** If a behaviour is specific to a particular language, then it's also too messy and complex to try to take special account for from the other side of an IPC channel. Instead, the complexity of each specific scripting language should be handled entirely within that language itself, such that the only thing exposed to the Kotlin code is a common interface built around structures that exist in most computer programming languages (like code strings, attributes, keys, calls, assignments, collections, etc). This not only keeps the scripting protocols and interfaces compatible with multiple backends; It also serves as a test that helps keep their design relatively clean and maintainable by forcing messy or complicated behaviours to be implemented in more appropriate places than the IPC interface. + +**Parts should be kept as modular and interchangeable as possible.** Each component type should have a somewhat well-defined job, and should not contain or be inseparably entwined with code that does things aside from that job. If a base class's primary role is to expose an REPL, for example, then extra features like command history or implementation details like running a subprocess can be moved into either another class or a subclass. If an interface's job is just to propagate code strings or resolve request packets into arbitrary Kotlin objects, then TODO: Finish this sentence. Again, IMO this both makes it easy to support versatile configurations and helps with keeping a reasonably neat codebase and architecture. + +**Different levels of execution/evaluation should not mix.** The IPC protocol defines the packet structures, types, and communication order that are for *implementing* scripting language semantics for accessing Kotlin/JVM data. Therefore, the IPC protocol should not itself become a *part of* scripting language semantics; No user/mod script in any language should ever have to manually create and send or receive and parse IPC packets. Certain API functions have been defined to provide additional capabilities that are *accessible through* scripting language semantics (in class ApiHelpers). Therefore, those functions should never be used in *implementing* scripting language semantics; No overloaded operator presented to a user script as part of the core Unciv API should ever implicitly call such a method as part of its basic functionality. The entrypoints for the scripting system have the roles of taking code strings (from user input, from mods, etc) and returning a result string (to print out, log, etc) (and possibly an exception Boolean flag). Therefore, they should never have to understand, or even be able to use, any data aside from opaque strings (such as IPC packets or structured return results). + +**In an API meant for dynamic scripting languages, dynamic behaviours are better than static ones.** The Unciv Kotlin codebase was around 60k lines when I started on this scripting API. By using reflection in the JVM and operator overloading in scripting languages, nearly all of the classes and structures defined in there can be mirrored directly in the scripting environment without having to write or maintain a single line of hardcoded API. Because API endpoints exposed in scripting languages are already all dynamically generated at runtime, when the class structure in Kotlin changes, the attributes and methods available from all scripting backends also immediately match the new Kotlin code without requiring any effort to update. + +**In the IPC mechanisms, the specification and architecture come before implementation.** IPC actions aren't statically checked like the Kotlin code is. They aren't even syntax-checked like Python code. I do have them spitting out exceptions showing the offending packets in both Kotlin and Python in cases of obvious desync, but even that's breakable. The only thing really keeping them working is simultaneous adherence on both ends to a common protocol, which is easier when the protocol is fairly simple. If implementing a particular syntax for a scripting language would require adding a new packet type or changing the REPL loop's control flow, consider whether the use case for it would be better served by adding an API-level helper function instead. (I.E. Consider adding something to ScriptingScope.ApiHelpers and letting scripts handle it themselves, instead of adding something to ScriptingProtocol and E.G. a magic method in wrapping.py.) + +## Class Overview + +The major classes involved in the scripting API are structured as follows. `UpperCamelCase()` and parentheses means a new instantiation of a class. `lowerCamelCase` means a reference to an already-existing instance. An asterisk at the start of an item means zero or multiple instances of that class may be held. A question mark at the start of an item means that it may not exist in all implementations of the parent base class/interface. A question mark at the end of an item means that it is nullable, or otherwise may not be available in all states. + +```JS +UncivGame(): + ScriptingState(): // Persistent per UncivGame(). + ScriptingScope(): + civInfo? // These are set by WorldScreen init, and unset by MainMenuScreen. + gameInfo? + uncivGame + worldScreen? + *ScriptingBackend(): + ScriptingScope + ?ScriptingReplManager(): + Blackbox() // Common interface to wrap foreign interpreter with pipes, STDIN/STDOUT, queues, sockets, embedding, JNI, etc. + ScriptingScope + ScriptingProtocol(): + ScriptingScope + ?folderHandler: setupInterpreterEnvironment() // If used, a temporary directory with file structure copied from engine and shared folders in `assets/scripting`. + ConsoleScreen(): // Persistent as long as window isn't resized. Recreates itself and restores most of its state from scriptingState if resized. + scriptingState +WorldScreen(): + consoleScreen + scriptingState // ScriptingState has getters and setters that wrap ScriptingScope, which WorldScreen uses to update game info. +MainMenuScreen(): + consoleScreen + scriptingState // Same as for worldScreen. +InstanceTokenizer() // Holds WeakRefs used by ScriptingProtocol. Unserializable objects get strings as placeholders, and then turned back into into objects if seen again. +Reflection() // Used by some hard-coded scripting backends, and essential to dynamic bindings in ScriptingProtocol(). +SourceManager() // Source of the folderHandler and setupInterpreterEnvironment() above. +TokenizingJson() // Serializer and functions that use InstanceTokenizer. +``` + + +# Package com.unciv.scripting.protocol + +## REPL Loop + +*Implemented by `class ScriptingProtocolReplManager(){}`.* + +1. A scripted action is initiated from the Kotlin side, by sending a command string to the script interpreter. + 1. While the script interpreter is running, it has a chance to request values from the Kotlin side by sending back packets encoding attribute/property, key, and call, and assignment stacks. + 2. When the Kotlin side receives a request for a value, it uses reflection to access the requested property, call the requested method, or assign to the requested property, and it sends the result to the script interpreter. No changes to gameInfo state should happen during this loop except for what is specifically requested by the running script. + 3. When the script interpreter finishes running, it sends a special packet to the Kotlin side communicating that the script interpreter has no more requests to make. The script interpreter then sends the REPL output of the command to the Kotlin side. +2. When the Kotlin interpreter receives the packet marking the end of the command run, it stops listening for value requests packets. It then receives the command result as the next value, and passes it back to the console screen or script handler. + +From Kotlin: +```Python +fun ExecuteCommand(command:String): + SendToInterpreter(command) + LockGameInfo() + while True: + packet:Packet = ReceiveFromInterpreter().parsed() + if isPropertyRequest(packet): + UnlockGameInfo() + response:Packet = ResolvePacket(ScriptingScope, packet) + LockGameInfo() + SendToInterpreter(response) + else if isCommandEndPacket(packet): + break + UnlockGameInfo() + PrintToConsole(ReceiveFromInterpreter().parsed().data:String) +``` + +The "packets" should probably all be encoded as strings, probably JSON. The technique used to connect the script interpreter to the Kotlin code shouldn't matter, as long as it's wrapped up in and implements the `Blackbox` interface. IPC/embedding based on pipes, STDIN/STDOUT, sockets, queues, embedding, JNI, etc. should all be interchangeable and equally functional. + +I'm not sure if there'd be much point to or a good technique for letting the script interpreter run constantly and initiate actions on its own, instead of waiting for commands from the Kotlin side. + +You would presumably have to interrupt the main Kotlin-side thread anyway in order to safely run any actions initiated by the script interpreter— Which means that you may as well just register a handler to call the script interpreter at that point. + +Plus, letting the script interpreter run completely in parallel would probably introduce potential for all sorts of issues with non-deterministic synchronicity, and performance issues Calling the script interpreter from the Kotlin side means that the state of the Kotlin side is more predictable at the moment of script execution. + +![This simple, thirty-step process is all it takes to execute a single scripted command…](/extraImages/ScriptingCallTrace.png) + +(There may be more calls to TokenizingJson than shown in the above diagram, because it's used as the universal serialization tool for encoding and decoding every packet, as well as the arbitrarily-structured JsonElement data in every packet.) + +## IPC Protocol + +*Implemented by `ScriptingProtocol.kt`, `ipc.py`, and `wrapping.py`.* + +A single IPC action consists of one request packet and one response packet.\ +A request packet should always be followed by a response packet if it has an action.\ +If a request packet has a null action, then it should not be followed by a response. This is to let flags be sent without generating useless responses. + +Responses do not have to be sent in the same order as their corresponding requests. New requests can be sent out while old ones are left "open"— E.G., if creating a response requires requesting new information. + +(So far, I think all the requests and responses follow a "stack" model, though. If request B is sent out before response A is received, then response B gets received before response A, like parentheses. The time two requests remain open can be completely nested subsets or supersets, or they can be completely separated in series, but they don't currently ever partially overlap.) + +(That said, none of these are hard requirements. If you want to do something fancy with coroutines or whatever and dispatch to multiple open request handlers, and you can make it both stable and language-agnostic, go right ahead.) + +Both the Kotlin side and the script interpreter can send and receive packets, but not necessarily at all times. + +(The current loop is described in the section above. Kotlin initiates a scripting exec, during which the script interpreter can request values from Kotlin, and at the end of which the script interpreter sends its STDOUT response to the Kotlin side.) + +A single packet is a JSON string of the form: + +*Implemented by `data class ScriptingPacket(){}` and `class ForeignPacket()`.* + +```JS +{ + "action": String?, + "identifier": String?, + "data": Any?, + "flags": Collection +} +``` + +Identifiers should be set to a unique value in each request.\ +Each response should have the same identifier as its corresponding request.\ +Upon receiving a response, both its action and identifier should be checked to match the relevant request. + +--- + +*Implemented by `object InstanceTokenizer{}` and `object TokenizingJson{}`.* + +The data field is allowed to represent any hierarchy, of instances of any types. + +If it must represent instances that are not possible or not useful to serialize as JSON hierarchies, then unique identifying token strings should be generated and sent in the places of those instances. + +If those strings are received at any hierarchical depth in the data field of any later packets, then they are to be substituted with their original instances in all uses of the information from those packets.\ +If the original instance of a received token string no longer exists, then an exception should be thrown, and handled as would any exception at the point where the instance is to be accessed. + +Example Kotlin-side instance requested by script interpreter: + +```Kotlin +SomeKotlinInstance@M3mAdDr +``` + +Example response packet to send this instance to the script interpreter: + +```JSON +{ + "action": "read_response", + "identifier": "ABC001", + "data": { + "value": "_someStringifiedTokenForSomeKotlinInstance", + "exception": null + }, + "flags": [] +} +``` + +Example subsequent request packet from script interpreter using the token string: + +```JSON +{ + "action": "assign", + "identifier": "CDE002", + "data": { + "path": [{"type": "Property", "name": "someProperty", "params": []}], + "value": [5, "ActualStringValue", "_someStringifiedTokenForSomeKotlinInstance"] + }, + "flags": [] +} +``` + +Equivalent Kotlin-side assignment operation resulting from this later request packet: + +```Kotlin +someProperty = listOf(5, "ActualStringValue", SomeKotlinInstance@M3mAdDr) +``` + +(Caveats in practice: The scripting API design is highly asymmetric in that script interpreter needs a lot of access to the Kotlin side's state, but the Kotlin side should rarely or never need the script interpreter's state, so the script interpreter doesn't have to bother implementing its own arbitrary object tokenization. Requests sent by the Kotlin side also all have very simple response formats because of this, while access to and use of complicated Kotlin-side instances is always initiated by a request from the script interpreter while in the execution loop of its REPL, so the Kotlin side bothers implementing arbitrary instance tokenization only when receiving requests and not when receiving responses. Exceptions from reifying invalid received tokens on the Kotlin side should be handled as would any other exceptions at their code paths, but because such tokens are only used on the Kotlin side when preparing a response to a received request from the scripting side, that currently means sending a response packet that is marked in some way as representing an exception and then carrying on as normal.) + +--- + +Some action types, data formats, and expected response types and data formats for packets sent from the Kotlin side to the script interpreter include: + +*Implemented by `class ScriptingProtocol(){}` and `class UncivReplTransceiver()`.* + + ``` + 'motd': null -> + 'motd_response': String + ``` + + ``` + 'autocomplete': {'command': String, 'cursorpos': Int} -> + 'autocomplete_response': Collection or String + //List of matches, or help text to print. + ``` + + ``` + 'exec': String -> + 'exec_response': String + //REPL print. + //Response may include 'Exception' flag. (Not implemented.) + ``` + + ``` + 'terminate': null -> + 'terminate_response': String? + //Error message or null. + ``` + +The above are basically a mirror of ScriptingBackend, so the same interface can be implemented in the scripting language. + +--- + +Some action types, data formats, and expected response types and data formats for packets sent from the script interpreter to the Kotlin side include: + +*Implemented by `class ScriptingProtocol(){}` and `class ForeignObject()`.* + + ``` + 'read': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'read_reponse': Any? or String + //Attribute/property access, by list of `PathElement` properties, relative to the root object if given or a default scope otherwise. + //The use_root and root fields are optional. + //Response must be String if sent with Exception flag. + ``` + + ``` + 'assign': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>, 'value': Any} -> + 'assign_response': String? + //Error message or null. + ``` + + ``` + 'delete': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'delete_response': String? + //Error message or null. + //Only meaningful and implemented for MutableMap() keys and MutableList() indices. + ``` + + ``` + 'dir': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'dir_response': Collection or String + //Names of all members/properties/attributes/methods. + //Response must be String if sent with Exception flag. + ``` + + ``` + //'hash': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + //'hash_response': Any? or String + //Response must be String if sent with Exception flag. + //Implemented, but removed. I'm not actually sure what the use of this would be. Hashes are usually just a shortcut for (in)equality, so I think a Kotlin-side equality or identity operator might be needed for this to be useful. + ``` + + ``` + 'keys': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'keys_response': Collection or String + //Response must be String if sent with Exception flag. + //Keys of Map-interfaced instances. Used by Python bindings for iteration and autocomplete. + ``` + + ``` + 'length': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'length_response': Int or String + //Response must be String if sent with Exception flag. + //Used by Python bindings for length and also for iteration. + ``` + + ``` + 'contains': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>, 'value': Any?} -> + 'contains_response': Boolean or String + //Doing this through an IPC call instead of in the script interpreter should let tokenized instances be checked for properly. + //Response must be String if sent with Exception flag. + ``` + + ``` + //'isiterable': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + //'isiterable_response': Boolean or String + //Response must be String if sent with Exception flag. + //Not implemented. Implement if needed. + ``` + + ``` + 'ismapping': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'isiterable_response': Boolean or String + //Response must be String if sent with Exception flag. + //Used by Python bindings to hide Python-emulating .values, .keys, and .entries to allow access to Kotlin objects when not a mapping. + ``` + + ``` + 'callable': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'callable_response': Boolean or String + //Response must be String if sent with Exception flag. + //Used by Python autocompleter to add opening bracket to methods and function suggestions. Quite useful for exploring API at a glance. + ``` + + ``` + 'args': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'args_response': Map>> or String + //Map of dispatchable signatures as strings to lists of pairs of names and types of arguments accepted by a function. + //Response must be String if sent with Exception flag. + //Currently just used by Python autocompleter to generate help text. + //Could also be used to control function signatures in scripting environment. If so, then names of types should be standardized. + ``` + + ``` + 'docstring': {'use_root' Boolean, 'root': Any?, 'path': List<{'type':String, 'name':String, 'params':List}>} -> + 'docstring_response': String or String + //Response must be String if sent with Exception flag. + //Used by Python wrappers and autocompleter to get help text showing arguments and types for callables. Useful for exploring API without having to browse code. + ``` + +The path elements in some of the data fields mirror PathElement. + +--- + +Flags are string values for communicating extra information that doesn't need a separate packet or response. Depending on the flag and action, they may be contextual to the packet, or they may not. I think I see them mostly as a way to semantically separate meta-communication about the protocol from actual requests for actions: + +*Implemented by `enum class KnownFlag(){}` and `class UncivReplTransceiver()`.* + + ``` + 'PassMic' + //Indicates that the sending side has no more requests to make, and that the receiving side should either send the next request or expect a response to an open request. + //Sent by Kotlin side at start of script engine startup/MOTD, autocompletion, and execution to allow script to request values, and should be sent by script interpreter immediately before sending response with results of execution. + ``` + + ``` + 'Exception' + //Indicates that this packet is associated with an error. + ``` + +--- + +Thus, at the IPC level, all foreign backends will actually use the same language, which is this JSON-based protocol. Differences between Python, JS, Lua, etc. will all be down to how they interpret the "exec", "autocomplete", and "motd" requests differently, and how they use and expose the Kotlin/JVM-access request types differently, which each high-level scripting language is free to implement as works best for it. + +## Python Binding Implementation + +A description of how this REPL loop and IPC protocol are used to build a scripting langauage binding [is at `/android/assets/scripting/enginefiles/python/PythonScripting.md`](../android/assets/scripting/enginefiles/python/PythonScripting.md). diff --git a/core/src/com/unciv/JsonParser.kt b/core/src/com/unciv/JsonParser.kt index 794e6f9b7dc9b..15d4e7e92b9d5 100644 --- a/core/src/com/unciv/JsonParser.kt +++ b/core/src/com/unciv/JsonParser.kt @@ -14,4 +14,8 @@ class JsonParser { val jsonText = file.readString(Charsets.UTF_8.name()) return json.fromJson(tClass, jsonText) } -} \ No newline at end of file + +// fun getFromJsonString(tClass: Class, jsonText: String): T { // Mostly for debug. +// return json.fromJson(tClass, jsonText) +// } +} diff --git a/core/src/com/unciv/MainMenuScreen.kt b/core/src/com/unciv/MainMenuScreen.kt index bbfca4afeb4b5..a51ddbb2cc9ef 100644 --- a/core/src/com/unciv/MainMenuScreen.kt +++ b/core/src/com/unciv/MainMenuScreen.kt @@ -17,6 +17,7 @@ import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.MultiplayerScreen import com.unciv.ui.mapeditor.* import com.unciv.models.metadata.GameSetupInfo +import com.unciv.ui.consolescreen.IConsoleScreenAccessible import com.unciv.ui.newgamescreen.NewGameScreen import com.unciv.ui.pickerscreens.ModManagementScreen import com.unciv.ui.saves.LoadGameScreen @@ -24,7 +25,7 @@ import com.unciv.ui.utils.* import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip import kotlin.concurrent.thread -class MainMenuScreen: BaseScreen() { +class MainMenuScreen: BaseScreen(), IConsoleScreenAccessible { private val autosave = "Autosave" private val backgroundTable = Table().apply { background=ImageGetter.getBackground(Color.WHITE) } private val singleColumn = isCrampedPortrait() @@ -55,7 +56,7 @@ class MainMenuScreen: BaseScreen() { keyPressDispatcher[key] = function table.addTooltip(key, 32f) } - + table.pack() return table } @@ -147,6 +148,10 @@ class MainMenuScreen: BaseScreen() { } ExitGamePopup(this) } + + setOpenConsoleScreenHotkey() + setConsoleScreenCloseAction() + updateScriptingState() } @@ -168,10 +173,10 @@ class MainMenuScreen: BaseScreen() { screen.game.setScreen(newMapScreen) screen.dispose() } - val newMapButton = screen.getMenuButton("New map", "OtherIcons/New", 'n', true, newMapAction) + val newMapButton = screen.getMenuButton("New map", "OtherIcons/New", 'n', true, newMapAction) newMapButton.background = tableBackground add(newMapButton).row() - keyPressDispatcher['n'] = newMapAction + keyPressDispatcher['n'] = newMapAction val loadMapAction = { val loadMapScreen = SaveAndLoadMapScreen(null, false, screen) @@ -179,7 +184,7 @@ class MainMenuScreen: BaseScreen() { screen.game.setScreen(loadMapScreen) screen.dispose() } - val loadMapButton = screen.getMenuButton("Load map", "OtherIcons/Load", 'l', true, loadMapAction) + val loadMapButton = screen.getMenuButton("Load map", "OtherIcons/Load", 'l', true, loadMapAction) loadMapButton.background = tableBackground add(loadMapButton).row() keyPressDispatcher['l'] = loadMapAction diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 3630d13f8c80e..2671e7ea16821 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -13,6 +13,10 @@ import com.unciv.models.metadata.GameSettings import com.unciv.models.ruleset.RulesetCache import com.unciv.models.tilesets.TileSetCache import com.unciv.models.translations.Translations +import com.unciv.scripting.ScriptingState +import com.unciv.scripting.api.ScriptingScope +import com.unciv.scripting.utils.ScriptingDebugParameters +import com.unciv.ui.consolescreen.ConsoleScreen import com.unciv.ui.LanguagePickerScreen import com.unciv.ui.audio.MusicController import com.unciv.ui.audio.MusicMood @@ -22,8 +26,6 @@ import com.unciv.ui.worldscreen.WorldScreen import java.util.* import kotlin.concurrent.thread - - class UncivGame(parameters: UncivGameParameters) : Game() { // we need this secondary constructor because Java code for iOS can't handle Kotlin lambda parameters constructor(version: String) : this(UncivGameParameters(version, null)) @@ -35,6 +37,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() { val consoleMode = parameters.consoleMode val customSaveLocationHelper = parameters.customSaveLocationHelper val limitOrientationsHelper = parameters.limitOrientationsHelper + val runScriptAndExit = parameters.runScriptAndExit + lateinit var gameInfo: GameInfo fun isGameInfoInitialized() = this::gameInfo.isInitialized @@ -50,11 +54,13 @@ class UncivGame(parameters: UncivGameParameters) : Game() { /** For when you need to test something in an advanced game and don't have time to faff around */ var superchargedForDebug = false + val scriptingParametersForDebug = ScriptingDebugParameters + /** Simulate until this turn on the first "Next turn" button press. * Does not update World View changes until finished. * Set to 0 to disable. */ - val simulateUntilTurnForDebug: Int = 0 + var simulateUntilTurnForDebug: Int = 0 /** Console log battles */ @@ -71,7 +77,14 @@ class UncivGame(parameters: UncivGameParameters) : Game() { val translations = Translations() + lateinit var consoleScreen: ConsoleScreen + // Keep same ConsoleScreen() when possible, to avoid having to manually persist/restore history, input field, etc. + + // Set of functions to call when disposing/closing the game. + val disposeCallbacks = HashSet<() -> Unit>() + override fun create() { + Gdx.input.setCatchKey(Input.Keys.BACK, true) if (Gdx.app.type != Application.ApplicationType.Desktop) { viewEntireMapForDebug = false @@ -129,6 +142,18 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } } crashController = CrashController.Impl(crashReportSender) + + createScripting() + + if (runScriptAndExit != null) { + val backend = ScriptingState.spawnBackend(runScriptAndExit.first).backend + val execResult = ScriptingState.exec( + command = runScriptAndExit.second, + withBackend = backend + ) + runScriptAndExit.third?.invoke(execResult) + Gdx.app.exit() + } } fun loadGame(gameInfo: GameInfo) { @@ -146,6 +171,12 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } } + private fun createScripting() { + ScriptingScope.uncivGame = this + + consoleScreen = ConsoleScreen { } + } + fun setScreen(screen: BaseScreen) { Gdx.input.inputProcessor = screen.stage super.setScreen(screen) @@ -158,6 +189,12 @@ class UncivGame(parameters: UncivGameParameters) : Game() { Gdx.graphics.requestRendering() } + fun setConsoleScreen() { + if (settings.enableScriptingConsole) { + consoleScreen.openConsole() + } + } + // This is ALWAYS called after create() on Android - google "Android life cycle" override fun resume() { super.resume() @@ -171,6 +208,10 @@ class UncivGame(parameters: UncivGameParameters) : Game() { override fun resize(width: Int, height: Int) { screen.resize(width, height) + if (screen !== consoleScreen) { + // consoleScreen is usually persistent, so it needs to be resized even if not active. + consoleScreen.resize(width, height) + } } override fun render() = wrappedCrashHandlingRender() @@ -196,6 +237,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } settings.save() + for (callback in disposeCallbacks) callback() + threadList.filter { it !== Thread.currentThread() && it.name != "DestroyJavaVM"}.forEach { println (" Thread ${it.name} still running in UncivGame.dispose().") } @@ -204,6 +247,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() { companion object { lateinit var Current: UncivGame fun isCurrentInitialized() = this::Current.isInitialized + // The main loop thread of the game, from which the OpenGL context is available and in which blocking actions can interrupt rendering. + val MainThread = Thread.currentThread() // Originaly thought I'd be clever and set this from a postRunnable for an explicit guarantee of getting the loop thread, but that does a NPE. } } diff --git a/core/src/com/unciv/UncivGameParameters.kt b/core/src/com/unciv/UncivGameParameters.kt index 9ee7153b36f0d..3ed44c518e13e 100644 --- a/core/src/com/unciv/UncivGameParameters.kt +++ b/core/src/com/unciv/UncivGameParameters.kt @@ -1,6 +1,8 @@ package com.unciv import com.unciv.logic.CustomSaveLocationHelper +import com.unciv.scripting.ExecResult +import com.unciv.scripting.ScriptingBackendType import com.unciv.ui.utils.CrashReportSender import com.unciv.ui.utils.LimitOrientationsHelper import com.unciv.ui.utils.NativeFontImplementation @@ -11,5 +13,6 @@ class UncivGameParameters(val version: String, val fontImplementation: NativeFontImplementation? = null, val consoleMode: Boolean = false, val customSaveLocationHelper: CustomSaveLocationHelper? = null, - val limitOrientationsHelper: LimitOrientationsHelper? = null + val limitOrientationsHelper: LimitOrientationsHelper? = null, + val runScriptAndExit: Triple Unit)?>? = null // TODO: Probably make exit optional? Or just make the scripts do it themselves. ) { } diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index 130bf5af7f5ab..8df37843bb32b 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -4,13 +4,20 @@ import com.badlogic.gdx.Application import com.badlogic.gdx.Gdx import com.unciv.UncivGame import com.unciv.logic.GameSaver +import com.unciv.scripting.ScriptingBackendType import java.text.Collator import java.util.* import kotlin.collections.HashSet +import kotlin.collections.LinkedHashMap data class WindowState (val width: Int = 900, val height: Int = 600) class GameSettings { + + companion object { + val scriptingConsoleStartupDefaults = ScriptingBackendType.values().associate { it.metadata.displayName to it.suggestedStartup } + } + var showWorkedTiles: Boolean = false var showResourcesAndImprovements: Boolean = true var showTileYields: Boolean = false @@ -53,6 +60,12 @@ class GameSettings { var showExperimentalWorldWrap = false // We're keeping this as a config due to ANR problems on Android phones for people who don't know what they're doing :/ + var enableScriptingConsole = false + var showScriptingConsoleWarning = true + var scriptingConsoleStartups = scriptingConsoleStartupDefaults.toMutableMap() + + var enableModScripting = false + var lastOverviewPage: String = "Cities" var allowAndroidPortrait = false // Opt-in to allow Unciv to follow a screen rotation to portrait @@ -144,3 +157,4 @@ enum class LocaleCode(var language: String, var country: String) { Ukrainian("uk", "UA"), Vietnamese("vi", "VN"), } + diff --git a/core/src/com/unciv/models/modscripting/ModScriptingDebugParameters.kt b/core/src/com/unciv/models/modscripting/ModScriptingDebugParameters.kt new file mode 100644 index 0000000000000..53eb4fad5beeb --- /dev/null +++ b/core/src/com/unciv/models/modscripting/ModScriptingDebugParameters.kt @@ -0,0 +1,11 @@ +package com.unciv.models.modscripting + +object ModScriptingDebugParameters { // Enum-ifying this could let tests iterate through them exhaustively. + // Whether to print when script handlers are triggered. + var printHandlerRun = true // Const val? Faster. But I like the idea of the scripting API itself being able to change these. // Bah. Premature optimization. There are far slower places to worry about speed. + // + var printHandlerRegister = false + var printModRead = false + var printModRegister = false + var typeCheckHandlerParamsAtRuntime = true +} diff --git a/core/src/com/unciv/models/modscripting/ModScriptingHandlerTypes.kt b/core/src/com/unciv/models/modscripting/ModScriptingHandlerTypes.kt new file mode 100644 index 0000000000000..bf70bfc26e026 --- /dev/null +++ b/core/src/com/unciv/models/modscripting/ModScriptingHandlerTypes.kt @@ -0,0 +1,1095 @@ +@file:Suppress("RemoveExplicitTypeArguments") + +package com.unciv.models.modscripting + +import com.unciv.MainMenuScreen +import com.unciv.UncivGame +import com.unciv.logic.GameInfo +import com.unciv.logic.civilization.CivilizationInfo +import com.unciv.scripting.utils.StatelessMap +import com.unciv.ui.AddMultiplayerGameScreen +import com.unciv.ui.EditMultiplayerGameInfoScreen +import com.unciv.ui.LanguagePickerScreen +import com.unciv.ui.MultiplayerScreen +import com.unciv.ui.cityscreen.* +import com.unciv.ui.civilopedia.CivilopediaScreen +import com.unciv.ui.consolescreen.ConsoleScreen +import com.unciv.ui.mapeditor.* +import com.unciv.ui.newgamescreen.* +import com.unciv.ui.overviewscreen.* +import com.unciv.ui.pickerscreens.* +import com.unciv.ui.saves.LoadGameScreen +import com.unciv.ui.saves.SaveGameScreen +import com.unciv.ui.tilegroups.CityButton +import com.unciv.ui.trade.DiplomacyScreen +import com.unciv.ui.trade.LeaderIntroTable +import com.unciv.ui.trade.OfferColumnsTable +import com.unciv.ui.trade.TradeTable +import com.unciv.ui.utils.* +import com.unciv.ui.victoryscreen.VictoryScreen +import com.unciv.ui.worldscreen.* +import com.unciv.ui.worldscreen.bottombar.BattleTable +import com.unciv.ui.worldscreen.bottombar.TileInfoTable +import com.unciv.ui.worldscreen.mainmenu.OptionsPopup +import com.unciv.ui.worldscreen.mainmenu.WorldScreenCommunityPopup +import com.unciv.ui.worldscreen.mainmenu.WorldScreenMenuPopup +import com.unciv.ui.worldscreen.unit.IdleUnitButton +import com.unciv.ui.worldscreen.unit.UnitActionsTable +import com.unciv.ui.worldscreen.unit.UnitTable +import kotlin.reflect.KType +//import kotlin.reflect.full.isSubtypeOf +//import kotlin.reflect.full.starProjectedType +import kotlin.reflect.typeOf + +// Dependency/feature stack: +// ModScriptingHandlerTypes —> ModScriptingRunManager —> ModScriptingRegistrationHandler +// com.unciv.scripting —> com.unciv.models.modscripting +// Later namespaces in each stack may use members and features from earlier namespaces, but earlier namespaces should have behaviour that is completely independent of any later items. +// The scripting execution model (com.unciv.scripting) only *runs* scripts— Anything to do with loading them should go in here (com.unciv.models.modscripting) instead. +// Likewise, ModScriptingHandlerTypes is only for defining handlerTypes, ModScriptingRunManager is only for using that during gameplay, and anything to do with parsing mod structures should go into the level of ModScriptingRegistrationHandler. + +// Code generators setup: $ function GetScreens() ( shopt -s globstar && (grep -ohP '(?(\n$(for default in ${@:2}; do echo "$tab//$tab HandlerId.$default,"; done)\n$tab// )"; ); function HandlerBoilerplates() (export tab=" "; export defaults=(after_open before_close after_rebuild); while read name; do echo -e "handlerContext(ContextId.$name) {\n${tab}addInstantiationHandlers<$name>()\n$(MakeSingles $name ${defaults[@]})\n}"; done) + +// TODO: Autogenerate handler type documentation from this, similarly to UniqueType documentation. + +val HANDLER_DEFINITIONS = handlerDefinitions { // TODO: Definitely put this in its own package to preserve the namespace. + handlerContext(ContextId.UncivGame) { + addInstantiationHandlers() + } + handlerContext(ContextId.GameInfo) { + handler>(HandlerId.after_instantiate){ + param("gameInfo") { it.first } + param("civInfo") { it.second } + } + handler>(HandlerId.before_discard) { + param("gameInfo") { it.first } + param("civInfo") { it.second } + } + addSingleParamHandlers( + HandlerId.after_open, + HandlerId.before_close + ) + } +// handlerContext(ContextId.ConsoleScreen) { +// addInstantiationHandlers() +// addSingleParamHandlers( +// HandlerId.after_open, +// HandlerId.before_close +// ) +// } + + + // Boilerplate generator: $ GetScreens | HandlerBoilerplates + handlerContext(ContextId.AddMultiplayerGameScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.BaseScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CivilopediaScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ConsoleScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.DiplomacyScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.DiplomaticVotePickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.DiplomaticVoteResultScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.EditMultiplayerGameInfoScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.EmpireOverviewScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.GameParametersScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.GreatPersonPickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ImprovementPickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.LanguagePickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.LoadGameScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MainMenuScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MapEditorScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ModManagementScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MultiplayerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.NewGameScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.NewMapScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.PantheonPickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.PickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.PlayerReadyScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.PolicyPickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.PromotionPickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ReligiousBeliefsPickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.SaveAndLoadMapScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.SaveGameScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TechPickerScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.VictoryScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.WorldScreen) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + + + // Boilerplate generator: $ GetPopups | HandlerBoilerplates + handlerContext(ContextId.AlertPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.AskNumberPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.AskTextPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ExitGamePopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MapEditorMainScreenPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MapEditorMenuPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MapEditorRulesetPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.OptionsPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.Popup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ToastPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TradePopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TradeThanksPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.WorldScreenCommunityPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.WorldScreenMenuPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.YesNoPopup) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + + + + // Boilerplate generator: $ (export names=(); (for name in ${names[@]}; do echo "$name"; done) | HandlerBoilerplates) + // Fill the parentheses inside names=() with a space-delimited list copied from HandlerId to use a Bash array. + + handlerContext(ContextId.BattleTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityButton) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityInfoTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityReligionInfoTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityScreenCityPickerTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityScreenTileTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.CityStatsTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ConstructionInfoTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.DiplomacyOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ExpanderTab) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.GameOptionsTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.IdleUnitButton) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.LanguageTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.LeaderIntroTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MapEditorOptionsTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MapOptionsTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MapParametersTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.Minimap) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.MinimapHolder) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ModCheckboxTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.NationTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.OfferColumnsTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.PlayerPickerTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ReligionOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.ResourcesOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.SpecialistAllocationTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.StatsOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TabbedPager) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TechButton) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TileInfoTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TradesOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.TradeTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.UncivSlider) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.UnitActionsTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.UnitOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.UnitTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.WonderOverviewTable) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + handlerContext(ContextId.WorldScreenTopBar) { + addInstantiationHandlers() + // addSingleParamHandlers( + // HandlerId.after_open, + // HandlerId.before_close, + // HandlerId.after_rebuild, + // ) + } + +} + + +private inline fun HandlerDefinitionsBuilderScope.HandlerContextBuilderScope + .addSingleParamHandlers(vararg handlerTypes: HandlerId, paramName: String? = null) +{ + for (handlerType in handlerTypes) { + handler(handlerType) { + param(paramName ?: V::class.simpleName!!.replaceFirstChar { it.lowercase()[0] }) + } + } +} + +private inline fun HandlerDefinitionsBuilderScope.HandlerContextBuilderScope + .addInstantiationHandlers(paramName: String? = null) +{ + addSingleParamHandlers(HandlerId.after_instantiate, HandlerId.before_discard, paramName = paramName) +} + +val ALL_HANDLER_TYPES = HANDLER_DEFINITIONS.handlerContexts.values.asSequence() + .map { it.handlerTypes.values } + .flatten().toSet() + + +// TODO: Unit test to make sure all contexts IDs are used, and have at least one handler type. + +// Enum of identifiers for a scripting handler context namespace. + +// Names map directly to and deserialize directly from JSON keys in mod files. +enum class ContextId { // These are mostly so autocompletion, typodetection, usage search, etc will work. + UncivGame, + GameInfo, + TileGroup, + + // Code generator: $ GetScreens | while read name; do echo "$name,"; done + AddMultiplayerGameScreen, + BaseScreen, + CityScreen, + CivilopediaScreen, + ConsoleScreen, + DiplomacyScreen, + DiplomaticVotePickerScreen, + DiplomaticVoteResultScreen, + EditMultiplayerGameInfoScreen, + EmpireOverviewScreen, + GameParametersScreen, + GreatPersonPickerScreen, + ImprovementPickerScreen, + LanguagePickerScreen, + LoadGameScreen, + MainMenuScreen, + MapEditorScreen, + ModManagementScreen, + MultiplayerScreen, + NewGameScreen, + NewMapScreen, + PantheonPickerScreen, + PickerScreen, + PlayerReadyScreen, + PolicyPickerScreen, + PromotionPickerScreen, + ReligiousBeliefsPickerScreen, + SaveAndLoadMapScreen, + SaveGameScreen, + TechPickerScreen, + VictoryScreen, + WorldScreen, + + + // Code generator: $ GetPopups | while read name; do echo "$name,"; done + AlertPopup, + AskNumberPopup, + AskTextPopup, + ExitGamePopup, + MapEditorMainScreenPopup, + MapEditorMenuPopup, + MapEditorRulesetPopup, + NationPickerPopup, + OptionsPopup, + PassPopup, + Popup, + ToastPopup, + TradePopup, + TradeThanksPopup, + WorldScreenCommunityPopup, + WorldScreenMenuPopup, + YesNoPopup, + + // From "Type Hierarchy" browser for Table in Android Studio: + BattleTable, + CityButton, + CityInfoTable, + CityOverviewTable, + CityReligionInfoTable, + CityScreenCityPickerTable, + CityScreenTileTable, + CityStatsTable, + ConstructionInfoTable, + DiplomacyOverviewTable, + ExpanderTab, + GameOptionsTable, + IconTable, + IdleUnitButton, + InfluenceTable, + LanguageTable, + LeaderIntroTable, + MapEditorOptionsTable, + MapOptionsTable, + MapParametersTable, + Minimap, + MinimapHolder, + ModCheckboxTable, + NationTable, + OfferColumnsTable, + PlayerPickerTable, + ReligionOverviewTable, + ResourcesOverviewTable, + SpecialistAllocationTable, + StatsOverviewTable, + TabbedPager, + TechButton, + TileInfoTable, + TradesOverviewTable, + TradeTable, + UncivSlider, + UnitActionsTable, + UnitOverviewTable, + UnitTable, + WonderOverviewTable, + WorldScreenTopBar, +} + +// Enum of identifiers for names of script handlers. + +// Each identifier can be used in more than one context namespace, but does not have to be implemented in all contexts, and may not be defined in a single context more than once. + +// Names map directly to and deserialize directly from JSON keys in mod files. +@Suppress("EnumEntryName", "SpellCheckingInspection") // Handler names are not Kotlin code, but more like JSON keys in mod configuration files. +enum class HandlerId { + // After creation and initialization of a new object with the same name as the context. + after_instantiate, + // Before destruction of each object with the same name as the context. Not guaranteed to be run, and not guaranteed to be run consistently even if run. + before_discard, + // After data represented by object is exposed as context to player, including if such happens multiple times. + after_open, + // After data represented by object is replaced as working context GUI to player, including if such happens multiple times. + before_close, + after_rebuild, + before_gamesave, + after_turnstart, + after_unitmove, + after_cityconstruction, + after_cityfound, + after_techfinished, + after_policyadopted, + before_turnend, + after_click, + after_modload, + before_modunload, + + after_update, // E.G. WorldMapHolder, drawing cirles and arrows. +} + + +typealias Params = Map? +typealias ParamGetter = (Any?) -> Params +// Some handlerTypes may have parameters that should be set universally; Others may use or simply pass on parameters from where they're called. + +typealias HandlerContex = Map +typealias HandlerDefs = Map + +interface test { + var a: HandlerDefs +} + +fun test(a: test){ + a.a +} + +interface HandlerDefinitions: StatelessMap { + val handlerContexts: Map + override fun get(key: ContextId): HandlerContext { + val handlerContext = handlerContexts[key] + if (handlerContext == null) { + val exc = NullPointerException("Unknown HandlerContext $key!") + println(exc.stringifyException()) + throw exc + } + return handlerContext + } + operator fun get(contextKey: ContextId, handlerKey: HandlerId) = get(contextKey).get(handlerKey) +} + +interface HandlerContext: StatelessMap { + val name: ContextId + val handlerTypes: Map + override fun get(key: HandlerId): HandlerType { + val handlerType = handlerTypes[key] + if (handlerType == null) { + val exc = NullPointerException("Unknown HandlerType $key for $name context!") + println(exc.stringifyException()) + throw exc + } + return handlerType + } +} + +interface HandlerType { + val name: HandlerId + val paramTypes: Map? + val paramGetter: ParamGetter +// fun checkParamsValid(checkParams: Params): Boolean { // If this becomes a major performance sink, it could be disabled in release builds. But +// if (paramTypes == null || checkParams == null) { +// return checkParams == paramTypes +// } +// if (paramTypes!!.keys != checkParams.keys) { +// return false +// } +// return checkParams.all { (k, v) -> +// if (v == null) { +// paramTypes!![k]!!.isMarkedNullable +// } else { +// //v::class.starProjectedType.isSubtypeOf(paramTypes!![k]!!) +// // In FunctionDispatcher I compare the .jvmErasure KClass instead of the erased type. +// // Right. Erased/star-projected types probably aren't subclasses of the argumented types from typeOf(). Could implement custom typeOfInstance that looks for most specific common element in allSuperTypes for collection contents, but sounds excessive and expensive. +// v::class.isSubclassOf(paramTypes!![k]!!.jvmErasure) +// } +// } +// } +} + + +@DslMarker +private annotation class HandlerBuilder + +// Type-safe builder function for the root hierarchy of all handler context and handler type definitions. + +// @return A two-depth nested Map-like HandlerDefinitions object, which indexes handler types first by ContextId and then by HandlerId. +private fun handlerDefinitions(init: HandlerDefinitionsBuilderScope.() -> Unit): HandlerDefinitions { + val unconfigured = HandlerDefinitionsBuilderScope() + unconfigured.init() + return object: HandlerDefinitions { + override val handlerContexts = unconfigured.handlerContexts.toMap() + } +} + +// Type-safe builder scope for the root hierarchy of all handler context and handler type definitions. +@HandlerBuilder +private class HandlerDefinitionsBuilderScope { + + // Type-safe builder method for a handler context namespace. + + // @param name The name of this context. + fun handlerContext(name: ContextId, init: HandlerContextBuilderScope.() -> Unit) { + val unconfigured = HandlerContextBuilderScope(name) + unconfigured.init() + handlerContexts[name] = object: HandlerContext { + override val name = unconfigured.name + override val handlerTypes = unconfigured.handlers.toMap() + } + } + + // Type-safe builder scope for a handler context namespace. + + // @param name Name of the context. + @HandlerBuilder + class HandlerContextBuilderScope(val name: ContextId) { + + // Type-safe builder method for a handler type. + + // @param V The type that may be provided as an argument when running this handler type. + // @param name The name of this handler type. + fun handler(name: HandlerId, init: HandlerTypeBuilderScope.() -> Unit) { + val unconfigured = HandlerTypeBuilderScope(name) + unconfigured.init() + handlers[name] = object: HandlerType { + override val name = name + val context = this@HandlerContextBuilderScope + override val paramTypes = unconfigured.paramTypes.toMap() + override val paramGetter: ParamGetter = { given: Any? -> unconfigured.paramGetters.entries.associate { it.key to it.value(given as V) } } + override fun toString() = "HandlerType:${context.name}/${name}" + } + } + + // Type-safe builder scope for a handler type. + + // @param V The type that may be provided as an argument when running this handler type. + // @param name Name of the handler type. + @HandlerBuilder + class HandlerTypeBuilderScope(val name: HandlerId) { + + // Type-safe builder method for a parameter that a handler type accepts and then sets in a Map accessible by the scripting API while the handler type is running. + + // @param R The type that this value is to be set to in the script's execution context. + // @param name The key of this parameter in the scripting-accessible Map. + // @param getter A function that returns the value of this parameter in the scripting-accessible Map, when given the argument passed to this handler type. + @OptIn(ExperimentalStdlibApi::class) + inline fun param(name: String, noinline getter: (V) -> R = { it as R }) { + paramTypes[name] = typeOf() + paramGetters[name] = getter + } + + val paramTypes = mutableMapOf() + val paramGetters = mutableMapOf Any?>() + + } + + val handlers = mutableMapOf() + + + } + + val handlerContexts = mutableMapOf() + +} diff --git a/core/src/com/unciv/models/modscripting/ModScriptingRegistrationManager.kt b/core/src/com/unciv/models/modscripting/ModScriptingRegistrationManager.kt new file mode 100644 index 0000000000000..8bdb7e453089c --- /dev/null +++ b/core/src/com/unciv/models/modscripting/ModScriptingRegistrationManager.kt @@ -0,0 +1,137 @@ +package com.unciv.models.modscripting + +import com.unciv.scripting.ScriptingBackendType + +// For organizing and associating script handlerTypes with specific mods. +// Uses ModScriptingRunManager and ModScriptingHandlerTypes. +object ModScriptingRegistrationManager { + + val activeMods = mutableSetOf() + + fun init() { +// registerMod( +// ScriptedModRules.fromJsonString( +// """ +// { +// "name": "Test Mod", +// "language": "pathcode", +// "handlers": { +// "ConsoleScreen": { +// "after_open": { +// "background": ["examples", "get apiExecutionContext.handlerParameters", "get apiExecutionContext.scriptingBackend", "faef"] +// } +// } +// } +// } +// """ +// ) +// ) +// registerMod( +// ScriptedModRules.fromJsonString( +// """ +// { +// "name": "Test Mod2", +// "language": "python", +// "handlers": { +// "ConsoleScreen": { +// "before_close": { +// "background": ["from unciv_scripting_examples.MapEditingMacros import *; (makeMandelbrot() if unciv.apiHelpers.isInGame else None)"], +// "foreground": ["from unciv_scripting_examples.EventPopup import *; (showEventPopup(**EVENT_POPUP_DEMOARGS()) if apiHelpers.isInGame else None)"] +// } +// } +// } +// } +// """ +// ) +// ) + registerMod( + ScriptedModRules.fromJsonString( + """ + { + "name": "Test ModA", + "language": "python", + "handlers": { + "ConsoleScreen": { + "after_open": { + "background": ["import time; time.sleep(1); 'Ab'", "import time; time.sleep(2); 'Ab'"], + "foreground": ["import time; time.sleep(1); 'Af'", "import time; time.sleep(2); 'Af'"] + } + } + } + } + """ + ) + ) + registerMod( + ScriptedModRules.fromJsonString( + """ + { + "name": "Test ModB", + "language": "python", + "handlers": { + "ConsoleScreen": { + "after_open": { + "background": ["import time; time.sleep(1); 'Bb'", "import time; time.sleep(2); 'Bb'"], + "foreground": ["import time; time.sleep(1); 'Bf'", "import time; time.sleep(2); 'Bf'"] + } + } + } + } + """ + ) + ) + } + + fun registerMod(modRules: ScriptedModRules) { + if (ModScriptingDebugParameters.printModRegister) { + println("Registering ${modRules.handlersByType.values.map { it.background.size + it.foreground.size }.sum() } script handlers for mod ${modRules.name}.") + } + if (modRules in activeMods) { + throw IllegalArgumentException("Mod ${modRules.name} is already registered.") + } + val backend = modRules.backend + if (backend != null) { + backend.apply { + userTerminable = false + displayNote = modRules.name + } + activeMods.add(modRules) + for ((handlerType, modHandlers) in modRules.handlersByType) { + for (command in modHandlers.background) { + ModScriptingRunManager.registerHandler( + handlerType, + RegisteredHandler( + backend, + command, + modRules, + mainThread = false + ) + ) + } + for (command in modHandlers.foreground) { + ModScriptingRunManager.registerHandler( + handlerType, + RegisteredHandler( + backend, + command, + modRules, + mainThread = true + ) + ) + } + } + } + } + + + fun unregisterMod(modRules: ScriptedModRules) { + } + // …Yeah. Keep language awareness only in the mod loader. The actual backend interfaces and classes don't have to understand anything other than "text in, result out". (But then again, I'm already keeping .engine in the companions of the EnvironmentedScriptingBackends, and use those to instantiate their libraries…) +} + +@Suppress("EnumEntryName") +enum class ModScriptingLanguage(val backendType: ScriptingBackendType) { + pathcode(ScriptingBackendType.Reflective), + python(ScriptingBackendType.SystemPython), + javascript(ScriptingBackendType.SystemQuickJS) +} diff --git a/core/src/com/unciv/models/modscripting/ModScriptingRunManager.kt b/core/src/com/unciv/models/modscripting/ModScriptingRunManager.kt new file mode 100644 index 0000000000000..4f71383fe6449 --- /dev/null +++ b/core/src/com/unciv/models/modscripting/ModScriptingRunManager.kt @@ -0,0 +1,125 @@ +package com.unciv.models.modscripting + +import com.badlogic.gdx.Gdx +import com.unciv.UncivGame +import com.unciv.scripting.ExecResult +import com.unciv.scripting.ScriptingBackend +import com.unciv.scripting.ScriptingState +import com.unciv.scripting.utils.ScriptingErrorHandling +import com.unciv.scripting.sync.ScriptingRunThreader +import com.unciv.scripting.sync.blockingConcurrentRun +import com.unciv.scripting.sync.makeScriptingRunName +import com.unciv.ui.utils.BaseScreen +import java.lang.IllegalArgumentException +import kotlin.concurrent.thread + + +data class RegisteredHandler(val backend: ScriptingBackend, val code: String, val modRules: ScriptedModRules?, val mainThread: Boolean = false) + +// For organizing and running script handlerTypes during gameplay. +// Uses ModScriptingHandlerTypes. +object ModScriptingRunManager { + + private val registeredHandlers = ALL_HANDLER_TYPES.associateWith { mutableSetOf() } // Let's specify that the registration and functions must maintain the order, so mods can run multiple interacting commands predictably. + + fun registerHandler(handlerType: HandlerType, registeredHandler: RegisteredHandler) { + if (ModScriptingDebugParameters.printHandlerRegister) { + println("Registering $handlerType handler:\n${registeredHandler.toString().prependIndent("\t")}") + } + val registry = registeredHandlers[handlerType]!! + if (registeredHandler in registry) { + throw IllegalArgumentException("$registeredHandler is already registered for $handlerType!") + } + registry.add(registeredHandler) + } + + fun unregisterHandler(handlerType: HandlerType, registeredHandler: RegisteredHandler) { + if (ModScriptingDebugParameters.printHandlerRegister) { + println("Unregistering $handlerType handler:\n${registeredHandler.toString().prependIndent("\t")}") + } + val registry = registeredHandlers[handlerType]!! + if (registeredHandler !in registry) { + throw IllegalArgumentException("$registeredHandler isn't registered for $handlerType!") + } + registry.remove(registeredHandler) + } + + fun getRegisteredForHandler(handlerType: HandlerType) = registeredHandlers[handlerType]!! + + fun lambdaRegisteredHandlerRunner(registeredHandler: RegisteredHandler, withParams: Params): () -> Unit { + val name = makeScriptingRunName(registeredHandler.modRules?.name, registeredHandler.backend) + val nakedRunnable = fun() { + val execResult: ExecResult? + try { + execResult = ScriptingState.exec( + command = registeredHandler.code, + asName = name, + withParams = withParams, + withBackend = registeredHandler.backend, + allowWait = true + ) + } catch(e: Throwable) { + ScriptingErrorHandling.notifyPlayerScriptFailure(exception = e, asName = name) + return + } + if (execResult.isException) { + ScriptingErrorHandling.notifyPlayerScriptFailure(text = execResult.resultPrint, asName = name) + } + } + return if (registeredHandler.mainThread) fun() { + blockingConcurrentRun(Gdx.app::postRunnable, nakedRunnable) + // Just running Gdx.app.postRunnable would bypass ScriptingRunThreader's sequential locking, I think. It still kinda works, because I put @Synchronized on a lot of things, but it seems too unpredictable/uncontrolled/unmaintainable to me, especially with lots of modded scripts. + } else + fun() { nakedRunnable() } + // Both scripts and the error reporting mechanism for scripts can involve UI widgets, which means OpenGL calls, which means crashes unless they're on the main thread. + // …I think the easiest way to avoid those while still letting scripts run in the background might be to let scripts specify a set of "background" and "foreground" commands per handler… Also the game will probably thread-lock if another run is attempted/waited for while the current one's been posted to and is waiting on the main thread, so I should move the lock acquisition to a worker thread too. —Except that postRunnable's done concurrently, so it might be fine? + } + + fun runHandler(handlerType: HandlerType, baseParams: Any?, after: () -> Unit = {}) { + val registeredHandlers = getRegisteredForHandler(handlerType) + if (ModScriptingDebugParameters.printHandlerRun) { + println("Running ${registeredHandlers.size} handlers for $handlerType with $baseParams.") + } + if (registeredHandlers.isNotEmpty()) { + val params = handlerType.paramGetter(baseParams) + if (ModScriptingDebugParameters.printHandlerRun) { + println("\tFinal parameters:\n\t\t${params?.map { "${it.key} = ${it.value}" }?.joinToString("\n\t\t") }") + } +// if (!handlerType.checkParamsValid(params)) { +// throw IllegalStateException( +// """ +// Incorrect parameter signature for running mod script handlerTypes: +// handlerType = ${handlerType.name} +// handlerType.paramTypes = ${handlerType.paramTypes} +// baseParams = $baseParams +// params = $params +// """.trimIndent() +// ) +// } + ScriptingRunThreader.queueRuns( + registeredHandlers.asSequence().map { lambdaRegisteredHandlerRunner(it, params) } + ) + lockGame() + ScriptingRunThreader.queueRun { Gdx.app.postRunnable(::unlockGame) } + ScriptingRunThreader.queueRun { after() } + thread { // A locking operation. I have the thought that calling from a short-lived thread will be more resilient to threadlocking in the long term. Consider: Main thread waits on the lock. But then releasing lock ends up getting posted to main thread, so lock won't be released ever, as main thread needs to finish waiting on it before it can execute the release runnable. + ScriptingRunThreader.doRuns() + } + } else { + after() + } + } + + fun queueRuns() { + } + + private fun lockGame() { + //isPlayersTurn. + Gdx.input.inputProcessor = null + } + private fun unlockGame() { + Gdx.input.inputProcessor = (UncivGame.Current.screen as BaseScreen).stage + } +} + + diff --git a/core/src/com/unciv/models/modscripting/ScriptedModRules.kt b/core/src/com/unciv/models/modscripting/ScriptedModRules.kt new file mode 100644 index 0000000000000..3c72798593726 --- /dev/null +++ b/core/src/com/unciv/models/modscripting/ScriptedModRules.kt @@ -0,0 +1,119 @@ +package com.unciv.models.modscripting + +import com.badlogic.gdx.utils.Json +import com.badlogic.gdx.utils.JsonReader +import com.badlogic.gdx.utils.JsonValue +import com.unciv.scripting.ScriptingState + + +// TODO: Make sure DropBox multiplayer doesn't provide quick and easy arbitrary code execution for would-be attackers? + +class ScriptedModLoadable(val modRules: ScriptedModRules) { + +} + +class ScriptedModRules {// See, try to follow conventions of, TilesetAndMod and TilesetCache/ModOptions, maybe? + + // Getting this deserializing from JSON was a humongous, humongous pain. GDX seems to take only three levels of nested mappings before it stops knowing what to do with the arrays at the deepest level. I could have just used KotlinX instead as I'm bringing in the dependency anyway, but for some reason I seem to want to align the parts of the scripting API that interact with the existing codebase with the tools that are already used. + + // Recommend switching to KotlinX if anyone else decides to change this. See: ScriptingPacket, List, .decodeFromJsonElement, TokenizingJson— It's *so* easy (once you have it set up). + + companion object { + fun fromJsonString(jsonText: String): ScriptedModRules { + val mod = ScriptedModRules() + val jsonValue = JsonReader().parse(jsonText) + val json = Json().apply { setEnumNames(true) } + mod.apply { + name = jsonValue.getString("name") ?: "?" + language = json.fromJson(ModScriptingLanguage::class.java, jsonValue.getString("language")) + handlers = ScriptedModHandlerRoot.fromJson(jsonValue.get("handlers")) + } + return mod + } + } + + var name = "" + var language: ModScriptingLanguage? = null + var handlers = ScriptedModHandlerRoot() + + val backend by lazy { // TODO: This should be discarded when unloading. + val backendType = language?.backendType + if (backendType == null) + null + else + ScriptingState.spawnBackend(backendType).backend + } + + val handlersByType by lazy { + val flatmap = mutableMapOf() +// if (handlers == null) { +// return@lazy flatmap.toMap() +// } + for ((contextId, contextHandlers) in handlers.entries) { + for ((handlerId, handlerSet) in contextHandlers) { + val handlerType = HANDLER_DEFINITIONS[ContextId.valueOf(contextId), HandlerId.valueOf(handlerId)] + if (handlerType in flatmap) { + throw IllegalArgumentException("Handler type $handlerType defined more than once in mod $name!") + } + flatmap[handlerType] = handlerSet + } + } + return@lazy flatmap.toMap() + } +} + + + +class ScriptedModHandlerRoot: HashMap() { + companion object { + fun fromJson(jsonValue: JsonValue?): ScriptedModHandlerRoot { + val root = ScriptedModHandlerRoot() + if (jsonValue == null) { + return root + } + for (context in jsonValue) { + root[context.name] = ScriptedModHandlerContext.fromJson(context) + } + return root + } + } +} + +class ScriptedModHandlerContext: HashMap() { + companion object { + fun fromJson(jsonValue: JsonValue?): ScriptedModHandlerContext { + val context = ScriptedModHandlerContext() + if (jsonValue == null) { + return context + } + for (handler in jsonValue) { + context[handler.name] = ScriptedModHandlerSet.fromJson(handler) + } + return context + } + } +} + +class ScriptedModHandlerSet { + + companion object { + fun fromJson(jsonValue: JsonValue?): ScriptedModHandlerSet { + val handlerSet = ScriptedModHandlerSet() + if (jsonValue == null) { + return handlerSet + } + handlerSet.apply { + background = jsonValue.get("background")?.asStringArray()?.toList() ?: listOf() + foreground = jsonValue.get("foreground")?.asStringArray()?.toList() ?: listOf() + } + return handlerSet + } + } + + var background = listOf() // Run first, in worker threads. + + var foreground = listOf() // Run after, in main thread. UI-affecting + +} + +// I mean, seriously. It's over 100 lines with poorly documented GDX functions just to deserialize three properties. Maybe there's an easier way… Rewrite it please if you know what that is. diff --git a/core/src/com/unciv/models/modscripting/handlertypes/HandlerDefinitionsBuilders.kt b/core/src/com/unciv/models/modscripting/handlertypes/HandlerDefinitionsBuilders.kt new file mode 100644 index 0000000000000..73e24afd6203f --- /dev/null +++ b/core/src/com/unciv/models/modscripting/handlertypes/HandlerDefinitionsBuilders.kt @@ -0,0 +1,2 @@ +package com.unciv.models.modscripting.handlertypes + diff --git a/core/src/com/unciv/models/modscripting/handlertypes/HandlerIdentifiers.kt b/core/src/com/unciv/models/modscripting/handlertypes/HandlerIdentifiers.kt new file mode 100644 index 0000000000000..73e24afd6203f --- /dev/null +++ b/core/src/com/unciv/models/modscripting/handlertypes/HandlerIdentifiers.kt @@ -0,0 +1,2 @@ +package com.unciv.models.modscripting.handlertypes + diff --git a/core/src/com/unciv/models/modscripting/handlertypes/HandlerType.kt b/core/src/com/unciv/models/modscripting/handlertypes/HandlerType.kt new file mode 100644 index 0000000000000..73e24afd6203f --- /dev/null +++ b/core/src/com/unciv/models/modscripting/handlertypes/HandlerType.kt @@ -0,0 +1,2 @@ +package com.unciv.models.modscripting.handlertypes + diff --git a/core/src/com/unciv/scripting/LICENSE b/core/src/com/unciv/scripting/LICENSE new file mode 100644 index 0000000000000..9c59f04b1059b --- /dev/null +++ b/core/src/com/unciv/scripting/LICENSE @@ -0,0 +1 @@ +Copyright 2021 will-ca. All rights reserved (for now). diff --git a/core/src/com/unciv/scripting/ScriptingBackend.kt b/core/src/com/unciv/scripting/ScriptingBackend.kt new file mode 100644 index 0000000000000..27d71d2afba20 --- /dev/null +++ b/core/src/com/unciv/scripting/ScriptingBackend.kt @@ -0,0 +1,736 @@ +package com.unciv.scripting + +//import com.badlogic.gdx.Gdx +import com.badlogic.gdx.files.FileHandle +import com.unciv.UncivGame +import com.unciv.scripting.api.ScriptingScope +import com.unciv.scripting.reflection.Reflection +import com.unciv.scripting.protocol.Blackbox +import com.unciv.scripting.protocol.ScriptingReplManager +import com.unciv.scripting.protocol.ScriptingProtocolReplManager +import com.unciv.scripting.protocol.ScriptingRawReplManager +import com.unciv.scripting.protocol.SubprocessBlackbox +import com.unciv.scripting.utils.ApiSpecGenerator +import com.unciv.scripting.utils.ScriptingDebugParameters +import com.unciv.scripting.utils.SourceManager +import com.unciv.scripting.utils.SyntaxHighlighter +import kotlin.reflect.full.companionObjectInstance +//import java.util.* + + +/** + * Data class representing an autocompletion result. + * + * @property matches List of valid matches. Each match should be a full input string after applying autocompletion (I.E. Don't truncate to the cursor position). + * @property helpText String to print out instead of showing autocomplete matches. + */ +data class AutocompleteResults(val matches: List = listOf(), val helpText: String? = null) + + +// Data class representing the result of executing a command or script in an opaque ScriptingImplementation/ScriptingBackend. + +// @property resultPrint Unstructured output text of command. Analogous to STDOUT, or STDERR if isException is set. +// @property isException Whether the resultPrint represents an uncaught exception. Should only be used for errors that occur inside of a ScriptingImplementation/ScriptingBackend; For errors that occur in Kotlin code outside of a running ScriptingImplementation/ScriptingBackend, an Exception() should be thrown as usual. +data class ExecResult(val resultPrint: String, val isException: Boolean = false) + + +/** + * Base class for required companion objects of ScriptingBackend implementations. + * + * Subtypes (or specifically, companions of subtypes) of ScriptingBackend are organized in an Enum. + * Companion objects allow new instances of the correct subtype to be created directly from the Enum constants. + */ +abstract class ScriptingBackend_metadata { + /** + * @return A new instance of the parent class of which this object is a companion. + */ + abstract fun new(): ScriptingBackend // TODO: Um, class references are totally a thing, and probably distinct from KClass, right? + abstract val displayName: String // TODO: Translations on all these? + val syntaxHighlighting: SyntaxHighlighter? = null +} + +abstract class EnvironmentedScriptBackend_metadata: ScriptingBackend_metadata() { + abstract val engine: String + // Why did I put this here? There was probably a reason, because it was a lot of trouble. +} + + +/** + * Interface for a single object that parses, interprets, and executes scripts. + */ +interface ScriptingImplementation { + + /** + * @return Message to print on launch. Should be called exactly once per instance, and prior to calling any of the other methods defined here. + */ + fun motd(): String { + return "\n\nWelcome to the Unciv CLI!\nYou are currently running the dummy backend, which will echo all commands but never do anything.\n" + } + + /** + * @param command Current input to run autocomplete on. + * @param cursorPos Active cursor position in the current command input. + * @return AutocompleteResults object that represents either a List of full autocompletion matches or a help string to print for the current input. + */ + fun autocomplete(command: String, cursorPos: Int? = null): AutocompleteResults { + return AutocompleteResults(listOf(command+"_autocomplete")) + } + + /** + * @param command Code to execute + * @return REPL printout. + */ + fun exec(command: String): ExecResult { + return ExecResult(command) + } + + /** + * @return null on successful termination, an Exception() otherwise. + */ + fun terminate(): Exception? { + // The same reasoning for returning instead of throwing an Exception() as for Blackbox.stop() also applies here. + // Namely: Some errors may be expected, and thus not exceptional enough to warrant exceptional flow control via try/catch. Let those be propagated and handled or ignored more easily as return values. + // This protects the distinction between errors that are an expected and predictable part of normal operation (Network down, zombie process, etc), which don't cause executed code blocks to be broken out of at arbitrary points, and legitimately exceptional runtime errors where the program is doing something it shouldn't be, which break normal control flow by breaking out of code blocks mid-execution. + // So if an exception is raised, it can be caught and turned into a return value at the point where it happens, without having to wrap a try{} block around the entire call stack between there and the point where it's eventually handled. + return null + } + +} + +// TODO: Add .userTerminable flag and per-instance display string to ScriptingBackendBase. Let mod command histories be seen on ConsoleScreen? + +// TODO: Add note that methods should be called through ScriptingState, rather than directly. + +open class ScriptingBackend: ScriptingImplementation { + /** + * For the UI, a way is needed to list all available scripting backend types with 1. A readable display name and 2. A way to create new instances. + * + * So every ScriptngBackend has a Metadata:ScriptingBackend_metadata companion object, which is stored in the ScriptingBackendType enums. + */ + companion object Metadata: ScriptingBackend_metadata() { + override fun new() = ScriptingBackend() + // Trying to instantiate from a KClass seems messy when the constructors are expected to be called normally. This is easier. + override val displayName = "Dummy" + } + + /** + * Let the companion object of the correct subclass be accessed in subclass instances. + * + * Any access to the companion from instance methods should be done using this property, or else the companion object accessed will be the one from where instances was declared. + */ + open val metadata + get() = this::class.companionObjectInstance as ScriptingBackend_metadata + + // Flag marking whether or not the user should be allowed to manually terminate this backend. + // Meant to be set externally. + var userTerminable = true + // Optional short text conveying further information to show the user alongside the displayName. + // Meant to be set externally. + var displayNote: String? = null + +} + +//Test, reference, example, and backup + +//Has +// Non-essential. Nothing should depend on this. Should always be removable from the game's code. +class HardcodedScriptingBackend(): ScriptingBackend() { + companion object Metadata: ScriptingBackend_metadata() { + override fun new() = HardcodedScriptingBackend() + override val displayName = "Hardcoded" + } + + val commandshelp = mapOf( + "help" to "help - Display all commands\nhelp - Display information on a specific command.", + "countcities" to "countcities - Print out a numerical count of all cities in the current empire.", + "listcities" to "listcities - Print the names of all cities in the current empire.", + "locatebuildings" to "locatebuildings - Print out a list of all cities that have a given building.\nlocatebuildings - Print out a list of all cities that are using a given resource.", + "missingbuildings" to "missingbuildings - Print out a list of all cities that do not have a given building.", + "cheatson" to "cheatson - Enable commands that break game rules.", + "cheatsoff" to "cheatsoff - Disable commands that break game rules.", + "godmode" to "godmode [true|false] - Ignore many game rule restrictions. Allows instant purchase of tech, policies, buildings, tiles, and more.\n\tRun with no arguments to toggle. (Requires cheats.)", + "godview" to "godview [true|false] - Make the entire map visible.\n\tRun with no arguments to toggle. (Requires cheats.)", + "inspectpath" to "inspectpath - Read out the value of the Kotlin object at a given .\n\tThe path can be a string representing any combination of property accesses, map keys, array indexes, and method calls.\ninspectpath detailed - Also print out the class name and members of the object at the given path.", + "setpath" to "setpath - Set the Kotlin property at a given to a given .\n\tThe can be a string representing any combination of property accesses, map keys, array indexes, and method calls.\n\tThe value will be resolved the same was as the path, but will be delineated by the first space character after its start.", + "simulatetoturn" to "simulatetoturn - After this turn, automatically play until the turn specified by .\n\tMap view will be frozen while simulating. (Requires cheats.)", + "spawnbuilding" to "", + "spawnunit" to "", + "supercharge" to "supercharge [true|false] - Massively boost all empire growth stats.\n\tRun with no arguments to toggle. (Requires cheats.)" + ) + + var cheats: Boolean = false + + override fun motd(): String { + return "\n\nWelcome to the hardcoded demo CLI backend.\n\nPlease run \"help\" or press [TAB] to see a list of available commands.\nPress [TAB] at any time to see help for currently typed command.\n\nPlease note that the available commands are meant as a DEMO for the CLI.\n" + } + + fun getCommandHelpText(command: String): String { + if (command in commandshelp) { + return "\n${commandshelp[command]}" + } else { + return "\nNo help entry found for command '${command}'" + } + } + + override fun autocomplete(command: String, cursorPos: Int?): AutocompleteResults{ + return if (' ' in command) { + AutocompleteResults(helpText = getCommandHelpText(command.split(' ')[0])) + } else { + AutocompleteResults(commandshelp.keys.filter({ c -> c.startsWith(command) }).map({ c -> c + " " })) + } + } + + override fun exec(command: String): ExecResult { + var args = command.split(' ') + var out = "\n> ${command}\n" + fun appendOut(text: String) { + out += (text + "\n").prependIndent(" ") + } + when (args[0]) { + "help" -> { + if (args.size > 1) { + appendOut(getCommandHelpText(args[1])) + } else { + appendOut(commandshelp.keys.joinToString(", ")) + } + } + "countcities" -> { + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + appendOut(ScriptingScope.civInfo!!.cities.size.toString()) + } + "locatebuildings" -> { + var buildingcities: List = listOf() + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + if (args.size > 1) { + var searchfor = args.slice(1..args.size-1).joinToString(" ").trim(' ') + buildingcities = ScriptingScope.civInfo!!.cities + .filter { + searchfor in it.cityConstructions.builtBuildings || + it.cityConstructions.builtBuildings.any({ building -> + ScriptingScope.gameInfo!!.ruleSet.buildings[building]!!.requiresResource(searchfor) + }) + } + .map { it.name } + } + appendOut(buildingcities.joinToString(", ")) + } + "missingbuildings" -> { + var buildingcities: List = listOf() + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + if (args.size > 1) { + var searchfor = args.slice(1..args.size-1).joinToString(" ").trim(' ') + buildingcities = ScriptingScope.civInfo!!.cities + .filter { !(searchfor in it.cityConstructions.builtBuildings) } + .map { it.name } + } + appendOut(buildingcities.joinToString(", ")) + } + "listcities" -> { + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + appendOut(ScriptingScope.civInfo!!.cities + .map { city -> city.name } + .joinToString(", ") + ) + } + "cheatson" -> { + cheats = true + appendOut("Cheats enabled.") + } + "cheatsoff" -> { + cheats = false + appendOut("Cheats disabled.") + } + "godmode" -> { + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + if (cheats) { + var godmode = if (args.size > 1) args[1].toBoolean() else !(ScriptingScope.gameInfo!!.gameParameters.godMode) + ScriptingScope.gameInfo!!.gameParameters.godMode = godmode + appendOut("${if (godmode) "Enabled" else "Disabled"} godmode.") + } else { + appendOut("Cheats must be enabled to use this command!") + } + } + "godview" -> { + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + if (cheats) { + var godview = if (args.size > 1) args[1].toBoolean() else !(ScriptingScope.uncivGame!!.viewEntireMapForDebug) + ScriptingScope.uncivGame!!.viewEntireMapForDebug = godview + appendOut("${if (godview) "Enabled" else "Disabled"} whole map visibility.") + } else { + appendOut("Cheats must be enabled to use this command!") + } + } + "inspectpath" -> { + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + if (cheats) { + val detailed = args.size > 1 && args[1] == "detailed" + val startindex = if (detailed) 2 else 1 + val path = (if (args.size > startindex) args.slice(startindex..args.size-1) else listOf()).joinToString(" ") + try { + var obj = Reflection.evalKotlinString(ScriptingScope, path) + val isnull = obj == null + appendOut( + if (detailed) + "Type: ${if (isnull) null else obj!!::class.qualifiedName}\n\nValue: ${obj}\n\nMembers: ${if (isnull) null else obj!!::class.members.map {it.name}}\n" + else + "${obj}" + ) + } catch (e: Exception) { + appendOut("Error accessing: ${e}") + } + } else { + appendOut("Cheats must be enabled to use this command!") + } + } + "setpath" -> { + if (cheats) { + try { + val path = (if (args.size > 2) args.slice(2..args.size-1) else listOf()).joinToString(" ") + val value = Reflection.evalKotlinString(ScriptingScope, args[1]) + Reflection.setInstancePath( + ScriptingScope, + Reflection.parseKotlinPath(path), + value + ) + appendOut("Set ${path} to ${value}.") + } catch (e: Exception) { + appendOut("Error setting: ${e}") + } + } else { + appendOut("Cheats must be enabled to use this command!") + } + } + "simulatetoturn" -> { + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + if (cheats) { + var numturn = 0 + if (args.size > 1) { + try { + numturn = args[1].toInt() + } catch (e: NumberFormatException) { + appendOut("Invalid number: ${args[1]}\n") + } + } + ScriptingScope.uncivGame!!.simulateUntilTurnForDebug = numturn + appendOut("Will automatically simulate game until turn ${numturn} the next time you press Next Turn.\nThe map will not update until completed.") + } else { + appendOut("Cheats must be enabled to use this command!") + } + } + "supercharge" -> { + if (!(ScriptingScope.apiHelpers.isInGame)) { appendOut("Must be in-game for this command!"); return ExecResult(out) } + if (cheats) { + var supercharge = if (args.size > 1) args[1].toBoolean() else !(ScriptingScope.uncivGame!!.superchargedForDebug) + ScriptingScope.uncivGame!!.superchargedForDebug = supercharge + appendOut("${if (supercharge) "Enabled" else "Disabled"} stats supercharge.") + } else { + appendOut("Cheats must be enabled to use this command!") + } + } else -> { + appendOut("The command ${args[0]} is either not known or not implemented.") + } + } + return ExecResult(out) + //return "\n> ${command}\n${out}" + } +} + + +//Nothing should depend on this. +class ReflectiveScriptingBackend(): ScriptingBackend() { + + companion object Metadata: ScriptingBackend_metadata() { + override fun new() = ReflectiveScriptingBackend() + override val displayName = "Reflective" + } + + private val commandparams = mapOf("get" to 1, "set" to 2, "typeof" to 1, "examples" to 0, "runtests" to 0) //showprivates? + private val examples = listOf( // The new splitToplevelExprs means set can safely use equals sign for assignment. + "get uncivGame.loadGame(Unciv.GameStarter.startNewGame(apiHelpers.Jvm.companionByQualClass[\"com.unciv.models.metadata.GameSetupInfo\"].fromSettings(\"Chieftain\")))", + "get gameInfo.civilizations[1].policies.adoptedPolicies", + "set civInfo.tech.freeTechs = 5", +// "set civInfo.cities[0].health = 1", // Doesn't work as test due to new game, no city. + "set gameInfo.turns = 5", + "get civInfo.addGold(1337)", + "get civInfo.addNotification(\"Here's a notification!\", apiHelpers.Jvm.arrayOfTyped1(\"StatIcons/Resistance\"))", + "set worldScreen.bottomUnitTable.selectedUnit.promotions.XP = 2000", +// "get worldScreen.bottomUnitTable.selectedCity.population.setPopulation(25)", // Doesn't work as test due to new game, no city. + "set worldScreen.mapHolder.selectedTile.resource = \"Cattle\"", + "set worldScreen.mapHolder.selectedTile.naturalWonder = \"Krakatoa\"", + "get apiHelpers.Jvm.constructorByQualname[\"com.unciv.ui.worldscreen.AlertPopup\"](worldScreen, apiHelpers.Jvm.constructorByQualname[\"com.unciv.logic.civilization.PopupAlert\"](apiHelpers.Jvm.enumMapsByQualname[\"com.unciv.logic.civilization.AlertType\"][\"StartIntro\"], \"Text text.\")).open(false)", + "get civInfo.addGold(civInfo.tech.techsResearched.size)", + //"get uncivGame.setScreen(apiHelpers.Jvm.constructorByQualname[\"com.unciv.ui.mapeditor.MapEditorScreen\"](gameInfo.tileMap))", + // FIXME: This was working, but now hits an uinitialized .ruleset in the screen constructor. + // Still works in the .JAR. + "get apiHelpers.Jvm.constructorByQualname[\"com.unciv.ui.utils.ToastPopup\"](\"This is a popup!\", apiHelpers.Jvm.companionByQualClass[\"com.unciv.UncivGame\"].Current.getScreen(), 2000)", + "get apiHelpers.Jvm.singletonByQualname[\"com.unciv.ui.utils.Fonts\"].turn", + "get apiHelpers.App.assetImageB64(\"StatIcons/Resistance\")", + "get apiHelpers.App.assetFileString(\"jsons/Civ V - Gods & Kings/Terrains.json\")", + "get apiHelpers.App.assetFileB64(\"jsons/Tutorials.json\")", + "get apiHelpers.Jvm.staticPropertyByQualClassAndName[\"com.badlogic.gdx.graphics.Color\"][\"WHITE\"]", + "get apiHelpers.Jvm.constructorByQualname[\"com.unciv.ui.utils.ToastPopup\"](\"This is a popup!\", apiHelpers.Jvm.companionByQualClass[\"com.unciv.UncivGame\"].Current.getScreen(), 2000).add(apiHelpers.Jvm.functionByQualClassAndName[\"com.unciv.ui.utils.ExtensionFunctionsKt\"][\"toLabel\"](\"With Scarlet text! \", apiHelpers.Jvm.staticPropertyByQualClassAndName[\"com.badlogic.gdx.graphics.Color\"][\"SCARLET\"], 24))", + "set Unciv.ScriptingDebugParameters.printCommandsForDebug = true", + "set Unciv.ScriptingDebugParameters.printCommandsForDebug = false" + ) + private val tests = listOf( + "get modApiHelpers.lambdifyExecScript(\"get uncivGame\")", + "get apiHelpers.Jvm.functionByQualClassAndName[\"com.unciv.ui.utils.ExtensionFunctionsKt\"][\"onClick\"](apiHelpers.Jvm.constructorByQualname[\"com.unciv.ui.utils.ToastPopup\"](\"Click to add gold!\", apiHelpers.Jvm.companionByQualClass[\"com.unciv.UncivGame\"].Current.getScreen(), 5000), modApiHelpers.lambdifyReadPathcode(null, \"civInfo.addGold(1000)\"))", // The click action doesn't work, but this still tests extension/static function access. +// "get fFeiltali.stastIRFI" // Force a failure. + ) + + private fun runTests(): ExecResult { // TODO: Could add suppress flag to disable printing in unit tests. + val failResult = exec("get This.Command[Should](Fail)!") + if (!failResult.isException) { + throw AssertionError("ERROR in reflective scripting tests: Unable to detect failures.".also { println(it) }) + } + val failures = mutableMapOf() + val tests = sequenceOf(examples.filterNot { it.startsWith("runtests") }, tests).flatten().toList() + for (command in tests) { + val execResult = exec(command) + if (execResult.isException) { + failures[command] = execResult.resultPrint + } + } + return if (failures.isEmpty()) {ExecResult( + "${tests.size} reflective scripting tests PASSED!".also { println(it) } + )} else {ExecResult( + listOf( + "${failures.size}/${tests.size} reflective scripting tests FAILED:", + *failures.map { "\t${it.key}\n\t\t${it.value }" }.toTypedArray() + ).joinToString("\n").also { println(it) }, + true + )} + } + + private fun examplesPrintable() = "\nExamples:\n${examples.map({"> ${it}"}).joinToString("\n")}\n" + + override fun motd(): String { + return """ + + + Welcome to the reflective Unciv CLI backend. + + Commands you enter will be parsed as a path consisting of property reads, key and index accesses, function calls, and string, numeric, boolean, and null literals. + Keys, indices, and function arguments are parsed recursively. + Properties can be both read from and written to. + + Press [TAB] at any time to trigger autocompletion for all known leaf names at the currently entered path. + + """.trimIndent() + } + + override fun autocomplete(command: String, cursorPos: Int?): AutocompleteResults { + try { + var comm = commandparams.keys.find { command.startsWith(it+" ") } + if (comm != null) { + val params = command.drop(comm.length+1).split(' ', limit=commandparams[comm]!!) + //val prefix + //val workingcode + //val suffix + val workingcode = params[params.size-1] + val workingpath = Reflection.parseKotlinPath(workingcode) + if (workingpath.any { it.type == Reflection.PathElementType.Call }) { + return AutocompleteResults(helpText = "No autocomplete available for function calls.") + } + val leafname = if (workingpath.isNotEmpty()) workingpath[workingpath.size - 1].name else "" + val prefix = command.dropLast(leafname.length) + val branchobj = Reflection.resolveInstancePath(ScriptingScope, workingpath.slice(0..workingpath.size-2)) + return AutocompleteResults( + branchobj!!::class.members + .map { it.name } + .filter { it.startsWith(leafname) } + .map { prefix + it } + ) + } else { + return AutocompleteResults(commandparams.keys.filter { it.startsWith(command) }.map { it+" " }) + } + } catch (e: Exception) { + return AutocompleteResults(helpText = "Could not get autocompletion: ${e}") + } + } + + override fun exec(command: String): ExecResult { // TODO: Treat multiple lines as consecutive commands, for modding. + var parts = command.split(' ', limit=2) + var out = "\n> ${command}\n" + var isException = false + fun appendOut(text: String) { + out += text + "\n" // Slow? Meh. The user will always be the bottleneck in this code. + } + try { + when (parts[0]) { + "get" -> { + appendOut("${Reflection.evalKotlinString(ScriptingScope, parts[1])}") + } + "set" -> { // TODO: Use the new pathcode splitter to accept equals sign format. +// var setparts = parts[1].split(' ', limit=2) + val setparts = Reflection.splitToplevelExprs( + parts[1], + delimiters = " " + ).filter { it.isNotBlank() } + if (setparts.size != 3 || setparts.elementAtOrNull(1) != "=") { + throw IllegalArgumentException("Expected two expressions separated by an equals sign with spaces. Got:\n" + setparts.joinToString("\n")) + } + var value = Reflection.evalKotlinString(ScriptingScope, setparts[2]) + Reflection.setInstancePath( + ScriptingScope, + path = Reflection.parseKotlinPath(setparts[0]), + value = value + ) + appendOut("Set ${setparts[0]} to ${value}") + } + "typeof" -> { + var obj = Reflection.evalKotlinString(ScriptingScope, parts[1]) + appendOut("${if (obj == null) null else obj!!::class.qualifiedName}") + } + "examples" -> { + appendOut(examplesPrintable()) + } + "runtests" -> { + val testResults = runTests() + appendOut(testResults.resultPrint) + isException = testResults.isException + } + else -> { + throw Exception("Unknown command:\n${parts[0].prependIndent("\t")}") + } + } + } catch (e: Throwable) { // The runtest command is meant to catch breakage from isMinifyEnabled=true removing scripting API functions, which would be NoSuchElementError, I think, so not an Exception subclass. + appendOut("Error evaluating command:\n${e.toString().prependIndent("\t")}") + isException = true + } + return ExecResult(out, isException) + } +} + +// Uses SourceManager to copy library files for engine type into a temporary directory per instance. + +// Tries to deletes temporary directory on backend termination, and registers a handler with UncivGame to delete temporary directory on application end as well. +abstract class EnvironmentedScriptingBackend(): ScriptingBackend() { + + companion object Metadata: EnvironmentedScriptBackend_metadata() { + // Need full metadata companion here, or else won't compile. + // Ideally would be able to just declare that subclasses must define a companion of the correct type, but ah well. + override val displayName = "" + override fun new() = throw UnsupportedOperationException("Base scripting backend class not meant to be instantiated.") + override val engine = "" + } + + val folderHandle = SourceManager.setupInterpreterEnvironment(metadata.engine) // Temporary directories are often RAM-backed, so, meh. Alternative to copying entire interpreter library/bindings would be to either implement a virtual filesystem (complicated and sounds brittle) or make scripts share files and thus let them interfere with each other if they have filesystem access (as is deliberately the case in the Python backend)… A couple hundred lines of text and a small, compressed example JPEG or three won't kill anything. + fun deleteFolder(): Unit { + if (ScriptingDebugParameters.printEnvironmentFolderCreation) { + println("Deleting interpreter environment for ${metadata.engine} scripting engine: ${folderHandle.path()}") + } + folderHandle.deleteDirectory() + } + + init { + UncivGame.Current.disposeCallbacks.add(::deleteFolder) + } + + override val metadata + // Since the companion object type is different, we have to define a new getter for the subclass instance companion getter to get its new members. + get() = this::class.companionObjectInstance as EnvironmentedScriptBackend_metadata + + // This requires the overridden values for engine, so setting it in the constructor causes a null error... May be fixed since moving engine to the companions. + // Also, BlackboxScriptingBackend inherits from this, but not all subclasses of BlackboxScriptingBackend might need it. So as long as it's not accessed, it won't be initialized. + + // TODO: Probably implement a the Disposable interface method here to clean up the folder. + + override fun terminate(): Exception? { + return try { + deleteFolder() + null + } catch (e: Exception) { + e + } finally { + UncivGame.Current.disposeCallbacks.remove(::deleteFolder) // Looks like value equality, but not referential equality, is preserved between different references to the same function… Good enough. + } + } + +} + + +abstract class BlackboxScriptingBackend(): EnvironmentedScriptingBackend() { + + companion object Metadata: EnvironmentedScriptBackend_metadata() { + // Need full metadata companion here, or else won't compile. + // Ideally would be able to just declare that subclasses must define a companion of the correct type, but ah well. + override val displayName = "" + override fun new() = throw UnsupportedOperationException("Base scripting backend class not meant to be instantiated.") + override val engine = "" + } + + abstract val blackbox: Blackbox + + abstract val replManager: ScriptingReplManager + // Should be lazy in implementations. Was originally a method that could be called by subclasses' constructors. This seems cleaner. Subclasses don't even have to define any functions this way. And the liberal use of lazy should naturally make sure the properties will always be initialized in the right order. + // Downside: Potential latency on first command, or possibly depending on motd() for immediate initialization. + + override fun motd(): String { + try { + return replManager.motd() + } catch (e: Exception) { + return "No MOTD for ${metadata.engine} backend: ${e}\n" // TODO: Hm.. + } + } + + override fun autocomplete(command: String, cursorPos: Int?): AutocompleteResults { + return try { + replManager.autocomplete(command, cursorPos) + } catch (e: Exception) { + AutocompleteResults(helpText = "Autocomplete error: ${e}") + } + } + + override fun exec(command: String): ExecResult { + return try { + replManager.exec("${command}\n") + } catch (e: RuntimeException) { + ExecResult("${e}", true) + } + } + + override fun terminate(): Exception? { + val deleteResult = super.terminate() + return (try { + replManager.terminate() + } catch (e: Exception) { + e + }) ?: deleteResult + } +} + + +abstract class SubprocessScriptingBackend(): BlackboxScriptingBackend() { + + abstract val processCmd: Array + + override val blackbox by lazy { SubprocessBlackbox(processCmd) } + + override val replManager: ScriptingReplManager by lazy { ScriptingRawReplManager(ScriptingScope, blackbox) } + + override fun motd(): String { + return """ + + + Welcome to the Unciv '${metadata.displayName}' API. This backend relies on running the system ${processCmd.firstOrNull()} command as a subprocess. + + If you do not have an interactive REPL below, then please make sure the following command is valid on your system: + + ${processCmd.joinToString(" ")} + + + """.trimIndent() + super.motd() // I don't think trying to translate this (or its subcomponents— Although I guess translations are going to be available for displayName anyway) would be the best idea. + } +} + + +abstract class ProtocolSubprocessScriptingBackend(): SubprocessScriptingBackend() { + + override val replManager by lazy { ScriptingProtocolReplManager(ScriptingScope, blackbox) } + +} + + +class SpyScriptingBackend(): ProtocolSubprocessScriptingBackend() { + + companion object Metadata: EnvironmentedScriptBackend_metadata() { + override fun new() = SpyScriptingBackend() + override val displayName = "System Python" + override val engine = "python" + } + + override val processCmd = arrayOf("python3", "-u", "-X", "utf8", folderHandle.child("main.py").toString()) + +} + + +class SqjsScriptingBackend(): SubprocessScriptingBackend() { + + companion object Metadata: EnvironmentedScriptBackend_metadata() { + override fun new() = SqjsScriptingBackend() + override val displayName = "System QuickJS" + override val engine = "qjs" + } + + override val processCmd = arrayOf("qjs", "--std", "--script", folderHandle.child("main.js").toString()) + +} + + +class SluaScriptingBackend(): SubprocessScriptingBackend() { + + companion object Metadata: EnvironmentedScriptBackend_metadata() { + override fun new() = SluaScriptingBackend() + override val displayName = "System Lua" + override val engine = "lua" + } + + override val processCmd = arrayOf("lua", folderHandle.child("main.lua").toString()) + +} + + +class DevToolsScriptingBackend(): ScriptingBackend() { + + //Probably redundant, and can probably be removed in favour of whatever mechanism is currently used to run the translation file generator. + + companion object Metadata: ScriptingBackend_metadata() { + override fun new() = DevToolsScriptingBackend() + override val displayName = "DevTools" + } + + val commands = listOf( + "PrintFlatApiDefs", + "PrintClassApiDefs", + "WriteOutApiFile ", + "WriteOutApiFile android/assets/scripting/sharedfiles/ScriptAPI.json" + ) + + override fun motd() = """ + + You have launched the DevTools CLI backend." + This tool is meant to help update code files. + + Available commands: + """.trimIndent()+"\n"+commands.map { "> ${it}" }.joinToString("\n")+"\n\n" + + override fun autocomplete(command: String, cursorPos: Int?) = AutocompleteResults(commands.filter { it.startsWith(command) }) + + override fun exec(command: String): ExecResult { + val commv = command.split(' ', limit=2) + var out = "> ${command}\n" + try { + when (commv[0]) { + "PrintFlatApiDefs" -> { + out += ApiSpecGenerator().generateFlatApi().toString() + "\n" + } + "PrintClassApiDefs" -> { + out += ApiSpecGenerator().generateClassApi().toString() + "\n" + } + else -> { + out += "Unknown command: ${commv[0]}\n" + } + } + + } catch (e: Exception) { + out += e.toString() + } + return ExecResult(out) + } +} + + +// @property suggestedStartup Default startup code to run when started by ConsoleScreen *only*. +enum class ScriptingBackendType(val metadata: ScriptingBackend_metadata, val suggestedStartup: String = "") { // Not sure how I feel about having suggestedStartup here— Kinda breaks separation of functionality and UI— But keeping a separate Map in the UI files would be too messy, and it's not as bad as putting it in the companion objects— Really, this entire Enum is a mash of stuff needed to launch and use all the backend types by anything else, so that fits. + Dummy(ScriptingBackend), + Hardcoded(HardcodedScriptingBackend, "help"), + Reflective(ReflectiveScriptingBackend, "examples"), + //MicroPython(UpyScriptingBackend), + SystemPython(SpyScriptingBackend, "from unciv import *; from unciv_pyhelpers import *"), + SystemQuickJS(SqjsScriptingBackend), + SystemLua(SluaScriptingBackend), +// DevTools(DevToolsScriptingBackend), + //For running ApiSpecGenerator. Comment in releases. Uncomment if needed. + // TODO: Have .new function? +} diff --git a/core/src/com/unciv/scripting/ScriptingBackendType.kt b/core/src/com/unciv/scripting/ScriptingBackendType.kt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/core/src/com/unciv/scripting/ScriptingConstants.kt b/core/src/com/unciv/scripting/ScriptingConstants.kt new file mode 100644 index 0000000000000..30748fe0787b3 --- /dev/null +++ b/core/src/com/unciv/scripting/ScriptingConstants.kt @@ -0,0 +1,100 @@ +package com.unciv.scripting + +import com.badlogic.gdx.Gdx +import com.unciv.JsonParser + + +val ScriptingConstants: _ScriptingConstantsClasses.ScriptingConstantsClass = JsonParser().getFromJson( + _ScriptingConstantsClasses.ScriptingConstantsClass::class.java, + _ScriptingConstantsClasses.ScriptingAssetFolders.scriptingAssets.child("ScriptingEngineConstants.json") +) + + +/** + * Class defining the structure of ScriptingConstants. + */ +object _ScriptingConstantsClasses{ + // Need to define classes to deserialize the JSONs into, but really the whole file should be one singleton. + // It would be slightly better with KotlinX, I think, since then I could at least use data classes and don't have to initialize all the properties with mutable values. LibGDX instantiates with no constructor arguments, and then assigns to properties/fields/whatever. + + /** + * Constant values for scripting API. + * + * Mostly mirrors assets/scripting/ScriptingEngineConstants.json. + * Also includes folder handles, and additional constants shared with script interpreter engines. + */ + class ScriptingConstantsClass() { + /** + * Map of parameters for each script interpreter engine type. + */ + var engines = HashMap() + // Really, these should be val:Map<>s in a data class constructor, not var:HashMap<>() in a regular class body. But GDX doesn't seem to like parsing .JSON into those, so instead let's override the accessors. + get() = field.toMap() as HashMap + set(value) = throw UnsupportedOperationException("This property is supposed to be constant.") + + /** + * List of filepaths that are shared by all engine types, starting from assets/scripting/sharedfiles. + * + * Used by SourceManager. + * Required because internal directories can't be identified or traversed on Desktop, as all assets are put on the classpath. + */ + var sharedfiles = ArrayList() + get() = field.toList() as ArrayList + set(value) = throw UnsupportedOperationException("This property is supposed to be constant.") + + val assetFolders = ScriptingAssetFolders + + val apiConstants = JsonParser().getFromJson(ScriptingAPIConstants::class.java, assetFolders.sharedfilesAssets.child("ScriptAPIConstants.json")) + } + + /** + * Configuration values for a single script interpreter engine type, as specified in assets/scripting/ScriptingEngineConstants.json. + */ + class ScriptingEngineConfig(){ + // Not sure if these should be called "engines" or "languages". "Language" better reflects the actual distinction between the files and (not implemented) syntax highlighting REGEXs for each, but "engine" is less ambiguous with the the translation stuff. + /** + * Filepath strings, starting from the root folder of the engine at assets/scripting/enginefiles/{engine}, that should be copied when constructing the environment of a particular engine type. + * + * Used by SourceManager. + * Required because internal directories can't be identified or traversed on Desktop, as all assets are put on the classpath. + */ + var files = ArrayList() + // https://github.com/libgdx/libgdx/issues/4074 + // https://github.com/libgdx/libgdx/wiki/Reading-and-writing-JSON + /** + * Stack of Regular Expression operations that can be used to apply LibGDX Color Markup Language syntax highlighting to the output of an engine type. + * Not yet implemented/used. + */ + var syntaxHighlightingRegexStack = ArrayList() + } + + /** + * Constant values mirroring assets/scripting/sharedfiles/ScriptingAPIConstants.json. + * + * Separate file and class from other constants because these have to be shared with each engine's interpreters. + */ + class ScriptingAPIConstants() { + /** + * Prefix used to generate and identify string tokens from InstanceTokenizer. + */ + var kotlinInstanceTokenPrefix = "" + } + + /** + * File handles for internal scripting asset folders. + */ + object ScriptingAssetFolders { + /** + * File handle for base internal scripting asset folder. + */ + val scriptingAssets = Gdx.files.internal("scripting/") + /** + * File handle for internal folder holding each engine's asset directory. + */ + val enginefilesAssets = scriptingAssets.child("enginefiles/") + /** + * File handle for internal asset folder holding files shared across each engine. + */ + val sharedfilesAssets = scriptingAssets.child("sharedfiles/") + } +} diff --git a/core/src/com/unciv/scripting/ScriptingState.kt b/core/src/com/unciv/scripting/ScriptingState.kt new file mode 100644 index 0000000000000..6243b18d2d4de --- /dev/null +++ b/core/src/com/unciv/scripting/ScriptingState.kt @@ -0,0 +1,225 @@ +package com.unciv.scripting + +import com.unciv.UncivGame // Only for blocking execution in multiplayer. +import com.unciv.scripting.api.ScriptingScope +import com.unciv.scripting.sync.ScriptingRunLock +import com.unciv.scripting.sync.makeScriptingRunName +import com.unciv.scripting.utils.ScriptingDebugParameters +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.utils.ToastPopup +import com.unciv.ui.utils.clipIndexToBounds +import com.unciv.ui.utils.enforceValidIndex +import com.unciv.ui.worldscreen.WorldScreen // Only for blocking execution in multiplayer. +import java.lang.IllegalStateException +import java.util.concurrent.Semaphore +import kotlin.collections.ArrayList + +// TODO: Add .github/CODEOWNERS file for automatic PR notifications. + +// TODO: Check for places to use Sequences. +// Hm. It seems that Sequence performance isn't even a simple question of number of loops, and is also affected by boxed types and who know what else. +// Premature optimization and such. Clearly long chains of loops can be rewritten as sequences. + +// TODO: There's probably some public vars that can/should be private set. + +// TODO: Mods blacklist, for security threats. + +// See https://github.com/yairm210/Unciv/pull/5592/commits/a1f51e08ab782ab46bda220e0c4aaae2e8ba21a4 for example of running locking operation in separate thread. + + +// TODO: + +/** + * Self-contained instance of scripting API use. + * + * Abstracts available scope, running backends, command history + * Should be unique per isolated use of scripting. E.G. One for the [~]-key console screen, one for each mod/all mods per save file (or whatever works best), etc. + * + * @property ScriptingScope ScriptingScope instance at the root of all scripting API. + */ +//This will be responsible for: Using the lock, threading, passing the entrypoint name to the lock, exposing context/running backend in ScriptingScope, and setting handler context arguments. +object ScriptingState { + // Singletons. Biggest benefit to having multiple ScriptingStates/ScriptingScopes would be concurrent execution of different engines with different states, which sounds more like a nightmare than a benefit. + + val scriptingBackends = ArrayList() + + private val outputHistory = ArrayList() + private val commandHistory = ArrayList() + + var activeBackendIndex: Int = 0 + private set + val activeBackend get() = scriptingBackends[activeBackendIndex] + + val maxOutputHistory: Int = 511 + val maxCommandHistory: Int = 511 + + var activeCommandHistory: Int = 0 + // Actually inverted, because history items are added to end of list and not start. 0 means nothing, 1 means most recent command at end of list. + + var consoleScreenListener: ((String) -> Unit)? = null // TODO: Switch to push instead of pull for ConsoleScreen. + + fun getOutputHistory() = outputHistory.toList() + + data class BackendSpawnResult(val backend: ScriptingBackend, val motd: String) + + fun spawnBackend(backendtype: ScriptingBackendType): BackendSpawnResult { + val backend: ScriptingBackend = backendtype.metadata.new() + scriptingBackends.add(backend) + activeBackendIndex = scriptingBackends.lastIndex + val motd = backend.motd() + echo(motd) + return BackendSpawnResult(backend, motd) + } + + fun getIndexOfBackend(backend: ScriptingBackend): Int? { + val index = scriptingBackends.indexOf(backend) + return if (index >= 0) + index + else + null + } + + fun switchToBackend(index: Int) { + scriptingBackends.enforceValidIndex(index) + activeBackendIndex = index + } + + fun switchToBackend(backend: ScriptingBackend) = switchToBackend(getIndexOfBackend(backend)!!) + + fun termBackend(index: Int): Exception? { + scriptingBackends.enforceValidIndex(index) + val result = scriptingBackends[index].terminate() + if (result == null) { + scriptingBackends.removeAt(index) + if (index < activeBackendIndex) { + activeBackendIndex -= 1 + } + activeBackendIndex = scriptingBackends.clipIndexToBounds(activeBackendIndex) + } + return result + } + + fun termBackend(backend: ScriptingBackend) = termBackend(getIndexOfBackend(backend)!!) + + fun hasBackend(): Boolean { + return scriptingBackends.isNotEmpty() + } + + fun echo(text: String) { + outputHistory.add(text) + consoleScreenListener?.invoke(text) + while (outputHistory.size > maxOutputHistory) { + outputHistory.removeAt(0) + // If these are ArrayLists, performance will probably be O(n) relative to maxOutputHistory. + // But premature optimization would be bad. + } + } + + fun autocomplete(command: String, cursorPos: Int? = null): AutocompleteResults { + // Deliberately not calling echo() to add into history because I consider autocompletion a protocol/API/UI level feature + if (!(hasBackend())) { + return AutocompleteResults() + } + return activeBackend.autocomplete(command, cursorPos) + } + + fun navigateHistory(increment: Int): String { + activeCommandHistory = commandHistory.clipIndexToBounds(activeCommandHistory + increment, extendEnd = 1) + if (activeCommandHistory <= 0) { + return "" + } else { + return commandHistory[commandHistory.size - activeCommandHistory] + } + } + + private val runLock = Semaphore(1, true) + var runningName: String? = null + private set + + // @throws IllegalStateException On failure to acquire scripting lock. + fun exec( + command: String, + asName: String? = null, + withParams: Map? = null, + allowWait: Boolean = false + ): ExecResult { + // TODO: Synchronize here instead of in ScriptingRunLock and ScriptingRunThreader. + if (ScriptingDebugParameters.printCommandsForDebug) { + println("Running: $command") + } + if (UncivGame.Current.screen is WorldScreen + && UncivGame.Current.isGameInfoInitialized() + && UncivGame.Current.gameInfo.gameParameters.isOnlineMultiplayer + ) { // TODO: After leaving game? + ToastPopup("Scripting not allowed in online multiplayer.", UncivGame.Current.screen as BaseScreen) // TODO: Translation. + return ExecResult("", true) + } + val backend = activeBackend + val name = asName ?: makeScriptingRunName(this::class.simpleName, backend) +// val releaseKey = ScriptingRunLock.acquire(name) + // Lock acquisition failure gets propagated as thrown Exception, rather than as return. E.G.: Lets lambdas (from modApiHelpers) fail and trigger their own error handling (exposing misbehaving mods to the user via popup). + // isException in ExecResult return value means exception in completely opaque scripting backend. Kotlin exception should still be thrown and propagated like normal. + try { + if (allowWait) { + runLock.acquire() + } else { + if (!runLock.tryAcquire()) { + throw IllegalStateException() + } + } + runningName = name + ScriptingScope.apiExecutionContext.apply { + handlerParameters = withParams?.toMap() + // Looking at the source code, some .to() extensions actually return mutable instances, and just type the return. + // That means that scripts, and the heavy casting in Reflection.kt, might actually be able to modify them. So make a copy before every script run. + scriptingBackend = backend + } + if (command.isNotEmpty()) { + if (command != commandHistory.lastOrNull()) + commandHistory.add(command) + while (commandHistory.size > maxCommandHistory) { + commandHistory.removeAt(0) + // No need to restrict activeCommandHistory to valid indices here because it gets set to zero anyway. + // Also probably O(n) to remove from start.. + } + } + activeCommandHistory = 0 + var out = if (hasBackend()) + backend.exec(command) + else + ExecResult("${this::class.simpleName} has no backends.", true) + echo(out.resultPrint) + return out + } finally { + ScriptingScope.apiExecutionContext.apply { + handlerParameters = null + scriptingBackend = null + } + runningName = null + runLock.release() +// ScriptingRunLock.release(releaseKey) + } + } + + fun exec( + command: String, + asName: String? = null, + withParams: Map? = null, + allowWait: Boolean = false, + withBackend: ScriptingBackend + ): ExecResult { + switchToBackend(withBackend) + return exec( + command = command, + asName = asName, + withParams = withParams, + allowWait = allowWait + ) + } +} + +// UI locking can honestly probably go into the mod script dispatcher thingy. +// ScriptingScope.worldScreen?.isPlayersTurn = false +//Hm. Should return to original value, not necessarily true. That means keeping a property, which means I'd rather put this in its own class. +//Not perfect. I think ScriptingScope also exposes mutating the GUI itself, and many things that aren't protected by this? Then again, a script that *wants* to cause a crash/ANR will always be able to do so by just assigning an invalid value or deleting a required node somewhere. Could make mod handlers outside of worldScreen blocking, with written stipulations on (dis)recommended size, and then +//https://github.com/yairm210/Unciv/pull/5592/commits/a1f51e08ab782ab46bda220e0c4aaae2e8ba21a4 diff --git a/core/src/com/unciv/scripting/api/ScriptingApiAppHelpers.kt b/core/src/com/unciv/scripting/api/ScriptingApiAppHelpers.kt new file mode 100644 index 0000000000000..d3d8f47dc1185 --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiAppHelpers.kt @@ -0,0 +1,43 @@ +package com.unciv.scripting.api + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.PixmapIO +import com.badlogic.gdx.utils.Base64Coder +import com.unciv.ui.utils.ImageGetter +import com.unciv.ui.utils.toPixmap +import java.io.ByteArrayOutputStream + +object ScriptingApiAppHelpers { + //Debug/dev identity function for both Kotlin and scripts. Check if value survives serialization, force something to be added to ScriptingProtocol.instanceSaver, etc. + + // @param path Path of an internal file as exposed in Gdx.files.internal. + // @return The contents of the internal file read as a text string. + fun assetFileString(path: String) = Gdx.files.internal(path).readString() + + // @param path Path of an internal file as exposed in Gdx.files.internal. + // @return The contents of the internal file encoded as a Base64 string. + fun assetFileB64(path: String) = String(Base64Coder.encode(Gdx.files.internal(path).readBytes())) + + // @param path Path of an internal image as exposed in ImageGetter as a TextureRegionDrawable from an atlas. + // @return The image encoded as a PNG file encoded as a Base64 string. + fun assetImageB64(path: String): String { + // To test in Python: + // import PIL.Image, io, base64; PIL.Image.open(io.BytesIO(base64.b64decode(apiHelpers.assetImage("StatIcons/Resistance")))).show() + val fakepng = ByteArrayOutputStream() + //Close this stream? Well, the docs say doing so "has no effect", and it should clearly get GC'd anyway. + val pixmap = ImageGetter.getDrawable(path).getRegion().toPixmap() + val exporter = PixmapIO.PNG() // Could be kept and "reused to encode multiple PNGs with minimal allocation", according to the docs. Probably not a sufficient bottleneck to justify the complexity and risk, though. + exporter.setFlipY(false) + exporter.write(fakepng, pixmap) + pixmap.dispose() // In theory needed to avoid memory leak. Doesn't seem to actually have any impact, compared to the .dispose() inside .toPixmap(). Maybe the exporter's dispose also calls this? + exporter.dispose() // This one should be called automatically by GC anyway. + return String(Base64Coder.encode(fakepng.toByteArray())) + } + + //val isMainThread get() = Thread().getCurrentThread() == UncivGame.mainThread + + //fun runInThread(func: () -> Unit) {} + + //fun runInMainLoop(func: () -> Unit) {} + +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiExecutionContext.kt b/core/src/com/unciv/scripting/api/ScriptingApiExecutionContext.kt new file mode 100644 index 0000000000000..7696314aaecc1 --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiExecutionContext.kt @@ -0,0 +1,14 @@ +package com.unciv.scripting.api + +import com.unciv.scripting.ScriptingBackend + +object ScriptingApiExecutionContext { + // Map that gets replaced with any contextual parameters when running script handlers. + + // E.G. The Unit that was moved, the city that was founded, the tech that was researched, the construction that was finished, the instance that was initialized. + + // To prevent scripts from interfering with each other by trying to mutate this, it is reset by ScriptingState before every script execution. As such, scripted handlers should re-access it when run to get valid values. + var handlerParameters: Map? = null + + var scriptingBackend: ScriptingBackend? = null // TODO: Actually just use ScriptingState.activeBackend? …Execution state shouldn't be kept completely under ScriptingScope at all now that it's all singletons. +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiGlHelpers.kt b/core/src/com/unciv/scripting/api/ScriptingApiGlHelpers.kt new file mode 100644 index 0000000000000..e8493f797ea6c --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiGlHelpers.kt @@ -0,0 +1,6 @@ +package com.unciv.scripting.api + +object ScriptingApiGlHelpers { + // https://github.com/yairm210/Unciv/pull/2786 + // ShapeRenderer, Gdx.gl? +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiHelpers.kt b/core/src/com/unciv/scripting/api/ScriptingApiHelpers.kt new file mode 100644 index 0000000000000..c81b26dd1e22d --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiHelpers.kt @@ -0,0 +1,34 @@ +package com.unciv.scripting.api + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.PixmapIO +import com.badlogic.gdx.utils.Base64Coder +import com.unciv.scripting.utils.FakeMap +import com.unciv.ui.utils.ImageGetter +import com.unciv.ui.utils.toPixmap +import java.io.ByteArrayOutputStream + +// TODO: Search core code for Transient lazy init caches (E.G. natural wonders saved in TileMap apparently, and TileMap.resources), and add functions to refresh them? + +object ScriptingApiHelpers { + // This, and the classes of its members, should try to implement only the minimum number of helper functions that are needed for each type of functionality otherwise not possible in scripts. E.G. Don't add special "loadGame" functions or whatever here, but do expose the existing methods of UncivGame. E.G. Don't add factories to speed up making alert popups, because all the required constructors can already be called through constructorByQualname anyway. Let the rest of the codebase and the scripts themselves do the work— Maintenance of the API itself will be easier if all it does is expose existing Kotlin code to dynamic Python/JS/Lua code. + val isInGame: Boolean + get() = (ScriptingScope.civInfo != null && ScriptingScope.gameInfo != null && ScriptingScope.uncivGame != null) + + val App = ScriptingApiAppHelpers + + val Sys = ScriptingApiSysHelpers + + val Jvm = ScriptingApiJvmHelpers + + val Mappers = ScriptingApiMappers + + val Math = ScriptingApiMathHelpers + + val registeredInstances = ScriptingApiInstanceRegistry + val instancesAsInstances = FakeMap{obj: Any? -> obj} + /// Scripting language bindings work by keeping track of the paths to values, and making Kotlin/the JVM resolve them only when needed. + // This creates a dilemma: Resolving a path into a Kotlin value too early means that no further paths (E.G. attribute, keys, calls) can be built on top of it. But resolving it late means that expected side effects may not happen (E.G. function calls probably shouldn't be deferred). And values that *must* be resolved, like the results of function calls, cannot have their own members and method accessed until they themselves are assigned to a path, because they're just kinda floating around as far as the scripting-exposed semantics are concerned. + // So this fake Map works around that, by providing a way for any random object to appear to have a path. + +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiInstanceRegistry.kt b/core/src/com/unciv/scripting/api/ScriptingApiInstanceRegistry.kt new file mode 100644 index 0000000000000..617c8ee8fb7db --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiInstanceRegistry.kt @@ -0,0 +1,83 @@ +package com.unciv.scripting.api + +import com.unciv.UncivGame +import java.util.Collections.newSetFromMap +import java.util.WeakHashMap +import kotlin.NoSuchElementException + +object AllScriptingApiInstanceRegistries { + private val registries = newSetFromMap(WeakHashMap()) + // Apparently this will be a WeakSet? Oh, all sets just wrap Maps? + init { + UncivGame.Current.disposeCallbacks.add { // Runtime.getRuntime().addShutdownHook() also works, but I probably prefer to unify with existing shutdown behaviour. + val allKeys = getAllKeys() + if (allKeys.isNotEmpty()) { + println("WARNING: ${allKeys.size} ScriptingApiInstanceRegistry()s still have keys in them:") + println("\t" + allKeys.map { "${it.value.size} keys in ${it.key}\n\t\t"+it.value.joinToString("\n\t\t") }.joinToString("\n\t")) + } + } + } + fun add(registry: ScriptingApiInstanceRegistry) { + registries.add(registry) + } + fun getAllKeys(): Map> { + return registries.filter { it.isNotEmpty() }.associateWith { it.keys } + } +} + +/** + * Namespace in ScriptingScope().apiHelpers, for scripts to do their own memory management by keeping references to objects alive. + * + * Wraps a MutableMap<>(). + * + * @throws IllegalArgumentException On an attempted assignment colliding with an existing key. + * @throws NoSuchElementException For reads and removals at non-existent keys. + */ +object ScriptingApiInstanceRegistry: MutableMap { // This is a singleton as ScriptingScope is a singleton now, but it's probably best to keep it with the same semantics as a class. + init { + AllScriptingApiInstanceRegistries.add(this) + } + private val backingMap = mutableMapOf() + override val entries + get() = backingMap.entries + override val keys + get() = backingMap.keys + override val values + get() = backingMap.values + override val size + get() = backingMap.size + override fun containsKey(key: String) = backingMap.containsKey(key) + override fun containsValue(value: Any?) = backingMap.containsValue(value) + override fun get(key: String): Any? { + if (key !in this) { + throw NoSuchElementException("\"${key}\" not in ${this}.") + } + return backingMap.get(key) + } + override fun isEmpty() = backingMap.isEmpty() + override fun clear() = backingMap.clear() + override fun put(key: String, value: Any?): Any? { + println(""" + + INFO: Assigning ${key} directly in ScriptingApiInstanceRegistry(). It is recommended that every script/mod do this only once per application lifespan, creating its own mapping under the registry for further assignments named according to the following format: + -<'mod'|'module'|'package'>:/ + E.G.: registeredInstances["python-module:myName/myCoolScript"] = {"some_name": someToken} + + """.trimIndent()) + if (key in this) { + throw IllegalArgumentException("\"${key}\" already in ${this}.") + } + return backingMap.put(key, value) + } + override fun putAll(from: Map) { + for ((key, value) in from) { + put(key, value) + } + } + override fun remove(key: String): Any? { + if (key !in this) { + throw NoSuchElementException("\"${key}\" not in ${this}.") + } + return backingMap.remove(key) + } +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiJvmHelpers.kt b/core/src/com/unciv/scripting/api/ScriptingApiJvmHelpers.kt new file mode 100644 index 0000000000000..a615ba5fcdba8 --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiJvmHelpers.kt @@ -0,0 +1,82 @@ +package com.unciv.scripting.api + +import com.unciv.scripting.reflection.Reflection +import com.unciv.scripting.reflection.makeFunctionDispatcher +import com.unciv.scripting.utils.FakeMap +import com.unciv.scripting.utils.LazyMap +import kotlin.reflect.KCallable +import kotlin.reflect.full.companionObjectInstance +import kotlin.reflect.jvm.kotlinFunction + +// Could also use ClassGraph to automatically find all relevant classes on build. +// https://github.com/classgraph/classgraph/wiki/Build-Time-Scanning +// Honestly, it's fine though. Having scripts provide the qualpaths themselves keeps everything dynamic, and the LazyMap caching keeps it (as) performant (as the rest of the API is, probably). The only real "benefit" of indexing everything beforehand would be enabling autocompletion. + +// Convert an Enum type parameter into a Map of its constants by their names. +// inline fun > enumToMap() = enumValues().associateBy { it.name } + +fun enumQualnameToMap(qualName: String) = Class.forName(qualName).enumConstants.associateBy { (it as Enum<*>).name } +// Always return a built-in Map class instance here, so its gets serialized as JSON object instead of tokenized, and scripts can refer directly to its items. +// I cast to Enum<*> fully expecting it would crash because it felt metaclass-y. But apparently it's just a base class, so it works? + +private const val exposeStates = false + +/** + * For use in ScriptingScope. Allows interpreted scripts access Kotlin/JVM class functionality that isn't attached to any application instances. + */ +object ScriptingApiJvmHelpers { + + val enumMapsByQualname = LazyMap(::enumQualnameToMap) // TODO: Rename to enumsByQualClassAndName? + + val classByQualname = LazyMap({ qualName: String -> Class.forName(qualName).kotlin }, exposeState = exposeStates) + + val singletonByQualname = LazyMap({ qualName: String -> classByQualname[qualName]?.objectInstance }, exposeState = exposeStates) + + val companionByQualClass = LazyMap({ qualName: String -> classByQualname[qualName]?.companionObjectInstance }, exposeState = exposeStates) + + val functionByQualClassAndName = LazyMap({ jclassQualname: String -> + val cls = Class.forName(jclassQualname) + LazyMap({ methodName: String -> makeFunctionDispatcher(cls.getDeclaredMethods().asSequence().filter { it.name == methodName }.map { it.kotlinFunction }.filterNotNull().toList() as List>) }, exposeState = exposeStates) + }, exposeState = exposeStates) + // apiHelpers.Jvm.functionByQualClassAndName["com.unciv.ui.utils.ExtensionFunctionsKt"]["toLabel"]("Test") + + // TODO: Right... Extension properties? + // Class.forName("kotlin.reflect.full.KClasses").getMethods().map{it.name} + // apiHelpers.Jvm.functionByQualClassAndName["kotlin.reflect.full.KClasses"]["getFunctions"](apiHelpers.Jvm.classByQualname["com.badlogic.gdx.scenes.scene2d.ui.Cell"]) + // Right. .kotlinFunction is null for extension property getters: + // Class.forName("kotlin.reflect.full.KClasses").getDeclaredMethods().first{it.name == "getFunctions"}.kotlinFunction + + val staticPropertyByQualClassAndName = LazyMap({ jclassQualname: String -> + val kcls = Class.forName(jclassQualname).kotlin + LazyMap({ name: String -> Reflection.readClassProperty(kcls, name) as Any? }, exposeState = exposeStates) + }, exposeState = exposeStates) + // apiHelpers.Jvm.classByQualname["com.badlogic.gdx.graphics.Color"].members[50].get() + // apiHelpers.Jvm.staticPropertyByQualClassAndName["com.badlogic.gdx.graphics.Color"]['WHITE'] + + val constructorByQualname = LazyMap({ qualName: String -> makeFunctionDispatcher(Class.forName(qualName).kotlin.constructors) }, exposeState = exposeStates) + // TODO (Later, Maybe): This would actually be quite easy to whitelist by package paths. + + val classByInstance = FakeMap{ obj: Any? -> obj!!::class } + + fun toString(obj: Any?) = obj.toString() + + fun arrayOfAny(elements: Collection): Array<*> = elements.toTypedArray() // Rename to toArray? Hm. Named for role, not for semantics— This seems more useful for making new arrays, whereas the toString, toList, etc, are for converting existing instances. + fun arrayOfTyped(elements: Collection): Array<*> = when (val item = elements.firstOrNull()) { + // For scripting API/reflection. Return type won't be known in IDE, but that's fine as it's erased at runtime anyway. Important thing is that the compiler uses the right functions, creating the right typed arrays at run time. + is String -> (elements as Collection).toTypedArray() // TODO: Use mostSpecificCommonSupertypeOrNull? + is Number -> (elements as Collection).toTypedArray() + else -> throw IllegalArgumentException("${item!!::class.qualifiedName}") + } + + fun arrayOfTyped1(item: Any?) = arrayOfTyped(listOf(item)) // The "Pathcode" DSL doesn't have any syntax for array or collection literals, and adding such would be beyond its scope. So these helper functions let small arrays be used (1) in the reflective scripting backend and (2), more importantly, in the programm-y and more speed-focused helper functions in ScriptingApiMappers and ModApiHelpers. + fun arrayOfTyped2(item1: Any?, item2: Any?) = arrayOfTyped(listOf(item1, item2)) + fun arrayOfTyped3(item1: Any?, item2: Any?, item3: Any?) = arrayOfTyped(listOf(item1, item2, item3)) + fun arrayOfTyped4(item1: Any?, item2: Any?, item3: Any?, item4: Any?) = arrayOfTyped(listOf(item1, item2, item3, item4)) + fun arrayOfTyped5(item1: Any?, item2: Any?, item3: Any?, item4: Any?, item5: Any?) = arrayOfTyped(listOf(item1, item2, item3, item4, item5)) + + fun toList(array: Array<*>) = array.toList() // sorted([real(m.getName()) for m in apiHelpers.Jvm.classByQualname["kotlin.collections.ArraysKt"].jClass.getMethods()]) + fun toList(iterable: Iterable<*>) = iterable.toList() + fun toList(sequence: Sequence<*>) = sequence.toList() + + //fun toChar(string: CharSequence) +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiMappers.kt b/core/src/com/unciv/scripting/api/ScriptingApiMappers.kt new file mode 100644 index 0000000000000..41ac8cf4f051d --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiMappers.kt @@ -0,0 +1,51 @@ +package com.unciv.scripting.api + +import com.unciv.scripting.reflection.Reflection +import com.unciv.scripting.utils.LazyMap + +// Not sure about the name, but not sure what would be better. + +object ScriptingApiMappers { + + //// Some ways to access or assign the same property(s) on a lot of instances at once, using only one IPC call. Maybe use parseKotlinPath? Probably preserve order in return instead of mapping from each instance, since script must already have list of tokens anyway (ideally also from a single IPC call). Or preserve mapping, since order could be messy, and to fit with the assignment function? + fun mapPathCodes(instances: List, pathcodes: Collection): List> { + val pathElementLists = pathcodes.associateWith(Reflection::parseKotlinPath) + return instances.map { + val instance = it + pathElementLists.mapValues { + Reflection.resolveInstancePath(instance, it.value) + } + } + } + + fun getPathCodesFrom(instance: Any, pathcodes: Collection) = pathcodes.associateWith { Reflection.resolveInstancePath(instance, Reflection.parseKotlinPath(it)) } + + fun getPathCodes(instancesAndPathcodes: Map>): Map> { + val pathElementLists = LazyMap>(Reflection::parseKotlinPath) + return instancesAndPathcodes.mapValues { (instance, pathcodes) -> + pathcodes.associateWith { + Reflection.resolveInstancePath(instance, pathElementLists[it]!!) + } + } + } + + fun applyPathCodesTo(instance: Any, pathcodesAndValues: Map): Any { + for ((pathcode, value) in pathcodesAndValues) { + Reflection.setInstancePath(instance, Reflection.parseKotlinPath(pathcode), value) + } + return instance + } + + fun applyPathCodes(instancesPathcodesAndValues: Map>) { + val pathElementLists = LazyMap>(Reflection::parseKotlinPath) + for ((instance, assignments) in instancesPathcodesAndValues) { + for ((pathcode, value) in assignments) { + Reflection.setInstancePath(instance, pathElementLists[pathcode]!!, value) + } + } + } +} +// st=time.time(); [real(t.baseTerrain) for t in gameInfo.tileMap.values]; print(time.time()-st) +// st=time.time(); apiHelpers.Mappers.mapPathCodes(gameInfo.tileMap.values, ['baseTerrain']); print(time.time()-st) +// Holy shizzle that's faster. +// (It shows a lot of time went to serialization before though, I think.) diff --git a/core/src/com/unciv/scripting/api/ScriptingApiMathHelpers.kt b/core/src/com/unciv/scripting/api/ScriptingApiMathHelpers.kt new file mode 100644 index 0000000000000..c77de707978ba --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiMathHelpers.kt @@ -0,0 +1,52 @@ +package com.unciv.scripting.api + +object ScriptingApiMathHelpers { + + fun add() {} + + fun subtract() {} + + fun multiply() {} + + fun divide() {} + + fun exponentiate() {} + + fun logarithm() {} + + fun floor() {} + + fun ceiling() {} + + fun truncateToInt() {} + + fun truncateToLong() {} + + fun round() {} + + fun roundToMultiple() {} + + fun modulo() {} + + fun sum() {} + + fun geometric() {} + + fun min() {} + + fun max() {} + + fun greaterThan() {} + + fun lessThan() {} + + fun equals() {} + + fun not() {} + + fun ifSwitch() {} + + fun interpolate() {} + + fun contains() {} +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiSysHelpers.kt b/core/src/com/unciv/scripting/api/ScriptingApiSysHelpers.kt new file mode 100644 index 0000000000000..585cdcc4854a6 --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiSysHelpers.kt @@ -0,0 +1,14 @@ +package com.unciv.scripting.api + +import com.badlogic.gdx.Gdx + +object ScriptingApiSysHelpers { + fun printLine(msg: Any?) = println(msg.toString()) // Different name from Kotlin's is deliberate, to abstract for scripts. + fun readLine() = kotlin.io.readLine() // Kotlin 1.6+ exposes readln(), unified name with println(). + //Return a line from the main game process's STDIN. + fun copyToClipboard(value: Any?) { + //Better than scripts potentially doing it themselves. In Python, for example, a way to do this would involve setting up an invisible TKinter window. + Gdx.app.clipboard.contents = value.toString() + } + // Native file chooser could be cool too (E.G. modded map editing tools), but doesn't look to be simple across all platforms. +} diff --git a/core/src/com/unciv/scripting/api/ScriptingApiUnciv.kt b/core/src/com/unciv/scripting/api/ScriptingApiUnciv.kt new file mode 100644 index 0000000000000..4d28b0b4d29d5 --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingApiUnciv.kt @@ -0,0 +1,14 @@ +package com.unciv.scripting.api + +import com.unciv.scripting.utils.ScriptingDebugParameters + +object ScriptingApiUnciv { + // These are also all accessible by qualified name through ScriptingApiJvmHelpers. + // But the functionality they provide is basic enough that it probably merits explicitly exposing. + val GameSaver = com.unciv.logic.GameSaver + val GameStarter = com.unciv.logic.GameStarter + val HexMath = com.unciv.logic.HexMath + val MapSaver = com.unciv.logic.MapSaver + val ScriptingDebugParameters = com.unciv.scripting.utils.ScriptingDebugParameters + val UncivDateFormat = com.unciv.ui.utils.UncivDateFormat +} diff --git a/core/src/com/unciv/scripting/api/ScriptingModApiHelpers.kt b/core/src/com/unciv/scripting/api/ScriptingModApiHelpers.kt new file mode 100644 index 0000000000000..961b38f884d8b --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingModApiHelpers.kt @@ -0,0 +1,85 @@ +package com.unciv.scripting.api + +import com.unciv.scripting.ScriptingState +import com.unciv.scripting.reflection.Reflection +import com.unciv.scripting.utils.ScriptingBackendException +import com.unciv.scripting.utils.ScriptingErrorHandling +import com.unciv.scripting.sync.ScriptingRunLock + +// Wrapper for a function that takes no arguments. +// +// Presents as a reflection-friendly invokable instance +// Catches all thrown Exceptions and shows error dialogs instead. +// +// @param asName Textual identifier for this function to use in error messages. +// @param func The function to wrap. +class LambdaWrapper0(asName: String? = null, func: () -> R?): () -> R? { // Making private messes with calling reflectively. + val lambda: () -> R? = func.unwrapped() + val suppressing: () -> R? = lambda.reportExceptionsAsScriptErrors(asName) + override fun invoke() = suppressing() +} + +// Extension function to strip a zero-argument function of a wrapping LambdaWrapper0 if present. +private fun (() -> R?).unwrapped() = if (this is LambdaWrapper0) this.lambda else this + +// Extension function to wrap a zero-argument function to suppress all exceptions, instead returning null and notifying the player. +private fun (() -> R).reportExceptionsAsScriptErrors(asName: String? = null): () -> R? { + return { + try { + this() + } catch (e: Exception) { + ScriptingErrorHandling.notifyPlayerScriptFailure(e, asName = asName) + null + } + } +} + +private val alphanumeric = ('A'..'Z') + ('a'..'z') + ('0'..'9') +private fun (String?).lambdaName() = "${this}+λ${(1..3).map { alphanumeric.random() }.joinToString("")}" + +object ScriptingModApiHelpers { + fun lambdifyExecScript(code: String): () -> Unit? { + val backend = ScriptingScope.apiExecutionContext.scriptingBackend!! + val name = ScriptingRunLock.runningName.lambdaName() + return LambdaWrapper0(name) { + val execResult = ScriptingState.exec( + command = code, + asName = name, + withBackend = backend + ) + if (execResult.isException) { + throw ScriptingBackendException(execResult.resultPrint) + } // Thrown exception will be caught by reportExceptionsAsScriptErrors, and show error dialog. + Unit + } + } + //setTimeout? // Probably don't implement this. But see ToastPopup.startTimer() if you do. + fun lambdifyReadPathcode(instance: Any?, pathcode: String): () -> Any? { + val path = Reflection.parseKotlinPath(pathcode) + return LambdaWrapper0(ScriptingRunLock.runningName.lambdaName()) { Reflection.resolveInstancePath(instance ?: ScriptingScope, path) } + // Lambda generated by using lambdifyReadPathCode (or something in Mappers) from inside a lambdifyReadPathCode() during a JVM-space callback could have incorrect names, but oh well? + } + fun lambdifyAssignPathcode(instance: Any?, pathcode: String, value: Any?): () -> Unit? { + val path = Reflection.parseKotlinPath(pathcode) + return LambdaWrapper0(ScriptingRunLock.runningName.lambdaName()) { Reflection.setInstancePath(instance ?: ScriptingScope, path, value) } + } + fun lambdifyAssignPathcode(instance: Any?, pathcode: String, value: () -> Any?): () -> Unit? { + val path = Reflection.parseKotlinPath(pathcode) + val getter = value.unwrapped() + return LambdaWrapper0(ScriptingRunLock.runningName.lambdaName()) { Reflection.setInstancePath(instance ?: ScriptingScope, path, getter()) } + } + fun lambdifySuppressReturn(func: () -> R): () -> Unit? { + val lambda = func.unwrapped() + return LambdaWrapper0(ScriptingRunLock.runningName.lambdaName()) { lambda(); null } + } + fun lambdifyCombine(funcs: List<() -> Any?>): () -> Unit? { + val lambdas = funcs.map { it.unwrapped() } + return LambdaWrapper0(ScriptingRunLock.runningName.lambdaName()) { for (f in lambdas) { f() } } + } + + // Due to potential and purpose of being used in GUI callbacks, returns from all the lambdafication functions suppress exceptions and show an error dialog instead. + // This function calls one of those lambdas without the universal exception catching, in case a script wants to use a JVM-space lambda itself and be able to catch JVM-space propagated exceptions for whatever reason. + fun callLambdaAllowException(func: () -> Unit?) = func.unwrapped()() + + +} diff --git a/core/src/com/unciv/scripting/api/ScriptingScope.kt b/core/src/com/unciv/scripting/api/ScriptingScope.kt new file mode 100644 index 0000000000000..aac663491bd53 --- /dev/null +++ b/core/src/com/unciv/scripting/api/ScriptingScope.kt @@ -0,0 +1,56 @@ +package com.unciv.scripting.api + +import com.unciv.UncivGame +import com.unciv.logic.GameInfo +import com.unciv.logic.civilization.CivilizationInfo +import com.unciv.ui.mapeditor.MapEditorScreen +import com.unciv.ui.worldscreen.WorldScreen + +// Could use annotations to expose documentation. Probably not worth it unless integrable with KDoc. + +// TODO: Making ((…) -> Unit) functions in the core codebase return the instance itself would significantly increase the power of the Pathcode DSL. + +/** + * Holds references to all internal game data that scripting backends have access to. + * + * Also where to put any future PlayerAPI, CheatAPI, ModAPI, etc. + * + * For LuaScriptingBackend, UpyScriptingBackend, QjsScriptingBackend, etc, the hierarchy of data under this class definition should probably directly mirror the wrappers in the namespace exposed to running scripts. + * + * WorldScreen gives access to UnitTable.selectedUnit, MapHolder.selectedTile, etc. Useful for contextual operations. + * + * The members of this class and its nested classes should be designed for use by running scripts, not for implementing the protocol or API of scripting backends. + * E.G.: If you need access to a file to build the scripting environment, then add it to ScriptingEngineConstants.json instead of using apiHelpers.assetFileB64. If you need access to some new type of property, then geneneralize it as much as possible and add an IPC request type for it in ScriptingProtocol.kt or add support for it in Reflection.kt. + * In Python terms, that means that magic methods all directly send and parse IPC packets, while running scripts transparently use those magic methods to access the functions here. + * API calls are for running scripts, and may be less stable. Building the scripting environment itself should be done directly using the IPC protocol and other lower-level constructs. + * + * To reduce the chance of E.G. name collisions in .apiHelpers.registeredInstances, or one misbehaving mod breaking everything by unassigning .gameInfo, different ScriptingState()s should each have their own ScriptingScope(). + */ +object ScriptingScope + // This entire API should still be considered unstable. It may be drastically changed at any time. + + //If this is going to be exposed to downloaded mods, then every declaration here, as well as *every* declaration that is safe for scripts to have access to, should probably be whitelisted with annotations and checked or errored at the point of reflection. + { + var civInfo: CivilizationInfo? = null + var gameInfo: GameInfo? = null + var uncivGame: UncivGame? = null + var worldScreen: WorldScreen? = null + var mapEditorScreen: MapEditorScreen? = null + + val Unciv = ScriptingApiUnciv + + val apiHelpers = ScriptingApiHelpers + + val modApiHelpers = ScriptingModApiHelpers + + val apiExecutionContext = ScriptingApiExecutionContext + + // TODO: Some way to clear the instancesaver? + + // fun containersToJson(root: Any?) + // TODO: Serialize containers to JSON? + +} + +// Does having one state manage multiple backends that all share the same scope really make sense? Mod handler dispatch, callbacks, etc might all be easier if the multi-backend functionality of ScriptingState were implemented only for ConsoleScreen. +// ScriptingState also helps separate , keep the shared ScriptingScope between all of them (such that it only needs to be updated once on game context changes), and update diff --git a/core/src/com/unciv/scripting/backends/HardcodedScriptingBackends.kt b/core/src/com/unciv/scripting/backends/HardcodedScriptingBackends.kt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/core/src/com/unciv/scripting/backends/bases/ScriptingBackendBase.kt b/core/src/com/unciv/scripting/backends/bases/ScriptingBackendBase.kt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/core/src/com/unciv/scripting/protocol/Blackbox.kt b/core/src/com/unciv/scripting/protocol/Blackbox.kt new file mode 100644 index 0000000000000..7189ee369df9d --- /dev/null +++ b/core/src/com/unciv/scripting/protocol/Blackbox.kt @@ -0,0 +1,80 @@ +package com.unciv.scripting.protocol + + +/** + * Unified interface for anything that receives and responds to input without any access to or relevance for its internal states. + * + * Should be able to wrap STDIN/STDOUT, pipes, JNI, NDK, network sockets, external processes, embedded code, etc, and make them all interchangeable. + */ +interface Blackbox { + + fun start() { } + + /** + * Try to shut down this black box. + * + * Because there might be normal situations where a "black box" isn't viable to cleanly shut down, I'm thinking that letting exceptions be returned instead of thrown will let those situations be distinguished from actual errors. + * + * E.G.: Making an invalid API call to a requests library should still throw the Exception as usual, but making the right call only to find out that the network's down or a process is frozen would be a more "normal" and uncontrollable situation, so in that case the exception should be a return value instead of being thrown. + * + * This way, the entire call stack between where a predictable error happens and where it's eventually handled doesn't have to be wrapped in an overly broad try{} block. + * + * @return null on success, or an Exception() on failure. + */ + fun stop(): Exception? = null + + /** + * Whether this black box is "running", and able to receive and respond to input. + */ + val isAlive: Boolean + get() = false + + /** + * Approximate number of items ready to be read. Should try to always return 0 correctly, but may return 1 if a greater number of items are available. + */ + val readyForRead: Int + get() = 0 + + /** + * Whether this black box is ready to be written to. + */ + val readyForWrite: Boolean + get() = false + + /** + * @param block Whether to wait for the next available output, or throw an exception if none are available. + * @throws IllegalStateException if blocking is disabled and black box is not ready for read. + * @return String output from black box. + */ + fun read(block: Boolean = true): String = "" + + /** + * Read out all lines up to a limit if given a limit greater than zero. + * + * @param block Whether to wait until at least one line is available before returning. + * @return Empty list if no lines are available and blocking is disabled. List of at least one string if blocking is allowed. + */ + fun readAll(block: Boolean = true, limit: Int = 0): List { + //Should probably be Final. + val lines = ArrayList() + var i = 0 + if (block) { + lines.add(read(block=true)) + i += 1 + } + while (readyForRead > 0 && (limit == 0 || i < limit)) { + lines.add(read(block=true)) + i += 1 + } + return lines + } + + /** + * Write a single string to the black box. + * + * @param string String to be written. + */ + fun write(string: String) { } + +} + diff --git a/core/src/com/unciv/scripting/protocol/ScriptingProtocol.kt b/core/src/com/unciv/scripting/protocol/ScriptingProtocol.kt new file mode 100644 index 0000000000000..8d6627677c772 --- /dev/null +++ b/core/src/com/unciv/scripting/protocol/ScriptingProtocol.kt @@ -0,0 +1,455 @@ +package com.unciv.scripting.protocol + +import com.unciv.scripting.AutocompleteResults +import com.unciv.scripting.ExecResult +import com.unciv.scripting.reflection.FunctionDispatcher +import com.unciv.scripting.reflection.Reflection +import com.unciv.scripting.utils.ScriptingDebugParameters +import com.unciv.scripting.serialization.TokenizingJson +import com.unciv.ui.utils.stringifyException +import kotlin.random.Random +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import java.util.UUID + + +// See Module.md for a description of the protocol. +// Please update the specifications there if you add, remove, or change any action types or packet structures here. + +// To add a new packet type: +// Add to Module.md. +// Add to responseTypes. +// Add to makeActionRequests and parseActionResponses, or to makeActionResponse. +// Use in scripting backend, E.G. wrapping.py + + +// TODO: Profile script execution. + + +/** + * Implementation of IPC packet specified in Module.md. + * + * @property action String or null specifying request or response type of packet. + * @property identifier Randomly generated String, or null, that should be shared by and unique to each request-response pair. + * @property data Arbitratry hierarchy of containers and values. + * @property flags Collection of Strings that communicate extra information in this packet. Can be used with null action. + */ +@Serializable +data class ScriptingPacket( + var action: String?, + var identifier: String?, + var data: JsonElement? = null, + // This can have arbitrary structure, values/types, and depth. So since it lives so close to the IPC protocol anyway, JsonElement is the easiest way to represent and serialize it. + var flags: Collection = listOf() +) { + + companion object { + /** + * Parse a JSON string into a ScriptingPacket instance. + + * Uses automatic Kotlin object tokenization by InstanceTokenizer through TokenizingJson. + + * @param string Valid JSON string. + * @return ScriptingPacket instance with properties and data field hierarchy of JSON string. + */ + fun fromJson(string: String): ScriptingPacket = TokenizingJson.json.decodeFromString(string) + } + + /** + * Encode this packet into a JSON string. + + * Uses automatic Kotlin object tokenization by InstanceTokenizer through TokenizingJson. + + * @return Valid JSON string. + */ + fun toJson() = TokenizingJson.json.encodeToString(this) + + /** + * @param flag Flag type to check for. + * @return Whether the given flag is present in this packet's flags field. + */ + fun hasFlag(flag: ScriptingProtocol.KnownFlag) = flag.name in flags + +} + + +/** + * Implementation of IPC communication protocol specified in Module.md. + * + * Does not handle transmission or reception. Only creates responses and requests. + * Agnostic to Unciv types. Protocol spec should be generic enough to apply to all Kotlin objects. + * Uses automatic Kotlin/JVM object tokenization by InstanceTokenizer through TokenizingJson. + * + * @property scope Kotlin/JVM object that represents the hierarchical root of actions that require recursively resolving a property path through reflection. In practice, this should never be set to anything other than an instance of ScriptingScope. But that's an API-level implementation and use case detail, so the protocol is typed for and should never assume anything more specific than Any. + * @property instanceSaver Mutable list in which to save response values, to prevent created instances from being garbage collected before the other end of the IPC can save or use them. List, not Set, to preserve instance identity and not value. Not automatically cleared. Should be manually cleared when not needed. + */ +class ScriptingProtocol(val scope: Any, val instanceSaver: MutableList? = null) { + + /** + * Enum class of valid items for the flag field in scripting packets. + * + * The flag field requires strings, so currently the .value property is usually used when constructing and working with scripting packets. + * + * @property name Serialized string value of this flag. + */ + enum class KnownFlag { + PassMic, // Names of these must match Module.md spec. + Exception + } + + companion object { + + /** + * @return Unique, never-repeating ID string. + */ + fun makeUniqueId(): String { + return "${System.nanoTime()}-${Random.nextBits(30)}-${UUID.randomUUID().toString()}" + } + + /** + * Map of valid request action types to their required response action types. + */ + val responseTypes = mapOf( + // Deliberately repeating myself because I don't want to imply a hard specification for the names of response packets. + "motd" to "motd_response", + "autocomplete" to "autocomplete_response", + "exec" to "exec_response", + "terminate" to "terminate_response", + "read" to "read_response", + "assign" to "assign_response", + "delete" to "delete_response", + "dir" to "dir_response", +// "hash" to "hash_response", + "keys" to "keys_response", + "length" to "length_response", + "contains" to "contains_response", + "ismapping" to "ismapping_response", + "callable" to "callable_response", + "args" to "args_response", + "docstring" to "docstring_response" + ) + + /** + * Ensure that a response packet is a valid response for a request packet. + * + * This function only checks the action type and unique identifier metadata of the packets. + * It should catch desynchronization arising from E.G. race conditions, or if one side either forgets to send an expected packet or erroneously sends an unexpected packet. + * But as the data field of each packet is variable, and defined more by convention/convergence than by specification, it will not catch if the data contained in the request or response is malformed or invalid. That task is left up to the sender and receiver of the packet. + * + * @param request Original packet. Sets action type and identifier. + * @param response Response packet. Should have matching action type and same identifier as request. + * @throws IllegalStateException If response and request mismatched. + */ + fun enforceIsResponse(request: ScriptingPacket, response: ScriptingPacket): ScriptingPacket { + if (!( + (response.action == responseTypes[request.action]!!) + && response.identifier == request.identifier + )) { + throw IllegalStateException("Scripting packet response does not match request ID and type: ${request}, ${response}") + } + return response + } + + } + + + /** + * Functions to generate requests to send to a script interpreter. + * + * Implements the specifications on packet fields and structure in Module.md. + * Function names and call arguments parallel ScriptingBackend. + */ + object makeActionRequests { + fun motd() = ScriptingPacket( + "motd", + makeUniqueId(), + JsonNull, + listOf(KnownFlag.PassMic.name) + ) + fun autocomplete(command: String, cursorPos: Int?) = ScriptingPacket( + "autocomplete", + makeUniqueId(), + JsonObject(mapOf("command" to JsonPrimitive(command), "cursorpos" to JsonPrimitive(cursorPos))), + listOf(KnownFlag.PassMic.name) + ) + fun exec(command: String) = ScriptingPacket( + "exec", + makeUniqueId(), + JsonPrimitive(command), + listOf(KnownFlag.PassMic.name) + ) + fun terminate() = ScriptingPacket( + "terminate", + makeUniqueId() + ) + } + + /** + * Functions to parse a response packet received after a request packet sent to a scripting interpreter. + * + * Implements the specifications on packet fields and structure in Module.md. + * Function names and return types parallel ScriptingBackend. + */ + object parseActionResponses { + fun motd(packet: ScriptingPacket): String = (packet.data as JsonPrimitive).content + + fun autocomplete(packet: ScriptingPacket): AutocompleteResults = + if (packet.data is JsonArray) + AutocompleteResults((packet.data as List).map { (it as JsonPrimitive).content }) + else + AutocompleteResults(helpText = (packet.data as JsonPrimitive).content) + + fun exec(packet: ScriptingPacket) = ExecResult( + resultPrint = (packet.data as JsonPrimitive).content, + isException = packet.hasFlag(KnownFlag.Exception) + ) + + fun terminate(packet: ScriptingPacket): Exception? = + if (packet.data == JsonNull || packet.data == null) + null + else + RuntimeException((packet.data as JsonPrimitive).content) + } + + /** + * Save an instance in the mutable list cache that prevents generated responses from being garbage-collected before the other end of the protocol can use them. + * + * @param obj Instance to save. + * @return Same instance as given, unchanged. Allows this function to be chained, or used to pass through an anonymous instance. + */ + private fun trySaveInstance(obj: T): T { + instanceSaver?.add(obj)//TODO: I should use this in more of the request types. + return obj + } + + // Helper class for parsing the data field of a common request packet structure from script interpreters. + + // THIS IS A CONVENTION THAT MANY REQUEST TYPES HAVE CONVERGED ON, NOT A SPECIFICATION THAT IS INTEGRAL TO THE PROTOCOL ITSELF. + // See Module.md. + + // @param packet Packet from which to read the data field. + private class ScriptingPacketPathedData(val packet: ScriptingPacket) { + val packetData = packet.data as JsonObject + + val use_root = (getRealOrNull(packetData["use_root"]) ?: false) as Boolean + val root = getRealOrNull(packetData["root"]) + val path = TokenizingJson.json.decodeFromJsonElement>(packetData["path"]!!) + val value = getRealOrNull(packetData["value"]) + + init { + if (ScriptingDebugParameters.printAccessForDebug) printDebug() + } + + private fun getRealOrNull(jsonElement: JsonElement?): Any? { + if (jsonElement == null) return null + return TokenizingJson.getJsonReal(jsonElement) + } + + private fun printDebug() = println("${packet.action}: ${if (use_root) "Root: ${root} " else ""}Path: ${Reflection.stringifyKotlinPath(path)}${if (value == null) "" else " Value: ${value}"}") + } + + /** + * Return a valid response packet for a request packet from a script interpreter. + * + * This is what allows scripts to access Kotlin/JVM properties, methods, keys, etc. + * Implements the specifications on action and response types, structures, and behaviours in Module.md. + * + * @param packet Packet to respond to. + * @return Response packet. + */ + fun makeActionResponse(packet: ScriptingPacket): ScriptingPacket { + val action = responseTypes[packet.action]!! + var responseData: JsonElement? = null + val responseFlags = mutableListOf() + when (packet.action) { + // There's a lot of repetition here, because I don't want to enforce any specification on what form the request and response data fields for actions must take. + // I prefer to try to keep the code for each response type independent enough to be readable on its own. + // This is kinda the reference (and only) implementation of the protocol spec. So the serialization and such can be and is handled with functions, but the actual structure and logic of each response should be hardcoded manually IMO. + "read" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + responseData = TokenizingJson.getJsonElement( + trySaveInstance( + Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + ), + requireTokenization = TokenizingJson::isNotPrimitive + ) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "assign" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + Reflection.setInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path, + packetData.value + ) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "delete" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + Reflection.removeInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "dir" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + responseData = TokenizingJson.getJsonElement(leaf!!::class.members.map {it.name}) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "keys" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = trySaveInstance(Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + )) + responseData = TokenizingJson.getJsonElement((leaf as Map).keys) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "length" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + try { + responseData = TokenizingJson.getJsonElement((leaf as Array<*>).size) + // AFAICT avoiding these casts/checks would require reflection. + } catch (e: ClassCastException) { + try { + responseData = TokenizingJson.getJsonElement((leaf as Map<*, *>).size) + } catch (e: ClassCastException) { + responseData = TokenizingJson.getJsonElement((leaf as Collection<*>).size) + } + } + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "contains" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + try { + responseData = TokenizingJson.getJsonElement(packetData.value in (leaf as Map)) + } catch (e: ClassCastException) { + responseData = TokenizingJson.getJsonElement(packetData.value in (leaf as Collection)) + } + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "ismapping" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + try { + leaf as Map + // Ensure same behaviour as "keys" action. IK It's probably/hopefully the same as using the is operator, but I'm not sure. + // TODO: Make this and other key operations work with operator overloading. Though Map is already an interface that anything can implement, so maybe not. + responseData = TokenizingJson.getJsonElement(true) + } catch (e: ClassCastException) { + responseData = TokenizingJson.getJsonElement(false) + } + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "callable" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + responseData = TokenizingJson.getJsonElement(leaf is FunctionDispatcher) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "args" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + responseData = TokenizingJson.getJsonElement( + mapOf>>( + *((leaf as FunctionDispatcher).functions.map { + it.toString() to it.parameters.map { listOf(it.name?.toString(), it.type.toString()) } + }).toTypedArray() + // The innermost listOf should semantically be a Pair as per the spec in Module.md, but a List seems safer to serialize. + ) + ) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + "docstring" -> { + try { + val packetData = ScriptingPacketPathedData(packet) + val leaf = Reflection.resolveInstancePath( + if (packetData.use_root) packetData.root else scope, + packetData.path + ) + responseData = TokenizingJson.getJsonElement(leaf.toString()) + } catch (e: Exception) { + responseData = JsonPrimitive(e.stringifyException()) + responseFlags.add(KnownFlag.Exception.name) + } + } + else -> { + throw IllegalArgumentException("Unknown action received in scripting request packet: ${packet.action}") + } + } + return ScriptingPacket(action, packet.identifier, responseData, responseFlags) + } + +} diff --git a/core/src/com/unciv/scripting/protocol/ScriptingReplManager.kt b/core/src/com/unciv/scripting/protocol/ScriptingReplManager.kt new file mode 100644 index 0000000000000..2e0cc5fd71aa1 --- /dev/null +++ b/core/src/com/unciv/scripting/protocol/ScriptingReplManager.kt @@ -0,0 +1,143 @@ +package com.unciv.scripting.protocol + +import com.unciv.scripting.AutocompleteResults +import com.unciv.scripting.ExecResult +import com.unciv.scripting.ScriptingImplementation +import com.unciv.scripting.utils.ScriptingDebugParameters + +//import com.unciv.scripting.protocol.ScriptingPacket +//import com.unciv.scripting.protocol.ScriptingProtocol + + +abstract class ScriptingReplManager(val scope: Any, val blackbox: Blackbox): ScriptingImplementation { + // + + //Thus, separate partly as a semantic distinction. ScriptingBackend is designed mostly to interact with ScriptingState and (indirectly, through ScriptingState) ConsoleScreen by presenting a clean interface to shallower classes in the call stack. This class is designed to do the opposite, and keep all the code for wrapping the interfaces of the deeper and more complicated ScriptingProtocol and Blackbox classes in one place. +} + + +/** + * REPL manager that sends and receives only raw code and prints raw strings with a black box. Allows interacting with an external script interpreter, but not suitable for exposing Kotlin-side API in external scripts. + */ +class ScriptingRawReplManager(scope: Any, blackbox: Blackbox): ScriptingReplManager(scope, blackbox) { + + override fun motd(): String { + return "${exec("motd()\n")}\n" + } + + override fun autocomplete(command: String, cursorPos: Int?): AutocompleteResults { + return AutocompleteResults() + } + + override fun exec(command: String): ExecResult { + if (!blackbox.readyForWrite) { + throw IllegalStateException("REPL not ready: ${blackbox}") + } else { + blackbox.write(command) + return ExecResult(blackbox.readAll(block=true).joinToString("\n")) + } + } + + override fun terminate(): Exception? { + return blackbox.stop() + } +} + + +/** + * REPL manager that uses the IPC protocol defined in ScriptingProtocol.kt to communicate with a black box. Suitable for presenting arbitrary access to Kotlin/JVM state to scripting APIs. See Module.md for a detailed description of the REPL loop. + */ +class ScriptingProtocolReplManager(scope: Any, blackbox: Blackbox): ScriptingReplManager(scope, blackbox) { + + /** + * ScriptingProtocol puts references to pre-tokenized returned objects in here. + * Should be cleared here at the end of each REPL execution. + * + * This makes sure a single script execution doesn't get its tokenized Kotlin/JVM objects garbage collected, and has a chance to save them elsewhere (E.G. ScriptingScope.apiHelpers.registeredInstances) if it needs them later. + * Should preserve each instance, not just each value, so should be List and not Set. + * To test in Python console backend: x = apiHelpers.Jvm.Vector2(1,2); civInfo.endTurn(); print(apiHelpers.toString(x)) + */ + val instanceSaver = mutableListOf() + + val scriptingProtocol = ScriptingProtocol(scope, instanceSaver = instanceSaver) + + //TODO: Doc + fun getRequestResponse(packetToSend: ScriptingPacket, enforceValidity: Boolean = true, execLoop: () -> Unit = fun(){}): ScriptingPacket { + // Please update the specifications in Module.md if you change the basic structure of this REPL loop. + if (ScriptingDebugParameters.printPacketsForDebug) println("\nSending: ${packetToSend}") + blackbox.write(packetToSend.toJson() + "\n") + execLoop() + val response = ScriptingPacket.fromJson(blackbox.read(block=true)) + if (ScriptingDebugParameters.printPacketsForDebug) println("\nReceived: ${response}") + if (enforceValidity) { + ScriptingProtocol.enforceIsResponse(packetToSend, response) + } + instanceSaver.clear() // Clear saved references to objects in response, now that the script has had a chance to save them elsewhere. + return response + } + + /** + * Listens to requests for values from the black box, and replies to them, during script execution. + * Terminates loop after receiving a request with a the 'PassMic' flag. + */ + fun foreignExecLoop() { + while (true) { + val request = ScriptingPacket.fromJson(blackbox.read(block=true)) + if (ScriptingDebugParameters.printPacketsForDebug) println("\nReceived: ${request}") + if (request.action != null) { + val response = scriptingProtocol.makeActionResponse(request) + if (ScriptingDebugParameters.printPacketsForDebug) println("\nSending: ${response}") + blackbox.write(response.toJson() + "\n") + } + if (request.hasFlag(ScriptingProtocol.KnownFlag.PassMic)) { + break + } + } + } + + override fun motd(): String { + return ScriptingProtocol.parseActionResponses.motd( + getRequestResponse( + ScriptingProtocol.makeActionRequests.motd(), + execLoop = ::foreignExecLoop + ) + ) + } + + override fun autocomplete(command: String, cursorPos: Int?): AutocompleteResults { + return ScriptingProtocol.parseActionResponses.autocomplete( + getRequestResponse( + ScriptingProtocol.makeActionRequests.autocomplete(command, cursorPos), + execLoop = ::foreignExecLoop + ) + ) + } + + override fun exec(command: String): ExecResult { + if (!blackbox.readyForWrite) { + throw IllegalStateException("REPL not ready: ${blackbox}") // Switch to ExecResult() return? + } else { + return ScriptingProtocol.parseActionResponses.exec( + getRequestResponse( + ScriptingProtocol.makeActionRequests.exec(command), + execLoop = ::foreignExecLoop + ) + ) + } + } + + override fun terminate(): Exception? { + try { + val msg = ScriptingProtocol.parseActionResponses.terminate( + getRequestResponse( + ScriptingProtocol.makeActionRequests.terminate() + ) + ) + if (msg != null) { + return RuntimeException(msg) + } + } catch (e: Exception) { + } + return blackbox.stop() + } +} diff --git a/core/src/com/unciv/scripting/protocol/SubprocessBlackbox.kt b/core/src/com/unciv/scripting/protocol/SubprocessBlackbox.kt new file mode 100644 index 0000000000000..667359caa0a88 --- /dev/null +++ b/core/src/com/unciv/scripting/protocol/SubprocessBlackbox.kt @@ -0,0 +1,114 @@ +package com.unciv.scripting.protocol + +import java.io.* + + +/** + * Blackbox that launches and wraps a child process, allowing interacting with it using a common interface. + * + * @property processCmd String Array of the command to run to start the child process. + */ +class SubprocessBlackbox(val processCmd: Array): Blackbox { + + /** + * The wrapped process. + */ + var process: Process? = null + + /** + * STDOUT of the wrapped process, or null. + */ + var inStream: BufferedReader? = null + + /** + * STDIN of the wrapped process, or null. + */ + var outStream: BufferedWriter? = null + + /** + * Null, or error message string if launching the process produced an exception. + */ + var processLaunchFail: String? = null + + override val isAlive: Boolean + get() { + return try { + @Suppress("NewApi") + process != null && process!!.isAlive() + // Usually process will be null on Android anyway. + } catch(e: NoSuchMethodError) { true } // NoSuchMethodError is for compiled access. NoSuchMethodException is for reflective access. There's no reflection happening in the try{} block. + // I'm not planning to use subprocesses on Android, so it's okay if the catch{} block returns an incorrect answer. + // But if subprocesses are to be used on Android, then more work should be done to return an accurate answer in all cases. + } + + override val readyForWrite: Boolean + get() = isAlive + + override val readyForRead: Int + get() = if (isAlive && inStream!!.ready()) 1 else 0 + + init { + start() + } + + override fun toString(): String { + return "${this::class.simpleName}(processCmd=${processCmd}).apply{ process=${process}; inStream=${inStream}; outStream=${outStream}; processLaunchFail=${processLaunchFail} }" + } + + /** + * Launch the child process. + * + * Set the inStream and outStream to readers and writers for its STDOUT and STDIN respectively if successful. + * Set processLauchFail to the exception raised if launching produces an exception. + * + * @throws RuntimeException if the process is already running. + */ + override fun start() { + if (isAlive) { + throw RuntimeException("Process is already running: ${process}") // Could translate. Probably shouldn't. + } + try { + process = Runtime.getRuntime().exec(processCmd) + } catch (e: Exception) { + process = null // Comment this out to test the API level thing. + processLaunchFail = e.toString() + return + } + inStream = BufferedReader(InputStreamReader(process!!.getInputStream())) + outStream = BufferedWriter(OutputStreamWriter(process!!.getOutputStream())) + } + + override fun stop(): Exception? { + try { + if (isAlive) { + process!!.destroy() + } + } catch (e: Exception) { + return e + } finally { + try { + inStream!!.close() + outStream!!.close() + } catch (e: Exception) { + } + process = null + inStream = null + outStream = null + } + return null + } + + override fun read(block: Boolean): String { // TODO: Max wait time (and periodic checking that process hasn't crashed) possible? + if (block || readyForRead > 0) { + return inStream!!.readLine() + } else { + throw IllegalStateException("Empty STDOUT for ${process}.") // Could translate. Probably shouldn't. + } + } + + override fun write(string: String) { + outStream!!.write(string) + outStream!!.flush() + } + +} diff --git a/core/src/com/unciv/scripting/reflection/FunctionDispatcher.kt b/core/src/com/unciv/scripting/reflection/FunctionDispatcher.kt new file mode 100644 index 0000000000000..e75354ce7fdbe --- /dev/null +++ b/core/src/com/unciv/scripting/reflection/FunctionDispatcher.kt @@ -0,0 +1,188 @@ +package com.unciv.scripting.reflection + +import kotlin.reflect.KCallable +import kotlin.reflect.KParameter +import kotlin.reflect.KType +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.isSubtypeOf +import kotlin.reflect.full.isSuperclassOf +import kotlin.reflect.full.isSupertypeOf +import kotlin.reflect.jvm.jvmErasure + +// I'm choosing to define this as a class to avoid having to pass the three configuration parameters through every function, but I do suspect that instantiating the class for every dynamic function call may be measurably slower than making the whole class a singleton object and passing the configurations through function arguments instead. + +// Return a [FunctionDispatcher]() with consistent settings for the scripting API. +fun makeFunctionDispatcher(functions: Collection>) = FunctionDispatcher( + functions = functions, + matchNumbersLeniently = true, + matchClassesQualnames = false, + resolveAmbiguousSpecificity = true +) + +/** + * Dynamic dispatch to one of multiple KCallables. + * + * Uses reflection to narrow down functions to the one(s) that have the correct signature for a given array of arguments + * + * Varargs can be used, but they must be supplied as a single correctly typed array instead of as separate arguments. + * + * @property functions List of functions against which to resolve calls. + * @property matchNumbersLeniently Whether to treat all numeric types as the same. Useful for E.G. untyped deserialized data. Adds small extra step to most calls. + * @property matchClassesQualnames Whether to treat classes as the same if they have the same qualifiedName. Useful for E.G. ignoring the invariant arrays representing vararg parameters. Adds small extra step to some calls. + * @property resolveAmbiguousSpecificity Whether to try to resolve multiple ambiguous matching signatures by finding one that strictly subtypes all others. Rules for this are documented under getMostSpecificCallable. Does not add any extra steps unless needed; Increases function domain properly handled but does not decrease performance in other uses. + */ +open class FunctionDispatcher( + val functions: Collection>, + val matchNumbersLeniently: Boolean = false, + val matchClassesQualnames: Boolean = false, + val resolveAmbiguousSpecificity: Boolean = false +) { + + // Could try to implement KCallable interface. But not sure it's be worth it or map closely enough— What do lambdas do? I guess isOpen, isAbstract, etc should just all be False? + + // Not supporting varargs for now. Doing so would require rebuilding the arguments array to move all vararg arguments into a new array for KCallable.call(). + + // Right. It's called "Overload Resolution" when done statically, and Kotlin has specs under that title. + + /** + * @return Whether a given argument value can be cast to the type of a given KParameter. + */ + private fun checkParameterMatches(kparam: KParameter, arg: Any?, paramKtypeAppend: ArrayList): Boolean { + // If performance becomes an issue, try inlining these. Then again, the JVM presumably optimizes it at runtime already (and there's far more calls than this containing function). + paramKtypeAppend.add(kparam.type) + if (arg == null) { + // Multiple dispatch of null between Any and Any? seems ambiguous in Kotlin even without reflection. + // Here, I'm resolving it myself, so it seems fine. + // However, with generics, even if I find the right KCallable, it seems that a nullable argument T? will usually (but not always, depending on each time you compile) be sent to the non-nullable T version of the function if one has been defined. + // KCallable.toString() shows the nullable signature, and KParam.name shows the argument name from the nullable version. But an exception is still thrown on .call() with null, and its text will use the argument name from the non-nullable version. + // I suppose it's not a problem here as it seems broken in Kotlin generally. + return kparam.type.isMarkedNullable + } + val kparamcls = kparam.type.jvmErasure + val argcls = arg::class + if (matchNumbersLeniently && argcls.isSubclassOf(Number::class) && kparamcls.isSubclassOf(Number::class)) { + // I think/hope this basically causes Java-style implicit conversions (or Kotlin implicit casts?). + // NOTE: However, doesn't correctly forbid unconvertible types. E.G. Doubles match against Floats. + // Info: https://docs.oracle.com/javase/specs/jls/se7/html/jls-5.html#jls-5.1.2 + return true + } + return kparamcls.isSuperclassOf(argcls) + // Seems to also work for generics, I guess. + || (matchClassesQualnames && kparamcls.qualifiedName != null && argcls.qualifiedName != null && kparamcls.qualifiedName == argcls.qualifiedName) + // Lets more types be matched to invariants, such as for vararg arrays. + // However, the JVM still throws its own error in that case, so leaving this disabled for now. + } + + /** + * @return Whether a given KCallable's signature might be compatible with a given Array of arguments. + */ + private fun checkCallableMatches(callable: KCallable, arguments: Array, paramKtypeAppends: HashMap, ArrayList>): Boolean { + // I'm not aware of any situation where this function's behaviour will deviate from Kotlin, but that doesn't mean there aren't any. Wait, no. I do know that runtime checking and resolution of erased generics will probably be looser than at compile time. They seem to act like Any(?). + val ktypeappend = arrayListOf() + paramKtypeAppends[callable] = ktypeappend + val params = callable.parameters + return params.size == arguments.size + && (params zip arguments).all { // Check argument classes match parameter types, skipping the receiver. + (kparam, arg) -> checkParameterMatches(kparam, arg, paramKtypeAppend=ktypeappend) + } + } + + /** + * @return The KCallables that have a signature which may be compatible with a given Array of arguments. + */ + private fun getMatchingCallables(arguments: Array, paramKtypeAppends: HashMap, ArrayList>): List> { + // Private because subclasses may choose to modify the arguments passed to call(). + return functions.filter { checkCallableMatches(it, arguments, paramKtypeAppends) } + } + + // Given a List of KCallables and a Map of their parameters' types, try to find one KCallable the signature of which is a subtype of all of the others. + + // For a KCallable to be returned, the following criteria must be true: + // Every relevant parameter type in it must be either the same as the corresponding parameter type in every other KCallable or a subtype of the corresponding parameter type in every other KCallable. + // At least one parameter type in it must be a strict subtype of the corresponding parameter type for every other KCallable. + + // This is essentially equivalent to the behaviour specified in Chapter 11.7 "Choosing the most specific candidate from the overload candidate set" of the Kotlin language specification. + + // + private fun getMostSpecificCallable(matches: List>, paramKtypes: Map, ArrayList>): KCallable? { + // Private because subclasses may choose to modify the arguments passed to call(). + // Should only be called when multiple/ambiguous signatures have been found. + return matches.firstOrNull { // Check all signatures. + val checkcallable = it // The signature we are currently checking for specificity. + val checkparamktypes = paramKtypes[checkcallable]!! + matches.all { // Compare currently checked signature to all other signatures. It must be a strict subtype of all other signatures. + val othercallable = it + if (checkcallable == othercallable) { + // Don't check against itself. + return@all true + } + var subtypefound = false + var supertypefound = false + for ((checkktype, otherktype) in checkparamktypes zip paramKtypes[othercallable]!!) { + // Compare all parameter types of currently checked signature to all parameter types of the other signature we are currently comparing it to. + if (checkktype == otherktype) { + // Identical types that neither allow nor forbid a match. + continue + } + if (!subtypefound && checkktype.isSubtypeOf(otherktype)) { + // At least one strict subtype is needed. + subtypefound = true + } + if (checkktype.isSupertypeOf(otherktype)) { + // No strict supertypes are allowed. + supertypefound = true + break + } + } + (subtypefound && !supertypefound) + // I did something similar for Cython once. Well, specifically, someone else had done something similar, and like this, it was running in exponential time or something, so I made it faster by building an index. + } + } + } + + /** + * Call the correct function for a given array of arguments. + * + * @param arguments The arguments with which to call the function. + * @return The result from dispatching the given arguments to the function definition with a compatible signature. + * @throws IllegalArgumentException If no compatible signature was found, or if more than one compatible signature was found. + */ + open fun call(arguments: Array): R { + // KCallable's .call() takes varargs instead of an array object. But spreads are expensive, so I'm not doing that. + // To test from Python: + // gameInfo.civilizations.add(1, civInfo) + // gameInfo.civilizations.add(civInfo) + // Both need to work. + // Supporting named parameters would greatly complicate both signature matching and specificity resolution, and is not currently planned. + val callableparamktypes = hashMapOf, ArrayList>() + // Map of all traversed KCallables to lists of their parameters' KTypes. + // Only parameters, and not arguments, are saved, though both are traversed. + // KCallables that don't match the call arguments should only have as many parameter KTypes saved as it took to find out they don't match. + val matches = getMatchingCallables(arguments, paramKtypeAppends=callableparamktypes) + var match: KCallable? = null + if (matches.isEmpty()) { + throw IllegalArgumentException("No matching signatures found for calling ${nounifyFunctions()} with given arguments: (${arguments.map {if (it == null) "null" else it::class?.simpleName ?: "null"}.joinToString(", ")})") + } + if (matches.size > 1) { + if (resolveAmbiguousSpecificity) { + //Kotlin seems to choose the most specific signatures based on inheritance hierarchy. + //E.G. UncivGame.setScreen(), which uses a more specific parameter type than its GDX base class and thus creates a different, semi-ambiguous (sub-)signature, but still gets resolved. + //Yeah, Kotlin semantics "choose the most specific function": https://kotlinlang.org/spec/overload-resolution.html#the-forms-of-call-expression + match = getMostSpecificCallable(matches, paramKtypes = callableparamktypes) + } + if (match == null) { + throw IllegalArgumentException("Multiple matching signatures found for calling ${nounifyFunctions()} with given arguments:\n\t(${arguments.map {if (it == null) "null" else it::class?.simpleName ?: "null"}.joinToString(", ")})\n\t${matches.map {it.toString()}.joinToString("\n\t")}") + } + } else { + match = matches[0]!! + } + return match.call( + *arguments + ) as R + } + + /** + * @return A short, human-readable string that describes the target functions collectively. + */ + open fun nounifyFunctions() = "${functions.size} functions" +} diff --git a/core/src/com/unciv/scripting/reflection/Reflection.kt b/core/src/com/unciv/scripting/reflection/Reflection.kt new file mode 100644 index 0000000000000..b01ae98be8753 --- /dev/null +++ b/core/src/com/unciv/scripting/reflection/Reflection.kt @@ -0,0 +1,448 @@ +package com.unciv.scripting.reflection + +import com.unciv.scripting.serialization.TokenizingJson +import com.unciv.ui.utils.stringifyException +import kotlin.collections.ArrayList +import kotlinx.serialization.Serializable +import kotlin.reflect.* +import kotlin.reflect.full.* + +// I've noticed that the first time running a script is significantly slower than any subsequent times. Takes 50% longer to run the Python test suite the first time than the second time, and simple functions go from incurring a noticeable delay to being visually instant. +// I don't think anything either can or needs to be done about that, but I assume it's the JVM JIT'ing. + + +fun allCommonSuperclasses(classes: Iterable>): Set> { + val allSupers = classes.asSequence().map { it.allSuperclasses.asSequence() }.flatMap { it }.toSet() + return allSupers.asSequence().filter { checkSuper -> classes.all { checkSuper.isSuperclassOf(it) } }.toSet() // Sequence, even though only one loop, because .filter returns a List() and I want a Set(). +} + +fun mostSpecificClassOrNull(classes: Set>) = classes.firstOrNull { checkCls -> classes.all { checkCls.isSubclassOf(it) } } + +fun Iterable.mostSpecificCommonSuperclassOrNull() = mostSpecificClassOrNull(allCommonSuperclasses(this.map { it::class } )) + +fun Iterable.mostSpecificCommonSupertypeOrNull(): KType? { + var isNullable = false + val nonNull = mutableSetOf() + for (v in this) { // I've been coding under the Pythonic assumption that iterable comprehensions will be much faster than loops, but I guess that probably doesn't actually apply on the JVM. + if (v == null) { + isNullable = true + } else { + nonNull.add(v) + } + } + val mostSpecific = nonNull.mostSpecificCommonSuperclassOrNull() + return if (mostSpecific == null) { + mostSpecific + } else { + mostSpecific.starProjectedType.withNullability(isNullable) + } +} + + +// TODO: Show warning on accessing deprecated property? + +object Reflection { + + @Suppress("UNCHECKED_CAST") + fun readClassProperty(cls: KClass<*>, propertyName: String) + = (cls.members.first { it.name == propertyName } as KProperty0<*>).get() as R? + + @Suppress("UNCHECKED_CAST") + fun readInstanceProperty(instance: Any, propertyName: String): R? { + // From https://stackoverflow.com/a/35539628/12260302 + val kprop = (instance::class.members.first { it.name == propertyName } as KProperty1) // Memoization candidates? I already have LazyMap, which should work for this. + return (if (kprop.isConst) + kprop.getter.call() + else + kprop.get(instance)) as R? + // KProperty1().get(instance) Fails for consts: apiHelpers.Jvm.singletonByQualname["com.unciv.Constants"].close + // m=next(m for m in apiHelpers.Jvm.classByQualname["com.unciv.Constants"].members if m.name == 'close') + // object o {val a=1; const val b=2} + // (o::class.members.first{it.name == "a"} as KProperty1).get(o) + // (o::class.members.first{it.name == "b"} as KProperty1).getter.call() + } + + // Return an [InstanceMethodDispatcher]() with consistent settings for the scripting API. + fun makeInstanceMethodDispatcher(instance: Any, methodName: String) = InstanceMethodDispatcher( + instance = instance, + methodName = methodName, + matchNumbersLeniently = true, + matchClassesQualnames = false, + resolveAmbiguousSpecificity = true + ) + + /** + * Dynamic multiple dispatch for Any Kotlin instances by methodName. + * + * Uses reflection to first find all members matching the expected method name, and then to call the correct method for given arguments. + * + * See the [FunctionDispatcher] superclass for details on the method resolution strategy and configuration parameters. + * + * @property instance The receiver on which to find and call a method. + * @property methodName The name of the method to resolve and call. + */ + class InstanceMethodDispatcher( + val instance: Any, + val methodName: String, + matchNumbersLeniently: Boolean = false, + matchClassesQualnames: Boolean = false, + resolveAmbiguousSpecificity: Boolean = false + ) : FunctionDispatcher( + functions = instance::class.members.filter { it is KFunction<*> && it.name == methodName }, + // TODO: .functions? Choose one that includes superclasses but excludes extensions. + // FIXME: Right. Cell.row is an example of a name used as both a property and a function. + // p=apiHelpers.Jvm.constructorByQualname["com.unciv.ui.utils.Popup"](uncivGame.consoleScreen); disp=p.add(apiHelpers.Jvm.functionByQualClassAndName["com.unciv.ui.utils.ExtensionFunctionsKt"]["toLabel"]("Test Text.")).row + // [apiHelpers.Jvm.classByInstance[f] for f in disp.functions] + // KFunctionImpl vs KMutableProperty1Impl, apparently. + // Adding `is Function` to the filter should do it, I think? + // apiHelpers.Jvm.classByQualname["com.badlogic.gdx.scenes.scene2d.ui.Cell"].members + // apiHelpers.Jvm.classByQualname["com.badlogic.gdx.scenes.scene2d.ui.Cell"] + // apiHelpers.Jvm.functionByQualClassAndName["kotlin.reflect.full.KClasses"]["getFunctions"](apiHelpers.Jvm.classByQualname["com.badlogic.gdx.scenes.scene2d.ui.Cell"]) + matchNumbersLeniently = matchNumbersLeniently, + matchClassesQualnames = matchClassesQualnames, + resolveAmbiguousSpecificity = resolveAmbiguousSpecificity + ) { + + // This isn't just a nice-to-have feature. Before I implemented it, identical calls from demo scripts to methods with multiple versions (E.G. ArrayList().add()) would rarely but randomly fail because the member/signature that was found would change between runs or compilations. + + // TODO: This is going to need unit tests. + + /** + * @return Helpful representative text. + */ + override fun toString() = """${this::class.simpleName}(instance=${this.instance::class.simpleName}(), methodName="${this.methodName}") with ${this.functions.size} dispatch candidates""" + // Used by "docstring" packet action in ScriptingProtocol, which is in turn exposed in interpreters as help text. + + override fun call(arguments: Array): R { + return super.call(arrayOf(instance, *arguments)) + // Add receiver to arguments. + } + + override fun nounifyFunctions() = "${instance::class?.simpleName}.${methodName}" + } + + + fun readInstanceItem(instance: Any, keyOrIndex: Any): R { + // TODO: Make this work with operator overloading. Though Map is already an interface that anything can implement, so maybe not. + if (keyOrIndex is Int) { + return try { (instance as List)[keyOrIndex] } + catch (e: ClassCastException) { (instance as Array)[keyOrIndex] } as R + } else { + return (instance as Map)[keyOrIndex] as R + } + } + + + fun setInstanceProperty(instance: Any, propertyName: String, value: T?) { + val property = instance::class.members + .first { it.name == propertyName } as KMutableProperty1 + property.set(instance, value) + } + + fun setInstanceItem(instance: Any, keyOrIndex: Any, value: Any?) { + if (keyOrIndex is Int) { + (instance as MutableList)[keyOrIndex] = value + } else { + (instance as MutableMap)[keyOrIndex] = value + } + } + + fun removeInstanceItem(instance: Any, keyOrIndex: Any) { + if (keyOrIndex is Int) { + (instance as MutableList).removeAt(keyOrIndex) + } else { + (instance as MutableMap).remove(keyOrIndex) + } + } + + + enum class PathElementType { + Property, + Key, + Call + } + + @Serializable + data class PathElement( + val type: PathElementType, + val name: String, + /** + * For key and index accesses, and function calls, whether to evaluate name instead of using params for arguments/key. + * This lets simple parsers be written and used, that can simply break up a common subset of many programming languages into string components without themselves having to analyze or understand any more complex semantics. + * + * Default should be false, so deserialized JSON path lists are configured correctly in ScriptingProtocol.kt. + */ + val doEval: Boolean = false, + val params: List<@Serializable(with=TokenizingJson.TokenizingSerializer::class) Any?> = listOf() + //val namedParams + //Probably not worth it. But if you want to add support for named arguments in calls (which will also require changing InstanceMethodDispatcher's multiple dispatch resolution, and which respect default arguments), then it will probably have to be in a new field. + ) + + + private val brackettypes: Map = mapOf( + '[' to "[]", + '(' to "()" + ) + + private val bracketmeanings: Map = mapOf( + "[]" to PathElementType.Key, + "()" to PathElementType.Call + ) + + fun parseKotlinPath(code: String): List { // Probably don't need unit tests specifically for this. Any scripting backend unit tests will be implicitly using it anyway, and in this case, the test cases for ReflectiveScriptingBackend are basically reference inputs. + var path: MutableList = ArrayList() + //var curr_type = PathElementType.Property + var curr_name = ArrayList() + var curr_brackets = "" + var curr_bracketdepth = 0 + var just_closed_brackets = true + for (char in code) { + if (curr_bracketdepth == 0) { + if (char == '.') { + if (!just_closed_brackets) { + path.add(PathElement( + PathElementType.Property, + curr_name.joinToString("") + )) + } + curr_name.clear() + just_closed_brackets = false + continue + } + if (char in brackettypes) { + if (!just_closed_brackets) { + path.add(PathElement( + PathElementType.Property, + curr_name.joinToString("") + )) + } + curr_name.clear() + curr_brackets = brackettypes[char]!! + curr_bracketdepth += 1 + just_closed_brackets = false + continue + } + curr_name.add(char) + } + just_closed_brackets = false + if (curr_bracketdepth > 0) { + if (char == curr_brackets[1]) { + curr_bracketdepth -= 1 + if (curr_bracketdepth == 0) { + path.add(PathElement( + bracketmeanings[curr_brackets]!!, + curr_name.joinToString(""), + true + )) + curr_brackets = "" + curr_name.clear() + just_closed_brackets = true + continue + } + } else if (char == curr_brackets[0]) { + curr_bracketdepth += 1 + } + curr_name.add(char) + } + } + if (!just_closed_brackets && curr_bracketdepth == 0) { + path.add(PathElement( + PathElementType.Property, + curr_name.joinToString("") + )) + curr_name.clear() + } + if (curr_bracketdepth > 0) { + throw IllegalArgumentException("Unclosed parentheses.") + } + return path + } + + + fun stringifyKotlinPath(path: List): String { + val components = ArrayList() + for (element in path) { // TODO: Encoded strings. + components.add( when (element.type) { + PathElementType.Property -> ".${element.name}" + PathElementType.Key -> "[${if (element.doEval) element.name else element.params[0]!!}]" + PathElementType.Call -> "(${if (element.doEval) element.name else element.params.joinToString(", ")})" + }) + } + return components.joinToString() + } + + + fun splitToplevelExprs( // Probably don't need unit tests specifically for this. Any scripting backend unit tests will be implicitly using it anyway, and in this case, the test cases for ReflectiveScriptingBackend are basically reference inputs. + code: String, + delimiters: CharSequence = ",", + bracketPairs: Map = mapOf('(' to ')', '[' to ']'), // Move defaults outside, so callers can E.G. flip them for a flipped string/maxParts. + // Don't give quote marks as brackets, because they act differently: First opening quote gets mistaken for unexpected closing bracket, and stuff inside them still won't be escaped. + maxParts: Int = 0, + backSlashEscape: Boolean = false // IDK about this. Simplicity, clarity, and reliability are more important here than being correct by an arbitrary and complicated standard— Point is to be able to parse an optimally useful and easy common subset of a lot of programming languages, in which context escapes are always a headache. + ): List { + if (code.isBlank()) + return listOf() + val subExprs = ArrayList() + var currentIndex = 0 + val bracketClosersStack = ArrayList() + val currExpr = ArrayList() + var lastChar: Char? = null + for ((i, char) in code.withIndex()) { + if ((backSlashEscape && lastChar == '\\') || (maxParts > 0 && subExprs.size >= maxParts - 1)) { + currExpr.add(char) + continue + } + if (bracketClosersStack.isEmpty() && char in delimiters) { + subExprs.add(currExpr.joinToString("")) + currExpr.clear() + continue + } + currExpr.add(char) + if (char in bracketPairs.values) { + if (char == bracketClosersStack.lastOrNull()) { + bracketClosersStack.removeLast() + continue + } else { + throw IllegalArgumentException("Unexpected bracket $char at index $i in code: $code") + } + } + val closingBracket = bracketPairs[char] + if (closingBracket != null) bracketClosersStack.add(closingBracket) + } + subExprs.add(currExpr.joinToString("")) + return subExprs + } + + fun splitToplevelExprs(code: String): List = splitToplevelExprs(code, ",") // For reflective use and debug— FunctionDispatcher doesn't use default args. + + + fun resolveInstancePath(instance: Any?, path: List): Any? { + //TODO: Allow passing an ((Any?)->Unit)? (or maybe Boolean) function as a parameter that gets called at every stage of resolution, to let exceptions be thrown if accessing something not whitelisted. + var obj: Any? = instance + for (element in path) { + try { + obj = when (element.type) { + PathElementType.Property -> { + try { + readInstanceProperty(obj!!, element.name) // Not explicitly typing the function call makes it always fail an implicit cast or something. + // TODO: Consider a LBYL instead of AFP here. + } catch (e: ClassCastException) { + makeInstanceMethodDispatcher( + obj!!, + element.name + ) + } + } + PathElementType.Key -> { + readInstanceItem( + obj!!, + if (element.doEval) + evalKotlinString(instance!!, element.name)!! + else + element.params[0]!! + ) + } + PathElementType.Call -> { + if (obj is FunctionDispatcher) { + // Undocumented implicit behaviour: Using the last object means that this should work with explicitly created FunctionDispatcher()s. + (obj).call( + ( + if (element.doEval) + splitToplevelExprs(element.name).map { evalKotlinString(instance!!, it) } + else + element.params + ).toTypedArray() + ) + } else { // Actual lambdas still don't work. Detecting functions with any number of args is apparently impossible in static Kotlin. + resolveInstancePath( // Might be a weird if this recurses… I think circular invoke properties would crash? + obj, + listOf( + PathElement( + type = PathElementType.Property, + name = "invoke" + ), + element + ) + ) + } + } + } + } catch (e: Exception) { + throw IllegalAccessException("Cannot access $element on $obj:\n${e.stringifyException().prependIndent("\t")}") + } + } + return obj + } + + + fun evalKotlinString(scope: Any?, string: String): Any? { + val trimmed = string.trim(' ') + if (trimmed == "null") { + return null + } + if (trimmed == "true") { + return true + } + if (trimmed == "false") { + return false + } + if (trimmed.length > 1 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1..trimmed.length-2) + } // TODO: Allow single-quoted strings? + val asint = trimmed.toIntOrNull() + if (asint != null) { + return asint + } + val asfloat = trimmed.toFloatOrNull() + if (asfloat != null) { + return asfloat + } + return resolveInstancePath(scope!!, parseKotlinPath(trimmed)) + } + + + fun setInstancePath(instance: Any?, path: List, value: Any?) { + val leafobj = resolveInstancePath(instance, path.slice(0..path.size-2)) + val leafelement = path[path.lastIndex] + when (leafelement.type) { + PathElementType.Property -> { + setInstanceProperty(leafobj!!, leafelement.name, value) + } + PathElementType.Key -> { + setInstanceItem( + leafobj!!, + if (leafelement.doEval) + evalKotlinString(instance, leafelement.name)!! + else + leafelement.params[0]!!, + value + ) + } + PathElementType.Call -> { + throw UnsupportedOperationException("Cannot assign to function call.") + } + } + } + + fun removeInstancePath(instance: Any?, path: List) { + val leafobj = resolveInstancePath(instance, path.slice(0..path.size-2)) + val leafelement = path[path.lastIndex] + when (leafelement.type) { + PathElementType.Property -> { + throw UnsupportedOperationException("Cannot remove instance property.") + } + PathElementType.Key -> { + removeInstanceItem( + leafobj!!, + if (leafelement.doEval) + evalKotlinString(instance, leafelement.name)!! + else + leafelement.params[0]!! + ) + } + PathElementType.Call -> { + throw UnsupportedOperationException("Cannot remove function call.") + } + } + } +} diff --git a/core/src/com/unciv/scripting/serialization/InstanceTokenizer.kt b/core/src/com/unciv/scripting/serialization/InstanceTokenizer.kt new file mode 100644 index 0000000000000..c67eb460ae971 --- /dev/null +++ b/core/src/com/unciv/scripting/serialization/InstanceTokenizer.kt @@ -0,0 +1,172 @@ +package com.unciv.scripting.serialization + +import com.unciv.scripting.ScriptingConstants +import com.unciv.scripting.utils.ScriptingDebugParameters +import com.unciv.scripting.utils.WeakIdentityMap +import java.lang.ref.WeakReference // Could use SoftReferences— Would seem convenient, but probably lead to mysterious bugs in scripts. +import java.util.UUID +import kotlin.math.floor +import kotlin.math.log +import kotlin.random.Random + + +/** + * Object that returns unique strings for any Kotlin/JVM instances, and then allows the original instances to be accessed given the token strings. + * + * Uses WeakReferences, so should not cause memory leaks on its own. + * + * Combined with TokenizingJson in ScriptingProtocol, allows scripts to handle unserializable objects, and use them in property/key/index assignments and function calls. + */ +object InstanceTokenizer { + + // Could even potentially get rid of `.registeredInstances` completely by automatically registering/reference counting in the JVM and freeing in scripting language destructors. But JS apparently doesn't give any way to control garbage collection, so the risk of memory leaks wouldn't be worth it. + + /** + * Map of currently known token strings to WeakReferences of the Kotlin/JVM instances they represent. + * Used for basic functionality of tracking tokens and transforming them back into arbitrary instances. + */ + private val instancesByTokens = mutableMapOf>() + + // Map of WeakReferences of Kotlin/JVM instances to token strings that represent them. + // Used to reuse existing tokens for previously tokenized objects, improving performance and avoiding a memory leak. + private val tokensByInstances = WeakIdentityMap() + // Without this, repeatedly running the Python tests led to a token count over 16835 after the first run, exceeding 25252 after the second run, and over 37887 after the third run, as of this comment. + // With it: Over 11223 after the first run, back down to 4399 in the middle of the second run and then up again to over 11223, down to 4396 in the middle of the third and back up to over 11223 afterwards, over 85000 following ten runs in a non-stop Python loop, but back down to 7809 after running as separate commands again and hitting the next cleanup at 127834. + // (Oh. Yeah. Duh. Because I made ScriptingProtocol save everything in the same script execution from being garbage collected, cleanup doesn't happen much in an ongoing Python loop. Oh well; That took a long time to run, and no script should be doing anywhere near that much in one go anyway (and even if it is processing that much data in one go, it should use its own data structures and only write out to Unciv at the very end).) + + // Logarithm of number of known tokens after the last cleaning, with tokenCountLogBase as base. Cleaning is triggered when changing this. + private var lastTokenCountLog: Int = 0 + // Logarithm base for lastTokenCountLog. Acts as factor threshold for cleaning invalid WeakReferences. + private const val tokenCountLogBase = 1.3f + + // Above this value, have a forceCleanChance to perform cleaning even if a no token count thresholds have been crossed. + // Needed because in theory, token count, as measured from a Collection size, can apparently max out with a large enough collection. + private const val forceCleanThreshold = Int.MAX_VALUE / 2 + // Chance of performing a cleaning when token count is above forceCleanThreshold. + private const val forceCleanChance = 0.001 + + // Compile-time flag on whether to keep track of previously tokenized objects and always reuse the same tokens for them. + // Disabling this could cause long-lived objects to create a lot of tokens that have no way of ever being cleaned. + // Keep it set to true to avoid scripts causing a memory leak and degrading token cleaning performance, basically. + private const val tokenReuse = true + // So why leave the flag in? It marks where the reuse behaviour happens, and helps keep its relationship to core functionality clear. + + /** + * Prefix that all generated token strings should start with. + * + * A string should be identifiable as a token string by checking whether it starts with this prefix. + * As such, it is useful for this value to be defined somewhere that scripts can access too. + */ + private val tokenPrefix + //I considered other structures like integer IDs, and objects with a particular structure and key. But semantically and syntactically, immutable and often-singleton/interned strings are really the best JSON representations of completely opaque Kotlin/JVM objects. + get() = ScriptingConstants.apiConstants.kotlinInstanceTokenPrefix + + /** + * Length to clip generated token strings to. Here in case token string generation uses the instance's .toString(), which it currently does. + */ + private const val tokenMaxLength = 100 + + /** + * Generate a distinctive token string to represent a Kotlin/JVM object. + * + * Should be human-informing when read. But should not be parsed, and should not encourage being parsed, to extract information. + * Only creates string. Does not automatically register resulting token string for detokenization. + * + * @param value: Instance to tokenize. + * @return Token string. + */ + private fun tokenFromInstance(value: Any?): String { + var token: String? = tokensByInstances[value] // Atomicity. Separating containment check would give the GC a chance to clear the key. + if (tokenReuse && token != null) { + return token + } + var stringified: String + stringified = try { // Because this can be overridden, it can fail. E.G. MapUnit.toString() depends on a lateinit. + value.toString() + } catch (e: Exception) { + "?" // Use exception text? + } + if (stringified.length > tokenMaxLength) { + stringified = stringified.slice(0..tokenMaxLength -4) + "..." + } + token = "$tokenPrefix${System.identityHashCode(value)}:${if (value == null) "null" else value::class.qualifiedName}/${stringified}:${UUID.randomUUID().toString()}" + if (tokenReuse) { + tokensByInstances[value] = token + } + return token + } + + /** + * @param value Any value or instance. + * @return Whether or not it is a token string. + */ + private fun isToken(value: Any?): Boolean { + return value is String && value.startsWith(tokenPrefix) + } + + /** + * Remove all tokens and WeakReferences whose instances have already been garbage-collected. + * + * Runs in O(n) time relative to token count. + * + * Should not have to be called manually. + */ + fun clean() { + val badtokens = if (tokenReuse) + tokensByInstances.clean(true)!! + else + instancesByTokens.entries.asSequence().filter { it.value.get() == null }.map { it.key }.toList() // Legacy mode. toList() is only for type. + for (t in badtokens) { + instancesByTokens.remove(t) + } + } + + // Try to clean all invalid tokens. + + // Only does anything if detects a sufficient change in token count from the last cleanup, as defined by lastTokenCountLog and tokenCountLogBase. + + // Should not have to be called manually. + fun tryClean() { + val count = instancesByTokens.size + val countLog = floor(log(count.toFloat(), tokenCountLogBase)).toInt() + if (countLog != lastTokenCountLog || (count >= forceCleanThreshold && Random.nextDouble() <= forceCleanChance)) { + // In theory, could cause repeated and inefficient bouncing near a trigger threshold— Unlikeliness aside, that also won't happen because ScriptingProtocol prevents new tokens from being freed per script execution. + // forceCleanThreshold should make sure cleaning still happens even with count clipped to MAX_INT. + if (ScriptingDebugParameters.printTokenizerMilestones) { + println("${this::class.simpleName} now tracks ${count} tokens and ${tokensByInstances.size} instances. Cleaning.") + } + clean() + lastTokenCountLog = countLog + } + } + + /** + * @param obj Instance to tokenize. + * @return Token string that can later be detokenized back into the original instance. + */ + fun getToken(obj: Any?): String { // TODO: Switch to Any, since null will just be cleaned anyway? + tryClean() + val token = tokenFromInstance(obj) + instancesByTokens[token] = WeakReference(obj) + return token + } + + /** + * Detokenize a token string into the real Kotlin/JVM instance it represents. + * + * Accepts non-token values, and passes them through unchanged. So can be used to E.G. blindly transform a Collection/JSON Array that only maybe contains some token strings by being called on every element. + * + * @param token Previously generated token, or any instance or value. + * @throws NullPointerException If given a token string but not a valid one (E.G. if its object was garbage-collected, or if it's fake). + * @return Real instance from detokenizing input if given a token string, input value or instance unchanged if not given a token string. + */ + fun getReal(token: Any?): Any? { + tryClean() + return if (isToken(token)) + instancesByTokens[token]!!.get()// TODO: Add another non-null assertion here? Unknown tokens and expired tokens are only a cleaning cycle apart, which seems race condition-y. + // TODO: Helpful exception message for invalid tokens? + else + token + } + +} diff --git a/core/src/com/unciv/scripting/serialization/TokenizingJson.kt b/core/src/com/unciv/scripting/serialization/TokenizingJson.kt new file mode 100644 index 0000000000000..0e2ce69b47a15 --- /dev/null +++ b/core/src/com/unciv/scripting/serialization/TokenizingJson.kt @@ -0,0 +1,198 @@ +package com.unciv.scripting.serialization + +import kotlinx.serialization.* +//import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +//import kotlinx.serialization.json.decodeFromJsonElement +//import kotlinx.serialization.modules.SerializersModule + +/** + * Json serialization that accepts Any?, and converts non-primitive values to string keys using InstanceTokenizer. + */ +object TokenizingJson { + + /** + * KotlinX serializer that automatically converts non-primitive values to string tokens on serialization, and automatically replaces string tokens with the real Kotlin/JVM objects they represent on detokenization. + * + * I tested the serialization function, but I'm not sure it's actually used anywhere since I think PathElements (which use it for params) are only ever received and not sent. + * + * Adapted from https://stackoverflow.com/a/66158603/12260302 + */ + object TokenizingSerializer: KSerializer { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Any?") + + /** + * Only these types will be serialized. Everything else will be replaced with a string key from InstanceTokenizer. + */ + @Suppress("UNCHECKED_CAST") + private val dataTypeSerializers: Map> = + //Tried replacing this with a generic funserialize and serializer. The change in signature prevents serialize from being overridden correctly, I guess. + //Also tried generic fungetSerializer(v:T)=serialize() as KSerializer. + mapOf( + "Boolean" to serializer(), + "Byte" to serializer(), + "Char" to serializer(), + "Double" to serializer(), + "Float" to serializer(), + "Int" to serializer(), + "Long" to serializer(), + "Short" to serializer(), + "String" to serializer() + ).mapValues { (_, v) -> v as KSerializer } + + override fun serialize(encoder: Encoder, value: Any?) { + // Hum. I tested this, but I'm not sure it's actually used anywhere since I think PathElements (which use it for params) are only ever received and not sent. + if (value == null) { + encoder.encodeNull() + } else { + val classname = value!!::class.simpleName!! + if (classname in dataTypeSerializers) {// && !isTokenizationMandatory(value)) { + encoder.encodeSerializableValue(dataTypeSerializers[value!!::class.simpleName!!]!!, value!!) + } else { + encoder.encodeString(InstanceTokenizer.getToken(value!!)) + } + } + } + + override fun deserialize(decoder: Decoder): Any? { + if (decoder is JsonDecoder) { + val jsonLiteral = (decoder as JsonDecoder).decodeJsonElement() + val rawval: Any? = getJsonReal(jsonLiteral) + return InstanceTokenizer.getReal(rawval) + } else { + throw UnsupportedOperationException("Decoding is not supported by TokenizingSerializer for ${decoder::class.simpleName}.") + } + } + } + + /** + * KotlinX JSON entrypoint with default properties to make it easier to make and work with IPC packets. + */ + val json = Json { + explicitNulls = true; //Disable these if it becomes a problem. + encodeDefaults = true; +// serializersModule = serializersModule + } + +// /** +// * Forbid some objects from being serialized as normal JSON values. +// * +// * com.unciv.models.ruleset.Building, for example, and resumably all other types that inherit from Stats, implements Iterable<*> and thus gets serialized as JSON Arrays by default even though it's probably better to tokenize them. +// * Python example: civInfo.cities[0].cityConstructions.getBuildableBuildings() +// * (Should return list of Building tokens, not list of lists of tokens for seemingly unrelated stats.) +// * +// * @param value Value or instance to check. +// * @return Whether the given value is required to be tokenized. +// */ +// private fun isTokenizationMandatory(value: Any?): Boolean { +// // TODO: Remove this? +// if (value == null) { +// return false +// } +// val qualname = value::class.qualifiedName +// return qualname != null && qualname.startsWith("com.unciv") +// // Originally, containers would be serialized when requested so they could be efficiently traversed in scripting languages with their own semantics. +// // With the switch away from bind-by-path to bind-by-reference, that behaviour is now a greater deviation from the convention of everything on the scripting side being a wrapper for Kotlin/JVM instances by default. +// } + + // @return Whether the value corresponds to a primitive JSON value type. + fun isNotPrimitive(value: Any?): Boolean { + return when (value) { + null -> false + is String -> false // TODO: Chars? + is Boolean -> false + is Number -> false + else -> true + } + } + + /** + * Get a KotlinX JsonElement for any Kotlin/JVM value or instance. + * + * @param value Any Kotlin/JVM value or instance to turn into a JsonElement. + * @param requireTokenization If given, a function that returns whether the value must be tokenized even if it can be serialized. + * @return Input value unchanged if value is already a JsonElement, otherwise JsonElement best representing value— Generally best effort to create JsonObject, JsonArray, JsonPrimitive, or JsonNull directly from value, and token string JsonPrimitive if best effort fails or tokenization is mandatory for the given value. + */ + fun getJsonElement(value: Any?, requireTokenization: ((Any?) -> Boolean)? = null): JsonElement { + if (value is JsonElement) { + return value + } + if (requireTokenization == null || !requireTokenization(value)) { + if (value is Map<*, *>) { + return JsonObject( (value as Map).entries.associate { + json.encodeToString(getJsonElement(it.key)) to getJsonElement(it.value) + // TODO: Currently, this means that string keys are encoded with quotes as part of the string. + // Serialized keys are additionally different from Kotlin/JVM keys exposed reflectively. + // Treating keys as their own JSON strings may be the only way to let all types of values be represented unambiguously in keys, though. + // Maybe just treat strings normally but stringify everything else? That would be consistent with Python behaviour and a superset of normal string-encoding behaviour. + // Collisions would be tricky— Probably just either throw an exception or make real strings always take precedence. It's probably not worth it to specify that JSON keys have to be encoded. + // TODO: More testing/documentation is needed. + // Well, now Maps just aren't ever serialized in normal use anymore, so it's fine I guess? + } ) + } + if (value is Iterable<*>) { + // Apparently ::class.java.isArray can be used to check for primitive arrays, but it breaks + return JsonArray(value.map(::getJsonElement)) + } + if (value is Sequence<*>) { + return getJsonElement((value as Sequence).toList()) + } + if (value is String) { + return JsonPrimitive(value as String) + } + if (value is Number) {//(value is Int || value is Long || value is Float || value is Double) { + return JsonPrimitive(value as Number) + } + if (value is Boolean) { //TODO: Arrays and primitive arrays? + return JsonPrimitive(value as Boolean) + } + if (value == null) { + return JsonNull + } + } + return JsonPrimitive(InstanceTokenizer.getToken(value)) + } + + /** + * Get a real value or instance from any KotlinX JsonElement. + * + * @param value JsonElement to make into a real instance. + * @return Detokenized instance from input value if input value represents a token string, direct conversion of input value into a Kotlin/JVM primitive or container otherwise. + */ + fun getJsonReal(value: JsonElement): Any? { + if (value is JsonNull || value == JsonNull) { + return null + } + if (value is JsonArray) { + return (value as List).map(::getJsonReal)// as List + } + if (value is JsonObject) { + return (value as Map).entries.associate { + InstanceTokenizer.getReal(it.key) to getJsonReal(it.value) + }// as Map + } + if (value is JsonPrimitive) { + val v = value as JsonPrimitive + return if (v.isString) { + InstanceTokenizer.getReal(v.content) + } else { + v.content.toIntOrNull() + ?: v.content.toLongOrNull() + ?: v.content.toFloatOrNull() // NOTE: This may prevent .toDoubleOrNull() from ever being used. I think the implicit number type conflation in FunctionDispatcher means that Floats can still be used where Doubles are expected, though. + ?: v.content.toDoubleOrNull() + ?: v.content.toBooleanStrictOrNull() + ?: v.content + } + } + throw IllegalArgumentException("Unrecognized type of JsonElement: ${value::class}/${value}") + } +} diff --git a/core/src/com/unciv/scripting/sync/ScriptingRunLock.kt b/core/src/com/unciv/scripting/sync/ScriptingRunLock.kt new file mode 100644 index 0000000000000..f8a200fe225cc --- /dev/null +++ b/core/src/com/unciv/scripting/sync/ScriptingRunLock.kt @@ -0,0 +1,57 @@ +package com.unciv.scripting.sync + +import com.unciv.scripting.ScriptingBackend +import com.unciv.scripting.ScriptingState +import com.unciv.scripting.utils.ScriptingDebugParameters +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +// Does not prevent concurrent run attempts. Just tries to make them to error immediately instead of messing anything up. + + + +// Lock to prevent multiple scripts from trying to run at the same time. +object ScriptingRunLock { + + private val isRunning = // Why is this separate from ScriptingRunThreader? Well, for one thing, ScriptingRunThreader doesn't let return values from scripts be used + AtomicBoolean(false) + + var runningName: String? = null + private set + + private var runningKey: String? = null // Unique key set each run to make sure + + // @param name An informative string to identify this acquisition and subsequent activities. + // @throws IllegalStateException if lock is already in use. + // @return A randomly generated string to pass to the release function. + @Synchronized fun acquire(name: String? = null): String { + // Different *threads* that try to run scripts concurrently should already queue up nicely without this due to the use of the Synchronized annotation on ScriptingState.exec(), I think. + // But because of the risk of recursive script runs (which will deadlock if trying to acquire a lock that must be released by the same thread, and which break the already-in-use IPC loop for a backend if allowed to continue without a lock), the behaviour here if or when that fails is to throw an exception. + if (!isRunning.compareAndSet(false, true)) throw IllegalStateException("Cannot acquire ${this::class.simpleName} for $name because it is already in use by $runningName.") // Prooobably don't translate? + if (ScriptingDebugParameters.printLockAcquisition) { + println("${this::class.simpleName} acquired by $name.") + } + val key = UUID.randomUUID().toString() + runningKey = key + runningName = name + return key + } + // @param releaseKey The string previously returned by the immediately preceding successful acquire(). + // @throws IllegalArgumentException If given the incorrect releaseKey. + // @throws IllegalStateException If not currently acquired. + @Synchronized fun release(releaseKey: String) { + if (releaseKey != runningKey) throw IllegalArgumentException("Invalid key given to release ${this::class.simpleName}.") + if (isRunning.get()) { + if (ScriptingDebugParameters.printLockAcquisition) { + println("${this::class.simpleName} released by $runningName.") + } + runningName = null + runningKey = null + isRunning.set(false) + } else { + throw IllegalStateException("Cannot release ${this::class.simpleName} because it has not been acquired.") + } + } +} + +fun makeScriptingRunName(executor: String?, backend: ScriptingBackend) = "$executor/${backend.metadata.displayName}@${ScriptingState.getIndexOfBackend(backend)}" diff --git a/core/src/com/unciv/scripting/sync/ScriptingRunThreader.kt b/core/src/com/unciv/scripting/sync/ScriptingRunThreader.kt new file mode 100644 index 0000000000000..1ed523bf6979b --- /dev/null +++ b/core/src/com/unciv/scripting/sync/ScriptingRunThreader.kt @@ -0,0 +1,91 @@ +package com.unciv.scripting.sync + +import com.badlogic.gdx.Gdx +import com.unciv.UncivGame +import com.unciv.scripting.utils.ScriptingDebugParameters +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.Semaphore +import kotlin.concurrent.thread + +// Utility to run multiple actions related to scripting in separate threads, but still sequentially. +object ScriptingRunThreader { // TODO: Probably obsolete this whole class, right? + private val runLock = Semaphore(1, true) + private val runQueue = ConcurrentLinkedDeque<() -> Unit>() + @Synchronized fun queueRun(toRun: () -> Unit) { + if (ScriptingDebugParameters.printThreadingStatus) { + println("Add $toRun to script thread queue.") + } + runQueue.add(toRun) + } + @Synchronized fun queueRuns(runs: Iterable<() -> Unit>) { + for (run in runs) queueRun(run) + } + @Synchronized fun queueRuns(runs: Sequence<() -> Unit>) { + for (run in runs) queueRun(run) + } + private fun doNextRunRecursive() { + val run: (() -> Unit)? = runQueue.poll() + if (run != null) { + if (ScriptingDebugParameters.printThreadingStatus) { + println("Continuing to consume script thread queue.") + } + thread { + try { + run() + } finally { + Gdx.app.postRunnable(::doNextRunRecursive) + } + } + } else { + if (ScriptingDebugParameters.printThreadingStatus) { + println("Finished consuming script thread queue.") + } + runLock.release() + } + } + @Synchronized fun doRuns() { + runLock.acquire() + if (Thread.currentThread() == UncivGame.MainThread) { + println("WARNING: Calling ${this::class.simpleName}.doRuns() from the main thread. I think using a short-lived worker thread for this may be more durable in terms of avoiding thread-locking bugs.") + } + if (ScriptingDebugParameters.printThreadingStatus) { + println("Starting to consume script thread queue.") + } + doNextRunRecursive() + } +} + +// Run the given function toRun, using the given concurrentRunner to run it, and block until it's done. + +// @throws IllegalStateException If already on the main thread. +fun blockingConcurrentRun(concurrentRunner: (Runnable) -> Unit, toRun: () -> Unit) { + if (concurrentRunner == Gdx.app::postRunnable + && Thread.currentThread() == UncivGame.MainThread) { + throw IllegalStateException("Tried to use lockingConcurrentRun to target the main thread with Gdx.app.postRunnable, while already on the main thread.") + } + val lock = Semaphore(1) + lock.acquire() + concurrentRunner { + toRun() + lock.release() + } + lock.acquire() +} + + +// Callable that takes a function toRun, a concurrentRunner that will execute it outside of the current thread, and, when called, returns the result of its execution in the foreign thread. + +// @property concurrentRunner First-order function that runs its argument concurrently (but with access to the same memory). +// @property toRun Function with a return result. +// @returns Result of the function toRun after it's run concurrently by the concurrentRunner. +class BlockingConcurrentRunReturn(val concurrentRunner: (Runnable) -> Unit, val toRun: () -> R) { + var result: R? = null + fun invoke(): R { + blockingConcurrentRun(concurrentRunner) { + result = toRun() + } + return result as R + } +} + + diff --git a/core/src/com/unciv/scripting/utils/ApiSpecGenerator.kt b/core/src/com/unciv/scripting/utils/ApiSpecGenerator.kt new file mode 100644 index 0000000000000..1a1bf1689a9ff --- /dev/null +++ b/core/src/com/unciv/scripting/utils/ApiSpecGenerator.kt @@ -0,0 +1,116 @@ +package com.unciv.scripting.utils + +import com.unciv.scripting.api.ScriptingScope +import kotlin.reflect.KCallable +import kotlin.reflect.KClass +import kotlin.reflect.KFunction +//import kotlin.reflect.KProperty1 +//import com.badlogic.gdx.utils.Json + + +// Automatically running this should probably be a part of the build process. +// Probably do whatever is done with TranslationFileWriter. + +// TODO: See UniqueDocsWriter.kt. https://github.com/yairm210/Unciv/commit/4617bc21a70f4f4bd29dc80ba7d648349c9fc3f8 + + +// Honestly, probably just deprecate this whole thing. Recent experiments with factories and enums shows how hard and messy parsing/reflecting Kotlin/JVM code structure can be, and the dynamically generated, lazily evaluated wrappers model used in the Python API works better and more flexibly than I ever expected anything using this system to. + + +data class ApiSpecDef( + var path: String, + var isIterable: Boolean, + var isMapping: Boolean, + var isCallable: Boolean, + var callableArgs: List, + // These are the basic values that are needed to implement a scripting API. + + var _type: String? = null, + //_docstring: String? = null, + var _repeatedReferenceTo: String? = null, + var _isJsonType: Boolean? = null, + var _iterableValueType: String? = null, + var _mappingKeyType: String? = null, + var _mappingValueType: String? = null, + var _callableReturnType: String? = null, + var _callableArgTypes: List? = null + //var _callableArgDefaults: List + // These values can be used to enhance behaviour in a scripting API, and may be needed for generating bindings in some languages. +) + +fun makeMemberSpecDef(member: KCallable<*>): ApiSpecDef { + //val kclass = member.returnType.classifier as KClass<*> + val submembers = mutableSetOf() + /*for (m in kclass.members) { + try { + submembers.add( m.name ) + } catch (e: Exception) { + println("Error accessing name of property ${m}.") + } + }*/ + //val tmp: Collection = kclass.members.filter { it.name != null }.map { it.name!! } + //submembers.addAll(tmp) + //Using a straight .map + return ApiSpecDef( + path = member.name, + isIterable = "iterator" in submembers, + isMapping = "get" in submembers, + isCallable = member is KFunction, + callableArgs = member.parameters.map { it.name } + ) +} + + +class ApiSpecGenerator { + + fun isUncivClass(cls: KClass<*>): Boolean { + return cls.qualifiedName!!.startsWith("com.unciv") + } + + fun getAllUncivClasses(): Set> { + val searchclasses = mutableListOf>(ScriptingScope::class) + val encounteredclasses = mutableSetOf>() + var i: Int = 0 + while (i < searchclasses.size) { // withIndex + var cls = searchclasses[i] + for (m in cls.members) { + var kclass: KClass<*>? + try { + kclass = (m.returnType.classifier as KClass<*>) + } catch (e: Exception) { + println("Skipping property ${m.name} in ${cls.qualifiedName} because of ${e}") + continue + } + //kclass.members //Directly accessing kclass.members gets a KotlinInternalReflectionError, but iterating through searchclasses seems to work just fine. + if (isUncivClass(kclass!!) && kclass!! !in encounteredclasses) { + encounteredclasses.add(kclass!!) + searchclasses.add(kclass!!) + } + } + i += 1 + } + return encounteredclasses + } + + fun generateRootsApi(): List { + return listOf() + } + + fun generateFlatApi(): List { + return ScriptingScope::class.members.map { it.name } + } + + fun generateClassApi(): Map> { + // Provide options for the scripting languages. This function + val classes = getAllUncivClasses() + var c = 0 // Test count. Something like 5,400, IIRC. For now, it's easier to just dynamically generate the API using Python's magic methods and the reflective tools in ScriptingProtocol. JS has proxies too, but other languages may not be so dynamic. // TBF I think some of those might have been GDX/Kotlin/JVM classes, which I should filter oout by .qualifiedName. + val output = mutableMapOf>( + *classes.map { + it.qualifiedName!! to it.members.map { c += 1; makeMemberSpecDef(it) } //Reflective function reference instead of wrapping lambda? + }.toTypedArray() + ) + println("\nGathered ${c} property specifications across ${classes.size} classes.\n") + return output + } +} + diff --git a/core/src/com/unciv/scripting/utils/FakeMaps.kt b/core/src/com/unciv/scripting/utils/FakeMaps.kt new file mode 100644 index 0000000000000..6d87a15b4a7a2 --- /dev/null +++ b/core/src/com/unciv/scripting/utils/FakeMaps.kt @@ -0,0 +1,58 @@ +package com.unciv.scripting.utils + +// Functions may have side effects. +// Containers, however, should change neither their own nor other data's public state on their own. +// Therefore, for certain models of scripting language bindings, it is easier to make guarantees about and expose flexible semantics for key indexing than for function calls. +// The classes here help expose functions as Map-like instances, letting simple, side-effect-free functions with direct mappings from input to output be presented in scripting language bindings without having to worry about side effects from E.G. repeated or deferred calling. + +interface StatelessMap: Map { // TODO: Move this. + companion object { + fun noStateError(): Nothing = throw(UnsupportedOperationException("Cannot access backing state of ${this::class.simpleName} by default.")) + } + override val entries: MutableSet> get() = noStateError() + override val keys: MutableSet get() = noStateError() + override val values: MutableCollection get() = noStateError() + override val size: Int get() = noStateError() + override fun containsKey(key: K): Boolean = noStateError() + override fun containsValue(value: V): Boolean = noStateError() + override fun isEmpty(): Boolean = noStateError() +} + +// +interface InvokableMap: Map { + operator fun invoke(key: K): V? = get(key) +} + +// Lazy Map of indeterminate size. + +// Memoizes a single-argument function. +// Generates values using the provided function the first time each key is encountered. +// Implements both invocation and indexing. + +// @property func The function that returns the value for a given key. +// @property exposeState Whether to expose the content-specific members of the backing map. +class LazyMap(val func: (K) -> V, val exposeState: Boolean = false): StatelessMap, InvokableMap { + // Benefit of a Map over a function is that because mapping access can be safely assumed by scripting language bindings to have no side effects, it's semantically easier for scripting language bindings to let the returned value be immediately called, autocompleted, indexed/attribute-read, etc. + private val backingMap = hashMapOf() + override fun get(key: K): V? { + val result: V? + if (key !in backingMap) { + result = func(key) + backingMap[key] = result + } else { + result = backingMap[key] + } + return result + } + override val entries get() = if (exposeState) backingMap.entries else StatelessMap.noStateError() + override val keys get() = if (exposeState) backingMap.keys else StatelessMap.noStateError() + override val values get() = if (exposeState) backingMap.values else StatelessMap.noStateError() + override val size get() = if (exposeState) backingMap.size else StatelessMap.noStateError() + override fun containsKey(key: K) = if (exposeState) backingMap.containsKey(key) else StatelessMap.noStateError() + override fun containsValue(value: V) = if (exposeState) backingMap.containsValue(value) else StatelessMap.noStateError() + override fun isEmpty() = if (exposeState) backingMap.isEmpty() else StatelessMap.noStateError() +} + +class FakeMap(private val getter: (K) -> V): StatelessMap, InvokableMap { + override fun get(key: K) = getter(key) +} diff --git a/core/src/com/unciv/scripting/utils/ScriptingApiAccessible.kt b/core/src/com/unciv/scripting/utils/ScriptingApiAccessible.kt new file mode 100644 index 0000000000000..5a2f77bb50cef --- /dev/null +++ b/core/src/com/unciv/scripting/utils/ScriptingApiAccessible.kt @@ -0,0 +1,17 @@ +package com.unciv.scripting.utils + + +enum class ScriptingApiExposure { + All, Player, Cheats, Mods +} // Mods should have more restrictive access to system-facing members than everything else, since they're the only untrusted code. + +@Retention(AnnotationRetention.RUNTIME) +annotation class ScriptingApiAccessible( + val readableBy: Array = arrayOf(ScriptingApiExposure.All), + val settableBy: Array = arrayOf(ScriptingApiExposure.All) +) + +// TODO (Later): Eventually use this to whitelist safe API accessible members for security/permission control. +// Probably keep a debug flag to expose everything. Or just reuse godmode flag? +// Anything that can be called directly by the GUI should probably have Player-level exposure. If it lets rules be broken, that should probably be fixed in Kotlin-side. +// Something will have to be done/decided about members of built-in types too. diff --git a/core/src/com/unciv/scripting/utils/ScriptingBackendException.kt b/core/src/com/unciv/scripting/utils/ScriptingBackendException.kt new file mode 100644 index 0000000000000..91178eb1659cb --- /dev/null +++ b/core/src/com/unciv/scripting/utils/ScriptingBackendException.kt @@ -0,0 +1,5 @@ +package com.unciv.scripting.utils + +class ScriptingBackendException(message: String): RuntimeException(message) { + // TODO: Use this more? +} diff --git a/core/src/com/unciv/scripting/utils/ScriptingDebugParameters.kt b/core/src/com/unciv/scripting/utils/ScriptingDebugParameters.kt new file mode 100644 index 0000000000000..17fb2dc9cc56f --- /dev/null +++ b/core/src/com/unciv/scripting/utils/ScriptingDebugParameters.kt @@ -0,0 +1,22 @@ +package com.unciv.scripting.utils + +object ScriptingDebugParameters { // TODO: See consoleLog. + // Whether to print out all/most IPC packets for debug. + var printPacketsForDebug = false + // Whether to print out all executed script code strings for debug (more readable than printing all packets). + var printCommandsForDebug = true + // Whether to print out all/most IPC actions for debug (more readable than printing all packets). + var printAccessForDebug = false + // Whether to print out major token count changes and cleaning events in InstanceTokenizer for debug. + var printTokenizerMilestones = false + // Whether to print out a warning when reflectively accessing definitions that have been deprecated. + var printReflectiveDeprecationWarnings = false // TODO + // Whether to print out when creating and deleting temporary + var printEnvironmentFolderCreation = false + // Whether to print out when the lock for stopping simultaneous script run attempts is acquired and released. + var printLockAcquisition = false + // Whether to print out when the queue for running mod-controlled scripts in sequence is expanded or consumed. + var printThreadingStatus = false + // TODO: Add to gameIsNotRunWithDebugModes unit tests. +} + diff --git a/core/src/com/unciv/scripting/utils/ScriptingErrorHandling.kt b/core/src/com/unciv/scripting/utils/ScriptingErrorHandling.kt new file mode 100644 index 0000000000000..fcfa86e6a7206 --- /dev/null +++ b/core/src/com/unciv/scripting/utils/ScriptingErrorHandling.kt @@ -0,0 +1,48 @@ +package com.unciv.scripting.utils + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.unciv.UncivGame +import com.unciv.scripting.sync.ScriptingRunLock +import com.unciv.ui.utils.* +import com.unciv.ui.utils.AutoScrollPane as ScrollPane + +object ScriptingErrorHandling { + fun notifyPlayerScriptFailure(text: String, asName: String? = null, toConsole: Boolean = true) { + Gdx.app.postRunnable { // This can potentially be run for scripts in worker threads, so in that case needs to go to the main thread to have OpenGL context and not crash. + val popup = Popup(UncivGame.Current.screen as BaseScreen) // TODO: Make this a class. + val widthTarget = popup.screen.stage.width / 2 + val msg1 = "{An error has occurred with the mod/script} \"${asName ?: ScriptingRunLock.runningName}\".\n\n{See system terminal output for details.}\n{Consider disabling mods if this keeps happening.}" // TODO: Translation. + popup.add(msg1.toLabel().apply + { + setAlignment(Align.center) + wrap = true + } ).width(widthTarget).row() + val contentTable = Table() + val msg2 = "\n\n${text.prependIndent("\t")}" + contentTable.add(Label(msg2, BaseScreen.skin).apply { + setFontSize(15) + wrap = true + }).width(widthTarget).row() + val scrollPane = ScrollPane(contentTable) + popup.add(scrollPane).row() // TODO: "Copy" button. + popup.addOKButton{} + popup.open(true) + if (toConsole) + printConsolePlayerScriptFailure(text, asName) + } + } + fun notifyPlayerScriptFailure(exception: Throwable, asName: String? = null) { + notifyPlayerScriptFailure(exception.toString(), asName, false) + printConsolePlayerScriptFailure(exception, asName) + } + fun printConsolePlayerScriptFailure(text: String, asName: String? = null) { + println("\nException with <${asName ?: ScriptingRunLock.runningName}> script:\n${text.prependIndent("\t")}\n") + // Really these should all go to STDERR. + } + fun printConsolePlayerScriptFailure(exception: Throwable, asName: String? = null) { + printConsolePlayerScriptFailure(exception.stringifyException(), asName) + } +} diff --git a/core/src/com/unciv/scripting/utils/SourceManager.kt b/core/src/com/unciv/scripting/utils/SourceManager.kt new file mode 100644 index 0000000000000..9606919901b0d --- /dev/null +++ b/core/src/com/unciv/scripting/utils/SourceManager.kt @@ -0,0 +1,63 @@ +package com.unciv.scripting.utils + +//import com.badlogic.gdx.Gdx +import com.badlogic.gdx.files.FileHandle +//import com.unciv.JsonParser +import com.unciv.scripting.ScriptingConstants +//import com.unciv.scripting.ScriptingConstants +import kotlin.concurrent.thread + + +/** + * Functions for managing, and using (copying/instantiating) internal assets and modded files associated with each script interpreter engine type. + */ +object SourceManager { + + /** + * Return a file handle for a specific engine type's internal assets directory. + */ + private fun getEngineLibraries(engine: String): FileHandle { + return ScriptingConstants.assetFolders.enginefilesAssets.child("${engine}/") + } + + /** + * Set up a directory tree with all known libraries and files for a scripting engine/language type. + * + * Creates temporary directory. + * Copies directory tree under android/assets/scripting/sharedfiles/ into it, as specified in ScriptingEngineConstants.json + * Copies directory tree under android/assets/scripting/enginefiles/{engine} into it, as specified in ScriptingEngineConstants.json. + * + * @param engine Name of the engine type, as defined in scripting constants. + * @return FileHandle() for the temporary directory. + */ + fun setupInterpreterEnvironment(engine: String): FileHandle { + val enginedir = getEngineLibraries(engine) + val outdir = FileHandle.tempDirectory("unciv-${engine}_") + if (ScriptingDebugParameters.printEnvironmentFolderCreation) { + println("Created interpreter environment for $engine scripting engine: ${outdir.path()}") + } + fun addfile(sourcedir: FileHandle, path: String) { + val target = outdir.child(path) + if (path.endsWith("/")) { + target.mkdirs() + } else { + sourcedir.child(path).copyTo(target) + } + } + for (fp in ScriptingConstants.sharedfiles) { + addfile(ScriptingConstants.assetFolders.sharedfilesAssets, fp) + } + for (fp in ScriptingConstants.engines[engine]!!.files) { + addfile(enginedir, fp) + } + return outdir + } + + fun applyModToInterpreterEnvironment( // It looks like mod loading is in RulesetCache? + modDir: FileHandle, + interpreterDir: FileHandle, + engine: String + ) { // https://github.com/yairm210/Unciv/pull/1825 + + } // TODO +} diff --git a/core/src/com/unciv/scripting/utils/SyntaxHighlighter.kt b/core/src/com/unciv/scripting/utils/SyntaxHighlighter.kt new file mode 100644 index 0000000000000..a11bca97dfa1e --- /dev/null +++ b/core/src/com/unciv/scripting/utils/SyntaxHighlighter.kt @@ -0,0 +1,16 @@ +package com.unciv.scripting.utils + + +interface SyntaxHighlighter { + // TODO: Implement/use these. + fun cmlFromText(text: String): String { + // https://github.com/libgdx/libgdx/wiki/Color-Markup-Language + return text + } +} + +class FunctionalSyntaxHighlighter(val transformList: List<(String) -> String>): SyntaxHighlighter { + override fun cmlFromText(text: String): String { + return transformList.fold(text){ t: String, f: (String) -> String -> f(t) } + } +} diff --git a/core/src/com/unciv/scripting/utils/WeakIdentityMap.kt b/core/src/com/unciv/scripting/utils/WeakIdentityMap.kt new file mode 100644 index 0000000000000..a6179d34cb418 --- /dev/null +++ b/core/src/com/unciv/scripting/utils/WeakIdentityMap.kt @@ -0,0 +1,124 @@ +package com.unciv.scripting.utils + +import java.lang.ref.WeakReference + + +// TODO: These will need tests. + + +// WeakReference with equality comparison by referential equality between its and other WeakIdentityMapKey()s' referents. + +// Has value equality with itself. +// Has value equality with any other WeakIdentityMapKey()s that points to the same living referent. +// If referent has been garbage collected, then points to null, and does not have value equality to any other WeakIdentityMapKey()s. +// Does not have value equality with anything else. + +// Should have valid hashCode behaviour given this. +class WeakIdentityMapKey(referent: T): WeakReference(referent) { + // Two states: + // 1. Behaviour with living referent, such as when added to Map or used for containment check or index access. Should equal any other WeakIdentityMapKey with the same referent. + // 2. Behaviour with with dead referent, such as when removed from map. Referent has become null, so can't use that. Should still have the same hashCode to not break hash bucket, should equal itself to still be removable, and should not equal anything else. + val hashCode = System.identityHashCode(referent) // Keep hash immutable, and keep this in the same Map bucket. + override fun hashCode() = hashCode + override fun equals(other: Any?): Boolean { + // Fulfills reflexive, symmetric, consistent, null inequality, and transitivity contracts. + return if (other === this) { + true // Makes sure Map key removal can always be done by using itself. + } else if (other is WeakIdentityMapKey<*>) { + val resolved = get() + if (resolved == null) + false + else + resolved === other.get() // Allows containment to be checked and access to be done by new WeakIdentityMapKey()s with the same referent. + } else { + false + } + // Originally, I wanted to have it equal the referent. But there's no way to make that symmetric/commutative, is there? + } +} + +// Map-like class that uses special WeakReferences to wrap keys and correlates keys based on referential identity instead of value equality. + +// For now, clean() must be called manually to free all keys that have been garbage collected. +class WeakIdentityMap(): MutableMap { + private val backingMap = mutableMapOf, V>() + override val entries get() = throw NotImplementedError() // backingMap.entries + override val keys get() = throw NotImplementedError() //backingMap.keys.map { it.get() } + override val size get() = backingMap.size + override val values get() = backingMap.values // Does exposing state make any sense? Meh. It's easier to do than not, TBH. + override fun clear() = backingMap.clear() + override fun containsKey(key: K) = backingMap.containsKey(WeakIdentityMapKey(key)) + override fun containsValue(value: V) = backingMap.containsValue(value) + override fun get(key: K) = backingMap.get(WeakIdentityMapKey(key)) + override fun isEmpty() = backingMap.isEmpty() + override fun put(key: K, value: V) = backingMap.put(WeakIdentityMapKey(key), value) + override fun putAll(from: Map) = backingMap.putAll(from.entries.associate { WeakIdentityMapKey(it.key) to it.value }) + override fun remove(key: K) = backingMap.remove(WeakIdentityMapKey(key)) + // Free up all invalid keys that have been garbage collected. + + // Runs in O(n) time relative to size. + + // @param returnValues Whether or not to return a list of values from all the removed keys. + fun clean(returnValues: Boolean = false): List? { + val badkeys = backingMap.keys.filter { it.get() == null } + val out = if (returnValues) + badkeys.map { backingMap[it] } + else + null + for (k in badkeys) { + backingMap.remove(k) + } + return out + } + override fun toString() = "{${backingMap.entries.joinToString(", ") { "${it.key.get()}=${it.value}" }}}" +} + +fun weakIdentityMapOf(vararg pairs: Pair): WeakIdentityMap { + val map = WeakIdentityMap() + map.putAll(pairs) + return map +} + +//mapOf(mutableListOf(1,2) to 5)[mutableListOf(1,2)] +//mapOf(WeakReference(mutableListOf(1,2)) to 5)[WeakReference(mutableListOf(1,2))] +//var l=mutableListOf(1,2); mapOf(WeakReference(l) to 5)[WeakReference(l)] + +//var a=mutableListOf(1,2); var b=mutableListOf(1); var m=mutableMapOf(a to 1, b to 2) +//m[listOf(1,2)] +//m[listOf(1)] +//b.add(2) + +//var a=mutableListOf(1,2); var b=mutableListOf(1); var m=weakIdentityMapOf(a to 1, b to 2) +//m[a] // 1 +//m[b] // 2 +//m[listOf(1,2)] // null +//m[listOf(1)] // null +//m[a.toList()] // null +//m[b.toList()] // null +//b.add(2) +//m[a] // 1 +//m[b] // 2 +//m.remove(a) +//m[a] // null +//m[b] // 2 +//m.remove(b) +//m[a] // null +//m[b] // null + +//object o {var a=mutableListOf(1,2); var b=mutableListOf(1); var c=mutableListOf(1,2)}; var m=weakIdentityMapOf(o.a to 1, o.b to 2, o.c to 3) +//listOf(m[o.a], m[o.b], m[o.c]) // 1,2,3 +//listOf(o.a in m, o.b in m, o.c in m) // true, true, true +//var c = WeakIdentityMapKey(o.c) +//o.c = mutableListOf() +//listOf(o.a.toList() in m, o.b in m, o.c in m) // false, true, false +//for (i in 1..2000) {(1..i).map{it to object{var x = i; val y=it}}.associate{it}} // GC cannot be forced on JVM, apparently, but this should be fairly strong hint. +//val x = WeakReference(object{val x=5}); while (x.get() != null){System.gc()} // Stronger hint yet. +////GC will never run no matter what in Kotlin REPL, seemingly. +//println(c.get()) // null +//println(m) +//println(m.size) // 3 +//m.clean() +//println(m) +//println(m.size) // 2 + +// TODO: Add test for behaviour with null values. (Should basically never be accessible, and then immediately be cleared, I think.) diff --git a/core/src/com/unciv/ui/consolescreen/ConsoleScreen.kt b/core/src/com/unciv/ui/consolescreen/ConsoleScreen.kt new file mode 100644 index 0000000000000..07a123c48d16e --- /dev/null +++ b/core/src/com/unciv/ui/consolescreen/ConsoleScreen.kt @@ -0,0 +1,366 @@ +package com.unciv.ui.consolescreen + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Event +import com.badlogic.gdx.scenes.scene2d.InputListener +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.SplitPane +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextField // Ooh. TextArea could be nice. (Probably overkill and horrible if done messily, though.) +import com.unciv.Constants +import com.unciv.models.modscripting.* +import com.unciv.scripting.ScriptingBackendType +import com.unciv.scripting.ScriptingState +import com.unciv.scripting.utils.ScriptingErrorHandling +import com.unciv.scripting.sync.makeScriptingRunName +import com.unciv.ui.utils.* +import com.unciv.ui.utils.AutoScrollPane as ScrollPane +import kotlin.math.max +import kotlin.math.min + +// Could also abstract this entire class as an extension on Table, letting it be used as screen, popup, or random widget, but don't really need to. + +class ConsoleScreen(var closeAction: () -> Unit): BaseScreen() { + + private val layoutTable = Table() + + private val topBar = Table() + private val backendsScroll: ScrollPane + private val backendsAdders = Table() + private val closeButton = Constants.close.toTextButton() + + private val middleSplit: SplitPane + private val printScroll: ScrollPane + private val printHistory = Table() + private val runningScroll: ScrollPane + private val runningContainer = Table() + private val runningList = Table() // TODO: Scroll. + + private val inputBar = Table() + private val inputField = TextField("", skin) + + private val inputControls = Table() + private val tabButton = "TAB".toTextButton() // TODO: Translation. + private val upButton = ImageGetter.getImage("OtherIcons/Up") + private val downButton = ImageGetter.getImage("OtherIcons/Down") + private val runButton = "ENTER".toTextButton() // TODO: Translation. + + private val layoutUpdators = ArrayList<() -> Unit>() + private var isOpen = false + + private var warningAccepted = false + + var inputText: String + get() = inputField.text + set(value: String) { inputField.setText(value) } + + var cursorPos: Int + get() = inputField.getCursorPosition() + set(value: Int) { + inputField.setCursorPosition(max(0, min(inputText.length, value))) + } + + private var lastSeenScriptingBackendCount = 0 + + init { + + backendsAdders.add("Launch new backend:".toLabel()).padRight(30f).padLeft(20f) // TODO: Translation. + for (backendtype in ScriptingBackendType.values()) { + var backendadder = backendtype.metadata.displayName.toTextButton() // Hm. Should this be translated/translatable? I suppose it already is translatable in OptionsPopup too. And in the running list— So basically everywhere it's shown. + backendadder.onClick { + val spawned = ScriptingState.spawnBackend(backendtype) +// echo(spawned.motd) + val startup = game.settings.scriptingConsoleStartups[backendtype.metadata.displayName]!! + if (startup.isNotBlank()) { + ScriptingState.switchToBackend(spawned.backend) + exec(startup) + } + updateRunning() + } + backendsAdders.add(backendadder) + } + backendsScroll = ScrollPane(backendsAdders) + + backendsAdders.left() + + val cell_backendsScroll = topBar.add(backendsScroll) + layoutUpdators.add { cell_backendsScroll.minWidth(stage.width - closeButton.getPrefWidth()) } + topBar.add(closeButton) + + printHistory.left() + printHistory.bottom() + printScroll = ScrollPane(printHistory) + + runningContainer.add("Active Backends:".toLabel()).padBottom(5f).row() // TODO: Translation. + runningContainer.add(runningList) + runningScroll = ScrollPane(runningContainer) + + middleSplit = SplitPane(printScroll, runningScroll, false, skin) + middleSplit.setSplitAmount(0.8f) + + inputControls.add(tabButton) + inputControls.add(upButton.surroundWithCircle(40f)) + inputControls.add(downButton.surroundWithCircle(40f)) + inputControls.add(runButton) + + val cell_inputField = inputBar.add(inputField) + layoutUpdators.add( { cell_inputField.minWidth(stage.width - inputControls.getPrefWidth()) } ) + inputBar.add(inputControls) + + layoutUpdators.add( { layoutTable.setSize(stage.width, stage.height) } ) + + val cell_topBar = layoutTable.add(topBar) + layoutUpdators.add( { cell_topBar.minWidth(stage.width) } ) + cell_topBar.row() + + val cell_middleSplit = layoutTable.add(middleSplit) + layoutUpdators.add( { cell_middleSplit.minWidth(stage.width).minHeight(stage.height - topBar.getPrefHeight() - inputBar.getPrefHeight()) } ) + cell_middleSplit.row() + + layoutTable.add(inputBar) + + runButton.onClick(::run) + keyPressDispatcher[Input.Keys.ENTER] = ::run + keyPressDispatcher[Input.Keys.NUMPAD_ENTER] = ::run + + tabButton.onClick(::autocomplete) + keyPressDispatcher[Input.Keys.TAB] = ::autocomplete + + upButton.onClick { navigateHistory(1) } + keyPressDispatcher[Input.Keys.UP] = { navigateHistory(1) } + downButton.onClick { navigateHistory(-1) } + keyPressDispatcher[Input.Keys.DOWN] = { navigateHistory(-1) } + + onBackButtonClicked(::closeConsole) + closeButton.onClick(::closeConsole) + + updateLayout() + + stage.addActor(layoutTable) + + echoHistory() + + updateRunning() + + layoutTable.addListener( // Check whenever receiving input event if there's any super obvious need to update the running backends list. Really I don't see the list changing on its own except in the mod manager screen, so this shouldn't ever be used in that case, but as I'm developing the mod script loader I'm noticing some missed backends, and a single equality check every couple hundred seconds on average doesn't feel expensive to make sure it's always up to date. + object: InputListener() { + override fun handle(e: Event): Boolean { + val size = ScriptingState.scriptingBackends.size + if (lastSeenScriptingBackendCount != size) { + lastSeenScriptingBackendCount = size + updateRunning() + } + return false + } + } + ) + } + + private fun updateLayout() { + for (func in layoutUpdators) { + func() + } + } + + private fun showWarningPopup() { + YesNoPopup( + "{WARNING}\n\n{The Unciv scripting API is a HIGHLY EXPERIMENTAL feature intended for advanced users!}\n{It may be possible to damage your device and files by running malicious or poorly designed code!}\n\n{Do you wish to continue?}", + { // TODO: Translation. + warningAccepted = true + }, + this, + { + closeConsole() + game.settings.enableScriptingConsole = false + game.settings.save() + } + ).open(true) + } + + fun openConsole() { + game.setScreen(this) + keyPressDispatcher.install(stage) //TODO: Can this be moved to UncivGame.setScreen? + ScriptingState.consoleScreenListener = { + Gdx.app.postRunnable { echo(it) } // Calling ::echo directly triggers OpenGL stuff, which means crashes when ScriptingState isn't running in the main thread. + } + stage.setKeyboardFocus(inputField) + inputField.getOnscreenKeyboard().show(true) + updateRunning() + this.isOpen = true + if (game.settings.showScriptingConsoleWarning && !warningAccepted) { + showWarningPopup() + } + ModScriptingRegistrationManager.activeMods +// ModScriptingRunManager.runHandler(HANDLER_DEFINITIONS[ContextId.ConsoleScreen, HandlerId.after_open], this) + } + + fun closeConsole() { +// ModScriptingRunManager.runHandler(HANDLER_DEFINITIONS[ContextId.ConsoleScreen, HandlerId.before_close], this) + closeAction() + keyPressDispatcher.uninstall() + this.isOpen = false + } + + private fun updateRunning() { + runningList.clearChildren() + for (backend in ScriptingState.scriptingBackends) { + var button = backend.metadata.displayName.toTextButton() + if (backend.displayNote != null) { + button.cells.last().row() + button.add(backend.displayNote.toString().toLabel(fontColor = Color.LIGHT_GRAY, fontSize = 16)) + } + runningList.add(button) + if (backend === ScriptingState.activeBackend) { + button.color = Color.GREEN + } + button.onClick { + ScriptingState.switchToBackend(backend) + updateRunning() + } + var termbutton = ImageGetter.getImage("OtherIcons/Stop") + val terminable = backend.userTerminable // Grey out if not terminable. + if (!terminable) { // TODO: There's a Button.disable() extension function. + termbutton.setColor(0.7f, 0.7f, 0.7f, 0.3f) +// termbutton.color.a = 0.3f + } + termbutton.onClick { + if (backend.userTerminable) { + val exc: Exception? = ScriptingState.termBackend(backend) + if (exc != null) { + echo("Failed to stop ${backend.metadata.displayName} backend: ${exc.toString()}") // TODO: Translation? I mean, probably not, really. + } + } + updateRunning() + } + runningList.add(termbutton.surroundWithCircle(40f, color = if (terminable) Color.WHITE else Color(1f, 1f, 1f, 0.5f))).row() + } + } + + private fun clear() { + printHistory.clearChildren() + } + + fun setText(text: String, cursormode: SetTextCursorMode=SetTextCursorMode.End) { + val originaltext = inputText + val originalcursorpos = cursorPos + inputText = text + when (cursormode) { + (SetTextCursorMode.End) -> { cursorPos = inputText.length } + (SetTextCursorMode.Unchanged) -> {} + (SetTextCursorMode.Insert) -> { cursorPos = inputText.length-(originaltext.length-originalcursorpos) } + (SetTextCursorMode.SelectAll) -> { throw UnsupportedOperationException("NotImplemented.") } + (SetTextCursorMode.SelectAfter) -> { throw UnsupportedOperationException("NotImplemented.") } + } + } + + fun setScroll(x: Float, y: Float, animate: Boolean = true) { + printScroll.scrollTo(x, y, 1f, 1f) + if (!animate) { + printScroll.updateVisualScroll() + } + } + + private fun echoHistory() { + // Doesn't restore autocompletion. I guess that's by design. Autocompletion is a protocol/UI-level feature IMO, and not part of the emulated STDIN/STDOUT. Call `echo()` in `ScriptingState`'s `autocomplete` method if that's a problem. + for (hist in ScriptingState.getOutputHistory()) { + echo(hist) + } + } + + private fun autocomplete() { + val original = inputText + val cursorpos = cursorPos + var results = ScriptingState.autocomplete(inputText, cursorpos) + if (results.helpText != null) { + echo(results.helpText!!) + return + } + if (results.matches.size < 1) { + return + } else if (results.matches.size == 1) { + setText(results.matches[0], SetTextCursorMode.Insert) + } else { + echo("") + for (m in results.matches) { + echo(m) + } + //var minmatch = original //Checking against the current input would prevent autoinsertion from working for autocomplete backends that support getting results from the middle of the current input. + var minmatch = original.slice(0..cursorpos-1) + var chosenresult = results.matches.first({true}) + for (l in original.lastIndex..chosenresult.lastIndex) { + var longer = chosenresult.slice(0..l) + if (results.matches.all { it.startsWith(longer) }) { + minmatch = longer + } else { + break + } + } + setText(minmatch + original.slice(cursorpos..original.lastIndex), SetTextCursorMode.Insert) + // TODO: Splice the longest starting substring with the text after the cursor, to let autocomplete implementations work on the middle of current input. + } + } + + private fun navigateHistory(increment: Int) { + setText(ScriptingState.navigateHistory(increment), SetTextCursorMode.End) + } + + private fun echo(text: String) { + val label = Label(text, skin) + val width = stage.width * 0.75f + label.setWidth(width) + label.setWrap(true) + printHistory.add(label).left().bottom().width(width).padLeft(15f).row() + val cells = printHistory.getCells() + while (cells.size > ScriptingState.maxOutputHistory && cells.size > 0) { // TODO: Move to separate method. + val cell = cells.first() + cell.getActor().remove() + cells.removeValue(cell, true) + //According to printHistory.getRows(), this isn't perfectly clean. The rows count still increases. + } + printHistory.invalidate() + setScroll(0f,0f) + } + + private fun exec(command: String) { + val name = makeScriptingRunName(this::class.simpleName, ScriptingState.activeBackend) + val execResult = ScriptingState.exec(command, asName = name) +// echo(execResult.resultPrint) + setText("") + if (execResult.isException) { + ScriptingErrorHandling.printConsolePlayerScriptFailure(execResult.resultPrint, asName = name) + ToastPopup("{Exception in} ${name}.", this) // TODO: Translation. + } + } + + private fun run() { + exec(inputText) + } + + fun clone(): ConsoleScreen { + return ConsoleScreen(closeAction).also { + it.inputText = inputText + it.cursorPos = cursorPos + it.setScroll(printScroll.getScrollX(), printScroll.getScrollY(), animate = false) + } + } + + override fun resize(width: Int, height: Int) { + if (stage.viewport.screenWidth != width || stage.viewport.screenHeight != height) { // Right. Actually resizing seems painful. + game.consoleScreen = clone() + if (isOpen) { + game.consoleScreen.openConsole() + // If this leads to race conditions or some such due to occurring at the same time as other screens' resize methods, then probably close the ConsoleScreen() instead. + } + } + } + + enum class SetTextCursorMode { + End, + Unchanged, + Insert, + SelectAll, + SelectAfter + } +} diff --git a/core/src/com/unciv/ui/consolescreen/IConsoleScreenAccessible.kt b/core/src/com/unciv/ui/consolescreen/IConsoleScreenAccessible.kt new file mode 100644 index 0000000000000..97f11d868013e --- /dev/null +++ b/core/src/com/unciv/ui/consolescreen/IConsoleScreenAccessible.kt @@ -0,0 +1,54 @@ +package com.unciv.ui.consolescreen + +import com.badlogic.gdx.Input +import com.unciv.logic.GameInfo +import com.unciv.logic.civilization.CivilizationInfo +import com.unciv.scripting.ScriptingState +import com.unciv.scripting.api.ScriptingScope +import com.unciv.ui.consolescreen.ConsoleScreen +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.worldscreen.WorldScreen +import com.unciv.ui.mapeditor.MapEditorScreen + +// TODO: Disable in Multiplayer WorldScreen. +// https://github.com/yairm210/Unciv/pull/5125/files + +//Interface that extends BaseScreen with methods for exposing the global ConsoleScreen. +interface IConsoleScreenAccessible { + + val BaseScreen.consoleScreen: ConsoleScreen + get() = game.consoleScreen + + //Set the console screen tilde hotkey. + fun BaseScreen.setOpenConsoleScreenHotkey() { + this.keyPressDispatcher[Input.Keys.GRAVE] = { game.setConsoleScreen() } + } + + //Set the console screen to return to the right screen when closed. + + //Defaults to setting the game's screen to this instance. Can also use a lambda, for E.G. WorldScreen and UncivGame.setWorldScreen(). + fun BaseScreen.setConsoleScreenCloseAction(closeAction: (() -> Unit)? = null) { + this.consoleScreen.closeAction = closeAction ?: { game.setScreen(this) } + } + + //Extension method to update scripting API scope variables that are expected to change over the lifetime of a ScriptingState. + + //Unprovided arguments default to null. This way, screens inheriting this interface don't need to explicitly clear everything they don't have. They only need to provide what they do have. + + //@param gameInfo Active GameInfo. + //@param civInfo Active CivilizationInfo. + //@param worldScreen Active WorldScreen. + fun BaseScreen.updateScriptingState( // TODO: Rename to setScriptingState + gameInfo: GameInfo? = null, + civInfo: CivilizationInfo? = null, + worldScreen: WorldScreen? = null, + mapEditorScreen: MapEditorScreen? = null + ) { + ScriptingScope.also { + it.gameInfo = gameInfo + it.civInfo = civInfo + it.worldScreen = worldScreen + it.mapEditorScreen = mapEditorScreen + } // .apply errors on compile with "val cannot be reassigned". + } +} diff --git a/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt b/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt index 6b7a2438bc9be..1514c24a66486 100644 --- a/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt +++ b/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt @@ -16,9 +16,10 @@ import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.translations.tr +import com.unciv.ui.consolescreen.IConsoleScreenAccessible import com.unciv.ui.utils.* -class MapEditorScreen(): BaseScreen() { +class MapEditorScreen(): BaseScreen(), IConsoleScreenAccessible { var mapName = "" var tileMap = TileMap() var ruleset = Ruleset().apply { add(RulesetCache.getBaseRuleset()) } @@ -140,6 +141,12 @@ class MapEditorScreen(): BaseScreen() { isPainting = false } }) + + setOpenConsoleScreenHotkey() + setConsoleScreenCloseAction() + updateScriptingState( + mapEditorScreen = this + ) } private fun checkAndFixMapSize() { diff --git a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt index d611665c6d545..116f678ae530f 100644 --- a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt +++ b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt @@ -3,6 +3,8 @@ package com.unciv.ui.utils import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.* +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.g2d.TextureRegion import com.badlogic.gdx.scenes.scene2d.ui.* import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener import com.badlogic.gdx.scenes.scene2d.utils.ClickListener @@ -13,6 +15,8 @@ import com.unciv.models.translations.tr import java.text.SimpleDateFormat import java.util.* import kotlin.concurrent.thread +import kotlin.math.max +import kotlin.math.min import kotlin.random.Random /** @@ -127,7 +131,7 @@ fun Table.addSeparator(color: Color = Color.WHITE, colSpan: Int = 0, height: Flo /** * Create a vertical separator as an empty Container with a colored background. - * + * * Note: Unlike the horizontal [addSeparator] this cannot automatically span several rows. Repeat the separator if needed. */ fun Table.addSeparatorVertical(color: Color = Color.WHITE, width: Float = 2f): Cell { @@ -155,6 +159,7 @@ fun ArrayList.withItem(item:T): ArrayList { return newArrayList } + /** Gets a clone of a [HashSet] with an additional item * * Solves concurrent modification problems - everyone who had a reference to the previous hashSet can keep using it because it hasn't changed @@ -185,6 +190,27 @@ fun HashSet.withoutItem(item:T): HashSet { return newHashSet } +// @param index Integer index to clip to the List's bounds. +// @param extendSize Allow indices that exceed the maximum index in this array by this amount. +// @return Input index clipped to the optionally extended range of the List's indices. +fun List.clipIndexToBounds(index: Int, extendEnd: Int = 0): Int { + return max(0, min(this.size-1+extendEnd, index)) +} + +/** + * Make sure an index is valid for this List. + * + * Doing all checks with the same function and error message is probably easier to debug than letting an array access fail at the point of access. + * + * @param index Index to check. + * @throws IndexOutOfBoundsException If given invalid index. + */ +fun List.enforceValidIndex(index: Int) { + if (index < 0 || this.size <= index) { + throw IndexOutOfBoundsException("Index {index} is out of range of ${this::class.simpleName}().") + } +} + /** Translate a percentage number - e.g. 25 - to the multiplication value - e.g. 1.25f */ fun String.toPercent() = toFloat().toPercent() @@ -237,11 +263,11 @@ fun String.toLabel(fontColor: Color = Color.WHITE, fontSize: Int = 18): Label { fun String.toCheckBox(startsOutChecked: Boolean = false, changeAction: ((Boolean)->Unit)? = null) = CheckBox(this.tr(), BaseScreen.skin).apply { isChecked = startsOutChecked - if (changeAction != null) onChange { + if (changeAction != null) onChange { changeAction(isChecked) } // Add a little distance between the icon and the text. 0 looks glued together, - // 5 is about half an uppercase letter, and 1 about the width of the vertical line in "P". + // 5 is about half an uppercase letter, and 1 about the width of the vertical line in "P". imageCell.padRight(1f) } @@ -288,6 +314,60 @@ fun List.randomWeighted(weights: List, random: Random = Random): T return this.last() } +/** + * @return String of exception preceded by entire stack trace. + */ +fun Throwable.stringifyException(): String { + val causes = arrayListOf() + var cause: Throwable? = this + while (cause != null) { + causes.add(cause) + cause = cause.cause + //I swear this is okay to do. + } + return listOf( + "", + *this.stackTrace, + "", + *causes.asReversed().map { it.toString() }.toTypedArray() + ).joinToString("\n") +} + +/** + * Turn a TextureRegion into a Pixmap. + * + * Originally a function defined in the Fonts Object and used only in Fonts.kt. + * Turned into an extension method to 1. Clean up the syntax and 2. Use it to expose internal, packed images to scripts in class ScriptingScope. + * + * .dispose() must be called on the returned Pixmap when it is no longer needed, or else it will leave a memory leak behind. + * + * @return New Pixmap with all the size and pixel data from this TextureRegion copied into it. + */ +// From https://stackoverflow.com/questions/29451787/libgdx-textureregion-to-pixmap +fun TextureRegion.toPixmap(): Pixmap { + val textureData = this.texture.textureData + if (!textureData.isPrepared) { + textureData.prepare() + } + val pixmap = Pixmap( + this.regionWidth, + this.regionHeight, + textureData.format + ) + val textureDataPixmap = textureData.consumePixmap() + pixmap.drawPixmap( + textureDataPixmap, // The other Pixmap + 0, // The target x-coordinate (top left corner) + 0, // The target y-coordinate (top left corner) + this.regionX, // The source x-coordinate (top left corner) + this.regionY, // The source y-coordinate (top left corner) + this.regionWidth, // The width of the area from the other Pixmap in pixels + this.regionHeight // The height of the area from the other Pixmap in pixels + ) + textureDataPixmap.dispose() // Prevent memory leak. + return pixmap +} + /** * Standardize date formatting so dates are presented in a consistent style and all decisions * to change date handling are encapsulated here diff --git a/core/src/com/unciv/ui/utils/Fonts.kt b/core/src/com/unciv/ui/utils/Fonts.kt index 2b26efa7d5355..6600a85dbbe18 100644 --- a/core/src/com/unciv/ui/utils/Fonts.kt +++ b/core/src/com/unciv/ui/utils/Fonts.kt @@ -86,22 +86,22 @@ class NativeBitmapFontData( private fun getPixmapFromChar(ch: Char): Pixmap { // Images must be 50*50px so they're rendered at the same height as the text - see Fonts.ORIGINAL_FONT_SIZE return when (ch) { - Fonts.strength -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("StatIcons/Strength").region) - Fonts.rangedStrength -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("StatIcons/RangedStrength").region) - Fonts.range -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("StatIcons/Range").region) - Fonts.movement -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("StatIcons/Movement").region) - Fonts.turn -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Turn").region) - Fonts.production -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Production").region) - Fonts.gold -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Gold").region) - Fonts.food -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Food").region) - Fonts.science -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Science").region) - Fonts.culture -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Culture").region) - Fonts.faith -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Faith").region) - Fonts.happiness -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable("EmojiIcons/Happiness").region) - MayaCalendar.tun -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable(MayaCalendar.tunIcon).region) - MayaCalendar.katun -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable(MayaCalendar.katunIcon).region) - MayaCalendar.baktun -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable(MayaCalendar.baktunIcon).region) - in MayaCalendar.digits -> Fonts.extractPixmapFromTextureRegion(ImageGetter.getDrawable(MayaCalendar.digitIcon(ch)).region) + Fonts.strength -> ImageGetter.getDrawable("StatIcons/Strength").region.toPixmap() + Fonts.rangedStrength -> ImageGetter.getDrawable("StatIcons/RangedStrength").region.toPixmap() + Fonts.range -> ImageGetter.getDrawable("StatIcons/Range").region.toPixmap() + Fonts.movement -> ImageGetter.getDrawable("StatIcons/Movement").region.toPixmap() + Fonts.turn -> ImageGetter.getDrawable("EmojiIcons/Turn").region.toPixmap() + Fonts.production -> ImageGetter.getDrawable("EmojiIcons/Production").region.toPixmap() + Fonts.gold -> ImageGetter.getDrawable("EmojiIcons/Gold").region.toPixmap() + Fonts.food -> ImageGetter.getDrawable("EmojiIcons/Food").region.toPixmap() + Fonts.science -> ImageGetter.getDrawable("EmojiIcons/Science").region.toPixmap() + Fonts.culture -> ImageGetter.getDrawable("EmojiIcons/Culture").region.toPixmap() + Fonts.faith -> ImageGetter.getDrawable("EmojiIcons/Faith").region.toPixmap() + Fonts.happiness -> ImageGetter.getDrawable("EmojiIcons/Happiness").region.toPixmap() + MayaCalendar.tun -> ImageGetter.getDrawable(MayaCalendar.tunIcon).region.toPixmap() + MayaCalendar.katun -> ImageGetter.getDrawable(MayaCalendar.katunIcon).region.toPixmap() + MayaCalendar.baktun -> ImageGetter.getDrawable(MayaCalendar.baktunIcon).region.toPixmap() + in MayaCalendar.digits -> ImageGetter.getDrawable(MayaCalendar.digitIcon(ch)).region.toPixmap() else -> fontImplementation.getCharPixmap(ch) } } @@ -135,29 +135,6 @@ object Fonts { font.setOwnsTexture(true) } - // From https://stackoverflow.com/questions/29451787/libgdx-textureregion-to-pixmap - fun extractPixmapFromTextureRegion(textureRegion:TextureRegion):Pixmap { - val textureData = textureRegion.texture.textureData - if (!textureData.isPrepared) { - textureData.prepare() - } - val pixmap = Pixmap( - textureRegion.regionWidth, - textureRegion.regionHeight, - textureData.format - ) - pixmap.drawPixmap( - textureData.consumePixmap(), // The other Pixmap - 0, // The target x-coordinate (top left corner) - 0, // The target y-coordinate (top left corner) - textureRegion.regionX, // The source x-coordinate (top left corner) - textureRegion.regionY, // The source y-coordinate (top left corner) - textureRegion.regionWidth, // The width of the area from the other Pixmap in pixels - textureRegion.regionHeight // The height of the area from the other Pixmap in pixels - ) - return pixmap - } - const val turn = '⏳' // U+23F3 'hourglass' const val strength = '†' // U+2020 'dagger' const val rangedStrength = '‡' // U+2021 'double dagger' diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index f52915d69e142..b9d0f6659c65e 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -27,6 +27,7 @@ import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.translations.tr import com.unciv.ui.cityscreen.CityScreen import com.unciv.ui.civilopedia.CivilopediaScreen +import com.unciv.ui.consolescreen.IConsoleScreenAccessible import com.unciv.ui.overviewscreen.EmpireOverviewScreen import com.unciv.ui.pickerscreens.* import com.unciv.ui.saves.LoadGameScreen @@ -55,7 +56,7 @@ import kotlin.concurrent.timer * @property mapHolder A [MinimapHolder] instance * @property bottomUnitTable Bottom left widget holding information about a selected unit or city */ -class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : BaseScreen() { +class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : BaseScreen(), IConsoleScreenAccessible { var isPlayersTurn = viewingCiv == gameInfo.currentPlayerCiv private set // only this class is allowed to make changes @@ -80,7 +81,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas private val diplomacyButtonHolder = Table() private val fogOfWarButton = createFogOfWarButton() private val nextTurnButton = createNextTurnButton() - private var nextTurnAction: () -> Unit = {} + private var nextTurnAction: () -> Unit = {} // TODO: Expose to scripting API. Also: Break up and expose individual actions, checking for order validity. Status: Lambdas not supported in Reflection.kt. private val tutorialTaskTable = Table().apply { background = ImageGetter.getBackground(ImageGetter.getBlue().lerp(Color.BLACK, 0.5f)) } private val notificationsScroll: NotificationsScroll @@ -191,6 +192,13 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // don't run update() directly, because the UncivGame.worldScreen should be set so that the city buttons and tile groups // know what the viewing civ is. shouldUpdate = true + + setConsoleScreenCloseAction({ game.setWorldScreen() }) + updateScriptingState( + gameInfo = gameInfo, + civInfo = selectedCiv, // TODO: Spectator. + worldScreen = this + ) } private fun stopMultiPlayerRefresher() { @@ -239,6 +247,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } // Space and N are assigned in createNextTurnButton + setOpenConsoleScreenHotkey() // CLI console keyPressDispatcher[Input.Keys.F1] = { game.setScreen(CivilopediaScreen(gameInfo.ruleSet, this)) } keyPressDispatcher['E'] = { game.setScreen(EmpireOverviewScreen(selectedCiv)) } // Empire overview last used page /* @@ -339,8 +348,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // if we find the current player didn't change, don't update // Additionally, check if we are the current player, and in that case always stop // This fixes a bug where for some reason players were waiting for themselves. - if (gameInfo.currentPlayer == latestGame.currentPlayer - && gameInfo.turns == latestGame.turns + if (gameInfo.currentPlayer == latestGame.currentPlayer + && gameInfo.turns == latestGame.turns && latestGame.currentPlayer != gameInfo.getPlayerToViewAs().civName ) { Gdx.app.postRunnable { loadingGamePopup.close() } diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index c3f32016ace71..d041f3b9f361e 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -5,6 +5,7 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.Input import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.Group import com.badlogic.gdx.scenes.scene2d.ui.* import com.badlogic.gdx.utils.Align import com.unciv.Constants @@ -13,6 +14,7 @@ import com.unciv.UncivGame import com.unciv.logic.MapSaver import com.unciv.logic.civilization.PlayerType import com.unciv.models.UncivSound +import com.unciv.models.metadata.GameSettings import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.tile.ResourceType @@ -73,6 +75,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { if (Gdx.app.type == Application.ApplicationType.Android) tabs.addPage("Multiplayer", getMultiplayerTab(), ImageGetter.getImage("OtherIcons/Multiplayer"), 24f) tabs.addPage("Advanced", getAdvancedTab(), ImageGetter.getImage("OtherIcons/Settings"), 24f) + tabs.addPage("Scripting", getScriptingTab(), ImageGetter.getImage("OtherIcons/Code"), 24f) if (RulesetCache.size > 1) { tabs.addPage("Locate mod errors", getModCheckTab(), ImageGetter.getImage("OtherIcons/Mods"), 24f) { _, _ -> if (modCheckFirstRun) runModChecker() @@ -259,6 +262,86 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { addSetUserId() } + private fun getScriptingTab() = Table(BaseScreen.skin).apply { + pad(10f) + defaults().pad(5f) + + add( + WrappableLabel( + "{WARNING: The features on this page are all experimental, and very powerful. It may be possible to damage your device with them if you do not know what you are doing.}\n\n{You have been warned!}", // TODO: Translation. + tabs.prefWidth * 0.5f + ).apply { + wrap = true + setAlignment(Align.center) + } + ).padTop(20f).row() + + addSeparator().padTop(20f) + + add("Scripting Console".toLabel(fontSize = 24)).padTop(20f).row() // TODO: Translation. + + val consoleBasicTable = Table(skin).apply { + pad(5f) + defaults().pad(2.5f) + addYesNoRow("{Enable scripting console}\n{Press ~ to activate}", // TODO: Translation. + settings.enableScriptingConsole) { + settings.enableScriptingConsole = it + } + addYesNoRow("Show warning when opening scripting console", // TODO: Translation. + settings.showScriptingConsoleWarning) { + settings.showScriptingConsoleWarning = it + } // Two affirmative actions to start using scripting console: Enable in OptionsPopup button, accept ConsoleScreen warning. + } + + add(consoleBasicTable).row() + + add("Console Startup Commands".toLabel(fontSize = 24)).padTop(20f).row() // TODO: Translation. + + val consoleStartupsTable = Table(skin).apply { + pad(5f) + defaults().pad(2.5f) + settings.scriptingConsoleStartups.keys.removeAll { it !in GameSettings.scriptingConsoleStartupDefaults } + + for ((displayName, startup) in settings.scriptingConsoleStartups) { + addTextFieldRow(displayName, startup, GameSettings.scriptingConsoleStartupDefaults[displayName]) { + settings.scriptingConsoleStartups[displayName] = it + } + } + } + + add(consoleStartupsTable).row() + + addSeparator().padTop(20f) + + add("Mod Scripting API".toLabel(fontSize = 24)).padTop(20f).row() // TODO: Translation. + + val modScriptingTable = Table(skin).apply { + pad(5f) + defaults().pad(2.5f) + val buttonSetter = object { var set = { _: Boolean -> println("Uninitialized button setting wrapper for enableModScripting.") } } // Need access to the button setter from inside the lambda passed to addYesNoRow, but only get it from the return value of addYesNoRow. It's… Not *that* messy, and doing this here is probably cleaner than what I would end up with if I tried to change addYesNoRow any further (which is also used a lot elsewhere). + buttonSetter.set = addYesNoRow( + "{Allow mods to run scripts}\n{CAUTION!}", // TODO: Translation. + settings.enableModScripting + ) { // Probably not worth adding the confirmation behaviour to addYesNoRow… Standardizing it sounds like a quick path to warning fatigue. + settings.enableModScripting = false + if (it) { // Aside from not wanting to get anyone hacked, should probably also check if Google Play has liability (or third-party distribution) issues— Not any more dangerous than a web browser, in fact arguably safer/more controlled, and trapped in Android sandbox regardless, but still. + YesNoPopup( // Four affirmative actions to enable a mod: Click OptionsPopup button, accept options warning, tick mod checkbox, accept mod warning. + "{Enabling this feature will allow mods you choose to run code on your device.}\n\n{Malicious code may be able to harm your device or steal your data!}\n{Never enable scripting for any mods you don't trust.}\n\n{Do you wish to continue?}", // TODO: Translation. + { settings.enableModScripting = it }, + UncivGame.Current.screen as BaseScreen, + { buttonSetter.set(false) } + ).open(true) + } + } + } + + add(modScriptingTable).row() + //TODO: Move to separate tab. + //TODO: Startup macros per backend type. + //TODO: ConsoleScreen warning toggle. + //https://thenounproject.com/icon/code-787514/ + } + private fun getModCheckTab() = Table(BaseScreen.skin).apply { defaults().pad(10f).align(Align.top) modCheckCheckBox = "Check extension mods based on vanilla".toCheckBox { @@ -613,8 +696,8 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { } } - - private fun Table.addYesNoRow(text: String, initialValue: Boolean, updateWorld: Boolean = false, action: ((Boolean) -> Unit)) { + // @return Lambda function for setting the button state (triggers action, world update, etc). + private fun Table.addYesNoRow(text: String, initialValue: Boolean, updateWorld: Boolean = false, action: ((Boolean) -> Unit)): (Boolean) -> Unit { val wrapWidth = tabs.prefWidth - 60f add(WrappableLabel(text, wrapWidth).apply { wrap = true }) .left().fillX() @@ -626,6 +709,51 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { previousScreen.shouldUpdate = true } add(button).row() + return { button.value = it } + } + + + // @param labelText Label to show next to the text field. + // @param initialValue Text in the text field. + // @param defaultValue If non-null, a button is added to reset the text field to this value. + // @param action Function given the updated value of the text field when it changes. Called before saving game settings. + private fun Table.addTextFieldRow(labelText: String, initialValue: String, defaultValue: String? = null, action: (String) -> Unit) { + val wrapWidth = tabs.prefWidth * 0.2f + add(WrappableLabel(labelText, wrapWidth).apply { wrap = true }) + .right().fillX() + .maxWidth(wrapWidth) + val input = TextField(initialValue, skin) + input.onChange { + action(input.text) + settings.save() // Don't currently see a need for updateWorld. + } + val fieldWidth = tabs.prefWidth * 0.5f + var lastcell: Cell<*> = add(input) + .left().fillX() + .minWidth(fieldWidth) + + if (defaultValue != null) { + val resetIcon = ImageGetter.getImage("OtherIcons/Close").apply { + setSize(20f, 20f) + } + val iconWrapper = Group().apply { // Stolen from, TODO: Unify with code in TabbedPager.addPage(). + isTransform = + false // performance helper - nothing here is rotated or scaled + setSize(20f, 20f) + resetIcon.center(this) + addActor(resetIcon) + } + val resetButton = Button(skin).apply { add(iconWrapper) }.onClick { + input.text = defaultValue + action(input.text) + settings.save() + } + + lastcell = add(resetButton) + } + // This is a table, so different rows should be aligned even if they have different sizes. (Don't need to add empty cell if not adding reset button.) + + lastcell.row() } //endregion @@ -639,12 +767,13 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { private class YesNoButton( initialValue: Boolean, skin: Skin, - action: (Boolean) -> Unit + private val action: (Boolean) -> Unit ) : TextButton (initialValue.toYesNo(), skin ) { - + // State of the button. Triggers action and updates text when changed. var value = initialValue - private set(value) { + set(value) { field = value + action(value) setText(value.toYesNo()) } @@ -652,7 +781,6 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { color = ImageGetter.getBlue() onClick { value = !value - action.invoke(value) } } diff --git a/docs/Credits.md b/docs/Credits.md index d7c2bcc9156a3..fde3972fc128e 100644 --- a/docs/Credits.md +++ b/docs/Credits.md @@ -684,6 +684,8 @@ Unless otherwise specified, all the following are from [the Noun Project](https: * [Multiplayer](https://thenounproject.com/search/?q=multiplayer&i=1215652) by Roy Charles * [Options](https://thenounproject.com/search/?q=options&i=866090) By Thengakola * [Package](https://thenounproject.com/search/?q=package&i=1886048) by shashank singh +* [Code](https://thenounproject.com/icon/code-787514/) by Júlia Korčoková (CC-BY-3.0, Modified) +* [Script](https://thenounproject.com/icon/script-2690195/) by Iconbox (CC-BY-3.0) # Sound credits diff --git a/extraImages/ScriptingCallTrace.odg b/extraImages/ScriptingCallTrace.odg new file mode 100644 index 0000000000000..a12dceaf3f5ec Binary files /dev/null and b/extraImages/ScriptingCallTrace.odg differ diff --git a/extraImages/ScriptingCallTrace.png b/extraImages/ScriptingCallTrace.png new file mode 100644 index 0000000000000..ac5bc74463cc8 Binary files /dev/null and b/extraImages/ScriptingCallTrace.png differ diff --git a/extraImages/ScriptingCallTraceExported.pdf b/extraImages/ScriptingCallTraceExported.pdf new file mode 100644 index 0000000000000..c44656e07894f Binary files /dev/null and b/extraImages/ScriptingCallTraceExported.pdf differ diff --git a/extraImages/ScriptingCallTraceFiltered.svg b/extraImages/ScriptingCallTraceFiltered.svg new file mode 100644 index 0000000000000..076c3810b9d3b --- /dev/null +++ b/extraImages/ScriptingCallTraceFiltered.svg @@ -0,0 +1,3566 @@ + + + +LuaJSCPythoncivInfogameInfoScriptingScope(Dynamic Access)Interpreter(Foreign)ConsoleScreen(Input)ScriptingStateScriptingBackendScriptingReplManagerBlackboxScriptingProtocolTokenizingJsonInstanceTokenizerReflectionScriptingBackendModHandlers(Input)ScriptingStateScriptingBackendBlack:CommandRed:Reflective Access (Repeatable)Purple:ResultRectangular:HardcodedOval:Dynamic/Unconstrained diff --git a/tests/src/com/unciv/scripting/InstanceMethodDispatcherTests.kt b/tests/src/com/unciv/scripting/InstanceMethodDispatcherTests.kt new file mode 100644 index 0000000000000..27b479629399b --- /dev/null +++ b/tests/src/com/unciv/scripting/InstanceMethodDispatcherTests.kt @@ -0,0 +1,174 @@ +package com.unciv.scripting + +import com.unciv.scripting.reflection.Reflection.InstanceMethodDispatcher +import com.unciv.testing.GdxTestRunner +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + + +private fun info(message: Any?) { } // Originally this was a KTS where I would just print the args. Replacing the printlns with noops keeps around the info passed to them, and looks cleaner/is better understood by IDEs than commenting. + +private typealias TestMethodResult = Unit // To make it easier to switch out in case it's decided to use the Signature class defined below and its assertion methods. + +//private fun error(detailMessage: Any? = null): Nothing = throw AssertionError(detailMessage) // To save a tiny bit of (keyboard) typing + + +@RunWith(GdxTestRunner::class) +class InstanceMethodDispatcherTests { + + private fun assertError(call: () -> Unit) { + Assert.assertThrows(IllegalArgumentException::class.java, call) + } + + private val testInstance = TestClasses.TestInstance() + + private val testInstanceDispatcherMeth = InstanceMethodDispatcher(testInstance, "meth") + private val testInstanceDipsatcherMeth2 = InstanceMethodDispatcher(testInstance, "meth2") + private val testInstanceDispatcherMeth3 = InstanceMethodDispatcher(testInstance, "meth3") + + private val testInstanceGenericInt = TestClasses.TestInstanceGeneric() + + private val testInstanceGenericIntDispatcherMeth = InstanceMethodDispatcher(testInstanceGenericInt, "meth") + private val testInstanceGenericIntDispatcherMeth2 = InstanceMethodDispatcher(testInstanceGenericInt, "meth2") + + private val testInstanceDispatcherLenient = InstanceMethodDispatcher(testInstance, "meth", true) + + private val testInstanceDispatcherMeth3ResolveAmbiguous = InstanceMethodDispatcher(testInstance, "meth3", resolveAmbiguousSpecificity=true) + + + // Regular tests. + + @Test + fun testBasicInt() = testInstanceDispatcherMeth.call(arrayOf(1)) + @Test + fun testBasicString() = testInstanceDispatcherMeth.call(arrayOf("A")) + @Test + fun testBasicIntStringlist() = testInstanceDispatcherMeth.call(arrayOf(2, listOf("Test", "List"))) + @Test + fun testBasicStringStringlist() = testInstanceDispatcherMeth.call(arrayOf("B", listOf("Test", "List"))) + @Test + fun testBasicStringNull() = testInstanceDispatcherMeth.call(arrayOf("B", null)) + + //@Test + //fun testBasic2Null() = testInstanceDipsatcherMeth2.call(arrayOf(null)) // Worked usually but not always when these tests were still a KTS. Possible compiler heisenbug here. Seems to be an internal Kotlin error caused when saving KParameter.type in a list under checkParameterMatches. + @Test + fun testBasic2StringFailAmbiguous() = assertError { + testInstanceDipsatcherMeth2.call(arrayOf("Fail")) + } + + @Test + fun testBasic3Stringlist() = testInstanceDispatcherMeth3.call(arrayOf(listOf("A"))) // Should always work, as only the signature for the base class should match. + @Test + fun testBasic3StringarraylistFailAmbiguous() = assertError { + testInstanceDispatcherMeth3.call(arrayOf(arrayListOf("B"))) + } // Requires subtype resolution. + @Test + fun testBasic3StringcustomlistFailAmbiguous() = assertError { + testInstanceDispatcherMeth3.call(arrayOf(TestClasses.CustomList())) + } + + + // Generic tests. + + @Test + fun testGenericInt() = testInstanceGenericIntDispatcherMeth.call(arrayOf(1)) + @Test + fun testGenericIntInt() = testInstanceGenericIntDispatcherMeth.call(arrayOf(2, 5)) + @Test + fun testGenericStringInt() = testInstanceGenericIntDispatcherMeth.call(arrayOf("A", 5)) + @Test + fun testGenericStringNull() = testInstanceGenericIntDispatcherMeth.call(arrayOf("B", null)) + //@Test //Huh. Apparently typing the object to Int doesn't stop a String from being used at its generic place. So, what? Erased generics have no influence at runtime, and typing using them is just a code-hinting/code-linting sham? + fun testGenericStringStringFailNomatch() = assertError { + testInstanceGenericIntDispatcherMeth.call(arrayOf("B", "Fail")) + } + + @Test + fun testGeneric2IntFailAmbiguous() = assertError { + testInstanceGenericIntDispatcherMeth2.call(arrayOf(3)) + } + @Test //This last one seems to work or break when you recompile. Actually I'm not sure dispatch resolution in this case is supported by the language. + fun testGeneric2Null() = testInstanceGenericIntDispatcherMeth2.call(arrayOf(null)) + + + // Numeric tests. + + @Test + fun testNumericFloatFailNomatch() = assertError { + testInstanceDispatcherMeth.call(arrayOf(10.0f)) + } + @Test + fun testNumericLongFailNomatch() = assertError { + testInstanceDispatcherMeth.call(arrayOf(11L)) + } + @Test + fun testNumericDoubleNomatch() = assertError { + testInstanceDispatcherMeth.call(arrayOf(12.5)) + } + @Test + fun testLenientInt() = testInstanceDispatcherLenient.call(arrayOf(15)) + /*dispnmeth.call(arrayOf(16.0f)) // TODO: Test casts and order? + dispnmeth.call(arrayOf(17L)) + dispnmeth.call(arrayOf(18.5))*/ // + + + // Ambiguous tests. + + @Test + fun testAmbiguous3Stringlist() = testInstanceDispatcherMeth3ResolveAmbiguous.call(arrayOf(listOf("A"))) // Only one method compatible at all. + @Test + fun testAmbiguous3Stringarraylist() = testInstanceDispatcherMeth3ResolveAmbiguous.call(arrayOf(arrayListOf("B"))) // Two methods compatible, but one is most specific. + @Test + fun testAmbiguous3Stringcustomlist() = testInstanceDispatcherMeth3ResolveAmbiguous.call(arrayOf(TestClasses.CustomList())) // Three methods compatible, but one is most specific. + +} + +object TestClasses { + + class CustomList : ArrayList() + +// class Signature(val types: List) { // TODO: Could use this to make sure the ambiguity tests are all going to the right method. +// companion object { +// fun fromArgs(vararg args: Any?) = Signature(args.map { if (it == null) null else it::class.starProjectedType }.toList()) +// fun fromFunction(func: KFunction<*>) = func.parameters.map { it.type } +// } +// fun assertEquals(vararg checkTypes: KType?) = checkTypes.size == types.size && (checkTypes.toList() zip types).all { (arg, param) -> arg == param } +// fun assertIsSuperOf(vararg checkTypes: KType?) = checkTypes.size == types.size && (checkTypes.toList() zip types).all { (arg, param) -> if (arg == null || param == null) arg == param else arg.isSubtypeOf(param) } +// } + + + @Suppress("unused") + class TestInstance { + fun meth(a:Int) = info("Int: ${a::class} $a") + fun meth(a:String) = info("String: ${a::class} $a") + fun meth(a:Int, b:Any) = info("Int: ${a::class} ${a}; Any: ${b::class} $b") + // fun meth(a:Int, b:Any?) { +// //This should fail if enabled at the same time as the one above. due to multiple signatures. // TODO: JvmName? +// info("Int: ${a::class} ${a}; Any?: ${b!!::class} ${b}") +// } + fun meth(a:String, b:Any?) = info("String: ${a::class} ${a}; Any?: ${if (b == null) null else b::class} $b") + + fun meth2(a: Any) = info("Any: ${a::class} $a") + @JvmName("meth2Nullable") // Wasn't needed when running with kotlinc -script. + fun meth2(a: Any?) = info("Any?: ${if (a == null) null else a::class} $a") + + fun meth3(a: List<*>) = info("List<*>: ${a::class} $a") + // fun meth3(a: MutableList) { // Apparently the jvmErasure for MutableList is just List. So having both makes resolutions ambiguous. +// info("MutableList<*>: ${a::class} ${a}") +// } + fun meth3(a: ArrayList<*>) = info("ArrayList<*>: ${a::class} $a") + fun meth3(a: CustomList<*>) = info("testlist<*>: ${a::class} $a") + } + + @Suppress("unused") + class TestInstanceGeneric { //Non-nullable upper bounds on T to test that T? is recognized as a distinct signature from T when calling with null. + fun meth(a: T) = info("T: ${a::class} $a") + fun meth(a:Int, b:T) = info("Int: ${a::class} ${a}; T: ${b::class} $b") + fun meth(a:String, b:T?) = info("String: ${a::class} ${a}; T?: ${if (b == null) null else b::class} $b") + + fun meth2(a: T) = info("T: ${a::class} $a") + @JvmName("meth2Nullable") // Wasn't needed when running with kotlinc -script. + fun meth2(b: T?) = info("T?: ${if (b == null) null else b::class} $b") + } +} diff --git a/tests/src/com/unciv/scripting/InstanceTokenizerTests.kt b/tests/src/com/unciv/scripting/InstanceTokenizerTests.kt new file mode 100644 index 0000000000000..fea3b65107bc1 --- /dev/null +++ b/tests/src/com/unciv/scripting/InstanceTokenizerTests.kt @@ -0,0 +1,6 @@ +// TODO +// Check: +// Same instance produces same token. +// Different instances produce different tokens even if they have equal values. +// Lots of instances kept around builds up lots of tokens. +// Lots of instances allowed to get garbage collected doesn't leave many tokens behind. diff --git a/tests/src/com/unciv/scripting/ScriptedTests.kt b/tests/src/com/unciv/scripting/ScriptedTests.kt new file mode 100644 index 0000000000000..4c523edf7e8a1 --- /dev/null +++ b/tests/src/com/unciv/scripting/ScriptedTests.kt @@ -0,0 +1,70 @@ +package com.unciv.scripting + +import com.badlogic.gdx.Application +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration +import com.badlogic.gdx.graphics.glutils.HdpiMode +import com.unciv.UncivGame +import com.unciv.UncivGameParameters +import com.unciv.app.desktop.NativeFontDesktop +import com.unciv.testing.GdxTestRunner +import com.unciv.ui.utils.Fonts +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +// The scripting API, by definition and by nature, works with the entire application state. +// Most of the risk of instability comes from its dynamism. It's neither particularly difficult nor terribly useful to guarantee and test that any specific set of edge cases will work consistently, but it's much more concerning whether any practical usage patterns involving large amounts of code have broken. With all the non-statically-checked IPC protocols, tokenization logic, and Python/JS operator overloading, hitting as much of the API surface as possible in as realistic a setup as possible is probably the easiest and most useful way to catch breaking changes. + +// So... The best way to have useful tests of the scripting API is going to be to launch an entire instance of the application, I think. +// There are/is seemingly (a) Github Action(s) to enable an OpenGL environment through software rendering, so that should hopefully be fine. + +@RunWith(GdxTestRunner::class) +class ScriptedTests { + + // @return The ExecResult from running the command with the backendType in a new Unciv application, or null if something went wrong. + private fun runScript(backendType: ScriptingBackendType, command: String): ExecResult? { + var execResult: ExecResult? = null + val uncivGame = UncivGame(UncivGameParameters( + "ScriptedTests", + fontImplementation = NativeFontDesktop(Fonts.ORIGINAL_FONT_SIZE.toInt()), + runScriptAndExit = Triple( + backendType, + command, + { execResult = it } + ) + )) + val application = Lwjgl3Application( + uncivGame, + Lwjgl3ApplicationConfiguration() + ) + return execResult + } + + private fun runScriptedTest(backendType: ScriptingBackendType, command: String) { + val execResult = runScript(backendType, command) + Assert.assertFalse( + execResult?.resultPrint?.prependIndent("\t") ?: "No execResult.", + execResult?.isException ?: true + ) + } + + @Test + fun scriptedPythonTests() { + runScriptedTest( + ScriptingBackendType.SystemPython, + "from unciv_scripting_examples.Tests import *; TestRunner.run_tests(debugprint=False)" + ) + } + + @Test + fun scriptedReflectiveTests() { + runScriptedTest( + ScriptingBackendType.Reflective, + "runtests" + ) + } +} diff --git a/tests/src/com/unciv/scripting/WeakIdentityMapTests.kt b/tests/src/com/unciv/scripting/WeakIdentityMapTests.kt new file mode 100644 index 0000000000000..70b786d12ed05 --- /dev/null +++ b/tests/src/com/unciv/scripting/WeakIdentityMapTests.kt @@ -0,0 +1 @@ +// TODO