diff --git a/buildmultimapslist.py b/buildmultimapslist.py deleted file mode 100644 index ff806f0e..00000000 --- a/buildmultimapslist.py +++ /dev/null @@ -1,35 +0,0 @@ -#! .venv\Scripts\python.exe -from soundrts.lib import log - -log.add_console_handler() - -import os -import os.path - -from soundrts.mapfile import Map - -DIR = "multi" - - -def size(m): - return Map(os.path.join(DIR, m)).size() - - -def add_digest(m): - p = os.path.join(DIR, m) - return "{} {}".format(m, Map(p).get_digest()) - - -def build(): - print("Updating list of official maps...") - f = open("cfg/official_maps.txt", "w") - maps = [] - for m in os.listdir(DIR): - maps.append(m) - maps.sort(key=size) - f.write("\n".join([add_digest(m) for m in maps])) - f.close() - - -if __name__ == "__main__": - build() diff --git a/cfg/official_maps.txt b/cfg/official_maps.txt deleted file mode 100644 index 5e135053..00000000 --- a/cfg/official_maps.txt +++ /dev/null @@ -1,89 +0,0 @@ -jl6.txt c2883685b840dfbcd71d30194c8e9297 -jl1.txt aec1cf42e0d53bafedf81a9189262fef -jl4 cdf1992f5f3b35cb6982542e93ade0b4 -kel5.txt a94132f3ee92298e386f99bba77c2f4e -jl8.txt aef8a470f4bc453049f4c1c109171fea -ce4.txt 3777872088673486aa50478153e296d9 -js3.txt f18b376950144fbbeccd0f72fd85bb2c -ce1.txt 978bd36969ff0a96bba0c13c91171434 -swk2b.txt 8660d64825d3fa4580b71866c5c48b97 -mb5.txt e26eba205f0c36d04986b431b578b050 -js2.txt 2e8d5b4370018a9a2340ed65b246b7bd -m1.txt bd48e09be593f86daf61f98085205288 -swk2c.txt 78da83fef9cea4474ed181766b309f6c -jl2.txt 6516549e657131b0b3a3ddfa2e233535 -swk2a.txt cf3b8f300d89edf17b4100f98e4abd4c -z1.txt b13b109aac0c3aeb092eeb9a79c398eb -ce5.txt 5702ce055e36cf13edfc281ad1eef344 -pm4.txt e27a7ad58a65ef7a27e23fb299dd046e -z5.txt 285c893c59d142881fc464523eef18c3 -js10.txt f2c95b7a719a2ad7769bef10114b1d4e -jl3.txt 925a36d767a7c38affa46611131f4a2c -jl7.txt 9804bc3a420a8885aac6785f12c3c2e6 -pm2.txt eda1281eeaa16db63f6c5170198b48c1 -pm5.txt 9d670a14af23e2d02c4fb6a512773165 -b1.txt 5600160e00c0d08749e96fc65fb489b1 -pm3.txt 38ed59140f01f8f3e6c288a10743c272 -kel1.txt 792cda103a397c36a5cd3ba9cfa134ce -b2.txt a648d4292385bed153364b142ba17318 -mb6.txt 89e0348209e449cc505c5b975b1f34a4 -pm6.txt de0f3147c71792d2a9d2c09b86ea2570 -js4.txt 9450e206b1d706ae96138babb9b4e323 -js1.txt 08dc0d7c14741d38a878384557612104 -ce2.txt 1425d8ef2439ea0dd64c2554110469ff -js5.txt c766e3f194f10ec50c8abd45483a7034 -kel3.txt ed5e828bba636f1431101df554d7e9dc -kel6.txt 8e041e470045b265289d65046aefae8b -mb7.txt a55e3a547be228085ccb9e7a9e2e3009 -js15.txt c8be54df0b2559205c245efa1feb755c -mb8.txt 982645cc6d50692d9206deb6e46577ae -mb2.txt ee433b79f17c6cd87c16c29119764df5 -jl5.txt e84bf85bb3362998167ec01cfdbbdcb5 -pm1.txt 8d85e105cfd419cf400e3593cb9ee396 -js6.txt bb4c0e0d539a7c68ef87b1cc02e90641 -mb9.txt 6cfc3b3cea846ee886198a73154a8f28 -z3.txt 4da4d35efbafffb7908f8c2de0160ffe -js9.txt 6873af97c2bd3682d275ec5a20a9bda3 -kel2.txt 26b478880c9583cc8727733e8beff891 -mb4.txt 5cc15edb4452cf10ce18b77b0e8fb5de -z2.txt 31e2bd88ccea55fe3b34e730669db66a -mb1.txt 92c7c943c10573b0f233705e76c5293f -js8.txt 4af6d5af4910f5fcfc9bb0e861fc88e7 -pra3.txt 30a6f73653c31e9c8c0265528c9b02d9 -js12.txt bf20b64ef8763528607d0a05c07b979f -ce3.txt eece4fb4081d9c5c311f1ce38b3f6936 -sg2.txt 6c317112877adbd23230605d8fc47ef9 -onj1.txt 5b04e804c1b94f384c1ff66c674226c5 -js7.txt 8017f8f5ae6b8d5aa63cdb69dd797630 -pra7.txt 5d181776443f747b83ad96cd6b4ed1ed -qc1.txt a898864189432b147bdb84ecbf08f242 -bs2.txt fed9e852cf1e6099f64b0380304004b5 -qc3.txt 99dab96bf03c7b49c10e325caa0b9d24 -z4.txt 49afb160be1a3055ff6f104fabe74694 -qc2.txt 1d153c67e41b8c8ca47047f5b960afa4 -sg1.txt 9ccfea14e7f816ba007a1db1dabfbd85 -bs1.txt ad7662354c89fe1720dc742ea2db526e -swk1.txt c41cec26e54b6631e913c73a53f1a9aa -js14.txt fb4f7670218f8c6cc7e40dfd4d917a74 -pra4.txt a1eb8aad7dbbf44b4e09f7d583746a6c -pra1.txt 56377ece52624dc9b6848e85fce9be50 -js11.txt 663779ed2b37d5a29f7a822827a2de7f -mb3.txt 0628997e2bf1833cc8a60c59765cda73 -pra6.txt eda16395465a88428671ef6fe02fdf2c -pra10.txt 732289bb0a315564706aef95f0a2bbbe -kel4.txt e6048e9ad5245a6b93cfdf1ca8cdcb5a -sg3.txt 79a16a63919dae961baf92e6b07a1d2b -pra8.txt cd29aaea0ac07906dc0d2415e0b9fb66 -pra2.txt cbe70f13a7941bc40adb47a668ee854f -pra9.txt caa627bc1ef93f47ce4ae301bf1eccbe -pra5.txt deed0f83d1ebbb05f98c87558b4199ba -can3.txt 4f05370496cdea935866a8bfe8e5538f -pra11.txt 4ef22dd73633d4dd608bceb07e2d8f8a -m2.txt b69f6c8acd3a50c7bafdd31d84027706 -sg4.txt 2b20a8972e0b267166138d34fa0241f6 -can5.txt cff2f5e8dbb947114da8e5eb8cdb2a67 -js13.txt 9aebf665743a1341bf07f252db2b1bc9 -can2.txt 18667e48937e912f247417776010d2d7 -can4.txt 5ebf1c1679e2bca793a39ab7b8c70e71 -can1.txt cc097d001a06c17520225eb0e0476675 -cw1.txt ede20dceecddda91520d7a37d73465e9 \ No newline at end of file diff --git a/cfg/package_servers.txt b/cfg/package_servers.txt new file mode 100644 index 00000000..d0867ca8 --- /dev/null +++ b/cfg/package_servers.txt @@ -0,0 +1,2 @@ +;uncomment a package server to activate it +;http://jlpo.free.fr/soundrts/packages/ \ No newline at end of file diff --git a/cfg/parameters.toml b/cfg/parameters.toml index 05ceb459..b5db7dd5 100644 --- a/cfg/parameters.toml +++ b/cfg/parameters.toml @@ -35,3 +35,8 @@ soundpack = 0.8 [volume] 1000 = 0.5 + +[packages] +# A package is a folder or a zip file. +base = "res" +additional = ["", "user", "user/downloaded"] diff --git a/doc_src/src/en/mapmaking.rst b/doc_src/src/en/mapmaking.rst index 288ca0b7..9cf93d6d 100644 --- a/doc_src/src/en/mapmaking.rst +++ b/doc_src/src/en/mapmaking.rst @@ -19,7 +19,7 @@ then you can store your first multiplayer map in the "multi" folder. If you are not allowed to write in the program files folder because you work in non-admin mode, you can store your working map file in the "multi" folder in "C:\\Documents and Settings\\Your Login\\Application Data\\SoundRTS". This folder is created the first time you start SoundRTS, unless a "user" folder exists near soundrts.exe. -Another solution is to install SoundRTS in a folder where you are allowed to write, and to work in the folder mentionned in the previous paragraph. +Another solution is to install SoundRTS in a folder where you are allowed to write, and to work in the folder mentioned in the previous paragraph. How to edit a map """"""""""""""""" @@ -37,9 +37,7 @@ A useful key combination is Control Shift F2: if you are the only human on the m How to find and remove an error """"""""""""""""""""""""""""""" -If, when you start the map, you get the message: "server error" and go back to the server menu, then the details of the error are in a file called "maperror.txt". This file is in your default temporary folder (for example "C:\\Documents and Settings\\Your Login\\Local Settings\\Temp\\soundrts") or in the SoundRTS folder. - -In the same directory you may find additional (but cryptic) information in "server.log" or in "client.log". +If, when you start the map, you get a "map error" message and go back to the menu, then you may sometimes find additional (but cryptic) information in "client.log" or in "server.log", usually in the "user/tmp" folder. If you still don't understand where the error is, feel free to contact me, directly or at the soundRTSChat list. @@ -324,7 +322,7 @@ If you are allowed to write in the folder where SoundRTS (or SoundRTS test) is i If you are not allowed to write in the program files folder because you work in non-admin mode, you can store your working map file in the "single" folder in "C:\\Documents and Settings\\Your Login\\Application Data\\SoundRTS". This folder is created the first time you start SoundRTS. -Another solution is to install SoundRTS in a folder where you are allowed to write, and to work in the folder mentionned in the previous paragraph. +Another solution is to install SoundRTS in a folder where you are allowed to write, and to work in the folder mentioned in the previous paragraph. Structure of the campaign folder """""""""""""""""""""""""""""""" @@ -496,7 +494,7 @@ Press PageUp or PageDown to select a terrain. The meaning of each terrain is sto Apply a terrain to a square """"""""""""""""""""""""""" -Press Enter to apply the terrain to the current square. Neighboring squares with the same caracteristics (ground and same height) will be linked automatically by a path. Different squares will have their path removed. +Press Enter to apply the terrain to the current square. Neighboring squares with the same characteristics (ground and same height) will be linked automatically by a path. Different squares will have their path removed. Toggle path to a neighbor """"""""""""""""""""""""" @@ -516,4 +514,4 @@ Press F10 and quit the game to leave the editor. An autosave of the map will be Add units """"""""" -Open the file in a text editor. Use commands mentionned in `Defining the starting resources of the players`_. \ No newline at end of file +Open the file in a text editor. Use commands mentioned in `Defining the starting resources of the players`_. diff --git a/doc_src/src/en/relnotes.rst b/doc_src/src/en/relnotes.rst index 3930d67d..029cc8de 100644 --- a/doc_src/src/en/relnotes.rst +++ b/doc_src/src/en/relnotes.rst @@ -3,6 +3,12 @@ Release notes .. contents:: +1.3.7 (to be released) +----- + +- removed the "maperror.txt" file (the information is already in the in-game error message). + + 1.3.6 ----- @@ -226,4 +232,4 @@ Interface improvements: - the description of a patrol order will recapitulate all the waypoints - bug fixed: pressing Tab would select blocked exits - bug fixed: it is no longer possible to build another wall on the same exit -- zoom mode: if no building land is found while a build order has been validated on a sub-square, an error will be raised (instead of searching for a building land in the enclosing square \ No newline at end of file +- zoom mode: if no building land is found while a build order has been validated on a sub-square, an error will be raised (instead of searching for a building land in the enclosing square diff --git a/requirements.txt b/requirements.txt index 426ba8f9..f2ae65b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,11 @@ upnpclient -pygame>=2 +pygame git+https://github.com/soundmud/accessible_output2 chardet cloudpickle +requests +tomli docutils cx_Freeze diff --git a/multi/b1.txt b/res/multi/b1.txt similarity index 100% rename from multi/b1.txt rename to res/multi/b1.txt diff --git a/multi/b2.txt b/res/multi/b2.txt similarity index 100% rename from multi/b2.txt rename to res/multi/b2.txt diff --git a/multi/bs1.txt b/res/multi/bs1.txt similarity index 100% rename from multi/bs1.txt rename to res/multi/bs1.txt diff --git a/multi/bs2.txt b/res/multi/bs2.txt similarity index 100% rename from multi/bs2.txt rename to res/multi/bs2.txt diff --git a/multi/can1.txt b/res/multi/can1.txt similarity index 100% rename from multi/can1.txt rename to res/multi/can1.txt diff --git a/multi/can2.txt b/res/multi/can2.txt similarity index 100% rename from multi/can2.txt rename to res/multi/can2.txt diff --git a/multi/can3.txt b/res/multi/can3.txt similarity index 100% rename from multi/can3.txt rename to res/multi/can3.txt diff --git a/multi/can4.txt b/res/multi/can4.txt similarity index 100% rename from multi/can4.txt rename to res/multi/can4.txt diff --git a/multi/can5.txt b/res/multi/can5.txt similarity index 100% rename from multi/can5.txt rename to res/multi/can5.txt diff --git a/multi/ce1.txt b/res/multi/ce1.txt similarity index 100% rename from multi/ce1.txt rename to res/multi/ce1.txt diff --git a/multi/ce2.txt b/res/multi/ce2.txt similarity index 100% rename from multi/ce2.txt rename to res/multi/ce2.txt diff --git a/multi/ce3.txt b/res/multi/ce3.txt similarity index 100% rename from multi/ce3.txt rename to res/multi/ce3.txt diff --git a/multi/ce4.txt b/res/multi/ce4.txt similarity index 100% rename from multi/ce4.txt rename to res/multi/ce4.txt diff --git a/multi/ce5.txt b/res/multi/ce5.txt similarity index 100% rename from multi/ce5.txt rename to res/multi/ce5.txt diff --git a/multi/cw1.txt b/res/multi/cw1.txt similarity index 100% rename from multi/cw1.txt rename to res/multi/cw1.txt diff --git a/multi/jl1.txt b/res/multi/jl1.txt similarity index 100% rename from multi/jl1.txt rename to res/multi/jl1.txt diff --git a/multi/jl2.txt b/res/multi/jl2.txt similarity index 100% rename from multi/jl2.txt rename to res/multi/jl2.txt diff --git a/multi/jl3.txt b/res/multi/jl3.txt similarity index 100% rename from multi/jl3.txt rename to res/multi/jl3.txt diff --git a/res/multi/jl4.zip b/res/multi/jl4.zip new file mode 100644 index 00000000..6025688c Binary files /dev/null and b/res/multi/jl4.zip differ diff --git a/multi/jl5.txt b/res/multi/jl5.txt similarity index 100% rename from multi/jl5.txt rename to res/multi/jl5.txt diff --git a/multi/jl6.txt b/res/multi/jl6.txt similarity index 100% rename from multi/jl6.txt rename to res/multi/jl6.txt diff --git a/multi/jl7.txt b/res/multi/jl7.txt similarity index 100% rename from multi/jl7.txt rename to res/multi/jl7.txt diff --git a/multi/jl8.txt b/res/multi/jl8.txt similarity index 100% rename from multi/jl8.txt rename to res/multi/jl8.txt diff --git a/multi/js1.txt b/res/multi/js1.txt similarity index 100% rename from multi/js1.txt rename to res/multi/js1.txt diff --git a/multi/js10.txt b/res/multi/js10.txt similarity index 100% rename from multi/js10.txt rename to res/multi/js10.txt diff --git a/multi/js11.txt b/res/multi/js11.txt similarity index 100% rename from multi/js11.txt rename to res/multi/js11.txt diff --git a/multi/js12.txt b/res/multi/js12.txt similarity index 100% rename from multi/js12.txt rename to res/multi/js12.txt diff --git a/multi/js13.txt b/res/multi/js13.txt similarity index 100% rename from multi/js13.txt rename to res/multi/js13.txt diff --git a/multi/js14.txt b/res/multi/js14.txt similarity index 100% rename from multi/js14.txt rename to res/multi/js14.txt diff --git a/multi/js15.txt b/res/multi/js15.txt similarity index 100% rename from multi/js15.txt rename to res/multi/js15.txt diff --git a/multi/js2.txt b/res/multi/js2.txt similarity index 100% rename from multi/js2.txt rename to res/multi/js2.txt diff --git a/multi/js3.txt b/res/multi/js3.txt similarity index 100% rename from multi/js3.txt rename to res/multi/js3.txt diff --git a/multi/js4.txt b/res/multi/js4.txt similarity index 100% rename from multi/js4.txt rename to res/multi/js4.txt diff --git a/multi/js5.txt b/res/multi/js5.txt similarity index 100% rename from multi/js5.txt rename to res/multi/js5.txt diff --git a/multi/js6.txt b/res/multi/js6.txt similarity index 100% rename from multi/js6.txt rename to res/multi/js6.txt diff --git a/multi/js7.txt b/res/multi/js7.txt similarity index 100% rename from multi/js7.txt rename to res/multi/js7.txt diff --git a/multi/js8.txt b/res/multi/js8.txt similarity index 100% rename from multi/js8.txt rename to res/multi/js8.txt diff --git a/multi/js9.txt b/res/multi/js9.txt similarity index 100% rename from multi/js9.txt rename to res/multi/js9.txt diff --git a/multi/kel1.txt b/res/multi/kel1.txt similarity index 100% rename from multi/kel1.txt rename to res/multi/kel1.txt diff --git a/multi/kel2.txt b/res/multi/kel2.txt similarity index 100% rename from multi/kel2.txt rename to res/multi/kel2.txt diff --git a/multi/kel3.txt b/res/multi/kel3.txt similarity index 100% rename from multi/kel3.txt rename to res/multi/kel3.txt diff --git a/multi/kel4.txt b/res/multi/kel4.txt similarity index 100% rename from multi/kel4.txt rename to res/multi/kel4.txt diff --git a/multi/kel5.txt b/res/multi/kel5.txt similarity index 100% rename from multi/kel5.txt rename to res/multi/kel5.txt diff --git a/multi/kel6.txt b/res/multi/kel6.txt similarity index 100% rename from multi/kel6.txt rename to res/multi/kel6.txt diff --git a/multi/m1.txt b/res/multi/m1.txt similarity index 100% rename from multi/m1.txt rename to res/multi/m1.txt diff --git a/multi/m2.txt b/res/multi/m2.txt similarity index 100% rename from multi/m2.txt rename to res/multi/m2.txt diff --git a/multi/mb1.txt b/res/multi/mb1.txt similarity index 100% rename from multi/mb1.txt rename to res/multi/mb1.txt diff --git a/multi/mb2.txt b/res/multi/mb2.txt similarity index 100% rename from multi/mb2.txt rename to res/multi/mb2.txt diff --git a/multi/mb3.txt b/res/multi/mb3.txt similarity index 100% rename from multi/mb3.txt rename to res/multi/mb3.txt diff --git a/multi/mb4.txt b/res/multi/mb4.txt similarity index 100% rename from multi/mb4.txt rename to res/multi/mb4.txt diff --git a/multi/mb5.txt b/res/multi/mb5.txt similarity index 100% rename from multi/mb5.txt rename to res/multi/mb5.txt diff --git a/multi/mb6.txt b/res/multi/mb6.txt similarity index 100% rename from multi/mb6.txt rename to res/multi/mb6.txt diff --git a/multi/mb7.txt b/res/multi/mb7.txt similarity index 100% rename from multi/mb7.txt rename to res/multi/mb7.txt diff --git a/multi/mb8.txt b/res/multi/mb8.txt similarity index 100% rename from multi/mb8.txt rename to res/multi/mb8.txt diff --git a/multi/mb9.txt b/res/multi/mb9.txt similarity index 100% rename from multi/mb9.txt rename to res/multi/mb9.txt diff --git a/multi/onj1.txt b/res/multi/onj1.txt similarity index 100% rename from multi/onj1.txt rename to res/multi/onj1.txt diff --git a/multi/pm1.txt b/res/multi/pm1.txt similarity index 100% rename from multi/pm1.txt rename to res/multi/pm1.txt diff --git a/multi/pm2.txt b/res/multi/pm2.txt similarity index 100% rename from multi/pm2.txt rename to res/multi/pm2.txt diff --git a/multi/pm3.txt b/res/multi/pm3.txt similarity index 100% rename from multi/pm3.txt rename to res/multi/pm3.txt diff --git a/multi/pm4.txt b/res/multi/pm4.txt similarity index 100% rename from multi/pm4.txt rename to res/multi/pm4.txt diff --git a/multi/pm5.txt b/res/multi/pm5.txt similarity index 100% rename from multi/pm5.txt rename to res/multi/pm5.txt diff --git a/multi/pm6.txt b/res/multi/pm6.txt similarity index 100% rename from multi/pm6.txt rename to res/multi/pm6.txt diff --git a/multi/pra1.txt b/res/multi/pra1.txt similarity index 100% rename from multi/pra1.txt rename to res/multi/pra1.txt diff --git a/multi/pra10.txt b/res/multi/pra10.txt similarity index 100% rename from multi/pra10.txt rename to res/multi/pra10.txt diff --git a/multi/pra11.txt b/res/multi/pra11.txt similarity index 100% rename from multi/pra11.txt rename to res/multi/pra11.txt diff --git a/multi/pra2.txt b/res/multi/pra2.txt similarity index 100% rename from multi/pra2.txt rename to res/multi/pra2.txt diff --git a/multi/pra3.txt b/res/multi/pra3.txt similarity index 100% rename from multi/pra3.txt rename to res/multi/pra3.txt diff --git a/multi/pra4.txt b/res/multi/pra4.txt similarity index 100% rename from multi/pra4.txt rename to res/multi/pra4.txt diff --git a/multi/pra5.txt b/res/multi/pra5.txt similarity index 100% rename from multi/pra5.txt rename to res/multi/pra5.txt diff --git a/multi/pra6.txt b/res/multi/pra6.txt similarity index 100% rename from multi/pra6.txt rename to res/multi/pra6.txt diff --git a/multi/pra7.txt b/res/multi/pra7.txt similarity index 100% rename from multi/pra7.txt rename to res/multi/pra7.txt diff --git a/multi/pra8.txt b/res/multi/pra8.txt similarity index 100% rename from multi/pra8.txt rename to res/multi/pra8.txt diff --git a/multi/pra9.txt b/res/multi/pra9.txt similarity index 100% rename from multi/pra9.txt rename to res/multi/pra9.txt diff --git a/multi/qc1.txt b/res/multi/qc1.txt similarity index 100% rename from multi/qc1.txt rename to res/multi/qc1.txt diff --git a/multi/qc2.txt b/res/multi/qc2.txt similarity index 100% rename from multi/qc2.txt rename to res/multi/qc2.txt diff --git a/multi/qc3.txt b/res/multi/qc3.txt similarity index 100% rename from multi/qc3.txt rename to res/multi/qc3.txt diff --git a/multi/sg1.txt b/res/multi/sg1.txt similarity index 100% rename from multi/sg1.txt rename to res/multi/sg1.txt diff --git a/multi/sg2.txt b/res/multi/sg2.txt similarity index 100% rename from multi/sg2.txt rename to res/multi/sg2.txt diff --git a/multi/sg3.txt b/res/multi/sg3.txt similarity index 100% rename from multi/sg3.txt rename to res/multi/sg3.txt diff --git a/multi/sg4.txt b/res/multi/sg4.txt similarity index 100% rename from multi/sg4.txt rename to res/multi/sg4.txt diff --git a/multi/swk1.txt b/res/multi/swk1.txt similarity index 100% rename from multi/swk1.txt rename to res/multi/swk1.txt diff --git a/multi/swk2a.txt b/res/multi/swk2a.txt similarity index 100% rename from multi/swk2a.txt rename to res/multi/swk2a.txt diff --git a/multi/swk2b.txt b/res/multi/swk2b.txt similarity index 100% rename from multi/swk2b.txt rename to res/multi/swk2b.txt diff --git a/multi/swk2c.txt b/res/multi/swk2c.txt similarity index 100% rename from multi/swk2c.txt rename to res/multi/swk2c.txt diff --git a/multi/z1.txt b/res/multi/z1.txt similarity index 100% rename from multi/z1.txt rename to res/multi/z1.txt diff --git a/multi/z2.txt b/res/multi/z2.txt similarity index 100% rename from multi/z2.txt rename to res/multi/z2.txt diff --git a/multi/z3.txt b/res/multi/z3.txt similarity index 100% rename from multi/z3.txt rename to res/multi/z3.txt diff --git a/multi/z4.txt b/res/multi/z4.txt similarity index 100% rename from multi/z4.txt rename to res/multi/z4.txt diff --git a/multi/z5.txt b/res/multi/z5.txt similarity index 100% rename from multi/z5.txt rename to res/multi/z5.txt diff --git a/rules2doc.py b/rules2doc.py index 3d732a61..a47cb6f0 100644 --- a/rules2doc.py +++ b/rules2doc.py @@ -2,6 +2,8 @@ from typing import Set from soundrts.lib import log +from soundrts.lib.package import Package +from soundrts.paths import BASE_PACKAGE_PATH log.add_console_handler() @@ -147,7 +149,8 @@ def can_use(c, t): rules = RulesForDoc() -rules.load(open("res/rules.txt").read(), open("res/ui/rules_doc.txt").read()) +base = Package.from_path(BASE_PACKAGE_PATH) +rules.load(base.open_text("rules.txt").read(), base.open_text("ui/rules_doc.txt").read()) for cat in ( ("1. Units", ("worker", "soldier")), ("2. Buildings", ("building",)), diff --git a/setup.py b/setup.py index 6693d19c..0b485ee5 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,6 @@ from cx_Freeze import Executable, setup import builddoc -import buildmultimapslist from soundrts.version import VERSION if platform.system() == "Windows" and ".venv" not in sys.executable: @@ -36,7 +35,7 @@ "silent": True, "packages": [], "excludes": ["Cython", "scipy", "numpy", "tkinter"], - "include_files": ["res", "single", "multi", "mods", "cfg", "doc"], + "include_files": ["res", "single", "mods", "cfg", "doc"], "replace_paths": [("*", f"{full_version}:")], } executables = [ @@ -44,7 +43,6 @@ Executable("server.py", base=None), ] -buildmultimapslist.build() builddoc.build() if os.path.exists(destination): print(f"{destination} already exists. Deleting...") diff --git a/soundrts/campaign.py b/soundrts/campaign.py index a7028ca6..067fe96b 100644 --- a/soundrts/campaign.py +++ b/soundrts/campaign.py @@ -1,35 +1,45 @@ import configparser import os import re +from pathlib import Path from . import msgparts as mp -from . import res -from .clientmedia import play_sequence, sounds, voice +from .clientmedia import play_sequence, voice from .clientmenu import Menu from .game import MissionGame +from .lib.package import resource_layer from .lib.msgs import nb2msg +from .lib.resource import res from .mapfile import Map from .paths import CAMPAIGNS_CONFIG_PATH -from .res import get_all_packages_paths -class MissionChapter(Map): - def __init__(self, p, campaign, id): - Map.__init__(self, p) +class Chapter: + campaign: "Campaign" + number: int + + def _next(self): + return self.campaign.next(self) + + +class MissionChapter(Chapter): + def __init__(self, campaign, number, map_): self.campaign = campaign - self.id = id + self.number = number + self.map = map_ - def _get_next(self): - return self.campaign.get_next(self) + @property + def title(self): + return self.map.title[1:] - def _victory(self): - menu = Menu([], []) - menu.append(mp.CONTINUE, self._get_next()) + def _run_victory_menu(self): + menu = Menu() + menu.append(mp.CONTINUE, self._next()) menu.append(mp.QUIT, None) menu.run() - def _defeat(self): - menu = Menu([], []) + def _run_defeat_menu(self): + menu = Menu() menu.append(mp.RESTART, self) menu.append(mp.QUIT, None) menu.run() @@ -43,101 +53,116 @@ def run(self): def run_next_step(self, game): if game.has_victory(): self.campaign.unlock_next(self) - if self._get_next(): - self._victory() + if self._next(): + self._run_victory_menu() else: - self._defeat() + self._run_defeat_menu() -class CutSceneChapter: - def __init__(self, path, campaign=None, id=None): +class CutSceneChapter(Chapter): + def __init__(self, campaign, number, path): self.path = path self.campaign = campaign - self.id = id + self.number = number self._load() def _load(self): - s = open(self.path).read() + s = self.campaign.resources.open_text(self.path).read() # header m = re.search("(?m)^title[ \t]+([0-9 ]+)$", s) if m: - l = m.group(1).split(" ") - l = [int(x) for x in l] + title = m.group(1).split(" ") + title = [int(x) for x in title] else: - l = nb2msg(self.id) - self.title = l + title = nb2msg(self.number) + self.title = title # content m = re.search("(?m)^sequence[ \t]+([0-9 ]+)$", s) if m: - l = m.group(1).split(" ") + sequence = m.group(1).split(" ") else: - l = [] - self.sequence = l - - def _get_next(self): - return self.campaign.get_next(self) + sequence = [] + self.sequence = sequence def run(self): voice.important(self.title) play_sequence(self.sequence) self.campaign.unlock_next(self) - if self._get_next(): - self._get_next().run() - - -def _is_a_cutscene(path): - if os.path.isfile(path): - with open(path) as t: - return t.readline() == "cut_scene_chapter\n" + if self._next(): + self._next().run() class Campaign: - def __init__(self, path, title=None): - self.path = path - if title: - self.title = title + def _id(self): + return re.sub("[^a-zA-Z0-9]", "_", self.name) + + def __init__(self, package, path): + self.name = Path(path).stem + self.resources = resource_layer(package, self.name) + self._set_title_and_mods() + self._set_mods_from_mods_txt() + self._set_chapters() + + def _set_title_and_mods(self): + if self.resources.isfile("campaign.txt"): + s = self.resources.open_text("campaign.txt").read() + else: + s = "" + m = re.search("(?m)^title[ \t]+([A-Za-z0-9 ]+)$", s) + if m: + self.title = m.group(1).split(" ") + else: + self.title = [self.name] + m = re.search("(?m)^mods[ \t]+([A-Za-z0-9 ]+)$", s) + if m: + self.mods = m.group(1) + elif re.search("(?m)^mods$", s): + self.mods = "" else: - self.title = [os.path.split(path)[1]] + self.mods = None + + def _set_mods_from_mods_txt(self): + if self.resources.isfile("mods.txt"): + self.mods = self.resources.open_text("mods.txt").read() + + def _set_chapters(self): self.chapters = [] - i = 0 + number = 0 while True: - cp = os.path.join(self.path, "%s.txt" % i) - if not os.path.isfile(cp): - cp = os.path.join(self.path, "%s" % i) - if not os.path.isdir(cp): + filename = f"{number}.txt" + if not self.resources.isfile(filename): + filename = f"{number}.zip" + if not self.resources.isfile(filename): break - if _is_a_cutscene(cp): - c = CutSceneChapter(cp, campaign=self, id=i) + if self._is_a_cutscene(filename): + c = CutSceneChapter(self, number, filename) else: - c = MissionChapter(cp, campaign=self, id=i) + file = self.resources.open_binary(filename) + map_ = Map.load(file, filename) + map_.name = self.name + "/" + str(number) + c = MissionChapter(self, number, map_) self.chapters.append(c) - i += 1 - p = os.path.join(self.path, "mods.txt") - if os.path.isfile(p): - self.mods = open(p).read() - else: - self.mods = None + number += 1 - def _get(self, id): - if id < len(self.chapters): - return self.chapters[id] + def _is_a_cutscene(self, path): + if path.endswith(".txt"): + with self.resources.open_text(path) as t: + return t.readline() == "cut_scene_chapter\n" + + def chapter(self, number): + if number < len(self.chapters): + return self.chapters[number] else: return None - def get_next(self, chapter): - return self._get(chapter.id + 1) - - def _get_id(self): - return re.sub("[^a-zA-Z0-9]", "_", self.path) + def next(self, chapter): + return self.chapter(chapter.number + 1) def _get_bookmark(self): c = configparser.SafeConfigParser() if os.path.isfile(CAMPAIGNS_CONFIG_PATH): - c.readfp(open(CAMPAIGNS_CONFIG_PATH)) - try: - return c.getint(self._get_id(), "chapter") - except: - return 0 + c.read_file(open(CAMPAIGNS_CONFIG_PATH)) + return c.getint(self._id(), "chapter", fallback=0) def _available_chapters(self): return self.chapters[: self._get_bookmark() + 1] @@ -145,64 +170,34 @@ def _available_chapters(self): def _set_bookmark(self, number): c = configparser.SafeConfigParser() if os.path.isfile(CAMPAIGNS_CONFIG_PATH): - c.readfp(open(CAMPAIGNS_CONFIG_PATH)) - if self._get_id() not in c.sections(): - c.add_section(self._get_id()) - c.set(self._get_id(), "chapter", repr(number)) + c.read_file(open(CAMPAIGNS_CONFIG_PATH)) + if self._id() not in c.sections(): + c.add_section(self._id()) + c.set(self._id(), "chapter", repr(number)) c.write(open(CAMPAIGNS_CONFIG_PATH, "w")) def unlock_next(self, chapter): - if self._get_bookmark() == chapter.id: - next_chapter = self.get_next(chapter) + if self._get_bookmark() == chapter.number: + next_chapter = self.next(chapter) if next_chapter: - self._set_bookmark(next_chapter.id) - - def load_resources(self): - sounds.load(res, self.path) - - def unload_resources(self): - sounds.unload(self.path) + self._set_bookmark(next_chapter.number) def run(self): if self.mods is not None: res.set_mods(self.mods) try: - self.load_resources() - menu = Menu(self.title, []) - if len(self._available_chapters()) > 1: - ch = self._available_chapters()[-1] - menu.append(mp.CONTINUE + ch.title, ch) - for ch in self._available_chapters(): - prefix = nb2msg(ch.id) if ch.id > 0 else [] - menu.append(prefix + ch.title, ch) - menu.append(mp.BACK, None) - menu.run() + res.set_campaign(self) + self.menu().run() finally: - self.unload_resources() - - -def _get_campaigns(): - w = [] - for pp in get_all_packages_paths(): - d = os.path.join(pp, "single") - if os.path.isdir(d): - for n in os.listdir(d): - p = os.path.join(d, n) - if os.path.isdir(p): - if n == "campaign": - w.append(Campaign(p, mp.CAMPAIGN)) - else: - w.append(Campaign(p)) - return w - - -_campaigns = None -_mods_at_the_previous_campaigns_update = None - - -def campaigns(): - global _campaigns, _mods_at_the_previous_campaigns_update - if _campaigns is None or _mods_at_the_previous_campaigns_update != res.mods: - _campaigns = _get_campaigns() - _mods_at_the_previous_campaigns_update = res.mods - return _campaigns + res.set_campaign() + + def menu(self): + menu = Menu(self.title) + if len(self._available_chapters()) > 1: + chapter = self._available_chapters()[-1] + menu.append(mp.CONTINUE + chapter.title, chapter) + for chapter in self._available_chapters(): + prefix = nb2msg(chapter.number) if chapter.number > 0 else [] + menu.append(prefix + chapter.title, chapter) + menu.append(mp.BACK, None) + return menu diff --git a/soundrts/clientgame.py b/soundrts/clientgame.py index 32522bf9..b68fc859 100644 --- a/soundrts/clientgame.py +++ b/soundrts/clientgame.py @@ -1,7 +1,9 @@ +import cProfile import math import queue import re import sys +import threading import time import pygame @@ -16,7 +18,7 @@ USEREVENT, ) -from . import config, res +from . import config from . import msgparts as mp from . import parameters from .animation import noise @@ -43,6 +45,7 @@ from .lib.mouse import set_cursor from .lib.msgs import eval_msg_and_volume, nb2msg from .lib.nofloat import PRECISION +from .lib.resource import res from .lib.screen import ( get_screen, screen_render, @@ -50,9 +53,12 @@ set_game_mode, ) from .lib.sound import angle, distance, psounds, stereo +from .paths import CUSTOM_BINDINGS_PATH from .version import IS_DEV_VERSION from .worldroom import Square +PROFILE = False + # minimal interval (in seconds) between 2 sounds ALERT_LIMIT = 0.5 @@ -940,18 +946,31 @@ def _process_srv_events(self): e = self._srv_queue.get() self._process_srv_event(*e) - def loop(self, game): - game.map.load_style(res) + def get_bindings(self): + b = res.text("ui/bindings", append=True, localize=True) try: - game.map.load_resources() - update_orders_list() # when style has changed - game.pre_run() - if game.world.objective: - voice.confirmation(mp.OBJECTIVE + game.world.objective) - self.load_bindings(game.map.get_bindings()) + b += "\n" + open(CUSTOM_BINDINGS_PATH).read() + except OSError: + pass + return b + + def run_game(self, game): + t = threading.Thread(target=game.world.loop) + t.daemon = True + t.start() + + update_orders_list() # when style has changed + game.pre_run() + if game.world.objective: + voice.confirmation(mp.OBJECTIVE + game.world.objective) + self.load_bindings(self.get_bindings()) + if PROFILE: + cProfile.runctx("self._loop()", globals(), locals(), "interface_profile.tmp") + else: self._loop() - finally: - game.map.unload_resources() + game._record_stats(game.world) + game.post_run() + game.world.stop() def _loop(self): from .clientserver import ConnectionAbortedError diff --git a/soundrts/clienthelp.py b/soundrts/clienthelp.py index 1244d661..c79d7939 100644 --- a/soundrts/clienthelp.py +++ b/soundrts/clienthelp.py @@ -1,8 +1,8 @@ -from . import res +from soundrts.lib.resource import res def _read_table_from_file(name): - return [x.split() for x in res.get_text_file(name).split("\n") if x.strip()] + return [x.split() for x in res.text(name).split("\n") if x.strip()] _game_table = _read_table_from_file("ui/game_help") diff --git a/soundrts/clientmain.py b/soundrts/clientmain.py index 306a5397..751a3126 100644 --- a/soundrts/clientmain.py +++ b/soundrts/clientmain.py @@ -8,7 +8,7 @@ os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1" -from .lib import log +from .lib import log, resource from .lib.log import exception, warning from .paths import CLIENT_LOG_PATH from .version import VERSION_FOR_BUG_REPORTS @@ -35,9 +35,8 @@ from . import discovery from . import msgparts as mp -from . import res, stats -from .campaign import campaigns -from .clientmedia import close_media, init_media, voice +from . import stats +from .clientmedia import close_media, init_media, voice, app_title from .clientmenu import CLOSE_MENU, Menu, input_string from .clientserver import ( connect_and_play, @@ -45,14 +44,13 @@ start_server_and_connect, ) from .clientversion import revision_checker -from .definitions import style +from .definitions import style, rules from .game import ReplayGame, TrainingGame from .lib.msgs import nb2msg -from .lib.resource import best_language_match, preferred_language -from .mapfile import worlds_multi +from .lib.resource import best_language_match, preferred_language, res from .metaserver import servers_list from .paths import CONFIG_DIR_PATH, REPLAYS_PATH, SAVE_PATH -from .version import VERSION, server_is_compatible +from .version import server_is_compatible def choose_server_ip_in_a_list(): @@ -195,7 +193,7 @@ def _set_faction(self, pn, r): def _add_faction_menus(self, menu): for pn, (p, pr) in enumerate(zip(self._players, self._factions)): - for r in ["random_faction"] + self._map.factions: + for r in ["random_faction"] + rules.factions: if r != pr: menu.append( [p,] + style.get(r, "title"), (self._set_faction, pn, r) @@ -204,32 +202,33 @@ def _add_faction_menus(self, menu): def _build_players_menu(self): menu = Menu() if len(self._players) < self._map.nb_players_max: - menu.append(mp.INVITE + mp.QUIET_COMPUTER, (self._add_ai, "easy")) - menu.append( - mp.INVITE + mp.AGGRESSIVE_COMPUTER, (self._add_ai, "aggressive") - ) - menu.append( - mp.INVITE + mp.AGGRESSIVE_COMPUTER + nb2msg(2), (self._add_ai, "ai2") - ) + self._add_ai_invite_menu(menu) if len(self._players) >= self._map.nb_players_min: menu.append(mp.START, self._run_game) - if len(self._map.factions) > 1: + if len(rules.factions) > 1: self._add_faction_menus(menu) menu.append(mp.CANCEL, CLOSE_MENU, mp.CANCEL_THIS_GAME) return menu + def _add_ai_invite_menu(self, menu): + menu.append(mp.INVITE + mp.QUIET_COMPUTER, (self._add_ai, "easy")) + menu.append(mp.INVITE + mp.AGGRESSIVE_COMPUTER, (self._add_ai, "aggressive")) + menu.append(mp.INVITE + mp.AGGRESSIVE_COMPUTER + nb2msg(2), (self._add_ai, "ai2")) + def _open_players_menu(self, m): - # note: won't work with factions defined in the map - style.load(res.get_text_file("ui/style", append=True, localize=True)) self._players = [config.login] self._factions = ["random_faction"] self._map = m - self._players_menu = self._build_players_menu() - self._players_menu.loop() + res.set_map(m) + try: + self._players_menu = self._build_players_menu() + self._players_menu.loop() + finally: + res.set_map() def run(self): menu = Menu(mp.START_A_GAME_ON, remember="mapmenu") - for m in worlds_multi(): + for m in res.multiplayer_maps(): menu.append(m.title, (self._open_players_menu, m)) menu.append(mp.QUIT2, None) menu.run() @@ -238,7 +237,7 @@ def run(self): def single_player_menu(): Menu( mp.MAKE_A_SELECTION, - [(c.title, c) for c in campaigns()] + [(c.title, c) for c in res.campaigns()] + [ (mp.START_A_GAME_ON, TrainingMenu().run), (mp.RESTORE, restore_game), @@ -280,6 +279,7 @@ def set_and_launch_mod(mods): def mods_menu(): + res.update_packages() mods_menu = Menu(mp.MODS) mods_menu.append([0], (set_and_launch_mod, "")) for mod in res.available_mods(): @@ -298,6 +298,7 @@ def set_and_launch_soundpack(soundpacks): def soundpacks_menu(): + res.update_packages() soundpacks_menu = Menu(mp.SOUNDPACKS) soundpacks_menu.append(mp.NOTHING, (set_and_launch_soundpack, "")) for soundpack in res.available_soundpacks(): @@ -322,7 +323,7 @@ def options_menu(): def main_menu(): Menu( - [f"SoundRTS {VERSION} {res.mods} {res.soundpacks},"] + mp.MAKE_A_SELECTION, + [app_title() + ","] + mp.MAKE_A_SELECTION, [ [mp.SINGLE_PLAYER, single_player_menu, mp.SINGLE_PLAYER_EXPLANATION], [mp.MULTIPLAYER2, multiplayer_menu, mp.MULTIPLAYER2_EXPLANATION], diff --git a/soundrts/clientmedia.py b/soundrts/clientmedia.py index fe1df436..2efef4d1 100644 --- a/soundrts/clientmedia.py +++ b/soundrts/clientmedia.py @@ -1,5 +1,3 @@ -"""This ugly module is fighting with res.py to make some useful job.""" - import os import platform @@ -7,9 +5,9 @@ from . import config from . import msgparts as mp -from . import res from .lib import sound from .lib.msgs import nb2msg +from .lib.resource import res from .lib.screen import set_screen from .lib.sound_cache import sounds from .lib.voice import voice @@ -22,9 +20,13 @@ fullscreen = False +def app_title(): + return f"SoundRTS {VERSION} {res.mods} {res.soundpacks}" + + def update_display_caption(): """set the window title""" - pygame.display.set_caption(f"SoundRTS {VERSION} {res.mods} {res.soundpacks}") + pygame.display.set_caption(app_title()) def minimal_init(): @@ -32,7 +34,7 @@ def minimal_init(): sound.init(config.num_channels) voice.init(config) set_screen(fullscreen) - update_display_caption() + res.register(update_display_caption) pygame.key.set_repeat(500, 100) diff --git a/soundrts/clientmenu.py b/soundrts/clientmenu.py index d8128b33..19b54937 100644 --- a/soundrts/clientmenu.py +++ b/soundrts/clientmenu.py @@ -115,10 +115,10 @@ class Menu: server = None def __init__(self, title=None, choices=None, default_choice_index=0, remember=None): - if not title: + if title is None: title = [] self.title = title - if not choices: + if choices is None: choices = [] self.choices = choices self.choice_index = None diff --git a/soundrts/clientservermenu.py b/soundrts/clientservermenu.py index e9c8d0b6..00e1e69c 100644 --- a/soundrts/clientservermenu.py +++ b/soundrts/clientservermenu.py @@ -1,15 +1,14 @@ import time from typing import List -from . import mapfile from . import msgparts as mp -from . import res from .clientmedia import play_sequence, voice from .clientmenu import Menu -from .definitions import style +from .definitions import style, rules from .game import MultiplayerGame from .lib.log import info, warning from .lib.msgs import eval_msg_and_volume, nb2msg +from .lib.resource import res def insert_silences(msg): @@ -241,17 +240,16 @@ class _BeforeGameMenu(_ServerMenu): registered_players = () def srv_map(self, args: List[str]) -> None: - self.map = mapfile.Map( - unpack=" ".join(args).encode() - ) # warning: args is split from a stripped string - self.map.load_style(res) + # warning: args is split from a stripped string + self.map = res.unpack_map(" ".join(args).encode(), save=True) + res.set_map(self.map) def srv_registered_players(self, args): self.registered_players = [p.split(",") for p in args] def _add_faction_menu(self, menu, pn, p, pr): - if len(self.map.factions) > 1: - for r in ["random_faction"] + self.map.factions: + if len(rules.factions) > 1: + for r in ["random_faction"] + rules.factions: if r != pr: menu.append( name(p) + style.get(r, "title"), diff --git a/soundrts/clientversion.py b/soundrts/clientversion.py index e71ef478..261fba70 100644 --- a/soundrts/clientversion.py +++ b/soundrts/clientversion.py @@ -7,7 +7,8 @@ from . import stats from .clientmedia import voice from .metaserver import METASERVER_URL -from .paths import OLD_STATS_PATH, STATS_PATH +from .paths import STATS_PATH +from .update import update_packages_from_servers from .version import VERSION @@ -36,10 +37,10 @@ def run(self): except: pass try: - stats.Stats(OLD_STATS_PATH, METASERVER_URL).send() stats.Stats(STATS_PATH, METASERVER_URL).send() except: pass + update_packages_from_servers() def start_if_needed(self): if self.never_started: diff --git a/soundrts/definitions.py b/soundrts/definitions.py index ac1ed26e..b96fb493 100644 --- a/soundrts/definitions.py +++ b/soundrts/definitions.py @@ -160,12 +160,14 @@ def _val(self, obj, attr): return return o[attr] - def get(self, obj, attr): + def get(self, obj, attr, default=None): v = self._val(obj, attr) if v is None and attr[-8:-1] == "_level_": v = self._val(obj, attr[:-8]) if isinstance(v, list): v = v[:] + if v is None and default is not None: + return default return v def get_dict(self, obj): @@ -255,7 +257,7 @@ class Rules(_Definitions): def normalized_cost_or_resources(self, lst): lst = lst[:] - n = self.get("parameters", "nb_of_resource_types") + n = self.get("parameters", "nb_of_resource_types", 2) while len(lst) < n: lst += [0] while len(lst) > n: @@ -266,7 +268,7 @@ def interpret(self, d, base, name): if hasattr(base, "interpret"): base.interpret(d) if "cost" not in d and hasattr(base, "cost"): - d["cost"] = [0] * self.get("parameters", "nb_of_resource_types") + d["cost"] = [0] * self.get("parameters", "nb_of_resource_types", 2) d = _update_old_definitions(d, name) for k, v in list(d.items()): if k == "class": diff --git a/soundrts/game.py b/soundrts/game.py index dd8e62f9..f832262c 100644 --- a/soundrts/game.py +++ b/soundrts/game.py @@ -1,6 +1,5 @@ import os.path import random -import threading import time from typing import List, Tuple, Union @@ -10,16 +9,16 @@ from . import clientgame, config, definitions from . import msgparts as mp -from . import res, stats +from . import stats from .clientgameorder import update_orders_list from .clientmedia import play_sequence, voice from .definitions import rules, style -from .lib.log import exception, warning +from .lib.log import warning from .lib.msgs import nb2msg -from .mapfile import Map +from .lib.resource import res from .paths import REPLAYS_PATH, SAVE_PATH from .version import VERSION, compatibility_version -from .world import World +from .world import World, MapError from .worldclient import ( Coordinator, DirectClient, @@ -30,8 +29,6 @@ send_platform_version_to_metaserver, ) -PROFILE = False - class _Game: @@ -51,15 +48,11 @@ def create_replay(self): ) self.replay_write(self.game_type_name) players = " ".join([p.login for p in self.players]) - self.replay_write(self.map.get_name() + " " + players) + self.replay_write(self.map.name + " " + players) self.replay_write(VERSION) - self.replay_write(res.mods) + self.replay_write(str(res.mods)) self.replay_write(compatibility_version()) - if self.game_type_name == "mission": - self.replay_write(self.map.campaign.path) - self.replay_write(str(self.map.id)) - else: - self.replay_write(self.map.pack().decode()) + self._replay_write_map() self.replay_write(players) alliances = [p.alliance for p in self.players] self.replay_write(" ".join(map(str, alliances))) @@ -67,13 +60,16 @@ def create_replay(self): self.replay_write(" ".join(factions)) self.replay_write(str(self.seed)) + def _replay_write_map(self): + self.replay_write(self.map.digest()) + def replay_write(self, s): self._replay_file.write(s + "\n") def _game_type(self): return "{}/{}/{}".format( VERSION, - self.game_type_name + "-" + self.map.get_name(), + self.game_type_name + "-" + self.map.name, self.nb_human_players, ) @@ -83,44 +79,22 @@ def _record_stats(self, world): def run(self, speed=config.speed): if self.record_replay: self.create_replay() - self.world = World( - self.default_triggers, - self.seed, - must_apply_equivalent_type=self.must_apply_equivalent_type, - ) - if self.world.load_and_build_map(self.map): - self.world.populate_map(self.players) + + self.world = World(self.default_triggers, self.seed) + + try: + self.world.load_and_build_map(self.map) + except MapError as msg: + msg = "map error: %s" % msg + warning(msg) + voice.alert(mp.BEEP + [msg]) + else: + self.world.populate_map(self.players, equivalents=self.must_apply_equivalent_type) self.nb_human_players = self.world.current_nb_human_players() - t = threading.Thread(target=self.world.loop) - t.daemon = True - t.start() self.interface = clientgame.GameInterface(self.local_client, speed=speed) self.interface.auto = self.auto - if PROFILE: - import cProfile - - cProfile.runctx( - "self.interface.loop(self)", - globals(), - locals(), - "interface_profile.tmp", - ) - import pstats - - for n in ("interface_profile.tmp",): - p = pstats.Stats(n) - p.strip_dirs() - p.sort_stats("time", "cumulative").print_stats(30) - p.print_callers(30) - p.print_callees(20) - p.sort_stats("cumulative").print_stats(50) - else: - self.interface.loop(self) - self._record_stats(self.world) - self.post_run() - self.world.stop() - else: - voice.alert(mp.BEEP + [self.world.map_error]) + self.interface.run_game(self) + if self.record_replay: self._replay_file.close() @@ -191,7 +165,7 @@ def _countdown(self): def pre_run(self): if len(self.humans) > 1: - send_platform_version_to_metaserver(self.map.get_name(), len(self.humans)) + send_platform_version_to_metaserver(self.map.name, len(self.humans)) self._countdown() def post_run(self): @@ -227,21 +201,11 @@ def run_on(self): os.path.join(REPLAYS_PATH, "%s.txt" % int(time.time())), "w" ) self._replay_file.write(self._replay_file_content) - try: - self.map.load_resources() - rules.copy(self._rules) - definitions._ai = self._ai - style.copy(self._style) - update_orders_list() # when style has changed - t = threading.Thread(target=self.world.loop) - t.daemon = True - t.start() - self.interface.loop(self) - self._record_stats(self.world) - self.post_run() - self.world.stop() - finally: - self.map.unload_resources() + rules.copy(self._rules) + definitions._ai = self._ai + style.copy(self._style) + update_orders_list() # when style has changed + self.interface.run_game(self) class TrainingGame(_MultiplayerGame, _Savable): @@ -263,12 +227,17 @@ class MissionGame(_Game, _Savable): game_type_name = "mission" _has_victory = False - def __init__(self, map): - self.map = map + def __init__(self, chapter): + self.chapter = chapter + self.map = chapter.map self.seed = random.randint(0, 10000) self.local_client = DirectClient(config.login, self) self.players = [self.local_client] + def _replay_write_map(self): + self.replay_write(self.chapter.campaign.name) + self.replay_write(str(self.chapter.number)) + def pre_run(self): if self.world.intro: play_sequence(self.world.intro) @@ -282,11 +251,13 @@ def has_victory(self): def run_on(self): try: - self.map.campaign.load_resources() + res.set_campaign(self.chapter.campaign) + res.set_map(self.map) _Savable.run_on(self) - self.map.run_next_step(self) + self.chapter.run_next_step(self) finally: - self.map.campaign.unload_resources() + res.set_map() + res.set_campaign() class ReplayGame(_Game): @@ -313,15 +284,7 @@ def __init__(self, replay: str) -> None: version, mods, ) - campaign_path_or_packed_map = self.replay_read() - if game_type_name == "mission" and "***" not in campaign_path_or_packed_map: - from .campaign import Campaign - - self.map = Campaign(campaign_path_or_packed_map)._get( - int(self.replay_read()) - ) - else: - self.map = Map(unpack=campaign_path_or_packed_map.encode()) + self._load_chapter_or_unpack_map(game_type_name) players = self.replay_read().split() alliances = self.replay_read().split() factions = self.replay_read().split() @@ -339,6 +302,16 @@ def __init__(self, replay: str) -> None: p.alliance = a p.faction = f + def _load_chapter_or_unpack_map(self, game_type_name): + campaign_name_or_map_digest = self.replay_read() + if game_type_name == "mission" and "***" not in campaign_name_or_map_digest: + campaign = res.find_campaign(campaign_name_or_map_digest) + res.set_campaign(campaign) + chapter = campaign.chapter(int(self.replay_read())) + self.map = chapter.map + else: + self.map = res.find_multiplayer_map(campaign_name_or_map_digest) + def replay_read(self): s = self._file.readline() if s and s.endswith("\n"): @@ -349,11 +322,7 @@ def pre_run(self): voice.info(mp.OBSERVE_ANOTHER_PLAYER_EXPLANATION) voice.flush() - def run(self): - if getattr(self.map, "campaign", None): - self.map.campaign.load_resources() - try: - _Game.run(self) - finally: - if getattr(self.map, "campaign", None): - self.map.campaign.unload_resources() + def post_run(self): + super().post_run() + res.set_map() + res.set_campaign() diff --git a/soundrts/lib/bindings.py b/soundrts/lib/bindings.py index 26358a7c..c86ef01d 100644 --- a/soundrts/lib/bindings.py +++ b/soundrts/lib/bindings.py @@ -13,6 +13,7 @@ KMOD_SHIFT, ) +from .defs import preprocess from .log import warning @@ -57,13 +58,6 @@ def _normalized_event(e): return normalized_mods, e.scancode -def _preprocess(s): - s = re.sub("(?m);.*$", "", s) # remove comments - s = re.sub("(?m)^[ \t]*$\n", "", s) # remove empty lines - s = re.sub(r"(?m)\\[ \t]*$\n", " ", s) # join lines ending with "\" - return s - - class Bindings: def __init__(self): self._bindings = {} @@ -114,7 +108,7 @@ def command_from_name(name): except AttributeError: raise _Error("'%s' is not a command" % name) - for line in _preprocess(s).split("\n"): + for line in preprocess(s).split("\n"): try: line = self._apply_definitions(line) self._process_line(line, command_from_name) diff --git a/soundrts/lib/package.py b/soundrts/lib/package.py new file mode 100644 index 00000000..0c7bce9f --- /dev/null +++ b/soundrts/lib/package.py @@ -0,0 +1,192 @@ +import io +import os +import zipfile +from pathlib import Path +from typing import IO +from zipfile import ZipFile + +from soundrts.lib import encoding +from soundrts.lib.zipdir import zipdir + + +class Package: # Dir? VirtualDir? + """a (virtual) directory (actually in a filesystem or in a zip file)""" + name = "default" + + @staticmethod + def from_path(name: str): + name = Path(name) + if name.suffix in [".zip", ".pkg"]: + return ZipPackage(ZipFile(name)) + else: + return FolderPackage(name) + + def open_binary(self, name) -> IO: ... + def dirnames(self): ... + def filenames(self): ... + def relative_paths_of_files_in_subtree(self, subdir): ... + def subpackage(self, subdir): ... + + def open_text(self, name): + b = self.open_binary(name) + e = encoding.encoding(b.read(), name) + b.seek(0) + return io.TextIOWrapper(b, encoding=e, errors="replace") + + def isfile(self, name): ... + def isdir(self, name): ... + + def is_a_soundpack(self): + for name in ("rules.txt", "ai.txt"): + if self.isfile(name): + return False + return True + + +class FolderPackage(str, Package): # DirInFilesystem? + + def open_binary(self, name): + path = os.path.join(self, name) + # local folder reading by zipping the folder first + if os.path.isdir(path): + f = io.BytesIO() + zipdir(path, f, compression=zipfile.ZIP_STORED) + f.seek(0) + return f + return open(path, "rb") + + def isfile(self, name): + path = os.path.join(self, name) + return os.path.isfile(path) + + def isdir(self, name): + path = os.path.join(self, name) + return os.path.isdir(path) + + def dirnames(self): + return next(os.walk(self))[1] + + def filenames(self): + return next(os.walk(self))[2] + + def relative_paths_of_files_in_subtree(self, subdir): + top = os.path.join(self, subdir) + if os.path.isdir(top): + for dirpath, _, filenames in os.walk(top): + for name in filenames: + path = os.path.join(dirpath, name) + yield path[len(self)+1:] + + def subpackage(self, subdir): + path = os.path.join(self, subdir) + if os.path.isdir(path): + return Package.from_path(path) + + +class ZipPackage(Package): # DirInZip? + def __init__(self, zipfile: ZipFile, subdir: str = None): + self._zipfile = zipfile + self._subdir = subdir + + def __repr__(self): + if self._subdir is None: + return f"" + else: + return f"" + + def dirnames(self): + result = set() + for name in self._namelist(): + try: + name = Path(name).parts[0] + except IndexError: + pass + else: + if not self.isfile(name): + result.add(name) + return result + + def filenames(self): + result = set() + for name in self._namelist(): + try: + name = Path(name).parts[0] + except IndexError: + pass + else: + if self.isfile(name): + result.add(name) + return result + + def relative_paths_of_files_in_subtree(self, path): + for name in self._namelist(): + if name.startswith(path + "/"): + yield name + + def subpackage(self, subdir): + if subdir.endswith(".zip") and subdir in self._namelist(): + return ZipPackage(ZipFile(self.open_binary(subdir))) + for name in self._namelist(): + if name.startswith(subdir + "/"): + return ZipPackage(self._zipfile, self._path(subdir)) + + def open_binary(self, name): + return self._zipfile.open(self._path(name)) + + def _path(self, name): + if not self._subdir: + return name + else: + return self._subdir + "/" + name + + def isfile(self, name): + return self._path(name) in self._zipfile.namelist() + + def isdir(self, name): + for n in self._namelist(): + if n.startswith(name + "/"): + return True + + def _namelist(self): + if self._subdir is None: + return self._zipfile.namelist() + else: + return self._short_name_list() + + def _short_name_list(self): + start = self._subdir + "/" + for name in self._zipfile.namelist(): + if name.startswith(start): + yield name[len(start):] + + +def resource_layer(package, name): + if package: + package.name = name + return package + + +class PackageStack(list): + def __init__(self, paths): + list.__init__(self) + self.extend(map(Package.from_path, paths)) + + def mod(self, name): + for package in reversed(self): + subdir = "mods/" + name + mod = resource_layer(package.subpackage(subdir), name) + if mod: + if mod.isfile("mod.txt"): + s = mod.open_text("mod.txt").read() + if s.startswith("mods "): + mod.mods = s.split(" ", 1)[1].split(",") + return mod + + def mods(self): + mod_names = set() + for package in reversed(self): + subdir = "mods" + mods = package.subpackage(subdir) + if mods is not None: + mod_names.update(mods.dirnames()) + return [self.mod(name) for name in mod_names] diff --git a/soundrts/lib/resource.py b/soundrts/lib/resource.py index 5f90a772..c2e0071b 100644 --- a/soundrts/lib/resource.py +++ b/soundrts/lib/resource.py @@ -1,29 +1,41 @@ -"""The resources are usually stored on a disk (as opposed to memory). -Which resource will be loaded will depend on the preferred language, +"""Which resource will be loaded will depend on the preferred language, the active packages, the loading order of the mods. Some resources will be combined differently: some are replaced (sounds), some are merged (some text files). """ -import io import locale import os import re -import zipfile -from typing import Dict, List, Optional, Union +from pathlib import Path +from typing import List -from soundrts.lib import encoding -from soundrts.lib.log import warning +from soundrts.definitions import rules, load_ai, style +from soundrts.lib.log import warning, exception +from soundrts.lib.package import PackageStack, Package +from soundrts.lib.sound_cache import sounds +from .. import config, options +from ..mapfile import Map +from ..pack import unpack_file +from ..paths import BASE_PACKAGE_PATH, packages_paths, DOWNLOADED_PATH -TXT_FILE = "ui/tts" - -def localize_path(path, lang): +def localized_path(path, lang): """Return the path modified for this language. For example, "ui" becomes "ui-fr". """ return re.sub(r"(? List[str]: - name += ".txt" - if root is None: - roots = self._paths - else: - roots = [root] + def _add(self, package): + if package: + self._layers.append(package) + + _notify = None + + def register(self, f): + self._notify = f + self._notify() + + _previous_layers = None + + def _reload(self): + self._layers = self.packages[:1] + self.mods = self._add_layers(self.packages, self.mods) + self.soundpacks = self._add_layers(self.packages, self.soundpacks) + if self._campaign: + self._add(self._campaign.resources) + if self._map: + self._add(self._map.resources) + if self._layers != self._previous_layers: + self.language = self._best_available_language() + self.load_rules_and_ai() + self.load_style() + sounds.load_default(self) + if self._notify: + self._notify() + self._previous_layers = self._layers[:] + + def set_mods(self, new_mods): + if new_mods != self.mods: + self.mods = new_mods + self._reload() + + def set_soundpacks(self, new_soundpacks): + if new_soundpacks != self.soundpacks: + self.soundpacks = new_soundpacks + self._reload() + + def set_map(self, m=None): + self._map = m + self._reload() + + def set_campaign(self, c=None): + self._campaign = c + self._reload() + + def load_rules_and_ai(self): + rules.load(self.text("rules", append=True)) + load_ai(self.text("ai", append=True)) + + def load_style(self): + style.load(self.text("ui/style", append=True, localize=True)) + + def texts(self, name: str, localize=False, root=None) -> List[str]: result = [] - for root in roots: - if isinstance(root, zipfile.ZipFile): - for text_file_path in self._localized_paths(name, localize): - if text_file_path in root.namelist(): - with root.open(text_file_path) as b: - e = encoding.encoding(b.read(), text_file_path) - with root.open(text_file_path) as b: - w = io.TextIOWrapper(b, encoding=e, errors="replace") # type: ignore - result.append(w.read()) + for package, path in self.paths(name + ".txt", root, localize): + try: + with package.open_text(path) as file: + text = file.read() + except (FileNotFoundError, KeyError): + pass else: - for text_file_path in self._localized_paths( - os.path.join(root, name), localize - ): - if os.path.isfile(text_file_path): - with open(text_file_path, "rb") as b: - e = encoding.encoding(b.read(), text_file_path) - with open(text_file_path, encoding=e, errors="replace") as t: - result.append(t.read()) + result.append(text) return result - def get_text_file(self, name, localize=False, append=False, root=None): + def text(self, name, localize=False, append=False, root=None): """Return the content of the text file with the highest priority or the concatenation of the text files contents. """ + texts = self.texts(name, localize, root) if append: - return "\n".join(self._get_text_files(name, localize, root)) + return "\n".join(texts) else: - return self._get_text_files(name, localize, root)[-1] - - def load_texts( - self, root: Optional[Union[str, zipfile.ZipFile]] = None - ) -> Dict[str, str]: - result = {} - for txt in self._get_text_files(TXT_FILE, localize=True, root=root): - lines = txt.split("\n") - for line in lines: - try: - line = line.strip() - if line: - key, value = line.split(None, 1) - if value: - result[key] = value - else: - warning("in '%s', line ignored: %s", TXT_FILE, line) - except: - warning("in '%s', syntax error: %s", TXT_FILE, line) - return result + return texts[-1] - def get_sound_paths(self, path, root=None): - """Return the list of sound paths. - Depends on the load order of the mods and the preferred language. - The list is reversed so the most relevant paths appear first. - """ + def paths(self, path, root=None, localize=False): if root is None: - roots = self._paths + roots = self._layers else: roots = [root] - result = [] + if localize: + lang = self.language + else: + lang = None for root in roots: - result.extend(self._localized_paths(os.path.join(root, path), True)) - return reversed(result) + for p in localized_paths(path, lang): + yield root, p + + _multi_maps = None + _mods_at_the_previous_multi_maps_update = None + + def multiplayer_maps(self): + if self._multi_maps is None or self._mods_at_the_previous_multi_maps_update != self.mods: + self._reload() # required by test_desync (used by _move_recommended_maps) + self._multi_maps = _get_multi_maps() + self._mods_at_the_previous_multi_maps_update = self.mods + return self._multi_maps + + def find_multiplayer_map(self, digest): + for m in self.multiplayer_maps(): + if m.digest() == digest: + return m + + def unpack_map(self, b: bytes, save=False): + buffer, name = unpack_file(b) + m = Map.loads(buffer, name) + if save and not self.find_multiplayer_map(m.digest()): + filename = Path(name).stem + "_" + m.digest()[:8] + Path(name).suffix + _save_downloaded_map(buffer, filename) + self._multi_maps = None # update soon + return m + + _campaigns = None + _mods_at_the_previous_campaigns_update = None + + def campaigns(self): + if self._campaigns is None or self._mods_at_the_previous_campaigns_update != self.mods: + self._campaigns = _campaigns() + _mods_at_the_previous_campaigns_update = self.mods + return self._campaigns + + def find_campaign(self, name): + for c in self.campaigns(): + if c.name == name: + return c + + +def _campaigns(): + from soundrts.campaign import Campaign + campaigns = [] + for package in res.packages: + single = package.subpackage("single") + if single: + for n in single.dirnames(): + c = Campaign(single.subpackage(n), n) + campaigns.append(c) + return campaigns + + +def _map_size(m): + return m.size() + + +def official_multiplayer_maps(): + maps = [] + official = res.packages[0].subpackage("multi") + for n in official.filenames(): + m = Map.load(official.open_binary(n), n) + m.official = True + maps.append(m) + return maps + + +def _add_custom_multi(maps): + for package in res.packages[1:] + [Package.from_path(DOWNLOADED_PATH)]: + multi = package.subpackage("multi") + if multi: + for n in list(multi.filenames()) + list(multi.dirnames()): + try: + m = Map.load(multi.open_binary(n), n) + except Exception as e: + exception("couldn't load map %s: %s", n, e) + else: + m.title.insert(0, 1097) # heal sound to alert player + maps.append(m) + + +def _copy_recommended_maps(maps): + for n in reversed(style.get("parameters", "recommended_maps")): + for m in reversed(maps[:]): # reversed so the custom map is after the official map + if m.name == n: + maps.insert(0, m) + + +def _get_multi_maps(): + maps = [] + maps.extend(official_multiplayer_maps()) + _add_custom_multi(maps) + from .message import Message + maps.sort(key=lambda x: Message(x.title).translate_and_collapse(remove_sounds=True)) + _copy_recommended_maps(maps) + return maps + + +def _save_downloaded_map(b, name): + try: + with open(os.path.join(DOWNLOADED_PATH, "multi", name), "wb") as f: + f.write(b) + except IOError: + warning("couldn't write %s", name) + + +def _resource_stack(): + if options.mods is not None: + mods = options.mods + else: + mods = config.mods + if options.soundpacks is not None: + soundpacks = options.soundpacks + else: + soundpacks = config.soundpacks + + result = ResourceStack([BASE_PACKAGE_PATH] + packages_paths()) + result.set_mods(mods) + result.set_soundpacks(soundpacks) + + return result + + +res = _resource_stack() diff --git a/soundrts/lib/sound_cache.py b/soundrts/lib/sound_cache.py index 86ad5722..9d27c5cb 100644 --- a/soundrts/lib/sound_cache.py +++ b/soundrts/lib/sound_cache.py @@ -1,9 +1,9 @@ """Sounds and text stored in memory (cache). Loaded from resources (depending on the active language, packages, mods, campaign, map).""" -import os import re import zipfile +from pathlib import Path from typing import Dict, Optional, Union import pygame @@ -12,17 +12,39 @@ from soundrts.lib.log import warning from soundrts.lib.msgs import NB_ENCODE_SHIFT +TXT_FILE = "ui/tts" + SHORT_SILENCE = "9998" # 0.01 s SILENCE = "9999" # 0.2 s -class Layer: +class TextTable(dict): + def __init__(self, res, path): + super().__init__() + for txt in res.texts(TXT_FILE, localize=True, root=path): + self._update_from_text(txt) + + def _update_from_text(self, txt): + lines = txt.split("\n") + for line in lines: + line = line.strip() + if line: + try: + key, value = line.split(None, 1) + except ValueError: + warning("in '%s', syntax error: %s", TXT_FILE, line) + else: + if value: + self[key] = value + else: + warning("in '%s', line ignored: %s", TXT_FILE, line) - txt: Dict[str, str] + +class Layer: sounds: Dict[str, Union[str, tuple, pygame.mixer.Sound]] def __init__(self, res, path=None): - self.txt = res.load_texts(path) + self.txt = TextTable(res, path) self._load_sounds(res, path) if path is None: # the silent sounds are needed (used as random noises in style.txt) @@ -39,45 +61,39 @@ def _load_sound(self, key, file_ref): def _load_sounds(self, res, root: Optional[Union[str, zipfile.ZipFile]]): self.sounds = {} - if isinstance(root, zipfile.ZipFile): - for path in res.get_sound_paths("ui", ""): - for name in root.namelist(): - if name.startswith(path) and name.endswith(".ogg"): - self._load_sound(os.path.basename(name)[:-4], (root, name)) - else: - for path in res.get_sound_paths("ui", root): - if os.path.isdir(path): - for dirpath, _, filenames in os.walk(path): - for name in filenames: - if name.endswith(".ogg"): - self._load_sound(name[:-4], os.path.join(dirpath, name)) - - -def _volume(name, path): + for package, path in reversed(list(res.paths("ui", root, localize=True))): + for name in package.relative_paths_of_files_in_subtree(path): + n = Path(name) + if n.suffix == ".ogg": + key = n.stem + file_ref = package, name + self._load_sound(key, file_ref) + + +def _volume(name, mod_name): d1 = parameters.d.get("volume", {}) if d1.get(name) is not None: return d1.get(name) else: d2 = parameters.d.get("default_volume", {}) - for n2, dv in d2.items(): - if n2 in os.path.normpath(path).split(os.sep): - return dv + if d2.get(mod_name) is not None: + return d2.get(mod_name) return 1 class Sound(pygame.mixer.Sound): - def __init__(self, path, name): - super().__init__(file=path) + def __init__(self, file, mod_name, name): + super().__init__(file=file) self.name = name - self.path = path + self.mod_name = mod_name self.update_volume() def update_volume(self): - self.set_volume(_volume(self.name, self.path)) + self.set_volume(_volume(self.name, self.mod_name)) class SoundCache: - """The sound cache contains numbered sounds and texts. + """Numbered sounds and texts. Usually a number will give only one type of value, but strange things can happen (until I fix this), with SHORT_SILENCE and SILENCE for example. """ @@ -100,38 +116,30 @@ def get_sound(self, name, warn=True): for layer in reversed(self.layers): if key in layer.sounds: s = layer.sounds[key] - if isinstance(s, str): # full path of the sound - # load the sound now + if isinstance(s, Sound): + return s + else: + package, name = s + mod_name = package.name try: - layer.sounds[key] = Sound(s, key) + layer.sounds[key] = Sound(package.open_binary(name), mod_name, key) return layer.sounds[key] - except: - warning("couldn't load %s" % s) + except IOError: + warning("couldn't load %s from %s", s, mod_name) del layer.sounds[key] continue # try next layer - elif isinstance(s, tuple): - zip_archive, name = s - layer.sounds[key] = pygame.mixer.Sound(file=zip_archive.open(name)) - return layer.sounds[key] - else: # sound - return s if warn: warning("this sound may be missing: %s", name) return None def has_sound(self, name): """return True if the cache have a sound with that name""" - return self.get_sound(name, warn=False) - - def has_text(self, key): - """return True if the cache have a text with that name""" - assert isinstance(key, str) - for layer in reversed(self.layers): - if key in layer.txt: - return True - return False + try: + return self.get_sound(name, warn=False) + except pygame.error: + pass - def get_text(self, key): + def text(self, key): """return the text corresponding to the given name""" assert isinstance(key, str) for layer in reversed(self.layers): @@ -142,21 +150,14 @@ def load_default(self, res): """load the default layer into memory from res""" self.layers = [Layer(res)] - def load(self, res, path): - self.layers.append(Layer(res, path)) - - def unload(self, path): - assert self.layers[-1].path == path - del self.layers[-1] - def translate_sound_number(self, sound_number): """Return the text or sound corresponding to the sound number. - If the number is greater than NB_ENCODE_SHIFT, then its really a number. + If the number is greater than NB_ENCODE_SHIFT, then it's really a number. """ key = "%s" % sound_number - if self.has_text(key): - return self.get_text(key) + if self.text(key) is not None: + return self.text(key) if re.match("^[0-9]+$", key) is not None and int(key) >= NB_ENCODE_SHIFT: return "%s" % (int(key) - NB_ENCODE_SHIFT) if self.has_sound(key): @@ -165,7 +166,7 @@ def translate_sound_number(self, sound_number): warning("this sound may be missing: %s", sound_number) try: return str(key) - except: + except ValueError: warning("Unicode error in %s", repr(key)) return str(key, errors="ignore") diff --git a/soundrts/lib/zipdir.py b/soundrts/lib/zipdir.py index af85c182..b5685c3b 100644 --- a/soundrts/lib/zipdir.py +++ b/soundrts/lib/zipdir.py @@ -10,8 +10,8 @@ from .log import warning -def zipdir(target_dir, dest_file): - z = zipfile.ZipFile(dest_file, "w", zipfile.ZIP_DEFLATED) +def zipdir(target_dir, dest_file, compression=zipfile.ZIP_DEFLATED): + z = zipfile.ZipFile(dest_file, "w", compression=compression) rootlen = len(target_dir) + 1 for base, _, filenames in os.walk(target_dir): for n in filenames: diff --git a/soundrts/mapfile.py b/soundrts/mapfile.py index 94de1913..045b7816 100644 --- a/soundrts/mapfile.py +++ b/soundrts/mapfile.py @@ -1,325 +1,108 @@ -import base64 import io import os.path import re -import shutil -import zipfile -from hashlib import md5 -from typing import List, Optional +from hashlib import sha256 +from zipfile import ZipFile -from . import res, world -from .definitions import Style, rules, load_ai -from .lib import zipdir -from .lib.log import exception -from .paths import TMP_PATH, CUSTOM_BINDINGS_PATH +from .lib.package import ZipPackage, Package, resource_layer -class Map: - - map_string = None - path: str - - def __init__( - self, - p: Optional[str] = None, - digest="no_digest", - official=False, - unpack: Optional[bytes] = None, - ): - self.digest = digest - self.official = official - if unpack is not None: - self._unpack(unpack) - elif p is not None: - self.path = p - self._load_header() - - def load_resources(self): - from .clientmedia import res, sounds - - if self._zip is not None: - path = self._zip - else: - path = self.path - sounds.load(res, path) - - def unload_resources(self): - from .clientmedia import sounds - - if self._zip is not None: - path = self._zip - else: - path = self.path - sounds.unload(path) - - def load_rules_and_ai(self, res): - rules.load( - res.get_text_file("rules", append=True), - self.campaign_rules, - self.additional_rules, - ) - load_ai( - res.get_text_file("ai", append=True), self.campaign_ai, self.additional_ai - ) - - def load_style(self, res): - from .definitions import style - - style.load( - res.get_text_file("ui/style", append=True, localize=True), - self.campaign_style, - self.additional_style, - ) - - def get_bindings(self): - b = res.get_text_file("ui/bindings", append=True, localize=True) - b += "\n" + self.get_campaign("ui/bindings.txt") - b += "\n" + self.get_additional("ui/bindings.txt") - try: - b += "\n" + open(CUSTOM_BINDINGS_PATH).read() - except OSError: - pass - return b - - def _read_additional_file(self, n): - p = os.path.join(self.path, n) - if os.path.isfile(p): - with open(p, encoding="utf-8", errors="replace") as t: - return t.read() - else: - return "" - - def _read_campaign_file(self, n): - if getattr(self, "campaign", None) is None: - return "" - p = os.path.join(self.campaign.path, n) - if os.path.isfile(p): - with open(p, encoding="utf-8", errors="replace") as t: - return t.read() - else: - return "" - - @property - def additional_rules(self): - return self._read_additional_file("rules.txt") - - @property - def campaign_rules(self): - return self._read_campaign_file("rules.txt") - - @property - def additional_ai(self): - return self._read_additional_file("ai.txt") - - @property - def campaign_ai(self): - return self._read_campaign_file("ai.txt") - - @property - def additional_style(self): - return self._read_additional_file(os.path.join("ui", "style.txt")) - - @property - def campaign_style(self): - return self._read_campaign_file(os.path.join("ui", "style.txt")) - - def get_additional(self, n): - return self._read_additional_file(n) +def _name_from_path(path): + name = os.path.basename(path) + name = os.path.splitext(name)[0] + name = re.sub("[^A-Za-z0-9._-]", "", name) + return name - def get_campaign(self, n): - return self._read_campaign_file(n) - def get_name(self, short=False) -> str: - name = os.path.basename(self.path) - if short: - name = os.path.splitext(name)[0] - name = re.sub("[^A-Za-z0-9._-]", "", name) - if name == "": - name = "unknown" - return name - - def read(self): - if self.map_string is not None: - return self.map_string - if os.path.isdir(self.path): - p = os.path.join(self.path, "map.txt") - else: - p = self.path - with open(p, encoding="utf-8", errors="replace") as t: - return t.read() - - def _extract_title(self, s): - m = re.search("(?m)^title[ \t]+([0-9 ]+)$", s) - if m and not self.official: - self.title = [int(x) for x in m.group(1).split(" ")] - else: - name = os.path.split(self.path)[1].lower() - name = re.sub(r"\.txt$", "", name) - name = re.sub("[^a-zA-Z0-9]", "", name) - self.title = [name] - - def get_digest(self): - # I use MD5 because: - # 1. I am not sure if hash() gives the same result on different versions. - # 2. MD5 is better as a hash than a simple CRC32 checksum. - # 3. MD5 is secure enough (this protection can be removed easily anyway), so SHA1 is not needed here. - try: - s = self.read() - except: - s = "" - s += self.additional_rules + self.additional_ai - return md5(s.encode()).hexdigest() - - def _check_digest(self): - if self.digest is None: - self.title.insert(0, 1097) # heal sound to alert player - elif self.digest != "no_digest" and self.get_digest() != self.digest: - self.title.insert(0, 1029) # hostile sound to alert player - - def _extract_nb_players(self, s): - search = re.search(r"(?m)^nb_players_min[ \t]+([0-9]+)$", s) - if search is not None: - self.nb_players_min = int(search.group(1)) +class Map: + # stats, logs, replay menu... (not really an id though) + name = "unknown" + + # raw content + buffer: bytes + buffer_name: str # includes the extension for the filetype (check if zipfile?) + + # unpacked content + definition: str = None + resources = None + + # header (also in parsed definition) + title: list + nb_players_min: int + nb_players_max: int + + def __init__(self, path: str = None): + if path is not None: + self._init_from_path(path) + + @staticmethod + def load(f, name): + m = Map() + m._init_from_buffer(f.read(), name) + return m + + def _load_from_text_file(self, f): + self.definition = f.read() + self._load_header() + + def _load_from_package(self, package): + self.resources = resource_layer(package, self.name) + self._load_from_text_file(self.resources.open_text("map.txt")) + + def _init_from_path(self, path): + self.name = _name_from_path(path) + if path.endswith(".txt"): + f = open(path, encoding="utf-8", errors="replace") + self._load_from_text_file(f) else: - self.nb_players_min = 1 - search = re.search(r"(?m)^nb_players_max[ \t]+([0-9]+)$", s) - if search is not None: - self.nb_players_max = int(search.group(1)) + package = Package.from_path(path) + self._load_from_package(package) + + @staticmethod + def loads(buffer: bytes, name): + map_ = Map() + map_._init_from_buffer(buffer, name) + return map_ + + def _init_from_buffer(self, buffer, name_with_ext): + self.buffer = buffer + self.buffer_name = name_with_ext + path = name_with_ext # "short path" (Path.name) + self.name = _name_from_path(path) + if path.endswith(".txt"): + s = buffer.decode(encoding="utf-8", errors="replace") + f = io.StringIO(s, newline=None) + self._load_from_text_file(f) else: - self.nb_players_max = 1 + package = ZipPackage(ZipFile(io.BytesIO(buffer))) + self._load_from_package(package) def _load_header(self): - try: - s = self.read() - except: - s = "" - self._extract_title(s) - self._check_digest() - self._extract_nb_players(s) + self.title = self._extract_title() + self.nb_players_min = self._find_int_from("nb_players_min", 1) + self.nb_players_max = self._find_int_from("nb_players_max", 1) - _original_map_bytes = None + def _extract_title(self): + return [f"{self.name}"] + self._title_from_definition() - def pack(self) -> bytes: - if self._original_map_bytes is not None: - return self._original_map_bytes - if os.path.isfile(self.path): - map_name = os.path.split(self.path)[-1] - with open(self.path, encoding="utf-8", errors="replace") as t: - content = base64.b64encode( - t.read().encode(encoding="utf-8", errors="replace") - ) - return ( - map_name.encode(encoding="utf-8", errors="replace") + b"***" + content - ) + def _title_from_definition(self) -> list: + line: str = self._find_a_line_with("title") + if line: + return [int(x) for x in line.split(" ")] else: - b = io.BytesIO() - zipdir.zipdir(self.path, b) - content = base64.b64encode(b.getvalue()) - return b"zip" + b"***" + content + return [] - _zip = None + def _find_a_line_with(self, keyword): + match = re.search("(?m)^%s[ \t]+([0-9 ]+)$" % keyword, self.definition) + if match: + return match.group(1) - def _unpack(self, map_bytes: bytes) -> None: - self._original_map_bytes = map_bytes - try: - path, content = map_bytes.split(b"***", 1) - self.path = path.decode(encoding="utf-8", errors="replace") - if self.path != "zip": - self.map_string = base64.b64decode(content).decode( - encoding="utf-8", errors="replace" - ) - with open( - os.path.join(TMP_PATH, "recent_map.txt"), - "w", - encoding="utf-8", - errors="replace", - ) as t: - t.write(self.map_string) - else: - zd = os.path.join(TMP_PATH, "recent_map") - shutil.rmtree(zd, True) - b = io.BytesIO(base64.b64decode(content)) - zipdir.unzipdir(b, zd) - self.path = zd - b.seek(0) - self._zip = zipfile.ZipFile(b) - except: - exception("unpacking problem") + def _find_int_from(self, keyword, default): + line = self._find_a_line_with(keyword) + if line: + return int(line) else: - self._load_header() - - def size(self): - result = 0 - w = world.World([], 0) - w.load_and_build_map(self) - for sq in w.squares: - result += len(sq.objects) - for st in w.players_starts + w.computers_starts: - for _, _, n in st[1]: - result += n - return result - - _factions = None - _mods = None - - @property - def factions(self): - if self._factions is None and self._mods != res.mods: - self.load_rules_and_ai(res) - self._factions = rules.factions - self._mods = res.mods - return self._factions - - -def _add_official_multi(w): - maps = [line.strip().split() for line in open("cfg/official_maps.txt")] - for n, digest in maps: - p = os.path.join("multi", n) - w.append(Map(p, digest, official=True)) - - -def _add_if_not_there(w, p): - if os.path.normpath(p) not in (os.path.normpath(x.path) for x in w): - w.append(Map(p, None)) - - -def _add_custom_multi(w): - for pp in res.get_all_packages_paths(): - for dirpath, dirnames, filenames in os.walk(os.path.join(pp, "multi")): - for n in filenames: - if n != "map.txt": - _add_if_not_there(w, os.path.join(dirpath, n)) - for n in dirnames[:]: - if os.path.isfile(os.path.join(dirpath, n, "map.txt")): - _add_if_not_there(w, os.path.join(dirpath, n)) - dirnames.remove(n) - - -def _move_recommended_maps(w): - style = Style() - style.load(res.get_text_file("ui/style", append=True, localize=True)) - for n in reversed(style.get("parameters", "recommended_maps")): - for m in reversed(w[:]): # reversed so the custom map is after the official map - if m.get_name(short=True) == n: - w.remove(m) - w.insert(0, m) - - -def _get_worlds_multi(): - w = [] - _add_official_multi(w) - _add_custom_multi(w) - _move_recommended_maps(w) - return w - - -_multi_maps = None -_mods_at_the_previous_multi_maps_update = None - + return default -def worlds_multi() -> List[Map]: - global _multi_maps, _mods_at_the_previous_multi_maps_update - if _multi_maps is None or _mods_at_the_previous_multi_maps_update != res.mods: - _multi_maps = _get_worlds_multi() - _mods_at_the_previous_multi_maps_update = res.mods - return _multi_maps + def digest(self): + return sha256(self.buffer).hexdigest() diff --git a/soundrts/msgparts.py b/soundrts/msgparts.py index 333f1367..407ad449 100644 --- a/soundrts/msgparts.py +++ b/soundrts/msgparts.py @@ -17,7 +17,6 @@ CANCEL2 = [4075] # BACK? CANCEL_GAME = [4070] CANCEL_THIS_GAME = [4060] # explanation of CANCEL -CAMPAIGN = [4267] CHANGED = [4224] CHEATMODE = [4265] CHOOSE_SERVER_IN_LIST = [4119] diff --git a/soundrts/options.py b/soundrts/options.py index 65022c53..f0b3257f 100644 --- a/soundrts/options.py +++ b/soundrts/options.py @@ -6,20 +6,23 @@ ip = None mods = None +soundpacks = None port = 2500 def _parse_options(): - global ip, mods, port + global ip, mods, soundpacks, port default_port = port parser = optparse.OptionParser() parser.add_option("-i", "--ip", type="string") parser.add_option("-m", "--mods", type="string") + parser.add_option("-s", "--soundpacks", type="string") parser.add_option("-p", type="int", help=optparse.SUPPRESS_HELP) parser.set_defaults(ip=None, mods=None, p=default_port, g=False) options, _ = parser.parse_args() ip = options.ip mods = options.mods + soundpacks = options.soundpacks port = options.p if ip: warning("using IP %s", ip) diff --git a/soundrts/pack.py b/soundrts/pack.py new file mode 100644 index 00000000..fbd113e3 --- /dev/null +++ b/soundrts/pack.py @@ -0,0 +1,43 @@ +import base64 +import io +import os + +from soundrts.lib import zipdir + +SEPARATOR = b"***" + + +def unpack_file(bytes_: bytes): + encoded_name, encoded_buffer = bytes_.split(SEPARATOR, 1) + name: str = encoded_name.decode(encoding="utf-8", errors="replace") + buffer: bytes = base64.b64decode(encoded_buffer) + return buffer, name + + +def pack_file_or_folder(path) -> bytes: + if os.path.isfile(path): + name, buffer = _pack_file(path) + else: + name, buffer = _pack_folder(path) + return pack_buffer(buffer, name) + + +def _pack_file(path): + n = os.path.split(path)[-1] + with open(path, "rb") as f: + b = f.read() + return n, b + + +def _pack_folder(path): + n = os.path.split(path)[-1] + ".zip" + f = io.BytesIO() + zipdir.zipdir(path, f) + b = f.getvalue() + return n, b + + +def pack_buffer(buffer, name): + encoded_name = name.encode(encoding="utf-8", errors="replace") + encoded_buffer = base64.b64encode(buffer) + return encoded_name + SEPARATOR + encoded_buffer diff --git a/soundrts/paths.py b/soundrts/paths.py index 55f8f7c8..1ce73d10 100644 --- a/soundrts/paths.py +++ b/soundrts/paths.py @@ -1,5 +1,7 @@ import os +from soundrts import parameters + def _mkdir(path): if not os.path.exists(path): @@ -12,12 +14,12 @@ def _mkdir(path): if os.path.exists("user"): CONFIG_DIR_PATH = "user" -elif "APPDATA" in os.environ: # Windows XP +elif "APPDATA" in os.environ: # Windows CONFIG_DIR_PATH = os.path.join(os.environ["APPDATA"], "SoundRTS") -elif "HOME" in os.environ: # Linux +elif "HOME" in os.environ: CONFIG_DIR_PATH = os.path.join(os.environ["HOME"], ".SoundRTS") -else: # Windows 95, Windows 98 ? - CONFIG_DIR_PATH = os.getcwd() +else: + CONFIG_DIR_PATH = "user" _mkdir(CONFIG_DIR_PATH) TMP_PATH = os.path.join(CONFIG_DIR_PATH, "tmp") @@ -26,21 +28,40 @@ def _mkdir(path): REPLAYS_PATH = os.path.join(CONFIG_DIR_PATH, "replays") _mkdir(REPLAYS_PATH) +DOWNLOADED_PATH = os.path.join(CONFIG_DIR_PATH, "downloaded") +_mkdir(DOWNLOADED_PATH) + CLIENT_LOG_PATH = os.path.join(TMP_PATH, "client.log") SERVER_LOG_PATH = os.path.join(TMP_PATH, "server.log") -MAPERROR_PATH = os.path.join(TMP_PATH, "maperror.txt") CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, "SoundRTS.ini") CAMPAIGNS_CONFIG_PATH = os.path.join(CONFIG_DIR_PATH, "campaigns.ini") -OLD_STATS_PATH = os.path.join(CONFIG_DIR_PATH, "stats.txt") STATS_PATH = os.path.join(CONFIG_DIR_PATH, "stats.tmp") SAVE_PATH = os.path.join(CONFIG_DIR_PATH, "savegame") CUSTOM_BINDINGS_PATH = os.path.join(CONFIG_DIR_PATH, "bindings.txt") -MAPS_PATHS = ["", CONFIG_DIR_PATH] -if MAPS_PATHS[0] == MAPS_PATHS[1]: - del MAPS_PATHS[1] -else: - _mkdir(os.path.join(CONFIG_DIR_PATH, "single")) - _mkdir(os.path.join(CONFIG_DIR_PATH, "multi")) - _mkdir(os.path.join(CONFIG_DIR_PATH, "mods")) +_mkdir(os.path.join(CONFIG_DIR_PATH, "single")) +_mkdir(os.path.join(CONFIG_DIR_PATH, "multi")) +_mkdir(os.path.join(CONFIG_DIR_PATH, "mods")) +_mkdir(os.path.join(CONFIG_DIR_PATH, "packages")) + +_mkdir(os.path.join(DOWNLOADED_PATH, "multi")) +_mkdir(os.path.join(DOWNLOADED_PATH, "packages")) + +DOWNLOADED_PACKAGES_PATH = os.path.join(DOWNLOADED_PATH, "packages") + +BASE_PACKAGE_PATH = parameters.d["packages"]["base"] +BASE_PATHS = parameters.d["packages"]["additional"] + + +def packages_paths(): + packages = [] + packages.extend(BASE_PATHS) + for rp in BASE_PATHS: + pp = os.path.join(rp, "packages") + if os.path.isdir(pp): + for name in os.listdir(pp): + p = os.path.join(pp, name) + if os.path.normpath(p) != os.path.normpath(BASE_PACKAGE_PATH): + packages.append(p) + return packages diff --git a/soundrts/res.py b/soundrts/res.py deleted file mode 100644 index 1c333c80..00000000 --- a/soundrts/res.py +++ /dev/null @@ -1,78 +0,0 @@ -"""SoundRTS resource manager""" - -import os - -from . import config, options -from .lib.resource import ResourceLoader -from .paths import MAPS_PATHS - - -def get_all_packages_paths(): - """return the default "maps and mods" paths followed by the paths of the active packages""" - return MAPS_PATHS # + package_manager.get_packages_paths() - - -if options.mods is not None: - mods = options.mods -else: - mods = config.mods -_r = ResourceLoader(mods, config.soundpacks, get_all_packages_paths()) -mods = _r.mods -soundpacks = _r.soundpacks -get_text_file = _r.get_text_file -load_texts = _r.load_texts -get_sound_paths = _r.get_sound_paths - - -def reload_all(): - global mods, soundpacks - from .clientmedia import sounds, update_display_caption - - _r.update_mods_list(mods, soundpacks, get_all_packages_paths()) - mods = _r.mods - soundpacks = _r.soundpacks - update_display_caption() - sounds.load_default(_r) - - -def set_mods(new_mods): - global mods - if new_mods != mods: - mods = new_mods - reload_all() - - -def set_soundpacks(new_soundpacks): - global soundpacks - if new_soundpacks != soundpacks: - soundpacks = new_soundpacks - reload_all() - - -# mods - - -def is_a_soundpack(path): - for name in ("rules.txt", "ai.txt"): - if os.path.isfile(os.path.join(path, name)): - return False - return True - - -def is_a_mod(path): - return not is_a_soundpack(path) - - -def available_mods(check_mod_type=is_a_mod): - result = [] - for path in get_all_packages_paths(): - mods_path = os.path.join(path, "mods") - for mod in os.listdir(mods_path): - path = os.path.join(mods_path, mod) - if os.path.isdir(path) and check_mod_type(path) and mod not in result: - result.append(mod) - return result - - -def available_soundpacks(): - return available_mods(is_a_soundpack) diff --git a/soundrts/serverclient.py b/soundrts/serverclient.py index ef32ac79..83aa5462 100644 --- a/soundrts/serverclient.py +++ b/soundrts/serverclient.py @@ -6,7 +6,8 @@ from .lib.log import exception, info, warning from .lib.msgs import encode_msg -from .mapfile import worlds_multi +from .lib.resource import res +from .pack import pack_buffer if TYPE_CHECKING: from .servermain import Server @@ -21,6 +22,16 @@ ) +def _map(map_index_or_name): + maps = res.multiplayer_maps() + try: + return maps[int(map_index_or_name)] + except ValueError: + for scenario in maps: + if map_index_or_name in scenario.name: + return scenario + + class ConnectionToClient(asynchat.async_chat): is_disconnected = False @@ -110,7 +121,7 @@ def send_maps(self): self.push( "maps %s\n" % " ".join( - [",".join([str(y) for y in x.title]) for x in worlds_multi()] + [",".join([str(y) for y in x.title]) for x in res.multiplayer_maps()] ) ) else: @@ -136,14 +147,14 @@ def _get_version_and_login_from_data(self, data): version, login = data.split(" ", 1) except: warning("can't extract version and login: %s" % data) - return (None, None) - if re.match("^[a-zA-Z0-9]{1,20}$", login) == None: + return None, None + if re.match("^[a-zA-Z0-9]{1,20}$", login) is None: warning("bad login: %s" % login) - return (version, None) + return version, None if len(self.server.clients) >= self.server.nb_clients_max: warning("refused client %s: too many clients." % login) - return (version, None) - return (version, self._unique_login(login)) + return version, None + return version, self._unique_login(login) @property def compatible_clients(self): @@ -188,33 +199,32 @@ def cmd_login(self, args): # "in the lobby" commands def cmd_create(self, args: str) -> None: + map_index_or_name = args[0] + speed = float(args[1]) + is_public = len(args) >= 3 and args[2] == "public" if self.server.can_create(self): - self.state = OrganizingAGame() - self.push("game_admin_menu\n") - scs = worlds_multi() - try: - scenario = scs[int(args[0])] - except ValueError: - for scenario in scs: - if args[0] in scenario.path: - break - self.push("map %s\n" % scenario.pack().decode()) - speed = float(args[1]) - is_public = len(args) >= 3 and args[2] == "public" - self.server.games.append( - Game(scenario, speed, self.server, self, is_public) - ) - self.server.update_menus() + map_ = _map(map_index_or_name) + if map_: + self._create_game(map_, speed, is_public) else: warning("game not created (max number reached)") self.notify("too_many_games") + def _create_game(self, map_, speed, is_public): + self.state = OrganizingAGame() + self.push("game_admin_menu\n") + self.push("map %s\n" % pack_buffer(map_.buffer, map_.buffer_name).decode()) + self.server.games.append( + Game(map_, speed, self.server, self, is_public) + ) + self.server.update_menus() + def cmd_register(self, args: str) -> None: game = self.server.get_game_by_id(args[0]) if game is not None and game.can_register(): self.state = WaitingForTheGameToStart() self.push("game_guest_menu\n") - self.push("map %s\n" % game.scenario.pack().decode()) + self.push("map %s\n" % pack_buffer(game.scenario.buffer, game.scenario.buffer_name).decode()) game.register(self) self.server.update_menus() else: @@ -222,7 +232,7 @@ def cmd_register(self, args: str) -> None: def cmd_quit(self, unused_args): # When the client wants to quit, he first sends "quit" to the server. - # Then the server knows he musn't send commands anymore. He warns the + # Then the server knows he mustn't send commands anymore. He warns the # client: "ok, you can quit". Then the client closes the connection # and then, and only then, the server forgets the client. self.push("quit\n") diff --git a/soundrts/serverroom.py b/soundrts/serverroom.py index 24642f5c..e9f5f08c 100644 --- a/soundrts/serverroom.py +++ b/soundrts/serverroom.py @@ -27,6 +27,8 @@ def pack(p): class _State: + allowed_commands: tuple + def send_menu(self, client): pass @@ -164,7 +166,7 @@ def _start(self): info( "start game %s on map %s with players %s", self.id, - self.scenario.get_name(), + self.scenario.name, " ".join(p.login for p in self.players), ) self.guests = [] @@ -277,7 +279,7 @@ def _dispatch_orders_if_needed(self): def invite(self, client): self.guests.append(client) client.notify( - "invitation", self.admin.login, self.scenario.get_name(short=True) + "invitation", self.admin.login, self.scenario.name ) def invite_computer(self, level): @@ -327,7 +329,7 @@ def register(self, client): @property def short_status(self): return ( - self.scenario.get_name(short=True), + self.scenario.name, ",".join([c.login for c in self.players]), self.nb_minutes, ) diff --git a/soundrts/tests/jl2.txt b/soundrts/tests/jl2.txt new file mode 100644 index 00000000..5aee88d4 --- /dev/null +++ b/soundrts/tests/jl2.txt @@ -0,0 +1,49 @@ +; multiplayer map 2 +; +; ******* +; 7 * * +; * *** * +; 6 * * +; ******* ******* +; 5* * * * +; * * * *** * * * +; 4* * * * * * +; * * * *** * * * +; 3* * * * +; ******* ******* +; 2 * * +; * *** * +; 1 * * +; ******* +; A B C D E F G + +title 3002 +objective 145 88 + +; map size +square_width 12 +nb_columns 7 +nb_lines 7 + +; paths (give only the starting square) +west_east_paths c1 d1 c2 d2 c3 d3 b4 e4 c5 d5 c6 d6 c7 d7 +west_east_bridges a3 f3 a5 f5 +south_north_paths a3 a4 b3 b4 c3 c4 d2 d5 e3 e4 f3 f4 g3 g4 +south_north_bridges c1 c6 e1 e6 + +; resources +;goldmines 75 d1 d7 a4 g4 +goldmines 75 c1 e7 a5 g3 +goldmines 150 e2 c6 b3 f5 +woods 75 e1 c7 a3 g5 +woods 75 e2 c6 b3 f5 +woods 75 e2 c6 b3 f5 +nb_meadows_by_square 2 +additional_meadows + +; players +nb_players_min 2 +nb_players_max 4 +starting_squares d1 d7 a4 g4 +starting_units townhall farm peasant +starting_resources 10 10 \ No newline at end of file diff --git a/soundrts/tests/jl4.zip b/soundrts/tests/jl4.zip new file mode 100644 index 00000000..644b3a1a Binary files /dev/null and b/soundrts/tests/jl4.zip differ diff --git a/soundrts/tests/res.zip b/soundrts/tests/res.zip new file mode 100644 index 00000000..44534a14 Binary files /dev/null and b/soundrts/tests/res.zip differ diff --git a/soundrts/tests/res/multi/jl1.txt b/soundrts/tests/res/multi/jl1.txt new file mode 100644 index 00000000..515e921e --- /dev/null +++ b/soundrts/tests/res/multi/jl1.txt @@ -0,0 +1,40 @@ +; multiplayer map 1 +; +; ******* +; 4* + + * +; *+***+* +; 3* + + * +; ***+*** +; 2* + + * +; *+***+* +; 1* + + * +; ******* +; A B C + +title 3001 +objective 145 88 + +; map size +square_width 12 +nb_columns 3 +nb_lines 4 + +; paths (give only the starting square) +west_east_paths a1 b1 a2 b2 a3 b3 a4 b4 +south_north_paths b2 +south_north_bridges a1 c1 a3 c3 + +; resources +;goldmines 75 b1 b4 +goldmines 75 a1 c4 +goldmines 150 c2 a3 +woods 75 c1 a4 c2 c2 a3 a3 +nb_meadows_by_square 2 +additional_meadows b1 b4 + +; players +nb_players_min 2 +nb_players_max 2 +starting_squares b1 b4 +starting_units townhall farm peasant +starting_resources 10 10 \ No newline at end of file diff --git a/soundrts/tests/res/multi/jl2.txt b/soundrts/tests/res/multi/jl2.txt new file mode 100644 index 00000000..5aee88d4 --- /dev/null +++ b/soundrts/tests/res/multi/jl2.txt @@ -0,0 +1,49 @@ +; multiplayer map 2 +; +; ******* +; 7 * * +; * *** * +; 6 * * +; ******* ******* +; 5* * * * +; * * * *** * * * +; 4* * * * * * +; * * * *** * * * +; 3* * * * +; ******* ******* +; 2 * * +; * *** * +; 1 * * +; ******* +; A B C D E F G + +title 3002 +objective 145 88 + +; map size +square_width 12 +nb_columns 7 +nb_lines 7 + +; paths (give only the starting square) +west_east_paths c1 d1 c2 d2 c3 d3 b4 e4 c5 d5 c6 d6 c7 d7 +west_east_bridges a3 f3 a5 f5 +south_north_paths a3 a4 b3 b4 c3 c4 d2 d5 e3 e4 f3 f4 g3 g4 +south_north_bridges c1 c6 e1 e6 + +; resources +;goldmines 75 d1 d7 a4 g4 +goldmines 75 c1 e7 a5 g3 +goldmines 150 e2 c6 b3 f5 +woods 75 e1 c7 a3 g5 +woods 75 e2 c6 b3 f5 +woods 75 e2 c6 b3 f5 +nb_meadows_by_square 2 +additional_meadows + +; players +nb_players_min 2 +nb_players_max 4 +starting_squares d1 d7 a4 g4 +starting_units townhall farm peasant +starting_resources 10 10 \ No newline at end of file diff --git a/soundrts/tests/res/multi/jl3.txt b/soundrts/tests/res/multi/jl3.txt new file mode 100644 index 00000000..5f3a2139 --- /dev/null +++ b/soundrts/tests/res/multi/jl3.txt @@ -0,0 +1,82 @@ +; multiplayer map 3 +; +; *************** +; 7* * * * +; * *** *** * * * +; 6* * * * +; *** * * ******* +; 5* * * +; * * * * * * * * +; 4* * x * * +; * * * * * * * * +; 3* * * +; ******* * * *** +; 2*x* * * +; * * * *** *** * +; 1* *x x* * +; *************** +; A B C D E F G + +title 3003 +objective 145 88 + + +; map size + +square_width 12 + +nb_columns 7 +nb_lines 7 + + +; paths (give only the starting square) + +west_east_paths a1 c1 d1 f1 +west_east_paths b2 c2 d2 f2 +west_east_paths b3 c3 d3 +west_east_paths c4 b4 d4 e4 +west_east_paths c5 d5 e5 +west_east_paths a6 c6 d6 e6 +west_east_paths a7 c7 d7 f7 + +west_east_bridges a3 f3 +west_east_bridges a5 f5 + +south_north_paths a1 a3 a4 a6 +south_north_paths b1 b3 b4 b5 +south_north_paths c3 c4 c5 +south_north_paths d2 d3 d4 d5 +south_north_paths e2 e3 e4 +south_north_paths f2 f3 f4 f6 +south_north_paths g1 g3 g4 g6 + +south_north_bridges c1 c6 +south_north_bridges e1 e6 + + +; resources +goldmines 150 a2 b7 g6 f1 +goldmines 150 c1 a5 e7 g3 +goldmines 150 e1 a3 c7 g5 +goldmines 750 d4 + +;woods 75 a2 b7 g6 f1 +woods 75 a1 a7 g7 g1 +woods 75 e1 c7 a3 g5 +woods 75 e2 c6 b3 f5 +woods 75 e2 c6 b3 f5 +woods 75 c1 a5 e7 g3 +woods 150 d4 d4 + +nb_meadows_by_square 2 +additional_meadows a2 b7 g6 f1 + + +; players + +nb_players_min 2 +nb_players_max 4 + +starting_squares a2 b7 g6 f1 +starting_units townhall farm peasant +starting_resources 10 10 \ No newline at end of file diff --git a/soundrts/tests/res/multi/jl4.zip b/soundrts/tests/res/multi/jl4.zip new file mode 100644 index 00000000..644b3a1a Binary files /dev/null and b/soundrts/tests/res/multi/jl4.zip differ diff --git a/multi/jl4/ai.txt b/soundrts/tests/res/multi/jl4/ai.txt similarity index 100% rename from multi/jl4/ai.txt rename to soundrts/tests/res/multi/jl4/ai.txt diff --git a/multi/jl4/map.txt b/soundrts/tests/res/multi/jl4/map.txt similarity index 100% rename from multi/jl4/map.txt rename to soundrts/tests/res/multi/jl4/map.txt diff --git a/multi/jl4/readme.txt b/soundrts/tests/res/multi/jl4/readme.txt similarity index 100% rename from multi/jl4/readme.txt rename to soundrts/tests/res/multi/jl4/readme.txt diff --git a/multi/jl4/rules.txt b/soundrts/tests/res/multi/jl4/rules.txt similarity index 100% rename from multi/jl4/rules.txt rename to soundrts/tests/res/multi/jl4/rules.txt diff --git a/multi/jl4/ui-cs/tts.txt b/soundrts/tests/res/multi/jl4/ui-cs/tts.txt similarity index 100% rename from multi/jl4/ui-cs/tts.txt rename to soundrts/tests/res/multi/jl4/ui-cs/tts.txt diff --git a/multi/jl4/ui-de/tts.txt b/soundrts/tests/res/multi/jl4/ui-de/tts.txt similarity index 100% rename from multi/jl4/ui-de/tts.txt rename to soundrts/tests/res/multi/jl4/ui-de/tts.txt diff --git a/multi/jl4/ui-fr/tts.txt b/soundrts/tests/res/multi/jl4/ui-fr/tts.txt similarity index 100% rename from multi/jl4/ui-fr/tts.txt rename to soundrts/tests/res/multi/jl4/ui-fr/tts.txt diff --git a/multi/jl4/ui-it/tts.txt b/soundrts/tests/res/multi/jl4/ui-it/tts.txt similarity index 100% rename from multi/jl4/ui-it/tts.txt rename to soundrts/tests/res/multi/jl4/ui-it/tts.txt diff --git a/multi/jl4/ui-pt-BR/tts.txt b/soundrts/tests/res/multi/jl4/ui-pt-BR/tts.txt similarity index 100% rename from multi/jl4/ui-pt-BR/tts.txt rename to soundrts/tests/res/multi/jl4/ui-pt-BR/tts.txt diff --git a/multi/jl4/ui-ru/tts.txt b/soundrts/tests/res/multi/jl4/ui-ru/tts.txt similarity index 100% rename from multi/jl4/ui-ru/tts.txt rename to soundrts/tests/res/multi/jl4/ui-ru/tts.txt diff --git a/multi/jl4/ui-sk/tts.txt b/soundrts/tests/res/multi/jl4/ui-sk/tts.txt similarity index 100% rename from multi/jl4/ui-sk/tts.txt rename to soundrts/tests/res/multi/jl4/ui-sk/tts.txt diff --git a/multi/jl4/ui/9001.ogg b/soundrts/tests/res/multi/jl4/ui/9001.ogg similarity index 100% rename from multi/jl4/ui/9001.ogg rename to soundrts/tests/res/multi/jl4/ui/9001.ogg diff --git a/multi/jl4/ui/bindings.txt b/soundrts/tests/res/multi/jl4/ui/bindings.txt similarity index 100% rename from multi/jl4/ui/bindings.txt rename to soundrts/tests/res/multi/jl4/ui/bindings.txt diff --git a/multi/jl4/ui/style.txt b/soundrts/tests/res/multi/jl4/ui/style.txt similarity index 100% rename from multi/jl4/ui/style.txt rename to soundrts/tests/res/multi/jl4/ui/style.txt diff --git a/multi/jl4/ui/tts.txt b/soundrts/tests/res/multi/jl4/ui/tts.txt similarity index 100% rename from multi/jl4/ui/tts.txt rename to soundrts/tests/res/multi/jl4/ui/tts.txt diff --git a/soundrts/tests/res/multi/jl5.txt b/soundrts/tests/res/multi/jl5.txt new file mode 100644 index 00000000..a47e0c82 --- /dev/null +++ b/soundrts/tests/res/multi/jl5.txt @@ -0,0 +1,34 @@ +title 3005 +objective 145 88 + +; map size +square_width 12 +nb_columns 10 +nb_lines 9 + +; paths (give only the starting square) +west_east_paths c1 d1 e1 f1 d2 e2 a3 c3 d3 e3 f3 a4 b4 d4 e4 f4 g4 i4 a5 b5 d5 e5 f5 g5 h5 i5 a6 b6 c6 d6 e6 f6 h6 i6 a7 e7 f7 h7 i7 d8 e8 f8 a9 d9 e9 h9 +south_north_paths a3 a5 b3 b4 c4 d1 d3 d4 d5 d8 e1 e3 e4 e5 e6 e8 f1 f3 f4 f5 f6 f8 g1 g2 g3 g4 g5 g6 g7 g8 h1 h4 h6 i5 i6 j1 j6 j7 + +; resources +goldmines 300 a3 c1 d9 j8 +woods 75 a3 c1 d9 j8 a3 c1 d9 j8 +goldmines 300 a6 g2 g9 j5 +woods 75 a6 g2 g9 j5 a6 g2 g9 j5 +goldmines 300 c6 e7 f3 g6 +goldmines 300 a9 b9 j1 j2 +woods 75 a9 j1 a9 j1 +nb_meadows_by_square 3 +terrain sea a1 b1 i1 a2 b2 c2 i2 h3 i3 j3 c7 d7 a8 b8 c8 h8 i8 c9 j9 +water a1 b1 i1 a2 b2 c2 i2 h3 i3 j3 c7 d7 a8 b8 c8 h8 i8 c9 j9 +remove_meadows a1 b1 i1 a2 b2 c2 i2 h3 i3 j3 c7 d7 a8 b8 c8 h8 i8 c9 j9 +remove_meadows a1 b1 i1 a2 b2 c2 i2 h3 i3 j3 c7 d7 a8 b8 c8 h8 i8 c9 j9 +remove_meadows a1 b1 i1 a2 b2 c2 i2 h3 i3 j3 c7 d7 a8 b8 c8 h8 i8 c9 j9 +high_grounds c1 d1 e1 f1 h1 j1 d2 e2 f2 h2 a3 b3 a4 b4 c4 i4 j4 b5 c5 h6 i6 j6 a7 b7 h7 i7 j7 d8 e8 f8 j8 a9 d9 e9 f9 h9 i9 + +; players +nb_players_min 2 +nb_players_max 4 +starting_squares a3 c1 d9 j8 +starting_units townhall farm 4 peasant +starting_resources 10 10 \ No newline at end of file diff --git a/soundrts/tests/res/multi/jl6.txt b/soundrts/tests/res/multi/jl6.txt new file mode 100644 index 00000000..e1283368 --- /dev/null +++ b/soundrts/tests/res/multi/jl6.txt @@ -0,0 +1,49 @@ +; multiplayer map 6 +; +; ******* +; 6* + + * +; *+***+* +; 5* + + * +; ******* +; 4*w*w*w* +; ******* +; 3*w*w*w* +; ******* +; 2* + + * +; *+***+* +; 1* + + * +; ******* +; A B C + +title 3006 +objective 145 88 + +; map size +square_width 12 +nb_columns 3 +nb_lines 6 + +; paths (give only the starting square) +west_east_paths a1 b1 a2 b2 a5 b5 a6 b6 +south_north_paths a1 c1 a5 c5 + +; resources +;goldmines 75 b1 b4 +goldmines 75 a1 c6 +goldmines 150 c2 a5 +woods 75 c1 a6 c2 c2 a5 a5 +nb_meadows_by_square 2 +additional_meadows b1 b6 + +water a3 b3 c3 a4 b4 c4 +terrain ocean a3 b3 c3 a4 b4 c4 +remove_meadows a3 b3 c3 a4 b4 c4 +remove_meadows a3 b3 c3 a4 b4 c4 +high_grounds c2 a5 + +; players +nb_players_min 2 +nb_players_max 2 +starting_squares b1 b6 +starting_units townhall farm peasant +starting_resources 10 10 \ No newline at end of file diff --git a/soundrts/tests/res/multi/jl7.txt b/soundrts/tests/res/multi/jl7.txt new file mode 100644 index 00000000..492b67e2 --- /dev/null +++ b/soundrts/tests/res/multi/jl7.txt @@ -0,0 +1,60 @@ +title 3007 +objective 145 88 + +; map size +square_width 12 +nb_columns 15 +nb_lines 15 + +; paths (give only the starting square) +west_east_paths c4 l4 c12 g13 h13 l12 h3 g3 +south_north_paths d3 l3 d12 l12 c7 c8 m7 m8 + + +; resources +nb_meadows_by_square 0 + +; central marsh +goldmines 3000 h8 +woods 1000 h8 h8 h8 +additional_meadows h8 +speed .5 1 h8 +cover .5 0 h8 +terrain marsh h8 +computer_only 0 0 h8 dragon 7 zombie 7 skeleton j6 3 archer j10 3 archer f6 3 archer f10 3 archer + +; starting squares +goldmines 400 c8 d4 d12 h3 h13 l4 l12 m8 +woods 100 c8 d4 d12 h3 h13 l4 l12 m8 +additional_meadows c8 d4 d12 h3 h13 l4 l12 m8 +additional_meadows c8 d4 d12 h3 h13 l4 l12 m8 +additional_meadows c8 d4 d12 h3 h13 l4 l12 m8 +additional_meadows c8 d4 d12 h3 h13 l4 l12 m8 +additional_meadows c8 d4 d12 h3 h13 l4 l12 m8 + +;ocean +terrain ocean a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15 c1 c2 c3 c5 c6 c10 c11 c13 c14 c15 d1 d2 d5 d6 d7 d8 d9 d10 d11 d14 d15 e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11 e12 e13 e14 e15 f1 f2 f3 f4 f5 f7 f9 f11 f12 f13 f14 f15 g1 g2 g4 g5 g6 g7 g8 g9 g10 g11 g12 g14 g15 h1 h2 h4 h5 h7 h9 h11 h12 h14 h15 i1 i2 i4 i5 i6 i7 i8 i9 i10 i11 i12 i14 i15 j1 j2 j3 j4 j5 j7 j9 j11 j12 j13 j14 j15 k1 k2 k3 k4 k5 k6 k7 k8 k9 k10 k11 k12 k13 k14 k15 l1 l2 l5 l6 l7 l8 l9 l10 l11 l14 l15 m1 m2 m3 m5 m6 m10 m11 m13 m14 m15 n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 n11 n12 n13 n14 n15 o1 o2 o3 o4 o5 o6 o7 o8 o9 o10 o11 o12 o13 o14 o15 +water a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15 c1 c2 c3 c5 c6 c10 c11 c13 c14 c15 d1 d2 d5 d6 d7 d8 d9 d10 d11 d14 d15 e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11 e12 e13 e14 e15 f1 f2 f3 f4 f5 f7 f9 f11 f12 f13 f14 f15 g1 g2 g4 g5 g6 g7 g8 g9 g10 g11 g12 g14 g15 h1 h2 h4 h5 h7 h9 h11 h12 h14 h15 i1 i2 i4 i5 i6 i7 i8 i9 i10 i11 i12 i14 i15 j1 j2 j3 j4 j5 j7 j9 j11 j12 j13 j14 j15 k1 k2 k3 k4 k5 k6 k7 k8 k9 k10 k11 k12 k13 k14 k15 l1 l2 l5 l6 l7 l8 l9 l10 l11 l14 l15 m1 m2 m3 m5 m6 m10 m11 m13 m14 m15 n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 n11 n12 n13 n14 n15 o1 o2 o3 o4 o5 o6 o7 o8 o9 o10 o11 o12 o13 o14 o15 + +; It might be interesting to slow down aerial units. (eventually uncomment next line to divide speed by 4) +speed 0 .25 a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15 c1 c2 c3 c5 c6 c10 c11 c13 c14 c15 d1 d2 d5 d6 d7 d8 d9 d10 d11 d14 d15 e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11 e12 e13 e14 e15 f1 f2 f3 f4 f5 f7 f9 f11 f12 f13 f14 f15 g1 g2 g4 g5 g6 g7 g8 g9 g10 g11 g12 g14 g15 h1 h2 h4 h5 h7 h9 h11 h12 h14 h15 i1 i2 i4 i5 i6 i7 i8 i9 i10 i11 i12 i14 i15 j1 j2 j3 j4 j5 j7 j9 j11 j12 j13 j14 j15 k1 k2 k3 k4 k5 k6 k7 k8 k9 k10 k11 k12 k13 k14 k15 l1 l2 l5 l6 l7 l8 l9 l10 l11 l14 l15 m1 m2 m3 m5 m6 m10 m11 m13 m14 m15 n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 n11 n12 n13 n14 n15 o1 o2 o3 o4 o5 o6 o7 o8 o9 o10 o11 o12 o13 o14 o15 + +terrain mountain f8 j8 h6 h10 +speed .1 .2 f8 j8 h6 h10 + +; dense woods +high_grounds c7 c9 c4 d3 m7 m9 g3 i3 g13 i13 l13 m12 l3 m4 c12 d13 j6 j10 f6 f10 +woods 100 c7 c9 c4 d3 m7 m9 g3 i3 g13 i13 l13 m12 l3 m4 c12 d13 j6 j10 f6 f10 +woods 100 c7 c9 c4 d3 m7 m9 g3 i3 g13 i13 l13 m12 l3 m4 c12 d13 j6 j10 f6 f10 +woods 100 c7 c9 c4 d3 m7 m9 g3 i3 g13 i13 l13 m12 l3 m4 c12 d13 j6 j10 f6 f10 +cover .5 0 c7 c9 c4 d3 m7 m9 g3 i3 g13 i13 l13 m12 l3 m4 c12 d13 j6 j10 f6 f10 +additional_meadows c7 c9 c4 d3 m7 m9 g3 i3 g13 i13 l13 m12 l3 m4 c12 d13 j6 j10 f6 f10 +additional_meadows c7 c9 c4 d3 m7 m9 g3 i3 g13 i13 l13 m12 l3 m4 c12 d13 j6 j10 f6 f10 +goldmines 500 j6 j10 f6 f10 + +; players +nb_players_min 2 +nb_players_max 8 +starting_squares c8 d4 d12 h3 h13 l4 l12 m8 +starting_units townhall farm 4 peasant +starting_resources 10 10 \ No newline at end of file diff --git a/soundrts/tests/res/multi/jl8.txt b/soundrts/tests/res/multi/jl8.txt new file mode 100644 index 00000000..295df15f --- /dev/null +++ b/soundrts/tests/res/multi/jl8.txt @@ -0,0 +1,44 @@ +title 3008 +objective 145 88 + +square_width 12 +nb_columns 3 +nb_lines 6 + +nb_players_min 2 +nb_players_max 2 +starting_squares b1 b6 +starting_units townhall 2 farm 4 peasant +starting_resources 10 10 + +goldmine 300 a5 c2 +goldmine 100 b6 +goldmine 100 b1 +wood 100 a3 a4 b3 b4 c3 c4 +wood 100 a3 a4 b3 b4 c3 c4 +wood 100 a3 a4 b3 b4 c3 c4 +wood 100 a3 a4 b3 b4 c3 c4 +wood 100 a3 a4 b3 b4 c3 c4 +wood 100 a3 a4 b3 b4 c3 c4 +wood 100 a3 a4 b3 b4 c3 c4 +wood 75 a5 a5 a6 c1 c2 c2 + +nb_meadows_by_square 0 +; 2 meadows +additional_meadows a1 a2 a5 a6 b2 b5 c1 c2 c5 c6 +additional_meadows a1 a2 a5 a6 b2 b5 c1 c2 c5 c6 +; 3 meadows +additional_meadows b1 b6 +additional_meadows b1 b6 +additional_meadows b1 b6 + +terrain _forest a5 a6 c1 c2 +terrain _meadows a1 a2 b1 b2 b5 b6 c5 c6 +high_grounds a5 c2 +water +ground +no_air +cover 0.1 0.0 a3 a4 b3 b4 c3 c4 + +west_east path a1 a2 a3 a4 a5 a6 b1 b2 b3 b4 b5 b6 +south_north path a1 a2 a3 a5 b2 b3 b4 c1 c3 c4 c5 diff --git a/soundrts/tests/res2.zip b/soundrts/tests/res2.zip new file mode 100644 index 00000000..aa731710 Binary files /dev/null and b/soundrts/tests/res2.zip differ diff --git a/soundrts/tests/res2/mods/mod1/rules.txt b/soundrts/tests/res2/mods/mod1/rules.txt new file mode 100644 index 00000000..e69de29b diff --git a/soundrts/tests/res2/readme.txt b/soundrts/tests/res2/readme.txt new file mode 100644 index 00000000..e69de29b diff --git a/soundrts/tests/single/campaign1.zip b/soundrts/tests/single/campaign1.zip new file mode 100644 index 00000000..4fa86d13 Binary files /dev/null and b/soundrts/tests/single/campaign1.zip differ diff --git a/soundrts/tests/single/campaign1/0.txt b/soundrts/tests/single/campaign1/0.txt index e69de29b..7db909a4 100644 --- a/soundrts/tests/single/campaign1/0.txt +++ b/soundrts/tests/single/campaign1/0.txt @@ -0,0 +1,3 @@ +cut_scene_chapter +title 4272 +sequence 7500 \ No newline at end of file diff --git a/soundrts/tests/single/campaign1/1.zip b/soundrts/tests/single/campaign1/1.zip new file mode 100644 index 00000000..eff0b86e Binary files /dev/null and b/soundrts/tests/single/campaign1/1.zip differ diff --git a/soundrts/tests/single/campaign1/1/ai.txt b/soundrts/tests/single/campaign1/1/ai.txt deleted file mode 100644 index 766613aa..00000000 --- a/soundrts/tests/single/campaign1/1/ai.txt +++ /dev/null @@ -1,3 +0,0 @@ -def easy -get 7 peasant -goto -1 diff --git a/soundrts/tests/single/campaign1/1/rules.txt b/soundrts/tests/single/campaign1/1/rules.txt deleted file mode 100644 index c1622e9d..00000000 --- a/soundrts/tests/single/campaign1/1/rules.txt +++ /dev/null @@ -1,2 +0,0 @@ -def peasant -cost 7 0 diff --git a/soundrts/tests/single/campaign1/1/ui/style.txt b/soundrts/tests/single/campaign1/1/ui/style.txt deleted file mode 100644 index 41c753be..00000000 --- a/soundrts/tests/single/campaign1/1/ui/style.txt +++ /dev/null @@ -1,2 +0,0 @@ -def peasant -test 7 diff --git a/soundrts/tests/single/campaign1/1/ui/tts.txt b/soundrts/tests/single/campaign1/1/ui/tts.txt deleted file mode 100644 index 99b192aa..00000000 --- a/soundrts/tests/single/campaign1/1/ui/tts.txt +++ /dev/null @@ -1 +0,0 @@ -0 campaign1 map1 diff --git a/soundrts/tests/single/campaign1/2.txt b/soundrts/tests/single/campaign1/2.txt new file mode 100644 index 00000000..a770847c --- /dev/null +++ b/soundrts/tests/single/campaign1/2.txt @@ -0,0 +1 @@ +title 11 22 diff --git a/soundrts/tests/single/map1.zip b/soundrts/tests/single/map1.zip new file mode 100644 index 00000000..ecb7ef5d Binary files /dev/null and b/soundrts/tests/single/map1.zip differ diff --git a/soundrts/tests/single/map1/map.txt b/soundrts/tests/single/map1/map.txt new file mode 100644 index 00000000..a770847c --- /dev/null +++ b/soundrts/tests/single/map1/map.txt @@ -0,0 +1 @@ +title 11 22 diff --git a/soundrts/tests/single/map1/rules.txt b/soundrts/tests/single/map1/rules.txt index 67c8772e..d683cecf 100644 --- a/soundrts/tests/single/map1/rules.txt +++ b/soundrts/tests/single/map1/rules.txt @@ -1,2 +1,10 @@ def peasant cost 6 0 + +def faction1 +class faction + +def faction2 +class faction + +def parameters diff --git a/soundrts/tests/test_autorepair.py b/soundrts/tests/test_autorepair.py index 6750bc87..9c29425b 100644 --- a/soundrts/tests/test_autorepair.py +++ b/soundrts/tests/test_autorepair.py @@ -14,7 +14,7 @@ def push(self, *args): @pytest.fixture() def world(): - w = World([]) + w = World() w.load_and_build_map(Map("soundrts/tests/jl1_cyclic.txt")) return w diff --git a/soundrts/tests/test_cache.py b/soundrts/tests/test_cache.py index 9010efd6..0afde98a 100644 --- a/soundrts/tests/test_cache.py +++ b/soundrts/tests/test_cache.py @@ -2,14 +2,30 @@ import pytest from soundrts import clientmedia -from soundrts.lib.resource import ResourceLoader +from soundrts.campaign import Campaign +from soundrts.definitions import get_ai, rules, style, load_ai +from soundrts.lib import resource +from soundrts.lib.package import Package +from soundrts.lib.resource import ResourceStack from soundrts.lib.sound_cache import sounds +from soundrts.mapfile import Map +from soundrts.pack import pack_file_or_folder +from soundrts.paths import BASE_PACKAGE_PATH + +maps_paths = ["soundrts/tests/single/map1", "soundrts/tests/single/map1.zip"] +maps = [Map(path) for path in maps_paths] +campaigns = [ + Campaign(Package.from_path("soundrts/tests/single/campaign1"), "campaign1"), + Campaign(Package.from_path("soundrts/tests/single/campaign1.zip"), "campaign1"), +] + +resource.preferred_language = "en" @pytest.fixture(scope="module") def default(request): clientmedia.minimal_init() - res = ResourceLoader("", "", []) + res = ResourceStack([BASE_PACKAGE_PATH]) request.addfinalizer(pygame.display.quit) return res @@ -17,7 +33,7 @@ def default(request): @pytest.fixture(scope="module") def test(request): clientmedia.minimal_init() - res = ResourceLoader("", "", [], base_path="soundrts/tests/res") + res = ResourceStack(["soundrts/tests/res"]) request.addfinalizer(pygame.display.quit) return res @@ -50,52 +66,49 @@ def test_get_sound_with_locale(test): assert sounds.get_sound("fdsgedtgf") is None assert sounds.get_sound("9998") is not None assert sounds.get_sound("9999") is not None - # a sound can be stored in a sub directory + # a sound can be stored in a subdirectory assert sounds.get_sound("1028") is not None -def test_get_text(test): +def test_text(test): res = test res.language = "en" sounds.load_default(res) - assert sounds.get_text("0") == "yes" - assert sounds.get_text("1") == "no" - assert sounds.get_text("2") is None + assert sounds.text("0") == "yes" + assert sounds.text("1") == "no" + assert sounds.text("2") is None -def test_get_text_with_locale(test): +def test_text_with_locale(test): res = test res.language = "fr" sounds.load_default(res) - assert sounds.get_text("0") == "oui" - assert sounds.get_text("1") == "no" - assert sounds.get_text("2") is None + assert sounds.text("0") == "oui" + assert sounds.text("1") == "no" + assert sounds.text("2") is None def test_get_style(test): res = test res.language = "en" - from soundrts.definitions import style - style.load(res.get_text_file("ui/style", append=True, localize=True)) + style.load(res.text("ui/style", append=True, localize=True)) assert style.get("peasant", "test") == ["0"] def test_get_style_with_locale(test): res = test res.language = "fr" - from soundrts.definitions import style - style.load(res.get_text_file("ui/style", append=True, localize=True)) + style.load(res.text("ui/style", append=True, localize=True)) assert style.get("peasant", "test") == ["1"] def test_get_rules_and_ai(test): res = test - from soundrts.definitions import get_ai, load_ai, rules - rules.load(res.get_text_file("rules", append=True)) - load_ai(res.get_text_file("ai", append=True)) + rules.load(res.text("rules", append=True)) + load_ai(res.text("ai", append=True)) assert rules.get("peasant", "cost") == [0, 0] assert rules.get("test", "cost") == [0, 0] assert get_ai("easy") == ["get 1 peasant", "goto -1"] @@ -103,103 +116,103 @@ def test_get_rules_and_ai(test): def test_folder_map(test): res = test - from soundrts.definitions import get_ai, rules, style - from soundrts.mapfile import Map + res.language = "en" - map1 = Map("soundrts/tests/single/map1") - map1.load_resources() - map1.load_rules_and_ai(res) - map1.load_style(res) - assert rules.get("test", "cost") == [0, 0] - assert rules.get("peasant", "cost") == [6000, 0] - assert get_ai("easy") == ["get 6 peasant", "goto -1"] - assert style.get("peasant", "test") == ["6"] - assert sounds.get_text("0") == "map1" - map1.unload_resources() + for m in maps: + assert m.name == "map1" + assert m.definition == "title 11 22\n" + assert sounds.text("0") != "map1" + res.set_map(m) + try: + assert rules.get("test", "cost") == [0, 0] + assert rules.get("peasant", "cost") == [6000, 0] + assert get_ai("easy") == ["get 6 peasant", "goto -1"] + assert style.get("peasant", "test") == ["6"] + assert sounds.text("0") == "map1" + assert set(rules.factions) == {"faction1", "faction2"} + finally: + res.set_map() + assert rules.get("test", "cost") == [0, 0] + assert rules.get("peasant", "cost") == [0, 0] + assert get_ai("easy") == ["get 1 peasant", "goto -1"] + assert style.get("peasant", "test") == ["0"] + assert sounds.text("0") == "yes" def test_campaign(test): - from soundrts.campaign import Campaign - - c = Campaign("soundrts/tests/single/campaign1") - c.load_resources() - assert sounds.get_text("0") == "campaign1" - c.unload_resources() + for c in campaigns: + assert sounds.text("0") == "yes" + test.set_campaign(c) + assert sounds.text("0") == "campaign1" + test.set_campaign() + assert sounds.text("0") == "yes" -def test_campaign_map(test): +def test_campaign_cutscene(test): res = test - from soundrts.campaign import Campaign - from soundrts.definitions import get_ai, rules, style - - c = Campaign("soundrts/tests/single/campaign1") - c.load_resources() - map0 = c.chapters[0] - map0.load_resources() - map0.load_rules_and_ai(res) - map0.load_style(res) - assert rules.get("test", "cost") == [0, 0] - assert rules.get("peasant", "cost") == [5000, 0] - assert get_ai("easy") == ["get 5 peasant", "goto -1"] - assert style.get("peasant", "test") == ["5"] - assert sounds.get_text("0") == "campaign1" - map0.unload_resources() - c.unload_resources() + for c in campaigns: + res.set_campaign(c) + m = c.chapters[0] + assert sounds.text("0") == "campaign1" + res.set_campaign() -def test_campaign_map_with_special_rules(test): +def test_campaign_map(test): res = test - from soundrts.campaign import Campaign - from soundrts.definitions import get_ai, rules, style - - c = Campaign("soundrts/tests/single/campaign1") - c.load_resources() - map1 = c.chapters[1] - map1.load_resources() - map1.load_rules_and_ai(res) - map1.load_style(res) - assert rules.get("test", "cost") == [0, 0] - assert rules.get("peasant", "cost") == [7000, 0] - assert get_ai("easy") == ["get 7 peasant", "goto -1"] - assert style.get("peasant", "test") == ["7"] - assert sounds.get_text("0") == "campaign1 map1" - map1.unload_resources() - c.unload_resources() + for campaign in campaigns: + res.set_campaign(campaign) + try: + chapter = campaign.chapters[2] + assert chapter.map.name == "campaign1/2" + assert chapter.title == [11, 22] + assert chapter.map.resources is None + res.set_map(chapter.map) + try: + assert rules.get("test", "cost") == [0, 0] + assert rules.get("peasant", "cost") == [5000, 0] + assert get_ai("easy") == ["get 5 peasant", "goto -1"] + assert style.get("peasant", "test") == ["5"] + assert sounds.text("0") == "campaign1" + finally: + res.set_map() + finally: + res.set_campaign() -def test_unpacked_folder_map_redefines_text(test): - from soundrts.mapfile import Map - - default_text = sounds.get_text("0") - m = Map(unpack=Map("soundrts/tests/single/map1").pack()) - m.load_resources() - assert sounds.get_text("0") == "map1" - assert sounds.get_text("0") != default_text - m.unload_resources() - assert sounds.get_text("0") == default_text - - -def test_unpacked_folder_map_redefines_sound(test): - from soundrts.mapfile import Map - - default_sound = sounds.get_sound("9998") - m = Map(unpack=Map("soundrts/tests/single/map1").pack()) - m.load_resources() - assert isinstance(sounds.get_sound("9998"), pygame.mixer.Sound) - assert sounds.get_sound("9998") is not default_sound - m.unload_resources() - assert sounds.get_sound("9998") is default_sound - - -def test_unpacked_folder_map_redefines_rules_ai_and_style(test): +def test_campaign_map_with_special_rules(test): res = test - from soundrts.definitions import get_ai, rules, style - from soundrts.mapfile import Map - - m = Map(unpack=Map("soundrts/tests/single/map1").pack()) - m.load_rules_and_ai(res) - m.load_style(res) - assert rules.get("test", "cost") == [0, 0] - assert rules.get("peasant", "cost") == [6000, 0] - assert get_ai("easy") == ["get 6 peasant", "goto -1"] - assert style.get("peasant", "test") == ["6"] + for campaign in campaigns: + res.set_campaign(campaign) + chapter = campaign.chapters[1] + assert chapter.map.name == "campaign1/1" + res.set_map(chapter.map) + assert rules.get("test", "cost") == [0, 0] + assert rules.get("peasant", "cost") == [7000, 0] + assert get_ai("easy") == ["get 7 peasant", "goto -1"] + assert style.get("peasant", "test") == ["7"] + assert sounds.text("0") == "campaign1 map1" + res.set_map() + res.set_campaign() + + +def test_unpacked_folder_map_redefines_resources(test): + for path in maps_paths: + default_text = sounds.text("0") + default_sound = sounds.get_sound("9998") + + m = test.unpack_map(pack_file_or_folder(path)) + test.set_map(m) + + assert sounds.text("0") == "map1" + assert sounds.text("0") != default_text + assert isinstance(sounds.get_sound("9998"), pygame.mixer.Sound) + assert sounds.get_sound("9998").mod_name != default_sound.mod_name + assert rules.get("test", "cost") == [0, 0] + assert rules.get("peasant", "cost") == [6000, 0] + assert get_ai("easy") == ["get 6 peasant", "goto -1"] + assert style.get("peasant", "test") == ["6"] + + test.set_map() + + assert sounds.text("0") == default_text + assert sounds.get_sound("9998").mod_name == default_sound.mod_name diff --git a/soundrts/tests/test_cyclic_map.py b/soundrts/tests/test_cyclic_map.py index 4e5d717c..46b9c4dd 100644 --- a/soundrts/tests/test_cyclic_map.py +++ b/soundrts/tests/test_cyclic_map.py @@ -10,7 +10,7 @@ @pytest.fixture() def world(): - w = World([]) + w = World() w.load_and_build_map(Map("soundrts/tests/jl1_cyclic.txt")) return w diff --git a/soundrts/tests/test_map.py b/soundrts/tests/test_map.py index df3fc86b..d585b440 100644 --- a/soundrts/tests/test_map.py +++ b/soundrts/tests/test_map.py @@ -1,32 +1,46 @@ -import unittest +from pathlib import Path +from soundrts.game import MultiplayerGame +from soundrts.lib.resource import res +from soundrts.lib.package import Package from soundrts.mapfile import Map +from soundrts.pack import pack_file_or_folder, pack_buffer from soundrts.world import World from soundrts.worldclient import Coordinator -class CoordinatorTestCase(unittest.TestCase): - def testSyncError(self): - c = Coordinator(None, None, None) - c.world = World([]) - c.world.load_and_build_map(Map("multi/m2.txt")) - c.world.update() - c.get_sync_debug_msg_1() - c.get_sync_debug_msg_2() - c.world.update() - c.get_sync_debug_msg_1() - c.get_sync_debug_msg_2() - +def test_sync_error(): + m = Map("soundrts/tests/jl1.txt") + main_server = None + game = MultiplayerGame(m, [("test", 1, "faction")], "test", main_server, 0, 1) + c: Coordinator = game.players[0] + c.world = World() + c.world.load_and_build_map(m) + c.world.update() + c.get_sync_debug_msg_1() + c.get_sync_debug_msg_2() + c.world.update() + c.get_sync_debug_msg_1() + c.get_sync_debug_msg_2() # print c.get_sync_debug_msg_1() # print c.get_sync_debug_msg_2() def test_nb_players_after_unpack(): - for n in ["jl1.txt", "jl4"]: - m = Map(unpack=Map(f"multi/{n}").pack()) + for n in ["jl1.txt", "jl4.zip"]: + m = res.unpack_map(pack_file_or_folder(f"soundrts/tests/{n}")) assert m.nb_players_min == 2 + assert m.name == Path(n).stem + +def test_pkg_map_inside_pkg(): + package = Package.from_path("soundrts/tests/res.zip") + b = package.open_binary("multi/jl4.zip").read() + multi = package.subpackage("multi") + assert multi.open_binary("jl4.zip").read() == b + loaded = Map.load(package.open_binary("multi/jl4.zip"), "jl4.zip") -if __name__ == "__main__": - unittest.main() + packed_buffer = pack_buffer(b, "jl4.zip") + unpacked = res.unpack_map(packed_buffer) + assert unpacked.definition == loaded.definition diff --git a/soundrts/tests/test_order.py b/soundrts/tests/test_order.py index 34cdc99d..a248e3b1 100644 --- a/soundrts/tests/test_order.py +++ b/soundrts/tests/test_order.py @@ -2,7 +2,7 @@ from soundrts.world import World from soundrts.worldclient import DummyClient -tiny_map_str = """ +tiny_map = b""" square_width 12 nb_columns 2 nb_lines 1 @@ -18,7 +18,7 @@ def test_enter_building_from_another_square(): w = World() - w.load_and_build_map(Map.from_string(tiny_map_str)) + w.load_and_build_map(Map.loads(tiny_map, "tiny.txt")) w.populate_map([DummyClient()]) p = w.players[0] tower, unit = p.units diff --git a/soundrts/tests/test_perception.py b/soundrts/tests/test_perception.py index d1b05098..47bfbd24 100644 --- a/soundrts/tests/test_perception.py +++ b/soundrts/tests/test_perception.py @@ -14,7 +14,7 @@ def push(self, *args): @pytest.fixture() def world(): - w = World([]) + w = World() w.load_and_build_map(Map("soundrts/tests/height.txt")) return w diff --git a/soundrts/tests/test_resource.py b/soundrts/tests/test_resource.py deleted file mode 100644 index 63a83ab3..00000000 --- a/soundrts/tests/test_resource.py +++ /dev/null @@ -1,37 +0,0 @@ -from soundrts.lib.resource import best_language_match, localize_path - - -def test_localize_path(): - assert localize_path("/ui", "fr").replace("\\", "/") == "/ui-fr" - assert localize_path("/ui/", "fr").replace("\\", "/") == "/ui-fr/" - assert localize_path("/uii", "fr").replace("\\", "/") == "/uii" - assert localize_path("/oui", "fr").replace("\\", "/") == "/oui" - assert localize_path("/ui/i", "fr").replace("\\", "/") == "/ui-fr/i" - # Is this test useful? I'm not sure. - # assert localize_path("/ui/io/i", "fr").replace("\\", "/") == "/ui/io/i" - assert localize_path("/oui/i", "fr").replace("\\", "/") == "/oui/i" - - -def test_best_language_match(): - AVAILABLE_LANGUAGES = [ - "en", - "cs", - "de", - "es", - "fr", - "it", - "pl", - "pt-BR", - "ru", - "sk", - "zh", - ] - assert best_language_match("en", AVAILABLE_LANGUAGES) == "en" - assert best_language_match("fr_ca", AVAILABLE_LANGUAGES) == "fr" - assert best_language_match("fr", AVAILABLE_LANGUAGES) == "fr" - assert best_language_match("pt_BR", AVAILABLE_LANGUAGES) == "pt-BR" - assert best_language_match("pt_br", AVAILABLE_LANGUAGES) == "pt-BR" - assert best_language_match("pt", AVAILABLE_LANGUAGES) == "pt-BR" - assert best_language_match("de", AVAILABLE_LANGUAGES) == "de" - assert best_language_match("pl", AVAILABLE_LANGUAGES) == "pl" - assert best_language_match("es", AVAILABLE_LANGUAGES) == "es" diff --git a/soundrts/tests/test_world.py b/soundrts/tests/test_world.py index 28376886..09ddf5c0 100644 --- a/soundrts/tests/test_world.py +++ b/soundrts/tests/test_world.py @@ -6,10 +6,10 @@ class WorldTestCase(unittest.TestCase): def setUp(self): - self.w = World([]) - self.w.load_and_build_map(Map("multi/m2.txt")) - self.w2 = World([]) - self.w2.load_and_build_map(Map("multi/jl2.txt")) + self.w = World() + self.w.load_and_build_map(Map("soundrts/tests/jl1.txt")) + self.w2 = World() + self.w2.load_and_build_map(Map("soundrts/tests/jl2.txt")) def tearDown(self): pass @@ -20,17 +20,15 @@ def testShortestPath(self): g["a1"].shortest_path_distance_to(g["a2"]), g["a1"].shortest_path_distance_to(g["b1"]), ) - self.assertIn( - g["a1"].shortest_path_to(g["e5"]).other_side.place.name, ("a2", "b1") - ) - self.assertEqual(g["b1"].shortest_path_to(g["e5"]).other_side.place.name, "b2") - self.assertEqual(g["b1"].shortest_path_to(g["d2"]).other_side.place.name, "c1") + self.assertEqual(g["a1"].shortest_path_to(g["a4"]).other_side.place.name, "a2") + self.assertEqual(g["b1"].shortest_path_to(g["c2"]).other_side.place.name, "c1") + self.assertEqual(g["b1"].shortest_path_to(g["a3"]).other_side.place.name, "a1") g2 = self.w2.grid self.assertEqual(g2["a1"].shortest_path_to(g2["c1"]), None) self.assertEqual(g2["c1"].shortest_path_to(g2["a1"]), None) def testCheckString(self): - World([]).get_digest() + World().get_digest() self.assertEqual(self.w.get_digest(), self.w.get_digest()) self.assertNotEqual(self.w.get_digest(), self.w2.get_digest()) self.w.get_objects_string() diff --git a/soundrts/tests/test_worldplayer.py b/soundrts/tests/test_worldplayer.py index 7cf16364..c3e1bb43 100644 --- a/soundrts/tests/test_worldplayer.py +++ b/soundrts/tests/test_worldplayer.py @@ -38,7 +38,7 @@ def push(self, *args): print(args) -tiny_map_str = """ +tiny_map = b""" square_width 12 nb_columns 1 nb_lines 1 @@ -56,7 +56,7 @@ class _PlayerBaseTestCase(unittest.TestCase): def set_up( self, alliance=(1, 2), cloak=False, map_name="jl1_extended", ai=("easy", "easy") ): - self.w = World([]) + self.w = World() self.w.load_and_build_map(Map("soundrts/tests/%s.txt" % map_name)) if cloak: rules.unit_class("new_flyingmachine").is_a_cloaker = True @@ -70,11 +70,9 @@ def set_up( self.w._update_buckets() def tiny_set_up(self): - self.w = World([]) - tiny_map = Map() - tiny_map.map_string = tiny_map_str - tiny_map.path = "" - self.w.load_and_build_map(tiny_map) + self.w = World() + map_ = Map.loads(tiny_map, "tiny.txt") + self.w.load_and_build_map(map_) cp = DummyClient() self.w.populate_map([cp]) self.cp = self.w.players[0] diff --git a/soundrts/tests/test_worldplayercomputer.py b/soundrts/tests/test_worldplayercomputer.py index 559103bc..3704ac1b 100644 --- a/soundrts/tests/test_worldplayercomputer.py +++ b/soundrts/tests/test_worldplayercomputer.py @@ -16,7 +16,7 @@ def __init__(self, types): def test_is_ok_for_warehouse(): - w = World([]) + w = World() c = Computer(w, DummyClient()) a1 = Square(w, 0, 0, 12) assert a1.name == "a1" diff --git a/soundrts/tests/unittests/test_clientmenu.py b/soundrts/tests/unittests/test_clientmenu.py new file mode 100644 index 00000000..2d6f2ff4 --- /dev/null +++ b/soundrts/tests/unittests/test_clientmenu.py @@ -0,0 +1,27 @@ +import pygame +import pytest + +from soundrts import clientmedia +from soundrts.clientmenu import _first_letter +from soundrts.lib.resource import ResourceStack +from soundrts.lib.sound_cache import sounds +from soundrts.paths import BASE_PACKAGE_PATH + + +@pytest.fixture(scope="module") +def default(request): + clientmedia.minimal_init() + res = ResourceStack([BASE_PACKAGE_PATH]) + request.addfinalizer(pygame.display.quit) + return res + + +def test_first_letter(default): + sounds.load_default(default) + assert _first_letter([[4031], None]) == sounds.text("4031")[0].lower() + assert _first_letter([['4267'], None]) == sounds.text("4267")[0].lower() + assert _first_letter([['orc'], None]) == 'o' + assert _first_letter([['Orc'], None]) == 'o' + assert _first_letter([['4267', 'editor'], None]) == sounds.text("4267")[0].lower() + assert _first_letter([['orc', 'editor'], None]) == 'o' + assert _first_letter([[1000, 'orc', 'editor'], None]) == 'o' diff --git a/soundrts/tests/unittests/test_package.py b/soundrts/tests/unittests/test_package.py new file mode 100644 index 00000000..bd5450a2 --- /dev/null +++ b/soundrts/tests/unittests/test_package.py @@ -0,0 +1,60 @@ +from soundrts.lib.package import Package, PackageStack + + +def test_zip_package(): + p = Package.from_path("soundrts/tests/res2.zip") + + assert p.subpackage("mods/mod1") is not None + assert p.subpackage("mods/mod") is None + assert p.subpackage("mods/mod11") is None + + assert next(p.relative_paths_of_files_in_subtree("mods")).startswith("mods") + + assert set(p.dirnames()) == {"mods"} + + assert p.isdir("mods") + assert not p.isfile("mods") + assert p.isfile("mods/mod1/rules.txt") + assert not p.isdir("mods/mod1/rules.txt") + assert p.isfile("readme.txt") + assert not p.isdir("readme.txt") + + +def test_folder_package(): + p = Package.from_path("soundrts/tests/res2") + + assert p.subpackage("mods/mod1") is not None + assert p.subpackage("mods/mod") is None + assert p.subpackage("mods/mod11") is None + + assert next(p.relative_paths_of_files_in_subtree("mods")).startswith("mods") + + assert set(p.dirnames()) == {"mods"} + + assert p.isdir("mods") + assert not p.isfile("mods") + assert p.isfile("mods/mod1/rules.txt") + assert not p.isdir("mods/mod1/rules.txt") + assert p.isfile("readme.txt") + assert not p.isdir("readme.txt") + + +def test_res_folder_package(): + p = Package.from_path("soundrts/tests/res") + + assert p.subpackage("ui") is not None + assert p.subpackage("u") is None + assert p.subpackage("ui1") is None + + assert next(p.relative_paths_of_files_in_subtree("ui")).startswith("ui") + assert set(p.dirnames()) == {"ui", "ui-fr", "multi"} + + +def test_subpackage_dirnames(): + for ext in [".zip", ""]: + p = Package.from_path("soundrts/tests/res2" + ext) + mods = p.subpackage("mods") + assert set(mods.dirnames()) == {"mod1", "sound1", "mod2"} + assert mods.subpackage("sound1").is_a_soundpack() + assert not mods.subpackage("mod1").is_a_soundpack() + assert len(PackageStack(["soundrts/tests/res2" + ext]).mods()) == len({"mod1", "sound1", "mod2"}) diff --git a/soundrts/tests/unittests/test_resource.py b/soundrts/tests/unittests/test_resource.py new file mode 100644 index 00000000..06a8566c --- /dev/null +++ b/soundrts/tests/unittests/test_resource.py @@ -0,0 +1,74 @@ +from soundrts.lib.resource import ResourceStack, localized_paths, res, official_multiplayer_maps +from soundrts.lib.resource import best_language_match, localized_path + + +def test_localize_path(): + assert localized_path("/ui", "fr").replace("\\", "/") == "/ui-fr" + assert localized_path("/ui/", "fr").replace("\\", "/") == "/ui-fr/" + assert localized_path("/uii", "fr").replace("\\", "/") == "/uii" + assert localized_path("/oui", "fr").replace("\\", "/") == "/oui" + assert localized_path("/ui/i", "fr").replace("\\", "/") == "/ui-fr/i" + # Is this test useful? I'm not sure. + # assert localize_path("/ui/io/i", "fr").replace("\\", "/") == "/ui/io/i" + assert localized_path("/oui/i", "fr").replace("\\", "/") == "/oui/i" + + +def test_best_language_match(): + available = ["en", "cs", "de", "es", "fr", "it", "pl", "pt-BR", "ru", "sk", "zh"] + assert best_language_match("en", available) == "en" + assert best_language_match("fr_ca", available) == "fr" + assert best_language_match("fr", available) == "fr" + assert best_language_match("pt_BR", available) == "pt-BR" + assert best_language_match("pt_br", available) == "pt-BR" + assert best_language_match("pt", available) == "pt-BR" + assert best_language_match("de", available) == "de" + assert best_language_match("pl", available) == "pl" + assert best_language_match("es", available) == "es" + + +def _mods(_packages): + return [(p.name, p.__class__.__name__) for p in _packages] + + +def test_update(): + assert localized_path("ui", "en") == "ui-en" + assert localized_paths("ui", "en") == ["ui", "ui-en"] + + r = ResourceStack(["soundrts/tests/res", "soundrts/tests/res2"]) + assert _mods(r._layers) == [("default", "FolderPackage")] + assert r.mods == "" + assert r.soundpacks == "" + assert r.text("rules") == 'def test\ncost 0 0\n\ndef peasant\ncost 0 0\n' + + r = ResourceStack(["soundrts/tests/res.zip", "soundrts/tests/res2"]) + assert _mods(r._layers) == [("default", "ZipPackage")] + assert r.mods == "" + assert r.soundpacks == "" + assert r.text("rules") == 'def test\ncost 0 0\n\ndef peasant\ncost 0 0\n' + + r = ResourceStack(["soundrts/tests/res.zip"]) + r.set_mods("mod1,mod2") + r.set_soundpacks("sound1") + assert _mods(r._layers) == [("default", "ZipPackage")] + assert r.mods == "" + assert r.soundpacks == "" + + r = ResourceStack(["soundrts/tests/res.zip", "soundrts/tests/res2"]) + r.set_mods("mod1,mod2") + r.set_soundpacks("sound1") + assert _mods(r._layers) == [("default", "ZipPackage"), + ("mod1", "FolderPackage"), ("mod2", "FolderPackage"), + ("sound1", "FolderPackage")] + assert r.mods == "mod1,mod2" + assert r.soundpacks == "sound1" + + r = ResourceStack(["soundrts/tests/res.zip", "soundrts/tests/res2.zip"]) + r.set_mods("mod,mod2") + assert _mods(r._layers) == [("default", "ZipPackage"), ("mod2", "ZipPackage")] + assert r.mods == "mod2" + assert r.soundpacks == "" + + +def test_maps_list(): + assert res.multiplayer_maps() + assert official_multiplayer_maps() diff --git a/soundrts/tests/unittests/test_worldunit.py b/soundrts/tests/unittests/test_worldunit.py index bf8c0050..d05a14c5 100644 --- a/soundrts/tests/unittests/test_worldunit.py +++ b/soundrts/tests/unittests/test_worldunit.py @@ -1,7 +1,9 @@ from unittest.mock import Mock, call +from soundrts.definitions import rules +from soundrts.lib.nofloat import PRECISION from soundrts.worldroom import Square, Inside -from soundrts.worldunit import Unit +from soundrts.worldunit import Unit, Soldier class _Unit(Unit): @@ -78,3 +80,73 @@ def test_counterattack_if_defensive_mode(): unit.counterattack(place) unit.take_order.assert_not_called() + + +_damage_vs_rules = """ +def pikeman +class soldier +damage 6 +damage_vs cavalry 12 + +def cavalry +class soldier + +def light_cavalry +is_a cavalry + +def light_cavalry_subtype +is_a light_cavalry + +def horse_archer +is_a cavalry archer +""" + + +def test_damage_vs(): + rules.load(_damage_vs_rules, base_classes={"soldier": Soldier}) + + # basic case + a_pikeman = rules.classes["pikeman"].create_from_nowhere() + a_cavalry = rules.classes["cavalry"].create_from_nowhere() + assert a_pikeman._base_damage_versus(a_pikeman) == 6 * PRECISION + assert a_pikeman._base_damage_versus(a_cavalry) == 12 * PRECISION + + # "inheritance" + a_light_cavalry = rules.classes["light_cavalry"].create_from_nowhere() + assert a_pikeman._base_damage_versus(a_light_cavalry) == 12 * PRECISION + + # second level of "inheritance" + a_light_cavalry_subtype = rules.classes["light_cavalry_subtype"].create_from_nowhere() + assert a_pikeman._base_damage_versus(a_light_cavalry_subtype) == 12 * PRECISION + + # multiple "inheritance" + a_horse_archer = rules.classes["horse_archer"].create_from_nowhere() + assert a_pikeman._base_damage_versus(a_horse_archer) == 12 * PRECISION + + +_armor_vs_rules = """ +def archer +class soldier + +def horse_archer +is_a cavalry archer + +def heavy +class soldier +armor 1 +armor_vs archer 2 +""" + + +def test_armor_vs(): + rules.load(_armor_vs_rules, base_classes={"soldier": Soldier}) + + # basic case + an_archer = rules.classes["archer"].create_from_nowhere() + a_heavy = rules.classes["heavy"].create_from_nowhere() + assert a_heavy.armor_versus(a_heavy) == 1 * PRECISION + assert a_heavy.armor_versus(an_archer) == 2 * PRECISION + + # multiple "inheritance" + a_horse_archer = rules.classes["horse_archer"].create_from_nowhere() + assert a_heavy.armor_versus(a_horse_archer) == 2 * PRECISION diff --git a/soundrts/update.py b/soundrts/update.py new file mode 100644 index 00000000..119d0aba --- /dev/null +++ b/soundrts/update.py @@ -0,0 +1,103 @@ +from hashlib import sha256 +from pathlib import Path + +import requests + +from .lib.defs import preprocess +from .lib.log import warning +from .lib.voice import voice +from .lib.zipdir import zipdir, unzipdir +from .paths import DOWNLOADED_PACKAGES_PATH, CONFIG_DIR_PATH + + +# def update_packages_from_servers(): +# try: +# r = requests.get("http://jlpo.free.fr/soundrts/package_servers.txt") +# r.raise_for_status() +# except requests.RequestException: +# warning("couldn't download package servers list") +# else: +# for packages_url in r.iter_lines(decode_unicode=True): +# _update_packages_from_server(packages_url) + + +def update_packages_from_servers(): + with open("cfg/package_servers.txt") as f: + s = preprocess(f.read()) + for packages_url in s.split("\n"): + packages_url = packages_url.strip() + if packages_url: + _update_packages_from_server(packages_url) + + +def _update_packages_from_server(server_url): + try: + r = requests.get(server_url + "index.txt") + r.raise_for_status() + except requests.RequestException: + warning("couldn't download packages index from '%s'", server_url) + else: + for line in r.iter_lines(decode_unicode=True): + try: + name, digest = line.split() + except ValueError: + warning("wrong line in packages index: %s", line) + else: + _update_package(server_url, name, digest) + + +def _update_package(server_url, name, digest): + package_path = Path(DOWNLOADED_PACKAGES_PATH).joinpath(name) + if not package_path.exists() or sha256(package_path.read_bytes()).hexdigest() != digest: + voice.alert(["updating", name]) + try: + r = requests.get(server_url + name) + r.raise_for_status() + except requests.RequestException: + warning("couldn't load package: %s", name) + voice.alert(["error"]) + else: + content_digest = sha256(r.content).hexdigest() + if content_digest == digest: + with open(package_path, "wb") as f: + f.write(r.content) + voice.alert(["ok"]) + else: + warning("wrong package digest for %s: %s", name, content_digest) + voice.alert(["error"]) + + +# The following functions are not called directly yet. +# They can be used to build a package server. + +def build_packages_index(): + publish = Path(CONFIG_DIR_PATH, "editor", "export") + files = [] + for path in publish.iterdir(): + if path.suffix == ".zip": + name = path.name + content = path.read_bytes() + size = len(content) + digest = sha256(content).hexdigest() + files.append((size, name, digest)) + with open(publish.joinpath("index.txt"), "wt") as f: + for _, name, digest in sorted(files): + f.write(f"{name} {digest}\n") + + +def build_packages(): + edit = Path(CONFIG_DIR_PATH, "editor", "packages") + publish = Path(CONFIG_DIR_PATH, "editor", "export") + for path in edit.iterdir(): + name = path.name + ".package.zip" + zipdir(str(path), publish.joinpath(name)) + build_packages_index() + + +def unpack_packages(): + edit = Path(CONFIG_DIR_PATH, "editor", "packages") + reference = Path(CONFIG_DIR_PATH, "editor", "import") + for path in reference.iterdir(): + if path.suffix == ".zip": + destination = edit.joinpath(path.stem.replace(".package", "")) + unzipdir(path, destination) diff --git a/soundrts/version.py b/soundrts/version.py index 2ac3625d..eb5df0d7 100644 --- a/soundrts/version.py +++ b/soundrts/version.py @@ -1,6 +1,7 @@ from hashlib import md5 -from . import config, res +from . import config +from .lib.resource import res VERSION = "1.3.6" IS_DEV_VERSION = config.debug_mode @@ -24,9 +25,7 @@ def compatibility_version(): def rules_hash(): - rules_and_ai = res.get_text_file("rules", append=True) + res.get_text_file( - "ai", append=True - ) + rules_and_ai = res.text("rules", append=True) + res.text("ai", append=True) return md5(rules_and_ai.encode()).hexdigest() diff --git a/soundrts/world.py b/soundrts/world.py index a69c7106..47d1cace 100644 --- a/soundrts/world.py +++ b/soundrts/world.py @@ -1,5 +1,4 @@ import copy -import os.path import queue import random import re @@ -9,14 +8,13 @@ from itertools import chain from soundrts.lib.nofloat import square_of_distance - -from . import res from .definitions import VIRTUAL_TIME_INTERVAL, get_ai_names, rules from .lib import chronometer as chrono from .lib import collision +from .lib.defs import preprocess from .lib.log import exception, warning from .lib.nofloat import PRECISION, int_distance, to_int -from .paths import MAPERROR_PATH +from .lib.resource import res from .worldclient import DummyClient from .worldentity import COLLISION_RADIUS from .worldexit import passage @@ -50,10 +48,11 @@ def convert_and_split_first_numbers(words): class World: - def __init__(self, default_triggers, seed=0, must_apply_equivalent_type=False): + def __init__(self, default_triggers=None, seed=0): + if default_triggers is None: + default_triggers = [] self.default_triggers = default_triggers self.seed = seed - self.must_apply_equivalent_type = must_apply_equivalent_type self.id = self.get_next_id() self.random = random.Random() self.random.seed(int(seed)) @@ -567,7 +566,7 @@ def is_a_square(x): @property def nb_res(self): - return rules.get("parameters", "nb_of_resource_types") + return rules.get("parameters", "nb_of_resource_types", 2) def _add_start(self, w, words): if w == "player": @@ -623,7 +622,7 @@ def _add_trigger(self, words): def random_choice_repl(self, matchobj): return self.random.choice(matchobj.group(1).split("\n#end_choice\n")) - def _load_map(self, map): + def _parse_map(self, map_definition): triggers = [] starting_resources = [0 for _ in range(self.nb_res)] @@ -634,10 +633,8 @@ def _load_map(self, map): "high_grounds", ] - s = map.read() # "universal newlines" - s = re.sub("(?m);.*$", "", s) # remove comments - s = re.sub("(?m)^[ \t]*$\n", "", s) # remove empty lines - s = re.sub(r"(?m)\\[ \t]*$\n", " ", s) # join lines ending with "\" + s = map_definition # "universal newlines" + s = preprocess(s) s = s.replace("(", " ( ") s = s.replace(")", " ) ") s = re.sub(r"\s*\n\s*", r"\n", s) # strip lines @@ -770,22 +767,13 @@ def _load_map(self, map): self._add_trigger(t) def load_and_build_map(self, map): - if os.path.exists(MAPERROR_PATH): - try: - os.remove(MAPERROR_PATH) - except: - warning("cannot remove map error file") - try: - map.load_rules_and_ai(res) - self._load_map(map) - self.map = map - self.square_width = int(self.square_width * PRECISION) - self._build_map() - except MapError as msg: - warning("map error: %s", msg) - self.map_error = "map error: %s" % msg - return False - return True + res.set_map(map) + res.load_rules_and_ai() # TODO: remove this line when tests don't require it + + self._parse_map(map.definition) + + self.square_width = int(self.square_width * PRECISION) + self._build_map() # move this to Game? @@ -814,7 +802,7 @@ def at_least_two_camps(self): def food_limit(self): return self.global_food_limit - def populate_map(self, clients, random_starts=True): + def populate_map(self, clients, random_starts=True, equivalents=False): if random_starts: players_starts = self.random.sample(self.players_starts, len(clients)) else: @@ -827,20 +815,21 @@ def populate_map(self, clients, random_starts=True): DummyClient(neutral=True).create_player(self) starts = chain(players_starts, self.computers_starts) for player, start in zip(self.players, starts): - player.init_position(start) + parsed_start = self.parse_start(start, player.faction, equivalents) + player.init_position(parsed_start) - def parse_start(self, start, faction): + def parse_start(self, start, faction, must_apply_equivalent_type): resources = rules.normalized_cost_or_resources(start[0]) - units, upgrades, forbidden_techs = self.parse_assets(start, faction) + units, upgrades, forbidden_techs = self.parse_assets(start, faction, must_apply_equivalent_type) triggers = start[2] return units, upgrades, forbidden_techs, resources, triggers - def parse_assets(self, start, faction): + def parse_assets(self, start, faction, must_apply_equivalent_type): units = [] upgrades = [] forbidden_techs = [] for place, type_, n in start[1]: - if self.must_apply_equivalent_type: + if must_apply_equivalent_type: type_ = rules.equivalent_type(type_, faction) if isinstance(type_, str) and type_[0:1] == "-": forbidden_techs.append(type_[1:]) @@ -1025,21 +1014,14 @@ class MapError(Exception): def map_error(line, msg): - msg = f"error: {msg}" - if line: - msg += f' (in "{line}")' - try: - open(MAPERROR_PATH, "w").write(msg) - except: - warning("could not write in %s", MAPERROR_PATH) - raise MapError(msg) + raise MapError(_formatted_msg(line, msg)) def map_warning(line, msg): + warning(_formatted_msg(line, msg)) + + +def _formatted_msg(line, msg): if line: msg += f' (in "{line}")' - warning(msg) - try: - open(MAPERROR_PATH, "w").write(msg) - except: - warning("could not write in %s", MAPERROR_PATH) + return msg diff --git a/soundrts/worldclient.py b/soundrts/worldclient.py index 370fa93b..d3e93026 100644 --- a/soundrts/worldclient.py +++ b/soundrts/worldclient.py @@ -266,7 +266,7 @@ def get_digest(self): def get_sync_debug_msg_1(self): return "out_of_sync_error: map={} version={} platform={} python={} md5={} time={}".format( - self.world.map.get_name(), + self.game_session.map.name, VERSION, platform.platform(), sys.version.replace("\n", " "), diff --git a/soundrts/worldplayerbase.py b/soundrts/worldplayerbase.py index 3c0614fa..3c7ecc88 100644 --- a/soundrts/worldplayerbase.py +++ b/soundrts/worldplayerbase.py @@ -651,8 +651,8 @@ def init_alliance(self): if self.client.alliance == p.client.alliance: self.allied.append(p) - def init_position(self, start): - units, self.upgrades, self.forbidden_techs, resources, triggers = self.world.parse_start(start, self.faction) + def init_position(self, parsed_start): + units, self.upgrades, self.forbidden_techs, resources, triggers = parsed_start self.resources = resources for index, qty in enumerate(self.resources): diff --git a/soundrts/worldplayerstats.py b/soundrts/worldplayerstats.py index 1c2ba7c2..af889cf0 100644 --- a/soundrts/worldplayerstats.py +++ b/soundrts/worldplayerstats.py @@ -5,14 +5,21 @@ class Stats: - time: int + _game_duration = None def __init__(self, player): self._stats = {} self.player = player + @property + def game_duration(self): + if self._game_duration is not None: + return self._game_duration + else: + return self.player.world.time + def freeze(self): - self.time = self.player.world.time + self._game_duration = self.player.world.time def add(self, event, target, inc=1): if target is not None: @@ -41,7 +48,7 @@ def score(self): return score def game_duration_in_minutes_seconds(self): - t = self.time // 1000 + t = self.game_duration // 1000 m = int(t // 60) s = int(t - m * 60) return m, s diff --git a/soundrts/worldroom.py b/soundrts/worldroom.py index 1f27e020..83b9b54e 100644 --- a/soundrts/worldroom.py +++ b/soundrts/worldroom.py @@ -47,6 +47,8 @@ def decorated_f(*args, **kargs): class _Space: + high_ground = False + def __init__(self): self.objects = [] diff --git a/soundrts/worldunit.py b/soundrts/worldunit.py index b64c4d5f..01c10f29 100644 --- a/soundrts/worldunit.py +++ b/soundrts/worldunit.py @@ -33,20 +33,22 @@ def ground_or_air(t): class Creature(Entity): damage_vs: dict = dict() + armor_vs: dict = dict() @classmethod def interpret(cls, d): - dmg = d.get("damage_vs", []) - d["damage_vs"] = dict() - targets = [] - for s in dmg: - try: - n = to_int(s) - for t in targets: - d["damage_vs"][t] = n - targets = [] - except ValueError: - targets.append(s) + for vs_attr in ["damage_vs", "armor_vs"]: + dmg = d.get(vs_attr, []) + d[vs_attr] = dict() + targets = [] + for s in dmg: + try: + n = to_int(s) + for t in targets: + d[vs_attr][t] = n + targets = [] + except ValueError: + targets.append(s) type_name: Optional[str] = None is_a_unit = False @@ -162,6 +164,10 @@ def set_player(self, player): for o in self.inside.objects: o.set_player(player) + @classmethod + def create_from_nowhere(cls): + return cls.__new__(cls) + def __init__(self, player, place, x, y, o=90): super().__init__(place, x, y, o) self.position_to_hold = place # defend the creation place @@ -647,9 +653,16 @@ def _choose_enemy(self, place): x.id, ) ) - self.action = AttackAction(self, reachable_enemies[0]) + self._attack(reachable_enemies[0]) return True + def _attack(self, target): + # don't notify or attack if already attacking the same target + # (at the moment, this test is necessary if the target is not a menace, for example a farm) + if not isinstance(self.action, AttackAction) or self.action.target != target: + self.action = AttackAction(self, target) + self.notify("attack") + def flee(self): sl = [e.other_side.place for e in self.place.exits] if self._previous_square: @@ -684,10 +697,28 @@ def decide(self): # attack def hit(self, target): - base_damage = self.damage_vs.get(target.type_name, self.damage) - damage = max(self.minimal_damage, base_damage - target.armor) + base_damage = self._base_damage_versus(target) + damage = max(self.minimal_damage, base_damage - target.armor_versus(self)) target.receive_hit(damage, self) + def armor_versus(self, attacker): + d = self.armor_vs + if attacker.type_name in d: + return d[attacker.type_name] + for t in attacker.expanded_is_a: + if t in d: + return d[t] + return self.armor + + def _base_damage_versus(self, target): + d = self.damage_vs + if target.type_name in d: + return d[target.type_name] + for t in target.expanded_is_a: + if t in d: + return d[t] + return self.damage + def _hit_or_miss(self, target): if self.has_hit(target): self.hit(target) diff --git a/test_desync.py b/test_desync.py index d30db17f..9221dcf9 100644 --- a/test_desync.py +++ b/test_desync.py @@ -2,6 +2,8 @@ import logging import os +from soundrts.lib.resource import res, official_multiplayer_maps + os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1" import random @@ -16,14 +18,14 @@ from soundrts import worldplayercomputer2 as wpc2, clientgame, version from soundrts.lib.nofloat import PRECISION -from soundrts import clientmain, res, servermain, world +from soundrts import clientmain, servermain, world from soundrts.game import MultiplayerGame, TrainingGame from soundrts.lib import sound from soundrts.lib.voice import voice -from soundrts.mapfile import worlds_multi from soundrts.clientgame import GameInterface from soundrts.clientmain import restore_game, replay, replay_filenames +res.language = random.choice(list(res._available_languages())) version.IS_DEV_VERSION = True clientgame.IS_DEV_VERSION = True import pytest # exceptions will be reraised by log.exception() @@ -128,7 +130,7 @@ def run_server(): import logging from soundrts.lib import log log.clear_handlers() - log.add_console_handler(logging.CRITICAL) + log.add_console_handler(logging.ERROR) if "win32gui" in sys.modules and "PYCHARM_HOSTED" not in os.environ: hwnd = win32gui.GetForegroundWindow() @@ -200,7 +202,7 @@ def __init__(self, dt): self._next_time = time.time() + dt def run(self, interface: GameInterface): - sound.main_volume = 0 + sound.main_volume = 0.1 if not isinstance(interface, GameInterface): return True if self._next_time <= time.time(): @@ -244,7 +246,7 @@ def game_session(): _mods = random.choice(["", "crazymod9beta10", "aoe", "starwars", "blitzkrieg", "modern"]) res.set_mods(_mods) - maps = [m for m in worlds_multi() if m.official] + maps = official_multiplayer_maps() m = random.choice(maps) n_guests_max = random.randint(0, 2) @@ -253,7 +255,7 @@ def game_session(): ais = random.choice([(0, 1, 10), (1, 1, 10), (1, 0, 10), (0, 0, 10)]) print("mod(s):", _mods) - print("map:", m.path, "for", m.nb_players_max, "players max") + print("map:", m.name, "for", m.nb_players_max, "players max") print("clients:", n + 1) print("local AIs:", m.nb_players_max - n - 1, "from", ais) print() @@ -263,7 +265,7 @@ def game_session(): # game creator p = Process( target=run_client, - args=(0, [Create(m.path, 20), Invite(n), InviteAI(*ais), Start(), PressRandomKeys(.1)], _mods), + args=(0, [Create(m.name, 20), Invite(n), InviteAI(*ais), Start(), PressRandomKeys(.1)], _mods), ) p.start() lp.append(p) @@ -286,8 +288,8 @@ def game_session(): # replay p = Process(target=_replay, args=(2, [Wait(8), Save()])) - p.start() - lp.append(p) +# p.start() +# lp.append(p) time.sleep(45) @@ -300,5 +302,5 @@ def game_session(): while True: game_session() -# TODO: test campaigns, languages +# TODO: test campaigns (orc campaign: resources (or unittest?)) # TODO: specific folder (saves, replays) diff --git a/train.py b/train.py deleted file mode 100644 index 8facf529..00000000 --- a/train.py +++ /dev/null @@ -1,98 +0,0 @@ -import random - -from soundrts.lib.nofloat import PRECISION -from soundrts.mapfile import Map -from soundrts.world import World -from soundrts.worldclient import DummyClient -from soundrts.worldplayercomputer import Computer - -tiny_map_str = """ -square_width 12 -nb_columns 1 -nb_lines 1 -nb_meadows_by_square 9 -goldmine 1000 a1 - -nb_players_min 1 -nb_players_max 1 -starting_squares a1 -starting_units townhall farm peasant -starting_resources 10 10 -""" -tiny_map2_str = """ -square_width 12 -nb_columns 3 -nb_lines 1 -nb_meadows_by_square 18 -goldmine 1000 a1 c1 -wood 1000 a1 c1 -west_east_paths a1 b1 - -nb_players_min 2 -nb_players_max 2 -starting_squares a1 c1 -starting_units townhall farm peasant -starting_resources 10 10 -""" - - -def get_map(s): - m = Map() - m.map_string = s - m.path = "" - return m - - -def time_to_500_gold(nb_workers): - Computer.nb_workers_to_get = nb_workers - w = World([]) - w.load_and_build_map(get_map(tiny_map_str)) - c = DummyClient(AI_type="aggressive") - w.populate_map([c]) - p = w.players[0] - dt = 3 * 60 - for i in range(dt * 20): - # if i % dt == 0: - # print(i // dt, "minutes", - # "gold:", p.resources[0] // PRECISION, - # "workers:", len(list(filter(lambda x: x.type_name == "peasant", p.units)))) - w.update() - if p.resources[0] > 500 * PRECISION: - return i / dt - - -def computer_vs_computer(nb_workers): - w = World([], seed=random.randint(0, 100000)) - w.load_and_build_map(get_map(tiny_map2_str)) - c = DummyClient(AI_type="aggressive") - c.alliance = 9 - c2 = DummyClient(AI_type="aggressive") - w.populate_map([c, c2]) - p, p2 = w.players - p.nb_workers_to_get = nb_workers - dt = 3 * 60 - for i in range(dt * 60): - w.update() - if i % dt == 0: - print(len(p.units), len(p2.units)) - # print(p.units) - # print(p2.units) - if not p.units: - return "loss", i - if not p2.units: - return "win", i - return "draw", i - - -def test1(): - for n in range(30): - print(n, time_to_500_gold(n)) - - -n = 0 -for _ in range(10): - win, t = computer_vs_computer(15) - print(win) - if win == "win": - n += 1 -print(n, "wins /", 10)