commit 270a9bea570a8e5258f62dad729898724dd75e89 Author: Eriks K Date: Mon Jul 4 10:44:45 2022 +0300 Init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1be0771 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +__pycache__ +*.spec +*.pyc +*.pyo +*.pyd +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesis +aviator_dumps/ +debug +dist/ +work/ +.git +inpoc1 +log +npp +nppd +scripts +venv diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0d351b6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 120 +line_length = 120 +multi_line_output = 3 +default_section = THIRDPARTY +recursive = true +skip = venv/ +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true + +[{*.json, *.sh}] +indent_style = space +indent_size = 2 + + +[Makefile] +indent_style = tab diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f3ac414 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +ignore = E203,E722,W503 +exclude = .git,__pycache__,venv,debug +max-line-length = 120 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8887195 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.idea/* +*__pycache__* +dist/* +html/* +work/* +*venv*/* +*.spec +bot.pid +*.log +*.txt.* +*.csv +*.csv.* +debug/ +log/ +ebot/debug +ebot/log +ebot/*.json +*.json +scripts +aviator_dumps diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..471710d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.9-alpine +ARG version=2022.3.3 +MAINTAINER Eriks K +LABEL version=$version + +WORKDIR /app +ENV PATH=$PATH:/app/.local/bin +COPY ./ebot /app/ebot +COPY setup.py requirements.txt /app/ + +RUN pip install -U --no-cache wheel pip setuptools \ + && python -OO /app/setup.py install \ + && mkdir player + +# && addgroup -S nocando \ +# && adduser -S nocando -G nocando \ +# && chown -R nocando:nocando /app \ +# && chmod -R 775 /app + +#USER nocando +#COPY entrypoint.sh /entrypoint.sh +#ENTRYPOINT ["/entrypoint.sh"] +WORKDIR /app/player +CMD ["python", "-O", "-m", "ebot"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..53d1f3d --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. 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 +them 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 prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. 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. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey 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; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + 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. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +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. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + 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 +state 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 3 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, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program 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, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU 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. But first, please read +. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1ab8b50 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +version = `python -c 'from ebot import __version__;print(__version__)'` + + +lint: + isort ebot + black ebot + flake8 ebot + + +version: lint + ./run.sh version + + +docker-build: lint + ./run.sh dockerbuild + + +docker-release: lint + ./run.sh dockerrelease diff --git a/README.md b/README.md new file mode 100644 index 0000000..99018e0 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# eBot + +Script uses [eeriks/eRepublik][1] package to automate eRepublik gameplay + +## How to run ebot +1. Install Docker/Docker Desktop +2. Create root folder where to place Your bots (eg `/home/user/ebot/`, `c:\\Users\user\ebot`) +3. Create a bot folder for each account inside root folder (eg `/home/user/ebot/player_a`, `c:\\Users\user\ebot\player_b`) +4. Copy `docker-compose.yaml` (text) file inside root folder (eg `/home/user/ebot/docker-compose.yaml`, `c:\\Users\user\ebot\docker-compose.yaml`) and change names accordingly: +```yaml +version: '3' + +services: + player_a: + image: ebot + volumes: + - ./player_a:/app/player + restart: always +``` +5. Build docker image: `docker build --build-arg version="development" --tag ebot .` +6. Create [config file for each account](https://eeriks.github.io/erepublik) and place them inside each of the bot's folder named `config.json` (eg `/home/user/ebot/player_a/config.json`, `c:\\Users\user\ebot\player_b\config.json`) +7. You're all done! To run the bots: + - Linux/Unix/MacOS: execute from the ebot root folder `docker-compose up -d` + - Windows: Somehow from the Desktop app run `docker-compose.yaml`. TBD + +## How to run from source +### Setup on Linux/Unix/MacOS +1. Install latest Python (minimum required version is 3.8) +2. Clone repository +``` bash +git clone git@bitbucket.org:keriks/erepublik-bot.git +cd erepublik-bot/ +``` +3. Create and activate virtual environment and install dependencies +``` bash +python3 -m venv venv +source venv/bin/activate +pip install -Ur requirements.txt +``` +4. Configure default `config.json` file or delete it and run +``` bash +python -m ebot +``` + +### Setup on Windows +1. Install requirements: + - [Python3][3] (minimum required version is 3.8) + - [Git][4] + +2. Clone repository (all future commands are meant to be run from `CMD` Start > Search cmd > Open +``` bash +git clone git@bitbucket.org:keriks/erepublik-bot.git +cd erepublik-bot/ +``` +3. Create and activate virtual environment and install dependencies +``` bash +python3 -m venv venv +venv\Scripts\activate +pip install -Ur requirements.txt +``` +4. Configure default `config.json` file or delete it and run +``` bash +python -m ebot +``` + +## Creating standalone executable +### Compilation on Linux/Unix/MacOS +``` bash +./upgrade.sh +./compile.sh +``` + +### Compilation on Windows +``` cmd +venv\Scripts\activate +compile.bat +``` + +## Running, updating standalone versions as services +### Running on Linux/Unix/MacOS + +``` bash +#!/bin/bash +#Change variable 'bot' to desired value +bot="player_name" +_bot_exe="bot_${bot}" +#Create separate folder for (each) account (-p don't warn if folder exists) +mkdir -p $bot + +# stop previous process to update executable +killall "$_bot_exe" + +# Sleep 3 seconds to allow bot to exit gracefully +sleep 3 + +# find latest versions name +newver=$(ls -dtr1 dist/* | tail -1) + +# Hard link latest version +cp -lfp "$newver" "${bot}/${_bot_exe}" +cd "${bot}" +./"${_bot_exe}" & +disown -h %1 +``` + +### Running on Windows +1. Double click executable to run bot with open console +2. As background service using NSSM: + 1. [Download NSSM][5] + 2. open cmd prompt and enter `nssm install ` replace `` with Your players name e.g., `nssm install bot_player_a` + 3. Follow instructions in [NSSM - usage][6] + + + +[1]: https://github.com/eeriks/erepublik +[2]: https://www.codacy.com?utm_source=bitbucket.org&utm_medium=referral&utm_content=keriks/erepublik-bot&utm_campaign=Badge_Grade +[3]: https://www.python.org/downloads/windows/ +[4]: https://git-scm.com/download/win +[5]: https://nssm.cc/download "NSSM - the Non-Sucking Service Manager" +[6]: https://nssm.cc/usage diff --git a/aviator_support.py b/aviator_support.py new file mode 100644 index 0000000..c3e0be1 --- /dev/null +++ b/aviator_support.py @@ -0,0 +1,88 @@ +""" eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + +import argparse +import logging +import os +import sys + +from erepublik._logging import ErepublikFormatter, ErepublikFileHandler, ErepublikLogConsoleHandler +from erepublik.classes import ErepublikException +from erepublik.utils import json + +from ebot.aviator_support import SupplierCitizen, LatvianSupply +from ebot.helpers import EbotErrorHttpHandler + +logger = logging.getLogger("Aviator") + +formatter = ErepublikFormatter() +file_handler = ErepublikFileHandler() +file_handler.setFormatter(formatter) +file_handler.setLevel(logging.INFO) +console_handler = ErepublikLogConsoleHandler() +console_handler.setFormatter(formatter) +console_handler.setLevel(logging.DEBUG) +logger.addHandler(file_handler) +error_handler = EbotErrorHttpHandler() +error_handler.setFormatter(formatter) +error_handler.setLevel(logging.ERROR) +logger.addHandler(error_handler) +logger.setLevel(logging.DEBUG) + +if __name__ == "__main__": + try: + parser = argparse.ArgumentParser() + parser.add_argument("-e", "--email", type=str, default="", help="Email address") + parser.add_argument("-p", "--password", type=str, default="", help="Password") + parser.add_argument("-c", "--config", type=str, default="./config.json", help="Config file to use") + parser.add_argument("-d", "--debug", default=False, action="store_true", help="Debug") + parser.add_argument("-r", "--provisional", default=False, action="store_true", help="Provisional") + parser.add_argument("-x", "--checkpoint", type=int, default=0, help="Checkpoint id") + args = parser.parse_args() + except Exception as e: + logger.error(str(e), exc_info=True, stack_info=True) + sys.exit(1) + player = SupplierCitizen("", "") + try: + player = SupplierCitizen.load_from_dump("SupplierCitizen__dump.json") + except Exception as e: + player.report_error(str(e)) + + if not player.logged_in: + if not args.email or not args.password: + with open(args.config, "r") as f: + configs = json.load(f) + e = configs["email"] + p = configs["password"] + del f, configs + else: + e = args.email + p = args.password + + player = SupplierCitizen(e, p) + del e, p + + player.set_debug(bool(args.debug or args.provisional)) + try: + player.update() + player.dump_instance() + supply = LatvianSupply(player, args.debug, args.provisional) + supply(checkpoint_id=args.checkpoint) + except ErepublikException as e: + print(e) + player.report_error(str(e)) + except Exception as e: # noqa + player.report_error(str(e)) diff --git a/compile.bat b/compile.bat new file mode 100644 index 0000000..f917d93 --- /dev/null +++ b/compile.bat @@ -0,0 +1 @@ +pyinstaller --noupx -i favicon.ico -F -c --workpath work --add-data LICENSE;LICENSE.txt -n bot_v2022.3.3 ebot/__main__.py diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4421c48 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,15 @@ +version: '3' + +services: + player_a: + image: ebot + volumes: + - /app/player + - ./player_a/config.json:/app/player/config.json + restart: always + player2: + image: ebot + volumes: + - /app/player + - ./player2/config.json:/app/player/config.json + restart: always diff --git a/ebot/__deprecated.py b/ebot/__deprecated.py new file mode 100644 index 0000000..0d431b2 --- /dev/null +++ b/ebot/__deprecated.py @@ -0,0 +1,512 @@ +# def task_fight(self): +# if self.restricted_ip: +# self.tasks.pop("fight") +# return +# elif self.now > utils.localize_dt(datetime(2021, 2, 8)): +# self.write_warning("Fight is now disabled") +# self.tasks.pop("fight") +# return +# +# self.write_log(self.health_info) +# +# count, log_msg, force_fight = self.should_fight(False) +# +# if count or force_fight: +# fought = False +# if self.tasks.get_default("cheap_medals", False): +# cheap_medals = self.get_cheap_tp_divisions() +# air_div = ground_div = None +# if self.config.air and cheap_medals["air"]: +# air_div = cheap_medals["air"][0] +# if self.config.ground and cheap_medals["ground"]: +# ground_div = cheap_medals["ground"][0] +# if air_div and (not air_div[0] or not self.config.ground): +# div_data = air_div +# else: +# div_data = ground_div +# if div_data: +# medal_damage, division = div_data +# self.write_log(f"Chose {division} with {medal_damage}dmg medal") +# if self.change_division(division.battle, division): +# side: BattleSide +# if division.battle.defender.country == self.details.citizenship: +# side = division.battle.defender +# else: +# side = division.battle.invader +# +# air = int(division.is_air) +# tgt_dmg = DMG_MAP[division.div][side.is_defender] +# hit = self.get_air_hit_dmg_value() if air else self.get_ground_hit_dmg_value() +# +# tgt_dmg = tgt_dmg if tgt_dmg > medal_damage else medal_damage +# +# hits = min((self.energy.food_fights - 5, max((count, int(tgt_dmg / hit) + 1)))) +# self.set_default_weapon(division.battle, division) +# +# if self.details.current_country not in side.deployed + [side.country]: +# self.travel_to_battle(division.battle, side.deployed + [side.country]) +# fought = bool(self.fight(division.battle, division, side, hits + 2)) +# battle_count = len(cheap_medals["air"]) + len(cheap_medals["ground"]) +# self.report_action( +# "CHEAP MEDAL CHOOSER", +# f"Had {battle_count} options and chose {div_data}", +# kwargs=dict(cheap_medals=cheap_medals), +# ) +# if not fought: +# self.find_battle_and_fight() +# +# self.travel_to_residence() +# self.update_weekly_challenge() +# +# self.tasks["fight"] = self._get_next_fight_time() + +# def _get_required_fight_energy(self) -> int: +# def _check_energy(__ec: List[Tuple[int, str]], __ne: int, __msg: str) -> int: +# __ec.append((__ne, __msg)) +# return min(__ec, key=lambda _x: _x[0])[0] +# +# e_checks: List[Tuple[int, str]] = [] +# # Default - fight when full energy +# energy = self.energy.limit * 2 - self.energy.interval * 2 +# e_checks.append((energy, f"Full energy: {self.energy.limit} * 2 - {self.energy.interval} * 2")) +# +# if self.is_levelup_reachable: +# energy = 2 * (self.energy.limit - self.energy.interval) +# msg = f"Levelup reachable: 2 * ({self.energy.limit} - {self.energy.interval})" +# e_checks.append((energy, msg)) +# elif self.is_levelup_close: +# energy = (self.details.xp_till_level_up + 5) * 10 - self.energy.limit +# msg = f"Near level up: ({self.details.xp_till_level_up} + 5) * 10 - {self.energy.limit}" +# e_checks.append((energy, msg)) +# else: +# # Obligatory need 75pp +# if self.details.pp < 75: +# energy = _check_energy(e_checks, 75 - self.details.pp, f"Obligatory need 75pp: 75 - {self.details.pp}") +# +# # Continuous fighting +# if self.config.continuous_fighting and self.has_battle_contribution: +# energy = _check_energy(e_checks, self.energy.interval, f"Continuous_fighting: {self.energy.interval}") +# +# # All-in for AIR battles +# if self.config.all_in: +# needed = self.energy.limit * 2 - self.energy.interval * 3 +# energy = _check_energy(e_checks, needed, f"All-in: {needed}") +# +# # Get to next Energy +1 +# if self.config.next_energy and self.details.next_pp: +# self.details.next_pp.sort() +# energy = _check_energy( +# e_checks, +# (self.details.next_pp[0] - self.details.pp) * 10, +# f"Get to next Energy +1: ({self.details.next_pp}[0] - {self.details.pp}) * 10", +# ) +# for e, i in e_checks: +# self.logger.debug(f"{e}hp {i}") +# return energy + +# def _get_next_fight_time(self) -> datetime: +# needed_energy = self._get_required_fight_energy() - self.energy.energy +# if needed_energy > 0: +# next_minutes = max([6, needed_energy // self.energy.interval * 6]) +# else: +# next_minutes = 0 +# next_wc_start_fight_time = _norm(self.next_wc_start + timedelta(minutes=6)) +# next_fight_time = _norm(self.energy.reference_time + timedelta(minutes=next_minutes)) +# +# return min((next_wc_start_fight_time, next_fight_time)) + +# def task_epic_hunt(self): +# if self.restricted_ip: +# self.tasks.pop("epic_hunt") +# return +# elif self.now > utils.localize_dt(datetime(2021, 2, 8)): +# self.write_warning("Fight is now disabled") +# self.tasks.pop("fight") +# return +# +# self.update_war_info() +# epic_divs: Generator[BattleDivision, Any, None] = ( +# div for battle in self.all_battles.values() for div in battle.div.values() if div.epic +# ) +# +# for div in epic_divs: +# if div.div_end: +# continue +# air = bool(div.is_air and self.config.air) +# ground = bool(self.config.ground and bool(div.div == self.division or self.maverick)) +# if air or ground: +# target_div = div +# battle = target_div.battle +# invaders = [battle.invader.country] + battle.invader.deployed +# side = battle.invader if self.details.citizenship in invaders else battle.defender +# self.change_division(target_div.battle, target_div) +# +# if self.details.current_country not in side.deployed + [side.country]: +# self.travel_to_battle(battle, side.deployed + [side.country]) +# self.fight(target_div.battle, target_div, side, self.energy.food_fights) +# self.travel_to_residence() +# break + +# def _get_hits_for_dmg(self, is_tp: bool, damage: int) -> int: +# booster = self.get_active_ground_damage_booster() +# booster_kwargs = {} +# if booster: +# booster_kwargs.update({f"booster_{booster}": True}) +# hit_dmg = self.get_ground_hit_dmg_value(tp=is_tp, **booster_kwargs) +# return int(damage / hit_dmg + 1) + + +# def get_medal_damage(self, division: BattleDivision, side: BattleSide): +# medal = self.get_battle_round_data(division) +# medal = medal[side.is_defender] +# if medal: +# return medal.get("1").get("raw_value") +# return 0 + +# def _pay_for_hit(self, division: BattleDivision, is_defender: bool): +# hit_dmg = self.get_division_max_hit(division) +# max_hit_fighter_id = None +# hit_price = self._default_pay_table[division.div] +# if not division.div == self.division and hit_dmg != 10_000: +# data = self.get_battle_round_data(division)[is_defender] +# for fighter in data.values(): +# hover_card_data = self._get_main_citizen_hovercard(fighter.get("citizenId")).json() +# hc_md = hover_card_data["fighterInfo"]["military"] +# if not hc_md["division"] == division.div: +# continue +# base_hit = hc_md["damagePerHit"] +# manual_base_hit = utils.calculate_hit( +# hc_md["strength"], +# hc_md["rank"], +# hover_card_data["citizenship"]["id"] == self.details.citizenship, +# False, +# False, +# ) +# if int(hit_dmg) in [int(base_hit), int(manual_base_hit)] or abs(base_hit - hit_dmg) <= 2: +# max_hit_fighter_id = fighter.get("citizenId") +# break +# else: +# for fighter in data.values(): +# hover_card_data = self._get_main_citizen_hovercard(fighter.get("citizenId")).json() +# if hover_card_data["fighterInfo"]["military"]["division"] == division.div: +# max_hit_fighter_id = fighter.get("citizenId") +# break +# else: +# self.report_error("Unable to find player to whom I should pay for the hit!") +# if max_hit_fighter_id: +# if max_hit_fighter_id in self._preset_pay_table: +# hit_price = self._preset_pay_table[max_hit_fighter_id] +# else: +# fighter_profile = self.get_citizen_profile(max_hit_fighter_id) +# about_me_price = re.findall(r"(\d{3,})", fighter_profile["aboutMe"]) +# if about_me_price: +# hit_price = int(min(about_me_price, key=int)) +# if max_hit_fighter_id in self._preset_pay_table: +# if self._hits_to_pay[max_hit_fighter_id] + hit_price < 10 * hit_price and not self.stop_threads.is_set(): +# self._hits_to_pay[max_hit_fighter_id] += hit_price +# hit_price = 0 +# self.report_action( +# "PAYMENT_AGGREGATION", +# f"Current debt for player #{max_hit_fighter_id} is {self._hits_to_pay[max_hit_fighter_id]}cc", +# _hits_to_pay=self._hits_to_pay, +# ) +# else: +# hit_price = self._hits_to_pay[max_hit_fighter_id] + hit_price +# self.report_action( +# "PAYMENT_AGGREGATION", +# f"Debt for player #{max_hit_fighter_id} is cleared ({hit_price}cc)", +# _hits_to_pay=self._hits_to_pay, +# ) +# self._hits_to_pay[max_hit_fighter_id] = 0 +# if hit_price and max_hit_fighter_id: +# # self.write_log(f"self.donate_money({max_hit_fighter_id}, {hit_price}, 1)") +# self.donate_money(max_hit_fighter_id, hit_price, 1) +# return True +# return False + +# def _sub_task_fight_in_empty_battle(self, queue: List[_EmptyMedal], _processed_divisions: Set[int]): +# while not self.stop_threads.is_set(): +# try: +# if self.stop_threads.is_set(): +# return +# try: +# queue.sort(key=attrgetter("time", "round", "division_id")) +# next_medal = queue.pop(0) +# except IndexError: +# next_medal = None +# +# if next_medal is not None: +# self.stop_threads.wait(utils.get_sleep_seconds(next_medal.time)) +# if self.stop_threads.is_set(): +# break +# elif self.now > utils.localize_dt(datetime(2021, 2, 8)): +# self.write_warning("Fight is now disabled") +# return +# try: +# self.update_war_info() +# self._update_lock.clear() +# try: +# battle: Optional[Battle] = self.all_battles[next_medal.battle_id] +# except KeyError: +# self.report_error( +# f"Battle '{next_medal.battle_id}' not found in all_battles " +# f"(Medal data: {next_medal})", +# sentry=False, +# ) +# self.report_action( +# "EMPTY_BH_ERROR", +# f"Error while checking {next_medal.battle_id}\n" +# f"https://www.erepublik.com/en/military/battlefield/{next_medal.battle_id}", +# ) +# raise ErepublikException("No battle data!") +# +# if battle is None: +# raise ErepublikException("No battle or division data!") +# elif battle.zone_id != next_medal.round: +# raise ErepublikException("Wrong battle round!") +# +# try: +# division: Optional[BattleDivision] = battle.div[next_medal.division_id] +# except KeyError: +# self.report_error( +# f"Division '{next_medal.division_id}' not found in " +# f"battle.div (Medal data: {next_medal})", +# sentry=False, +# ) +# self.report_action("EMPTY_BH_ERROR", f"Error while checking {repr(battle)}\n{battle.link}") +# raise ErepublikException("No division data!") +# +# if division is None: +# raise ErepublikException("No battle or division data!") +# elif division.is_air: +# raise ErepublikException("Air battle!") +# elif division.div_end: +# raise ErepublikException("Division finished") +# +# if not self.maverick and not division.div == self.division: +# raise ErepublikException("Don't have a MP can't fight in non native divisions!") +# side = battle.defender if next_medal.defender_side else battle.invader +# +# try: +# if self.details.current_country not in side.deployed + [side.country] or battle.is_rw: +# self.change_division(battle, division, side) +# medal_damage = self.get_medal_damage(division, side) +# except AttributeError: +# battle = self.all_battles.get(next_medal.battle_id) +# side = battle.defender if next_medal.defender_side else battle.invader +# if self.details.current_country not in side.deployed + [side.country] or battle.is_rw: +# self.change_division(battle, division, side) +# medal_damage = self.get_medal_damage(division, side) +# +# if medal_damage <= DMG_MAP[division.div][next_medal.defender_side] / 2: +# damage_amount = DMG_MAP[division.div][next_medal.defender_side] +# if division.div != self.division: # Maverick +# hit = self.get_division_max_hit(division) +# if hit > 200_000: +# my_profile = self._get_main_citizen_profile_json(self.details.citizen_id).json() +# rang = my_profile["military"]["militaryData"]["ground"]["rankNumber"] +# active_booster = self.get_active_ground_damage_booster() +# hit_dmg = utils.get_final_hit_dmg( +# hit, rang, True, self.details.is_elite, booster=active_booster +# ) +# ground_hits = int(damage_amount / hit_dmg + Decimal("0.49")) +# else: +# ground_hits = self.energy.limit * 2 +# +# else: +# ground_hits = self._get_hits_for_dmg(True, damage_amount) +# fight = False +# hits_ok = self.my_companies.ff_lockdown < ground_hits < self.energy.food_fights +# if not hits_ok and division.div != self.division: +# hits_ok = self.my_companies.ff_lockdown < ground_hits < self.energy.food_fights +# if hits_ok: +# self._update_lock.set() +# self.change_division(battle, division, side) +# fight = bool(self.fight(battle, division, side, ground_hits)) +# if fight and not division.div == self.division: +# self._pay_for_hit(division, side.is_defender) +# elif next_medal.second_attempt: +# bombs = self.inventory.final.get("bomb", {}) +# if 215 in bombs: +# bomb_id = 215 +# bomb_data = bombs.get(215) +# for bomb_id, bomb_data in bombs.items(): +# if (bomb_id == 21 and division.div != self.division) or bomb_id == 216: +# continue +# if bomb_data.get("fire_power"): +# bombs_required = int(damage_amount / bomb_data.get("fire_power")) +# if bomb_data.get("amount") >= bombs_required: +# self._update_lock.set() +# fight = True +# if self.details.current_country not in side.deployed + [side.country]: +# self.travel_to_battle(battle, side.deployed + [side.country]) +# self.change_division(battle, division) +# bombs_deployed = self.deploy_bomb( +# battle, division, bomb_id, not side.is_defender, bombs_required +# ) +# self.report_action( +# "CLEARED_MEDAL", +# f"Deployed {bombs_deployed} bombs in " +# f"{division.div} in battle {battle.id}", +# extra_info=dict( +# battle=battle, +# division=division, +# next_medal=next_medal, +# bombs_deployed=bombs_deployed, +# bomb_data=bomb_data, +# ), +# ) +# # break +# if fight: +# self.report_action( +# "EMPTY_BH", f"Cleared empty d{division.div} BH in {battle}", battle=battle.as_dict +# ) +# elif not next_medal.second_attempt: +# next_medal.second_attempt = True +# next_medal.time = _norm(self.now + timedelta(minutes=10)) +# queue.append(next_medal) +# else: +# if division.id in _processed_divisions: +# _processed_divisions.remove(division.id) +# +# except ErepublikException: +# continue +# except: # noqa +# self.report_error(f"Thread {threading.current_thread().name} ran into an error") +# finally: +# self._update_lock.set() +# else: +# self.travel_to_residence() +# self.stop_threads.wait(60) +# battle = division = side = damage_amount = bombs = ground_hits = None # noqa +# bombs_required = bombs = fight = bombs_deployed = next_medal = None # noqa +# except: # noqa +# self.report_error(f"Thread {threading.current_thread().name} ran into an error") +# +# +# def background_task_clear_bhs(self, minute: Union[int, bool]): +# _processed_divisions: Set[int] = set() +# if self.now > utils.localize_dt(datetime(2021, 2, 8)): +# self.write_warning("Fight is now disabled") +# return + +# if minute: +# try: +# minute = 15 if isinstance(minute, bool) else int(minute) +# except ValueError: +# return +# else: +# return +# next_check: datetime = self.now +# next_check: datetime = _norm(next_check.replace(minute=next_check.minute // 5 * 5, second=0)) +# queue: List[_EmptyMedal] = [] +# +# empty_bh_thread = threading.Thread( +# target=self._sub_task_fight_in_empty_battle, +# args=(queue, _processed_divisions), +# name=self.thread_name("empty_bh_queue"), +# ) +# empty_bh_thread.start() +# self._bg_task_queue.append(empty_bh_thread) +# +# while not self.stop_threads.is_set(): +# if self.now > utils.localize_dt(datetime(2021, 2, 8)): +# self.write_warning("Fight is now disabled") +# return +# try: +# self.update_war_info() +# # Check TP battles +# tp_battles = self.sorted_battles(True, True) +# +# # Removes old division ids from processed division ids set {1,2,3}.inter_update({3,4,5}) == {3} +# _processed_divisions.intersection_update({d for b in tp_battles for d in b.div.keys()}) +# for battle in tp_battles: +# if self.details.citizenship.id in [battle.defender.id, battle.invader.id]: +# if battle.has_air: +# continue +# else: +# fight_time = _norm(battle.start + timedelta(minutes=minute)) +# defender_side = battle.defender.id == self.details.citizenship.id +# battle_stats = self.get_battle_division_stats(list(battle.div.values())[0]) +# try: +# round_stats = battle_stats.get("stats").get("current").get(f"{battle.zone_id}") +# for div in battle.div.values(): +# td = ( +# round_stats.get(str(div.div)) +# .get(str(self.details.citizenship.id)) +# .get(str(div.id)) +# .get("top_damage") +# ) +# if td and not td[0].get("sector", ""): +# td = td[0] +# div = battle.div[td["battle_zone_id"]] +# empty_medal = _EmptyMedal( +# fight_time, battle.id, div.id, defender_side, battle.zone_id +# ) +# if ( +# td["damage"] <= DMG_MAP[div.div][defender_side] / 2 +# and empty_medal not in queue +# ): +# queue.append(empty_medal) +# queue.sort( +# key=attrgetter( +# "time", "defender_side", "round", "battle_id", "division_id" +# ) +# ) +# except Exception: +# pass +# for div in battle.div.values(): +# if div.id in _processed_divisions: +# continue +# else: +# _processed_divisions.add(div.id) +# empty_medal = _EmptyMedal(fight_time, battle.id, div.id, defender_side, battle.zone_id) +# side = battle.defender if defender_side else battle.invader +# if empty_medal in queue: +# continue +# else: +# try: +# medal_damage = self.get_medal_damage(div, side) +# except AttributeError: +# battle = self.all_battles.get(battle.id) +# div: BattleDivision = battle.div[div.id] +# side = battle.defender if defender_side else battle.invader +# medal_damage = self.get_medal_damage(div, side) +# if medal_damage <= DMG_MAP[div.div][defender_side] / 2 and empty_medal not in queue: +# queue.append(empty_medal) +# queue.sort( +# key=attrgetter("time", "defender_side", "round", "battle_id", "division_id") +# ) +# else: +# break +# +# next_check = _norm(next_check + timedelta(minutes=5)) +# self.stop_threads.wait(utils.get_sleep_seconds(next_check)) +# except Exception as e: +# self.report_error(f"Task error: empty_tp_bh_hunter {e.args}") +# empty_bh_thread.join() +# return + + +# def get_cheap_tp_divisions(self) -> Dict[str, List[Tuple[int, BattleDivision]]]: +# ret = super().get_cheap_tp_divisions() +# real_return = {"ground": [], "air": []} +# for dmg, division in ret["ground"]: +# if division.div == self.division: +# real_return["ground"].append((dmg, division)) +# real_return["air"] = ret["air"] +# return real_return + +# def fight(self, battle: classes.Battle, division, side=None, count=None, use_ebs=False) -> Optional[int]: +# if self.inventory.final.get("other", {}).get(3, {}).get("amount", 0) > 10: +# self.activate_battle_effect(battle.id, "snowFight") +# if not self.inventory.active.get("prestige_points"): +# ppb = self.inventory.boosters.get("prestige_points", {}) +# for _q in sorted(ppb): +# _qd = ppb.get(_q) +# for _dur in sorted(_qd): +# pp_boost: types.InvFinalItem = _qd[_dur] +# if pp_boost.get("expiration") and pp_boost.get("amount") > 10: +# self.activate_pp_booster(pp_boost) +# +# return super().fight(battle, division, side, count, use_ebs) diff --git a/ebot/__init__.py b/ebot/__init__.py new file mode 100644 index 0000000..49dd7c8 --- /dev/null +++ b/ebot/__init__.py @@ -0,0 +1,22 @@ +"""Package for automating erepublik tasks and whole gameplay""" + +__author__ = """Eriks K""" +__email__ = "ebot@72.lv" +__version__ = "2022.3.3" + +__copyright__ = """ eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + diff --git a/ebot/__main__.py b/ebot/__main__.py new file mode 100644 index 0000000..fb3fdf2 --- /dev/null +++ b/ebot/__main__.py @@ -0,0 +1,160 @@ +""" eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + +import logging +import os +import signal +import sys +import time +import warnings +from typing import Dict, List, Union + +import httpx +import sentry_sdk +from erepublik import utils +from erepublik._logging import ErepublikFileHandler, ErepublikFormatter, ErepublikLogConsoleHandler + +from ebot import __version__ +from ebot.helpers import BotRestart, BotStop, EbotErrorHttpHandler +from ebot.main import main +from ebot.utils import parse_config, pid_alive + +json = utils.json +logger = logging.getLogger("Player") + +VERSION = __version__ +EVERSION = utils.VERSION + +sentry_sdk.init( + "https://8ae666ac1cc344b3ab4a8ba6d3d1c420@o334571.ingest.sentry.io/4686978", + release=f"{VERSION} (using eRepublik {EVERSION})", + with_locals=True, + auto_enabling_integrations=False, +) + +sentry_sdk.set_tag("eRepublik", EVERSION) +sentry_sdk.set_tag("eBot", VERSION) +formatter = ErepublikFormatter() +file_handler = ErepublikFileHandler() +file_handler.setFormatter(formatter) +logger.addHandler(file_handler) +error_handler = EbotErrorHttpHandler() +error_handler.setFormatter(formatter) +error_handler.setLevel(logging.ERROR) +logger.addHandler(error_handler) +logger.setLevel(logging.INFO) + + +INTERACTIVE = True +CONFIG: Dict[str, Union[str, int, bool, List[str], Dict[str, Dict[str, Union[bool, List[int]]]]]] = {} + + +def def_sig(a, b): # noqa + raise BotStop() + + +signal.signal(signal.SIGINT, def_sig) +signal.signal(signal.SIGTERM, def_sig) +signal.signal(signal.SIGABRT, def_sig) +if sys.platform.startswith("win"): + signal.signal(signal.SIGBREAK, def_sig) +else: + signal.signal(signal.SIGUSR1, def_sig) + signal.signal(signal.SIGHUP, def_sig) + +if __name__ == "__main__": + os.makedirs("log", exist_ok=True) + if not sys.version_info >= (3, 8): + raise AssertionError( + "This script requires Python version 3.8 and higher\n" + "But Your version is v{}.{}.{}".format(*sys.version_info) + ) + try: + # os.chdir(os.path.dirname(os.path.realpath(__file__))) + if os.path.isfile("bot.pid"): + with open("bot.pid") as f: + old_pid = f.read() + if old_pid.isnumeric(): + old_pid = int(old_pid) + try: + os.kill(old_pid, 15) + except: # noqa + pass + while pid_alive(old_pid) and not sys.platform.startswith("win"): + time.sleep(1) + with open("bot.pid", "w") as f: + f.write(f"{os.getpid()}") + config_location = "config.json" + if os.path.isfile(config_location): + with open(config_location, "r") as f: + CONFIG.update(json.load(f)) + + logger.info("Config file found. Checking...") + should_save = parse_config(CONFIG) + else: + try: + should_save = parse_config(CONFIG) + except EOFError: + logger.error("Unable to read input for config file!\nTerminating...") + raise BotStop() + if should_save: + with open(config_location, "w") as f: + json.dump(CONFIG, f, indent=True) + + if CONFIG["interactive"]: + console_handler = ErepublikLogConsoleHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + if CONFIG.get("debug"): + logger.setLevel(logging.DEBUG) + for handler in logger.handlers: + if isinstance(handler, (ErepublikLogConsoleHandler, ErepublikFileHandler)): + handler.setLevel(logging.DEBUG) + warnings.simplefilter("default") # Change the filter in this process + os.environ["PYTHONWARNINGS"] = "default" # Also affect subprocesses + + logger.info("To quit press [ctrl] + [c]") + logger.info(f"Version: {VERSION} (elib {EVERSION})") + + while True: + try: + main(CONFIG) + except BotRestart: + continue + logger.error("Restarting after 1h") + try: + utils.interactive_sleep(60 * 60) + except (OSError, EOFError): + utils.silent_sleep(60 * 60) + except BotStop: + logger.info("Everything is done! Hope You enjoyed!") + utils.silent_sleep(1) + except httpx.ConnectError: + logger.critical("Connection Error! Can't continue, will quit!", exc_info=True) + utils.silent_sleep(1) + except Exception as e: # noqa + logger.critical( + f"Fatal error. {e}", + exc_info=True, + stack_info=True, + extra=dict(ebot_version=VERSION, erep_version=EVERSION), + ) + sentry_sdk.capture_exception(e) + finally: + if os.path.isfile("bot.pid"): + os.unlink("bot.pid") + sys.exit(0) diff --git a/ebot/aviator_support.py b/ebot/aviator_support.py new file mode 100644 index 0000000..9b82279 --- /dev/null +++ b/ebot/aviator_support.py @@ -0,0 +1,1058 @@ +""" eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + +import datetime +import os +import pathlib +import re +import sys +from io import BytesIO +from itertools import product +from operator import attrgetter +from typing import Any, Dict, List, Set, Union + +import httpx +from cairosvg import svg2png +from erepublik import classes, constants, utils +from erepublik.citizen import CitizenEconomy, CitizenLeaderBoard, CitizenMedia, CitizenSocial +from erepublik.constants import AIR_RANKS, GROUND_RANKS, Rank + +forbidden_ids = [] + + +class MyOfferItem: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + if hasattr(self, k): + setattr(self, k, v) + + price: float = 999_999_999.0 + country: constants.Country = constants.Country(0, "", "", "") + amount: int = 0 + offer_id: int = 0 + citizen_id: int = 0 + quality: int = 0 + price_per_use: float = 0.0 + true_amount: int = 0 + + def __repr__(self): + return ( + f"MyOfferItem(price={self.price}, country={repr(self.country)}, amount={self.amount}, " + f"offer_id={self.offer_id}, citizen_id={self.citizen_id}, quality={self.quality}, " + f"price_per_use={self.price_per_use}, true_amount={self.true_amount})" + ) + + +class _OfferHolder: + _content: List[MyOfferItem] + _limit: int + + def __init__(self, limit: int): + self._content = [] + self._limit = limit + + def append(self, obj: MyOfferItem): + self._content.append(obj) + self.check() + + def sort(self): + # for equal price per use, largest amount offers will be first + self._content.sort(key=attrgetter("true_amount"), reverse=True) + self._content.sort(key=attrgetter("price_per_use")) + + @property + def total_amount(self) -> int: + return sum(o.true_amount for o in self._content) + + def check(self): + self.sort() + while (self.total_amount - self._content[-1].true_amount) >= self._limit: + self._content.pop(-1) + if self._content[-1].true_amount > (self.total_amount - self._limit) > 0: + o = self._content[-1] + ta = o.true_amount + a = o.amount + coef = ta / a + o.true_amount -= self.total_amount - self._limit + o.amount = int(o.true_amount / coef) + + @property + def list(self) -> List[MyOfferItem]: + return self._content + + def __iter__(self): + for i in self._content: + yield i + + +class Contestant: + id: int + name: str + air_kill_count: int + ground_kill_count: int + air_rank: Rank + ground_rank: Rank + + residence: int + military_unit: int + has_house: bool + done_7_dos: bool + factory_count_increased: bool + factory_count: int + + extra: List[Any] + + def __init__(self, _id: int): + self.id = _id + self.name = "" + self.air_kill_count = 0 + self.ground_kill_count = 0 + self.air_rank = AIR_RANKS[1] + self.ground_rank = GROUND_RANKS[1] + + self.residence = 0 + self.factory_count = 0 + self.factory_count_increased = False + self.done_7_dos = False + self.has_house = False + self.extra = [] + self.military_unit = 0 + + @property + def as_dict(self) -> Dict[str, Any]: + ret = self.__dict__ + ret.update( + _props=dict( + link=self.link, + air_rank_ok=self._air_rank_ok, + ground_rank_penalty=self.ground_rank_penalty, + air_health=self.air_health, + air_health_total=self.air_health_total, + ground_health=self.ground_health, + ground_weapons=self.ground_weapons, + air_rank_s=self.air_rank_s, + ground_rank_s=self.ground_rank_s, + total_health=self.total_health, + total_kills=self.total_kills, + ) + ) + return ret + + @property + def link(self): + return f"https://www.erepublik.com/en/citizen/profile/{self.id}" + + @property + def _air_rank_ok(self) -> bool: + return self.air_rank.id < 56 + + @property + def ground_rank_penalty(self) -> int: + if self.ground_rank.id < 70: + return 0 + else: + return 100 - (89 - self.ground_rank.id) * 5 + + @property + def air_health(self): + if self.has_house and self._air_rank_ok: + return self.air_kill_count * (30 if self.air_rank.id < 50 else 20 if self.air_rank.id < 56 else 0) + else: + return 0 + + @property + def air_health_total(self): + if self.has_house and self._air_rank_ok: + return self.air_health * (1 + self.factory_count_increased) + 3500 * self.done_7_dos + else: + return 0 + + @property + def ground_health(self): + amount = self.ground_kill_count * 20 if self.has_house else 0 + return int((amount * (100 - self.ground_rank_penalty)) / 100) // 2 * 2 + + @property + def ground_weapons(self): + amount = self.ground_kill_count // 4 if self.has_house else 0 + return int((amount * (100 - self.ground_rank_penalty)) / 100) + + @property + def air_rank_s(self) -> str: + return self.air_rank.name + + @property + def ground_rank_s(self) -> str: + return self.ground_rank.name + + @property + def total_health(self) -> int: + return (self.air_health_total + self.ground_health) if self.has_house else 0 + + @property + def total_kills(self) -> int: + return self.air_kill_count + self.ground_kill_count + + @classmethod + def from_dict(cls, **data) -> "Contestant": + obj: Contestant = cls(data["id"]) + obj.name = data["name"] + obj.air_kill_count = data["air_kill_count"] + obj.ground_kill_count = data["ground_kill_count"] + obj.air_rank = AIR_RANKS[data["air_rank"]["id"]] + obj.ground_rank = GROUND_RANKS[data["ground_rank"]["id"]] + + obj.residence = data["residence"] + obj.factory_count = data["factory_count"] + obj.factory_count_increased = data["factory_count_increased"] + obj.done_7_dos = data["done_7_dos"] + obj.has_house = data["has_house"] + obj.extra = data["extra"] + obj.military_unit = data["military_unit"] + return obj + + +class SupplierCitizen(CitizenLeaderBoard, CitizenMedia, CitizenEconomy, CitizenSocial): + def __init__(self, email: str, password: str): + """Instantiate Stripped down version of erepublik.Citizen adjusted for aviator support. + + :param email: Citizen's email address + :param password: Citizen's password + :type email: str + :type password: str + """ + super().__init__(email, password) + self.stop_threads.set() + self._req.debug = True + self.config.interactive = True + self.set_debug(True) + self.init_logger() + + def update(self): + self.get_csrf_token() + self.update_citizen_info() + self.update_inventory() + self.update_money() + if not os.environ.get("PYTHON_TESTS"): + self.reporter.do_init() + self.telegram.do_init(417412798, "363081107:AAE4MmIz_TJh_mFkNDA5MDkwZTY4YjI1ZWJ", self.name) # noqa + self.telegram.send_message(f"*Started aviator supply* {utils.now():%F %T}") + + def get_weekly_daily_orders_done(self, name: str, mu_id: int, provisional: bool) -> bool: + weeks_ago = int(not bool(provisional)) + params = dict( + currentPage=1, + panel="members", + sortBy="dailyOrdersCompleted", + weekFilter=f"week{weeks_ago}", + search=name, + ) + member = self._get_military_unit_data(mu_id, **params).json() + try: + return bool(member.get("panelContents", {}).get("members", [{}])[0].get("allDailyOrdersCompleted")) + except: # noqa + return False + + def get_citizen_profile(self, player_id: int = None): + return self._get_main_citizen_profile_json(player_id).json() + + def direct_get_citizen_residency_data(self, name: str, city_id: int): + return self._get_main_city_data_residents(city_id, params={"search": name}).json() + + def get_multiple_market_offers( + self, prod: str, amount: int, q: int = None, glob: bool = False + ) -> List[MyOfferItem]: + raw_short_names = dict(frm="foodRaw", wrm="weaponRaw", hrm="houseRaw", arm="airplaneRaw") + q1_industries = list(raw_short_names.values()) + q5_industries = ["house", "aircraft", "ticket"] + industry_id = constants.INDUSTRIES[prod] + if prod in raw_short_names: + q = 1 + prod = raw_short_names[prod] + elif not industry_id: + self.report_error(f"Industry '{prod}' not implemented") + raise classes.ErepublikException(f"Industry '{prod}' not implemented") + + max_quality = 1 if prod in q1_industries else 5 if prod.lower() in q5_industries else 7 + q = max_quality if q and q > max_quality else q + if glob: + countries: Set[constants.Country] = self.get_countries_with_regions() + else: + countries: Set[constants.Country] = {self.details.citizenship} + + ret_list: _OfferHolder = _OfferHolder(amount) + start_dt = self.now + iterable = [countries, [q] if q else range(1, max_quality + 1)] + for country, qlt in product(*iterable): + r = self._post_economy_marketplace(country.id, industry_id, qlt).json() + if r.get("error"): + self.write_warning(f"{country}: {r.get('message', '')}", extra={"resp": r}) + continue + for offer in r["offers"]: + price = float(offer["priceWithTaxes"]) + amount = int(offer["amount"]) + true_amount = amount * constants.FOOD_ENERGY[f"q{qlt}"] if industry_id == 1 else amount + ppu = price / (constants.FOOD_ENERGY[f"q{qlt}"] if industry_id == 1 else 1) + ret_list.append( + MyOfferItem( + price=price, + country=country, + amount=amount, + offer_id=int(offer["id"]), + citizen_id=int(offer["citizen_id"]), + quality=qlt, + price_per_use=ppu, + true_amount=true_amount, + ) + ) + self.logger.debug(f"Scraped market in {self.now - start_dt}!") + + return ret_list.list + + def buy_multiple_market_offers(self, offers: Union[List[MyOfferItem], _OfferHolder]): + cur_country = self.details.current_country + for offer in sorted(offers, key=attrgetter("country.id")): + if offer.country != cur_country: + for x in range(3): + if self.travel_to_country(offer.country): + break + else: + raise classes.ErepublikException(f"Unable to travel to {offer.country}!") + cur_country = offer.country + self.buy_market_offer(offer, offer.amount) # noqa + self.travel_to_residence() + + +class LatvianSupply: + citizen: SupplierCitizen = None + free_energy: int = 0 + organisation: SupplierCitizen = None + CONSOLE_TABLE_FORMAT: str = "{:>9} | {:<28} | {:26} | {:6} | {:26} | {:6} | {:^4} | {:^10} | {}" + + contestants: Dict[int, Contestant] + air_top: Dict[int, int] + ground_top: Dict[int, int] + already_sent: Dict[int, Dict[str, int]] + context: Dict[str, Union[str, int, float]] + debug: bool + provisional: bool + checkpoint_id: int = 0 + + def __init__(self, citizen: SupplierCitizen, debug: bool = False, provisional: bool = False): + self.citizen = citizen + self.contestants = {} + self.air_top = {} + self.ground_top = {} + self.already_sent = {} + self.context = dict( + PLAYER_COUNT=0, + TABLE_AIR="", + TABLE_GROUND="", + TABLE_TOTAL="", + STARTING_ENERGY=0, + TOTAL_CC=0, + TOTAL_ENERGY=0, + END_ENERGY=0, + ) + + self.citizen.config.interactive = True + self.citizen._req.debug = True # noqa + self.citizen.set_debug(True) + self.debug = debug + self.provisional = provisional + if debug: + citizen.write_log("Running in DEBUG mode!") + if provisional: + citizen.write_log("Running in PROVISIONAL mode!") + + def __call__(self, *args, **kwargs): + starting_checkpoint_id = kwargs.get("checkpoint_id") or 0 + + # Step 1: Setup primary data + if starting_checkpoint_id < 1: + latest_article = self.get_latest_article_data() + self.context["STARTING_ENERGY"] = sum( + amount * constants.FOOD_ENERGY[q] for q, amount in latest_article.get("free_food", {}).items() + ) + self.free_energy = self.context["STARTING_ENERGY"] + + self.context.update(TOTAL_WEEK=latest_article.get("week", 0) + 1) + self.context.update(WEEK=self.context["TOTAL_WEEK"] - 184) + article_id = latest_article.get("article_id") + self.dump_checkpoint(1, **{"article_id": article_id, "context": self.context}) + else: + data = self.load_checkpoint(1) + article_id = data["article_id"] + self.context.update(**data["context"]) + + # Step 2: Setup ranking data + if starting_checkpoint_id < 2: + for top_data in self.citizen.get_aircraft_kill_rankings(71, 0 if self.provisional else 1, 0).get("top"): + self.air_top.update({int(top_data.get("id")): int(top_data["values"])}) + for top_data in self.citizen.get_ground_kill_rankings(71, 0 if self.provisional else 1, 0, 0).get("top"): + self.ground_top.update({int(top_data.get("id")): int(top_data["values"])}) + + self.dump_checkpoint(2, **{"air_top": self.air_top, "ground_top": self.ground_top}) + else: + data = self.load_checkpoint(2) + self.air_top = data["air_top"] + self.ground_top = data["ground_top"] + + # Step 3: Get contestants + if starting_checkpoint_id < 3: + comments: Dict[str, Any] = self.citizen.get_article_comments(article_id, 1) + if not comments.get("comments", {}): + raise classes.ErepublikException("No comments found") + self.citizen.write_log( + self.CONSOLE_TABLE_FORMAT.format( + "ID", "Vārds", "G rangs", "G kili", "A rangs", "A kili", "7do", "Fabrikas", "Papildu" + ) + ) + self.set_contestants_from_comments(comments.get("comments", {})) + self.context["PLAYER_COUNT"] = len(self.contestants) + self.dump_checkpoint(3, **dict(contestants=self.contestants, context=self.context)) + else: + data = self.load_checkpoint(3) + self.context.update(**data["context"]) + for contestant in data["contestants"].values(): + cont = Contestant.from_dict(**contestant) + self.contestants.update({cont.id: cont}) + + # Step 4: calculate expenses and buy necessary items + if starting_checkpoint_id < 4: + cc_spent_on_food = cc_spent_on_tanks = 0 + previously_sent = dict(food=0, tanks=0) + for a in self.already_sent.values(): + previously_sent["food"] += a["food"] + previously_sent["tanks"] += a["tanks"] + total_food_requirement = sum(c.total_health for c in self.contestants.values()) + self.context["TOTAL_ENERGY"] = total_food_requirement + total_food_requirement -= self.free_energy + if total_food_requirement - previously_sent["food"] > 0: + food_offers = self.citizen.get_multiple_market_offers( + "food", total_food_requirement - previously_sent["food"], glob=True + ) + cc_spent_on_food = round(sum(o.amount * o.price for o in food_offers), 2) + if self.debug or self.provisional: + for offer in food_offers: + self.citizen.logger.debug(repr(offer)) + else: + self.citizen.buy_multiple_market_offers(food_offers) + + total_tank_requirement = sum(c.ground_weapons for c in self.contestants.values()) + self.context["TOTAL_TANKS"] = total_tank_requirement + if total_tank_requirement - previously_sent["tanks"] > 0: + tank_offers = self.citizen.get_multiple_market_offers( + "weapon", + total_tank_requirement - previously_sent["tanks"], + 7, + glob=(not self.debug and not self.provisional), + ) + cc_spent_on_tanks = round(sum(o.amount * o.price for o in tank_offers), 2) + if self.debug or self.provisional: + for offer in tank_offers: + self.citizen.logger.debug(repr(offer)) + else: + self.citizen.buy_multiple_market_offers(tank_offers) + + self.context["TOTAL_CC"] = round(cc_spent_on_food + cc_spent_on_tanks * 1.1, 2) + self.citizen.update_inventory() + self.dump_checkpoint(4, **{"context": self.context}) + else: + data = self.load_checkpoint(4) + self.context.update(**data["context"]) + + # Step 5: send supplies + if starting_checkpoint_id < 5: + sent_data = [] + for contestant in sorted( + self.contestants.values(), + key=attrgetter("total_health", "total_kills"), + reverse=True, + ): + self.send_food_supplies(contestant.id, sent_data) + self.send_tank_supplies(contestant.id, sent_data) + with open(utils.get_file(f"{self.citizen.eday}.json"), "w") as f: + utils.json_dump(sent_data, f) + + self.citizen.write_log(f"Not used energy: {self.free_energy:,d}hp") + self.context["END_ENERGY"] = self.free_energy + self.dump_checkpoint(5, **{"context": self.context}) + else: + data = self.load_checkpoint(5) + self.context.update(**data["context"]) + + # Step 6: Generate images for article + if starting_checkpoint_id < 6: + image_title = f"Day {self.start_eday}-{self.end_eday}" + + air_url = self.get_air_image_url(image_title) + ground_url = self.get_ground_image_url(image_title) + total_url = self.get_total_image_url(image_title) + + self.context.update(AIR_URL=air_url, GROUND_URL=ground_url, TOTAL_URL=total_url) + self.dump_checkpoint(6, **{"context": self.context}) + else: + data = self.load_checkpoint(6) + self.context.update(**data["context"]) + + # Step 7: Generate article data + if starting_checkpoint_id < 7: + for k, v in self.prepare_article_image().items(): + self.context[f"TABLE_{k.upper()}"] = v + article_body = self.generate_article_body() + article_data = dict( + content=article_body, + from_eday=self.start_eday, + till_eday=self.end_eday, + title=f'[KM] eLatviešu apgāde [d{self.article_eday} {self.citizen.now.strftime("%H:%M")}]', + comment=( + f"★★★★ APGĀDE PAR NEDĒĻU [DAY {self.start_eday}-{self.end_eday}] IZDALĪTA ★★★★\n" + "★ Apgādei piesakies šī komentāra atbildes komentāros ar saucienu - piesakos! ★" + ), + ) + self.citizen.write_log( + f"Publishing info:\n\n### Article ###\n{article_data['title']}\n\n{article_data['comment']}\n\n" + ) + self.dump_checkpoint(7, **{"context": self.context, "article_data": article_data}) + else: + data = self.load_checkpoint(7) + self.context.update(**data["context"]) + article_data = data["article_data"] + + self.organisation = SupplierCitizen("organisation.email@example.com", "") + self.organisation.set_debug(True) + self.organisation.update() + + while not self.organisation.token: + self.organisation.update_citizen_info() + + # Step 8: refund + if starting_checkpoint_id < 8: + if not self.debug and not self.provisional: + self.organisation.donate_money(self.citizen.details.citizen_id, int(self.context["TOTAL_CC"] + 0.5), 1) + self.organisation.update_money() + + refund_cc = int(250_000.5 - self.organisation.details.cc) + if refund_cc > 0: + wall_body = ( + "★★★ [ PAZIŅOJUMS KONGRESAM ] ★★★\n\n" + f"Veikta apgāde par d{self.start_eday}-{self.end_eday} {refund_cc}cc apmērā!\n\n" + f"Ierosiniet naudas pārskaitījumu uz {self.organisation.name}\n" + ) + self.citizen.write_log("### Wall ###\n" f"{wall_body}") + self.citizen.telegram.send_message(wall_body) + + # Step 9: Publish article + if starting_checkpoint_id < 9 and not self.debug and not self.provisional: + comment_data = dict(message=article_data.pop("comment")) + + new_article_id = self.organisation.publish_article(article_data["title"], article_data["content"], 3) + comment_data.update(article_id=new_article_id) + self.organisation.write_article_comment(**comment_data) + self.organisation.vote_article(new_article_id) + self.citizen.vote_article(new_article_id) + self.citizen.endorse_article(new_article_id, 100) + + httpx.post( + "https://erep.lv/aviator/latest_article/", + json=dict( + week=self.context["TOTAL_WEEK"], article_id=new_article_id, free_food={"q1": self.free_energy // 2} + ), + ) + + httpx.post( + "https://erep.lv/aviator/set/", + json=[ + dict( + id=aviator.id, name=aviator.name, factory_count=aviator.factory_count, rank=aviator.air_rank.id + ) + for aviator in self.contestants.values() + ], + ) + with open(f'aviator_support_{self.citizen.eday}_{self.citizen.now.strftime("%F %T")}.json', "w") as f: + utils.json_dump(self, f) + + @property + def offset(self): + return int(self.debug and not self.provisional) + + @property + def as_dict(self): + return self.__dict__ + + def get_latest_article_data(self): + data = httpx.get(f"https://erep.lv/aviator/latest_article/{self.offset}/").json() + if not data.get("status"): + raise classes.ErepublikException("Article ID and week problem") + return data + + def set_contestants_from_comments(self, comments): + time_string = "%Y-%m-%d %H:%M:%S" + for comment_data in comments.values(): + if comment_data.get("authorId") == 1954361: + start_dt = utils.localize_dt(datetime.datetime.strptime(comment_data.get("createdAt"), time_string)) + days_ahead = 1 - start_dt.weekday() + if days_ahead <= 0: + days_ahead += 7 + end_dt = utils.good_timedelta(start_dt, datetime.timedelta(days_ahead)).replace( + hour=0, minute=0, second=0 + ) + if not comment_data.get("replies", {}): + raise classes.ErepublikException("No replies found") + + for reply_data in comment_data.get("replies").values(): + if utils.localize_dt(datetime.datetime.strptime(reply_data.get("createdAt"), time_string)) > end_dt: + continue + if re.search(r"piesakos", reply_data.get("message"), re.I): + self._setup_contestant(reply_data) + + @property + def start_eday(self): + now = utils.now() - datetime.timedelta(days=utils.now().weekday()) + return utils.eday_from_date(constants.erep_tz.normalize(now - datetime.timedelta(days=6))) + + @property + def end_eday(self): + return self.start_eday + 6 + + @property + def article_eday(self): + return self.end_eday + 1 + + def send_food_supplies(self, contestant_id: int, actions: List[Any]): + contestant = self.contestants[contestant_id] + food = {q: self.citizen.food[q] for q in reversed(list(constants.FOOD_ENERGY.keys())) if self.citizen.food[q]} + health = (contestant.air_health_total + contestant.ground_health) // 2 * 2 + if contestant.id in self.already_sent: + sent = self.already_sent[contestant.id]["food"] + health -= sent + if health < 0: + health = 0 + actions.append(dict(player_id=contestant.id, name=contestant.name, amount=sent, industry=1)) + self.free_energy -= sent + if self.free_energy < 0: + self.free_energy = 0 + if not health: + # actions.append(dict(player_id=contestant.id, name=contestant.name, amount=0, industry=1)) + return + + while health > 0: + food = { + q: food.get(q) for q in reversed(list(constants.FOOD_ENERGY.keys())) if food.get(q) and food.get(q) > 0 + } + for quality, amount in food.items(): + if constants.FOOD_ENERGY[quality] <= health: + break + else: + self.citizen.write_warning(f"{contestant.name} ({contestant.id}) needs to receive extra {health}hp") + break + + if amount * constants.FOOD_ENERGY[quality] > health: + amount = health // constants.FOOD_ENERGY[quality] + + q = int(quality[1]) + if self.debug or self.provisional: + self.citizen.logger.debug( + f"citizen.donate_items(citizen_id={contestant.id}, " f"amount={amount}, industry_id=1, quality={q})" + ) + else: + donated = self.citizen.donate_items(citizen_id=contestant.id, amount=amount, industry_id=1, quality=q) + if donated != amount: + health -= donated * constants.FOOD_ENERGY[quality] + self.citizen.write_warning(f"{contestant.name} ({contestant.id}) needs to receive extra {health}hp") + + actions.append( + dict( + player_id=contestant.id, + name=contestant.name, + amount=donated * constants.FOOD_ENERGY[quality], + industry=1, + ) + ) + break + food[quality] -= amount + self.citizen.food[quality] -= amount + self.citizen.inventory.final["Food"][q]["amount"] -= amount + health -= amount * constants.FOOD_ENERGY[quality] + actions.append( + dict( + player_id=contestant.id, + name=contestant.name, + amount=amount * constants.FOOD_ENERGY[quality], + industry=1, + ) + ) + + def send_tank_supplies(self, contestant_id: int, actions: List[Any]): + contestant = self.contestants[contestant_id] + tank_amount = contestant.ground_weapons + if contestant.id in self.already_sent: + sent = self.already_sent[contestant_id]["tanks"] + tank_amount -= sent + if tank_amount < 0: + tank_amount = 0 + actions.append(dict(player_id=contestant.id, name=contestant.name, amount=sent, industry=2)) + if not tank_amount: + # actions.append(dict(player_id=contestant.id, name=contestant.name, amount=0, industry=2)) + return + + if self.debug or self.provisional: + self.citizen.logger.debug( + f"citizen.donate_items(citizen_id={contestant.id}, " f"amount={tank_amount}, industry_id=2, quality=7)" + ) + donated = tank_amount + else: + donated = self.citizen.donate_items(citizen_id=contestant.id, amount=tank_amount, industry_id=2, quality=7) + if donated != tank_amount: + tank_amount -= donated + self.citizen.write_log( + f"{contestant.name} ({contestant.id}) needs to receive extra {tank_amount} q7 tanks" + ) + + actions.append(dict(player_id=contestant.id, name=contestant.name, amount=donated, industry=2)) + + def get_air_image_url(self, title: str) -> str: + return self.upload_image_and_get_url(self._create_aviator_png(title)) + + def get_ground_image_url(self, title: str) -> str: + return self.upload_image_and_get_url(self._create_ground_png(title)) + + def get_total_image_url(self, title: str) -> str: + return self.upload_image_and_get_url(self._create_total_png(title)) + + def prepare_article_image(self) -> Dict[str, str]: + contestant_captions = {"ground": [], "air": [], "total": []} + for contestant in sorted( + self.contestants.values(), key=attrgetter("total_kills", "total_health"), reverse=True + ): + bb_link = f"[url={contestant.link}]{contestant.name}[/url]" + if contestant.total_health: + contestant_captions["total"].append(bb_link) + for k, v in contestant_captions.items(): + v = ", ".join(v) + if k == "total": + v = f"Apgādi saņēma: {v}." + contestant_captions[k] = v + title_trans = {"air": "Gaisa apgāde", "ground": "Zemes apgāde", "total": ""} + return { + name: ( + f"[b]{title_trans[name]}[/b]\n" + f"[center][url={self.context[url_key]}][img]{self.context[url_key]}[/img][/url][/center]\n" + f"{contestant_captions[name]}" + ) + for name, url_key in ( + ("air", "AIR_URL"), + ("ground", "GROUND_URL"), + ("total", "TOTAL_URL"), + ) + } + + def generate_article_body(self) -> str: + filename = f"{os.path.abspath(os.path.dirname(sys.argv[0]))}/scripts/KM_apgade.txt" + if os.path.isfile(filename): + with open(filename) as article_template_file: + template = article_template_file.read() + article = template.format(**self.context) + with open(utils.get_file(f"{self.article_eday}.txt"), "w") as article_file: + article_file.write(article) + return article + return "" + + def dump_checkpoint(self, checkpoint_id: int, **data_to_dump): + dump_path = pathlib.Path(f"aviator_dumps/d{self.article_eday}/") + dump_path.mkdir(parents=True, exist_ok=True) + with open(dump_path / f"{checkpoint_id:02d}_checkpoint.json", "w") as f: + utils.json_dump(data_to_dump, f) + + def load_checkpoint(self, checkpoint_id: int): + load_path = pathlib.Path(f"aviator_dumps/d{self.article_eday}/") + with open(load_path / f"{checkpoint_id:02d}_checkpoint.json", "r") as f: + data = utils.json_load(f) + return data + + @staticmethod + def upload_image_and_get_url(image: BytesIO, title: str = None) -> str: + if title is None: + title = "aviator_top" + title += ".png" + p = httpx.post( + "https://erep.lv/image/upload", + files=[("file", (title, image, "image/png"))], + data=dict(password=""), + ) + if p.json().get("status"): + return p.json().get("url") + else: + raise ValueError("Unable to upload table image!") + + def _create_aviator_png(self, title: str) -> BytesIO: + """Convert data to png table. + :param title: String containing table name + :rtype: PNG image containing + """ + data = list(row for row in self.contestants.values() if row.air_kill_count) + formatted_data = "" + + col_headers = [ + ("name", "Spēlētājs", 10), + ("air_kill_count", "Kili", 350), + ("air_health", "Enerģija", 440), + ("done_7_dos", "7 DO", 470), + ("factory_count_increased", "Fabrikas", 540), + ("air_health_total", "Kopā", 650), + ] + + for col, col_value, x in col_headers: + anchor = ( + "start" if col == "name" else "middle" if col in ["done_7_dos", "factory_count_increased"] else "end" + ) + formatted_data += f"\n" + formatted_data += f"{col_value}\n" + bold = " font-weight='bold'" if col == "name" else "" + for row in sorted(data, key=attrgetter("air_kill_count", "air_health_total"), reverse=True): + value = getattr(row, col, None) + if col == "air_health_total": + if row.extra: + value = str(row.extra[0]) + else: + value = row.air_health_total + if isinstance(value, bool): + value = "+" if value else "–" + elif isinstance(value, int): + value = f"{value:,.0f}".replace(",", " ") + elif isinstance(value, str): + if len(value) > 29: + value = value[:26] + ".." + + formatted_data += f"{value}\n" + formatted_data += "\n" + + height = 33 + len(data) * 24 + + row_highlight_count = (len(data) + 1) // 2 + + row_highlights = "" + for i in range(row_highlight_count): + row_highlights += f"" + + svg_template = ( + f"" # noqa + f"{title}" # noqa + f"{row_highlights}" + f"" # noqa + f"" # noqa + f"" # noqa + f"" # noqa + f"" # noqa + f"" # noqa + f"{formatted_data}" + ) + small_svg = "".join([row.strip() for row in svg_template.split("\n")]) + icon_file = BytesIO() + svg2png(small_svg, write_to=icon_file, scale=2) + icon_file.seek(0) + return icon_file + + def _setup_contestant(self, reply_data): + contestant = Contestant(int(reply_data.get("authorId"))) + contestant.air_kill_count = self.air_top.get(contestant.id, 0) + contestant.ground_kill_count = self.ground_top.get(contestant.id, 0) + self.contestants.update({contestant.id: contestant}) + + profile = self.citizen.get_citizen_profile(contestant.id) + contestant.name = profile["citizen"]["name"] + contestant.air_rank = AIR_RANKS[profile["military"]["militaryData"]["aircraft"]["rankNumber"]] + contestant.ground_rank = GROUND_RANKS[profile["military"]["militaryData"]["ground"]["rankNumber"]] + contestant.military_unit = ( + profile["military"]["militaryUnit"]["id"] if profile["military"]["militaryUnit"] else 0 + ) + + if profile.get("isBanned"): + contestant.extra.append("BANNED") + elif not profile.get("location", {}).get("citizenshipCountry", {}).get("id") == 71: + contestant.extra.append("Nav pilsonis") + elif not profile["city"]["residenceCityId"]: + contestant.extra.append("Nav rezidences") + else: + contestant.residence = profile["city"]["residenceCityId"] + + if contestant.id in forbidden_ids: + contestant.extra.append("Aizliegta pieteikšanās") + if contestant.extra: + return + + residence_data = self.citizen.direct_get_citizen_residency_data(contestant.name, contestant.residence) + __residents = residence_data.get("widgets", {}).get("residents", {}).get("residents") + + contestant.factory_count = 0 + contestant.has_house = False + for resident in __residents: + if int(resident.get("citizenId")) == contestant.id: + contestant.factory_count = resident.get("numFactories", 0) + contestant.has_house = bool(resident.get("activeHouses")) + break + resp = httpx.post( + f"https://erep.lv/aviator/check/{contestant.id}", + data={"current_count": contestant.factory_count}, + ) + contestant.factory_count_increased = resp.json()["status"] + if not contestant.has_house: + contestant.extra.append("Nav māju") + + if contestant.military_unit: + contestant.done_7_dos = self.citizen.get_weekly_daily_orders_done( + contestant.name, contestant.military_unit, self.provisional + ) + self.citizen.write_log( + self.CONSOLE_TABLE_FORMAT.format( + contestant.id, + contestant.name, + contestant.ground_rank_s, + contestant.ground_kill_count, + contestant.air_rank_s, + contestant.air_kill_count, + contestant.done_7_dos, + contestant.factory_count_increased, + ", ".join(contestant.extra), + ) + ) + + def _create_ground_png(self, title: str) -> BytesIO: + """Convert data to png table. + :param title: String containing table name + :rtype: PNG image containing + """ + data = list(row for row in self.contestants.values() if row.ground_kill_count) + formatted_data = "" + + col_headers = [ + ("name", "Spēlētājs", 10), + ("ground_kill_count", "Kili", 350), + ("penalty", "", 450), + ("ground_health", "Enerģija", 550), + ("ground_weapons", "q7", 650), + ] + + for col, col_value, x in col_headers: + anchor = "start" if col == "name" else "end" + formatted_data += f" \n" + formatted_data += f" {col_value}\n" + bold = " font-weight='bold'" if col == "name" else "" + for row in sorted(data, key=attrgetter("ground_kill_count", "ground_health"), reverse=True): + value = getattr(row, col, None) + if col == "penalty": + if not row.ground_health and not row.ground_rank_penalty == 100 and row.extra: + value = row.extra[0] + elif row.ground_rank_penalty: + value = f"-{row.ground_rank_penalty}%" + else: + value = "" + if isinstance(value, bool): + value = "+" if value else "–" + elif isinstance(value, int): + value = f"{value:,.0f}".replace(",", " ") + elif isinstance(value, str): + if len(value) > 29: + value = value[:26] + ".." + + formatted_data += f" {value}\n" + formatted_data += " \n" + + height = 33 + len(data) * 24 + + row_highlight_count = (len(data) + 1) // 2 + + row_highlights = "" + for i in range(row_highlight_count): + row_highlights += f"" + + svg_template = ( + f"" # noqa + f"{title}" # noqa + f"{row_highlights}" + f"" # noqa + f"" # noqa + f"" # noqa + f"" # noqa + f"" # noqa + f"{formatted_data}" + ) + small_svg = "".join([row.strip() for row in svg_template.split("\n")]) + icon_file = BytesIO() + svg2png(small_svg, write_to=icon_file, scale=2) + icon_file.seek(0) + return icon_file + + def _create_total_png(self, title: str) -> BytesIO: + """Convert data to png table. + :param title: String containing table name + :rtype: PNG image containing + """ + data = list(self.contestants.values()) + formatted_data = "" + + col_headers = [ + ("name", "Spēlētājs", 10), + ("total_kills", "Kili kopā", 400), + ("total_health", "Enerģija kopā", 550), + ("ground_weapons", "Tanki", 650), + ] + + for col, col_value, x in col_headers: + anchor = "start" if col == "name" else "end" + formatted_data += f"" + formatted_data += f"{col_value}" + bold = " font-weight='bold'" if col == "name" else "" + for row in sorted(data, key=attrgetter("total_kills", "total_health"), reverse=True): + value = getattr(row, col, None) + if col == "total_health" and not value: + if row.extra: + value = row.extra[0] + if isinstance(value, bool): + value = "+" if value else "–" + elif isinstance(value, int): + value = f"{value:,.0f}".replace(",", " ") + elif isinstance(value, str): + if len(value) > 29: + value = value[:26] + ".." + + formatted_data += f"{value}" + formatted_data += "" + + height = 33 + len(data) * 24 + + row_highlight_count = (len(data) + 1) // 2 + + row_highlights = "" + for i in range(row_highlight_count): + row_highlights += f"" + + svg_template = ( + f"" # noqa + f"{title}" # noqa + f"{row_highlights}" + f"" # noqa + f"" # noqa + f"" # noqa + f"" # noqa + f"{formatted_data}" + ) + small_svg = "".join([row.strip() for row in svg_template.split("\n")]) + icon_file = BytesIO() + svg2png(small_svg, write_to=icon_file, scale=2) + icon_file.seek(0) + return icon_file diff --git a/ebot/helpers.py b/ebot/helpers.py new file mode 100644 index 0000000..8554b03 --- /dev/null +++ b/ebot/helpers.py @@ -0,0 +1,212 @@ +""" eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + +import logging +from datetime import datetime +from operator import attrgetter +from typing import Any, Dict, List, NamedTuple, NoReturn, Optional, Tuple, Union + +from erepublik._logging import ErepublikErrorHTTTPHandler +from erepublik.classes import ErepublikException +from erepublik.constants import max_datetime + +DMG_MAP: Dict[int, Tuple[int, int]] = { + 1: (6_000_000, 12_000_000), + 2: (12_000_000, 18_000_000), + 3: (18_000_000, 24_000_000), + 4: (60_000_000, 90_000_000), + 11: (80_000, 100_000), +} + + +class _EmptyMedal: + def __init__( + self, + time: datetime, + battle_id: int, + division_id: int, + defender_side: bool, + zone: int, + second_attempt: bool = False, + ): + self.time: datetime = time + self.division_id: int = division_id + self.battle_id: int = battle_id + self.defender_side: bool = bool(defender_side) + self.round: int = zone + self.second_attempt: bool = second_attempt + + @property + def _sort_sequence(self): + return self.time, not self.defender_side, -self.round, self.battle_id, self.division_id + + def __hash__(self): + return hash((self.battle_id, self.division_id)) + + @property + def __dict__(self): + return dict( + time=self.time, + battle_id=self.battle_id, + division_id=self.division_id, + round=self.round, + defender_side=self.defender_side, + second_attempt=self.second_attempt, + ) + + def __repr__(self): + return self.__str__() + + def __str__(self): + return ( + f"_EmptyMedal(time={self.time.strftime('%F %T')}, battle_id={self.battle_id}, " + f"division_id={self.division_id}, defender_side={self.defender_side}, round={self.round})" + ) + + def __eq__(self, other): + try: + return (self.battle_id, self.division_id) == (other.battle_id, other.division_id) + except AttributeError: + return False + + def __ne__(self, other): + try: + return not self.__eq__(other) + except AttributeError: + return True + + @property + def as_dict(self): + return self.__dict__ + + +class Task(NamedTuple): + name: str + time: datetime + priority: bool = False + + def __repr__(self): + return f"{self.time.strftime('%F %T')} {self.human_name}" + (" (P)" if self.priority else "") + + @property + def human_name(self) -> str: + return " ".join(self.name.split("_")).capitalize() + + +class Tasks: + __storage: List[Task] + + _defaults: Dict[str, any] + + def __repr__(self): + return f"" + + def __init__(self): + self.__storage = [] + self._defaults = {} + + def __getitem__(self, key: Union[str, int]) -> Optional[Task]: + try: + if isinstance(key, int): + return self.__storage[key] + for task in self.__storage: + if task.name == key: + return task + except IndexError: + return Task("Do nothing", max_datetime) + + def __setitem__(self, key: Union[str, int], value: Union[datetime, Tuple[datetime, bool]]) -> NoReturn: + priority = False + if isinstance(value, tuple): + value, priority = value + if isinstance(key, int) and isinstance(value, datetime): + old_item = self.__storage.pop(key) + item = Task(old_item.name, value, priority) + elif isinstance(key, str) and isinstance(value, datetime): + item = Task(key, value, priority) + task_idx = self.__get_key_index(key) + if task_idx is not None: + self.__storage.pop(task_idx) + else: + raise TypeError( + f"key, value pairs must be of types (int, datetime) or (str, datetime) " + f"not ({type(key)}, {type(value)})" + ) + self.__storage.append(item) + + def __get_key_index(self, key: str) -> Optional[int]: + for idx, task in enumerate(self.__storage): + if task.name == key: + return idx + return None + + def append(self, __object: Task) -> None: + if not isinstance(__object, Task): + raise TypeError(f"object must be of instance Task, not {type(__object)}") + + self.__storage.append(__object) + + def sort(self) -> NoReturn: + self.__storage.sort(key=attrgetter("name")) + self.__storage.sort(key=attrgetter("priority"), reverse=True) + self.__storage.sort(key=attrgetter("time")) + + def pop(self, key: str) -> Optional[Task]: + _idx = self.__get_key_index(key) + if isinstance(_idx, int): + return self.__storage.pop(_idx) + else: + return + + def __iter__(self): + for task in self.__storage: + yield task + + def set_default(self, key: str, value: Any) -> NoReturn: + self._defaults[key] = value + + def get_default(self, key: str, default: Any = None) -> Any: + return self._defaults.get(key, default) + + @property + def as_dict(self): + return {"tasks": self.__storage, "defaults": self._defaults} + + +class BotStop(SystemExit): + pass + + +class BotRestart(ErepublikException): + pass + + +class EbotErrorHttpHandler(ErepublikErrorHTTTPHandler): + def __init__(self): + logging.Handler.__init__(self, level=logging.ERROR) + self._reporter = None + self.host = "erep.lv" + self.url = "/ebot/error/" + self.method = "POST" + self.secure = True + self.credentials = ("0", "changeme") + self.context = None + + def _get_last_response(self): + return {} + + def _get_instance_json(self): + return "" diff --git a/ebot/main.py b/ebot/main.py new file mode 100644 index 0000000..a1a1f14 --- /dev/null +++ b/ebot/main.py @@ -0,0 +1,153 @@ +""" eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + +import copy +import os +import random +import sys +from datetime import timedelta +from typing import Dict, List, Union + +import httpx +import sentry_sdk +from erepublik import Citizen, classes, utils + +from ebot import __version__ +from ebot.helpers import BotRestart, BotStop +from ebot.player import Player +from ebot.utils import _norm + +VERSION = __version__ +EVERSION = utils.VERSION + + +def main(config: Dict[str, Union[int, bool, str, List[str], Dict[str, Union[str, int]]]]): + player = Player("", "", False) + try: # If errors before player is initialized + while True: + has_dump = os.path.isfile("Player__dump.json") + if has_dump: + player = Player.load_from_dump("Player__dump.json") + else: + player = Player(email=config["email"], password=config["password"], auto_login=False) + player.setup_citizen(config) + player.login() + if player.logged_in: + sentry_sdk.set_user(dict(id=player.details.citizen_id, username=player.name, email=player.config.email)) + try: + import setproctitle + + setproctitle.setproctitle(f"eBot - {player}") + except ImportError: + pass + break + else: + utils.silent_sleep(2) + + while True: + try: + player.update_all() + if not player.restricted_ip: + player.dump_instance() + break + except Exception as e: # noqa + player.report_error(f"Error updating all data on initial update {e}") + utils.silent_sleep(2) + + player.write_log( + f"View Your stats at:\n" + f"'https://erep.lv/player/{player.name.replace(' ', '%20')}'\n" + f"Your password is '{player.reporter.key}'" + ) + + # if player.reporter.allowed: + report = copy.deepcopy(config) + report.pop("email", None) + report.pop("password", None) + report.update(VERSION=VERSION, eRepublik_version=EVERSION) + player.reporter.report_action("ACTIVE_CONFIG", json_val=report) + + player.setup_tasks(config) + + _run_main_loop(player) + + player.set_locks() + player.logger.warning("Too many errors.") + except BotStop as bs: + raise bs + except BotRestart: + pass + except httpx.ConnectError as conn_err: + raise conn_err + except Exception as e: # noqa + if isinstance(player, Citizen): + name = player.name + elif config.get("email", None): + name = config["email"] + else: + name = "Uninitialized" + sentry_sdk.capture_exception(e) + player.report_error( + f"Fatal error. {e}", extra=dict(player_name=name, ebot_version=VERSION, erep_version=EVERSION) + ) + finally: + if isinstance(player, Citizen): + player.set_locks() + + +def _run_main_loop(player: Player): + error_count = 0 + while error_count < 3 and not player.stop_threads.is_set(): + try: + player.update_all() + player.do_tasks() + next_task = player.get_next_task() + player.travel_to_residence() + random_seconds = random.randint(0, 121) if player.tasks.get_default("random_sleep", True) else 0 + sleep_seconds = int(utils.get_sleep_seconds(next_task.time)) + if sleep_seconds <= 0: + player.write_warning(f"Loop detected! Offending task: '{next_task.name}'") + next_time = _norm(next_task.time + timedelta(seconds=random_seconds)).strftime("%F %T") + tasks = player.tasks.as_dict["tasks"] + player.write_log("My next Tasks and there time:\n" + "\n".join((str(t) for t in tasks))) + player.write_log( + f"Sleeping until (eRep): {next_time} " f"(sleeping for {sleep_seconds}s + random {random_seconds}s)" + ) + seconds_to_sleep = sleep_seconds + random_seconds + player.stop_threads.wait(seconds_to_sleep) + + except (classes.ErepublikNetworkException, httpx.ConnectError) as exc: + sentry_sdk.capture_exception(exc) + player.write_warning("Network ERROR detected. Sleeping for 1min...") + player.sleep(60) + except BotRestart: + player.set_locks() + return + except classes.ErepublikException as exc: + sentry_sdk.capture_exception(exc) + player.report_error(f"Known error detected! {exc}") + except (KeyboardInterrupt, BotStop): + player.set_locks() + sys.exit(0) + except Exception as exc: + sentry_sdk.capture_exception(exc) + player.report_error( + f"Unknown error! {exc}", + extra=dict(player_name=player.name, ebot_version=VERSION, erep_version=EVERSION), + ) + error_count += 1 + if error_count < 3: + player.sleep(60) diff --git a/ebot/player.py b/ebot/player.py new file mode 100644 index 0000000..901dad2 --- /dev/null +++ b/ebot/player.py @@ -0,0 +1,769 @@ +""" eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + +import os +import re +import signal +import sys +import threading +from collections import defaultdict +from datetime import datetime, timedelta +from random import randint +from typing import Any, Callable, Dict, List, NoReturn, Optional, Set, Tuple, Union + +import httpx +import sentry_sdk +from dateutil.parser import parse +from erepublik import Citizen, utils +from erepublik.classes import Battle +from httpx import Response + +from ebot import __version__ +from ebot.helpers import BotStop, Task, Tasks +from ebot.utils import _norm, jsonify + +VERSION = __version__ +EVERSION = utils.VERSION + + +class BasePlayer(Citizen): + do_captcha: bool = False + _last_donation_time: datetime + __last_update_times: Dict[str, Dict[str, Union[datetime, Any]]] + + def __init__(self, email, password, auto_login: bool = False): + super().__init__(email, password, auto_login) + self._last_donation_time = self.now + self.__last_update_times = defaultdict(lambda: dict(time=_norm(self.now - timedelta(days=1)), value=None)) + + def setup_citizen(self, config): + for _key in [ + "work", + "train", + "ot", + "wam", + "employees", + "auto_sell_all", + "auto_buy_raw", + "force_wam", + "telegram", + "spin_wheel_of_fortune", + "interactive", + ]: + if hasattr(self.config, _key): + setattr(self.config, _key, bool(config.get(_key, False))) + else: + raise AttributeError(f'{self.__class__.__name__}.config has no attribute "{_key}"!') + self.config.auto_sell = config.get("auto_sell", []) + self.config.telegram_token = config.get("telegram_token") or None + self.config.telegram_chat_id = config.get("telegram_chat_id", "") + self.reporter.allowed = not config.get("reporting_is_not_allowed") + self.set_debug(config.get("debug", False)) + self.set_interactive(config.get("interactive", False)) + + if config.get("proxy"): + proxy_data = config.get("proxy") + if hasattr(self, f"set_{proxy_data.get('kind')}_proxy"): + args = ( + proxy_data.get("host"), + proxy_data.get("port"), + proxy_data.get("username"), + proxy_data.get("password"), + ) + getattr(self, f"set_{proxy_data.get('kind')}_proxy")(*args) + else: + self.logger.error(f"Can't set '{proxy_data.get('kind')}' proxy") + raise LookupError(f"Can't set '{proxy_data.get('kind')}' proxy") + + def setup_tasks(self, config: Dict[str, Any]): + pass + + def signal_quit(self, sig_num, frame): + if frame: + pass + self.set_locks() + self.sleep(2) + self.logger.debug(f"Received: {sig_num} (Quit)") + if not self.restricted_ip: + self.dump_instance() + raise BotStop(0) + + def signal_reload(self, sig_num, frame): + if frame: + pass + self.logger.debug(f"Received: {sig_num} (Update)") + self.logger.warning("Forcing full update...") + self.update_all(True) + self.send_state_update() + self.send_inventory_update() + self.write_log("Forced full update completed!") + + def signal_config_reload(self, sig_num, frame): + if frame: + pass + self.logger.debug(f"Received: {sig_num} (Update)") + self.logger.warning("Reloading config file...") + self.load_config() + self.update_all(True) + self.send_state_update() + self.send_inventory_update() + self.write_log("Configs reloaded successfully!") + + def report_error(self, msg: str = "", sentry: bool = True, extra: Dict[str, Any] = None): + if sentry: + sentry_sdk.capture_exception() + if extra is None: + extra = {} + extra.update({"ebot_version": VERSION, "erep_version": EVERSION}) + super().report_error(msg, extra) + + def _report_action(self, action: str, msg: str, **kwargs): + super()._report_action(action, msg, **jsonify(kwargs)) + + def report_action(self, action: str, msg: str, **kwargs): + self._report_action(action, msg, **jsonify(kwargs)) + + def _get_main_citizen_profile_json(self, c_id: int): + if _norm(self.__last_update_times[f"q7hit__{c_id}"]["time"] + timedelta(hours=4)) <= self.now: + self.__last_update_times[f"q7hit__{c_id}"]["value"] = super()._get_main_citizen_profile_json(c_id) + self.__last_update_times[f"q7hit__{c_id}"]["time"] = self.now + return self.__last_update_times[f"q7hit__{c_id}"]["value"] + + @classmethod + def load_from_dump(cls, dump_name: str = ""): + filename = dump_name if dump_name else f"{cls.__name__}__dump.json" + with open(filename) as f: + data = utils.json.load(f) + instance = cls(data["config"]["email"], "", False) + for cookie in data["cookies"]: + instance._req.cookies.set(cookie["name"], cookie["value"], cookie["domain"], cookie["path"]) + if data.get("user_agent"): + instance._req.headers.update({"User-Agent": data["user_agent"]}) + + instance.load_config("config.json") + instance._resume_session() + instance.login() + return instance + + def load_config(self, config_path: str = "config.json"): + if os.path.isfile(config_path): + with open(config_path) as f: + try: + configs = utils.json.load(f) + except utils.json.JSONDecodeError: + self.logger.error(f"Config file '{config_path}' must be a JSON file!") + return + self.setup_citizen(configs) + else: + self.logger.warning(f"Config file '{config_path}' not found!") + + def donate_money(self, *args, **kwargs) -> bool: + self.sleep((self._last_donation_time + timedelta(seconds=5) - self.now).total_seconds()) # noqa + ret = super().donate_money(*args, **kwargs) + self._last_donation_time = self.now + return ret + + def candidate_for_party_presidency(self) -> Optional[Response]: + if self.politics.is_party_member: + return super().candidate_for_party_presidency() + else: + self.report_action("POLITIC_CONGRESS", "Unable to apply for party president elections - not a party member") + return None + + def candidate_for_congress(self, presentation: str = "") -> Optional[Response]: + if self.politics.is_party_member: + return super().candidate_for_congress(presentation) + else: + self.report_action("POLITIC_CONGRESS", "Unable to apply for congress elections - not a party member") + return None + + @property + def as_dict(self): + d = super().as_dict + d.update(_last_donation_time=self._last_donation_time, __last_update_times=self.__last_update_times) + return d + + def do_captcha_challenge(self, *args, **kwargs): + if self.do_captcha: + return super().do_captcha_challenge(*args, **kwargs) + return False + + def solve_captcha(self, src: str) -> List[Dict[str, int]]: + r = self.reporter._req.post(f"{self.reporter.url}/captcha/api", data={"password": "CaptchaDevAPI", "src": src}) + r = r.json() + if r["status"]: + return [dict(x=icon["x"] + randint(-4, 4), y=icon["y"] + randint(-4, 4)) for icon in r["result"]["result"]] + return [] + + +class PlayerTasks(BasePlayer): + tasks: Tasks + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tasks = Tasks() + + def setup_tasks(self, config): + super().setup_tasks(config) + self._setup_player_tasks(config) + + @property + def as_dict(self): + ret = super().as_dict + ret["tasks"] = self.tasks.as_dict + return ret + + def do_tasks(self) -> NoReturn: + if self.restricted_ip: + self.tasks.pop("wam") + self.tasks.pop("employ") + self.tasks.sort() + for task in list(self.tasks): + if task.time <= self.now: + task_function_name: str = f"task_{task.name}" + try: + task_function: Callable = getattr(self, task_function_name) + except (AttributeError, TypeError): + self.report_error(f"Task '{task.name}' has no task's function '{task_function_name}' defined!") + raise NotImplementedError(f"Task '{task_function_name}' not implemented!") + if callable(task_function): + self.write_log(f"Doing task: {task.human_name}") + task_function() + else: + raise TypeError(f"Task '{task_function_name}' is not a function!") + + def get_next_task(self) -> Task: + self.tasks.sort() + return self.tasks[0] + + def enable_task(self, title: str, time: datetime = None, priority: bool = False): + next_time = time if time is not None else self.now + self.tasks[title] = (next_time, priority) + + def task_work(self): + self.update_citizen_info() + self.work() + if self.config.ot: + self.task_ot() + self.collect_daily_task() + self.tasks["work"] = _norm(self.now.replace(hour=0, minute=0, second=0) + timedelta(days=1)), True + + def task_ot(self): + self.update_inventory() + if self.ot_points >= 24 and self.now >= self.my_companies.next_ot_time: + self.work_ot() + + self.update_inventory() + try: + hours = ((24 - self.ot_points) // len(self.inventory.active["House"])) + 1 + except ZeroDivisionError: + hours = 24 + self.tasks["ot"] = _norm(self.now + timedelta(hours=hours if hours > 1 else 1)), True + + def task_train(self): + self.update_citizen_info() + self.train() + self.collect_daily_task() + next_time = _norm(self.now.replace(hour=0, minute=0, second=0) + timedelta(days=1)) + self.tasks["train"] = next_time, True + + def task_wam(self): + if self.restricted_ip: + self.tasks.pop("wam") + return + has_more_to_wam = self.work_as_manager() + if has_more_to_wam: + next_time = _norm(self.tasks["wam"].time + timedelta(hours=1)) + else: + next_time = _norm( + self.now.replace(hour=self.tasks.get_default("wam_hour", 14), minute=0, second=0, microsecond=0) + + timedelta(days=1) + ) + + self.tasks["wam"] = next_time + + def task_employees(self): + if self.restricted_ip: + self.tasks.pop("employees") + return + if self.employ_employees(): + _h = self.tasks.get_default("employ_hour", 8) + next_time = _norm(self.now.replace(hour=_h, minute=0, second=0) + timedelta(days=1)) + else: + next_time = _norm(self.tasks["employees"].time + timedelta(minutes=30)) + self.tasks["employees"] = next_time + + def task_buy_gold(self): + for offer in self.get_monetary_offers(): + if offer["amount"] >= 10 and self.details.cc >= 20 * offer["price"]: + # TODO: check allowed amount to buy + self.buy_monetary_market_offer(offer=offer["offer_id"], amount=10, currency=62) + break + + self.tasks["buy_gold"] = _norm(self.tasks["buy_gold"].time + timedelta(days=1)), True + + def task_congress(self): + now = self.now + if 1 <= now.day < 16: + next_time = _norm(now.replace(day=16)) + elif 16 <= now.day < 24: + self.candidate_for_congress() + if not now.month == 12: + next_time = _norm(now.replace(month=now.month + 1, day=16)) + else: + next_time = _norm(now.replace(year=now.year + 1, month=1, day=16)) + else: + if not now.month == 12: + next_time = _norm(now.replace(month=now.month + 1, day=16)) + else: + next_time = _norm(now.replace(year=now.year + 1, month=1, day=16)) + self.tasks["congress"] = _norm(next_time.replace(hour=1, minute=30, second=0, microsecond=0)) + + def task_party_president(self): + next_time = self.now + + if next_time.day == 15: + pass + elif next_time.day < 15: # now.day ∈[1;15) + self.candidate_for_party_presidency() + else: # now.day ∈ (15;31] + self.candidate_for_party_presidency() + next_time = next_time.replace(day=16) + if self.now.month == 12: + next_time = next_time.replace(year=self.now.year + 1, month=1) + else: + next_time = next_time.replace(month=self.now.month + 1) + + self.tasks["party_president"] = _norm(next_time.replace(day=16, hour=0, minute=1, second=0, microsecond=0)) + + def task_contribute_cc(self): + if not self.now.weekday(): + self.update_money() + cc = (self.details.cc // self.tasks.get_default("contribute_cc")) * self.tasks.get_default("contribute_cc") + self.contribute_cc_to_country(cc, self.details.citizenship) + next_time = _norm((self.now + timedelta(days=7 - self.now.weekday())).replace(hour=2, minute=0, second=0)) + self.tasks["contribute_cc"] = next_time + + def task_renew_houses(self): + end_times = self.renew_houses() + if end_times: + self.tasks["renew_houses"] = _norm(min(end_times.values()) - timedelta(hours=24)) + else: + self.logger.warning("No houses found!") + for q in range(1, self.tasks.get_default("renew_houses", 0) + 1): + self.write_log(f"Buying and activating q{q}...") + if not self.buy_and_activate_house(q): + break + end_times = self.check_house_durability() + if end_times: + next_time = _norm(min(end_times.values()) - timedelta(hours=24)) + else: + next_time = _norm(self.now + timedelta(hours=6)) + + self.tasks["renew_houses"] = next_time + + def task_spin_wheel_of_fortune(self): + percents_to_lose = self.tasks.get_default("spin_wheel_of_fortune") + self.update_citizen_info() + if self.wheel_of_fortune and percents_to_lose: + if isinstance(percents_to_lose, bool): + percents_to_lose = 10 + else: + percents_to_lose = int(percents_to_lose) + + max_spend = ((self.details.cc * (percents_to_lose / 100)) // 100) * 100 + max_cost = 0 + next_cost = max_cost + 100 if max_cost else 500 + current_sum = 0 + + while current_sum < max_spend: + max_cost = next_cost + next_cost += 100 + current_sum += max_cost + self.spin_wheel_of_fortune(max_cost) + self.tasks["spin_wheel_of_fortune"] = _norm(self.now.replace(hour=0, minute=0, second=0) + timedelta(days=1)) + + def _setup_player_tasks(self, config: Dict[str, Any]): + now = self.now + self.tasks.set_default("random_sleep", config.get("random_sleep")) + for _action in ["work", "train", "ot"]: # "fight", "epic_hunt" + if getattr(self.config, _action): + self.enable_task(_action, now, priority=True) + + for _action in ["congress", "party_president"]: + if config.get(_action, True): + self.enable_task(_action) + + for _action in ["wam", "employees"]: + if getattr(self.config, _action) and not self.restricted_ip: + _hour = 14 + if not isinstance(config[_action], bool): + try: + _hour = abs(int(config[_action])) % 24 + except ValueError: + _hour = 14 + self.tasks.set_default(f"{_action}_hour", _hour) + self.enable_task(_action, _norm(now.replace(hour=_hour, minute=0, second=0, microsecond=0))) + + if config.get("buy_gold"): + self.enable_task("buy_gold", _norm(now.replace(hour=23, minute=57, second=0, microsecond=0)), True) + + if config.get("contribute_cc", 0): + self.tasks.set_default("contribute_cc", int(config.get("contribute_cc", 0))) + self.enable_task("contribute_cc", _norm(now.replace(hour=2, minute=0, second=0))) + + if config.get("renew_houses"): + self.tasks.set_default("renew_houses", config.get("renew_houses")) + self.enable_task("renew_houses") + + if config.get("spin_wheel_of_fortune", False): + spin_value = config.get("spin_wheel_of_fortune") + percent = int(10 if isinstance(spin_value, bool) else spin_value) + self.tasks.set_default("spin_wheel_of_fortune", percent) + self.enable_task("spin_wheel_of_fortune") + + +class PlayerBackgroundTasks(BasePlayer): + _preset_pay_table: Dict[int, int] + _default_pay_table: Dict[int, int] + _hits_to_pay: Dict[int, int] + + _bg_task_queue: List[threading.Thread] + + def __init__(self, *args, **kwargs): + self._preset_pay_table = {} + self._default_pay_table = {1: 400, 2: 500, 3: 600} + self._bg_task_queue = [] + self._hits_to_pay = defaultdict(int) + super().__init__(*args, **kwargs) + + def setup_tasks(self, config): + super().setup_tasks(config) + self._setup_player_bg_tasks(config) + + def signal_quit(self, sig_num, frame): + self.set_locks() + if self._hits_to_pay: + self.write_log("Paying for hits, before quitting") + for player_id, amount in self._hits_to_pay.items(): + if amount: + self.donate_money(player_id, amount, 1) + self._hits_to_pay[player_id] = 0 + for thread in self._bg_task_queue: + if thread.is_alive(): + self.write_log(f"Waiting on thread '{thread.name:^32}'") + thread.join() + return super().signal_quit(sig_num, frame) + + def signal_reload(self, sig_num, frame): + for thread in self._bg_task_queue: + self.write_log(f"Thread '{thread.name:^32}', alive {thread.is_alive()}") + if self._hits_to_pay: + self.write_log("Clearing hit table...") + for player_id, amount in self._hits_to_pay.items(): + if amount: + self.donate_money(player_id, amount, 1) + self._hits_to_pay[player_id] = 0 + else: + self._hits_to_pay.clear() + return super().signal_reload(sig_num, frame) + + def thread_name(self, name: str) -> str: + return f"{self.name}__{threading.active_count() - 1}__{name}" + + def background_task_start_battles(self, wars): + def update_war_info(): + rj = self._get_military_campaigns_json_list().json() + if rj.get("countries"): + if rj.get("battles"): + return {battle_data.get("id"): Battle(battle_data) for battle_data in rj.get("battles").values()} + return {} + + wars: Dict[str, Dict[str, Union[bool, List[int]]]] + finished_war_ids: Set[int] = {*[]} + war_data: Dict[str, Dict[str, Union[bool, List[int]]]] = wars + war_ids: Set[int] = {int(war_id) for war_id in war_data.keys()} + next_attack_time = self.now + next_attack_time = _norm(next_attack_time.replace(minute=next_attack_time.minute // 5 * 5, second=0)) + while not self.stop_threads.is_set(): + try: + attacked = False + all_battles = update_war_info() + running_wars = {b.war_id for b in all_battles.values()} + if all_battles: + for war_id in war_ids - finished_war_ids - running_wars: + war = war_data[str(war_id)] + war_regions = set(war.get("regions")) + auto_attack = war.get("auto_attack") + try: + start_h = war.get("attack_time")[0] + end_h = war.get("attack_time")[1] + except (TypeError, IndexError): + start_h = 21 + end_h = 3 + + if start_h <= end_h: + time_check = start_h <= self.now.hour < end_h + else: + time_check = start_h <= self.now.hour or self.now.hour < end_h + + status = self.get_war_status(war_id) + if status.get("ended", False): + finished_war_ids.add(war_id) + continue + if not status.get("can_attack"): + continue + if auto_attack or time_check: + for reg in war_regions: + if attacked: + break + if reg in status.get("regions", {}).keys(): + reg_name = status.get("regions", {}).get(reg) + self._post_wars_attack_region(war_id, reg, reg_name) + self._report_action("MILITARY_QUEUE_ATTACK", f"Battle for *{reg_name}* queued") + break + if attacked: + break + war_ids -= finished_war_ids + if attacked: + next_attack_time = _norm(next_attack_time + timedelta(hours=1)) + else: + next_attack_time = _norm(next_attack_time + timedelta(minutes=5)) + self.stop_threads.wait(utils.get_sleep_seconds(next_attack_time)) + except Exception as e: + self.report_error(f"Task error: start_battles {e.args}") + + def background_task_report_game_token_price(self): + while not self.stop_threads.is_set(): + try: + _next_time = _norm(self.now.replace(second=0, microsecond=0)) + + if _next_time.minute < 50: + _next_time = _norm(_next_time.replace(minute=(_next_time.minute // 10 + 1) * 10)) + else: + _next_time = _norm(_next_time.replace(minute=0) + timedelta(hours=1)) + + offers = self.get_game_token_offers() + httpx.post("https://erep.lv/market/gametoken/add/", json=dict(all_offers=True, **offers)) + self.stop_threads.wait(utils.get_sleep_seconds(_next_time)) + except Exception as e: + self.report_error(f"Task error: game_token {e}") + + def background_task_report_org_accounts(self, org_list: List[int] = None): + if org_list is None: + org_list = [] + watch_organisations = org_list + while not self.stop_threads.is_set(): + try: + next_report_time = _norm(self.now.replace(minute=0, second=0, microsecond=0)) + while not self.stop_threads.is_set(): + for organisation_id in watch_organisations: + to_report = {"organisation_id": organisation_id} + account = self.fetch_organisation_account(organisation_id) + if account.get("ok"): + to_report.update(**account) + httpx.post("https://erep.lv/org/account/add/", json=to_report) + next_report_time = _norm(next_report_time + timedelta(hours=1)) + to_sleep = (next_report_time - self.now).total_seconds() + self.stop_threads.wait(to_sleep if to_sleep > 0 else 0) + except Exception as e: + self.report_error(f"Task error: report_org_accounts {e}") + + def background_task_birthday_journey(self): + priority = [ + "anniversary_decoration", + "permanent_energy_house", + "energy_house", + "energy_booster", + "house_pool_bonus", + "ground_vehicle_blueprint", + "air_vehicle_blueprint", + "air_damage_booster_50", + "air_damage_booster_20", + "gold", + "energy_bars", + "winter_treat", + "trump_bomb", + "big_bomb", + "stinger_missile", + "damage_booster_100", + "overtime_points", + "speed_booster_2", + "air_deploy_rank_points_booster_20", + "air_deploy_rank_points_booster_10", + "air_deploy_influence_booster_20", + "air_deploy_influence_booster_10", + "deploy_size_booster_200", + "deploy_size_booster_100", + "ground_deploy_rank_points_booster_20", + "ground_deploy_rank_points_booster_10", + "ground_deploy_influence_booster_20", + "ground_deploy_influence_booster_10", + "vehicle_discharge_document", + "fuel", + ] + try: + node_map: Dict[int, Set[int]] = defaultdict(set) + while not self.stop_threads.is_set(): + quest_data = self.get_anniversary_quest_data() + # can_claim_extra = quest_data.get("rewards", {}).get("canCollectExtra") + if not node_map: + for pair in quest_data["neighbors"]: + node_map[pair["nodeId"]].add(pair["neighborId"]) + node_map[pair["neighborId"]].add(pair["nodeId"]) + visited_nodes: List[Tuple[int, datetime, str]] = [] + + if not quest_data.get("status", {}).get("progress"): + self.start_unlocking_map_quest_node(30219) + quest_data = self.get_anniversary_quest_data() + if quest_data["status"]["progress"]: + for node in quest_data["status"]["progress"].values(): + visited_nodes.append((node["nodeId"], parse(node["finishUnlockTime"]), node["nodeStatus"])) + visited_nodes.sort(key=lambda o: o[1]) + visited_nodes.reverse() + current_queue = [] + available_nodes = {*[]} + for node_id, finish_datetime, status in visited_nodes: + if status in ("claimed", "unclaimed"): + available_nodes.update(node_map[node_id]) + # elif status == "unclaimed": + # available_nodes.update(node_map[node_id]) + # collection_data = self.collect_map_quest_node(node_id).json() + # if collection_data.get("error"): + # self.logger.error(collection_data.get("error", {}).get("message")) + # claimable_node = quest_data.get("cities").get(str(node_id)) + # if quest_data.get("status").get("inventory").get("miles") < claimable_node.get("miles"): + # self.travel_to_region(713) + # self.travel_to_residence() + # else: + # if can_claim_extra: + # self.collect_map_quest_node(node_id, True) + elif status == "unlocking": + current_queue.append(finish_datetime) + if not current_queue: + available_nodes -= {nid for nid, _, __ in visited_nodes} + if available_nodes: + for kind in priority: + for node_id in available_nodes: + if kind in [r["type"] for r in quest_data["cities"][str(node_id)]["rewards"]]: + self.start_unlocking_map_quest_node(node_id) + quest_data = self.get_anniversary_quest_data() + current_queue.append( + parse(quest_data["status"]["progress"][str(node_id)]["finishUnlockTime"]) + ) + if current_queue: + break + else: + continue + break + current_queue.sort() + if current_queue: + next_finish = min(current_queue) + else: + self.report_error("Task Birthday Journey: Empty queue!", sentry=False) + return + to_sleep = utils.get_sleep_seconds(next_finish) + self.write_log( + f"Task: 'Birthday Journey' sleeping until (eRep): {next_finish} (sleeping for {to_sleep}s)" + ) + self.stop_threads.wait(to_sleep if to_sleep > 0 else 0) + except Exception as e: + self.report_error(f"Task error: Birthday Journey {e}") + + def _setup_player_bg_tasks(self, config: Dict[str, Any]): + if config.get("state_update_repeater", True): + t = threading.Thread(target=self.state_update_repeater, name=self.thread_name("state_update_repeater")) + t.start() + self._bg_task_queue.append(t) + for bg_task in ["start_battles", "game_tokens", "org_fetcher", "birthday_journey"]: # "clear_bhs", + if config.get(bg_task): + try: + _task_fn = getattr(self, f"background_task_{bg_task}") + except AttributeError: + self.report_error(f"Task '{bg_task}' has no task's function 'background_task_{bg_task}' defined!") + raise NotImplementedError(f"Task 'background_task_{bg_task}' not implemented!") + args = (config.get(bg_task),) if bg_task in ["start_battles", "clear_bhs", "org_fetcher"] else tuple() + t = threading.Thread(target=_task_fn, args=args, name=self.thread_name(bg_task)) + t.start() + self._bg_task_queue.append(t) + + @property + def as_dict(self): + ret = super().as_dict + ret.update(hits_payment_aggregation=self._hits_to_pay) + return ret + + def get_war_status(self, war_id: int) -> Dict[str, Union[bool, Dict[int, str]]]: + r = self._get_wars_show(war_id) + html = r.text + ret = {} + reg_re = re.compile(rf'data-war-id="{war_id}" data-region-id="(\d+)" data-region-name="([- \w]+)"') + if reg_re.findall(html): + ret.update(regions={}, can_attack=True) + for reg in reg_re.findall(html): + ret["regions"].update({int(reg[0]): reg[1]}) + elif re.search( + r'Join', + html, + ): + battle_id = re.search( + r'Join', + html, + ).group(1) + ret.update(can_attack=False, battle_id=int(battle_id)) + elif re.search(r"This war is no longer active.", html): + ret.update(can_attack=False, ended=True) + else: + ret.update(can_attack=False) + return ret + + +class Player(PlayerTasks, PlayerBackgroundTasks, BasePlayer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + signal.signal(signal.SIGINT, self.signal_quit) + signal.signal(signal.SIGTERM, self.signal_quit) + signal.signal(signal.SIGABRT, self.signal_quit) + if sys.platform.startswith("win"): + signal.signal(signal.SIGBREAK, self.signal_quit) + else: + signal.signal(signal.SIGUSR1, self.signal_reload) + signal.signal(signal.SIGUSR2, self.signal_config_reload) + signal.signal(signal.SIGHUP, self.signal_quit) + pid = os.getpid() + for action, _signal in [("UPDATE", "SIGUSR1"), ("RELOAD", "SIGUSR2"), ("QUIT", "SIGINT")]: + self.logger.debug( + f"To {action:^6}, run: 'kill -{getattr(signal, _signal):<2} {pid}' or 'kill -{_signal:<7} {pid}'" + ) + + def setup_tasks(self, config: Dict[str, Any]): + super().setup_tasks(config) + + def check_house_durability(self) -> Dict[int, datetime]: + ret = {} + for q, dt in super().check_house_durability().items(): + if q <= self.tasks.get_default("renew_houses", 0): + ret.update({q: dt}) + return ret + + def to_json(self, indent: bool = False) -> str: + return utils.json_dumps(self.as_dict, indent=False, sort_keys=True) + + def setup_citizen(self, config): + super().setup_citizen(config) + self.tasks.set_default("cheap_medals", config.get("cheap_medals", False)) diff --git a/ebot/utils.py b/ebot/utils.py new file mode 100644 index 0000000..dd4fd1c --- /dev/null +++ b/ebot/utils.py @@ -0,0 +1,240 @@ +""" eBot +Copyright (C) 2022 Eriks K + +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 version 3 of the License. + +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, see . +""" + +import json +import logging +import os +import platform +import re +import subprocess +from datetime import datetime +from typing import Any, Dict, List, Union + +from erepublik import constants, utils + +logger = logging.getLogger("Player") + + +def _norm(dt: datetime) -> datetime: + return constants.erep_tz.normalize(dt) + + +def parse_input(msg: str) -> bool: + msg += " (y|n):" + data = None + while data not in ["", "y", "Y", "n", "N"]: + try: + data = input(msg) + except EOFError: + data = "n" + + return data in ["", "y", "Y"] + + +def get_config( + config_location: str = "config.json", +) -> Dict[str, Union[str, int, bool, List[str], Dict[str, Union[int, str, bool]]]]: + CONFIG = {} + if os.path.isfile(config_location): + with open(config_location, "r") as f: + CONFIG.update(json.load(f)) + + logger.info("Config file found. Checking...") + should_save = parse_config(CONFIG) + else: + should_save = parse_config(CONFIG) + if should_save: + with open(config_location, "w") as f: + json.dump(CONFIG, f, indent=True) + return CONFIG + + +def parse_config(config: Dict[str, Any]) -> bool: + """Parse configuration dictionary and fill any missing keys. + + :type config: dict + :rtype: bool + :param config: None or dict with configs + :return: boolean if passed dict had to be changed + """ + changed = False + if not config.get("email"): + config["email"] = input("Player email: ") + changed = True + + if not config.get("password"): + config["password"] = input("Player password: ") + changed = True + + if "employ" in config: + config["employees"] = config.pop("employ") + changed = True + + _basic_prompt_dict = dict( + work="Should I work", + train="Should I train", + ot="Should I work overtime", + wam="Should I WAM", + employees="Should I employ employees", + ) + + for key, prompt in _basic_prompt_dict.items(): + if key not in config: + config[key] = parse_input(prompt) + changed = True + + if config["wam"] or config["employees"]: + if "auto_sell" not in config or not isinstance(config["auto_sell"], list): + config["auto_sell"] = [] + changed = True + if parse_input("Should I auto sell produced products"): + if parse_input("Should I auto sell final products"): + _final_prompt_dict = dict( + food="Should I auto sell Food products", + weapon="Should I auto sell Weapon products", + house="Should I auto sell House products", + aircraft="Should I auto sell Aircraft products", + ) + for key, prompt in _final_prompt_dict.items(): + if parse_input(prompt): + config["auto_sell"].append(key) + if parse_input("Should I auto sell raw products"): + _raw_prompt_dict = dict( + foodRaw="Should I auto sell Food raw", + weaponRaw="Should I auto sell Weapon raw", + houseRaw="Should I auto sell House raw", + airplaneRaw="Should I auto sell Aircraft raw", + ) + for key, prompt in _raw_prompt_dict.items(): + if parse_input(prompt): + config["auto_sell"].append(key) + if config["auto_sell"]: + if "auto_sell_all" not in config: + print("When selling produced items should I also sell items already in inventory?") + config["auto_sell_all"] = parse_input("Y - sell all, N - only just produced") + changed = True + else: + config["auto_sell_all"] = False + + if "auto_buy_raw" not in config: + config["auto_buy_raw"] = parse_input("Should I auto buy raw deficit at WAM or employ") + changed = True + else: + config["auto_sell"] = [] + config["auto_sell_all"] = False + config["auto_buy_raw"] = False + + if "fight" not in config: + config["fight"] = False # parse_input("Should I fight") + changed = True + + if config.get("fight"): + _fight_prompt_dict = dict( + air="Should I fight in AIR", + ground="Should I fight in GROUND", + all_in="When full energy should i go all in\n Y - all in, N - 1h worth of energy", + next_energy="Should I fight when next WC +1 energy is reachable", + boosters="Should I use +50% dmg boosters, when fighting on ground", + travel_to_fight="Should I travel to fight", + epic_hunt="Should I check for epic battles", + rw_def_side="Should I fight on defenders side in RWs", + continuous_fighting="If already fought in any battle, \nshould I continue to fight all FF in that battle", + maverick="If MaverickPack is active, \nshould I try to fight in non-native divisions?", + ) + + for key, prompt in _fight_prompt_dict.items(): + if key not in config: + config[key] = parse_input(prompt) + changed = True + + if not config["epic_hunt"]: + config["epic_hunt_ebs"] = False + elif "epic_hunt_ebs" not in config: + config["epic_hunt_ebs"] = parse_input("Should I eat EBs when fighting in epic battle") + changed = True + + else: + config["air"] = False + config["ground"] = False + config["all_in"] = False + config["next_energy"] = False + config["boosters"] = False + config["travel_to_fight"] = False + config["epic_hunt"] = False + config["epic_hunt_ebs"] = False + config["rw_def_side"] = False + config["continuous_fighting"] = False + + _other_prompt_dict = dict( + spin_wheel_of_fortune="Should I auto spin WheelOfFortune for 10% cc amount", + congress="Candidate for congress", + party_president="Candidate for party presidency", + debug="Should I generate debug files", + random_sleep="Should I add random amount (0-120sec) to sleep time", + buy_gold="Should I auto buy 10g every day", + interactive="Should I print output to console?", + telegram="Should I send notifications through Telegram", + ) + + for key, prompt in _other_prompt_dict.items(): + if key not in config: + config[key] = parse_input(prompt) + changed = True + + if "telegram_chat_id" not in config and config["telegram"]: + config["telegram_chat_id"] = "" + + if "telegram_token" not in config and config["telegram"]: + config["telegram_token"] = "" + + if "proxy" not in config: + config["_proxy"] = { + "kind": "socks or http", + "host": "localhost", + "port": 8080, + "username": "optional", + "password": "optional", + } + changed = True + + return changed + + +def jsonify(data) -> Any: + return utils.json_loads(dict_to_json(data)) + + +def dict_to_json(data) -> str: + return utils.json_dumps(data) + + +def pid_alive(pid: int) -> bool: + """Check For whether a pid is alive""" + + system = platform.uname().system + if re.search(r"Linux|Darwin", system, re.IGNORECASE): + try: + os.kill(pid, 0) + except OSError: + return False + else: + return True + elif re.search("Windows", system, re.IGNORECASE): + out = subprocess.check_output(["tasklist", "/fi", f"PID eq {pid}"]).strip() + return bool(re.search(b"No tasks", out, re.IGNORECASE)) + else: + return False + # raise RuntimeError(f"unsupported system={system}") diff --git a/enable_watchdog_for_pi.sh b/enable_watchdog_for_pi.sh new file mode 100644 index 0000000..3c63a4d --- /dev/null +++ b/enable_watchdog_for_pi.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Description: Enabling Watchdog on Raspberry Pi +# Author: Eriks K +# Created: 2020-12-04 +# Tutorial source: https://medium.com/@arslion/enabling-watchdog-on-raspberry-pi-b7e574dcba6b + +# Stage 0: Check if root to ensure that commands can be executed +if [[ ! $UID -eq 0 ]]; then + echo "You must be root to run this script! Please run with sudo: " + echo "sudo ${0}" + exit 1 +fi + +# single > writes to file, double >> appends to file + +# Stage 1: Activating watchdog hardware in pi +cp /boot/config.txt /boot/config.txt.bak # Backup + +echo "# Enabling watchdog." >> /boot/config.txt +echo "dtparam=watchdog=on" >> /boot/config.txt + +# Stage 2: Installing watchdog +apt-get install watchdog + +# Stage 3: Configuring watchdog to respond to events +cp /etc/watchdog.conf /etc/watchdog.conf.bak # Backup + +echo 'max-load-1 = 24' >> /etc/watchdog.conf +echo 'min-memory = 1' >> /etc/watchdog.conf +echo 'watchdog-timeout=15' >> /etc/watchdog.conf +echo 'watchdog-device = /dev/watchdog' >> /etc/watchdog.conf + +# Stage 4: Starting/Monitoring watchdog service: +systemctl start watchdog + +# Stage 5: Add watchdog on boot. +cp /lib/systemd/system/watchdog.service /lib/systemd/system/watchdog.service.bak # Backup +sed -i 's|^WantedBy=.*$|WantedBy=multi-user.target|' /lib/systemd/system/watchdog.service + +systemctl enable watchdog + diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..81c8ac7 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -e + +echo "from entrypoint: '$@'" +echo "getent: '$(getent passwd nocando)'" + +GID=$(stat -c %g /app/player) +UID=$(stat -c %u /app/player) + +if [ -z $(getent passwd nocando) ]; then + addgroup -g $GID -S nocando + adduser -u $UID -s /bin/sh -S nocando -G nocando +fi + +chown -R nocando:nocando /app/player + +cd /app/player + +su nocando -c exec "$@" diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..e329450 Binary files /dev/null and b/favicon.ico differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d0086a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[tool.black] +line-length = 120 +target-version = ['py38', 'py39'] +extend-exclude = 'venv' +workers = 4 + + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 120 + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "eRepublik_bot" +description = "Python package for automated eRepublik playing" +version = "2022.3.3" +authors = [ + { name = "Eriks K", email = "ebot@72.lv" } +] +license = {text = "GPL-3.0-only"} +requires-python = ">=3.8" +dependencies = [ + "python-dateutil==2.8.2", + "sentry-sdk==1.5.7", + "eRepublik==0.30.0.4", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", +] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ + 'setproctitle==1.2.2', + 'isort==5.10.1', + 'black==22.1.0', + 'flake8==4.0.1', + 'PyInstaller==4.10', + 'pur==6.0.1', + 'ipython==8.1.1', + 'responses==0.19.0', + 'cairosvg==2.5.2' +] + +[project.scripts] +ebot = "ebot.__main__" + +[tool.setuptools.dynamic] +version = {attr = "ebot.__version__"} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ab093e2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,12 @@ +-r requirements.txt +setproctitle==1.2.2 + +isort==5.10.1 +black==22.1.0 +flake8==4.0.1 +PyInstaller==4.10 +pur==6.0.1 +ipython==8.1.1 +responses==0.19.0 +cairosvg==2.5.2 +# git+git://github.com/eeriks/erepublik.git@state-updater#egg=eRepublik diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f8540ea --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-dateutil==2.8.2 +sentry-sdk==1.5.7 +eRepublik==0.30.0.4 +#git+git://github.com/eeriks/erepublik.git@httpx#egg=eRepublik diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..07de3ef --- /dev/null +++ b/run.sh @@ -0,0 +1,206 @@ +#!/bin/sh +set -eu +err_report() { + echo >&2 "Script '$0' failed with status code: $1!" + exit $1 +} +trap 'ERRC=$?; [ $ERRC -eq 0 ] && exit 0 || err_report $ERRC' EXIT + +_check_BOTS(){ + if test -z "$BOTS"; then + echo >&2 "Please set environment variable 'BOTS' before running this script!" + echo >&2 "Example: BOTS=\"Plato Lana zac_A\" $0" + exit 1 + fi +} + +_get_full_path(){ + printf -- "$(cd "$(dirname "$1")"; pwd)/$(basename "$1")" +} + + +SCRIPT_DIR=$(dirname $(_get_full_path $0)) +cd $SCRIPT_DIR +. venv/bin/activate + +ebot_version=$(./venv/bin/python -c "import ebot; print(ebot.__version__)") +erep_version=$(./venv/bin/python -c "import erepublik; print(erepublik.__version__)") + +_stop_bots(){ + _check_BOTS + for bot in $BOTS; do + if test -e "$bot/bot.pid"; then + if test -z "`ps -eF | grep -f "$bot/bot.pid"| grep bot_`"; then + rm "$bot/bot.pid" + else + pkill -F "$bot/bot.pid" + fi + fi + done +} + +restart(){ + _check_BOTS + _stop_bots + + for bot in $BOTS; do + cd $SCRIPT_DIR/$bot && ./run.sh + done +} + +compile_version(){ + . venv/bin/activate + echo $(which python) + pyt_v=$(python -c "import platform;print(platform.python_version())") + echo $pyt_v + version=$ebot_version + elibv=$erep_version + commit=$(git log -1 --pretty=format:%h) + sys_v=$(uname -s) + cpu_a=$(uname -m) + filename="bot_${version}__${elibv}__${pyt_v}__${sys_v}__${cpu_a}__${commit}.elf" + _compile "$filename" +} + +compile_generic(){ + filename="ebot.elf" + set +eu + rm "$filename.spec" + set -eu + _compile "$filename" +} + +compile_entry() { + if test "$1" = "version"; then + compile_version + elif test "$1" = "generic"; then + compile_generic + else + echo &>2 "Unknown compilation kind '$1'!\nAvailabale options: version, generic" + fi +} + +_compile(){ + set +eu + rm -fr __pycache__/ ebot/__pycache__/ + set -eu + filename="$1" + pyinstaller -F --distpath dist --workpath work --add-data 'LICENSE:.' -n "$filename" ebot/__main__.py +} + +update(){ + _check_BOTS + fetchupdate + compile_version + newver=$(ls -dtr1 $SCRIPT_DIR/dist/* | tail -1) + _stop_bots + + for bot in $BOTS; do + cp -lfp "$newver" $SCRIPT_DIR/$bot/bot_$bot + done + restart +} + +aviators(){ + if test -z "$1"; then + echo >&2 "No config file specified!\nMust run like: $0 aviator \"path/to/config/file\"" + exit 1 + elif test ! -f "$1"; then + echo >&2 "Config file does not exist!" + exit 1 + else + python aviator_support.py -c "$1" > log/aviator.log + fi +} + +version(){ + if test -n "`git status --porcelain --untracked-files=no`" ; then + echo "Please commit Your changes before updating version!" + echo $(git status --porcelain --untracked-files=no) + exit 1 + else + git pull + new_version=$(python -c "exec(\"\"\"import datetime, re;from ebot import __version__;n=datetime.date.today();i=0\ntry:\n v=re.search(r'(?P\\d+)\\.(?P\\d{1,2})\\.(?P\\d+)',__version__)\n i=int(v.group('iter'))\n if n.year==int(v.group('year'))and n.month==int(v.group('month')):i+=1\n else: i=1\nexcept: i=1\nfinally: print(f'{n.year}.{n.month}.{i}')\"\"\")") + echo $new_version + + sed -i.bak -E 's|^ARG version=.*$|ARG version='${new_version}'|g' Dockerfile + rm Dockerfile.bak + + sed -i.bak -E 's|__version__ = ".+"|__version__ = "'${new_version}'"|g' ebot/__init__.py + rm ebot/__init__.py.bak + + sed -i.bak -E "s|bot_v.* ebot|bot_v${new_version} ebot|g" compile.bat + rm compile.bat.bak + + sed -i.bak -E "s|version=\"[0-9]{4}\.[0-9]{1,2}.[0-9]{1,}\"|version=\"${new_version}\"|g" setup.py + rm setup.py.bak + git add ebot/__init__.py compile.bat setup.py Dockerfile + git commit -m "⬆ New version - ${new_version} ⬆" + git tag "v${new_version}" + fi +} + +fetchupdate(){ + git pull + pip install -r requirements-dev.txt +} + +initialize(){ + name=$(echo "$1" | iconv -t ascii//TRANSLIT | sed -r s/[^a-zA-Z0-9]+/-/g | sed -r s/^-+\|-+$//g | tr A-Z a-z) + mkdir -p "$name" + python -c "import json;from ebot.utils import get_config;conf=get_config('$name/config.json');f=open('$name/config.json', 'w');json.dump(conf, f);f.close();" + echo "#!/bin/sh\n./bot_$name &\ndisown -h %1\n" > "$name/run.sh" + chmod +x "$name/run.sh" + newver=$(ls -dtr1 $SCRIPT_DIR/dist/* | tail -1) + cp -lfp "$newver" "$SCRIPT_DIR/$name/bot_$name" +} + +dockerbuild(){ + docker build --build-arg version="$ebot_version" --tag ebot --tag registry.72.lv/ebot:$ebot_version --tag registry.72.lv/ebot:latest . +} + +dockerpush(){ + docker push -a registry.72.lv/ebot +} + +dockerrelease(){ + set -e + if test -n "`git status --porcelain --untracked-files=no`"; then + echo "Please commit before release!" + exit 1 + fi + dockerbuild + dockerpush +} + + +help(){ + echo "Main entry script for working with script setup and code" + echo "" + echo "Available commands:" + echo " start|restart Start or restart all eBot instances" + echo " stop Stop all eBot instances" + echo " update Fetch and compile newest code and update all eBots " + echo " version Increment eBot version" + echo " pull Fetch newest code and install dependencies" + echo " aviator 'config.json' Run aviator support script" + echo " initialize 'player' Initialize eBot account" + echo " dockerbuild Build and publish fresh docker image" + echo "" +} + +case "${1:-}" in + "start"|"restart") restart;; + "stop") _stop_bots;; + "update") update;; + "version") version;; + "pull") fetchupdate;; + "initialize") initialize "$2";; + "aviator") aviators "$2";; + "help") help;; + "dockerbuild") dockerbuild;; + "dockerrelease") dockerrelease ;; + "compile") compile_entry "${2:-version}";; + *) echo "Unknown command '${1:-}'!"; help; exit 1;; +esac + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6181388 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,20 @@ +[flake8] +max-line-length = 160 +exclude = .tox,.git,log,debug,venv +ignore = D100,D101,D102,D103,E251 + +[pycodestyle] +max-line-length = 160 +exclude = .tox,.git,log,debug,venv + +[mypy] +python_version = 3.9 +check_untyped_defs = True +ignore_missing_imports = False +warn_unused_ignores = True +warn_redundant_casts = True +warn_unused_configs = True + +[isort] +multi_line_output = 2 +line_length = 160 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3f3e523 --- /dev/null +++ b/setup.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""The setup script.""" + +from setuptools import find_packages, setup + +with open('requirements.txt') as f: + requirements = f.read().split() + +try: + with open('requirements-dev.txt') as f: + test_requirements = requirements + f.read().split()[2:] +except FileNotFoundError: + test_requirements = requirements + +setup( + author="Eriks K", + author_email="ebot@72.lv", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ], + description="Python package for automated eRepublik playing", + entry_points={}, + install_requires=requirements, + include_package_data=True, + name="eRepublik_bot", + packages=find_packages(include=["ebot"]), + python_requires=">=3.8, <4", + setup_requires=requirements, + version="2022.3.3", + zip_safe=False, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/economy_donate-items-action.html b/tests/fixtures/economy_donate-items-action.html new file mode 100644 index 0000000..a376a30 --- /dev/null +++ b/tests/fixtures/economy_donate-items-action.html @@ -0,0 +1,1448 @@ + + + + + + + + + + + + + + + +eRepublik + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+ + + +
+ + +
+
+ +
+ + +
+ +
+
+
+
+
+ +
+
+
+ +

Are your sure you want to do this?

+
+ +
+
+ +
+ +
+ Close +
+
+

Report content

+
+
+
+
+
+
+
+ + + + +
+
+ Close +
+
+
+
+

Error!

+

{{settings.msgs}}

+
+
+

Daily Order completed

+
+
+
+
+ + + +
+
+ +{{daily_order.bparts}} + Different Bazooka parts +
+
+ +{{daily_order.ebs}} + Energy Bar +
+ +
+
+
+
+
+
+
+ + Get Reward + + + Close + +
+
+
+
+
+ + + + +
+ + + Oskara Kalpaks + +

+ + online + [ + online ] + + + + 46 + Oskara Kalpaks

+ +
+
+ +
+ +
+ + +
+ + + +
+ + +
+ + + Location: + + + Latvia + Latvia, + Kurzeme + + + + Citizenship: + + Latvia + Latvia + +
+ Adult Citizen +

 

+ eRepublik birthday +

+ Jul 04, 2009

+ + National rank: + 101 + + + +
+ +
+ +
+ + +
+ +
+ + + + +
+

List of eRepublik shortcuts

+
    +
  • AlertsShift + A
  • +
  • Military campaignsShift + C
  • +
  • Military unitShift + M
  • +
  • My placesShift + L
  • +
  • New MessageShift + N
  • +
  • StorageShift + S
  • +
  • Top newsShift + T
  • +
  • World mapShift + W
  • +
  • ResidenceShift + G
  • +
+ (press ESC to close) +
+
+ + diff --git a/tests/fixtures/en.html b/tests/fixtures/en.html new file mode 100644 index 0000000..b951fd3 --- /dev/null +++ b/tests/fixtures/en.html @@ -0,0 +1,466 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Free Online Multiplayer Strategy Game | eRepublik + + + + + + + + + + + + + + + + + +
+
+ + + + + + +

A new world is emerging. Your country needs YOU!

+ +
+
+

Features

+
    +
  • + + Conquer your country's neighbours and extend its territories +
  • +
  • + + Build a company and develop your economic empire +
  • +
  • + + Fight against real people on the battlefield +
  • +
+
+
+

Top countries

+
    +
  • + Indonesia + Indonesia + 4881 +
  • +
  • + Serbia + Serbia + 2535 +
  • +
  • + Brazil + Brazil + 2453 +
  • +
  • + Argentina + Argentina + 1980 +
  • +
  • + Romania + Romania + 1883 +
  • +
+
+
+

What others are saying

+
    +
  • + + “eRepublik creates multiplayer global strategy game” + + +
  • +
  • + + “eRepublik offers a real second life” + + +
  • +
  • + + “eRepublik takes strategy games to the Web” + + +
  • +
+
+
+ + + + + + + + + + + + + +
+

Join the new world

+   + +
+ +
+
+ + + + + + + + + + +
+ + diff --git a/tests/fixtures/en_login.html b/tests/fixtures/en_login.html new file mode 100644 index 0000000..913f286 --- /dev/null +++ b/tests/fixtures/en_login.html @@ -0,0 +1,1744 @@ + + + + + + + + + + + + + + + +eRepublik + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+ + + +
+ + +
+
+ +
+ + +
+ +
+
+
+
+
+ +
+
+
+ +

Are your sure you want to do this?

+
+ +
+
+ +
+ +
+ Close +
+
+

Report content

+
+
+
+
+
+
+
+ + + + +
+
+ Close +
+
+
+
+

Error!

+

{{settings.msgs}}

+
+
+

Daily Order completed

+
+
+
+
+ + + +
+
+ +{{daily_order.bparts}} + Different Bazooka parts +
+
+ +{{daily_order.ebs}} + Energy Bar +
+ +
+
+
+
+
+
+
+ + Get Reward + + + Close + +
+
+
+
+
+ + + + + + + + + + +
+ + + +
+
+

{{data.title}}

+
+ +
+

+ + Weekly Challenge completed +

+
+ +
+

+ Next reward
+ {{data.nextReward.text}}

+
+
+ + + 00:00:00 + + +
+ {{data.player.name}} + + Get all rewards + + + + {{data.player.name}} + + {{data.player.prestigePoints | number : fractionSize}} Prestige Points + + + + +
+ +
+ +
+ + +
+ + + Get reward +
+ +
+ + +
+ + +
+ + + Get reward +
+ +
+ +
+
+ + +
+
+ +
+
+

{{popup.message}}

+ + Close + +
+
+ + + +
+ + + + + +
+ +
+ + + +
+ +

+ + in + + {{settings.citizen.selectedCountry.name}} + + +

+ +
+ + +
    +
  • + + + + +
    +

    Settings

    + +

    Read articles published in:

    + +
    +
    + + + +
    + +
      + +
    • + + + {{country.name}} + +
    • +
    +
    + +

    Show:

    + +
    +
    + + +
    + +
      +
    • + +
    • +
    +
    + + +
    +
  • +
+
+ +
+
+ +
+ + + +
+ +
+ more news + See all +
+
+ + +
+ Close + +
+

Manage objectives

+
+ +
+
+ +
+ +
+
+ +
+
+

+ + + +
+
+ +
+ +
+ +
+
+
+ + +
+
+ + + +
+
+

My contributions

+

Campaign of the day

+

Latvia's Campaigns

+

Allies' campaigns

+

All campaigns

+ + + + + +
Latvia is not involved in any active battles.
+ +
Your allies are currently not involved in any military campaigns.
+
    +
  • + + + + + + + + vs + + + + + + + {{::campaign.region.name}} + + + Fight + Fight + +
  • +
+ + +
+ + +
+ + + +
+ +
+ + + + + +
+ + + + + + + + +
+ +
+
+
    +
  • + {{feedData.unreadCount}} + + {{feedData.name}} +
  • +
+
+
+
+

+ + + {{ _getTranslation('feed','battlefield')}} + + + + {{settings.wallData.dailyOrder.progress.current}}/{{settings.wallData.dailyOrder.progress.required}} + + +

+
+ {{_getTranslation('wallTexts','see_more')}} +
+
+
+ + +
+
+
+ {{settings.postCharactersLimit - settings.newPost.length}} {{_getTranslation('wallTexts','characters_left')}} + +
+
+ {{_getTranslation('feed','postAs')}} + +
+
+
+
+ +
+ +
+
+
+
+ + + + + + + + +
+ +
+ + + + +
+

List of eRepublik shortcuts

+
    +
  • AlertsShift + A
  • +
  • Military campaignsShift + C
  • +
  • Military unitShift + M
  • +
  • My placesShift + L
  • +
  • New MessageShift + N
  • +
  • StorageShift + S
  • +
  • Top newsShift + T
  • +
  • World mapShift + W
  • +
  • ResidenceShift + G
  • +
+ (press ESC to close) +
+
+ + diff --git a/tests/test_aviator_support.py b/tests/test_aviator_support.py new file mode 100644 index 0000000..456f139 --- /dev/null +++ b/tests/test_aviator_support.py @@ -0,0 +1,106 @@ +import json +import os +import unittest + +import requests +import responses + +from ebot.aviator_support import AviatorCitizen, aviator_support + + +class AviatorCitizenTest(AviatorCitizen): + def post(self, url: str, data: dict = None, json: dict = None, **kwargs): + print("POST", url, data, json, kwargs) + return super().post(url, data, json, **kwargs) + + def get(self, url: str, **kwargs): + print("GET", url, kwargs) + return super().get(url, **kwargs) + + +class TestAviatorSupport(unittest.TestCase): + """Tests for `erepublik` eLatvian aviator support.""" + + def setUp(self): + os.environ["PYTHON_TESTS"] = "1" + """Set up test fixtures, if any.""" + + @responses.activate + def test_simple(self): + responses.add( + responses.GET, + "https://erep.lv/aviator/latest_article/", + json=dict(status=True, free_food={"q1": 0}, article_id=2724438, week=166), + ) + responses.add(responses.POST, "https://api.erep.lv/promos/add/", json=dict(status=True)) + responses.add(responses.POST, "https://api.erep.lv/bot/update", json=dict(status=True)) + responses.add(responses.POST, "https://api.erep.lv/bot/register", json=dict(status=True)) + responses.add(responses.POST, "https://erep.lv/aviator/check/1596281", json=dict(status=True)) + responses.add(responses.POST, "https://erep.lv/aviator/check/1607812", json=dict(status=True)) + responses.add(responses.POST, "https://erep.lv/aviator/check/4360535", json=dict(status=False)) + responses.add(responses.POST, "https://erep.lv/aviator/check/5365408", json=dict(status=True)) + + # with open('fixtures/en.html') as f: + with open("fixtures/en_login.html") as f: + responses.add(responses.GET, "https://www.erepublik.com/en", body=f.read()) + responses.add( + responses.POST, + url="https://www.erepublik.com/en/login", + body=f.read(), + status=302, + headers={"Location": "https://www.erepublik.com/en"}, + ) + responses.add(responses.GET, url="https://www.erepublik.com/en/login", body=f.read()) + + citizen = AviatorCitizenTest("email", "password") + citizen.config.telegram = False + + with open("fixtures/en_login.html") as f: + responses.add(responses.GET, "https://www.erepublik.com/en", body=f.read()) + + with open("fixtures/en_economy_inventory-items_.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/economy/inventory-items/", json=json.load(f)) + + with open("fixtures/en_economy_mymarketoffers.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/economy/myMarketOffers/", json=json.load(f)) + + with open("fixtures/en_main_articlecomments.json") as f: + responses.add(responses.POST, "https://www.erepublik.com/en/main/articleComments", json=json.load(f)) + + with open("fixtures/en_main_leaderboards-kills-aircraft-rankings_71_1_0_0.json") as f: + responses.add(responses.GET, json=json.load(f), url="https://www.erepublik.com/en/main/leaderboards-kills-aircraft-rankings/71/1/0/0") + + with open("fixtures/citizen_profile_1596281.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/main/citizen-profile-json/1596281", json=json.load(f)) + + with open("fixtures/citizen_profile_1607812.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/main/citizen-profile-json/1607812", json=json.load(f)) + + with open("fixtures/citizen_profile_4360535.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/main/citizen-profile-json/4360535", json=json.load(f)) + + with open("fixtures/citizen_profile_5365408.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/main/citizen-profile-json/5365408", json=json.load(f)) + + with open("fixtures/city_699_residents.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/main/city-data/699/residents", json=json.load(f)) + + with open("fixtures/city_706_residents.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/main/city-data/706/residents", json=json.load(f)) + + with open("fixtures/city_709_residents.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/main/city-data/709/residents", json=json.load(f)) + + with open("fixtures/military_unit_data.json") as f: + responses.add(responses.GET, "https://www.erepublik.com/en/military/military-unit-data/", json=json.load(f)) + + with open("fixtures/economy_marketplaceajax.json") as f: + responses.add(responses.POST, "https://www.erepublik.com/en/economy/marketplaceAjax", json=json.load(f)) + + with open("fixtures/economy_marketplaceactions.json") as f: + responses.add(responses.POST, "https://www.erepublik.com/en/economy/marketplaceActions", json=json.load(f)) + + with open("fixtures/economy_donate-items-action.html") as f: + responses.add(responses.POST, "https://www.erepublik.com/en/economy/donate-items-action", body=f.read()) + + aviator_support(citizen) diff --git a/tests/test_player_tasks.py b/tests/test_player_tasks.py new file mode 100644 index 0000000..7293507 --- /dev/null +++ b/tests/test_player_tasks.py @@ -0,0 +1,42 @@ +import unittest +from datetime import datetime as dt, timedelta as td +from erepublik.constants import max_datetime + +from ebot.helpers import Tasks, Task, PlayerTasks + + +class PlayerTests(PlayerTasks): + @property + def next_wc_start(self): + return max_datetime + + +class TestTasks(unittest.TestCase): + """Tests for `erepublik` eLatvian aviator support.""" + + def setUp(self): + self.player = PlayerTests("email@example.com", "Pa$$word1", False) + self.player.set_debug(True) + self.player.energy.set_reference_time(self.player.now) + + def test_get_next_fight_energy_and_time(self): + self.player.energy.limit = 1060 + self.player.energy.interval = 26 + self.player.details.xp = 1_294_914 + self.assertTrue(self.player.is_levelup_reachable) + self.assertEqual(self.player._get_required_fight_energy(), 2 * (1060 - 26)) + + self.player.details.xp = 1_294_814 + self.assertTrue(self.player.is_levelup_close) + self.assertEqual(self.player._get_required_fight_energy(), 850) + + self.player.details.xp = 1_294_014 + self.assertFalse(self.player.is_levelup_reachable) + self.assertFalse(self.player.is_levelup_close) + self.assertEqual(self.player._get_required_fight_energy(), 75) + + self.player.details.pp = 76 + self.assertEqual(self.player._get_required_fight_energy(), 1060 * 2 - 26 * 2) + + self.player.energy.pp = 76 + self.assertEqual(self.player._get_required_fight_energy(), 1060 * 2 - 26 * 2) diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..2e52cc3 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,87 @@ +import unittest +from datetime import datetime as dt, timedelta as td + +from ebot.helpers import Tasks, Task + + +class TestTasks(unittest.TestCase): + """Tests for `erepublik` eLatvian aviator support.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + def test_get_item(self): + tasks = Tasks() + time_part = dt.now().replace(second=0, microsecond=0) + tasks["first_task"] = time_part + time_part3 = time_part - td(minutes=3) + tasks["third_task"] = time_part3 + + self.assertEqual(tasks["first_task"].name, "first_task") + self.assertEqual(tasks["first_task"].time, time_part) + self.assertEqual(tasks[1].name, "third_task") + self.assertEqual(tasks[1].time, time_part3) + self.assertEqual(tasks["non existant"], None) + + def test_set_item(self): + tasks = Tasks() + time_part = dt.now().replace(second=0, microsecond=0) + tasks["first_task"] = time_part + tasks["first_task"] = time_part + time_part3 = time_part - td(minutes=3) + self.assertEqual(tasks["first_task"].time, time_part) + tasks[0] = time_part3 + self.assertEqual(tasks[0].time, time_part3) + self.assertRaises(TypeError, tasks.__setitem__, *(1.4, 3.14)) + + def test_append_and_sort(self): + tasks = Tasks() + time_part1 = dt.now().replace(second=0, microsecond=0) + time_part2 = time_part1 + td(minutes=2) + time_part3 = time_part1 - td(minutes=3) + + t1 = Task("time_part1", time_part1) + t2 = Task("time_part2", time_part2) + t3 = Task("time_part3", time_part3) + self.assertDictEqual(tasks.as_dict, {"tasks": [], "defaults": {}}) + + tasks.append(t1) + tasks.append(t2) + tasks.append(t3) + self.assertDictEqual(tasks.as_dict, {"tasks": [t1, t2, t3], "defaults": {}}) + + result_task_list = [t3, t1, t2] + tasks.sort() + for idx, task in enumerate(tasks): + self.assertEqual(task, result_task_list[idx]) + self.assertDictEqual(tasks.as_dict, {"tasks": result_task_list, "defaults": {}}) + + self.assertRaises(TypeError, tasks.append, 1) + self.assertEqual(repr(tasks), "") + self.assertEqual(repr(t1), f"{time_part1.strftime('%F %T')} Time part1") + + def test_pop(self): + tasks = Tasks() + time_part1 = dt.now().replace(second=0, microsecond=0) + t1 = Task("time_part1", time_part1) + self.assertDictEqual(tasks.as_dict, {"tasks": [], "defaults": {}}) + tasks.append(t1) + self.assertDictEqual(tasks.as_dict, {"tasks": [t1], "defaults": {}}) + poped_t1 = tasks.pop("time_part1") + self.assertEqual(poped_t1, t1) + self.assertDictEqual(tasks.as_dict, {"tasks": [], "defaults": {}}) + poped_t = tasks.pop("time_part1") + self.assertEqual(poped_t, None) + self.assertDictEqual(tasks.as_dict, {"tasks": [], "defaults": {}}) + + def test_defaults(self): + tasks = Tasks() + tasks.set_default("some_key", "some_value") + tasks.set_default("some_other_key", 1) + self.assertDictEqual(tasks._defaults, {"some_key": "some_value", "some_other_key": 1}) + tasks.set_default("some_other_key", 2) + self.assertDictEqual(tasks._defaults, {"some_key": "some_value", "some_other_key": 2}) + self.assertEqual(tasks.get_default("some_key"), "some_value") + self.assertEqual(tasks.get_default("some_other_key"), 2) + self.assertEqual(tasks.get_default("non_existant"), None) + self.assertEqual(tasks.get_default("non_existant", -1), -1)