From 8bbe86759c42fc334dda93e008bab6b51ca17d41 Mon Sep 17 00:00:00 2001 From: clxud Date: Thu, 2 Jul 2026 02:08:40 +0000 Subject: [PATCH] Initial commit: TSS Bot Web Backend (Rust/Axum) --- .gitignore | 4 + LICENSE | 339 ++++++ README.md | 31 + backend/Cargo.lock | 770 ++++++++++++++ backend/Cargo.toml | 15 + backend/README.md | 60 ++ backend/src/main.rs | 2428 +++++++++++++++++++++++++++++++++++++++++++ example.env | 8 + 8 files changed, 3655 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/Cargo.lock create mode 100644 backend/Cargo.toml create mode 100644 backend/README.md create mode 100644 backend/src/main.rs create mode 100644 example.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10ea0a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +backend/target +.env +.env.local +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..58b4f77 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# tssbot.web-backend + +Rust/Axum API server for the TSS Bot web platform. Serves tournament, team, player, and game data via REST endpoints backed by SQLite. + +## Build + +```bash +cargo build --release +``` + +## Run + +```bash +cp example.env .env # edit with your values +cargo run --release +``` + +The backend reads `.env` from the parent directory or the current directory. + +## Environment Variables + +| Variable | Description | +|---|---| +| `BACKEND_PORT` | Port to listen on (default: 6000) | +| `BACKEND_HOST` | Bind address (default: 127.0.0.1) | +| `BACKEND_ALLOWED_ORIGINS` | Comma-separated CORS origins | +| `TSS_BATTLES_DB` | Path to battles SQLite database | +| `TSS_TEAMS_DB` | Path to teams SQLite database | +| `TSS_TOURNAMENTS_DB` | Path to tournaments SQLite database | +| `VEHICLE_TRANSLATIONS_JSON` | Path to vehicle name translations | +| `VEHICLE_DATA_CACHE_JSON` | Path to vehicle data/icon cache | diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..76a2087 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,770 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tssbot-backend" +version = "0.1.0" +dependencies = [ + "axum", + "rusqlite", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..0f27684 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tssbot-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +urlencoding = "2" diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a142e4b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,60 @@ +# tssbot backend + +Rust backend API service for Toothless' TSS Bot. + +It reads two SQLite databases: + +- `TSS_BATTLES_DB` for `tss_battles.db` (matches, players, and the `match_logs` table) +- `TSS_TEAMS_DB` for `tss_teams.db` +- `TSS_TOURNAMENTS_DB` for `tss_tournaments.db` + +If any of these are unset, the backend first looks under `STORAGE_VOL_PATH` +before falling back to the current working directory. +- `BACKEND_HOST` bind host, default `127.0.0.1` +- `BACKEND_ALLOWED_ORIGINS` comma-separated browser origins allowed by CORS + +Both paths can be absolute or relative to the repo root when run through the root scripts/systemd units. + +## Vehicle translation + icons + +At startup the backend loads two cache files (built by the bots, shared under +`STORAGE/CACHE`) into memory to translate `player_games_hist.vehicle_internal` +(the WT cdk) into localized vehicle names and icon filenames for the game scoreboard: + +- `VEHICLE_TRANSLATIONS_JSON` → `vehicle_translations.json` (`{ cdk: { en, ru, ... } }`). + The `/api/tss/games/:id` endpoint honors `?lang=` (default `en`), falling back + `lang → en → raw cdk`. +- `VEHICLE_DATA_CACHE_JSON` → `vehicle_data_cache_all.json` (`[cdk, name, icon, tags]`), + used for icon filenames (fallback `.png`). + +The icon PNGs themselves are served statically by the frontend at `/vehicle-icons` +(deploy-time copy/symlink of `SHARED/ICONS/VEHICLES`). + +It currently exposes: + +- `GET /health` +- `GET /api/tss/leaderboard/teams?limit=100` +- `GET /api/tss/leaderboard/players?limit=100` +- `GET /api/tss/games/recent?limit=50` +- `GET /api/tss/games/:session_id?lang=en` — scoreboard (teams, players, vehicle lineup) +- `GET /api/tss/games/:session_id/logs` — chat + battle logs from `match_logs` +- `GET /api/tss/teams/resolve?name=...` +- `GET /api/tss/teams/search?q=...&limit=10` +- `GET /api/tss/teams/:team` +- `GET /api/tss/teams/:team/history` +- `GET /api/tss/teams/:team/games` +- `GET /api/tss/player/:uid` + +## Local development + +```sh +npm run dev:backend +``` + +The backend listens on by default. Override with `BACKEND_PORT` and `BACKEND_HOST`. + +## Production build + +```sh +npm run build:backend +``` diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..ea346f1 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,2428 @@ +use axum::{ + extract::{Path, Query, State}, + http::{header, HeaderValue, Method, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + env, fs, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path as FsPath, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::net::TcpListener; +use tower_http::{ + cors::{AllowOrigin, CorsLayer}, + trace::TraceLayer, +}; + +const MAX_TEAM_NAME_LENGTH: usize = 80; +const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300); + +struct AppState { + battles_db: PathBuf, + teams_db: PathBuf, + tournaments_db: PathBuf, + leaderboard_cache: Mutex>, + vehicle_names: HashMap>, + vehicle_icons: HashMap, +} + +struct CachedLeaderboard { + teams: Vec, +} + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + fn not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.into(), + } + } + + fn internal(message: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(json!({ "error": self.message }))).into_response() + } +} + +type ApiResult = Result, ApiError>; + +#[derive(Deserialize)] +struct LimitQuery { + limit: Option, +} + +#[derive(Deserialize)] +struct LangQuery { + lang: Option, +} + +#[derive(Deserialize)] +struct ResolveQuery { + name: String, +} + +#[derive(Deserialize)] +struct SearchQuery { + q: Option, + name: Option, + limit: Option, +} + +#[derive(Serialize)] +struct HealthResponse { + ok: bool, + service: &'static str, + battles_db: String, + teams_db: String, + databases: BTreeMap<&'static str, bool>, +} + +#[derive(Clone, Serialize)] +struct LeaderboardResponse { + teams: Vec, +} + +#[derive(Serialize)] +struct PlayerLeaderboardResponse { + players: Vec, +} + +#[derive(Serialize)] +struct RecentGamesResponse { + matches: Vec, +} + +#[derive(Serialize)] +struct SearchResponse { + teams: Vec, +} + +#[derive(Serialize)] +struct ResolveResponse { + team_id: i64, + name: String, +} + +#[derive(Serialize)] +struct TeamSearchRow { + team_id: i64, + name: String, + members: i64, +} + +#[derive(Clone, Serialize)] +struct TeamLeaderboardRow { + team_id: i64, + name: String, + player_count: i64, + total_battles: i64, + wins: i64, + losses: i64, + win_rate: f64, + total_kills: i64, +} + +#[derive(Serialize)] +struct PlayerLeaderboardRow { + uid: String, + nick: Option, + total_battles: i64, + wins: i64, + losses: i64, + win_rate: f64, + ground_kills: i64, + air_kills: i64, + total_kills: i64, + assists: i64, + captures: i64, + deaths: i64, + score: i64, + kdr: f64, + teams_seen: i64, + last_seen: i64, +} + +#[derive(Serialize)] +struct TeamDetail { + team_id: i64, + name: String, + members: i64, + captain_uid: Option, + data_set: &'static str, + team_summary: TeamSummary, + players: Vec, +} + +#[derive(Default, Serialize)] +struct TeamSummary { + player_count: i64, + total_battles: i64, + wins: i64, + losses: i64, + win_rate: f64, + kdr: f64, + total_kills: i64, + total_points: i64, +} + +#[derive(Serialize)] +struct PlayerSummary { + uid: String, + nick: Option, + role: String, + total_battles: i64, + wins: i64, + losses: i64, + win_rate: f64, + total_kills: i64, + ground_kills: i64, + air_kills: i64, + assists: i64, + deaths: i64, + kdr: f64, +} + +#[derive(Serialize)] +struct HistoryResponse { + team_id: i64, + name: String, + history: Vec, +} + +#[derive(Serialize)] +struct PeriodHistory { + period: String, + battles: i64, + wins: i64, + losses: i64, + win_rate: f64, +} + +#[derive(Serialize)] +struct GamesResponse { + team_id: i64, + name: String, + games: Vec, +} + +#[derive(Serialize)] +struct GameResponse { + game: GameRow, + participants: Vec, +} + +#[derive(Serialize)] +struct TournamentsResponse { + tournaments: Vec, +} + +#[derive(Serialize)] +struct TournamentSummary { + tournament_id: i64, + name: Option, + format: Option, + status: Option, + match_count: i64, + team_count: i64, + date_start: Option, + date_end: Option, +} + +#[derive(Serialize)] +struct TournamentDetailResponse { + tournament_id: i64, + name: Option, + format: Option, + status: Option, + match_count: i64, + team_count: i64, + date_start: Option, + date_end: Option, + matches: Vec, + standings: Vec, +} + +#[derive(Serialize)] +struct TournamentMatchRow { + match_id: String, + type_bracket: String, + side: Option, + round: Option, + position: Option, + team_a_name: Option, + team_b_name: Option, + winner_name: Option, + score_a: i64, + score_b: i64, + status: String, + battles: Vec, +} + +#[derive(Serialize)] +struct TournamentBattleRow { + session_hex: String, + position: Option, + have_replay: bool, +} + +#[derive(Serialize)] +struct TournamentStandingRow { + group_index: i64, + team_name: Option, + points: i64, + wins: i64, + draws: i64, + losses: i64, + buchholz: f64, + rank: Option, +} + +#[derive(Serialize)] +struct GameLogsResponse { + chat_log: Vec, + battle_log: Vec, + event_log: Value, +} + +#[derive(Serialize)] +struct GameRow { + #[serde(skip_serializing_if = "Option::is_none")] + team_name: Option, + session_id: String, + timestamp: i64, + endtime_unix: i64, + map_name: Option, + mission_mode: Option, + result: String, + player_count: i64, + winning_team: Option, + losing_team: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tournament_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tournament_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + draw: bool, + stats: GameStats, +} + +#[derive(Serialize)] +struct GameStats { + ground_kills: i64, + air_kills: i64, + assists: i64, + captures: i64, + deaths: i64, + score: i64, + missile_evades: i64, + shell_interceptions: i64, + team_kills_stat: i64, +} + +#[derive(Serialize)] +struct GameParticipant { + team_name: String, + result: String, + player_count: i64, + stats: GameStats, + players: Vec, +} + +#[derive(Serialize)] +struct GamePlayer { + uid: String, + nick: Option, + vehicles: Vec, + stats: GameStats, +} + +#[derive(Serialize)] +struct Vehicle { + cdk: String, + name: String, + icon: String, +} + +#[derive(Serialize)] +struct PlayerSearchResponse { + players: Vec, +} + +#[derive(Serialize)] +struct PlayerRef { + uid: String, + nick: Option, +} + +#[derive(Serialize)] +struct PlayerProfile { + uid: String, + nick: Option, + data_set: &'static str, + career: PlayerCareer, + teams: Vec, +} + +#[derive(Serialize)] +struct PlayerCareer { + total_battles: i64, + wins: i64, + losses: i64, + win_rate: f64, + ground_kills: i64, + air_kills: i64, + total_kills: i64, + assists: i64, + captures: i64, + deaths: i64, + kdr: f64, +} + +#[derive(Serialize)] +struct PlayerTeamRef { + team_id: Option, + team_name: Option, + games: i64, + last_seen: i64, +} + +struct TeamRecord { + team_id: i64, + name: String, + members: i64, + captain_uid: Option, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + load_root_env(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); + let port = env_u16("BACKEND_PORT") + .or_else(|| env_u16("PORT")) + .unwrap_or(6000); + let state = Arc::new(AppState { + battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"), + teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"), + tournaments_db: resolve_db_path("TSS_TOURNAMENTS_DB", "tss_tournaments.db"), + leaderboard_cache: Mutex::new(None), + vehicle_names: load_vehicle_names(&resolve_db_path( + "VEHICLE_TRANSLATIONS_JSON", + "vehicle_translations.json", + )), + vehicle_icons: load_vehicle_icons(&resolve_db_path( + "VEHICLE_DATA_CACHE_JSON", + "vehicle_data_cache.json", + )), + }); + tracing::info!( + "loaded {} vehicle name maps, {} vehicle icons", + state.vehicle_names.len(), + state.vehicle_icons.len() + ); + spawn_leaderboard_refresh(state.clone()); + + let app = Router::new() + .route("/health", get(health)) + .route("/api/tss/leaderboard/teams", get(leaderboard)) + .route("/api/tss/leaderboard/players", get(player_leaderboard)) + .route("/api/tss/games/recent", get(recent_games)) + .route("/api/tss/tournaments", get(tournaments)) + .route("/api/tss/tournaments/{tournament_id}", get(tournament_detail)) + .route("/api/tss/games/{session_id}", get(game_detail)) + .route("/api/tss/games/{session_id}/logs", get(game_logs)) + .route("/api/tss/teams/resolve", get(resolve_team)) + .route("/api/tss/teams/search", get(search_teams)) + .route("/api/tss/teams/{team}", get(team_detail)) + .route("/api/tss/teams/{team}/history", get(team_history)) + .route("/api/tss/teams/{team}/games", get(team_games)) + .route("/api/tss/players/resolve", get(resolve_player)) + .route("/api/tss/players/search", get(search_players)) + .route("/api/tss/player/{uid}", get(player_detail)) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_origin(allowed_origins()) + .allow_headers([header::ACCEPT, header::CONTENT_TYPE]), + ) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr = SocketAddr::from((host, port)); + let listener = TcpListener::bind(addr).await?; + tracing::info!("tssbot backend listening on http://{}", addr); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} + +async fn health(State(state): State>) -> Json { + let mut databases = BTreeMap::new(); + databases.insert( + "battles", + Connection::open_with_flags( + &state.battles_db, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .is_ok(), + ); + databases.insert( + "teams", + Connection::open_with_flags(&state.teams_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .is_ok(), + ); + + Json(HealthResponse { + ok: databases.values().all(|ok| *ok), + service: "tssbot-backend", + battles_db: state.battles_db.display().to_string(), + teams_db: state.teams_db.display().to_string(), + databases, + }) +} + +async fn leaderboard( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let limit = usize::try_from(query.limit.unwrap_or(100).clamp(1, 100)).unwrap_or(100); + if let Some(teams) = cached_leaderboard(&state, limit)? { + return Ok(Json(LeaderboardResponse { teams })); + } + + let teams = leaderboard_roster_rows(&state, limit)?; + Ok(Json(LeaderboardResponse { teams })) +} + +async fn player_leaderboard( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100)); + let battles_conn = open_db(&state.battles_db)?; + let players = player_leaderboard_rows(&battles_conn, limit)?; + Ok(Json(PlayerLeaderboardResponse { players })) +} + +fn leaderboard_teams(state: &AppState, limit: usize) -> Result, ApiError> { + let teams_conn = open_db(&state.teams_db)?; + // Deduplicate teams by name across tournaments — pick the highest team_id + // (most recent) per name for the roster count, but stats come from team_name. + let mut stmt = teams_conn + .prepare( + "SELECT MAX(team_id), name, MAX(members), MAX(captain_uid) + FROM teams_data + GROUP BY name COLLATE NOCASE + ORDER BY MAX(members) DESC, name COLLATE NOCASE ASC + LIMIT ?1", + ) + .map_err(db_error)?; + + let teams = stmt + .query_map(params![limit as i64], |row| read_team_record(row)) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(teams) +} + +fn leaderboard_roster_rows( + state: &AppState, + limit: usize, +) -> Result, ApiError> { + leaderboard_teams(state, limit).map(|teams| { + teams + .into_iter() + .map(|team| TeamLeaderboardRow { + team_id: team.team_id, + name: team.name, + player_count: team.members, + total_battles: 0, + wins: 0, + losses: 0, + win_rate: 0.0, + total_kills: 0, + }) + .collect() + }) +} + +fn leaderboard_rows(state: &AppState, limit: usize) -> Result, ApiError> { + let battles_conn = open_db(&state.battles_db)?; + let teams = leaderboard_teams(state, limit)?; + let team_names = teams + .iter() + .map(|team| team.name.as_str()) + .collect::>(); + let mut summaries = team_summaries_for(&battles_conn, &team_names)?; + + let mut rows = Vec::with_capacity(teams.len()); + for team in teams { + let summary = summaries + .remove(&team.name.to_ascii_lowercase()) + .unwrap_or_default(); + rows.push(TeamLeaderboardRow { + team_id: team.team_id, + name: team.name, + player_count: team.members, + total_battles: summary.total_battles, + wins: summary.wins, + losses: summary.losses, + win_rate: summary.win_rate, + total_kills: summary.total_kills, + }); + } + + Ok(rows) +} + +fn spawn_leaderboard_refresh(state: Arc) { + tokio::spawn(async move { + loop { + let refresh_state = state.clone(); + match tokio::task::spawn_blocking(move || leaderboard_rows(&refresh_state, 100)).await { + Ok(Ok(rows)) => { + if let Err(error) = cache_leaderboard(&state, &rows) { + tracing::warn!("could not cache leaderboard refresh: {}", error.message); + } + } + Ok(Err(error)) => { + tracing::warn!("could not refresh leaderboard cache: {}", error.message); + } + Err(error) => { + tracing::warn!("leaderboard refresh task failed: {}", error); + } + } + tokio::time::sleep(LEADERBOARD_CACHE_TTL).await; + } + }); +} + +async fn recent_games( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let limit = i64::from(query.limit.unwrap_or(50).clamp(1, 100)); + let battles_conn = open_db(&state.battles_db)?; + let matches = recent_games_for(&battles_conn, limit)?; + Ok(Json(RecentGamesResponse { matches })) +} + +async fn tournaments(State(state): State>) -> ApiResult { + let conn = open_db(&state.tournaments_db)?; + let tournaments = tournaments_list(&conn)?; + Ok(Json(TournamentsResponse { tournaments })) +} + +async fn tournament_detail( + State(state): State>, + Path(tournament_id): Path, +) -> ApiResult { + let tid = validate_tournament_id(&tournament_id)?; + let conn = open_db(&state.tournaments_db)?; + let summary = tournament_summary_for(&conn, tid)? + .ok_or_else(|| ApiError::not_found("Tournament not found"))?; + let standings = tournament_standings_for(&conn, tid)?; + let mut matches = tournament_match_rows_for(&conn, tid)?; + attach_battles(&conn, &state.battles_db, tid, &mut matches)?; + + Ok(Json(TournamentDetailResponse { + tournament_id: summary.tournament_id, + name: summary.name, + format: summary.format, + status: summary.status, + match_count: summary.match_count, + team_count: summary.team_count, + date_start: summary.date_start, + date_end: summary.date_end, + matches, + standings, + })) +} + +async fn game_detail( + State(state): State>, + Path(session_id): Path, + Query(query): Query, +) -> ApiResult { + let session_id = validate_session_id(&session_id)?; + let lang = query.lang.as_deref().unwrap_or("en"); + let battles_conn = open_db(&state.battles_db)?; + let game = game_for(&battles_conn, session_id)? + .ok_or_else(|| ApiError::not_found("Game not found"))?; + let participants = game_participants_for(&battles_conn, session_id, &state, lang)?; + Ok(Json(GameResponse { game, participants })) +} + +async fn game_logs( + State(state): State>, + Path(session_id): Path, +) -> ApiResult { + let session_id = validate_session_id(&session_id)?; + let conn = open_db(&state.battles_db)?; + // Logs are non-critical: a missing match_logs table or row yields empty arrays. + let row: Option<(Option, Option, Option)> = match conn + .query_row( + "SELECT chat_log_json, battle_log_json, event_log_json FROM match_logs WHERE session_id = ?1", + params![session_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional() + { + Ok(row) => row, + Err(_) => conn + .query_row( + "SELECT chat_log_json, battle_log_json FROM match_logs WHERE session_id = ?1", + params![session_id], + |r| Ok((r.get(0)?, r.get(1)?, None)), + ) + .optional() + .unwrap_or(None), + }; + let parse = |s: Option| -> Vec { + s.and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default() + }; + let parse_event_log = |s: Option| -> Value { + s.and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_else(|| json!({ "kills": [], "damage": [] })) + }; + let (chat, battle, event_log) = row.unwrap_or((None, None, None)); + Ok(Json(GameLogsResponse { + chat_log: parse(chat), + battle_log: parse(battle), + event_log: parse_event_log(event_log), + })) +} + +async fn resolve_team( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let name = validate_team_name(&query.name)?; + let conn = open_db(&state.teams_db)?; + let team = find_team(&conn, name)?.ok_or_else(|| ApiError::not_found("Team not found"))?; + Ok(Json(ResolveResponse { + team_id: team.team_id, + name: team.name, + })) +} + +async fn search_teams( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or(""); + let name = validate_team_name(raw)?; + let limit = i64::from(query.limit.unwrap_or(10).clamp(1, 20)); + let like = format!("%{}%", escape_like(name)); + let conn = open_db(&state.teams_db)?; + let mut stmt = conn + .prepare( + "SELECT team_id, name, members + FROM teams_data + WHERE name LIKE ?1 ESCAPE '\\' + ORDER BY + CASE WHEN name = ?2 COLLATE NOCASE THEN 0 ELSE 1 END, + members DESC, + name COLLATE NOCASE ASC + LIMIT ?3", + ) + .map_err(db_error)?; + + let teams = stmt + .query_map(params![like, name, limit], |row| { + Ok(TeamSearchRow { + team_id: row.get(0)?, + name: row.get(1)?, + members: row.get(2)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + Ok(Json(SearchResponse { teams })) +} + +async fn team_detail( + State(state): State>, + Path(team_name): Path, +) -> ApiResult { + let decoded = decode_path_team(&team_name)?; + let teams_conn = open_db(&state.teams_db)?; + let battles_conn = open_db(&state.battles_db)?; + let team = + find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; + let summary = team_summary_for(&battles_conn, &team.name)?; + // team_id is the most recent tournament entry — used only for the roster lookup. + let players = player_summaries_for(&teams_conn, &battles_conn, team.team_id, &team.name)?; + + Ok(Json(TeamDetail { + team_id: team.team_id, + name: team.name, + members: team.members, + captain_uid: team.captain_uid, + data_set: "tss", + team_summary: summary, + players, + })) +} + +async fn team_history( + State(state): State>, + Path(team_name): Path, +) -> ApiResult { + let decoded = decode_path_team(&team_name)?; + let teams_conn = open_db(&state.teams_db)?; + let battles_conn = open_db(&state.battles_db)?; + let team = + find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; + + let history = period_history_for(&battles_conn, &team.name)?; + + Ok(Json(HistoryResponse { + team_id: team.team_id, + name: team.name, + history, + })) +} + +async fn team_games( + State(state): State>, + Path(team_name): Path, +) -> ApiResult { + let decoded = decode_path_team(&team_name)?; + let teams_conn = open_db(&state.teams_db)?; + let battles_conn = open_db(&state.battles_db)?; + let team = + find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; + let games = games_for(&battles_conn, &team.name)?; + + Ok(Json(GamesResponse { + team_id: team.team_id, + name: team.name, + games, + })) +} + +async fn resolve_player( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let name = validate_player_name(&query.name)?; + let conn = open_db(&state.battles_db)?; + let players = player_resolve(&conn, name)?; + if players.is_empty() { + return Err(ApiError::not_found("Player not found")); + } + Ok(Json(PlayerSearchResponse { players })) +} + +async fn search_players( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or(""); + let name = validate_player_name(raw)?; + let limit = i64::from(query.limit.unwrap_or(25).clamp(1, 25)); + let conn = open_db(&state.battles_db)?; + let players = player_search(&conn, name, limit)?; + Ok(Json(PlayerSearchResponse { players })) +} + +async fn player_detail( + State(state): State>, + Path(uid): Path, +) -> ApiResult { + let uid = validate_uid(&uid)?; + let conn = open_db(&state.battles_db)?; + let career = + player_career_for(&conn, &uid)?.ok_or_else(|| ApiError::not_found("Player not found"))?; + let nick = latest_nick_for(&conn, &uid)?; + let teams = player_teams_for(&conn, &uid)?; + Ok(Json(PlayerProfile { + uid, + nick, + data_set: "tss", + career, + teams, + })) +} + +fn open_db(path: &FsPath) -> Result { + Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| { + ApiError::internal(format!("Could not open {}: {}", path.display(), error)) + }) +} + +fn db_error(error: rusqlite::Error) -> ApiError { + ApiError::internal(format!("Database query failed: {}", error)) +} + +fn read_team_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TeamRecord { + team_id: row.get(0)?, + name: row.get(1)?, + members: row.get(2)?, + captain_uid: row.get(3)?, + }) +} + +fn find_team(conn: &Connection, name: &str) -> Result, ApiError> { + // Return the most recent tournament entry for this team (highest team_id). + conn.query_row( + "SELECT team_id, name, members, captain_uid + FROM teams_data + WHERE name = ?2 COLLATE NOCASE OR team_id = ?1 + ORDER BY team_id DESC + LIMIT 1", + params![name.parse::().ok(), name], + read_team_record, + ) + .optional() + .map_err(db_error) +} + +fn team_summary_for(conn: &Connection, team_name: &str) -> Result { + conn.query_row( + "SELECT + COUNT(DISTINCT session_id), + COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), + COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END), + COALESCE(SUM(ground_kills), 0), + COALESCE(SUM(air_kills), 0), + COALESCE(SUM(assists), 0), + COALESCE(SUM(deaths), 0), + COALESCE(SUM(score), 0), + COUNT(DISTINCT UID) + FROM player_games_hist + WHERE team_name = ?1 COLLATE NOCASE", + params![team_name], + |row| { + let battles: i64 = row.get(0)?; + let wins: i64 = row.get(1)?; + let losses: i64 = row.get(2)?; + let ground: i64 = row.get(3)?; + let air: i64 = row.get(4)?; + let assists: i64 = row.get(5)?; + let deaths: i64 = row.get(6)?; + let score: i64 = row.get(7)?; + let player_count: i64 = row.get(8)?; + let total_kills = ground + air; + Ok(TeamSummary { + player_count, + total_battles: battles, + wins, + losses, + win_rate: percent(wins, battles), + kdr: ratio(total_kills, deaths), + total_kills, + total_points: score + assists + total_kills, + }) + }, + ) + .map_err(db_error) +} + +fn team_summaries_for( + conn: &Connection, + team_names: &[&str], +) -> Result, ApiError> { + if team_names.is_empty() { + return Ok(HashMap::new()); + } + + let placeholders = std::iter::repeat("?") + .take(team_names.len()) + .collect::>() + .join(", "); + let sql = format!( + "SELECT + team_name, + COUNT(DISTINCT session_id), + COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), + COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END), + COALESCE(SUM(ground_kills), 0), + COALESCE(SUM(air_kills), 0), + COALESCE(SUM(assists), 0), + COALESCE(SUM(deaths), 0), + COALESCE(SUM(score), 0), + COUNT(DISTINCT UID) + FROM player_games_hist + WHERE team_name COLLATE NOCASE IN ({placeholders}) + GROUP BY team_name COLLATE NOCASE" + ); + let mut stmt = conn.prepare(&sql).map_err(db_error)?; + + let summaries = stmt + .query_map(rusqlite::params_from_iter(team_names.iter()), |row| { + let name: String = row.get(0)?; + let battles: i64 = row.get(1)?; + let wins: i64 = row.get(2)?; + let losses: i64 = row.get(3)?; + let ground: i64 = row.get(4)?; + let air: i64 = row.get(5)?; + let assists: i64 = row.get(6)?; + let deaths: i64 = row.get(7)?; + let score: i64 = row.get(8)?; + let player_count: i64 = row.get(9)?; + let total_kills = ground + air; + Ok(( + name.to_ascii_lowercase(), + TeamSummary { + player_count, + total_battles: battles, + wins, + losses, + win_rate: percent(wins, battles), + kdr: ratio(total_kills, deaths), + total_kills, + total_points: score + assists + total_kills, + }, + )) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + Ok(summaries) +} + +fn cached_leaderboard( + state: &AppState, + limit: usize, +) -> Result>, ApiError> { + let cache = state + .leaderboard_cache + .lock() + .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; + Ok(cache.as_ref().and_then(|cached| { + (cached.teams.len() >= limit).then(|| cached.teams.iter().take(limit).cloned().collect()) + })) +} + +fn cache_leaderboard(state: &AppState, teams: &[TeamLeaderboardRow]) -> Result<(), ApiError> { + let mut cache = state + .leaderboard_cache + .lock() + .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; + *cache = Some(CachedLeaderboard { + teams: teams.to_vec(), + }); + Ok(()) +} + +fn player_summaries_for( + teams_conn: &Connection, + battles_conn: &Connection, + // team_id: roster for this specific tournament entry only + team_id: i64, + // team_name: cross-tournament stats key + team_name: &str, +) -> Result, ApiError> { + let mut stmt = teams_conn + .prepare( + "SELECT uid, role + FROM team_members + WHERE team_id = ?1 + ORDER BY uid", + ) + .map_err(db_error)?; + let members = stmt + .query_map(params![team_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + let mut stats_stmt = battles_conn + .prepare( + "SELECT + UID, + COUNT(DISTINCT session_id), + COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), + COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END), + COALESCE(SUM(ground_kills), 0), + COALESCE(SUM(air_kills), 0), + COALESCE(SUM(assists), 0), + COALESCE(SUM(deaths), 0), + (SELECT nick FROM player_games_hist + WHERE UID = p.UID AND nick IS NOT NULL + ORDER BY endtime_unix DESC LIMIT 1) + FROM player_games_hist p + WHERE team_name = ?1 COLLATE NOCASE + GROUP BY UID", + ) + .map_err(db_error)?; + + let mut summaries = stats_stmt + .query_map(params![team_name], |row| { + let uid: String = row.get(0)?; + let battles: i64 = row.get(1)?; + let wins: i64 = row.get(2)?; + let losses: i64 = row.get(3)?; + let ground: i64 = row.get(4)?; + let air: i64 = row.get(5)?; + let assists: i64 = row.get(6)?; + let deaths: i64 = row.get(7)?; + let nick: Option = row.get(8)?; + let total_kills = ground + air; + Ok(( + uid.clone(), + PlayerSummary { + uid, + nick, + role: String::new(), + total_battles: battles, + wins, + losses, + win_rate: percent(wins, battles), + total_kills, + ground_kills: ground, + air_kills: air, + assists, + deaths, + kdr: ratio(total_kills, deaths), + }, + )) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + let mut out = Vec::with_capacity(members.len()); + for (uid, role) in members { + let mut summary = summaries.remove(&uid).unwrap_or(PlayerSummary { + uid, + nick: None, + role: String::new(), + total_battles: 0, + wins: 0, + losses: 0, + win_rate: 0.0, + total_kills: 0, + ground_kills: 0, + air_kills: 0, + assists: 0, + deaths: 0, + kdr: 0.0, + }); + summary.role = role; + out.push(summary); + } + + out.sort_by(|a, b| b.total_battles.cmp(&a.total_battles)); + Ok(out) +} + +fn player_leaderboard_rows( + conn: &Connection, + limit: i64, +) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "WITH per_session AS ( + SELECT + UID, + session_id, + MAX(victor_bool) AS victor_bool, + MAX(ground_kills) AS ground_kills, + MAX(air_kills) AS air_kills, + MAX(assists) AS assists, + MAX(captures) AS captures, + MAX(deaths) AS deaths, + MAX(score) AS score, + MAX(endtime_unix) AS endtime_unix, + MAX(team_name) AS team_name + FROM player_games_hist + WHERE UID IS NOT NULL AND UID != '' + GROUP BY UID, session_id + ), + latest_names AS ( + SELECT UID, nick + FROM ( + SELECT + UID, + nick, + ROW_NUMBER() OVER ( + PARTITION BY UID + ORDER BY endtime_unix DESC, nick COLLATE NOCASE ASC + ) AS rn + FROM player_games_hist + WHERE nick IS NOT NULL AND nick != '' + ) + WHERE rn = 1 + ) + SELECT + p.UID, + n.nick, + COUNT(*) AS battles, + COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins, + COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses, + COALESCE(SUM(p.ground_kills), 0) AS ground_kills, + COALESCE(SUM(p.air_kills), 0) AS air_kills, + COALESCE(SUM(p.assists), 0) AS assists, + COALESCE(SUM(p.captures), 0) AS captures, + COALESCE(SUM(p.deaths), 0) AS deaths, + COALESCE(SUM(p.score), 0) AS score, + COUNT(DISTINCT p.team_name) AS teams_seen, + MAX(p.endtime_unix) AS last_seen + FROM per_session p + LEFT JOIN latest_names n ON n.UID = p.UID + GROUP BY p.UID + ORDER BY score DESC, (ground_kills + air_kills) DESC, battles DESC, p.UID ASC + LIMIT ?1", + ) + .map_err(db_error)?; + + let players = stmt + .query_map(params![limit], |row| { + let ground: i64 = row.get(5)?; + let air: i64 = row.get(6)?; + let battles: i64 = row.get(2)?; + let wins: i64 = row.get(3)?; + let deaths: i64 = row.get(9)?; + let total_kills = ground + air; + Ok(PlayerLeaderboardRow { + uid: row.get(0)?, + nick: row.get(1)?, + total_battles: battles, + wins, + losses: row.get(4)?, + win_rate: percent(wins, battles), + ground_kills: ground, + air_kills: air, + total_kills, + assists: row.get(7)?, + captures: row.get(8)?, + deaths, + score: row.get(10)?, + kdr: ratio(total_kills, deaths), + teams_seen: row.get(11)?, + last_seen: row.get(12)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + Ok(players) +} + +fn player_search(conn: &Connection, query: &str, limit: i64) -> Result, ApiError> { + let like = format!("%{}%", escape_like(query)); + let mut stmt = conn + .prepare( + "SELECT UID, MIN(nick) AS nick, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE nick LIKE ?1 ESCAPE '\\' COLLATE NOCASE + GROUP BY UID + ORDER BY + CASE WHEN MIN(nick) = ?2 COLLATE NOCASE THEN 0 ELSE 1 END, + last_seen DESC + LIMIT ?3", + ) + .map_err(db_error)?; + let players = stmt + .query_map(params![like, query, limit], |row| { + Ok(PlayerRef { + uid: row.get(0)?, + nick: row.get(1)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(players) +} + +fn player_resolve(conn: &Connection, name: &str) -> Result, ApiError> { + // Exact nick match first; fall back to substring search. + let mut stmt = conn + .prepare( + "SELECT UID, MIN(nick) AS nick + FROM player_games_hist + WHERE nick = ?1 COLLATE NOCASE + GROUP BY UID + ORDER BY nick + LIMIT 25", + ) + .map_err(db_error)?; + let exact = stmt + .query_map(params![name], |row| { + Ok(PlayerRef { + uid: row.get(0)?, + nick: row.get(1)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + if !exact.is_empty() { + return Ok(exact); + } + player_search(conn, name, 25) +} + +fn latest_nick_for(conn: &Connection, uid: &str) -> Result, ApiError> { + conn.query_row( + "SELECT nick FROM player_games_hist WHERE UID = ?1 ORDER BY endtime_unix DESC LIMIT 1", + params![uid], + |row| row.get::<_, Option>(0), + ) + .optional() + .map_err(db_error) + .map(|opt| opt.flatten()) +} + +fn player_career_for(conn: &Connection, uid: &str) -> Result, ApiError> { + // Player totals repeat across a player's per-vehicle rows for a session, so + // collapse to one value per session (MAX) before summing. + conn.query_row( + "SELECT + COUNT(*) AS battles, + COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins, + COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses, + COALESCE(SUM(gk), 0), COALESCE(SUM(ak), 0), + COALESCE(SUM(asi), 0), COALESCE(SUM(cap), 0), COALESCE(SUM(de), 0) + FROM ( + SELECT session_id, + MAX(victor_bool) AS victor_bool, + MAX(ground_kills) AS gk, MAX(air_kills) AS ak, + MAX(assists) AS asi, MAX(captures) AS cap, MAX(deaths) AS de + FROM player_games_hist + WHERE UID = ?1 + GROUP BY session_id + )", + params![uid], + |row| { + let battles: i64 = row.get(0)?; + let wins: i64 = row.get(1)?; + let losses: i64 = row.get(2)?; + let ground: i64 = row.get(3)?; + let air: i64 = row.get(4)?; + let assists: i64 = row.get(5)?; + let captures: i64 = row.get(6)?; + let deaths: i64 = row.get(7)?; + let total_kills = ground + air; + Ok(( + battles, + wins, + losses, + ground, + air, + assists, + captures, + deaths, + total_kills, + )) + }, + ) + .optional() + .map_err(db_error) + .map(|opt| { + opt.and_then( + |(battles, wins, losses, ground, air, assists, captures, deaths, total_kills)| { + if battles == 0 { + None + } else { + Some(PlayerCareer { + total_battles: battles, + wins, + losses, + win_rate: percent(wins, battles), + ground_kills: ground, + air_kills: air, + total_kills, + assists, + captures, + deaths, + kdr: ratio(total_kills, deaths), + }) + } + }, + ) + }) +} + +fn player_teams_for(conn: &Connection, uid: &str) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT team_id, MAX(team_name) AS team_name, + COUNT(DISTINCT session_id) AS games, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE UID = ?1 AND team_id IS NOT NULL + GROUP BY team_id + ORDER BY last_seen DESC", + ) + .map_err(db_error)?; + let teams = stmt + .query_map(params![uid], |row| { + Ok(PlayerTeamRef { + team_id: row.get(0)?, + team_name: row.get(1)?, + games: row.get(2)?, + last_seen: row.get(3)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(teams) +} + +fn period_history_for(conn: &Connection, team_name: &str) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT + strftime('%Y-%m', endtime_unix, 'unixepoch') AS period, + COUNT(DISTINCT session_id), + COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), + COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END) + FROM player_games_hist + WHERE team_name = ?1 COLLATE NOCASE AND endtime_unix > 0 + GROUP BY period + ORDER BY period ASC", + ) + .map_err(db_error)?; + + let rows = stmt + .query_map(params![team_name], |row| { + let period: String = row.get(0)?; + let battles: i64 = row.get(1)?; + let wins: i64 = row.get(2)?; + let losses: i64 = row.get(3)?; + Ok(PeriodHistory { + period, + battles, + wins, + losses, + win_rate: percent(wins, battles), + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn games_for(conn: &Connection, team_name: &str) -> Result, ApiError> { + // Reduce per-vehicle duplicate rows to one row per UID (MAX) before summing. + let mut stmt = conn + .prepare( + "WITH per_player AS ( + SELECT session_id, UID, + MAX(endtime_unix) AS endtime_unix, + MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won, + MAX(ground_kills) AS ground_kills, + MAX(air_kills) AS air_kills, + MAX(assists) AS assists, + MAX(captures) AS captures, + MAX(deaths) AS deaths, + MAX(score) AS score, + MAX(missile_evades) AS missile_evades, + MAX(shell_interceptions) AS shell_interceptions, + MAX(team_kills_stat) AS team_kills_stat + FROM player_games_hist + WHERE team_name = ?1 COLLATE NOCASE + GROUP BY session_id, UID + ) + SELECT + pp.session_id, + COALESCE(m.endtime_unix, MAX(pp.endtime_unix), 0) AS timestamp, + m.mission_name, + m.mission_mode, + m.tournament_name, + m.duration, + COALESCE(m.draw, 0), + CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result, + COUNT(*), + COALESCE(SUM(pp.ground_kills), 0), + COALESCE(SUM(pp.air_kills), 0), + COALESCE(SUM(pp.assists), 0), + COALESCE(SUM(pp.captures), 0), + COALESCE(SUM(pp.deaths), 0), + COALESCE(SUM(pp.score), 0), + COALESCE(SUM(pp.missile_evades), 0), + COALESCE(SUM(pp.shell_interceptions), 0), + COALESCE(SUM(pp.team_kills_stat), 0), + (SELECT pg.team_name + FROM player_games_hist pg + WHERE pg.session_id = pp.session_id + AND pg.team_name IS NOT NULL + AND pg.team_name != '' + AND pg.victor_bool = 'Win' + GROUP BY pg.team_name COLLATE NOCASE + ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE + LIMIT 1), + (SELECT pg.team_name + FROM player_games_hist pg + WHERE pg.session_id = pp.session_id + AND pg.team_name IS NOT NULL + AND pg.team_name != '' + AND pg.victor_bool = 'Loss' + GROUP BY pg.team_name COLLATE NOCASE + ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE + LIMIT 1) + FROM per_player pp + LEFT JOIN match_summary m ON m.session_id = pp.session_id + GROUP BY pp.session_id + ORDER BY timestamp DESC + LIMIT 100", + ) + .map_err(db_error)?; + + let rows = stmt + .query_map(params![team_name], |row| { + let session_id: String = row.get(0)?; + let timestamp: i64 = row.get(1)?; + let map_name: Option = row.get(2)?; + let mission_mode: Option = row.get(3)?; + let draw_int: i64 = row.get(6)?; + Ok(GameRow { + team_name: None, + session_id, + timestamp, + endtime_unix: timestamp, + map_name, + mission_mode, + result: row.get(7)?, + player_count: row.get(8)?, + winning_team: row.get(18)?, + losing_team: row.get(19)?, + tournament_id: None, + tournament_name: row.get(4)?, + duration: row.get(5)?, + draw: draw_int != 0, + stats: GameStats { + ground_kills: row.get(9)?, + air_kills: row.get(10)?, + assists: row.get(11)?, + captures: row.get(12)?, + deaths: row.get(13)?, + score: row.get(14)?, + missile_evades: row.get(15)?, + shell_interceptions: row.get(16)?, + team_kills_stat: row.get(17)?, + }, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiError> { + // One row per SESSION (not per team) so the battle-logs list shows each game + // once. Both team names come from the winner/loser subqueries; player_count is + // the larger team's size so the "NvN" label is per-side. + let mut stmt = conn + .prepare( + "WITH recent AS ( + SELECT session_id, MAX(endtime_unix) AS timestamp + FROM player_games_hist + WHERE team_name IS NOT NULL AND team_name != '' + GROUP BY session_id + ORDER BY timestamp DESC + LIMIT ?1 + ), + team_size AS ( + SELECT session_id, team_name, COUNT(DISTINCT UID) AS players + FROM player_games_hist + WHERE session_id IN (SELECT session_id FROM recent) + AND team_name IS NOT NULL AND team_name != '' + GROUP BY session_id, team_name COLLATE NOCASE + ) + SELECT + r.session_id, + COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp, + m.mission_name, + m.mission_mode, + m.tournament_name, + m.duration, + COALESCE(m.draw, 0), + (SELECT MAX(players) FROM team_size ts WHERE ts.session_id = r.session_id) AS player_count, + (SELECT pg.team_name + FROM player_games_hist pg + WHERE pg.session_id = r.session_id + AND pg.team_name IS NOT NULL + AND pg.team_name != '' + AND pg.victor_bool = 'Win' + GROUP BY pg.team_name COLLATE NOCASE + ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE + LIMIT 1), + (SELECT pg.team_name + FROM player_games_hist pg + WHERE pg.session_id = r.session_id + AND pg.team_name IS NOT NULL + AND pg.team_name != '' + AND pg.victor_bool = 'Loss' + GROUP BY pg.team_name COLLATE NOCASE + ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE + LIMIT 1) + FROM recent r + LEFT JOIN match_summary m ON m.session_id = r.session_id + ORDER BY timestamp DESC + LIMIT ?1", + ) + .map_err(db_error)?; + + let rows = stmt + .query_map(params![limit], |row| { + let timestamp: i64 = row.get(1)?; + let draw_int: i64 = row.get(6)?; + Ok(GameRow { + team_name: None, + session_id: row.get(0)?, + timestamp, + endtime_unix: timestamp, + map_name: row.get(2)?, + mission_mode: row.get(3)?, + result: String::new(), + player_count: row.get(7)?, + winning_team: row.get(8)?, + losing_team: row.get(9)?, + tournament_id: None, + tournament_name: row.get(4)?, + duration: row.get(5)?, + draw: draw_int != 0, + stats: GameStats { + ground_kills: 0, + air_kills: 0, + assists: 0, + captures: 0, + deaths: 0, + score: 0, + missile_evades: 0, + shell_interceptions: 0, + team_kills_stat: 0, + }, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + Ok(rows) +} + +fn tournaments_list(conn: &Connection) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT tournament_id, name, format, status, match_count, team_count, + date_start, date_end + FROM tournaments + ORDER BY COALESCE(date_end, scanned_unix, 0) DESC, tournament_id DESC + LIMIT 500", + ) + .map_err(db_error)?; + let rows = stmt + .query_map([], read_tournament_summary) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn tournament_summary_for( + conn: &Connection, + tid: i64, +) -> Result, ApiError> { + conn.query_row( + "SELECT tournament_id, name, format, status, match_count, team_count, + date_start, date_end + FROM tournaments WHERE tournament_id = ?1", + params![tid], + read_tournament_summary, + ) + .optional() + .map_err(db_error) +} + +fn read_tournament_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TournamentSummary { + tournament_id: row.get(0)?, + name: row.get(1)?, + format: row.get(2)?, + status: row.get(3)?, + match_count: row.get(4)?, + team_count: row.get(5)?, + date_start: row.get(6)?, + date_end: row.get(7)?, + }) +} + +fn tournament_match_rows_for( + conn: &Connection, + tid: i64, +) -> Result, ApiError> { + // Order: winner side, then final, then loser; group/swiss after. Within a + // side, by round then position (the schedule's round/matchNumber). NULLs last. + let mut stmt = conn + .prepare( + "SELECT match_id, type_bracket, side, round, position, + team_a_name, team_b_name, winner_name, score_a, score_b, status + FROM tournament_matches + WHERE tournament_id = ?1 + ORDER BY + CASE side WHEN 'winner' THEN 0 WHEN 'final' THEN 1 WHEN 'loser' THEN 2 + WHEN 'group' THEN 3 WHEN 'swiss' THEN 4 ELSE 5 END, + round IS NULL, round, + position IS NULL, position, + time_start, match_id", + ) + .map_err(db_error)?; + let rows = stmt + .query_map(params![tid], |row| { + Ok(TournamentMatchRow { + match_id: row.get(0)?, + type_bracket: row.get(1)?, + side: row.get(2)?, + round: row.get(3)?, + position: row.get(4)?, + team_a_name: row.get(5)?, + team_b_name: row.get(6)?, + winner_name: row.get(7)?, + score_a: row.get(8)?, + score_b: row.get(9)?, + status: row.get(10)?, + battles: Vec::new(), + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn tournament_standings_for( + conn: &Connection, + tid: i64, +) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT group_index, team_name, points, wins, draws, losses, buchholz, rank + FROM tournament_standings + WHERE tournament_id = ?1 + ORDER BY group_index, rank IS NULL, rank, points DESC", + ) + .map_err(db_error)?; + let rows = stmt + .query_map(params![tid], |row| { + Ok(TournamentStandingRow { + group_index: row.get(0)?, + team_name: row.get(1)?, + points: row.get(2)?, + wins: row.get(3)?, + draws: row.get(4)?, + losses: row.get(5)?, + buchholz: row.get(6)?, + rank: row.get(7)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +// Attach each match's battles and mark which ones we actually hold a replay for +// (session_hex present in match_summary over in the battles DB). +fn attach_battles( + conn: &Connection, + battles_db: &FsPath, + tid: i64, + matches: &mut [TournamentMatchRow], +) -> Result<(), ApiError> { + let mut index: HashMap<(String, String), usize> = HashMap::new(); + for (i, m) in matches.iter().enumerate() { + index.insert((m.match_id.clone(), m.type_bracket.clone()), i); + } + + let mut stmt = conn + .prepare( + "SELECT match_id, type_bracket, session_hex, position + FROM tournament_battles + WHERE tournament_id = ?1 + ORDER BY match_id, type_bracket, position IS NULL, position", + ) + .map_err(db_error)?; + let battles = stmt + .query_map(params![tid], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + let hexes: Vec = battles.iter().map(|(_, _, h, _)| h.clone()).collect(); + let held = held_session_ids(battles_db, &hexes)?; + + for (match_id, type_bracket, session_hex, position) in battles { + if let Some(&idx) = index.get(&(match_id, type_bracket)) { + let have_replay = held.contains(&session_hex); + matches[idx].battles.push(TournamentBattleRow { + session_hex, + position, + have_replay, + }); + } + } + Ok(()) +} + +fn held_session_ids( + battles_db: &FsPath, + hexes: &[String], +) -> Result, ApiError> { + let mut held = HashSet::new(); + if hexes.is_empty() { + return Ok(held); + } + let conn = open_db(battles_db)?; + for chunk in hexes.chunks(400) { + let placeholders = std::iter::repeat("?") + .take(chunk.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT session_id FROM match_summary WHERE session_id IN ({placeholders})" + ); + let mut stmt = conn.prepare(&sql).map_err(db_error)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(chunk.iter()), |row| { + row.get::<_, String>(0) + }) + .map_err(db_error)?; + for r in rows { + held.insert(r.map_err(db_error)?); + } + } + Ok(held) +} + + +fn game_for(conn: &Connection, session_id: &str) -> Result, ApiError> { + // player_games_hist stores one row per used vehicle per player, with the + // per-player stats duplicated across those rows. Reduce to one row per UID + // (MAX, since the values are identical) BEFORE summing team/game totals. + conn.query_row( + "SELECT + ?1 AS session_id, + COALESCE(m.endtime_unix, agg.timestamp, 0) AS timestamp, + m.mission_name, + m.mission_mode, + m.tournament_name, + m.duration, + COALESCE(m.draw, 0), + agg.player_count, + agg.ground_kills, + agg.air_kills, + agg.assists, + agg.captures, + agg.deaths, + agg.score, + agg.missile_evades, + agg.shell_interceptions, + agg.team_kills_stat, + (SELECT pg.team_name + FROM player_games_hist pg + WHERE pg.session_id = ?1 + AND pg.team_name IS NOT NULL + AND pg.team_name != '' + AND pg.victor_bool = 'Win' + GROUP BY pg.team_name COLLATE NOCASE + ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE + LIMIT 1), + (SELECT pg.team_name + FROM player_games_hist pg + WHERE pg.session_id = ?1 + AND pg.team_name IS NOT NULL + AND pg.team_name != '' + AND pg.victor_bool = 'Loss' + GROUP BY pg.team_name COLLATE NOCASE + ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE + LIMIT 1), + m.tournament_id + FROM ( + SELECT + COUNT(*) AS player_count, + MAX(endtime_unix) AS timestamp, + COALESCE(SUM(ground_kills), 0) AS ground_kills, + COALESCE(SUM(air_kills), 0) AS air_kills, + COALESCE(SUM(assists), 0) AS assists, + COALESCE(SUM(captures), 0) AS captures, + COALESCE(SUM(deaths), 0) AS deaths, + COALESCE(SUM(score), 0) AS score, + COALESCE(SUM(missile_evades), 0) AS missile_evades, + COALESCE(SUM(shell_interceptions), 0) AS shell_interceptions, + COALESCE(SUM(team_kills_stat), 0) AS team_kills_stat + FROM ( + SELECT UID, + MAX(endtime_unix) AS endtime_unix, + MAX(ground_kills) AS ground_kills, + MAX(air_kills) AS air_kills, + MAX(assists) AS assists, + MAX(captures) AS captures, + MAX(deaths) AS deaths, + MAX(score) AS score, + MAX(missile_evades) AS missile_evades, + MAX(shell_interceptions) AS shell_interceptions, + MAX(team_kills_stat) AS team_kills_stat + FROM player_games_hist + WHERE session_id = ?1 + GROUP BY UID + ) + ) agg + LEFT JOIN match_summary m ON m.session_id = ?1 + WHERE agg.player_count > 0", + params![session_id], + |row| { + let timestamp: i64 = row.get(1)?; + let draw_int: i64 = row.get(6)?; + Ok(GameRow { + team_name: None, + session_id: row.get(0)?, + timestamp, + endtime_unix: timestamp, + map_name: row.get(2)?, + mission_mode: row.get(3)?, + result: String::new(), + player_count: row.get(7)?, + winning_team: row.get(17)?, + losing_team: row.get(18)?, + tournament_id: row.get(19)?, + tournament_name: row.get(4)?, + duration: row.get(5)?, + draw: draw_int != 0, + stats: GameStats { + ground_kills: row.get(8)?, + air_kills: row.get(9)?, + assists: row.get(10)?, + captures: row.get(11)?, + deaths: row.get(12)?, + score: row.get(13)?, + missile_evades: row.get(14)?, + shell_interceptions: row.get(15)?, + team_kills_stat: row.get(16)?, + }, + }) + }, + ) + .optional() + .map_err(db_error) +} + +fn game_participants_for( + conn: &Connection, + session_id: &str, + state: &AppState, + lang: &str, +) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT + pp.team_name, + CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result, + COUNT(*), + COALESCE(SUM(pp.ground_kills), 0), + COALESCE(SUM(pp.air_kills), 0), + COALESCE(SUM(pp.assists), 0), + COALESCE(SUM(pp.captures), 0), + COALESCE(SUM(pp.deaths), 0), + COALESCE(SUM(pp.score), 0), + COALESCE(SUM(pp.missile_evades), 0), + COALESCE(SUM(pp.shell_interceptions), 0), + COALESCE(SUM(pp.team_kills_stat), 0) + FROM ( + SELECT UID, team_name, + MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won, + MAX(ground_kills) AS ground_kills, + MAX(air_kills) AS air_kills, + MAX(assists) AS assists, + MAX(captures) AS captures, + MAX(deaths) AS deaths, + MAX(score) AS score, + MAX(missile_evades) AS missile_evades, + MAX(shell_interceptions) AS shell_interceptions, + MAX(team_kills_stat) AS team_kills_stat + FROM player_games_hist + WHERE session_id = ?1 AND team_name IS NOT NULL AND team_name != '' + GROUP BY UID, team_name COLLATE NOCASE + ) pp + GROUP BY pp.team_name COLLATE NOCASE + ORDER BY + CASE WHEN MAX(pp.won) = 1 THEN 0 ELSE 1 END, + pp.team_name COLLATE NOCASE", + ) + .map_err(db_error)?; + + let mut participants = stmt + .query_map(params![session_id], |row| { + Ok(GameParticipant { + team_name: row.get(0)?, + result: row.get(1)?, + player_count: row.get(2)?, + stats: GameStats { + ground_kills: row.get(3)?, + air_kills: row.get(4)?, + assists: row.get(5)?, + captures: row.get(6)?, + deaths: row.get(7)?, + score: row.get(8)?, + missile_evades: row.get(9)?, + shell_interceptions: row.get(10)?, + team_kills_stat: row.get(11)?, + }, + players: Vec::new(), + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + for participant in &mut participants { + participant.players = + game_players_for(conn, session_id, &participant.team_name, state, lang)?; + } + + Ok(participants) +} + +fn game_players_for( + conn: &Connection, + session_id: &str, + team_name: &str, + state: &AppState, + lang: &str, +) -> Result, ApiError> { + // Stats are duplicated across a player's per-vehicle rows, so take MAX (not + // SUM) per UID. vehicle_internal collected (DISTINCT, comma-joined) for the lineup. + let mut stmt = conn + .prepare( + "SELECT + p.UID, + (SELECT pg.nick + FROM player_games_hist pg + WHERE pg.session_id = p.session_id + AND pg.UID = p.UID + AND pg.nick IS NOT NULL + AND pg.nick != '' + ORDER BY pg.endtime_unix DESC + LIMIT 1), + MAX(p.ground_kills), + MAX(p.air_kills), + MAX(p.assists), + MAX(p.captures), + MAX(p.deaths), + MAX(p.score), + MAX(p.missile_evades), + MAX(p.shell_interceptions), + MAX(p.team_kills_stat), + GROUP_CONCAT(DISTINCT p.vehicle_internal) + FROM player_games_hist p + WHERE p.session_id = ?1 AND p.team_name = ?2 COLLATE NOCASE + GROUP BY p.UID + ORDER BY MAX(p.score) DESC, p.UID", + ) + .map_err(db_error)?; + + let players = stmt + .query_map(params![session_id, team_name], |row| { + let cdks: Option = row.get(11)?; + let vehicles = cdks + .unwrap_or_default() + .split(',') + .map(|c| c.trim()) + .filter(|c| !c.is_empty()) + .map(|cdk| Vehicle { + name: lookup_vehicle_name(&state.vehicle_names, cdk, lang), + icon: lookup_vehicle_icon(&state.vehicle_icons, cdk), + cdk: cdk.to_string(), + }) + .collect(); + Ok(GamePlayer { + uid: row.get(0)?, + nick: row.get(1)?, + vehicles, + stats: GameStats { + ground_kills: row.get(2)?, + air_kills: row.get(3)?, + assists: row.get(4)?, + captures: row.get(5)?, + deaths: row.get(6)?, + score: row.get(7)?, + missile_evades: row.get(8)?, + shell_interceptions: row.get(9)?, + team_kills_stat: row.get(10)?, + }, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + Ok(players) +} + +fn validate_team_name(name: &str) -> Result<&str, ApiError> { + let trimmed = name.trim(); + if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH { + return Err(ApiError::bad_request( + "Team name must be 2 to 80 characters", + )); + } + Ok(trimmed) +} + +fn validate_player_name(name: &str) -> Result<&str, ApiError> { + let trimmed = name.trim(); + if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH { + return Err(ApiError::bad_request( + "Player name must be 2 to 80 characters", + )); + } + Ok(trimmed) +} + +fn validate_uid(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.len() > 32 || !trimmed.chars().all(|c| c.is_ascii_digit()) { + return Err(ApiError::bad_request("Invalid player UID")); + } + Ok(trimmed.to_string()) +} + +fn validate_tournament_id(value: &str) -> Result { + let trimmed = value.trim(); + match trimmed.parse::() { + Ok(id) if id > 0 => Ok(id), + _ => Err(ApiError::bad_request("Invalid tournament ID")), + } +} + +fn validate_session_id(value: &str) -> Result<&str, ApiError> { + if value.is_empty() + || value.len() > 96 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(ApiError::bad_request("Invalid game ID")); + } + + Ok(value) +} + +fn decode_path_team(value: &str) -> Result { + let decoded = urlencoding::decode(value) + .map_err(|_| ApiError::bad_request("Invalid team name"))? + .into_owned(); + validate_team_name(&decoded)?; + Ok(decoded) +} + +fn escape_like(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +fn percent(part: i64, total: i64) -> f64 { + if total <= 0 { + 0.0 + } else { + (part as f64 / total as f64) * 100.0 + } +} + +fn ratio(top: i64, bottom: i64) -> f64 { + if bottom <= 0 { + top as f64 + } else { + top as f64 / bottom as f64 + } +} + +fn env_u16(key: &str) -> Option { + env::var(key).ok()?.parse().ok() +} + +fn env_ip(key: &str) -> Option { + env::var(key).ok()?.parse().ok() +} + +fn allowed_origins() -> AllowOrigin { + let origins = env::var("BACKEND_ALLOWED_ORIGINS") + .or_else(|_| env::var("PUBLIC_ORIGIN")) + .unwrap_or_default() + .split(',') + .filter_map(|origin| { + let origin = origin.trim(); + if origin.is_empty() { + return None; + } + HeaderValue::from_str(origin).ok() + }) + .collect::>(); + + if origins.is_empty() { + AllowOrigin::list(Vec::::new()) + } else { + AllowOrigin::list(origins) + } +} + +// Vehicle cdk keys are lowercased on load and at lookup time so DB casing +// (e.g. "us_M4A2_76W_sherman" vs "us_m4a2_76w_sherman") never misses, mirroring +// the Python LangTableReader's case-insensitive behaviour. +fn load_vehicle_names(path: &FsPath) -> HashMap> { + let parsed: HashMap> = match fs::read_to_string(path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => HashMap::new(), + }; + parsed + .into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect() +} + +fn load_vehicle_icons(path: &FsPath) -> HashMap { + // vehicle_data_cache_all.json is [[cdk, name, icon, tags], ...] + let raw: Vec = match fs::read_to_string(path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => Vec::new(), + }; + let mut out = HashMap::new(); + for entry in raw { + if let (Some(cdk), Some(icon)) = ( + entry.get(0).and_then(|v| v.as_str()), + entry.get(2).and_then(|v| v.as_str()), + ) { + out.insert(cdk.to_lowercase(), icon.to_string()); + } + } + out +} + +fn lookup_vehicle_name( + names: &HashMap>, + cdk: &str, + lang: &str, +) -> String { + if let Some(by_lang) = names.get(&cdk.to_lowercase()) { + if let Some(n) = by_lang.get(lang) { + return n.clone(); + } + if let Some(n) = by_lang.get("en") { + return n.clone(); + } + } + cdk.to_string() +} + +fn lookup_vehicle_icon(icons: &HashMap, cdk: &str) -> String { + icons + .get(&cdk.to_lowercase()) + .cloned() + .unwrap_or_else(|| format!("{}.png", cdk.to_lowercase())) +} + +fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf { + let path = if let Ok(raw) = env::var(env_key) { + PathBuf::from(expand_home(&raw)) + } else if let Ok(storage) = env::var("STORAGE_VOL_PATH") { + PathBuf::from(expand_home(&storage)).join(default_file) + } else { + PathBuf::from(default_file) + }; + if path.is_absolute() { + path + } else { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } +} + +fn expand_home(value: &str) -> String { + if value == "~" { + return home_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| value.to_string()); + } + if let Some(rest) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + if let Some(home) = home_dir() { + return home.join(rest).display().to_string(); + } + } + value.to_string() +} + +fn home_dir() -> Option { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn load_root_env() { + let candidates = [ + env::current_dir().ok().map(|path| path.join(".env")), + env::current_dir() + .ok() + .and_then(|path| path.parent().map(|parent| parent.join(".env"))), + Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join(".env"), + ), + ]; + + for candidate in candidates.into_iter().flatten() { + if candidate.exists() { + load_env_file(&candidate); + return; + } + } +} + +fn load_env_file(path: &FsPath) { + let Ok(contents) = fs::read_to_string(path) else { + return; + }; + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some((key, value)) = trimmed.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() || env::var_os(key).is_some() { + continue; + } + let value = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + env::set_var(key, value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vehicle_name_prefers_lang_then_en_then_cdk() { + let mut names = HashMap::new(); + let mut t = HashMap::new(); + t.insert("en".to_string(), "T-34".to_string()); + t.insert("ru".to_string(), "Т-34".to_string()); + names.insert("ussr_t_34".to_string(), t); + assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "ru"), "Т-34"); + assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "de"), "T-34"); + assert_eq!( + lookup_vehicle_name(&names, "unknown_cdk", "en"), + "unknown_cdk" + ); + } + + #[test] + fn vehicle_icon_falls_back_to_cdk_png() { + let mut icons = HashMap::new(); + icons.insert("ussr_t_34".to_string(), "ussr_t_34.png".to_string()); + assert_eq!(lookup_vehicle_icon(&icons, "ussr_t_34"), "ussr_t_34.png"); + assert_eq!(lookup_vehicle_icon(&icons, "germ_pz"), "germ_pz.png"); + } + + #[test] + fn load_vehicle_icons_parses_cache_array() { + let dir = std::env::temp_dir(); + let p = dir.join("test_vehicle_cache_all.json"); + fs::write( + &p, + r#"[["ussr_t_34","T-34","ussr_t_34.png",{}],["germ_pz","Pz","germ_pz.png",{}]]"#, + ) + .unwrap(); + let icons = load_vehicle_icons(&p); + assert_eq!(icons.get("ussr_t_34").unwrap(), "ussr_t_34.png"); + assert_eq!(icons.len(), 2); + let _ = fs::remove_file(&p); + } +} diff --git a/example.env b/example.env new file mode 100644 index 0000000..d323c78 --- /dev/null +++ b/example.env @@ -0,0 +1,8 @@ +BACKEND_PORT=6000 +BACKEND_HOST=127.0.0.1 +BACKEND_ALLOWED_ORIGINS=https://example.com +TSS_BATTLES_DB=./tss_battles.db +TSS_TEAMS_DB=./tss_teams.db +TSS_TOURNAMENTS_DB=./tss_tournaments.db +VEHICLE_TRANSLATIONS_JSON=/mnt/HC_Volume_105581488/STORAGE/CACHE/vehicle_translations.json +VEHICLE_DATA_CACHE_JSON=/mnt/HC_Volume_105581488/STORAGE/CACHE/vehicle_data_cache.json